Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
54 changes: 54 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')!;
Expand Down
2 changes: 2 additions & 0 deletions src/installer/targets/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
windsurfTarget,
]);

export function getTarget(id: string): AgentTarget | undefined {
Expand Down
2 changes: 1 addition & 1 deletion src/installer/targets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
162 changes: 162 additions & 0 deletions src/installer/targets/windsurf.ts
Original file line number Diff line number Diff line change
@@ -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();