From 3e735058122d9eed08ad9781860120480d0bf0c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 23:54:02 +0000 Subject: [PATCH] feat: add one-command setup for installed AI clients `init` finds Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, and Codex, then writes a stdio config for each one. Existing settings are merged, a different codebase-context entry is left unchanged, and a backup is saved before a file changes. `--http` keeps the older wizard. --- README.md | 54 +--- src/cli-init.ts | 278 ++++++++++++++--- src/cli.ts | 2 +- src/init-clients.ts | 448 +++++++++++++++++++++++++++ tests/cli-init.test.ts | 250 ++++++++++++++- tests/init-clients.test.ts | 175 +++++++++++ tests/release-truth-surfaces.test.ts | 2 +- 7 files changed, 1123 insertions(+), 86 deletions(-) create mode 100644 src/init-clients.ts create mode 100644 tests/init-clients.test.ts diff --git a/README.md b/README.md index adab80b..2381620 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,31 @@ Codebase Context gives coding agents a map of your repository through MCP, for d **These commands use 2.4.0.** If that version is not available on npm yet, use the published 2.2.0 [setup guide](https://github.com/PatrickSys/codebase-context/blob/10ba0a635ff73cd5c5b011ffd5b31e7ed4656c65/docs/client-setup.md). -Use Node.js 22 or newer. Choose your coding tool and run its command once; the examples target the 2.4.0 release: +Use Node.js 22 or newer. From your project folder, run: ```bash -# Claude Code -claude mcp add --scope user --transport stdio codebase-context -- npx -y codebase-context@2.4.0 +npx -y codebase-context@2.4.0 init +``` -# Codex CLI -codex mcp add codebase-context -- npx -y codebase-context@2.4.0 +The command looks for Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, and Codex (the terminal app and the desktop app). For each one it finds, it adds Codebase Context and points it at the current folder. It asks before it changes anything. To skip the question: -# OpenCode 1.x (keep the quoted separator on Windows) -opencode mcp add codebase-context '--' npx -y codebase-context@2.4.0 +```bash +npx -y codebase-context@2.4.0 init --yes ``` -Start a new agent session in your project, then ask: +Your other settings stay in place. If a file already has a different Codebase Context setup, that file is left unchanged. A backup is saved next to any file that does change. Restart the app after setup. + +Claude Code, Cursor, VS Code, and Codex are configured in this project. Claude Desktop and Windsurf use their own settings file, still pointed at this folder. + +Start a new session in the project, then ask the agent to find a feature and open the relevant source files. The first run downloads a local model and indexes the repository; time depends on repository size, CPU and model. -> Use Codebase Context to find [feature] in this repository. Pass this repository's absolute path as project, wait for indexing if needed, and open the relevant source files. +The older question-and-answer setup, including a local web connection for Cursor and OpenCode, is still available: -Replace `[feature]` with something you want to find. The first run downloads a local model and indexes the repository; time depends on repository size, CPU and model. +```bash +npx -y codebase-context@2.4.0 init --http +``` + +Manual steps for other tools are in the [client setup guide](./docs/client-setup.md). ### New in 2.4.0 @@ -37,33 +44,6 @@ Known limits: [Intel Mac indexing crash (#68)](https://github.com/PatrickSys/cod The existing [benchmark](https://github.com/PatrickSys/codebase-context/blob/master/docs/benchmark.md) reports 25.7% expected-file recovery versus jCodeMunch's 27.1% in a fixed retrieval comparison; it does not establish a winner or coding quality. -For Codex Desktop, create or merge `.codex/config.toml` in the project you want to search: - -```toml -[mcp_servers.codebase-context] -command = "npx" -args = ["-y", "codebase-context@2.4.0"] -startup_timeout_sec = 120 -``` - -Trust the project if asked, restart Codex, and start a new task there. Use the prompt above. Config placement alone does not establish successful first use; this no-folder recipe has not been accepted in a fresh native Desktop task. - -Other clients use their own setup commands: - -| Client | Shortest current setup | -| --------------------------- | ---------------------------------------------------------------------------- | -| Gemini CLI | `gemini mcp add --scope user codebase-context npx -y codebase-context@2.4.0` | -| Cursor | Add `.cursor/mcp.json` | -| VS Code with GitHub Copilot | Add `.vscode/mcp.json` | -| GitHub Copilot CLI | `copilot mcp add codebase-context -- npx -y codebase-context@2.4.0` | -| Windsurf | 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. - -The [client setup guide](./docs/client-setup.md) has the exact commands and config for every client, plus what was checked locally and what still relies on official instructions. - -The default connection is `stdio` (standard input/output): your client starts the server when it needs it. HTTP is an advanced, client-dependent option; verify the setup guide and client support before relying on it. - ## What your agent gets ### Relevant code diff --git a/src/cli-init.ts b/src/cli-init.ts index bf126cc..f1bdd7e 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -1,16 +1,32 @@ /** - * Interactive setup wizard for codebase-context. - * Handles `codebase-context init` — generates MCP config and instruction block - * for the four first-wave AI clients: Claude Code, Cursor, Codex, OpenCode. + * `codebase-context init` writes stdio MCP config for installed AI clients. + * `--http` keeps the older wizard, including HTTP config for Cursor and OpenCode. * - * Depends only on Node built-ins and @inquirer/prompts. - * No imports from the rest of the codebase — keeps this module side-effect-free - * outside the wizard call and easy to unit test. + * Depends on Node built-ins, @inquirer/prompts, and ./init-clients.ts. */ import path from 'path'; import * as fs from 'node:fs/promises'; import { select, confirm } from '@inquirer/prompts'; +import { + STDIO_CLIENT_LABELS, + buildWritePlans, + clientForTarget, + configBackupPath, + defaultInstallProbe, + detectInstalledClients, + parseStdioTarget, + renderCodexServerToml, + type InstallProbe, + type StdioLaunch, + type StdioTarget, + type StdioWritePlan +} from './init-clients.js'; + +export type InitCliDependencies = { + probe?: InstallProbe; + now?: () => Date; +}; export type Client = 'claude-code' | 'cursor' | 'codex' | 'opencode'; @@ -24,10 +40,6 @@ export type McpConfigOptions = { type JsonObject = Record; -function quoteTomlString(value: string): string { - return JSON.stringify(value); -} - export function generateMcpConfig(client: Client, options: McpConfigOptions = {}): McpConfigResult { const rootPath = path.resolve(options.rootPath ?? process.cwd()); const nodeExecutable = options.nodeExecutable ?? process.execPath; @@ -75,11 +87,7 @@ export function generateMcpConfig(client: Client, options: McpConfigOptions = {} return { kind: 'file', path: path.join(rootPath, '.codex', 'config.toml'), - content: - `[mcp_servers.codebase-context]\n` + - `command = ${quoteTomlString(nodeExecutable)}\n` + - `args = [${stdioArgs.map(quoteTomlString).join(', ')}]\n` + - 'startup_timeout_sec = 120\n' + content: renderCodexServerToml(nodeExecutable, stdioArgs) }; case 'opencode': return { @@ -224,7 +232,8 @@ async function readExistingConfig(filePath: string): Promise { export async function _buildMergedMcpContent( filePath: string, generatedContent: string, - client: Extract + client: string, + serverKey: 'mcpServers' | 'mcp' | 'servers' = client === 'opencode' ? 'mcp' : 'mcpServers' ): Promise<{ content: string; mergedFromExisting: boolean }> { const existingText = await readExistingConfig(filePath); if (existingText === null) { @@ -245,7 +254,6 @@ export async function _buildMergedMcpContent( throw new Error(`Cannot merge ${client} config at ${filePath}: expected a JSON object.`); } - const serverKey = client === 'opencode' ? 'mcp' : 'mcpServers'; const existingServersValue = existing[serverKey]; if (existingServersValue !== undefined && !isJsonObject(existingServersValue)) { throw new Error( @@ -383,13 +391,22 @@ export async function _buildMergedCodexContent( }; } -type InitOptions = { +type HttpInitOptions = { + mode: 'http'; client?: Client; rootPath: string; yes: boolean; - help: boolean; }; +type AutoInitOptions = { + mode: 'auto'; + client?: StdioTarget; + rootPath: string; + yes: boolean; +}; + +type InitOptions = { mode: 'help' } | HttpInitOptions | AutoInitOptions; + function parseClient(value: string): Client { if (value === 'claude-code' || value === 'cursor' || value === 'codex' || value === 'opencode') { return value; @@ -399,24 +416,27 @@ function parseClient(value: string): Client { function parseInitOptions(args: string[]): InitOptions { if (args.includes('--help') || args.includes('-h')) { - return { rootPath: process.cwd(), yes: false, help: true }; + return { mode: 'help' }; } - let client: Client | undefined; + let rawClient: string | undefined; let yes = false; + let http = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--yes' || arg === '-y') { yes = true; + } else if (arg === '--http') { + http = true; } else if (arg === '--client') { const value = args[index + 1]; if (!value || value.startsWith('--')) { throw new Error('Expected a client after --client.'); } - client = parseClient(value); + rawClient = value; index++; } else if (arg?.startsWith('--client=')) { - client = parseClient(arg.slice('--client='.length)); + rawClient = arg.slice('--client='.length); } else if (arg === '--root') { index++; } else if (arg?.startsWith('--root=')) { @@ -426,24 +446,38 @@ function parseInitOptions(args: string[]): InitOptions { } } + const rootPath = resolveInitRoot(args, process.env.CODEBASE_ROOT, process.cwd()); + if (http) { + return { + mode: 'http', + ...(rawClient ? { client: parseClient(rawClient) } : {}), + rootPath, + yes + }; + } return { - ...(client ? { client } : {}), - rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT, process.cwd()), - yes, - help: false + mode: 'auto', + ...(rawClient ? { client: parseStdioTarget(rawClient) } : {}), + rootPath, + yes }; } function printInitUsage(): void { console.log('codebase-context init [options]'); console.log(''); - console.log('Set up MCP config for an AI client.'); + console.log('Add codebase-context to the AI clients installed on this machine.'); + console.log('The current folder is the project. Pass --root to choose another folder.'); console.log(''); console.log('Options:'); - console.log(' --client claude-code, cursor, codex, or opencode'); - console.log(' --root Project root (default: CODEBASE_ROOT or cwd)'); - console.log(' --yes, -y Write the MCP config without prompts'); + console.log(' --yes, -y Write the config without asking'); + console.log(' --root Project folder (default: current folder, or CODEBASE_ROOT)'); + console.log(' --client Limit setup to one client'); + console.log(' --http Older setup wizard (HTTP for Cursor and OpenCode)'); console.log(' --help, -h Show this help'); + console.log(''); + console.log('Clients: claude-code, claude-desktop, cursor, vscode, windsurf, codex'); + console.log('With --http: claude-code, cursor, codex, opencode'); } function getCurrentCliEntrypoint(): string { @@ -454,23 +488,160 @@ function getCurrentCliEntrypoint(): string { return path.resolve(entrypoint); } -export async function handleInitCli(args: string[]): Promise { - let options: InitOptions; +function configContentsMatch(existing: string, next: string, format: 'json' | 'toml'): boolean { + if (format === 'toml') { + return normalizeTomlNewlines(existing).trim() === normalizeTomlNewlines(next).trim(); + } try { - options = parseInitOptions(args); + return jsonValuesEqual(JSON.parse(existing) as unknown, JSON.parse(next) as unknown); + } catch { + return false; + } +} + +type StdioApplyResult = { + configPath: string; + status: 'written' | 'unchanged' | 'conflict'; + backupPath?: string; + message?: string; +}; + +async function applyStdioPlan(plan: StdioWritePlan, now: Date): Promise { + const existing = await readExistingConfig(plan.configPath); + let merged: { content: string; mergedFromExisting: boolean }; + try { + merged = + plan.format === 'toml' + ? await _buildMergedCodexContent(plan.configPath, plan.content) + : await _buildMergedMcpContent( + plan.configPath, + plan.content, + plan.clients[0]?.id ?? 'client', + plan.serverKey ?? 'mcpServers' + ); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - printInitUsage(); - process.exitCode = 1; + return { + configPath: plan.configPath, + status: 'conflict', + message: error instanceof Error ? error.message : String(error) + }; + } + + if (existing !== null && configContentsMatch(existing, merged.content, plan.format)) { + return { configPath: plan.configPath, status: 'unchanged' }; + } + + const dir = path.dirname(plan.configPath); + await fs.mkdir(dir, { recursive: true }); + let backupPath: string | undefined; + if (existing !== null) { + backupPath = configBackupPath(plan.configPath, now); + await fs.writeFile(backupPath, existing, 'utf8'); + } + await fs.writeFile(plan.configPath, merged.content, 'utf8'); + return { + configPath: plan.configPath, + status: 'written', + ...(backupPath ? { backupPath } : {}) + }; +} + +async function ensureProjectRoot(rootPath: string): Promise { + try { + await fs.access(rootPath); + } catch { + throw new Error(`Project root does not exist or is not accessible: ${rootPath}`); + } +} + +function stdioLaunchFor(rootPath: string): StdioLaunch { + return { + nodeExecutable: process.execPath, + serverEntrypoint: getCurrentCliEntrypoint(), + projectRoot: rootPath + }; +} + +async function runStdioSetup(options: AutoInitOptions, deps: InitCliDependencies): Promise { + const probe: InstallProbe = { + ...(deps.probe ?? defaultInstallProbe(options.rootPath)), + projectRoot: options.rootPath + }; + const clients = options.client + ? [clientForTarget(options.client, probe)] + : await detectInstalledClients(probe); + const plans = buildWritePlans(clients, stdioLaunchFor(options.rootPath)); + + if (plans.length === 0) { + console.log('No installed AI clients found.'); + console.log(`Checked for ${STDIO_CLIENT_LABELS.join(', ')}.`); + console.log('Install one of them, then run this command again from your project folder.'); return; } - if (options.help) { - printInitUsage(); + console.log('\nSet up codebase-context for this project\n'); + console.log(`Project: ${options.rootPath}\n`); + console.log('Clients found:'); + for (const plan of plans) { + const names = plan.clients.map((client) => client.label).join(', '); + console.log(` ${names}`); + console.log(` ${plan.configPath}`); + } + console.log( + '\nOther settings in those files are kept. A different codebase-context entry is left unchanged. A backup is saved before each file that changes.\n' + ); + + const apply = options.yes + ? true + : await confirm({ + message: 'Apply these configs?', + default: false + }); + if (!apply) { + console.log('No files changed.'); return; } + + try { + await ensureProjectRoot(options.rootPath); + } catch (error) { + console.error(`Init failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const now = deps.now ?? (() => new Date()); + let failed = false; + for (const plan of plans) { + try { + const result = await applyStdioPlan(plan, now()); + if (result.status === 'conflict') { + failed = true; + console.error(`Skipped: ${result.configPath}`); + console.error(result.message ?? 'Existing codebase-context entry was left unchanged.'); + continue; + } + if (result.status === 'unchanged') { + console.log(`Unchanged: ${result.configPath}`); + continue; + } + if (result.backupPath) console.log(`Backup: ${result.backupPath}`); + console.log(`Written: ${result.configPath}`); + } catch (error) { + failed = true; + console.error( + `Init failed for ${plan.configPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + if (failed) process.exitCode = 1; + else console.log('\nRestart the client and approve the server if it asks.'); +} + +async function runHttpWizard(options: HttpInitOptions): Promise { if (options.yes && !options.client) { - console.error('Error: --yes requires --client.'); + console.error('Error: --http --yes requires --client.'); printInitUsage(); process.exitCode = 1; return; @@ -524,11 +695,7 @@ export async function handleInitCli(args: string[]): Promise { try { if (applyMcp) { - try { - await fs.access(options.rootPath); - } catch { - throw new Error(`Project root does not exist or is not accessible: ${options.rootPath}`); - } + await ensureProjectRoot(options.rootPath); const dir = path.dirname(mcpResult.path); await fs.mkdir(dir, { recursive: true }); const mergedConfig = @@ -564,3 +731,24 @@ export async function handleInitCli(args: string[]): Promise { ' Start the HTTP server: npx codebase-context --http\n'; console.log(`\nNext steps:\n${nextSteps}`); } + +export async function handleInitCli(args: string[], deps: InitCliDependencies = {}): Promise { + let options: InitOptions; + try { + options = parseInitOptions(args); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + printInitUsage(); + process.exitCode = 1; + return; + } + + if (options.mode === 'help') { + printInitUsage(); + return; + } + if (options.mode === 'http') { + return runHttpWizard(options); + } + return runStdioSetup(options, deps); +} diff --git a/src/cli.ts b/src/cli.ts index 2e5f17e..e84d69c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,7 +87,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 Interactive setup wizard for AI clients'); + console.log(' init Add this project to installed AI clients'); console.log(' map [--json] [--pretty] Conventions map'); console.log(''); console.log('Global flags:'); diff --git a/src/init-clients.ts b/src/init-clients.ts new file mode 100644 index 0000000..ea53281 --- /dev/null +++ b/src/init-clients.ts @@ -0,0 +1,448 @@ +/** + * Detect installed MCP clients and build the stdio config for the current project. + * Project files stay inside the repo. Claude Desktop and Windsurf only have a user file. + */ + +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export const STDIO_CLIENT_LABELS = [ + 'Claude Code', + 'Claude Desktop', + 'Cursor', + 'VS Code', + 'Windsurf', + 'Codex CLI', + 'Codex Desktop' +] as const; + +export type StdioClientId = + | 'claude-code' + | 'claude-desktop' + | 'cursor' + | 'vscode' + | 'windsurf' + | 'codex-cli' + | 'codex-desktop'; + +const STDIO_TARGETS = [ + 'claude-code', + 'claude-desktop', + 'cursor', + 'vscode', + 'windsurf', + 'codex', + 'codex-cli', + 'codex-desktop' +] as const; + +export type StdioTarget = (typeof STDIO_TARGETS)[number]; + +export type InstallProbe = { + homeDir: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + projectRoot: string; + commandExists: (command: string) => Promise; + pathExists: (target: string) => Promise; +}; + +export type DetectedClient = { + id: StdioClientId; + label: string; + configPath: string; +}; + +export type StdioLaunch = { + nodeExecutable: string; + serverEntrypoint: string; + projectRoot: string; +}; + +export type StdioWritePlan = { + clients: DetectedClient[]; + configPath: string; + content: string; + format: 'json' | 'toml'; + serverKey: 'mcpServers' | 'servers' | null; +}; + +type ClientSpec = { + id: StdioClientId; + label: string; + commands: readonly string[]; + installPaths: (probe: InstallProbe) => string[]; + configPath: (probe: InstallProbe) => string; + format: 'json' | 'toml'; + serverKey: 'mcpServers' | 'servers' | null; +}; + +function pathFor(platform: NodeJS.Platform): path.PlatformPath { + return platform === 'win32' ? path.win32 : path.posix; +} + +function envValue(env: NodeJS.ProcessEnv, key: string): string | null { + const value = env[key]; + if (!value || !value.trim()) return null; + return value.trim(); +} + +function joinHome(probe: InstallProbe, ...parts: string[]): string { + return pathFor(probe.platform).join(probe.homeDir, ...parts); +} + +function appDataDir(probe: InstallProbe): string { + return ( + envValue(probe.env, 'APPDATA') ?? + pathFor(probe.platform).join(probe.homeDir, 'AppData', 'Roaming') + ); +} + +function localAppDataDir(probe: InstallProbe): string { + return ( + envValue(probe.env, 'LOCALAPPDATA') ?? + pathFor(probe.platform).join(probe.homeDir, 'AppData', 'Local') + ); +} + +function xdgConfigDir(probe: InstallProbe): string { + return envValue(probe.env, 'XDG_CONFIG_HOME') ?? joinHome(probe, '.config'); +} + +function programFilesDir(probe: InstallProbe): string { + return envValue(probe.env, 'ProgramFiles') ?? 'C:\\Program Files'; +} + +function claudeDesktopConfigPath(probe: InstallProbe): string { + if (probe.platform === 'darwin') { + return joinHome( + probe, + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json' + ); + } + if (probe.platform === 'win32') { + return pathFor(probe.platform).join(appDataDir(probe), 'Claude', 'claude_desktop_config.json'); + } + return pathFor(probe.platform).join(xdgConfigDir(probe), 'Claude', 'claude_desktop_config.json'); +} + +function claudeDesktopInstallPaths(probe: InstallProbe): string[] { + if (probe.platform === 'darwin') { + return [ + '/Applications/Claude.app', + joinHome(probe, 'Applications', 'Claude.app'), + joinHome(probe, 'Library', 'Application Support', 'Claude') + ]; + } + if (probe.platform === 'win32') { + const programs = programFilesDir(probe); + return [ + pathFor(probe.platform).join(localAppDataDir(probe), 'AnthropicClaude'), + pathFor(probe.platform).join(localAppDataDir(probe), 'Claude'), + pathFor(probe.platform).join(appDataDir(probe), 'Claude'), + pathFor(probe.platform).join(programs, 'Claude') + ]; + } + return [ + pathFor(probe.platform).join(xdgConfigDir(probe), 'Claude'), + joinHome(probe, '.local', 'share', 'Claude') + ]; +} + +function cursorInstallPaths(probe: InstallProbe): string[] { + const homeConfig = joinHome(probe, '.cursor'); + if (probe.platform === 'darwin') { + return ['/Applications/Cursor.app', joinHome(probe, 'Applications', 'Cursor.app'), homeConfig]; + } + if (probe.platform === 'win32') { + const local = localAppDataDir(probe); + return [ + pathFor(probe.platform).join(local, 'Programs', 'cursor'), + pathFor(probe.platform).join(local, 'Programs', 'Cursor'), + pathFor(probe.platform).join(local, 'cursor'), + homeConfig + ]; + } + return [homeConfig]; +} + +function vscodeInstallPaths(probe: InstallProbe): string[] { + if (probe.platform === 'darwin') { + return [ + '/Applications/Visual Studio Code.app', + joinHome(probe, 'Library', 'Application Support', 'Code') + ]; + } + if (probe.platform === 'win32') { + return [ + pathFor(probe.platform).join(localAppDataDir(probe), 'Programs', 'Microsoft VS Code'), + pathFor(probe.platform).join(appDataDir(probe), 'Code') + ]; + } + return [pathFor(probe.platform).join(xdgConfigDir(probe), 'Code')]; +} + +function windsurfInstallPaths(probe: InstallProbe): string[] { + const configDir = joinHome(probe, '.codeium', 'windsurf'); + if (probe.platform === 'darwin') { + return [ + '/Applications/Windsurf.app', + joinHome(probe, 'Applications', 'Windsurf.app'), + configDir + ]; + } + if (probe.platform === 'win32') { + return [ + pathFor(probe.platform).join(localAppDataDir(probe), 'Programs', 'Windsurf'), + configDir + ]; + } + return [configDir]; +} + +function codexConfigHome(probe: InstallProbe): string { + return envValue(probe.env, 'CODEX_HOME') ?? joinHome(probe, '.codex'); +} + +function codexDesktopInstallPaths(probe: InstallProbe): string[] { + if (probe.platform === 'darwin') { + return ['/Applications/Codex.app', joinHome(probe, 'Applications', 'Codex.app')]; + } + if (probe.platform === 'win32') { + const programs = programFilesDir(probe); + const local = localAppDataDir(probe); + return [ + pathFor(probe.platform).join(local, 'OpenAI', 'Codex'), + pathFor(probe.platform).join(local, 'Programs', 'Codex'), + pathFor(probe.platform).join(programs, 'Codex'), + pathFor(probe.platform).join(programs, 'OpenAI', 'Codex') + ]; + } + return []; +} + +function projectFile(probe: InstallProbe, ...parts: string[]): string { + return pathFor(probe.platform).join(probe.projectRoot, ...parts); +} + +const CLIENT_SPECS: readonly ClientSpec[] = [ + { + id: 'claude-code', + label: 'Claude Code', + commands: ['claude'], + installPaths: (probe) => [joinHome(probe, '.claude'), joinHome(probe, '.claude.json')], + configPath: (probe) => projectFile(probe, '.mcp.json'), + format: 'json', + serverKey: 'mcpServers' + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + commands: [], + installPaths: claudeDesktopInstallPaths, + configPath: claudeDesktopConfigPath, + format: 'json', + serverKey: 'mcpServers' + }, + { + id: 'cursor', + label: 'Cursor', + commands: ['cursor', 'cursor-agent'], + installPaths: cursorInstallPaths, + configPath: (probe) => projectFile(probe, '.cursor', 'mcp.json'), + format: 'json', + serverKey: 'mcpServers' + }, + { + id: 'vscode', + label: 'VS Code', + commands: ['code'], + installPaths: vscodeInstallPaths, + configPath: (probe) => projectFile(probe, '.vscode', 'mcp.json'), + format: 'json', + serverKey: 'servers' + }, + { + id: 'windsurf', + label: 'Windsurf', + commands: ['windsurf'], + installPaths: windsurfInstallPaths, + configPath: (probe) => joinHome(probe, '.codeium', 'windsurf', 'mcp_config.json'), + format: 'json', + serverKey: 'mcpServers' + }, + { + id: 'codex-cli', + label: 'Codex CLI', + commands: ['codex'], + installPaths: (probe) => [codexConfigHome(probe)], + configPath: (probe) => projectFile(probe, '.codex', 'config.toml'), + format: 'toml', + serverKey: null + }, + { + id: 'codex-desktop', + label: 'Codex Desktop', + commands: [], + installPaths: codexDesktopInstallPaths, + configPath: (probe) => projectFile(probe, '.codex', 'config.toml'), + format: 'toml', + serverKey: null + } +]; + +function specById(id: StdioClientId): ClientSpec { + const spec = CLIENT_SPECS.find((candidate) => candidate.id === id); + if (!spec) { + throw new Error(`Unknown stdio client "${id}".`); + } + return spec; +} + +export function parseStdioTarget(value: string): StdioTarget { + if ((STDIO_TARGETS as readonly string[]).includes(value)) { + return value as StdioTarget; + } + throw new Error( + `Unknown client "${value}". Choose claude-code, claude-desktop, cursor, vscode, windsurf, codex, codex-cli, or codex-desktop. OpenCode is available with --http.` + ); +} + +export function commandOnPath(command: string): Promise { + const finder = process.platform === 'win32' ? 'where.exe' : 'which'; + return new Promise((resolve) => { + execFile(finder, [command], { timeout: 5000, windowsHide: true }, (error) => { + resolve(error === null); + }); + }); +} + +export async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +export function defaultInstallProbe(projectRoot: string): InstallProbe { + return { + homeDir: os.homedir(), + platform: process.platform, + env: process.env, + projectRoot, + commandExists: commandOnPath, + pathExists + }; +} + +export async function detectInstalledClients(probe: InstallProbe): Promise { + const detected: DetectedClient[] = []; + for (const spec of CLIENT_SPECS) { + const checks = [ + ...spec.commands.map((command) => probe.commandExists(command)), + ...spec.installPaths(probe).map((target) => probe.pathExists(target)) + ]; + const present = await Promise.all(checks); + if (!present.some(Boolean)) continue; + detected.push({ + id: spec.id, + label: spec.label, + configPath: spec.configPath(probe) + }); + } + return detected; +} + +export function clientForTarget(target: StdioTarget, probe: InstallProbe): DetectedClient { + const id: StdioClientId = target === 'codex' ? 'codex-cli' : target; + const spec = specById(id); + return { + id, + label: target === 'codex' ? 'Codex' : spec.label, + configPath: spec.configPath(probe) + }; +} + +function renderMcpServersConfig(launch: StdioLaunch): string { + return JSON.stringify( + { + mcpServers: { + 'codebase-context': { + command: launch.nodeExecutable, + args: [launch.serverEntrypoint, launch.projectRoot] + } + } + }, + null, + 2 + ); +} + +function renderVsCodeConfig(launch: StdioLaunch): string { + return JSON.stringify( + { + servers: { + 'codebase-context': { + type: 'stdio', + command: launch.nodeExecutable, + args: [launch.serverEntrypoint, launch.projectRoot] + } + } + }, + null, + 2 + ); +} + +export function renderCodexServerToml(nodeExecutable: string, args: readonly string[]): string { + const quotedArgs = args.map((value) => JSON.stringify(value)).join(', '); + return ( + `[mcp_servers.codebase-context]\n` + + `command = ${JSON.stringify(nodeExecutable)}\n` + + `args = [${quotedArgs}]\n` + + 'startup_timeout_sec = 120\n' + ); +} + +function renderSpecContent(spec: ClientSpec, launch: StdioLaunch): string { + if (spec.format === 'toml') { + return renderCodexServerToml(launch.nodeExecutable, [ + launch.serverEntrypoint, + launch.projectRoot + ]); + } + if (spec.serverKey === 'servers') return renderVsCodeConfig(launch); + return renderMcpServersConfig(launch); +} + +export function buildWritePlans(clients: DetectedClient[], launch: StdioLaunch): StdioWritePlan[] { + const plans = new Map(); + for (const client of clients) { + const spec = specById(client.id); + const existing = plans.get(client.configPath); + if (existing) { + existing.clients.push(client); + continue; + } + plans.set(client.configPath, { + clients: [client], + configPath: client.configPath, + content: renderSpecContent(spec, launch), + format: spec.format, + serverKey: spec.serverKey + }); + } + return [...plans.values()]; +} + +export function configBackupPath(filePath: string, now: Date): string { + const stamp = now.toISOString().replace(/[:.]/g, '-'); + return `${filePath}.${stamp}.bak`; +} diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index 8d139a2..7e2e423 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -30,6 +30,7 @@ import { _buildMergedCodexContent, handleInitCli } from '../src/cli-init.js'; +import { buildWritePlans, type InstallProbe } from '../src/init-clients.js'; // --- generateMcpConfig --- @@ -445,14 +446,259 @@ describe('handleInitCli', () => { expect(writeFileMock).not.toHaveBeenCalled(); }); - it('keeps the interactive preview read-only when the user declines', async () => { + it('keeps the HTTP wizard read-only when the user declines', async () => { vi.mocked(select).mockResolvedValue('claude-code'); vi.mocked(confirm).mockResolvedValue(false); - await handleInitCli([]); + await handleInitCli(['--http']); + expect(select).toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); + + it('keeps the HTTP cursor config behind --http', async () => { + const projectRoot = path.resolve('C:/projects/Http Cursor'); + + await handleInitCli(['--http', '--client', 'cursor', '--yes', '--root', projectRoot]); + + expect(confirm).not.toHaveBeenCalled(); + const [writtenPath, writtenContent] = writeFileMock.mock.calls[0] as [ + string, + string, + ...unknown[] + ]; + expect(writtenPath).toBe(path.join(projectRoot, '.cursor', 'mcp.json')); + const parsed = JSON.parse(writtenContent) as { + mcpServers: { 'codebase-context': { type: string; url: string } }; + }; + expect(parsed.mcpServers['codebase-context']).toEqual({ + type: 'http', + url: 'http://127.0.0.1:3100/mcp' + }); + }); + + it('requires a client when the HTTP wizard is non-interactive', async () => { + const previousExitCode = process.exitCode; + + await handleInitCli(['--http', '--yes']); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); +}); + +describe('stdio init', () => { + const readFileMock = vi.mocked(fsMod.readFile); + const writeFileMock = vi.mocked(fsMod.writeFile); + const mkdirMock = vi.mocked(fsMod.mkdir); + const accessMock = vi.mocked(fsMod.access); + + beforeEach(() => { + vi.resetAllMocks(); + writeFileMock.mockResolvedValue(undefined); + mkdirMock.mockResolvedValue(undefined); + readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + }); + + function installProbe(projectRoot: string, overrides: Partial = {}): InstallProbe { + return { + homeDir: path.join(projectRoot, 'home'), + platform: process.platform, + env: {}, + projectRoot, + commandExists: async () => false, + pathExists: async () => false, + ...overrides + }; + } + + it('writes stdio config for each detected client without prompting', async () => { + const projectRoot = path.resolve('C:/projects/Detected App'); + const probe = installProbe(projectRoot, { + commandExists: async (command) => command === 'claude' || command === 'code' + }); + + await handleInitCli(['--yes', '--root', projectRoot], { probe }); + + expect(select).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + const writtenPaths = writeFileMock.mock.calls.map((call) => String(call[0])); + expect(writtenPaths).toEqual([ + path.join(projectRoot, '.mcp.json'), + path.join(projectRoot, '.vscode', 'mcp.json') + ]); + const claude = JSON.parse(String(writeFileMock.mock.calls[0]?.[1])) as { + mcpServers: { 'codebase-context': { args: string[] } }; + }; + const vscode = JSON.parse(String(writeFileMock.mock.calls[1]?.[1])) as { + servers: { 'codebase-context': { type: string; args: string[] } }; + }; + expect(claude.mcpServers['codebase-context'].args.at(-1)).toBe(projectRoot); + expect(vscode.servers['codebase-context'].type).toBe('stdio'); + expect(vscode.servers['codebase-context'].args.at(-1)).toBe(projectRoot); + }); + + it('writes nothing when auto setup is declined', async () => { + const projectRoot = path.resolve('C:/projects/Declined App'); + vi.mocked(confirm).mockResolvedValue(false); + + await handleInitCli(['--root', projectRoot], { + probe: installProbe(projectRoot, { + commandExists: async (command) => command === 'cursor' + }) + }); + + expect(select).not.toHaveBeenCalled(); + expect(confirm).toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('writes nothing when no clients are installed', async () => { + const projectRoot = path.resolve('C:/projects/Empty Machine'); + + await handleInitCli(['--yes', '--root', projectRoot], { probe: installProbe(projectRoot) }); + + expect(confirm).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('backs up an existing file, then merges the new stdio server', async () => { + const projectRoot = path.resolve('C:/projects/Backup App'); + const original = JSON.stringify( + { + mcpServers: { other: { command: 'keep', args: [] } }, + extra: true + }, + null, + 2 + ); + readFileMock.mockResolvedValue(original as unknown as Buffer); + const previousExitCode = process.exitCode; + + await handleInitCli(['--client', 'cursor', '--yes', '--root', projectRoot], { + now: () => new Date('2026-10-16T12:00:00.000Z') + }); + + const backupPath = `${path.join(projectRoot, '.cursor', 'mcp.json')}.2026-10-16T12-00-00-000Z.bak`; + expect(writeFileMock.mock.calls[0]?.[0]).toBe(backupPath); + expect(writeFileMock.mock.calls[0]?.[1]).toBe(original); + const merged = JSON.parse(String(writeFileMock.mock.calls[1]?.[1])) as { + extra: boolean; + mcpServers: Record; + }; + expect(merged.extra).toBe(true); + expect(merged.mcpServers.other).toEqual({ command: 'keep', args: [] }); + expect(merged.mcpServers['codebase-context']?.args?.at(-1)).toBe(projectRoot); + expect(merged.mcpServers['codebase-context']?.type).toBeUndefined(); + expect(process.exitCode).toBe(previousExitCode); + }); + + it('does not write or back up a different existing stdio entry', async () => { + const projectRoot = path.resolve('C:/projects/Protected App'); + const original = JSON.stringify({ + servers: { + other: { type: 'stdio', command: 'keep', args: [] }, + 'codebase-context': { type: 'stdio', command: 'old', args: ['elsewhere'] } + } + }); + readFileMock.mockResolvedValue(original as unknown as Buffer); + const previousExitCode = process.exitCode; + + await handleInitCli(['--client', 'vscode', '--yes', '--root', projectRoot]); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); + + it('leaves a conflicting client untouched and still writes the others', async () => { + const projectRoot = path.resolve('C:/projects/Partial App'); + readFileMock.mockImplementation(async (filePath: unknown) => { + if (String(filePath).endsWith(`${path.sep}.mcp.json`)) { + return JSON.stringify({ + mcpServers: { 'codebase-context': { command: 'other' } } + }) as unknown as Buffer; + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }); + const previousExitCode = process.exitCode; + + await handleInitCli(['--yes', '--root', projectRoot], { + probe: installProbe(projectRoot, { + commandExists: async (command) => command === 'claude' || command === 'cursor' + }) + }); + + expect(writeFileMock).toHaveBeenCalledOnce(); + expect(String(writeFileMock.mock.calls[0]?.[0])).toBe( + path.join(projectRoot, '.cursor', 'mcp.json') + ); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); + + it('does not rewrite a config that already has the same server', async () => { + const projectRoot = path.resolve('C:/projects/Same App'); + const plan = buildWritePlans( + [ + { + id: 'windsurf', + label: 'Windsurf', + configPath: path.join(projectRoot, 'home', '.codeium', 'windsurf', 'mcp_config.json') + } + ], + { + nodeExecutable: process.execPath, + serverEntrypoint: path.resolve(process.argv[1] ?? ''), + projectRoot + } + )[0]; + if (!plan) throw new Error('expected a Windsurf plan'); + readFileMock.mockResolvedValue(plan.content as unknown as Buffer); + + await handleInitCli(['--client', 'windsurf', '--yes', '--root', projectRoot], { + probe: installProbe(projectRoot) + }); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('preserves other VS Code servers when adding codebase-context', async () => { + const projectRoot = path.resolve('C:/projects/Vs Code App'); + readFileMock.mockResolvedValue( + JSON.stringify({ + servers: { other: { type: 'stdio', command: 'keep', args: ['a'] } }, + inputs: [] + }) as unknown as Buffer + ); + + await handleInitCli(['--client', 'vscode', '--yes', '--root', projectRoot], { + now: () => new Date('2026-10-16T12:00:00.000Z') + }); + + expect(String(writeFileMock.mock.calls[0]?.[0])).toContain('.bak'); + const merged = JSON.parse(String(writeFileMock.mock.calls[1]?.[1])) as { + inputs: unknown[]; + servers: Record; + }; + expect(merged.inputs).toEqual([]); + expect(merged.servers.other).toEqual({ type: 'stdio', command: 'keep', args: ['a'] }); + expect(merged.servers['codebase-context']?.command).toBe(process.execPath); + expect(merged.servers['codebase-context']?.args?.at(-1)).toBe(projectRoot); + }); + + it('rejects OpenCode unless the HTTP wizard is selected', async () => { + const previousExitCode = process.exitCode; + + await handleInitCli(['--client', 'opencode', '--yes']); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(accessMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); }); describe('_buildMergedCodexContent', () => { diff --git a/tests/init-clients.test.ts b/tests/init-clients.test.ts new file mode 100644 index 0000000..7378fed --- /dev/null +++ b/tests/init-clients.test.ts @@ -0,0 +1,175 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildWritePlans, + detectInstalledClients, + type DetectedClient, + type InstallProbe +} from '../src/init-clients.js'; + +function probe(overrides: Partial & Pick): InstallProbe { + return { + homeDir: overrides.homeDir ?? '/Users/ada', + platform: overrides.platform, + env: overrides.env ?? {}, + projectRoot: overrides.projectRoot ?? '/projects/demo', + commandExists: overrides.commandExists ?? (async () => false), + pathExists: overrides.pathExists ?? (async () => false) + }; +} + +describe('detectInstalledClients', () => { + it('detects a macOS app and points Claude Desktop at its user config', async () => { + const home = '/Users/ada'; + const found = await detectInstalledClients( + probe({ + platform: 'darwin', + homeDir: home, + pathExists: async (target) => target === '/Applications/Claude.app' + }) + ); + + expect(found.map((client) => client.id)).toEqual(['claude-desktop']); + expect(found[0]?.configPath).toBe( + path.posix.join( + home, + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json' + ) + ); + }); + + it('uses APPDATA for Claude Desktop and LOCALAPPDATA for Codex Desktop on Windows', async () => { + const home = 'C:\\Users\\ada'; + const appData = 'C:\\Users\\ada\\AppData\\Roaming'; + const localAppData = 'C:\\Users\\ada\\AppData\\Local'; + const found = await detectInstalledClients( + probe({ + platform: 'win32', + homeDir: home, + projectRoot: 'D:\\work\\demo', + env: { APPDATA: appData, LOCALAPPDATA: localAppData }, + pathExists: async (target) => + target === path.win32.join(appData, 'Claude') || + target === path.win32.join(localAppData, 'Programs', 'Codex') + }) + ); + + expect(found.map((client) => client.id)).toEqual(['claude-desktop', 'codex-desktop']); + expect(found[0]?.configPath).toBe( + path.win32.join(appData, 'Claude', 'claude_desktop_config.json') + ); + expect(found[1]?.configPath).toBe(path.win32.join('D:\\work\\demo', '.codex', 'config.toml')); + }); + + it('detects clients from commands when no install folder is present', async () => { + const projectRoot = '/projects/demo'; + const found = await detectInstalledClients( + probe({ + platform: 'linux', + projectRoot, + commandExists: async (command) => command === 'claude' || command === 'code' + }) + ); + + expect(found.map((client) => client.id)).toEqual(['claude-code', 'vscode']); + expect(found.map((client) => client.configPath)).toEqual([ + path.posix.join(projectRoot, '.mcp.json'), + path.posix.join(projectRoot, '.vscode', 'mcp.json') + ]); + }); + + it('detects Cursor, Windsurf, and Codex CLI from their home folders', async () => { + const home = '/Users/ada'; + const found = await detectInstalledClients( + probe({ + platform: 'darwin', + homeDir: home, + pathExists: async (target) => + target === path.posix.join(home, '.cursor') || + target === path.posix.join(home, '.codeium', 'windsurf') || + target === path.posix.join(home, '.codex') + }) + ); + + expect(found.map((client) => client.id)).toEqual(['cursor', 'windsurf', 'codex-cli']); + expect(found.find((client) => client.id === 'windsurf')?.configPath).toBe( + path.posix.join(home, '.codeium', 'windsurf', 'mcp_config.json') + ); + expect(found.find((client) => client.id === 'cursor')?.configPath).toBe( + path.posix.join('/projects/demo', '.cursor', 'mcp.json') + ); + }); + + it('does not treat a project config file as an installed client', async () => { + const found = await detectInstalledClients( + probe({ + platform: 'linux', + pathExists: async (target) => target === '/projects/demo/.cursor/mcp.json' + }) + ); + + expect(found).toEqual([]); + }); + + it('returns no clients when nothing is installed', async () => { + const found = await detectInstalledClients(probe({ platform: 'linux' })); + expect(found).toEqual([]); + }); +}); + +describe('buildWritePlans', () => { + const launch = { + nodeExecutable: '/usr/local/bin/node', + serverEntrypoint: '/opt/codebase-context/dist/index.js', + projectRoot: '/projects/My App' + }; + + it('writes Codex CLI and Codex Desktop to one project file', () => { + const clients: DetectedClient[] = [ + { + id: 'codex-cli', + label: 'Codex CLI', + configPath: '/projects/My App/.codex/config.toml' + }, + { + id: 'codex-desktop', + label: 'Codex Desktop', + configPath: '/projects/My App/.codex/config.toml' + } + ]; + + const plans = buildWritePlans(clients, launch); + + expect(plans).toHaveLength(1); + expect(plans[0]?.clients.map((client) => client.label)).toEqual(['Codex CLI', 'Codex Desktop']); + expect(plans[0]?.content).toBe( + `[mcp_servers.codebase-context]\ncommand = "/usr/local/bin/node"\nargs = ["/opt/codebase-context/dist/index.js", "/projects/My App"]\nstartup_timeout_sec = 120\n` + ); + }); + + it('uses the VS Code servers key and includes the project path', () => { + const plans = buildWritePlans( + [ + { + id: 'vscode', + label: 'VS Code', + configPath: '/projects/My App/.vscode/mcp.json' + } + ], + launch + ); + + expect(JSON.parse(plans[0]?.content ?? '{}')).toEqual({ + servers: { + 'codebase-context': { + type: 'stdio', + command: launch.nodeExecutable, + args: [launch.serverEntrypoint, launch.projectRoot] + } + } + }); + }); +}); diff --git a/tests/release-truth-surfaces.test.ts b/tests/release-truth-surfaces.test.ts index b2186a2..32e4d85 100644 --- a/tests/release-truth-surfaces.test.ts +++ b/tests/release-truth-surfaces.test.ts @@ -134,7 +134,7 @@ describe('release truth surfaces', () => { const setupPages = [ [ 'README.md', - 'claude mcp add --scope user --transport stdio codebase-context -- npx -y codebase-context@2.4.0' + 'npx -y codebase-context@2.4.0 init' ], [ 'docs/client-setup.md',