From 83698db7852806a29cc792caafa176205d2f0866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:31:14 +0000 Subject: [PATCH] feat(cli): extend init to six MCP clients with dry-run Configure Claude Code, Cursor, Codex, VS Code, Windsurf, and OpenCode in one pass with idempotent merges, user-scoped Windsurf paths, and explicit create/ update output. Add temp-HOME tests and document init in the README. --- README.md | 17 ++- docs/cli.md | 5 +- docs/client-setup.md | 7 +- src/cli-init.ts | 220 +++++++++++++++++++++------- src/cli.ts | 2 +- tests/cli-init-dry-run-home.test.ts | 91 ++++++++++++ tests/cli-init.test.ts | 36 ++++- 7 files changed, 315 insertions(+), 63 deletions(-) create mode 100644 tests/cli-init-dry-run-home.test.ts diff --git a/README.md b/README.md index d93d577..0e62882 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,14 @@ opencode mcp add codebase-context '--' npx -y codebase-context In the unreleased candidate, `--version` prints the installed package version and `map` builds a missing index before printing the map. A low-confidence search includes a hint that weak matches do not establish that code is absent. These additions require the built candidate; the unpinned commands above install published 2.2.0. -This repository's `init` command detects Claude Code, Codex, Cursor, and OpenCode and writes a project config for each selected tool. The saved launch is the Node binary and the package file from the npx cache. That command is in this source and is not part of published 2.2.0. Until it is published, use the copy-paste lines above. The [client setup guide](./docs/client-setup.md) marks the unpublished command. +This repository's `init` command detects Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, and OpenCode, then writes or merges each tool's MCP config in one pass. It follows the official VS Code `servers` and Windsurf user-config shapes (same pattern as the [@modelcontextprotocol/server-filesystem](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem) workspace examples). Use `--dry-run` to preview changes before writing. + +```bash +npx codebase-context init --yes # configure detected tools +npx codebase-context init --dry-run --yes # show Would create/update lines only +``` + +The saved launch is the Node binary and the package file from the npx cache. That command is in this source and is not part of published 2.2.0. Until it is published, use the copy-paste lines above. The [client setup guide](./docs/client-setup.md) marks the unpublished command. Start a new agent session in your project, then ask: @@ -31,7 +38,7 @@ Replace `[feature]` with something you want to find. The first run downloads a l ### Planned for 2.4.0 (not yet published) -The unreleased source candidate starts new indexes without a model download. Semantic embeddings are optional; existing indexes keep their recorded mode. Its setup wizard can configure Claude Code, Codex, Cursor and OpenCode using the Node binary and package file that ran setup. These changes are not in the current npm release. +The unreleased source candidate starts new indexes without a model download. Semantic embeddings are optional; existing indexes keep their recorded mode. Its setup wizard configures Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, and OpenCode using the Node binary and package file that ran setup, with `--dry-run` to preview MCP config changes. These changes are not in the current npm release. - NestJS support from community contributor Aaron Olin. See the [analyzer details](./docs/capabilities.md#analyzers). - Better React/Next.js detection and a fix for reranker passage scoring. @@ -57,10 +64,10 @@ Other clients use their own setup commands: | Client | Shortest current setup | | --------------------------- | ---------------------------------------------------------------------------- | | Gemini CLI | `gemini mcp add --scope user codebase-context npx -y codebase-context` | -| Cursor | Add `.cursor/mcp.json` | -| VS Code with GitHub Copilot | Add `.vscode/mcp.json` | +| Cursor | `codebase-context init --client cursor` or add `.cursor/mcp.json` | +| VS Code with GitHub Copilot | `codebase-context init --client vscode` or add `.vscode/mcp.json` | | GitHub Copilot CLI | `copilot mcp add codebase-context -- npx -y codebase-context` | -| Windsurf | Add `~/.codeium/windsurf/mcp_config.json` | +| Windsurf | `codebase-context init --client windsurf` or add `~/.codeium/windsurf/mcp_config.json` | Check an existing same-name entry before replacing it. To give the server a default folder, append that folder's absolute path to the `npx` arguments. The [client setup guide](./docs/client-setup.md) covers scopes, optional fixed-folder configuration and the interactive setup wizard's limitations. diff --git a/docs/cli.md b/docs/cli.md index 9ae65f6..16dae76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,16 +46,17 @@ CODEBASE_CONTEXT_ASCII=1 npx -y codebase-context patterns - `reindex` — rebuild index (full or incremental) - `style-guide` — find style guide sections in docs - `memory list|add|remove` — manage team memory (stored in `.codebase-context/memory.json`) -- MCP client registration — use the recipes in the [client setup guide](./client-setup.md). Published 2.2.0 has the older interactive HTTP wizard; the four-client wizard below requires the unreleased candidate. +- MCP client registration — use the recipes in the [client setup guide](./client-setup.md). Published 2.2.0 has the older interactive HTTP wizard; the six-client wizard below requires the unreleased candidate. ## `init` in the unreleased candidate -`init` detects Claude Code, Codex, Cursor and OpenCode, then configures the selected tools together. In a terminal, detected tools start selected; use Space to change the selection and Enter to apply it. Without a terminal, it configures all detected tools. +`init` detects Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, and OpenCode, then configures the selected tools together. In a terminal, detected tools start selected; use Space to change the selection and Enter to apply it. Without a terminal, it configures all detected tools. Add `--dry-run` to print `Would create`, `Would update`, or `Unchanged` without writing files. ```powershell codebase-context init --help codebase-context init codebase-context init --yes +codebase-context init --dry-run --yes codebase-context init --client codex --client cursor --root "C:\path\to\project" ``` diff --git a/docs/client-setup.md b/docs/client-setup.md index f43ab54..d992460 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -106,9 +106,12 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f The four-client wizard is not in published 2.2.0. From a built checkout or a locally installed candidate, run its actual entrypoint from the project you want to search: ```bash -# Detect Claude Code, Codex, Cursor and OpenCode, then configure the detected clients. +# Detect Claude Code, Codex, Cursor, VS Code, Windsurf, and OpenCode, then configure the detected clients. node /path/to/codebase-context/dist/index.js init --yes +# Preview MCP config changes without writing files. +node /path/to/codebase-context/dist/index.js init --dry-run --yes + # Configure one client for an explicit project. node /path/to/codebase-context/dist/index.js init --client codex --root /path/to/project --yes @@ -118,7 +121,7 @@ node /path/to/codebase-context/dist/index.js init --root /path/to/project --prev Replace the paths with the candidate installation and project folders. With an interactive terminal, omit `--yes` to select clients together. `--client` is repeatable. `--root` takes priority over `CODEBASE_ROOT` and the current folder. -Claude Code uses `.mcp.json`, Codex uses `.codex/config.toml`, Cursor uses `.cursor/mcp.json`, and OpenCode uses `opencode.json`. Generated configs save the running Node binary, the actual package entrypoint and the project folder. If setup ran through `npx`, that entrypoint is in its package cache. Rerun setup if that installation or cache is removed or moved. +Claude Code uses `.mcp.json`, Codex uses `.codex/config.toml`, Cursor uses `.cursor/mcp.json`, VS Code uses `.vscode/mcp.json` (`servers`), Windsurf uses `~/.codeium/windsurf/mcp_config.json`, and OpenCode uses `opencode.json`. Generated configs save the running Node binary, the actual package entrypoint and the project folder. If setup ran through `npx`, that entrypoint is in its package cache. Rerun setup if that installation or cache is removed or moved. Existing unrelated settings are preserved. A conflicting custom Codebase Context launch stops setup before selected config files are written. To replace a custom launch, remove only its Codebase Context entry from the client config, keep the other settings, and rerun setup. Restart the client and approve the server or project trust if asked. Config generation and SDK launch tests do not establish acceptance in each native client. diff --git a/src/cli-init.ts b/src/cli-init.ts index 4641e84..1888986 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -4,7 +4,11 @@ import { loadProjectConfig } from './server/config.js'; /** * Interactive setup wizard for codebase-context. * Handles `codebase-context init` — detects clients and merges project MCP configs - * for the four first-wave AI clients: Claude Code, Cursor, Codex, OpenCode. + * for Claude Code, Cursor, Codex, VS Code (Copilot), Windsurf, and OpenCode. + * Config merge shapes follow each client's official docs (VS Code uses a top-level + * `servers` object per the MCP filesystem server workspace examples; Windsurf uses + * `~/.codeium/windsurf/mcp_config.json`). Use `--dry-run` to preview writes first, + * matching the preview-before-apply pattern from MCP filesystem `edit_file`. * * Config writes run only after all selected registrations pass preflight. * File previews share the indexer's selection rules and project configuration. @@ -19,12 +23,12 @@ import { checkbox } from '@inquirer/prompts'; import { homedir } from 'node:os'; import { constants } from 'node:fs'; -export type Client = (typeof CLIENTS)[number]['id']; - export type McpConfigResult = { kind: 'file'; path: string; content: string }; export type McpConfigOptions = { rootPath?: string; + /** Override home for user-scoped configs (tests). Defaults to os.homedir(). */ + homeDirectory?: string; /** Node binary written into the client config. Defaults to the current process. */ nodeExecutable?: string; /** @@ -37,19 +41,79 @@ export type McpConfigOptions = { type JsonObject = Record; export const CLIENTS = [ - { id: 'claude-code', name: 'Claude Code', executable: 'claude', directory: '.claude' }, - { id: 'codex', name: 'Codex', executable: 'codex', directory: '.codex' }, - { id: 'cursor', name: 'Cursor', executable: 'cursor', directory: '.cursor' }, - { id: 'opencode', name: 'OpenCode', executable: 'opencode', directory: '.opencode' } + { + id: 'claude-code', + name: 'Claude Code', + executable: 'claude', + directory: '.claude', + scope: 'project' + }, + { id: 'codex', name: 'Codex', executable: 'codex', directory: '.codex', scope: 'project' }, + { id: 'cursor', name: 'Cursor', executable: 'cursor', directory: '.cursor', scope: 'project' }, + { + id: 'vscode', + name: 'VS Code (Copilot)', + executable: 'code', + directory: '.vscode', + scope: 'project' + }, + { + id: 'windsurf', + name: 'Windsurf', + executable: 'windsurf', + directory: '.codeium/windsurf', + scope: 'user' + }, + { + id: 'opencode', + name: 'OpenCode', + executable: 'opencode', + directory: '.opencode', + scope: 'project' + } ] as const; +export type Client = (typeof CLIENTS)[number]['id']; + +function resolveHome( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const paths = platform === 'win32' ? path.win32 : path.posix; + return env.HOME || env.USERPROFILE || homedir(); +} + +function jsonMcpServerKey( + client: Extract +): string { + if (client === 'opencode') return 'mcp'; + if (client === 'vscode') return 'servers'; + return 'mcpServers'; +} + +export function resolveClientConfigPath( + client: Client, + rootPath: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const paths = platform === 'win32' ? path.win32 : path.posix; + const home = resolveHome(env, platform); + if (client === 'codex') return path.join(rootPath, '.codex', 'config.toml'); + if (client === 'windsurf') return path.join(home, '.codeium', 'windsurf', 'mcp_config.json'); + if (client === 'claude-code') return path.join(rootPath, '.mcp.json'); + if (client === 'cursor') return path.join(rootPath, '.cursor', 'mcp.json'); + if (client === 'vscode') return path.join(rootPath, '.vscode', 'mcp.json'); + return path.join(rootPath, 'opencode.json'); +} + export async function detectClients( rootPath: string, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): Promise { const paths = platform === 'win32' ? path.win32 : path.posix; - const home = env.HOME || env.USERPROFILE || homedir(); + const home = resolveHome(env, platform); const configHome = env.XDG_CONFIG_HOME || paths.join(home, '.config'); const searchPath = (env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':'); const extensions = platform === 'win32' ? ['', '.exe', '.cmd', '.bat', '.ps1'] : ['']; @@ -62,8 +126,15 @@ export async function detectClients( ? env.CLAUDE_CONFIG_DIR || paths.join(home, '.claude') : client.id === 'opencode' ? paths.join(configHome, 'opencode') - : paths.join(home, client.directory); - const directories = [paths.join(rootPath, client.directory), userDirectory]; + : client.id === 'windsurf' + ? paths.join(home, '.codeium', 'windsurf') + : paths.join(home, client.directory.split('/').at(-1) ?? client.directory); + const projectDirectory = + client.id === 'windsurf' + ? paths.join(home, '.codeium', 'windsurf') + : paths.join(rootPath, client.directory); + const directories = + client.scope === 'user' ? [userDirectory] : [projectDirectory, userDirectory]; const executables = searchPath .filter(Boolean) .flatMap((directory) => @@ -116,47 +187,45 @@ function resolveServerEntrypoint(explicit?: string): string { */ export function generateMcpConfig(client: Client, options: McpConfigOptions = {}): McpConfigResult { const rootPath = path.resolve(options.rootPath ?? process.cwd()); + const homeDirectory = options.homeDirectory ?? resolveHome(); const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath); const serverEntrypoint = resolveServerEntrypoint(options.serverEntrypoint); const args = [serverEntrypoint, rootPath]; if (client === 'codex') { return { kind: 'file', - path: path.join(rootPath, '.codex', 'config.toml'), + path: resolveClientConfigPath('codex', rootPath, { HOME: homeDirectory }), content: `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(nodeExecutable)}\n` + `args = [${args.map((value) => JSON.stringify(value)).join(', ')}]\nstartup_timeout_sec = 120\n` }; } - const configPath = - client === 'claude-code' - ? '.mcp.json' + const configPath = resolveClientConfigPath(client, rootPath, { HOME: homeDirectory }); + const serverPayload = + client === 'opencode' + ? { type: 'local', command: [nodeExecutable, ...args] } : client === 'cursor' - ? '.cursor/mcp.json' - : 'opencode.json'; + ? { type: 'stdio', command: nodeExecutable, args } + : { command: nodeExecutable, args }; + const body = + client === 'opencode' + ? { + $schema: 'https://opencode.ai/config.json', + [jsonMcpServerKey('opencode')]: { 'codebase-context': serverPayload } + } + : client === 'vscode' + ? { [jsonMcpServerKey('vscode')]: { 'codebase-context': serverPayload } } + : client === 'windsurf' + ? { [jsonMcpServerKey('windsurf')]: { 'codebase-context': serverPayload } } + : { + mcpServers: { + 'codebase-context': serverPayload + } + }; return { kind: 'file', - path: path.join(rootPath, configPath), - content: JSON.stringify( - client === 'opencode' - ? { - $schema: 'https://opencode.ai/config.json', - mcp: { - 'codebase-context': { type: 'local', command: [nodeExecutable, ...args] } - } - } - : { - mcpServers: { - 'codebase-context': { - ...(client === 'cursor' ? { type: 'stdio' } : {}), - command: nodeExecutable, - args - } - } - }, - null, - 2 - ) + path: configPath, + content: JSON.stringify(body, null, 2) }; } @@ -281,13 +350,12 @@ function mergeGeneratedServer(existing: unknown, generated: unknown, client: Cli if (jsonValuesEqual(existing, generated)) return existing; if (!isJsonObject(existing) || !isJsonObject(generated) || 'url' in existing) return null; const local = client === 'opencode'; + const stdio = client === 'cursor'; const command = local && Array.isArray(existing.command) ? existing.command[0] : existing.command; const args = local && Array.isArray(existing.command) ? existing.command.slice(1) : existing.args; - if ( - (existing.type !== undefined && existing.type !== (local ? 'local' : 'stdio')) || - !isGeneratedLaunch(command, args, generatedEntrypoint(generated, client)) - ) - return null; + if (local && existing.type !== undefined && existing.type !== 'local') return null; + if (stdio && existing.type !== undefined && existing.type !== 'stdio') return null; + if (!isGeneratedLaunch(command, args, generatedEntrypoint(generated, client))) return null; return { ...existing, command: generated.command, @@ -303,7 +371,7 @@ function mergeGeneratedServer(existing: unknown, generated: unknown, client: Cli export async function _buildMergedMcpContent( filePath: string, generatedContent: string, - client: Extract + client: Extract ): Promise<{ content: string; mergedFromExisting: boolean }> { const existingText = await readExistingConfig(filePath); if (existingText === null) { @@ -324,7 +392,7 @@ export async function _buildMergedMcpContent( throw new Error(`Cannot merge ${client} config at ${filePath}: expected a JSON object.`); } - const serverKey = client === 'opencode' ? 'mcp' : 'mcpServers'; + const serverKey = jsonMcpServerKey(client); const existingServersValue = existing[serverKey]; if (existingServersValue !== undefined && !isJsonObject(existingServersValue)) { throw new Error( @@ -461,8 +529,40 @@ type InitOptions = { yes: boolean; help: boolean; preview: boolean; + dryRun: boolean; }; +function formatDisplayPath(configPath: string, rootPath: string): string { + const relative = path.relative(rootPath, configPath); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; + return configPath; +} + +function classifyInitChange( + existingText: string | null, + mergedContent: string +): 'create' | 'update' | 'unchanged' { + if (existingText === null) return 'create'; + if (existingText === mergedContent) return 'unchanged'; + return 'update'; +} + +function describeInitChange( + dryRun: boolean, + action: 'create' | 'update' | 'unchanged', + displayPath: string +): string { + if (action === 'unchanged') return `Unchanged: ${displayPath}`; + const prefix = dryRun + ? action === 'create' + ? 'Would create' + : 'Would update' + : action === 'create' + ? 'Created' + : 'Updated'; + return `${prefix}: ${displayPath}`; +} + function parseClient(value: string): Client { const client = CLIENTS.find((client) => client.id === value); if (client) return client.id; @@ -473,13 +573,22 @@ function parseClient(value: string): Client { function parseInitOptions(args: string[]): InitOptions { if (args.includes('--help') || args.includes('-h')) - return { clients: [], rootPath: process.cwd(), yes: false, help: true, preview: false }; + return { + clients: [], + rootPath: process.cwd(), + yes: false, + help: true, + preview: false, + dryRun: false + }; const clients: Client[] = []; let yes = false; let preview = false; + let dryRun = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--preview') preview = true; + else if (arg === '--dry-run') dryRun = true; else if (arg === '--yes' || arg === '-y') yes = true; else if (arg === '--client') { const value = args[++index]; @@ -495,7 +604,8 @@ function parseInitOptions(args: string[]): InitOptions { rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT), yes, help: false, - preview + preview, + dryRun }; } @@ -514,6 +624,7 @@ function printInitUsage(): void { ); console.log(' --root Project root (default: CODEBASE_ROOT or cwd)'); console.log(' --preview Show selected files and exclusions without writing config'); + console.log(' --dry-run Show MCP config changes without writing files'); console.log(' --yes, -y Configure every detected tool without prompts'); console.log(' --help, -h Show this help'); } @@ -554,30 +665,35 @@ export async function handleInitCli(args: string[]): Promise { } if (!clients.length) throw new Error( - 'No coding tools selected or detected. Choose with --client claude-code, --client codex, --client cursor, or --client opencode.' + `No coding tools selected or detected. Choose with --client (${CLIENTS.map((client) => client.id).join(', ')}).` ); const prepared = []; for (const client of clients) { const config = generateMcpConfig(client, { rootPath: options.rootPath }); + const existingText = await readExistingConfig(config.path); const merged = client === 'codex' ? await _buildMergedCodexContent(config.path, config.content) : await _buildMergedMcpContent(config.path, config.content, client); + const action = classifyInitChange(existingText, merged.content); prepared.push({ ...config, content: merged.content, - unchanged: (await readExistingConfig(config.path)) === merged.content + action, + displayPath: formatDisplayPath(config.path, options.rootPath) }); } for (const config of prepared) { - if (!config.unchanged) { + if (config.action !== 'unchanged' && !options.dryRun) { await fs.mkdir(path.dirname(config.path), { recursive: true }); await atomicWrite(config.path, config.content); } - console.log( - `${config.unchanged ? 'Unchanged' : 'Configured'}: ${path.relative(options.rootPath, config.path)}` - ); + console.log(describeInitChange(options.dryRun, config.action, config.displayPath)); + } + if (options.dryRun) { + console.log('Dry run complete; no files written.'); + return; } console.log('Reopen your tools; approve the server or project trust if asked.'); console.log("The first search builds this repository's index."); diff --git a/src/cli.ts b/src/cli.ts index 345eaec..b074f43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,7 +83,7 @@ function printUsage(): void { console.log(' patterns [--category all|di|state|testing|libraries] Team patterns'); console.log(' refs --symbol [--limit ] Symbol references'); console.log(' cycles [--scope ] Circular dependency detection'); - console.log(' init [--client ] [--yes] Detect and configure coding tools'); + console.log(' init [--client ] [--yes] [--dry-run] Configure detected coding tools'); console.log(' map [--json] [--pretty] Conventions map (indexes first if needed)'); console.log(''); console.log('Global flags:'); diff --git a/tests/cli-init-dry-run-home.test.ts b/tests/cli-init-dry-run-home.test.ts new file mode 100644 index 0000000..ff95b43 --- /dev/null +++ b/tests/cli-init-dry-run-home.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { handleInitCli } from '../src/cli-init.js'; + +describe('init dry-run and user-scoped configs', () => { + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + let tempHome = ''; + let projectRoot = ''; + let logs: string[] = []; + + afterEach(async () => { + process.env.HOME = previousHome; + process.env.USERPROFILE = previousUserProfile; + if (tempHome) await fs.rm(tempHome, { recursive: true, force: true }); + tempHome = ''; + projectRoot = ''; + logs = []; + }); + + async function runInit(args: string[]) { + logs = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(' ')); + }); + try { + await handleInitCli(args); + } finally { + logSpy.mockRestore(); + } + return logs.join('\n'); + } + + it('dry-run writes Windsurf user config under a temp HOME without touching disk', async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-home-')); + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + await fs.mkdir(path.join(tempHome, '.codeium', 'windsurf'), { recursive: true }); + + const output = await runInit([ + '--client', + 'windsurf', + '--yes', + '--dry-run', + '--root', + projectRoot + ]); + + expect(output).toContain(`Would create: ${path.join(tempHome, '.codeium', 'windsurf', 'mcp_config.json')}`); + expect(output).toContain('Dry run complete; no files written.'); + await expect( + fs.access(path.join(tempHome, '.codeium', 'windsurf', 'mcp_config.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('merges VS Code servers and reports an update on repeat dry-run', async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-home-')); + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + const vscodeDir = path.join(projectRoot, '.vscode'); + await fs.mkdir(vscodeDir, { recursive: true }); + await fs.writeFile( + path.join(vscodeDir, 'mcp.json'), + JSON.stringify( + { + servers: { + other: { command: 'echo', args: ['hi'] } + } + }, + null, + 2 + ) + ); + + const first = await runInit(['--client', 'vscode', '--yes', '--root', projectRoot]); + expect(first).toContain('Updated: .vscode/mcp.json'); + + const second = await runInit(['--client', 'vscode', '--yes', '--dry-run', '--root', projectRoot]); + expect(second).toContain('Unchanged: .vscode/mcp.json'); + + const saved = JSON.parse(await fs.readFile(path.join(vscodeDir, 'mcp.json'), 'utf8')) as { + servers: Record; + }; + expect(saved.servers.other).toEqual({ command: 'echo', args: ['hi'] }); + expect(saved.servers['codebase-context']).toBeDefined(); + }); +}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index bfff804..b397c7f 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -120,6 +120,33 @@ describe('generateMcpConfig', () => { command: [nodeExecutable, cacheEntrypoint, projectRoot] }); }); + + it('VS Code writes workspace servers config with the saved launch', () => { + const result = generateMcpConfig('vscode', savedLaunch(projectRoot)); + expect(result.path).toBe(path.join(projectRoot, '.vscode', 'mcp.json')); + expect(JSON.parse(result.content)).toEqual({ + servers: { + 'codebase-context': { + command: nodeExecutable, + args: [cacheEntrypoint, projectRoot] + } + } + }); + }); + + it('Windsurf writes user mcp_config.json with the saved launch', () => { + const home = path.resolve('C:/Users/Test User'); + const result = generateMcpConfig('windsurf', { ...savedLaunch(projectRoot), homeDirectory: home }); + expect(result.path).toBe(path.join(home, '.codeium', 'windsurf', 'mcp_config.json')); + expect(JSON.parse(result.content)).toEqual({ + mcpServers: { + 'codebase-context': { + command: nodeExecutable, + args: [cacheEntrypoint, projectRoot] + } + } + }); + }); }); describe('resolveInitRoot', () => { @@ -382,7 +409,14 @@ describe('detectClients', () => { const clients = await detectClients('/project', { PATH: 'C:/tools' }, 'win32'); - expect(CLIENTS.map(({ id }) => id)).toEqual(['claude-code', 'codex', 'cursor', 'opencode']); + expect(CLIENTS.map(({ id }) => id)).toEqual([ + 'claude-code', + 'codex', + 'cursor', + 'vscode', + 'windsurf', + 'opencode' + ]); expect(clients).toContain('codex'); expect(clients).not.toContain('claude-code'); expect(