From fd8dfe700b54cf7972af381c358a94d95f84c664 Mon Sep 17 00:00:00 2001 From: PatrickSys Date: Fri, 16 Oct 2026 19:16:59 +0200 Subject: [PATCH] fix: preserve existing Codex MCP config during init --- docs/client-setup.md | 13 +- src/cli-init.ts | 495 ++++++++++++++++++++++++------- tests/cli-init-bootstrap.test.ts | 33 +++ tests/cli-init.test.ts | 443 ++++++++++++++++++++++++--- 4 files changed, 833 insertions(+), 151 deletions(-) create mode 100644 tests/cli-init-bootstrap.test.ts diff --git a/docs/client-setup.md b/docs/client-setup.md index 7770e7f..0c156bb 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -101,11 +101,7 @@ On October 6 the exact published `npx` entrypoint was also tested without a fold ## Unreleased installer candidate -The next release is expected to support one project-only command from the target project: - -The `init --client codex --yes` flow is an unreleased source candidate. Published npm `2.2.0` does not include these flags. Use the published configuration above rather than an `@latest` installer command; candidate verification uses an exact built or installed candidate. - -A successful `init --client codex --yes` writes the project-scoped `.codex/config.toml` and prepares the index for that root. If config writing or index preparation fails, setup is incomplete and Codex is not ready to use. +The source `init` command supports project-scoped stdio setup for Claude Code and Codex. Published npm `2.2.0` does not include these flags. For candidate verification before publication, build the source checkout and run its absolute `dist/index.js` path from the project you want to configure: @@ -114,14 +110,15 @@ For candidate verification before publication, build the source checkout and run npm run build # In the target project, using the built CLI path +node C:\path\to\codebase-context\dist\index.js init --client claude-code --yes node C:\path\to\codebase-context\dist\index.js init --client codex --yes ``` -Use `--root C:\path\to\project` when the command is run from elsewhere. Here, `root` means the project folder to configure and index. The candidate writes the project-scoped `/.codex/config.toml`, preserves unrelated Codex settings, prepares the index for that folder, and does not write `AGENTS.md` or global client configuration. After a successful result, trust the project in Codex Desktop if prompted, restart or reopen Codex, start a new task in that project, and ask a small project question. A written config and ready index do not prove that Codex loaded or queried the server. +The project root resolves in this order: `--root`, `CODEBASE_ROOT`, then the current directory. Claude Code writes `/.mcp.json`; Codex writes `/.codex/config.toml`. Both configs launch the CLI entrypoint used for `init` and pass the resolved project root as its positional argument, so the MCP server starts with that project as its bootstrap root. -Before release, the generated `npx` server command still resolves the published package unless the candidate is installed in the target project's `node_modules`. Our packaged test installs the candidate in a disposable project and checks its file hash. Running the built setup command alone does not prove that Codex will launch that same candidate. +`--yes` writes only the MCP config. It does not edit `CLAUDE.md` or `AGENTS.md`, write global client settings, or prepare an index. Existing unrelated client settings are preserved; an existing different `codebase-context` entry stops setup for review. Rerun init if the installed package moves, because the generated config points to the exact entrypoint used during setup. -**Checked on 2026-09-15:** the packed candidate installed and indexed an isolated Windows project. A fresh Codex Desktop task then called the actual `search_codebase` MCP tool and located the requested chart component with its source file and lines. The normal Desktop task flow established project trust; no trust-file edits or replacement CLI/SDK search were used for that answer. This covers the small prepared project with shared caches, not a cold-machine install or the published npm package. +After setup, trust or approve the project server if the client asks, then restart or reopen the client. A written config does not prove that the host loaded the server or that an agent queried it. Prepare the index separately with the same CLI entrypoint's `reindex` command from the project folder when needed. ## 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); + } + ); });