diff --git a/README.md b/README.md index adab80b..ec2bc8b 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,19 @@ Codebase Context gives coding agents a map of your repository through MCP, for developers who want their agent to find relevant code and follow the team's patterns. Search, examples and project memory run locally by default. -## Set up your AI client +## Set up your coding tools **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: +Automatic multi-tool setup is awaiting publication. The unpinned prompt below uses npm's latest release and will support this flow once these changes are released; until then, use the published setup guide above. -```bash -# Claude Code -claude mcp add --scope user --transport stdio codebase-context -- npx -y codebase-context@2.4.0 - -# Codex CLI -codex mcp add codebase-context -- npx -y codebase-context@2.4.0 - -# OpenCode 1.x (keep the quoted separator on Windows) -opencode mcp add codebase-context '--' npx -y codebase-context@2.4.0 -``` +Use Node.js 22 or newer. Copy this prompt to your coding agent: -Start a new agent session in your project, then ask: +> Set up Codebase Context for this repository and any other repositories I explicitly name. Read https://github.com/PatrickSys/codebase-context#readme and the installed init help. Detect my supported coding tools and configure all of them, using `npx --yes codebase-context init --yes` once per repository. Preserve existing settings and instructions; explain any conflicting entry you cannot safely preserve. Add only a short, relevant usage note where this repository keeps agent instructions. Pass each repository's actual directory with --root. Once available, call Codebase Context to find a real source symbol and open its returned file. Report what works; if a tool needs reopening or trust approval, give me that exact next step and do not claim it connected. -> 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. +To set up directly, run `npx --yes codebase-context@2.4.0 init` in your project. It shows Claude Code, Codex, Cursor and OpenCode together, with detected tools selected. Press Space to change the selection and Enter to apply it. With `--yes`, or without a terminal, it configures every detected tool. Use repeatable `--client` options to choose explicitly and `--root` to select another repository. -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. +Each tool starts its own local server when needed. Reopen the tools and approve the server or project trust if asked. The first search builds the repository's index; setup itself does not index or change agent instructions. ### New in 2.4.0 @@ -37,32 +28,7 @@ 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. +The [client setup guide](./docs/client-setup.md) covers detection, reruns, manual recipes for Gemini, GitHub Copilot and Windsurf, and advanced HTTP configuration. Existing custom entries are preserved and reported as conflicts. Config placement alone does not prove a client connected. ## What your agent gets diff --git a/docs/cli.md b/docs/cli.md index 88d5b96..9e7fe76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,7 +46,7 @@ CODEBASE_CONTEXT_ASCII=1 npx -y codebase-context@2.4.0 patterns - `reindex` — rebuild index (full or incremental) - `style-guide` — find style guide sections in docs - `memory list|add|remove` — manage team memory (stored in `.codebase-context/memory.json`) -- MCP client registration — use the 2.4.0 recipes in the [client setup guide](./client-setup.md); `init` is an interactive HTTP setup wizard with the limitations described there +- MCP client registration — `init` detects Claude Code, Codex, Cursor and OpenCode and configures the selected tools together. Use `init --yes` for all detected tools, repeat `--client ` to choose tools, and pass `--root ` for another repository. See the [client setup guide](./client-setup.md). --- diff --git a/docs/client-setup.md b/docs/client-setup.md index 91e74e2..e237db9 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -2,13 +2,33 @@ **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). -Codebase Context runs as a local MCP server: a tool your AI editor or command-line client can call while it works. Register it without a fixed folder, then let the agent supply its current repository through the `project` tool parameter. These recipes target the 2.4.0 release. +Codebase Context runs as a local MCP server: a tool your AI editor or command-line client can call while it works. Use `init` to configure your supported coding tools for a repository. Each tool starts its own server with that repository selected; the agent can still select another repository with the `project` tool parameter. These recipes target the 2.4.0 release. Use Node.js 22 or newer, as required by the package. The copyable registration commands target PowerShell or a POSIX terminal. Windows Command Prompt was not tested; OpenCode's quoted separator is shell-dependent. -## Client recipes for 2.4.0 +## Set up your coding tools + +Automatic multi-tool setup is awaiting publication. These instructions describe the implementation in this repository; the README's unpinned prompt depends on a published release containing it. + +Run `npx --yes codebase-context@2.4.0 init` from your repository, or copy the agent prompt in the [README](../README.md#set-up-your-coding-tools). + +Setup shows Claude Code, Codex, Cursor and OpenCode together. It checks their executables on PATH and their known user/project directories, respecting `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `XDG_CONFIG_HOME`. It does not read credentials or run tools to detect them. Detected tools start checked; Space toggles a choice and Enter configures the selected tools once. Cancel leaves files unchanged. + +With `--yes`, or without terminal input/output, setup configures every detected tool. If none is found, it writes nothing and asks you to select tools explicitly: + +```bash +npx --yes codebase-context@2.4.0 init --client claude-code --client codex --root /path/to/repository --yes +``` + +`--root` takes priority over `CODEBASE_ROOT` and the current directory. Claude Code writes `.mcp.json`, Codex `.codex/config.toml`, Cursor `.cursor/mcp.json`, and OpenCode `opencode.json` inside that repository. All four start local servers with a version-pinned `npx` command; setup does not start a daemon or depend on the installer's cache location. + +Rerun the same command after moving a repository or updating the package. Identical entries stay unchanged. Setup updates recognized generated launch/root fields and preserves other settings. Custom or HTTP entries are left intact and reported as conflicts; all selected configs are checked before any are written. Resolve a conflict explicitly rather than removing unrelated settings. + +Reopen your tools and approve project trust or the server if asked. The first search builds the index. Setup writes MCP configs only; repository instructions belong to your agent and the README prompt. A successful setup exit does not by itself prove a client connected. + +## Manual client recipes for 2.4.0 Claude Code, Codex CLI and OpenCode registration commands were checked again on October 6 without a project argument. The other clients retain their older config-shape evidence; no-folder native agent use is unverified for them. See the dated notes below. @@ -75,7 +95,7 @@ Equivalent project config: [mcp_servers.codebase-context] command = "npx" args = ["-y", "codebase-context@2.4.0"] -startup_timeout_sec = 120 +startup_timeout_sec = 300 ``` **What was checked:** The command ran with an isolated `CODEX_HOME`, wrote `config.toml`, and `codex mcp get` returned the same command and arguments. @@ -92,7 +112,7 @@ Create or merge `.codex/config.toml` in the project you want to search. Preserve [mcp_servers.codebase-context] command = "npx" args = ["-y", "codebase-context@2.4.0"] -startup_timeout_sec = 120 +startup_timeout_sec = 300 ``` Trust the project if asked, restart Codex and start a new task there. Use the first-use prompt above so the agent selects the current repository. The CLI command in the preceding section writes user-level config; this recipe uses a trusted project's config instead. @@ -101,24 +121,6 @@ Trust the project if asked, restart Codex and start a new task there. Use the fi On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a folder, environment root or MCP roots, launched from an unrelated directory. That check does not verify the `2.4.0` package. Its first tool call returned `selection_required`; an explicit `project` call selected the prepared fixture, search found the expected symbol, context returned its map, and the runner host opened the returned source. A later call without `project` retained that selection. This is SDK/protocol and runner-host evidence with warmed caches and a pre-indexed fixture, not a native Desktop task. -## Interactive setup wizard - -Run setup from the project you want the client to search: - -```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 To set a default project before the first tool call, append its full path to the server arguments, for example `args = ["-y", "codebase-context@2.4.0", "C:/projects/my-app"]`. Use a quoted literal shell argument in registration commands. This makes the default fixed across repositories when the registration is user-scoped; explicit tool selection can still choose another allowed project. @@ -129,8 +131,6 @@ To prepare the index separately, run from the project folder: npx -y codebase-context@2.4.0 reindex ``` -The interactive wizard limitations described above are still present in the 2.4.0 source. A successful wizard exit alone does not establish working client registration. - ## Gemini CLI **Status:** Config written locally diff --git a/src/cli-init.ts b/src/cli-init.ts index bf126cc..3918705 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -1,6 +1,6 @@ /** * Interactive setup wizard for codebase-context. - * Handles `codebase-context init` — generates MCP config and instruction block + * Handles `codebase-context init` — detects clients and merges project MCP configs * for the four first-wave AI clients: Claude Code, Cursor, Codex, OpenCode. * * Depends only on Node built-ins and @inquirer/prompts. @@ -10,96 +10,134 @@ import path from 'path'; import * as fs from 'node:fs/promises'; -import { select, confirm } from '@inquirer/prompts'; +import { checkbox } from '@inquirer/prompts'; +import { homedir } from 'node:os'; +import { constants } from 'node:fs'; +import { createRequire } from 'node:module'; -export type Client = 'claude-code' | 'cursor' | 'codex' | 'opencode'; +export type Client = (typeof CLIENTS)[number]['id']; export type McpConfigResult = { kind: 'file'; path: string; content: string }; export type McpConfigOptions = { rootPath?: string; - nodeExecutable?: string; - serverEntrypoint?: string; + platform?: NodeJS.Platform; + packageVersion?: string; }; type JsonObject = Record; -function quoteTomlString(value: string): string { - return JSON.stringify(value); +const { version: packageVersion } = createRequire(import.meta.url)('../package.json') as { + version: string; +}; + +export const CLIENTS = [ + { id: 'claude-code', name: 'Claude Code', executable: 'claude', directory: '.claude' }, + { id: 'codex', name: 'Codex', executable: 'codex', directory: '.codex' }, + { id: 'cursor', name: 'Cursor', executable: 'cursor', directory: '.cursor' }, + { id: 'opencode', name: 'OpenCode', executable: 'opencode', directory: '.opencode' } +] as const; + +export async function detectClients( + rootPath: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): Promise { + const paths = platform === 'win32' ? path.win32 : path.posix; + const home = env.HOME || env.USERPROFILE || homedir(); + const configHome = env.XDG_CONFIG_HOME || paths.join(home, '.config'); + const searchPath = (env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':'); + const extensions = platform === 'win32' ? ['', '.exe', '.cmd', '.bat', '.ps1'] : ['']; + const detected: Client[] = []; + for (const client of CLIENTS) { + const userDirectory = + client.id === 'codex' + ? env.CODEX_HOME || paths.join(home, '.codex') + : client.id === 'claude-code' + ? env.CLAUDE_CONFIG_DIR || paths.join(home, '.claude') + : client.id === 'opencode' + ? paths.join(configHome, 'opencode') + : paths.join(home, client.directory); + const directories = [paths.join(rootPath, client.directory), userDirectory]; + const executables = searchPath + .filter(Boolean) + .flatMap((directory) => + extensions.map((extension) => paths.join(directory, client.executable + extension)) + ); + const candidates = [ + ...directories.map((candidate) => ({ path: candidate, directory: true })), + ...executables.map((candidate) => ({ path: candidate, directory: false })) + ]; + for (const candidate of candidates) { + try { + await fs.access( + candidate.path, + candidate.directory || platform === 'win32' ? constants.F_OK : constants.X_OK + ); + const info = await fs.stat(candidate.path); + if (candidate.directory ? !info.isDirectory() : !info.isFile()) continue; + detected.push(client.id); + break; + } catch (error) { + if ( + !isFileNotFound(error) && + !( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'EACCES' || error.code === 'ENOTDIR') + ) + ) + throw error; + } + } + } + return detected; } 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: '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: path.join(rootPath, '.cursor', 'mcp.json'), - content: JSON.stringify( - { - mcpServers: { - 'codebase-context': { - type: 'http', - url: 'http://127.0.0.1:3100/mcp' - } - } - }, - null, - 2 - ) - }; - case 'codex': - 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' - }; - case 'opencode': - return { - kind: 'file', - path: path.join(rootPath, 'opencode.json'), - content: JSON.stringify( - { + const version = options.packageVersion ?? packageVersion; + const windows = (options.platform ?? process.platform) === 'win32'; + const command = windows ? 'npx.cmd' : 'npx'; + const args = ['--yes', `codebase-context@${version}`, rootPath]; + if (client === 'codex') { + return { + kind: 'file', + path: path.join(rootPath, '.codex', 'config.toml'), + content: + `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(command)}\n` + + `args = [${args.map((value) => JSON.stringify(value)).join(', ')}]\nstartup_timeout_sec = 300\n` + }; + } + const configPath = + client === 'claude-code' + ? '.mcp.json' + : client === 'cursor' + ? '.cursor/mcp.json' + : 'opencode.json'; + return { + kind: 'file', + path: path.join(rootPath, configPath), + content: JSON.stringify( + client === 'opencode' + ? { $schema: 'https://opencode.ai/config.json', - mcp: { + mcp: { 'codebase-context': { type: 'local', command: [command, ...args] } } + } + : { + mcpServers: { 'codebase-context': { - type: 'remote', - url: 'http://127.0.0.1:3100/mcp' + ...(client === 'cursor' ? { type: 'stdio' } : {}), + command, + args } } }, - null, - 2 - ) - }; - } + null, + 2 + ) + }; } export function resolveInitRoot( @@ -130,58 +168,6 @@ export function resolveInitRoot( return path.resolve(explicitRoot ?? (envRoot ? envRoot : cwd)); } -export function generateInstructionBlock(): string { - return ` -## Codebase Context (MCP) - -**Start of every task:** Call \`get_memory\` to load team conventions before writing any code. - -**Before editing existing code:** Call \`search_codebase\` with \`intent: "edit"\`. If the preflight card says \`ready: false\`, read the listed files before touching anything. - -**Before writing new code:** Call \`get_team_patterns\` to check how the team handles DI, state, testing, and library wrappers — don't introduce a new pattern if one already exists. - -**When asked to "remember" or "record" something:** Call \`remember\` immediately, before doing anything else. - -**When adding imports that cross module boundaries:** Call \`detect_circular_dependencies\` with the relevant scope after adding the import. - -`; -} - -export function resolveInstructionFilePath(client: Client, cwd: string): string | null { - switch (client) { - case 'claude-code': - return path.join(cwd, 'CLAUDE.md'); - case 'cursor': - return path.join(cwd, '.cursorrules'); - case 'codex': - return path.join(cwd, 'AGENTS.md'); - case 'opencode': - return null; - } -} - -/** - * Appends the instruction block to an existing file, or skips if already present. - * Exported with leading underscore to signal internal/test-only use. - */ -export async function _appendInstructionBlock(filePath: string): Promise<'written' | 'skipped'> { - let existing = ''; - try { - existing = await fs.readFile(filePath, 'utf8'); - } catch { - // file does not exist — write fresh - await fs.writeFile(filePath, generateInstructionBlock(), 'utf8'); - return 'written'; - } - - if (existing.includes('')) { - return 'skipped'; - } - - await fs.writeFile(filePath, existing + '\n' + generateInstructionBlock(), 'utf8'); - return 'written'; -} - function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -217,9 +203,58 @@ async function readExistingConfig(filePath: string): Promise { } } +function isAbsolutePath(value: string): boolean { + return path.posix.isAbsolute(value) || path.win32.isAbsolute(value); +} + +/** Recognize only CBC-specific launch shapes written by init. */ +function isGeneratedLaunch(command: unknown, args: unknown): boolean { + if ( + typeof command !== 'string' || + !Array.isArray(args) || + !args.every((arg) => typeof arg === 'string') + ) + return false; + const executable = path.basename(command.replace(/\\/g, '/')).toLowerCase(); + if (executable === 'node' || executable === 'node.exe') { + return ( + args.length === 2 && + /[\\/]node_modules[\\/]codebase-context[\\/]dist[\\/]index\.js$/.test(args[0]) && + isAbsolutePath(args[0]) && + isAbsolutePath(args[1]) + ); + } + const launch = command === 'npx' || command === 'npx.cmd' ? args : []; + return ( + launch.length === 3 && + (launch[0] === '--yes' || launch[0] === '-y') && + /^codebase-context@\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(launch[1]) && + isAbsolutePath(launch[2]) + ); +} + +function mergeGeneratedServer(existing: unknown, generated: unknown, client: Client): unknown { + if (jsonValuesEqual(existing, generated)) return existing; + if (!isJsonObject(existing) || !isJsonObject(generated) || 'url' in existing) return null; + const local = client === 'opencode'; + const command = local && Array.isArray(existing.command) ? existing.command[0] : existing.command; + const args = local && Array.isArray(existing.command) ? existing.command.slice(1) : existing.args; + if ( + (existing.type !== undefined && existing.type !== (local ? 'local' : 'stdio')) || + !isGeneratedLaunch(command, args) + ) + return null; + return { + ...existing, + command: generated.command, + ...(!local ? { args: generated.args } : {}), + ...(generated.type ? { type: generated.type } : {}) + }; +} + /** * Merge generated MCP config into an existing JSON config file when possible. - * Preserves unrelated keys and refuses to replace a different codebase-context entry. + * Preserves unrelated keys and updates only recognized generated launches. */ export async function _buildMergedMcpContent( filePath: string, @@ -262,28 +297,21 @@ export async function _buildMergedMcpContent( 'codebase-context' ); const generatedCodebaseContext = generatedServersValue['codebase-context']; - if ( - hasExistingCodebaseContext && - !jsonValuesEqual(existingServers['codebase-context'], generatedCodebaseContext) - ) { + const mergedServer = hasExistingCodebaseContext + ? mergeGeneratedServer(existingServers['codebase-context'], generatedCodebaseContext, client) + : generatedCodebaseContext; + if (mergedServer === null) { throw new Error( `Cannot merge ${client} config at ${filePath}: it already contains a different codebase-context configuration.` ); } - + const merged = { + ...generated, + ...existing, + [serverKey]: { ...existingServers, 'codebase-context': mergedServer } + }; return { - content: JSON.stringify( - { - ...existing, - ...generated, - [serverKey]: { - ...existingServers, - ...generatedServersValue - } - }, - null, - 2 - ), + content: jsonValuesEqual(existing, merged) ? existingText : JSON.stringify(merged, null, 2), mergedFromExisting: true }; } @@ -297,8 +325,7 @@ function tomlKeyPattern(key: string): string { } /** - * Appends the project MCP table to Codex TOML, preserving other settings and - * refusing to replace an existing codebase-context table. + * Merges the generated Codex launch, preserving unrelated settings and custom entries. */ export async function _buildMergedCodexContent( filePath: string, @@ -333,13 +360,62 @@ export async function _buildMergedCodexContent( if (comparableSection === normalizedGenerated) { return { content: existing, mergedFromExisting: true }; } + const commandLine = /^([ \t]*command[ \t]*=[ \t]*)("(?:[^"\\]|\\.)*")([ \t]*(?:#.*)?)$/m; + const argsLine = /^([ \t]*args[ \t]*=[ \t]*)(\[[^\r\n]*\])([ \t]*(?:#.*)?)$/m; + const oldCommand = commandLine.exec(section); + const oldArgs = argsLine.exec(section); + const newCommand = commandLine.exec(normalizedGenerated); + const newArgs = argsLine.exec(normalizedGenerated); + if ( + exactEntryHeader.test(section) && + oldCommand && + oldArgs && + newCommand && + newArgs && + !/^[ \t]*url[ \t]*=/m.test(section) && + section.match(/^[ \t]*command[ \t]*=/gm)?.length === 1 && + section.match(/^[ \t]*args[ \t]*=/gm)?.length === 1 + ) { + try { + if (isGeneratedLaunch(JSON.parse(oldCommand[2]), JSON.parse(oldArgs[2]))) { + if ( + jsonValuesEqual(JSON.parse(oldCommand[2]), JSON.parse(newCommand[2])) && + jsonValuesEqual(JSON.parse(oldArgs[2]), JSON.parse(newArgs[2])) + ) { + return { content: existing, mergedFromExisting: true }; + } + const updated = normalizedExisting + .slice(existingEntry.index, sectionEnd) + .replace( + commandLine, + (_line, prefix: string, _value: string, comment: string) => + prefix + newCommand[2] + comment + ) + .replace( + argsLine, + (_line, prefix: string, _value: string, comment: string) => + prefix + newArgs[2] + comment + ); + const content = + normalizedExisting.slice(0, existingEntry.index) + + updated + + normalizedExisting.slice(sectionEnd); + return { + content: existing.includes('\r\n') ? content.replace(/\n/g, '\r\n') : content, + mergedFromExisting: true + }; + } + } catch { + // Non-generated TOML values remain untouched and are reported as conflicts. + } + } throw new Error( `Cannot merge Codex config at ${filePath}: it already contains a different codebase-context configuration.` ); } const dottedEntry = new RegExp( - `^\\s*${mcpServersKey}\\s*\\.\\s*${codebaseContextKey}\\s*\\.`, + `^\\s*${mcpServersKey}\\s*\\.\\s*${codebaseContextKey}\\s*(?:=|\\.)`, 'm' ); if (dottedEntry.test(normalizedExisting)) { @@ -384,51 +460,40 @@ export async function _buildMergedCodexContent( } type InitOptions = { - client?: Client; + clients: 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.`); + const client = CLIENTS.find((client) => client.id === value); + if (client) return client.id; + throw new Error( + `Unknown client "${value}". Choose ${CLIENTS.map((client) => client.id).join(', ')}.` + ); } function parseInitOptions(args: string[]): InitOptions { - if (args.includes('--help') || args.includes('-h')) { - return { rootPath: process.cwd(), yes: false, help: true }; - } - - let client: Client | undefined; + if (args.includes('--help') || args.includes('-h')) + return { clients: [], rootPath: process.cwd(), yes: false, help: true }; + const clients: Client[] = []; 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 { - throw new Error(`Unknown init option "${arg}".`); - } + if (arg === '--yes' || arg === '-y') yes = true; + else if (arg === '--client') { + const value = args[++index]; + if (!value || value.startsWith('--')) throw new Error('Expected a client after --client.'); + clients.push(parseClient(value)); + } else if (arg?.startsWith('--client=')) + clients.push(parseClient(arg.slice('--client='.length))); + else if (arg === '--root') index++; + else if (!arg?.startsWith('--root=')) throw new Error(`Unknown init option "${arg}".`); } - return { - ...(client ? { client } : {}), - rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT, process.cwd()), + clients: [...new Set(clients)], + rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT), yes, help: false }; @@ -436,131 +501,83 @@ function parseInitOptions(args: string[]): InitOptions { 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( + 'Detect and configure coding tools together. With a terminal, Space toggles and Enter applies.' + ); + console.log( + ' --client Choose a tool (repeatable): ' + + CLIENTS.map((client) => client.id).join(', ') + ); console.log(' --root Project root (default: CODEBASE_ROOT or cwd)'); - console.log(' --yes, -y Write the MCP config without prompts'); + console.log(' --yes, -y Configure every detected tool 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({ - message: 'Write instruction block? [y/N]', - default: false - }); - } + const options = parseInitOptions(args); + if (options.help) { + printInitUsage(); + return; + } + if (!(await fs.stat(options.rootPath)).isDirectory()) + throw new Error(`Project root is not a directory: ${options.rootPath}`); + console.log(`Set up Codebase Context in ${options.rootPath}`); + let clients = options.clients; + if (!clients.length) { + const detected = await detectClients(options.rootPath); + clients = + !options.yes && process.stdin.isTTY && process.stdout.isTTY + ? await checkbox({ + message: 'Choose tools (Space toggles, Enter sets up):', + choices: CLIENTS.map((client) => ({ + name: client.name + (detected.includes(client.id) ? ' (detected)' : ''), + value: client.id, + checked: detected.includes(client.id) + })) + }) + : detected; + } + if (!clients.length) + throw new Error( + 'No coding tools selected or detected. Choose with --client claude-code, --client codex, --client cursor, or --client opencode.' + ); - 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 = + const prepared = []; + for (const client of clients) { + const config = generateMcpConfig(client, { rootPath: options.rootPath }); + const merged = 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}`); + ? await _buildMergedCodexContent(config.path, config.content) + : await _buildMergedMcpContent(config.path, config.content, client); + prepared.push({ + ...config, + content: merged.content, + unchanged: (await readExistingConfig(config.path)) === merged.content + }); } - - 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}`); + for (const config of prepared) { + if (!config.unchanged) { + await fs.mkdir(path.dirname(config.path), { recursive: true }); + await fs.writeFile(config.path, config.content, 'utf8'); } + console.log( + `${config.unchanged ? 'Unchanged' : 'Configured'}: ${path.relative(options.rootPath, config.path)}` + ); } + console.log('Reopen your tools; approve the server or project trust if asked.'); + console.log("The first search builds this repository's index."); } catch (error) { + if ( + error instanceof Error && + (error.name === 'ExitPromptError' || + error.name === 'AbortPromptError' || + error.name === 'CancelPromptError') + ) { + console.log('Setup cancelled; no files written.'); + return; + } console.error(`Init failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; - return; } - - 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/src/cli.ts b/src/cli.ts index 2e5f17e..410e330 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 [--yes] [--client ] Detect and configure your coding tools'); console.log(' map [--json] [--pretty] Conventions map'); console.log(''); console.log('Global flags:'); diff --git a/tests/cli-init-bootstrap.test.ts b/tests/cli-init-bootstrap.test.ts index c16c3be..bb25481 100644 --- a/tests/cli-init-bootstrap.test.ts +++ b/tests/cli-init-bootstrap.test.ts @@ -1,6 +1,14 @@ import path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { describe, expect, it } from 'vitest'; -import { generateMcpConfig } from '../src/cli-init.js'; +import { + detectClients, + generateMcpConfig, + handleInitCli, + _buildMergedMcpContent, + _buildMergedCodexContent +} from '../src/cli-init.js'; import { resolveRootPath } from '../src/index.js'; describe('init bootstrap root', () => { @@ -10,8 +18,8 @@ describe('init bootstrap root', () => { 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') + platform: 'linux', + packageVersion: '9.8.7' }); if (generated.kind !== 'file') throw new Error('expected project MCP config'); @@ -19,7 +27,9 @@ describe('init bootstrap root', () => { mcpServers: { 'codebase-context': { command: string; args: string[] } }; }; const server = parsed.mcpServers['codebase-context']; - process.argv = [server.command, ...server.args]; + expect(server.command).toBe('npx'); + expect(server.args).toEqual(['--yes', 'codebase-context@9.8.7', projectRoot]); + process.argv = ['node', 'codebase-context', server.args.at(-1) ?? '']; process.env.CODEBASE_ROOT = path.resolve('C:/projects/Other App'); try { @@ -31,3 +41,118 @@ describe('init bootstrap root', () => { } }); }); + +describe('init preserves custom launchers', () => { + it('distinguishes executable files from directories while detecting tools', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'cbc-detect-files-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + await fs.mkdir(path.join(bin, 'claude'), { recursive: true }); + await fs.writeFile(path.join(root, '.codex'), 'This is a file, not a tool directory.'); + const env = { + PATH: bin, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config') + }; + try { + expect(await detectClients(root, env)).toEqual([]); + const executable = path.join(bin, process.platform === 'win32' ? 'codex.cmd' : 'codex'); + await fs.writeFile(executable, 'Detection must never execute this file.'); + await fs.chmod(executable, 0o755); + expect(await detectClients(root, env)).toEqual(['codex']); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('leaves a matching Codex launch and extra settings byte-for-byte unchanged', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'cbc-codex-format-')); + const generated = generateMcpConfig('codex', { rootPath: root }); + const original = ( + 'model = "example"\n\n' + + generated.content + + 'custom_label = "keep"\n' + ).replace(/\n/g, '\r\n'); + await fs.mkdir(path.dirname(generated.path), { recursive: true }); + await fs.writeFile(generated.path, original); + try { + await handleInitCli(['--client', 'codex', '--yes', '--root', root]); + expect(await fs.readFile(generated.path, 'utf8')).toBe(original); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + ['claude-code', 'other package'], + ['codex', 'other package'], + ['claude-code', 'custom npx'], + ['codex', 'custom npx'] + ] as const)('preserves %s config with a %s launcher', async (client, kind) => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'cbc-custom-launch-')); + const generated = generateMcpConfig(client, { rootPath: root }); + const launcher = path.join(root, 'node_modules', 'other-package', 'dist', 'index.js'); + const command = kind === 'custom npx' ? path.join(root, 'custom', 'npx.cmd') : 'node'; + const args = + kind === 'custom npx' ? ['--yes', 'codebase-context@2.3.0', root] : [launcher, root]; + const original = + client === 'codex' + ? `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(command)}\nargs = ${JSON.stringify(args)}\nstartup_timeout_sec = 75\n` + : JSON.stringify({ + mcpServers: { + 'codebase-context': { + command, + args, + env: { KEEP: 'yes' } + } + }, + customSetting: true + }); + await fs.mkdir(path.dirname(generated.path), { recursive: true }); + await fs.writeFile(generated.path, original); + try { + const merge = + client === 'codex' + ? _buildMergedCodexContent(generated.path, generated.content) + : _buildMergedMcpContent(generated.path, generated.content, client); + await expect(merge).rejects.toThrow('different codebase-context'); + expect(await fs.readFile(generated.path, 'utf8')).toBe(original); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('writes no selected configs when a later client uses a custom launcher', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'cbc-batch-launch-')); + const claude = path.join(root, '.mcp.json'); + const codex = path.join(root, '.codex', 'config.toml'); + const originalClaude = JSON.stringify({ + mcpServers: { other: { command: 'custom' } }, + customSetting: true + }); + const originalCodex = `[mcp_servers.codebase-context]\ncommand = "node"\nargs = [${JSON.stringify(path.join(root, 'other-package', 'dist', 'index.js'))}, ${JSON.stringify(root)}]\n`; + await fs.mkdir(path.dirname(codex), { recursive: true }); + await fs.writeFile(claude, originalClaude); + await fs.writeFile(codex, originalCodex); + const previousExitCode = process.exitCode; + try { + process.exitCode = undefined; + await handleInitCli([ + '--client', + 'claude-code', + '--client', + 'codex', + '--yes', + '--root', + root + ]); + expect(process.exitCode).toBe(1); + expect(await fs.readFile(claude, 'utf8')).toBe(originalClaude); + expect(await fs.readFile(codex, 'utf8')).toBe(originalCodex); + } finally { + process.exitCode = previousExitCode; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli-init-windows.test.ts b/tests/cli-init-windows.test.ts new file mode 100644 index 0000000..50c765f --- /dev/null +++ b/tests/cli-init-windows.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { generateMcpConfig } from '../src/cli-init.js'; + +describe.skipIf(process.platform !== 'win32')('Windows init launch', () => { + it.each(['An App', 'App&MissingCbcLaunchCommand', 'App%cbc_literal%End'])( + 'passes the repository path %s as one literal argument', + async (name) => { + const directory = await mkdtemp(path.join(tmpdir(), 'cbc-launch-')); + const root = path.join(directory, name); + await writeFile( + path.join(directory, 'npx.cmd'), + '@echo off\r\nnode "%~dp0capture.cjs" %*\r\n' + ); + await writeFile( + path.join(directory, 'capture.cjs'), + 'console.log(JSON.stringify({jsonrpc:"2.0",method:"captured",params:{args:process.argv.slice(2)}}));' + ); + const generated = generateMcpConfig('claude-code', { rootPath: root }); + const server = JSON.parse(generated.content).mcpServers['codebase-context'] as { + command: string; + args: string[]; + }; + const transport = new StdioClientTransport({ + ...server, + env: { PATH: directory + path.delimiter + process.env.PATH, cbc_literal: 'expanded' }, + stderr: 'pipe' + }); + const captured = new Promise((resolve, reject) => { + transport.onmessage = (message) => resolve(message); + transport.onerror = reject; + }); + try { + await transport.start(); + const result = await Promise.race([ + captured, + new Promise((_, reject) => { + const timer = setTimeout( + () => reject(new Error('Launch did not return arguments')), + 3000 + ); + timer.unref(); + }) + ]); + expect(result).toMatchObject({ + params: { args: ['--yes', expect.stringMatching(/^codebase-context@/), root] } + }); + } finally { + await transport.close(); + await rm(directory, { recursive: true, force: true }); + } + } + ); +}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index 8d139a2..1e3b11c 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -1,5 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import path from 'path'; +import { readFileSync } from 'node:fs'; + +const { version: packageVersion } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') +) as { version: string }; // Mock node:fs/promises at the top level so Vitest can hoist it correctly. vi.mock('node:fs/promises', async (importOriginal) => { @@ -7,6 +12,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, access: vi.fn(), + stat: vi.fn(), readFile: vi.fn(), writeFile: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined) @@ -14,18 +20,16 @@ vi.mock('node:fs/promises', async (importOriginal) => { }); vi.mock('@inquirer/prompts', () => ({ - select: vi.fn(), - confirm: vi.fn() + checkbox: vi.fn() })); import * as fsMod from 'node:fs/promises'; -import { confirm, select } from '@inquirer/prompts'; +import { checkbox } from '@inquirer/prompts'; import { + CLIENTS, generateMcpConfig, - generateInstructionBlock, - resolveInstructionFilePath, + detectClients, resolveInitRoot, - _appendInstructionBlock, _buildMergedMcpContent, _buildMergedCodexContent, handleInitCli @@ -35,14 +39,12 @@ import { describe('generateMcpConfig', () => { 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', () => { + it('generates a version-pinned npx.cmd launch for Claude Code on Windows', () => { const result = generateMcpConfig('claude-code', { rootPath: projectRoot, - nodeExecutable, - serverEntrypoint + platform: 'win32', + packageVersion: '9.8.7' }); expect(result.kind).toBe('file'); @@ -51,48 +53,71 @@ describe('generateMcpConfig', () => { expect(JSON.parse(result.content)).toEqual({ mcpServers: { 'codebase-context': { - command: nodeExecutable, - args: [serverEntrypoint, projectRoot] + command: 'npx.cmd', + args: ['--yes', 'codebase-context@9.8.7', projectRoot] } } }); }); - it('cursor returns a file at .cursor/mcp.json with http type', () => { - const result = generateMcpConfig('cursor'); + it('Cursor gets a project stdio config', () => { + const result = generateMcpConfig('cursor', { rootPath: projectRoot, platform: 'linux' }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; - expect(result.path).toBe(path.resolve('.cursor/mcp.json')); + expect(result.path).toBe(path.join(projectRoot, '.cursor', 'mcp.json')); + const parsed = JSON.parse(result.content) as { + mcpServers: { 'codebase-context': { type: string; command: string; args: string[] } }; + }; + expect(parsed.mcpServers['codebase-context']).toEqual({ + type: 'stdio', + command: 'npx', + args: ['--yes', `codebase-context@${packageVersion}`, projectRoot] + }); + }); + + it('defaults to a pinned npx launch using the current platform', () => { + const result = generateMcpConfig('claude-code', { rootPath: projectRoot }); + if (result.kind !== 'file') throw new Error('expected file config'); const parsed = JSON.parse(result.content) as { - mcpServers: { 'codebase-context': { type: string } }; + mcpServers: { 'codebase-context': { command: string; args: string[] } }; }; - expect(parsed.mcpServers['codebase-context'].type).toBe('http'); + const server = parsed.mcpServers['codebase-context']; + const windows = process.platform === 'win32'; + expect(server.command).toBe(windows ? 'npx.cmd' : 'npx'); + expect(server.args).toEqual([ + '--yes', + expect.stringMatching(/^codebase-context@\d+\.\d+\.\d+/), + projectRoot + ]); }); - it('Codex gets a project TOML config that passes paths containing spaces safely', () => { + it('Codex allows a cold package install before connecting', () => { const result = generateMcpConfig('codex', { rootPath: projectRoot, - nodeExecutable, - serverEntrypoint + platform: 'linux', + packageVersion: '9.8.7' }); 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` + `[mcp_servers.codebase-context]\ncommand = "npx"\nargs = ["--yes", "codebase-context@9.8.7", ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 300\n` ); }); - it('opencode returns a file at opencode.json with remote type', () => { - const result = generateMcpConfig('opencode'); + it('OpenCode uses a local stdio command array', () => { + const result = generateMcpConfig('opencode', { rootPath: projectRoot, platform: 'linux' }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; - expect(result.path).toBe(path.resolve('opencode.json')); + expect(result.path).toBe(path.join(projectRoot, 'opencode.json')); const parsed = JSON.parse(result.content) as { - mcp: { 'codebase-context': { type: string } }; + mcp: { 'codebase-context': { type: string; command: string[] } }; }; - expect(parsed.mcp['codebase-context'].type).toBe('remote'); + expect(parsed.mcp['codebase-context']).toEqual({ + type: 'local', + command: ['npx', '--yes', `codebase-context@${packageVersion}`, projectRoot] + }); }); }); @@ -122,90 +147,6 @@ describe('resolveInitRoot', () => { }); }); -// --- generateInstructionBlock --- - -describe('generateInstructionBlock', () => { - it('contains opening and closing delimiters', () => { - const block = generateInstructionBlock(); - expect(block).toContain(''); - expect(block).toContain(''); - }); - - it('contains all five tool call rules', () => { - const block = generateInstructionBlock(); - expect(block).toContain('get_memory'); - expect(block).toContain('search_codebase'); - expect(block).toContain('get_team_patterns'); - expect(block).toContain('remember'); - expect(block).toContain('detect_circular_dependencies'); - }); -}); - -// --- resolveInstructionFilePath --- - -describe('resolveInstructionFilePath', () => { - const cwd = '/test-cwd'; - - it('claude-code resolves to CLAUDE.md', () => { - const result = resolveInstructionFilePath('claude-code', cwd); - expect(result).toBe(path.join(cwd, 'CLAUDE.md')); - }); - - it('cursor resolves to .cursorrules', () => { - const result = resolveInstructionFilePath('cursor', cwd); - expect(result).toBe(path.join(cwd, '.cursorrules')); - }); - - it('codex resolves to AGENTS.md', () => { - const result = resolveInstructionFilePath('codex', cwd); - expect(result).toBe(path.join(cwd, 'AGENTS.md')); - }); - - it('opencode returns null', () => { - const result = resolveInstructionFilePath('opencode', cwd); - expect(result).toBeNull(); - }); -}); - -// --- _appendInstructionBlock file-write behavior --- - -describe('_appendInstructionBlock', () => { - const accessMock = vi.mocked(fsMod.access); - const readFileMock = vi.mocked(fsMod.readFile); - const writeFileMock = vi.mocked(fsMod.writeFile); - - beforeEach(() => { - vi.resetAllMocks(); - writeFileMock.mockResolvedValue(undefined); - }); - - it('appends block when file exists and block not yet present', async () => { - // Simulate file exists (access resolves) and has content without the delimiter - accessMock.mockResolvedValue(undefined); - readFileMock.mockResolvedValue('# existing content' as unknown as Buffer); - - await _appendInstructionBlock('/test/CLAUDE.md'); - - expect(writeFileMock).toHaveBeenCalledOnce(); - const callArgs = writeFileMock.mock.calls[0] as [string, string, ...unknown[]]; - const content = callArgs[1]; - expect(content).toContain('# existing content'); - expect(content).toContain(''); - }); - - it('skips when block already present', async () => { - accessMock.mockResolvedValue(undefined); - readFileMock.mockResolvedValue( - '# existing content\n\nsome block\n' as unknown as Buffer - ); - - const result = await _appendInstructionBlock('/test/CLAUDE.md'); - - expect(result).toBe('skipped'); - expect(writeFileMock).not.toHaveBeenCalled(); - }); -}); - describe('_buildMergedMcpContent', () => { const readFileMock = vi.mocked(fsMod.readFile); @@ -223,7 +164,7 @@ describe('_buildMergedMcpContent', () => { }) as unknown as Buffer ); - const generated = generateMcpConfig('cursor'); + const generated = generateMcpConfig('cursor', { platform: 'linux' }); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedMcpContent( @@ -232,7 +173,7 @@ describe('_buildMergedMcpContent', () => { 'cursor' ); const parsed = JSON.parse(merged.content) as { - mcpServers: Record; + mcpServers: Record; someOtherKey: boolean; }; @@ -242,10 +183,7 @@ describe('_buildMergedMcpContent', () => { type: 'http', url: 'http://127.0.0.1:4000/mcp' }); - expect(parsed.mcpServers['codebase-context']).toEqual({ - type: 'http', - url: 'http://127.0.0.1:3100/mcp' - }); + expect(parsed.mcpServers['codebase-context']).toMatchObject({ type: 'stdio', command: 'npx' }); }); it('preserves existing Claude Code mcpServers entries and adds codebase-context', async () => { @@ -260,8 +198,7 @@ describe('_buildMergedMcpContent', () => { const generated = generateMcpConfig('claude-code', { rootPath: path.resolve('C:/projects/app'), - nodeExecutable: 'C:/node.exe', - serverEntrypoint: path.resolve('C:/cbc/index.js') + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); @@ -283,8 +220,8 @@ describe('_buildMergedMcpContent', () => { 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')] + command: 'npx.cmd', + args: ['--yes', `codebase-context@${packageVersion}`, path.resolve('C:/projects/app')] }); }); @@ -303,8 +240,7 @@ describe('_buildMergedMcpContent', () => { ); const generated = generateMcpConfig('claude-code', { rootPath: path.resolve('C:/projects/app'), - nodeExecutable: 'C:/node.exe', - serverEntrypoint: path.resolve('C:/cbc/index.js') + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); @@ -332,6 +268,164 @@ describe('_buildMergedMcpContent', () => { _buildMergedMcpContent('/test/opencode.json', generated.content, 'opencode') ).rejects.toThrow(/already contains a different codebase-context configuration/i); }); + + it('treats a custom Cursor HTTP entry as a conflict', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + mcpServers: { 'codebase-context': { type: 'http', url: 'https://custom/mcp' } } + }) as unknown as Buffer + ); + const generated = generateMcpConfig('cursor', { rootPath: '/test', platform: 'linux' }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + await expect( + _buildMergedMcpContent('/test/.cursor/mcp.json', generated.content, 'cursor') + ).rejects.toThrow(/already contains a different codebase-context configuration/i); + }); + + it('updates a generated JSON launch when the project root moves and preserves entry extras', async () => { + const oldRoot = path.resolve('C:/projects/Old App'); + const newRoot = path.resolve('C:/projects/New App'); + readFileMock.mockResolvedValue( + JSON.stringify({ + customSetting: true, + mcpServers: { + 'codebase-context': { + command: 'C:/node.exe', + args: ['C:/cache/node_modules/codebase-context/dist/index.js', oldRoot], + customTimeout: 8 + } + } + }) as unknown as Buffer + ); + const generated = generateMcpConfig('claude-code', { + rootPath: newRoot, + platform: 'win32', + packageVersion: '9.8.7' + }); + 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 { + customSetting: boolean; + mcpServers: { 'codebase-context': Record }; + }; + expect(parsed.customSetting).toBe(true); + expect(parsed.mcpServers['codebase-context']).toEqual({ + command: 'npx.cmd', + args: ['--yes', 'codebase-context@9.8.7', newRoot], + customTimeout: 8 + }); + }); + + it('updates a previously generated pinned npx command when the project root moves', async () => { + const oldRoot = path.resolve('C:/projects/Old Pinned App'); + const newRoot = path.resolve('C:/projects/New Pinned App'); + readFileMock.mockResolvedValue( + JSON.stringify({ + mcpServers: { + 'codebase-context': { + command: 'npx', + args: ['--yes', 'codebase-context@2.3.0', oldRoot], + customLabel: 'preserve' + } + } + }) as unknown as Buffer + ); + const generated = generateMcpConfig('claude-code', { + rootPath: newRoot, + platform: 'linux', + packageVersion: '9.8.7' + }); + 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: { 'codebase-context': { command: string; args: string[]; customLabel: string } }; + }; + expect(parsed.mcpServers['codebase-context']).toEqual({ + command: 'npx', + args: ['--yes', 'codebase-context@9.8.7', newRoot], + customLabel: 'preserve' + }); + }); +}); + +describe('detectClients', () => { + const accessMock = vi.mocked(fsMod.access); + const missing = () => Object.assign(new Error('missing'), { code: 'ENOENT' }); + + beforeEach(() => { + vi.resetAllMocks(); + accessMock.mockRejectedValue(missing()); + vi.mocked(fsMod.stat).mockImplementation(async (candidate) => { + const executable = String(candidate) + .replace(/\\/g, '/') + .toLowerCase() + .startsWith('c:/tools/'); + return { isFile: () => executable, isDirectory: () => !executable } as Awaited< + ReturnType + >; + }); + }); + + it('uses one four-client table and detects installed tools through PATH candidates', async () => { + accessMock.mockImplementation(async (candidate) => { + const pathValue = String(candidate).toLowerCase(); + if (pathValue.startsWith('c:\\tools\\') && pathValue.endsWith('codex.exe')) return undefined; + throw missing(); + }); + + const clients = await detectClients('/project', { PATH: 'C:/tools' }, 'win32'); + + expect(CLIENTS.map(({ id }) => id)).toEqual(['claude-code', 'codex', 'cursor', 'opencode']); + expect(clients).toContain('codex'); + expect(clients).not.toContain('claude-code'); + expect( + accessMock.mock.calls.some(([candidate]) => String(candidate).includes('C:\\tools')) + ).toBe(true); + }); + + it.each([ + { + name: 'CODEX_HOME', + env: { PATH: '', CODEX_HOME: 'C:/custom-codex-home' }, + marker: 'custom-codex-home', + client: 'codex' + }, + { + name: 'CLAUDE_CONFIG_DIR', + env: { PATH: '', CLAUDE_CONFIG_DIR: 'C:/custom-claude-config' }, + marker: 'custom-claude-config', + client: 'claude-code' + }, + { + name: 'XDG_CONFIG_HOME', + env: { PATH: '', XDG_CONFIG_HOME: 'C:/custom-xdg-config' }, + marker: 'custom-xdg-config', + client: 'opencode' + } + ])('checks $name when detecting installed clients', async ({ env, marker, client }) => { + accessMock.mockImplementation(async (candidate) => { + if (String(candidate).toLowerCase().includes(marker)) return undefined; + throw missing(); + }); + + const clients = await detectClients('/project', env, 'win32'); + + expect(clients).toContain(client); + expect(accessMock.mock.calls.some(([candidate]) => String(candidate).includes(marker))).toBe( + true + ); + }); }); describe('handleInitCli', () => { @@ -339,118 +433,231 @@ describe('handleInitCli', () => { const writeFileMock = vi.mocked(fsMod.writeFile); const mkdirMock = vi.mocked(fsMod.mkdir); const accessMock = vi.mocked(fsMod.access); + const statMock = vi.mocked(fsMod.stat); + const previousExitCode = process.exitCode; + const previousIsTTY = process.stdin.isTTY; + const previousStdoutIsTTY = process.stdout.isTTY; + + const missing = () => Object.assign(new Error('missing'), { code: 'ENOENT' }); + const detectDirectories = (...names: string[]) => { + accessMock.mockImplementation(async (candidate) => { + const basename = String(candidate).toLowerCase().split(/[\\/]/).at(-1); + if (names.some((name) => basename === (name === 'opencode' ? 'opencode' : `.${name}`))) + return undefined; + throw missing(); + }); + }; beforeEach(() => { vi.resetAllMocks(); writeFileMock.mockResolvedValue(undefined); mkdirMock.mockResolvedValue(undefined); - readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + readFileMock.mockRejectedValue(missing()); + accessMock.mockRejectedValue(missing()); + statMock.mockResolvedValue({ isDirectory: () => true } as Awaited< + ReturnType + >); + process.exitCode = undefined; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); }); - it('writes only the selected project config in non-interactive mode', async () => { + afterAll(() => { + process.exitCode = previousExitCode; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: previousIsTTY }); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: previousStdoutIsTTY + }); + }); + + it('configures all detected clients with --yes', async () => { const projectRoot = path.resolve('C:/projects/Configured App'); + detectDirectories('claude', 'codex'); - await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); + await handleInitCli(['--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 }); + expect(checkbox).not.toHaveBeenCalled(); + expect(writeFileMock.mock.calls.map(([filePath]) => filePath)).toEqual( + expect.arrayContaining([ + path.join(projectRoot, '.mcp.json'), + path.join(projectRoot, '.codex', 'config.toml') + ]) + ); + expect(writeFileMock).toHaveBeenCalledTimes(2); }); - it('writes a project Codex TOML file when Codex is selected non-interactively', async () => { - const projectRoot = path.resolve('C:/projects/Codex App'); + it('configures all detected clients without a terminal even without --yes', async () => { + const projectRoot = path.resolve('C:/projects/No Terminal'); + detectDirectories('cursor', 'opencode'); + + await handleInitCli(['--root', projectRoot]); + + expect(checkbox).not.toHaveBeenCalled(); + expect(writeFileMock.mock.calls.map(([filePath]) => filePath)).toEqual( + expect.arrayContaining([ + path.join(projectRoot, '.cursor', 'mcp.json'), + path.join(projectRoot, 'opencode.json') + ]) + ); + expect(writeFileMock).toHaveBeenCalledTimes(2); + }); + + it('lets repeated --client options override detection', async () => { + const projectRoot = path.resolve('C:/projects/Explicit Clients'); + + await handleInitCli([ + '--client', + 'claude-code', + '--client', + 'codex', + '--yes', + '--root', + projectRoot + ]); + + expect(accessMock).not.toHaveBeenCalled(); + expect(writeFileMock.mock.calls.map(([filePath]) => filePath)).toEqual( + expect.arrayContaining([ + path.join(projectRoot, '.mcp.json'), + path.join(projectRoot, '.codex', 'config.toml') + ]) + ); + expect(writeFileMock).toHaveBeenCalledTimes(2); + }); + + it('reports no detected clients without writing when there is no terminal', async () => { + await handleInitCli(['--yes', '--root', path.resolve('C:/projects/Empty')]); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); - await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + it('shows detected clients checked in the terminal multi-select', async () => { + detectDirectories('claude', 'codex'); + vi.mocked(checkbox).mockResolvedValue(['claude-code']); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + await handleInitCli(['--root', path.resolve('C:/projects/Interactive')]); + + const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; + expect(choices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 'claude-code', checked: true }), + expect.objectContaining({ value: 'codex', checked: true }), + expect.objectContaining({ value: 'cursor', checked: false }), + expect.objectContaining({ value: 'opencode', checked: false }) + ]) + ); 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; - } + it('leaves all no-detection terminal choices unchecked and writes nothing on empty selection', async () => { + vi.mocked(checkbox).mockResolvedValue([]); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + + await handleInitCli(['--root', path.resolve('C:/projects/Cancelled')]); - 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()))); + const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; + expect(choices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 'claude-code', checked: false }), + expect.objectContaining({ value: 'codex', checked: false }), + expect.objectContaining({ value: 'cursor', checked: false }), + expect.objectContaining({ value: 'opencode', checked: false }) + ]) + ); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); }); - 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 + it('does not write when the terminal prompt throws its cancellation error', async () => { + detectDirectories('claude'); + vi.mocked(checkbox).mockRejectedValue( + Object.assign(new Error('User force closed the prompt'), { name: 'ExitPromptError' }) ); - const previousExitCode = process.exitCode; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); - await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); + await handleInitCli(['--root', path.resolve('C:/projects/Prompt Cancelled')]); + expect(writeFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('requires an existing directory for --root', async () => { + statMock.mockResolvedValue({ isDirectory: () => false } as Awaited< + ReturnType + >); + + await handleInitCli(['--client', 'codex', '--yes', '--root', path.resolve('C:/projects/File')]); + + expect(mkdirMock).not.toHaveBeenCalled(); 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; + it('does not create configs when --root does not exist', async () => { + statMock.mockRejectedValue(missing()); - await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + await handleInitCli([ + '--client', + 'codex', + '--yes', + '--root', + path.resolve('C:/projects/Missing Directory') + ]); 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']); + it('leaves an identical generated config untouched', async () => { + const projectRoot = path.resolve('C:/projects/Already Configured'); + const generated = generateMcpConfig('claude-code', { rootPath: projectRoot }); + readFileMock.mockResolvedValue(generated.content as unknown as Buffer); + + await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); - 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); + it('prepares every selected config before writing when a later client conflicts', async () => { + const projectRoot = path.resolve('C:/projects/Batch Conflict'); + const opencodePath = path.join(projectRoot, 'opencode.json'); + readFileMock.mockImplementation(async (filePath) => { + if (String(filePath) === opencodePath) { + return JSON.stringify({ + mcp: { 'codebase-context': { type: 'remote', url: 'https://custom' } } + }) as unknown as Buffer; + } + throw missing(); + }); + + await handleInitCli([ + '--client', + 'claude-code', + '--client', + 'opencode', + '--yes', + '--root', + projectRoot + ]); + + expect(process.exitCode).toBe(1); + expect(mkdirMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); - await handleInitCli([]); + it('shows help without prompting or writing files', async () => { + await handleInitCli(['--help']); + expect(checkbox).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); }); @@ -459,11 +666,15 @@ describe('_buildMergedCodexContent', () => { const readFileMock = vi.mocked(fsMod.readFile); const writeFileMock = vi.mocked(fsMod.writeFile); const mkdirMock = vi.mocked(fsMod.mkdir); + const statMock = vi.mocked(fsMod.stat); beforeEach(() => { vi.resetAllMocks(); writeFileMock.mockResolvedValue(undefined); mkdirMock.mockResolvedValue(undefined); + statMock.mockResolvedValue({ isDirectory: () => true } as Awaited< + ReturnType + >); readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); @@ -492,8 +703,7 @@ describe('_buildMergedCodexContent', () => { ); const generated = generateMcpConfig('codex', { rootPath: 'C:/projects/app', - nodeExecutable: 'C:/node.exe', - serverEntrypoint: 'C:/cbc/index.js' + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); @@ -508,8 +718,7 @@ describe('_buildMergedCodexContent', () => { 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') + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); readFileMock.mockResolvedValue(generated.content as unknown as Buffer); @@ -522,8 +731,7 @@ describe('_buildMergedCodexContent', () => { 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') + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); const existing = generated.content.replace( @@ -543,8 +751,7 @@ describe('_buildMergedCodexContent', () => { ); const generated = generateMcpConfig('codex', { rootPath: 'C:/projects/app', - nodeExecutable: 'C:/node.exe', - serverEntrypoint: 'C:/cbc/index.js' + platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); @@ -553,6 +760,33 @@ describe('_buildMergedCodexContent', () => { ); }); + it('updates a generated Codex launch for a moved root while preserving custom settings', async () => { + const oldRoot = path.resolve('C:/projects/Old Codex App'); + const newRoot = path.resolve('C:/projects/New Codex App'); + const existing = + '[mcp_servers.codebase-context]\n' + + 'command = "node"\n' + + `args = ["C:/cache/node_modules/codebase-context/dist/index.js", ${JSON.stringify(oldRoot)}]\n` + + 'startup_timeout_sec = 75\n' + + 'custom_label = "keep me"\n'; + readFileMock.mockResolvedValue(existing as unknown as Buffer); + const generated = generateMcpConfig('codex', { + rootPath: newRoot, + platform: 'win32', + packageVersion: '9.8.7' + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + + const merged = await _buildMergedCodexContent(generated.path, generated.content); + + expect(merged.content).toContain('command = "npx.cmd"'); + expect(merged.content).toContain( + `args = ["--yes", "codebase-context@9.8.7", ${JSON.stringify(newRoot)}]` + ); + expect(merged.content).toContain('startup_timeout_sec = 75'); + expect(merged.content).toContain('custom_label = "keep me"'); + }); + it.each([ { name: 'double-quoted parent and child keys', diff --git a/tests/release-truth-surfaces.test.ts b/tests/release-truth-surfaces.test.ts index b2186a2..8fc349b 100644 --- a/tests/release-truth-surfaces.test.ts +++ b/tests/release-truth-surfaces.test.ts @@ -134,11 +134,11 @@ 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 --yes codebase-context@2.4.0 init' ], [ 'docs/client-setup.md', - 'claude mcp add --scope user --transport stdio codebase-context -- npx -y codebase-context@2.4.0' + 'npx --yes codebase-context@2.4.0 init' ], ['docs/capabilities.md', '| **stdio** (default) |'], ['docs/cli.md', 'CODEBASE_ROOT=/path/to/repo npx -y codebase-context@2.4.0 status'],