From 105d0458b7874c32cb74eb2d5b74d0d165ae4278 Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Mon, 22 Jun 2026 22:33:24 +0530 Subject: [PATCH] feat(installer): add Windsurf as an agent install target --- CHANGELOG.md | 1 + __tests__/installer-targets.test.ts | 54 ++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- src/installer/targets/windsurf.ts | 162 ++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/windsurf.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d20a793..07489cf00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **Windsurf** is now a supported install target. `codegraph install` (and `--target=windsurf`) wires CodeGraph into Windsurf's MCP config at `~/.codeium/windsurf/mcp_config.json`, so Cascade can answer code questions through `codegraph_explore` instead of grepping. Since Windsurf only loads MCP servers from the single user-level config (there's no project-local file), this is a global install; after it runs, open Settings → MCP (or the Cascade MCP panel) and click **Refresh** to pick up the new server. `codegraph uninstall` removes the entry and leaves any other MCP servers in the file untouched. - **Claude Code:** an optional front-load hook makes your agent reach for CodeGraph automatically. When you ask a structural question — "how does X work", "what calls Y", "trace the flow from A to B" — CodeGraph injects the relevant source and call paths into the prompt up front, so the agent answers from the graph instead of grepping around to rebuild it. You're asked during `codegraph install` (default yes; Claude Code only, since it's the agent with prompt hooks), it's removed by `codegraph uninstall`, and `codegraph upgrade` turns it on for existing Claude setups. It's strictly additive and degradable — non-structural prompts and un-indexed projects are left alone — and you can switch it off any time without uninstalling by setting `CODEGRAPH_NO_PROMPT_HOOK=1`. - Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property. - `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked). diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index ffa708197..e2b5ee5f3 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -680,6 +680,60 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.existsSync(geminiMd)).toBe(false); }); + it('windsurf: install writes ~/.codeium/windsurf/mcp_config.json (mcpServers.codegraph), no type field', () => { + const windsurf = getTarget('windsurf')!; + const result = windsurf.install('global', { autoAllow: true }); + const mcp = path.join(tmpHome, '.codeium', 'windsurf', 'mcp_config.json'); + expect(result.files.some((f) => f.path === mcp)).toBe(true); + expect(fs.existsSync(mcp)).toBe(true); + + const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + // Windsurf's documented stdio shape is `{ command, args }` — no `type`. + expect(cfg.mcpServers.codegraph).toEqual({ command: 'codegraph', args: ['serve', '--mcp'] }); + expect(cfg.mcpServers.codegraph.type).toBeUndefined(); + // Surfaces the load-bearing "click Refresh" note. + expect(result.notes?.join(' ')).toMatch(/Refresh/i); + }); + + it('windsurf: install preserves a pre-existing sibling MCP server', () => { + const windsurf = getTarget('windsurf')!; + const mcp = path.join(tmpHome, '.codeium', 'windsurf', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'npx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + windsurf.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeDefined(); + }); + + it('windsurf: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const windsurf = getTarget('windsurf')!; + const mcp = path.join(tmpHome, '.codeium', 'windsurf', 'mcp_config.json'); + fs.mkdirSync(path.dirname(mcp), { recursive: true }); + fs.writeFileSync(mcp, JSON.stringify({ + mcpServers: { other: { command: 'npx', args: ['other-server'] } }, + }, null, 2) + '\n'); + + windsurf.install('global', { autoAllow: true }); + windsurf.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(mcp, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + }); + + it('windsurf: rejects --location=local with a clear note (global-only editor)', () => { + const windsurf = getTarget('windsurf')!; + expect(windsurf.supportsLocation('local')).toBe(false); + const result = windsurf.install('local', { autoAllow: true }); + expect(result.files).toEqual([]); + expect(result.notes?.join(' ')).toMatch(/no project-local config/); + }); + it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => { const gemini = getTarget('gemini')!; const antigravity = getTarget('antigravity')!; diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..69fd98d6e 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { windsurfTarget } from './windsurf'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + windsurfTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..6a2325bdd 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'windsurf'; /** * Result of `target.detect(location)`. diff --git a/src/installer/targets/windsurf.ts b/src/installer/targets/windsurf.ts new file mode 100644 index 000000000..5dfa4efe1 --- /dev/null +++ b/src/installer/targets/windsurf.ts @@ -0,0 +1,162 @@ +/** + * Windsurf (Codeium / Cognition) target. + * + * - MCP server entry to `~/.codeium/windsurf/mcp_config.json`. Standard + * `mcpServers.codegraph` shape, same container as Claude / Cursor / Kiro. + * + * Windsurf as of 2026-06 has NO project-local MCP config — it reads only the + * single user-level `mcp_config.json` under `~/.codeium/windsurf/`, on macOS, + * Linux, AND Windows alike (the path is `os.homedir()`-based on all three, so + * there's no `%APPDATA%` special case — same as Kiro). `supportsLocation('local')` + * returns false; the orchestrator skips Windsurf for a local install. + * + * The server entry is `{ command, args }` — NO `type` field. Windsurf's + * documented stdio examples omit it (unlike Claude/Cursor which take + * `type: "stdio"`), so we mirror the docs exactly to avoid a config the editor + * might reject. Same shape Antigravity uses. + * + * No permissions concept — Windsurf gates MCP tools through its own UI, not an + * external allowlist. `autoAllow` is silently ignored. + * + * No instructions file — per issue #529 the agent-facing usage guidance ships + * in the MCP server's `initialize` response (the single source of truth), so + * this target writes only the MCP entry. + * + * Docs: https://docs.windsurf.com/windsurf/cascade/mcp + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; + +function configDir(): string { + return path.join(os.homedir(), '.codeium', 'windsurf'); +} +function mcpConfigPath(): string { + return path.join(configDir(), 'mcp_config.json'); +} + +/** + * The codegraph MCP-server entry for Windsurf. Deliberately omits the `type` + * field — Windsurf's documented stdio examples are `{ command, args }` only. + */ +function buildWindsurfEntry(): { command: string; args: string[] } { + return { + command: 'codegraph', + args: ['serve', '--mcp'], + }; +} + +class WindsurfTarget implements AgentTarget { + readonly id = 'windsurf' as const; + readonly displayName = 'Windsurf'; + readonly docsUrl = 'https://docs.windsurf.com/windsurf/cascade/mcp'; + + supportsLocation(loc: Location): boolean { + return loc === 'global'; + } + + detect(loc: Location): DetectionResult { + if (loc !== 'global') { + return { installed: false, alreadyConfigured: false }; + } + const file = mcpConfigPath(); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + // "Installed" heuristic: the ~/.codeium dir (Windsurf creates it on first + // run) or the Windsurf config dir or the mcp_config.json itself exists. + const installed = + fs.existsSync(path.join(os.homedir(), '.codeium')) || + fs.existsSync(configDir()) || + fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc !== 'global') { + return { + files: [], + notes: ['Windsurf has no project-local config — re-run with --location=global.'], + }; + } + return { + files: [writeMcpEntry()], + // Load-bearing: a valid mcp_config.json is NOT picked up live. Windsurf + // only (re)loads MCP servers when you open the MCP panel and hit Refresh + // (or reload the window) — so without this note a user sees the entry on + // disk but no codegraph tools and assumes the install failed. + notes: ['In Windsurf, open Settings → MCP (or the Cascade MCP panel) and click "Refresh" to load codegraph.'], + }; + } + + uninstall(loc: Location): WriteResult { + if (loc !== 'global') return { files: [] }; + return { files: [removeCodegraphFromFile(mcpConfigPath())] }; + } + + printConfig(loc: Location): string { + if (loc !== 'global') { + return '# Windsurf has no project-local config — use --location=global.\n'; + } + const snippet = JSON.stringify({ mcpServers: { codegraph: buildWindsurfEntry() } }, null, 2); + return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + if (loc !== 'global') return []; + return [mcpConfigPath()]; + } +} + +function writeMcpEntry(): WriteResult['files'][number] { + const file = mcpConfigPath(); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = buildWindsurfEntry(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +/** + * Strip the codegraph entry from mcp_config.json. Surgical: only our + * `codegraph` key is removed; sibling MCP servers survive. Drops an emptied + * `mcpServers` wrapper too. Returns `not-found` when there was nothing to + * remove (file absent or no codegraph entry) so uninstall is safe to call + * when nothing was ever installed. + */ +function removeCodegraphFromFile(file: string): WriteResult['files'][number] { + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + const config = readJsonFile(file); + if (!config.mcpServers?.codegraph) return { path: file, action: 'not-found' }; + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + return { path: file, action: 'removed' }; +} + +export const windsurfTarget: AgentTarget = new WindsurfTarget();