diff --git a/docs/client-setup.md b/docs/client-setup.md index 3d47d49..c052e65 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -103,9 +103,21 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f ## Interactive setup wizard -Run `npx -y codebase-context@2.4.0 init` from the target project to open the existing interactive wizard. It asks you to choose Claude Code, Cursor, Codex or OpenCode, previews HTTP configuration and asks before applying it. It separately offers to write an instruction block to `CLAUDE.md`, `.cursorrules` or `AGENTS.md` where applicable. +Run setup from the project you want the client to search: -The wizard does not accept non-interactive client/root flags or prepare an index. Its HTTP configurations need a separately started server. In the current source, Claude/Codex registration attempts to execute `mcp` directly and can report failure while the wizard still exits successfully. Use the direct stdio commands or configuration above for those clients; the wizard is not a verified one-step setup. +```bash +# Claude Code +npx -y codebase-context@2.4.0 init --client claude-code --yes + +# Codex +npx -y codebase-context@2.4.0 init --client codex --yes +``` + +Claude Code writes `.mcp.json`; Codex writes `.codex/config.toml`. Both configs start the installed server with the selected project. Setup uses the current folder unless `--root` or `CODEBASE_ROOT` selects another one; `--root` takes priority. + +`--yes` writes the config without prompts. Run `init` without flags to choose a client interactively and optionally add project instructions. Cursor and OpenCode still use HTTP and need a separately running server. + +Existing settings are kept. If a different Codebase Context entry is already present, setup leaves the file unchanged and reports the conflict. Restart the client after setup and approve the server if asked. The first use builds the code index. Rerun setup if the Codebase Context installation moves, because the config uses its installed location. ## Optional fixed-folder setup and CLI preparation diff --git a/src/cli-init.ts b/src/cli-init.ts index e9efc76..bf126cc 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -10,28 +10,54 @@ import path from 'path'; import * as fs from 'node:fs/promises'; -import { execFileSync } from 'child_process'; import { select, confirm } from '@inquirer/prompts'; export type Client = 'claude-code' | 'cursor' | 'codex' | 'opencode'; -export type McpConfigResult = - | { kind: 'file'; path: string; content: string } - | { kind: 'command'; args: string[] }; +export type McpConfigResult = { kind: 'file'; path: string; content: string }; + +export type McpConfigOptions = { + rootPath?: string; + nodeExecutable?: string; + serverEntrypoint?: string; +}; type JsonObject = Record; -export function generateMcpConfig(client: Client): McpConfigResult { +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; + const serverEntrypoint = path.resolve( + options.serverEntrypoint ?? process.argv[1] ?? path.join(rootPath, 'dist', 'index.js') + ); + const stdioArgs = [serverEntrypoint, rootPath]; + switch (client) { case 'claude-code': return { - kind: 'command', - args: ['mcp', 'add', '--transport', 'http', 'codebase-context', 'http://127.0.0.1:3100/mcp'] + kind: 'file', + path: path.join(rootPath, '.mcp.json'), + content: JSON.stringify( + { + mcpServers: { + 'codebase-context': { + command: nodeExecutable, + args: stdioArgs + } + } + }, + null, + 2 + ) }; case 'cursor': return { kind: 'file', - path: '.cursor/mcp.json', + path: path.join(rootPath, '.cursor', 'mcp.json'), content: JSON.stringify( { mcpServers: { @@ -47,13 +73,18 @@ export function generateMcpConfig(client: Client): McpConfigResult { }; case 'codex': return { - kind: 'command', - args: ['mcp', 'add', 'codebase-context', 'http://127.0.0.1:3100/mcp'] + 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' }; case 'opencode': return { kind: 'file', - path: 'opencode.json', + path: path.join(rootPath, 'opencode.json'), content: JSON.stringify( { $schema: 'https://opencode.ai/config.json', @@ -71,6 +102,34 @@ export function generateMcpConfig(client: Client): McpConfigResult { } } +export function resolveInitRoot( + args: string[], + envRoot: string | undefined, + cwd = process.cwd() +): string { + let explicitRoot: string | undefined; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--root') { + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error('Expected a directory path after --root.'); + } + explicitRoot = value; + index++; + } else if (arg?.startsWith('--root=')) { + const value = arg.slice('--root='.length); + if (!value) { + throw new Error('Expected a directory path after --root.'); + } + explicitRoot = value; + } + } + + return path.resolve(explicitRoot ?? (envRoot ? envRoot : cwd)); +} + export function generateInstructionBlock(): string { return ` ## Codebase Context (MCP) @@ -127,57 +186,99 @@ function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function normalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeJson); + } + if (isJsonObject(value)) { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, normalizeJson(entry)]) + ); + } + return value; +} + +function jsonValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(normalizeJson(left)) === JSON.stringify(normalizeJson(right)); +} + +function isFileNotFound(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +} + +async function readExistingConfig(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isFileNotFound(error)) return null; + throw error; + } +} + /** * Merge generated MCP config into an existing JSON config file when possible. - * Preserves unrelated keys and only updates the codebase-context server entry. + * Preserves unrelated keys and refuses to replace a different codebase-context entry. */ 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) { + return { content: generatedContent, mergedFromExisting: false }; + } + let existing: unknown; try { - existing = JSON.parse(await fs.readFile(filePath, 'utf8')); - } catch { - return { content: generatedContent, mergedFromExisting: false }; + existing = JSON.parse(existingText) as unknown; + } catch (error) { + throw new Error(`Cannot merge ${client} config at ${filePath}: existing JSON is invalid.`, { + cause: error + }); } const generated = JSON.parse(generatedContent) as unknown; if (!isJsonObject(existing) || !isJsonObject(generated)) { - return { content: generatedContent, mergedFromExisting: false }; + throw new Error(`Cannot merge ${client} config at ${filePath}: expected a JSON object.`); } - if (client === 'cursor') { - const existingServers = isJsonObject(existing.mcpServers) ? existing.mcpServers : {}; - const generatedServers = isJsonObject(generated.mcpServers) ? generated.mcpServers : {}; - return { - content: JSON.stringify( - { - ...existing, - ...generated, - mcpServers: { - ...existingServers, - ...generatedServers - } - }, - null, - 2 - ), - mergedFromExisting: true - }; + const serverKey = client === 'opencode' ? 'mcp' : 'mcpServers'; + const existingServersValue = existing[serverKey]; + if (existingServersValue !== undefined && !isJsonObject(existingServersValue)) { + throw new Error( + `Cannot merge ${client} config at ${filePath}: ${serverKey} must be a JSON object.` + ); + } + const generatedServersValue = generated[serverKey]; + if (!isJsonObject(generatedServersValue)) { + throw new Error(`Invalid generated ${client} MCP config.`); + } + const existingServers = isJsonObject(existingServersValue) ? existingServersValue : {}; + const hasExistingCodebaseContext = Object.prototype.hasOwnProperty.call( + existingServers, + 'codebase-context' + ); + const generatedCodebaseContext = generatedServersValue['codebase-context']; + if ( + hasExistingCodebaseContext && + !jsonValuesEqual(existingServers['codebase-context'], generatedCodebaseContext) + ) { + throw new Error( + `Cannot merge ${client} config at ${filePath}: it already contains a different codebase-context configuration.` + ); } - const existingMcp = isJsonObject(existing.mcp) ? existing.mcp : {}; - const generatedMcp = isJsonObject(generated.mcp) ? generated.mcp : {}; return { content: JSON.stringify( { ...existing, ...generated, - mcp: { - ...existingMcp, - ...generatedMcp + [serverKey]: { + ...existingServers, + ...generatedServersValue } }, null, @@ -187,56 +288,232 @@ export async function _buildMergedMcpContent( }; } -export async function handleInitCli(_argv: string[]): Promise { - console.log('\nSet up codebase-context for your AI client\n'); +function normalizeTomlNewlines(value: string): string { + return value.replace(/\r\n/g, '\n'); +} - const client = await select({ - message: 'Which client?', - choices: [ - { name: 'Claude Code', value: 'claude-code' }, - { name: 'Cursor', value: 'cursor' }, - { name: 'Codex', value: 'codex' }, - { name: 'OpenCode', value: 'opencode' } - ] - }); +function tomlKeyPattern(key: string): string { + return `(?:${key}|"${key}"|'${key}')`; +} - const mcpResult = generateMcpConfig(client); +/** + * Appends the project MCP table to Codex TOML, preserving other settings and + * refusing to replace an existing codebase-context table. + */ +export async function _buildMergedCodexContent( + filePath: string, + generatedContent: string +): Promise<{ content: string; mergedFromExisting: boolean }> { + const existing = await readExistingConfig(filePath); + if (existing === null) { + return { content: generatedContent, mergedFromExisting: false }; + } - console.log('\n--- MCP Config Preview ---'); - if (mcpResult.kind === 'file') { - try { - await fs.access(mcpResult.path); - console.log(`Warning: ${mcpResult.path} already exists; existing entries will be preserved.`); - } catch { - // file does not exist + const normalizedExisting = normalizeTomlNewlines(existing); + const normalizedGenerated = normalizeTomlNewlines(generatedContent).trim(); + const mcpServersKey = tomlKeyPattern('mcp_servers'); + const codebaseContextKey = tomlKeyPattern('codebase-context'); + const entryHeader = new RegExp( + `^\\s*\\[{1,2}\\s*${mcpServersKey}\\s*\\.\\s*${codebaseContextKey}(?:\\s*\\.\\s*[^\\]]+)?\\s*\\]{1,2}\\s*(?:#.*)?$`, + 'gm' + ); + const existingEntry = entryHeader.exec(normalizedExisting); + + if (existingEntry) { + const nextTable = /^\s*\[{1,2}[^\r\n]+\]{1,2}\s*(?:#.*)?$/gm; + nextTable.lastIndex = entryHeader.lastIndex; + const nextTableMatch = nextTable.exec(normalizedExisting); + const sectionEnd = nextTableMatch?.index ?? normalizedExisting.length; + const section = normalizedExisting.slice(existingEntry.index, sectionEnd).trim(); + const exactEntryHeader = new RegExp( + `^\\[\\s*${mcpServersKey}\\s*\\.\\s*${codebaseContextKey}\\s*\\]\\s*(?:#.*)?$`, + 'm' + ); + const comparableSection = section.replace(exactEntryHeader, '[mcp_servers.codebase-context]'); + if (comparableSection === normalizedGenerated) { + return { content: existing, mergedFromExisting: true }; } - console.log(`File: ${mcpResult.path}\n${mcpResult.content}`); - } else { - console.log(`Command to run: ${mcpResult.args[0]} ${mcpResult.args.slice(1).join(' ')}`); + throw new Error( + `Cannot merge Codex config at ${filePath}: it already contains a different codebase-context configuration.` + ); } - const applyMcp = await confirm({ - message: 'Apply MCP config? [y/N]', - default: false - }); - - const instructionPath = resolveInstructionFilePath(client, process.cwd()); + const dottedEntry = new RegExp( + `^\\s*${mcpServersKey}\\s*\\.\\s*${codebaseContextKey}\\s*\\.`, + 'm' + ); + if (dottedEntry.test(normalizedExisting)) { + throw new Error( + `Cannot merge Codex config at ${filePath}: it already contains a different codebase-context configuration.` + ); + } - let applyInstruction = false; - if (instructionPath !== null) { - let fileExists = false; - try { - await fs.access(instructionPath); - fileExists = true; - } catch { - // does not exist + const parentTableHeader = new RegExp(`^\\s*\\[\\s*${mcpServersKey}\\s*\\]\\s*(?:#.*)?$`, 'gm'); + let parentTable = parentTableHeader.exec(normalizedExisting); + while (parentTable) { + const nextTable = /^\s*\[{1,2}[^\r\n]+\]{1,2}\s*(?:#.*)?$/gm; + nextTable.lastIndex = parentTableHeader.lastIndex; + const nextTableMatch = nextTable.exec(normalizedExisting); + const sectionEnd = nextTableMatch?.index ?? normalizedExisting.length; + const parentSettings = normalizedExisting.slice(parentTableHeader.lastIndex, sectionEnd); + const childEntry = new RegExp(`^\\s*${codebaseContextKey}\\s*(?:=|\\.)`, 'm'); + if (childEntry.test(parentSettings)) { + throw new Error( + `Cannot merge Codex config at ${filePath}: it already contains a different codebase-context configuration.` + ); } + parentTable = parentTableHeader.exec(normalizedExisting); + } + + const firstTable = /^\s*\[/m.exec(normalizedExisting); + const rootSettings = firstTable + ? normalizedExisting.slice(0, firstTable.index) + : normalizedExisting; + const inlineServers = new RegExp(`^\\s*${mcpServersKey}\\s*=`, 'm'); + if (inlineServers.test(rootSettings)) { + throw new Error( + `Cannot merge Codex config at ${filePath}: mcp_servers is already configured inline.` + ); + } + + const prefix = normalizedExisting.trimEnd(); + return { + content: `${prefix ? `${prefix}\n\n` : ''}${normalizedGenerated}\n`, + mergedFromExisting: true + }; +} + +type InitOptions = { + client?: Client; + rootPath: string; + yes: boolean; + help: boolean; +}; + +function parseClient(value: string): Client { + if (value === 'claude-code' || value === 'cursor' || value === 'codex' || value === 'opencode') { + return value; + } + throw new Error(`Unknown client "${value}". Choose claude-code, cursor, codex, or opencode.`); +} + +function parseInitOptions(args: string[]): InitOptions { + if (args.includes('--help') || args.includes('-h')) { + return { rootPath: process.cwd(), yes: false, help: true }; + } - if (fileExists) { - console.log(`\n--- Instruction Block Preview (will append to ${instructionPath}) ---`); + let client: Client | undefined; + let yes = false; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--yes' || arg === '-y') { + yes = 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); + index++; + } else if (arg?.startsWith('--client=')) { + client = parseClient(arg.slice('--client='.length)); + } else if (arg === '--root') { + index++; + } else if (arg?.startsWith('--root=')) { + // Root values are validated once by resolveInitRoot below. } else { - console.log(`\n--- Instruction Block Preview (will create ${instructionPath}) ---`); + throw new Error(`Unknown init option "${arg}".`); } + } + + return { + ...(client ? { client } : {}), + rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT, process.cwd()), + yes, + help: false + }; +} + +function printInitUsage(): void { + console.log('codebase-context init [options]'); + console.log(''); + console.log('Set up MCP config for an AI client.'); + 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(' --help, -h Show this help'); +} + +function getCurrentCliEntrypoint(): string { + const entrypoint = process.argv[1]; + if (!entrypoint) { + throw new Error('Cannot locate the codebase-context CLI entrypoint.'); + } + return path.resolve(entrypoint); +} + +export async function handleInitCli(args: string[]): 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.help) { + printInitUsage(); + return; + } + if (options.yes && !options.client) { + console.error('Error: --yes requires --client.'); + printInitUsage(); + process.exitCode = 1; + return; + } + + console.log('\nSet up codebase-context for your AI client\n'); + + const client = + options.client ?? + (await select({ + message: 'Which client?', + choices: [ + { name: 'Claude Code', value: 'claude-code' }, + { name: 'Cursor', value: 'cursor' }, + { name: 'Codex', value: 'codex' }, + { name: 'OpenCode', value: 'opencode' } + ] + })); + + const mcpResult = generateMcpConfig(client, { + rootPath: options.rootPath, + nodeExecutable: process.execPath, + serverEntrypoint: getCurrentCliEntrypoint() + }); + + console.log('\n--- MCP Config Preview ---'); + console.log( + 'Existing unrelated settings will be preserved; a different codebase-context entry will stop setup.' + ); + console.log(`File: ${mcpResult.path}\n${mcpResult.content}`); + + const applyMcp = options.yes + ? true + : await confirm({ + message: 'Apply MCP config? [y/N]', + default: false + }); + + const instructionPath = options.yes ? null : resolveInstructionFilePath(client, options.rootPath); + + let applyInstruction = false; + if (instructionPath !== null) { + console.log(`\n--- Instruction Block Preview (will append to ${instructionPath}) ---`); console.log(generateInstructionBlock()); applyInstruction = await confirm({ @@ -245,47 +522,45 @@ export async function handleInitCli(_argv: string[]): Promise { }); } - // Execute confirmed actions - - if (applyMcp) { - if (mcpResult.kind === 'file') { - const dir = path.dirname(mcpResult.path); - if (dir && dir !== '.') { - await fs.mkdir(dir, { recursive: true }); + try { + if (applyMcp) { + try { + await fs.access(options.rootPath); + } catch { + throw new Error(`Project root does not exist or is not accessible: ${options.rootPath}`); } + const dir = path.dirname(mcpResult.path); + await fs.mkdir(dir, { recursive: true }); const mergedConfig = - client === 'cursor' || client === 'opencode' - ? await _buildMergedMcpContent(mcpResult.path, mcpResult.content, client) - : { content: mcpResult.content, mergedFromExisting: false }; + client === 'codex' + ? await _buildMergedCodexContent(mcpResult.path, mcpResult.content) + : await _buildMergedMcpContent(mcpResult.path, mcpResult.content, client); await fs.writeFile(mcpResult.path, mergedConfig.content, 'utf8'); if (mergedConfig.mergedFromExisting) { console.log(`Merged: ${mcpResult.path} (existing entries preserved)`); } console.log(`Written: ${mcpResult.path}`); - } else { - const [cmd, ...rest] = mcpResult.args; - try { - execFileSync(cmd, rest, { stdio: 'inherit' }); - } catch { - console.log( - `Could not run '${cmd}' automatically. Run it yourself:\n ${cmd} ${rest.join(' ')}` - ); - } } - } - if (applyInstruction && instructionPath !== null) { - const status = await _appendInstructionBlock(instructionPath); - if (status === 'skipped') { - console.log(`Instruction block already present in ${instructionPath}, skipping.`); - } else { - console.log(`Written: ${instructionPath}`); + if (applyInstruction && instructionPath !== null) { + const status = await _appendInstructionBlock(instructionPath); + if (status === 'skipped') { + console.log(`Instruction block already present in ${instructionPath}, skipping.`); + } else { + console.log(`Written: ${instructionPath}`); + } } + } catch (error) { + console.error(`Init failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; } - console.log( - '\nNext steps:\n' + - ' Run `npx codebase-context map` to see your codebase conventions\n' + - ' Start the HTTP server: npx codebase-context --http\n' - ); + const nextSteps = + client === 'claude-code' || client === 'codex' + ? ' Restart or reopen the client in this project to load the MCP server.\n' + + ' Run `npx codebase-context map` to see your codebase conventions\n' + : ' Run `npx codebase-context map` to see your codebase conventions\n' + + ' Start the HTTP server: npx codebase-context --http\n'; + console.log(`\nNext steps:\n${nextSteps}`); } diff --git a/tests/cli-init-bootstrap.test.ts b/tests/cli-init-bootstrap.test.ts new file mode 100644 index 0000000..c16c3be --- /dev/null +++ b/tests/cli-init-bootstrap.test.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { generateMcpConfig } from '../src/cli-init.js'; +import { resolveRootPath } from '../src/index.js'; + +describe('init bootstrap root', () => { + it('passes the configured project root through the MCP server argv path', () => { + const projectRoot = path.resolve('C:/projects/An App With Spaces'); + const previousArgv = process.argv; + const previousEnvRoot = process.env.CODEBASE_ROOT; + const generated = generateMcpConfig('claude-code', { + rootPath: projectRoot, + nodeExecutable: process.execPath, + serverEntrypoint: path.resolve('C:/codebase-context/dist/index.js') + }); + if (generated.kind !== 'file') throw new Error('expected project MCP config'); + + const parsed = JSON.parse(generated.content) as { + mcpServers: { 'codebase-context': { command: string; args: string[] } }; + }; + const server = parsed.mcpServers['codebase-context']; + process.argv = [server.command, ...server.args]; + process.env.CODEBASE_ROOT = path.resolve('C:/projects/Other App'); + + try { + expect(resolveRootPath()).toBe(projectRoot); + } finally { + process.argv = previousArgv; + if (previousEnvRoot === undefined) delete process.env.CODEBASE_ROOT; + else process.env.CODEBASE_ROOT = previousEnvRoot; + } + }); +}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index 1dc718e..8d139a2 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -13,55 +13,82 @@ vi.mock('node:fs/promises', async (importOriginal) => { }; }); +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn() +})); + import * as fsMod from 'node:fs/promises'; +import { confirm, select } from '@inquirer/prompts'; import { generateMcpConfig, generateInstructionBlock, resolveInstructionFilePath, + resolveInitRoot, _appendInstructionBlock, - _buildMergedMcpContent + _buildMergedMcpContent, + _buildMergedCodexContent, + handleInitCli } from '../src/cli-init.js'; // --- generateMcpConfig --- describe('generateMcpConfig', () => { - it('claude-code returns a command with mcp add --transport http', () => { - const result = generateMcpConfig('claude-code'); - expect(result.kind).toBe('command'); - if (result.kind !== 'command') return; - expect(result.args[0]).toBe('mcp'); - expect(result.args).toContain('--transport'); - expect(result.args).toContain('http'); - expect(result.args).toContain('codebase-context'); - expect(result.args).toContain('http://127.0.0.1:3100/mcp'); + const projectRoot = path.resolve('C:/projects/Example Project'); + const nodeExecutable = path.resolve('C:/Program Files/nodejs/node.exe'); + const serverEntrypoint = path.resolve('C:/Program Files/codebase-context/dist/index.js'); + + it('Claude Code gets a project stdio config that passes the project root', () => { + const result = generateMcpConfig('claude-code', { + rootPath: projectRoot, + nodeExecutable, + serverEntrypoint + }); + + expect(result.kind).toBe('file'); + if (result.kind !== 'file') return; + expect(result.path).toBe(path.join(projectRoot, '.mcp.json')); + expect(JSON.parse(result.content)).toEqual({ + mcpServers: { + 'codebase-context': { + command: nodeExecutable, + args: [serverEntrypoint, projectRoot] + } + } + }); }); it('cursor returns a file at .cursor/mcp.json with http type', () => { const result = generateMcpConfig('cursor'); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; - expect(result.path).toBe('.cursor/mcp.json'); + expect(result.path).toBe(path.resolve('.cursor/mcp.json')); const parsed = JSON.parse(result.content) as { mcpServers: { 'codebase-context': { type: string } }; }; expect(parsed.mcpServers['codebase-context'].type).toBe('http'); }); - it('codex returns a command with mcp add', () => { - const result = generateMcpConfig('codex'); - expect(result.kind).toBe('command'); - if (result.kind !== 'command') return; - expect(result.args).toContain('mcp'); - expect(result.args).toContain('add'); - expect(result.args).toContain('codebase-context'); - expect(result.args).toContain('http://127.0.0.1:3100/mcp'); + it('Codex gets a project TOML config that passes paths containing spaces safely', () => { + const result = generateMcpConfig('codex', { + rootPath: projectRoot, + nodeExecutable, + serverEntrypoint + }); + + expect(result.kind).toBe('file'); + if (result.kind !== 'file') return; + expect(result.path).toBe(path.join(projectRoot, '.codex', 'config.toml')); + expect(result.content).toBe( + `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(nodeExecutable)}\nargs = [${JSON.stringify(serverEntrypoint)}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` + ); }); it('opencode returns a file at opencode.json with remote type', () => { const result = generateMcpConfig('opencode'); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; - expect(result.path).toBe('opencode.json'); + expect(result.path).toBe(path.resolve('opencode.json')); const parsed = JSON.parse(result.content) as { mcp: { 'codebase-context': { type: string } }; }; @@ -69,6 +96,32 @@ describe('generateMcpConfig', () => { }); }); +describe('resolveInitRoot', () => { + it('uses explicit root before CODEBASE_ROOT and cwd', () => { + expect( + resolveInitRoot(['--root', 'C:/projects/Explicit Project'], 'C:/projects/Env', 'C:/other') + ).toBe(path.resolve('C:/projects/Explicit Project')); + }); + + it('uses CODEBASE_ROOT when no explicit root is supplied', () => { + expect(resolveInitRoot([], 'C:/projects/Env Project', 'C:/other')).toBe( + path.resolve('C:/projects/Env Project') + ); + }); + + it('uses cwd when neither root override is supplied', () => { + expect(resolveInitRoot([], undefined, 'C:/projects/Current Project')).toBe( + path.resolve('C:/projects/Current Project') + ); + }); + + it('treats an empty CODEBASE_ROOT as unset', () => { + expect(resolveInitRoot([], '', 'C:/projects/Current Project')).toBe( + path.resolve('C:/projects/Current Project') + ); + }); +}); + // --- generateInstructionBlock --- describe('generateInstructionBlock', () => { @@ -173,7 +226,11 @@ describe('_buildMergedMcpContent', () => { const generated = generateMcpConfig('cursor'); if (generated.kind !== 'file') throw new Error('expected file config'); - const merged = await _buildMergedMcpContent('/test/.cursor/mcp.json', generated.content, 'cursor'); + const merged = await _buildMergedMcpContent( + '/test/.cursor/mcp.json', + generated.content, + 'cursor' + ); const parsed = JSON.parse(merged.content) as { mcpServers: Record; someOtherKey: boolean; @@ -191,7 +248,72 @@ describe('_buildMergedMcpContent', () => { }); }); - it('preserves existing opencode mcp entries and updates codebase-context deterministically', async () => { + it('preserves existing Claude Code mcpServers entries and adds codebase-context', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + mcpServers: { + 'existing-server': { type: 'stdio', command: 'node', args: ['server.js'] } + }, + someOtherKey: true + }) as unknown as Buffer + ); + + const generated = generateMcpConfig('claude-code', { + rootPath: path.resolve('C:/projects/app'), + nodeExecutable: 'C:/node.exe', + serverEntrypoint: path.resolve('C:/cbc/index.js') + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + const merged = await _buildMergedMcpContent( + '/test/.mcp.json', + generated.content, + 'claude-code' + ); + const parsed = JSON.parse(merged.content) as { + mcpServers: Record; + someOtherKey: boolean; + }; + + expect(merged.mergedFromExisting).toBe(true); + expect(parsed.someOtherKey).toBe(true); + expect(parsed.mcpServers['existing-server']).toEqual({ + type: 'stdio', + command: 'node', + args: ['server.js'] + }); + expect(parsed.mcpServers['codebase-context']).toEqual({ + command: 'C:/node.exe', + args: [path.resolve('C:/cbc/index.js'), path.resolve('C:/projects/app')] + }); + }); + + it('refuses to overwrite a disabled Claude Code codebase-context entry', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + mcpServers: { + 'codebase-context': { + type: 'stdio', + command: 'node', + args: ['server.js'], + disabled: true + } + } + }) as unknown as Buffer + ); + const generated = generateMcpConfig('claude-code', { + rootPath: path.resolve('C:/projects/app'), + nodeExecutable: 'C:/node.exe', + serverEntrypoint: path.resolve('C:/cbc/index.js') + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + await expect( + _buildMergedMcpContent('/test/.mcp.json', generated.content, 'claude-code') + ).rejects.toThrow(/already contains a different codebase-context configuration/i); + }); + + it('refuses to overwrite an existing conflicting OpenCode codebase-context entry', async () => { readFileMock.mockResolvedValue( JSON.stringify({ $schema: 'https://opencode.ai/config.json', @@ -206,21 +328,276 @@ describe('_buildMergedMcpContent', () => { const generated = generateMcpConfig('opencode'); if (generated.kind !== 'file') throw new Error('expected file config'); - const merged = await _buildMergedMcpContent('/test/opencode.json', generated.content, 'opencode'); - const parsed = JSON.parse(merged.content) as { - mcp: Record; - extra: string; + await expect( + _buildMergedMcpContent('/test/opencode.json', generated.content, 'opencode') + ).rejects.toThrow(/already contains a different codebase-context configuration/i); + }); +}); + +describe('handleInitCli', () => { + 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' })); + }); + + it('writes only the selected project config in non-interactive mode', async () => { + const projectRoot = path.resolve('C:/projects/Configured App'); + + await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); + + expect(select).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(writeFileMock).toHaveBeenCalledOnce(); + const [writtenPath, writtenContent] = writeFileMock.mock.calls[0] as [ + string, + string, + ...unknown[] + ]; + expect(writtenPath).toBe(path.join(projectRoot, '.mcp.json')); + const parsed = JSON.parse(writtenContent) as { + mcpServers: { 'codebase-context': { args: string[] } }; }; + expect(parsed.mcpServers['codebase-context'].args.at(-1)).toBe(projectRoot); + expect(mkdirMock).toHaveBeenCalledWith(projectRoot, { recursive: true }); + }); + + it('writes a project Codex TOML file when Codex is selected non-interactively', async () => { + const projectRoot = path.resolve('C:/projects/Codex App'); + + await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + + expect(writeFileMock).toHaveBeenCalledOnce(); + const [writtenPath, writtenContent] = writeFileMock.mock.calls[0] as [ + string, + string, + ...unknown[] + ]; + expect(writtenPath).toBe(path.join(projectRoot, '.codex', 'config.toml')); + expect(writtenContent).toContain('[mcp_servers.codebase-context]'); + expect(writtenContent).toContain(`args = [`); + expect(writtenContent).toContain(JSON.stringify(projectRoot)); + expect(mkdirMock).toHaveBeenCalledWith(path.join(projectRoot, '.codex'), { recursive: true }); + }); + + it('uses cwd as the project when init is run without a root override', async () => { + const previousEnvRoot = process.env.CODEBASE_ROOT; + delete process.env.CODEBASE_ROOT; + + try { + await handleInitCli(['--client', 'codex', '--yes']); + } finally { + if (previousEnvRoot === undefined) delete process.env.CODEBASE_ROOT; + else process.env.CODEBASE_ROOT = previousEnvRoot; + } + + const [writtenPath, writtenContent] = writeFileMock.mock.calls[0] as [ + string, + string, + ...unknown[] + ]; + expect(writtenPath).toBe(path.join(process.cwd(), '.codex', 'config.toml')); + expect(writtenContent).toContain(JSON.stringify(path.resolve(process.cwd()))); + }); + + it('does not overwrite a conflicting project MCP entry', async () => { + const projectRoot = path.resolve('C:/projects/Configured App'); + readFileMock.mockResolvedValue( + JSON.stringify({ + mcpServers: { + 'codebase-context': { disabled: true, command: 'old-server' } + } + }) as unknown as Buffer + ); + const previousExitCode = process.exitCode; + + await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); + + it('does not create a project config when the selected root is missing', async () => { + const projectRoot = path.resolve('C:/projects/Missing App'); + accessMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + const previousExitCode = process.exitCode; + + await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + }); + + it('shows init help without prompting or writing files', async () => { + await handleInitCli(['--help']); + + expect(select).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('keeps the interactive preview read-only when the user declines', async () => { + vi.mocked(select).mockResolvedValue('claude-code'); + vi.mocked(confirm).mockResolvedValue(false); + + await handleInitCli([]); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); +}); + +describe('_buildMergedCodexContent', () => { + const readFileMock = vi.mocked(fsMod.readFile); + const writeFileMock = vi.mocked(fsMod.writeFile); + const mkdirMock = vi.mocked(fsMod.mkdir); + + beforeEach(() => { + vi.resetAllMocks(); + writeFileMock.mockResolvedValue(undefined); + mkdirMock.mockResolvedValue(undefined); + readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + }); + + const runCodexSetupWithExistingConfig = async (existingToml: string) => { + const projectRoot = path.resolve('C:/projects/Codex Existing'); + const initialBytes = Buffer.from(existingToml, 'utf8'); + let persistedBytes = Buffer.from(initialBytes); + readFileMock.mockResolvedValue(existingToml as unknown as Buffer); + writeFileMock.mockImplementation(async (_filePath, content) => { + persistedBytes = Buffer.from(content as string, 'utf8'); + }); + + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + return { initialBytes, persistedBytes, exitCode: process.exitCode }; + } finally { + process.exitCode = previousExitCode; + } + }; + + it('preserves unrelated Codex TOML settings when adding the project server', async () => { + readFileMock.mockResolvedValue( + '[model]\nmodel = "gpt-example"\n\n[mcp_servers.other]\ncommand = "other"\n' as unknown as Buffer + ); + const generated = generateMcpConfig('codex', { + rootPath: 'C:/projects/app', + nodeExecutable: 'C:/node.exe', + serverEntrypoint: 'C:/cbc/index.js' + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + const merged = await _buildMergedCodexContent(generated.path, generated.content); expect(merged.mergedFromExisting).toBe(true); - expect(parsed.extra).toBe('value'); - expect(parsed.mcp['existing-server']).toEqual({ - type: 'remote', - url: 'http://127.0.0.1:5000/mcp' + expect(merged.content).toContain('[model]\nmodel = "gpt-example"'); + expect(merged.content).toContain('[mcp_servers.other]\ncommand = "other"'); + expect(merged.content).toContain('[mcp_servers.codebase-context]'); + }); + + it('leaves the same Codex codebase-context entry unchanged on a repeated setup', async () => { + const generated = generateMcpConfig('codex', { + rootPath: path.resolve('C:/projects/app'), + nodeExecutable: 'C:/node.exe', + serverEntrypoint: path.resolve('C:/cbc/index.js') }); - expect(parsed.mcp['codebase-context']).toEqual({ - type: 'remote', - url: 'http://127.0.0.1:3100/mcp' + if (generated.kind !== 'file') throw new Error('expected file config'); + readFileMock.mockResolvedValue(generated.content as unknown as Buffer); + + const merged = await _buildMergedCodexContent(generated.path, generated.content); + + expect(merged).toEqual({ content: generated.content, mergedFromExisting: true }); + }); + + it('treats quoted table keys as the same idempotent Codex entry', async () => { + const generated = generateMcpConfig('codex', { + rootPath: path.resolve('C:/projects/app'), + nodeExecutable: 'C:/node.exe', + serverEntrypoint: path.resolve('C:/cbc/index.js') + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + const existing = generated.content.replace( + '[mcp_servers.codebase-context]', + '["mcp_servers"."codebase-context"]' + ); + readFileMock.mockResolvedValue(existing as unknown as Buffer); + + const merged = await _buildMergedCodexContent(generated.path, generated.content); + + expect(merged).toEqual({ content: existing, mergedFromExisting: true }); + }); + + it('refuses a conflicting or disabled Codex codebase-context entry', async () => { + readFileMock.mockResolvedValue( + '[mcp_servers.codebase-context]\ndisabled = true\ncommand = "old"\n' as unknown as Buffer + ); + const generated = generateMcpConfig('codex', { + rootPath: 'C:/projects/app', + nodeExecutable: 'C:/node.exe', + serverEntrypoint: 'C:/cbc/index.js' }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + await expect(_buildMergedCodexContent(generated.path, generated.content)).rejects.toThrow( + /already contains a different codebase-context configuration/i + ); }); + + it.each([ + { + name: 'double-quoted parent and child keys', + toml: '["mcp_servers"."codebase-context"]\ncommand = "old"\n' + }, + { + name: 'double-quoted parent and bare child key', + toml: '["mcp_servers".codebase-context]\ncommand = "old"\n' + } + ])( + 'refuses to replace a Codex entry under $name without changing its bytes', + async ({ toml }) => { + const result = await runCodexSetupWithExistingConfig(toml); + + expect(result.exitCode).toBe(1); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(result.persistedBytes).toEqual(result.initialBytes); + } + ); + + it.each([ + { + name: 'inline child in the bare parent table', + toml: '[mcp_servers]\ncodebase-context = { command = "old", args = [] }\n' + }, + { + name: 'dotted child in the double-quoted parent table', + toml: '["mcp_servers"]\ncodebase-context.command = "old"\n' + }, + { + name: 'dotted child in the single-quoted parent table', + toml: "['mcp_servers']\n'codebase-context'.command = \"old\"\n" + }, + { + name: 'inline root table under a double-quoted key', + toml: '"mcp_servers" = { "codebase-context" = { command = "old", args = [] } }\n' + } + ])( + 'refuses to replace a Codex entry under $name without changing its bytes', + async ({ toml }) => { + const result = await runCodexSetupWithExistingConfig(toml); + + expect(result.exitCode).toBe(1); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(result.persistedBytes).toEqual(result.initialBytes); + } + ); });