diff --git a/README.md b/README.md index aa7f416..c08aa40 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,17 @@ Use Node.js 22 or newer. These commands use the current npm release of Codebase # Claude Code claude mcp add --scope user --transport stdio codebase-context -- npx -y codebase-context -# Codex CLI +# Codex CLI (this is the Codex setup line) codex mcp add codebase-context -- npx -y codebase-context # OpenCode 1.x (keep the quoted separator on Windows) opencode mcp add codebase-context '--' npx -y codebase-context ``` +`npx -y codebase-context --version` prints the installed package version. `npx -y codebase-context map` builds a file index when the project does not have one, then prints the map. A low-confidence search includes a hint: the matches are weak, and that is not a finding that the code is absent. + +This repository's `init` command detects Claude Code, Codex, Cursor, and OpenCode and writes a project config for each selected tool. The saved launch is the Node binary and the package file from the npx cache. That command is in this source and is not part of published 2.2.0. Until it is published, use the copy-paste lines above. The [client setup guide](./docs/client-setup.md) marks the unpublished command. + Start a new agent session in your project, then ask: > 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. diff --git a/docs/cli.md b/docs/cli.md index c561ab9..e23c347 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,7 +36,7 @@ CODEBASE_CONTEXT_ASCII=1 npx -y codebase-context patterns ## Commands -- `map` — conventions map: architecture layers, patterns, golden files +- `map` — conventions map: architecture layers, patterns, golden files. Builds a file index first when the project does not have one, so a fresh checkout does not report 0 files - `metadata` — tech stack overview - `patterns` — team conventions + adoption/trends - `search --query ` — ranked results; add `--intent edit` for a preflight card @@ -46,13 +46,14 @@ CODEBASE_CONTEXT_ASCII=1 npx -y codebase-context patterns - `reindex` — rebuild index (full or incremental) - `style-guide` — find style guide sections in docs - `memory list|add|remove` — manage team memory (stored in `.codebase-context/memory.json`) -- MCP client registration — use the recipes in the [client setup guide](./client-setup.md); `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. See the [client setup guide](./client-setup.md). Codex setup line: `codex mcp add codebase-context -- npx -y codebase-context` +- `--version` — print the installed package version --- ## First use -Run these CLI commands from the project root. Build or refresh the local index first; the first run downloads a local model and indexes the repository; time depends on repository size, CPU and model. Then inspect the conventions map and search for the code you need: +Run these CLI commands from the project root. `map` indexes the project files when no index exists yet. `reindex` builds or refreshes the full index, including local embeddings; the first embedding run downloads a local model. Time depends on repository size, CPU and model. ```bash npx -y codebase-context reindex @@ -69,7 +70,7 @@ npx -y codebase-context reindex npx -y codebase-context map ``` -The conventions map - run this first on an unfamiliar repo. It shows architecture layers, active patterns with adoption rates and trend direction, and the golden files the team treats as the strongest examples. This is also what the MCP server delivers to AI agents via the `codebase://context` resource on first call, before search narrows to a specific local example. +The conventions map - run this first on an unfamiliar repo. If the project has no index, `map` builds the file index before it prints, so the file count is not stuck at 0. It shows architecture layers, active patterns with adoption rates and trend direction, and the golden files the team treats as the strongest examples. This is also what the MCP server delivers to AI agents via the `codebase://context` resource on first call, before search narrows to a specific local example. That automatic index skips embeddings; run `reindex` when you want semantic search. Example output (truncated): @@ -148,6 +149,8 @@ npx -y codebase-context reindex npx -y codebase-context search --query "file watcher" --intent edit --limit 3 ``` +A low-confidence result includes a hint. The hint means the matches are weak. It is not a finding that the code is absent. + Example output (truncated): ```text diff --git a/docs/client-setup.md b/docs/client-setup.md index 4ad2c3e..29498b9 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -103,23 +103,26 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f ## Interactive setup wizard -> **Requires 2.4.0 (not yet published)**: The non-interactive `--client` and `--yes` flags are not available in published 2.2.0 (which runs an interactive HTTP wizard). These commands require the unpublished 2.4.0 release and will not work with the current npm release. +> **Requires 2.4.0 (not yet published)**: One `init` command detects Claude Code, Codex, Cursor, and OpenCode and configures them together. Published 2.2.0 still runs an older interactive wizard. These commands require the unpublished 2.4.0 release and will not work with the current npm release. -Run setup from the project you want the client to search: +Run setup from the project you want the clients to search: ```bash -# Claude Code (requires 2.4.0, not yet published) -npx -y codebase-context@2.4.0 init --client claude-code --yes - -# Codex (requires 2.4.0, not yet published) -npx -y codebase-context@2.4.0 init --client codex --yes +# requires the unpublished 2.4.0 +npx -y codebase-context@2.4.0 init --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. +That writes `.mcp.json` for Claude Code, `.codex/config.toml` for Codex, `.cursor/mcp.json` for Cursor, and `opencode.json` for OpenCode. Each file saves the Node binary that ran `init` and the package file from the npx cache, plus the project folder. The client starts that file directly and does not look up `npx` again. + +Codex setup line, including for the published package: + +```bash +codex mcp add codebase-context -- npx -y codebase-context +``` -`--yes` writes the config without prompts. In 2.2.0, running `init` without flags starts the older interactive wizard. Cursor and OpenCode still use HTTP and need a separately running server. +Repeat `--client` to choose tools, for example `--client claude-code --client codex`. `--root` selects another folder and takes priority over `CODEBASE_ROOT`. With a terminal and without `--yes`, detected tools start checked. -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. +`--yes` writes the config without prompts. Existing settings are kept. A recognized generated launch is updated when you rerun setup, including when the npx cache path moves. A different Codebase Context entry is left unchanged and reported. Restart the client after setup and approve the server if asked. `map` builds the file index when one is missing. ## Optional fixed-folder setup and CLI preparation @@ -131,7 +134,7 @@ To prepare the index separately, run from the project folder: npx -y codebase-context reindex ``` -The interactive wizard limitations described above are still present in the current source. A successful wizard exit alone does not establish working client registration. +A successful setup exit alone does not establish working client registration. ## Gemini CLI diff --git a/src/cli-init.ts b/src/cli-init.ts index bf126cc..0fa873f 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,149 @@ 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'; -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; + /** Node binary written into the client config. Defaults to the current process. */ nodeExecutable?: string; + /** + * Package entrypoint written into the client config. + * When init runs through npx this is the file inside the npx cache. + */ serverEntrypoint?: string; }; type JsonObject = Record; -function quoteTomlString(value: string): string { - return JSON.stringify(value); +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; } +function resolveServerEntrypoint(explicit?: string): string { + if (explicit) return path.resolve(explicit); + const entrypoint = process.argv[1]; + if (!entrypoint) { + throw new Error('Cannot locate the codebase-context CLI entrypoint.'); + } + return path.resolve(entrypoint); +} + +/** + * Launch command saved for every client: the Node binary that ran init, the + * package entrypoint (the npx cache path when init itself was started with npx), + * and the project folder. Clients then start that file directly. + */ 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 nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath); + const serverEntrypoint = resolveServerEntrypoint(options.serverEntrypoint); + const args = [serverEntrypoint, rootPath]; + if (client === 'codex') { + return { + kind: 'file', + path: path.join(rootPath, '.codex', 'config.toml'), + content: + `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(nodeExecutable)}\n` + + `args = [${args.map((value) => JSON.stringify(value)).join(', ')}]\nstartup_timeout_sec = 120\n` + }; + } + const configPath = + client === 'claude-code' + ? '.mcp.json' + : 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: { + 'codebase-context': { type: 'local', command: [nodeExecutable, ...args] } + } + } + : { + mcpServers: { 'codebase-context': { - type: 'remote', - url: 'http://127.0.0.1:3100/mcp' + ...(client === 'cursor' ? { type: 'stdio' } : {}), + command: nodeExecutable, + args } } }, - null, - 2 - ) - }; - } + null, + 2 + ) + }; } export function resolveInitRoot( @@ -130,58 +183,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 +218,82 @@ async function readExistingConfig(filePath: string): Promise { } } +function isAbsolutePath(value: string): boolean { + return path.posix.isAbsolute(value) || path.win32.isAbsolute(value); +} + +function samePath(left: string, right: string): boolean { + const normalize = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + return normalize(left) === normalize(right); +} + +function isNpxCacheEntrypoint(value: string): boolean { + return /[\\/]node_modules[\\/]codebase-context[\\/]dist[\\/]index\.js$/.test(value); +} + +/** + * Recognize only CBC-specific launch shapes written by init. + * A node launch counts when it points at the npx cache copy of this package, + * or at the entrypoint this run is about to save. + */ +function isGeneratedLaunch(command: unknown, args: unknown, entrypoint?: string): 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') { + const script = args[0] ?? ''; + return ( + args.length === 2 && + isAbsolutePath(script) && + isAbsolutePath(args[1] ?? '') && + (isNpxCacheEntrypoint(script) || (entrypoint !== undefined && samePath(script, entrypoint))) + ); + } + 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 generatedEntrypoint(generated: unknown, client: Client): string | undefined { + if (!isJsonObject(generated)) return undefined; + if (client === 'opencode' && Array.isArray(generated.command)) { + return typeof generated.command[1] === 'string' ? generated.command[1] : undefined; + } + return Array.isArray(generated.args) && typeof generated.args[0] === 'string' + ? generated.args[0] + : undefined; +} + +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, generatedEntrypoint(generated, client)) + ) + 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 +336,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 +364,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 +399,69 @@ 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 { + const generatedArgs: unknown = JSON.parse(newArgs[2]); + const generatedEntrypoint = + Array.isArray(generatedArgs) && typeof generatedArgs[0] === 'string' + ? generatedArgs[0] + : undefined; + if ( + isGeneratedLaunch(JSON.parse(oldCommand[2]), JSON.parse(oldArgs[2]), generatedEntrypoint) + ) { + 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 +506,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 +547,87 @@ 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( + 'Each config saves this Node binary and the package file from the npx cache, plus the project folder.' + ); + console.log('Codex setup line: codex mcp add codebase-context -- npx -y codebase-context'); + 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-map.ts b/src/cli-map.ts index 4479afe..8dac640 100644 --- a/src/cli-map.ts +++ b/src/cli-map.ts @@ -19,7 +19,9 @@ import { MEMORY_FILENAME } from './constants/codebase-context.js'; import { createProjectState } from './project-state.js'; +import { CodebaseIndexer } from './core/indexer.js'; import { buildCodebaseMap, renderMapMarkdown, renderMapPretty } from './core/codebase-map.js'; +import { loadProjectConfig } from './server/config.js'; import type { IndexState } from './tools/types.js'; function resolveMapRootPath(): string { @@ -41,6 +43,29 @@ function printMapUsage(): void { console.log('Default output is markdown (pipeable).'); } +async function ensureProjectIndex(rootPath: string, keywordIndex: string): Promise { + try { + await fs.access(keywordIndex); + return; + } catch { + // A missing index is why `map` used to report 0 files on a fresh project. + } + + console.error(`Indexing ${rootPath} before building the map.`); + const projectConfig = await loadProjectConfig(rootPath); + const indexer = new CodebaseIndexer({ + rootPath, + config: { + skipEmbedding: true, + ...(projectConfig?.parsing ? { parsing: projectConfig.parsing } : {}) + } + }); + const stats = await indexer.index(); + console.error( + `Indexed ${stats.indexedFiles} files for the map. Run \`reindex\` to add local embeddings.` + ); +} + export async function handleMapCli(args: string[]): Promise { const useJson = args.includes('--json'); const usePretty = args.includes('--pretty'); @@ -65,12 +90,14 @@ export async function handleMapCli(args: string[]): Promise { vectorDb: path.join(baseDir, VECTOR_DB_DIRNAME) }; + await ensureProjectIndex(rootPath, paths.keywordIndex); + let indexExists = false; try { await fs.access(paths.keywordIndex); indexExists = true; } catch { - // no index yet + // Indexing did not produce a keyword index. The map degrades to an empty project. } const indexState: IndexState = { status: indexExists ? 'ready' : 'idle' }; diff --git a/src/cli.ts b/src/cli.ts index 2e5f17e..4596cf7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,12 +87,16 @@ 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(' map [--json] [--pretty] Conventions map'); + console.log(' init Set up detected AI clients for this project'); + console.log(' map [--json] [--pretty] Conventions map (indexes first if needed)'); console.log(''); console.log('Global flags:'); - console.log(' --json Output raw JSON (default: human-readable)'); - console.log(' --help Show this help'); + console.log(' --json Output raw JSON (default: human-readable)'); + console.log(' --version Print the package version'); + console.log(' --help Show this help'); + console.log(''); + console.log('Codex setup line:'); + console.log(' codex mcp add codebase-context -- npx -y codebase-context'); console.log(''); console.log('Environment:'); console.log(' CODEBASE_ROOT Project root path (default: cwd for CLI only)'); diff --git a/src/core/codebase-map.ts b/src/core/codebase-map.ts index 61a4495..e9754cb 100644 --- a/src/core/codebase-map.ts +++ b/src/core/codebase-map.ts @@ -234,8 +234,20 @@ export async function buildCodebaseMap( })); // --- Graph stats --- + // Prefer recorded stats. When they are missing or say 0 files but the index + // already has files, count those files so the map does not report an empty project. + const countedFiles = new Set([ + ...Object.keys(graph.imports ?? {}), + ...Object.keys(graph.importedBy ?? {}), + ...chunks.flatMap((chunk) => + typeof chunk.relativePath === 'string' && chunk.relativePath.length > 0 + ? [chunk.relativePath] + : [] + ) + ]).size; + const recordedFiles = statsSource.files ?? 0; const graphStats = { - files: statsSource.files ?? 0, + files: recordedFiles > 0 ? recordedFiles : countedFiles, edges: statsSource.edges ?? 0, avgDependencies: statsSource.avgDependencies ?? 0 }; diff --git a/src/core/search-quality.ts b/src/core/search-quality.ts index 49c1938..f7e9e80 100644 --- a/src/core/search-quality.ts +++ b/src/core/search-quality.ts @@ -18,6 +18,9 @@ export function isTestArtifactPath(filePath: string): boolean { ); } +const LOW_CONFIDENCE_HINT = + 'Low confidence: this search is not evidence that the code is missing. Try one concrete symbol, file, or route, or call get_symbol_references.'; + export function assessSearchQuality( query: string, results: SearchResult[] @@ -28,7 +31,7 @@ export function assessSearchQuality( confidence: 0, signals: ['no results returned'], nextSteps: [ - 'Try a narrower query with one concrete symbol, route, or file hint.', + LOW_CONFIDENCE_HINT, 'Apply search filters (framework/language/componentType/layer).', 'Use get_symbol_references to find where a specific symbol is used across the codebase.' ] @@ -74,7 +77,7 @@ export function assessSearchQuality( signals, ...(lowConfidence && { nextSteps: [ - 'Add one or two concrete symbols, routes, or file hints to the query.', + LOW_CONFIDENCE_HINT, 'Apply filters (framework/language/componentType/layer) to narrow candidates.', 'Use get_symbol_references when the question is about where a symbol or function is used.' ] diff --git a/src/index.ts b/src/index.ts index 3e7a989..038f8f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,7 +81,7 @@ let mcpRuntimeReady = false; let mcpRuntimePromise: Promise | undefined; // Flags that are NOT project paths — skip them when resolving the bootstrap root. -const KNOWN_FLAGS = new Set(['--http', '--port', '--help']); +const KNOWN_FLAGS = new Set(['--http', '--port', '--help', '--version', '-v']); const CLI_SUBCOMMANDS = [ 'memory', 'search', @@ -105,7 +105,10 @@ const directSubcommand = process.argv[2]; const isDirectCliSubcommand = isDirectRun && typeof directSubcommand === 'string' && - (CLI_SUBCOMMANDS.includes(directSubcommand) || directSubcommand === '--help'); + (CLI_SUBCOMMANDS.includes(directSubcommand) || + directSubcommand === '--help' || + directSubcommand === '--version' || + directSubcommand === '-v'); // Resolve optional bootstrap root with validation handled later in main(). function resolveRootPath(): string | undefined { @@ -1985,7 +1988,10 @@ if (!isDirectCliSubcommand) { if (isDirectRun) { const subcommand = process.argv[2]; - if (CLI_SUBCOMMANDS.includes(subcommand) || subcommand === '--help') { + if (subcommand === '--version' || subcommand === '-v') { + console.log(PKG_VERSION); + process.exit(0); + } else if (CLI_SUBCOMMANDS.includes(subcommand) || subcommand === '--help') { handleCliCommand(process.argv.slice(2)).catch((error) => { console.error('Error:', error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/tests/cli-init-windows.test.ts b/tests/cli-init-windows.test.ts new file mode 100644 index 0000000..548e27f --- /dev/null +++ b/tests/cli-init-windows.test.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { generateMcpConfig } from '../src/cli-init.js'; + +describe('saved launch arguments', () => { + it.each(['An App', 'App&MissingCbcLaunchCommand', 'App%cbc_literal%End'])( + 'keeps the repository path %s as one argument beside the npx cache path', + (name) => { + const root = path.resolve('C:/projects', name); + const nodeExecutable = path.resolve('C:/Program Files/nodejs/node.exe'); + const cacheEntrypoint = path.resolve( + 'C:/Users/dev/AppData/Local/npm-cache/_npx/hash/node_modules/codebase-context/dist/index.js' + ); + const generated = generateMcpConfig('claude-code', { + rootPath: root, + nodeExecutable, + serverEntrypoint: cacheEntrypoint + }); + if (generated.kind !== 'file') throw new Error('expected file config'); + const server = JSON.parse(generated.content).mcpServers['codebase-context'] as { + command: string; + args: string[]; + }; + + expect(server.command).toBe(nodeExecutable); + expect(server.args).toEqual([cacheEntrypoint, root]); + } + ); +}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index 8d139a2..aeb20eb 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; import path from 'path'; // Mock node:fs/promises at the top level so Vitest can hoist it correctly. @@ -7,6 +7,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 +15,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 @@ -33,17 +32,24 @@ import { // --- generateMcpConfig --- +const nodeExecutable = path.resolve('C:/Program Files/nodejs/node.exe'); +const cacheEntrypoint = path.resolve( + 'C:/npm-cache/_npx/hash/node_modules/codebase-context/dist/index.js' +); + +function savedLaunch(rootPath: string) { + return { + rootPath, + nodeExecutable, + serverEntrypoint: cacheEntrypoint + }; +} + 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', () => { - const result = generateMcpConfig('claude-code', { - rootPath: projectRoot, - nodeExecutable, - serverEntrypoint - }); + + it('saves the npx cache path for Claude Code', () => { + const result = generateMcpConfig('claude-code', savedLaunch(projectRoot)); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; @@ -52,47 +58,62 @@ describe('generateMcpConfig', () => { mcpServers: { 'codebase-context': { command: nodeExecutable, - args: [serverEntrypoint, projectRoot] + args: [cacheEntrypoint, projectRoot] } } }); }); - it('cursor returns a file at .cursor/mcp.json with http type', () => { - const result = generateMcpConfig('cursor'); + it('Cursor gets a project stdio config that saves the npx cache path', () => { + const result = generateMcpConfig('cursor', savedLaunch(projectRoot)); 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: nodeExecutable, + args: [cacheEntrypoint, projectRoot] + }); + }); + + it('defaults to the current Node binary and the running package file', () => { + 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']; + expect(server.command).toBe(path.resolve(process.execPath)); + expect(server.args?.[0]).toBe(path.resolve(process.argv[1] ?? '')); + expect(server.args?.[1]).toBe(projectRoot); }); - it('Codex gets a project TOML config that passes paths containing spaces safely', () => { - const result = generateMcpConfig('codex', { - rootPath: projectRoot, - nodeExecutable, - serverEntrypoint - }); + it('Codex saves the node binary and the npx cache path', () => { + const result = generateMcpConfig('codex', savedLaunch(projectRoot)); 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 = ${JSON.stringify(nodeExecutable)}\nargs = [${JSON.stringify(cacheEntrypoint)}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` ); }); - it('opencode returns a file at opencode.json with remote type', () => { - const result = generateMcpConfig('opencode'); + it('OpenCode uses a local command that saves the npx cache path', () => { + const result = generateMcpConfig('opencode', savedLaunch(projectRoot)); 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: [nodeExecutable, cacheEntrypoint, projectRoot] + }); }); }); @@ -122,90 +143,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 +160,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 +169,7 @@ describe('_buildMergedMcpContent', () => { 'cursor' ); const parsed = JSON.parse(merged.content) as { - mcpServers: Record; + mcpServers: Record; someOtherKey: boolean; }; @@ -242,9 +179,9 @@ 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: path.resolve(process.execPath) }); }); @@ -258,11 +195,10 @@ describe('_buildMergedMcpContent', () => { }) 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') - }); + const generated = generateMcpConfig( + 'claude-code', + savedLaunch(path.resolve('C:/projects/app')) + ); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedMcpContent( @@ -283,8 +219,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: nodeExecutable, + args: [cacheEntrypoint, path.resolve('C:/projects/app')] }); }); @@ -303,8 +239,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 +267,156 @@ 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', savedLaunch(newRoot)); + 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: nodeExecutable, + args: [cacheEntrypoint, 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', savedLaunch(newRoot)); + 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: nodeExecutable, + args: [cacheEntrypoint, 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 +424,239 @@ 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 }); + }); + + afterAll(() => { + process.exitCode = previousExitCode; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: previousIsTTY }); + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: previousStdoutIsTTY + }); }); - it('writes only the selected project config in non-interactive mode', async () => { + 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(['--client', 'codex', '--yes', '--root', projectRoot]); + 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); + }); + + 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 () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + await handleInitCli(['--help']); + expect(log.mock.calls.flat().join('\n')).toContain( + 'codex mcp add codebase-context -- npx -y codebase-context' + ); + } finally { + log.mockRestore(); + } + expect(checkbox).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); }); @@ -459,11 +665,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 +702,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 +717,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 +730,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 +750,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 +759,29 @@ 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', savedLaunch(newRoot)); + if (generated.kind !== 'file') throw new Error('expected file config'); + + const merged = await _buildMergedCodexContent(generated.path, generated.content); + + expect(merged.content).toContain(`command = ${JSON.stringify(nodeExecutable)}`); + expect(merged.content).toContain( + `args = [${JSON.stringify(cacheEntrypoint)}, ${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/cli-map-export.test.ts b/tests/cli-map-export.test.ts index 78dd9cc..54b8e88 100644 --- a/tests/cli-map-export.test.ts +++ b/tests/cli-map-export.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtemp, rm, access, readFile } from 'fs/promises'; +import { mkdtemp, rm, access, readFile, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; import { handleMapCli } from '../src/cli-map.js'; @@ -60,4 +60,20 @@ describe('handleMapCli --export', () => { else process.env.CODEBASE_ROOT = original; } }); + + it('indexes a project that has no index before printing the map', async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'cli-map-index-')); + await writeFile(path.join(tmpDir, 'server.ts'), 'export const port = 3000;\n', 'utf-8'); + const original = process.env.CODEBASE_ROOT; + process.env.CODEBASE_ROOT = tmpDir; + try { + await handleMapCli(['--export']); + await access(path.join(tmpDir, '.codebase-context', 'index.json')); + const content = await readFile(path.join(tmpDir, 'CODEBASE_MAP.md'), 'utf-8'); + expect(content).toMatch(/Files: [1-9]/); + } finally { + if (original === undefined) delete process.env.CODEBASE_ROOT; + else process.env.CODEBASE_ROOT = original; + } + }); }); diff --git a/tests/cli-version.test.ts b/tests/cli-version.test.ts new file mode 100644 index 0000000..c4c56f6 --- /dev/null +++ b/tests/cli-version.test.ts @@ -0,0 +1,26 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const root = resolve(import.meta.dirname, '..'); +const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + version: string; +}; + +describe('version flag', () => { + it('prints the package version and exits', () => { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', resolve(root, 'src/index.ts'), '--version'], + { + cwd: root, + encoding: 'utf8', + timeout: 30_000 + } + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(packageJson.version); + }); +}); diff --git a/tests/codebase-map.test.ts b/tests/codebase-map.test.ts index c90de78..10f5b49 100644 --- a/tests/codebase-map.test.ts +++ b/tests/codebase-map.test.ts @@ -84,15 +84,19 @@ async function removeTempMapProject(rootPath: string): Promise { } function runMapCli(args: string[], rootPath: string) { - return spawnSync(process.execPath, ['--no-deprecation', '--import', 'tsx', ENTRYPOINT, 'map', ...args], { - cwd: CURRENT_REPO_ROOT, - env: { - ...process.env, - CODEBASE_ROOT: rootPath - }, - encoding: 'utf8', - timeout: 120_000 - }); + return spawnSync( + process.execPath, + ['--no-deprecation', '--import', 'tsx', ENTRYPOINT, 'map', ...args], + { + cwd: CURRENT_REPO_ROOT, + env: { + ...process.env, + CODEBASE_ROOT: rootPath + }, + encoding: 'utf8', + timeout: 120_000 + } + ); } // --------------------------------------------------------------------------- @@ -198,6 +202,21 @@ describe('buildCodebaseMap', () => { expect(map.suggestedNextCalls.length).toBeGreaterThan(0); }); + it('counts indexed files when recorded stats say zero', async () => { + const rootPath = await createTempMapProject({ + chunks: [ + { + relativePath: 'src/server.ts', + content: 'export const port = 3000;', + metadata: {} + } as CodeChunk + ], + graph: { stats: { files: 0, edges: 0, avgDependencies: 0 } } + }); + const map = await buildCodebaseMap(createProjectState(rootPath)); + expect(map.graphStats.files).toBe(1); + }); + it('caps suggested next calls at 3', async () => { const project = createProjectState(FIXTURE_ROOT); const map = await buildCodebaseMap(project, { mode: 'full' }); diff --git a/tests/search-quality.test.ts b/tests/search-quality.test.ts index 52adffe..f3703b0 100644 --- a/tests/search-quality.test.ts +++ b/tests/search-quality.test.ts @@ -26,7 +26,7 @@ describe('assessSearchQuality', () => { expect(quality.status).toBe('low_confidence'); expect(quality.confidence).toBe(0); expect(quality.signals).toContain('no results returned'); - expect(quality.nextSteps?.length).toBeGreaterThan(0); + expect(quality.nextSteps?.[0]).toMatch(/not evidence that the code is missing/i); }); it('flags test-artifact dominance for non-testing queries', () => {