From 099c14ecb9041117988a0fcc86cfbdcd29045e50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:44:38 +0000 Subject: [PATCH 1/3] feat(cli): interactive init wizard with npx launch and first index Phase P2 builds on the six-client init work: unified diff previews, tiny-config backups, Claude Desktop detection, npx -y codebase-context@latest launches, daily npm update notice, post-setup indexing with progress, and expanded tests. --- README.md | 28 ++- docs/client-setup.md | 31 +-- docs/demo.md | 2 + docs/demo/install.tape | 19 ++ src/cli-init-preview.ts | 32 +++ src/cli-init-version-notice.ts | 95 ++++++++ src/cli-init.ts | 329 +++++++++++++++++++++----- src/cli.ts | 2 +- tests/cli-init-atomic.test.ts | 20 +- tests/cli-init-backup.test.ts | 36 +++ tests/cli-init-bootstrap.test.ts | 3 +- tests/cli-init-dry-run-home.test.ts | 4 + tests/cli-init-preview.test.ts | 17 ++ tests/cli-init-saved-file.test.ts | 2 +- tests/cli-init-version-notice.test.ts | 67 ++++++ tests/cli-init.test.ts | 172 ++++++++++---- tests/release-truth-surfaces.test.ts | 2 +- 17 files changed, 726 insertions(+), 135 deletions(-) create mode 100644 docs/demo/install.tape create mode 100644 src/cli-init-preview.ts create mode 100644 src/cli-init-version-notice.ts create mode 100644 tests/cli-init-backup.test.ts create mode 100644 tests/cli-init-preview.test.ts create mode 100644 tests/cli-init-version-notice.test.ts diff --git a/README.md b/README.md index 0e62882..980af5f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,24 @@ 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 +## Install (one command) + +Use Node.js 22 or newer. From your project root: + +```bash +npx -y codebase-context@latest init +``` + +The wizard detects Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, OpenCode, and Claude Desktop when present, shows a diff preview for each MCP config change, backs up tiny existing configs as `*.bak`, writes `npx -y codebase-context@latest` plus your project folder, and runs a first index with progress. Non-interactive CI: + +```bash +npx -y codebase-context@latest init --yes --dry-run +npx -y codebase-context@latest init --yes --skip-index +``` + +Pin a version in MCP config instead of `@latest` if you prefer frozen updates. Disable the daily npm update notice with `CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` or `init --no-update-notice`. + +## Set up your AI client manually Use Node.js 22 or newer. These commands use the current npm release of Codebase Context: @@ -21,14 +38,15 @@ opencode mcp add codebase-context '--' npx -y codebase-context In the unreleased candidate, `--version` prints the installed package version and `map` builds a missing index before printing the map. A low-confidence search includes a hint that weak matches do not establish that code is absent. These additions require the built candidate; the unpinned commands above install published 2.2.0. -This repository's `init` command detects Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, and OpenCode, then writes or merges each tool's MCP config in one pass. It follows the official VS Code `servers` and Windsurf user-config shapes (same pattern as the [@modelcontextprotocol/server-filesystem](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem) workspace examples). Use `--dry-run` to preview changes before writing. +This repository's `init` command is the supported setup path (see **Install** above). It merges each tool's MCP config safely, previews diffs, and refuses to overwrite unrelated custom entries. `@inquirer/prompts` powers the terminal UI (~25KB); `@clack/prompts` was evaluated and left out to avoid a second prompt dependency. ```bash -npx codebase-context init --yes # configure detected tools -npx codebase-context init --dry-run --yes # show Would create/update lines only +npx -y codebase-context@latest init # interactive wizard +npx -y codebase-context@latest init --yes # configure every detected tool +npx -y codebase-context@latest init --dry-run --yes ``` -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. +Until this ships on npm, build from source or use the manual client commands below. The [client setup guide](./docs/client-setup.md) marks unpublished commands. Start a new agent session in your project, then ask: diff --git a/docs/client-setup.md b/docs/client-setup.md index d992460..96106d7 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -103,33 +103,30 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f ## Setup wizard in the unreleased candidate -The four-client wizard is not in published 2.2.0. From a built checkout or a locally installed candidate, run its actual entrypoint from the project you want to search: +The interactive wizard is not in published 2.2.0. From your project root: ```bash -# Detect Claude Code, Codex, Cursor, VS Code, Windsurf, and OpenCode, then configure the detected clients. -node /path/to/codebase-context/dist/index.js init --yes - -# Preview MCP config changes without writing files. -node /path/to/codebase-context/dist/index.js init --dry-run --yes +npx -y codebase-context@latest init +npx -y codebase-context@latest init --yes --dry-run +npx -y codebase-context@latest init --client codex --root /path/to/project --yes --skip-index +npx -y codebase-context@latest init --root /path/to/project --preview +``` -# Configure one client for an explicit project. -node /path/to/codebase-context/dist/index.js init --client codex --root /path/to/project --yes +With a TTY, omit `--yes` to pick clients (Space toggles, Enter confirms), review unified diffs for every config change, and confirm before writing. `--client` is repeatable. `--root` takes priority over `CODEBASE_ROOT` and the current folder. `--skip-index` skips the post-setup index; `--no-update-notice` skips the daily npm version hint (`CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` also works). -# Inspect selected source files and exclusions without writing client config. -node /path/to/codebase-context/dist/index.js init --root /path/to/project --preview -``` +Clients: Claude Code (`.mcp.json`), Claude Desktop (`claude_desktop_config.json` under the OS Claude app folder), Codex (`.codex/config.toml`), Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json` with top-level `servers`), Windsurf (`~/.codeium/windsurf/mcp_config.json`), OpenCode (`opencode.json`). Generated launches use `npx -y codebase-context@latest` plus your project folder so patch releases roll out automatically; pin `codebase-context@x.y.z` in MCP config when you need a frozen version. -Replace the paths with the candidate installation and project folders. With an interactive terminal, omit `--yes` to select clients together. `--client` is repeatable. `--root` takes priority over `CODEBASE_ROOT` and the current folder. +Existing unrelated settings are preserved. Tiny configs (≤16KiB) are copied to `*.bak` beside the file before an update. A conflicting custom Codebase Context launch stops setup before selected config files are written. To replace a custom launch, remove only its Codebase Context entry, keep the other settings, and rerun setup. Restart the client and approve the server or project trust if asked. -Claude Code uses `.mcp.json`, Codex uses `.codex/config.toml`, Cursor uses `.cursor/mcp.json`, VS Code uses `.vscode/mcp.json` (`servers`), Windsurf uses `~/.codeium/windsurf/mcp_config.json`, and OpenCode uses `opencode.json`. Generated configs save the running Node binary, the actual package entrypoint and the project folder. If setup ran through `npx`, that entrypoint is in its package cache. Rerun setup if that installation or cache is removed or moved. +New candidate indexes default to model-free search. The wizard runs a first index with progress unless `--skip-index` or `--dry-run` is set. -Existing unrelated settings are preserved. A conflicting custom Codebase Context launch stops setup before selected config files are written. To replace a custom launch, remove only its Codebase Context entry from the client config, keep the other settings, and rerun setup. Restart the client and approve the server or project trust if asked. Config generation and SDK launch tests do not establish acceptance in each native client. +Record the wizard for docs with [VHS](https://github.com/charmbracelet/vhs) using [`docs/demo/install.tape`](./demo/install.tape); generate `install.gif` locally and avoid committing large binaries. -New candidate indexes default to model-free search and do not initialize the optional model packages. Existing indexes keep their recorded mode; semantic indexing remains an explicit option. +The older interactive HTTP wizard remains the behavior of published 2.2.0. A successful candidate wizard exit alone does not establish working client registration. ## 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", "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. +To set a default project before the first tool call, append its full path to the server arguments, for example `args = ["-y", "codebase-context@latest", "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. To prepare the index separately, run from the project folder: @@ -137,8 +134,6 @@ To prepare the index separately, run from the project folder: npx -y codebase-context reindex ``` -The older interactive HTTP wizard remains the behavior of published 2.2.0. A successful candidate wizard exit alone does not establish working client registration. - ## Gemini CLI **Status:** Config written locally diff --git a/docs/demo.md b/docs/demo.md index 407fd82..6b29726 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -6,6 +6,8 @@ These commands use the current npm release. The output shown below is historical To try the same CLI flow, use Node.js 22 or newer and open a terminal in the repository you want to inspect. These commands target `codebase-context`; the CLI uses the current directory as the project root. MCP registration and project selection are covered in the [client setup guide](./client-setup.md). +Record the setup wizard with VHS: `vhs docs/demo/install.tape` (writes `docs/demo/install.gif`; keep large GIFs out of git). + The saved output below is from an earlier CLI run, not from the current commands. The machine-specific repository root is omitted from the returned file path. This capture does not verify the current package or an MCP connection. ## 0. Build The Local Index diff --git a/docs/demo/install.tape b/docs/demo/install.tape new file mode 100644 index 0000000..0690ecf --- /dev/null +++ b/docs/demo/install.tape @@ -0,0 +1,19 @@ +# Terminal recording for docs/demo/install.gif (generate locally; do not commit the GIF if large). +Output docs/demo/install.gif +Require vhs +Set Shell bash +Set FontSize 14 +Set Width 1200 +Set Height 700 +Set Theme "Catppuccin Mocha" + +Hide +Type "cd /path/to/your/project" +Enter +Sleep 500ms +Type "npx -y codebase-context@latest init --yes --skip-index --no-update-notice" +Enter +Sleep 3s +Show + +Sleep 2s diff --git a/src/cli-init-preview.ts b/src/cli-init-preview.ts new file mode 100644 index 0000000..90e1d14 --- /dev/null +++ b/src/cli-init-preview.ts @@ -0,0 +1,32 @@ +/** Small unified diff for config previews during init (no external diff dependency). */ + +export function formatConfigPreview( + displayPath: string, + existingText: string | null, + mergedContent: string +): string { + if (existingText === null) { + const lines = mergedContent.replace(/\r\n/g, '\n').split('\n'); + return [`--- /dev/null`, `+++ ${displayPath}`, ...lines.map((line) => `+${line}`)].join('\n'); + } + if (existingText === mergedContent) { + return `(unchanged) ${displayPath}`; + } + + const before = existingText.replace(/\r\n/g, '\n').split('\n'); + const after = mergedContent.replace(/\r\n/g, '\n').split('\n'); + const output = [`--- ${displayPath}`, `+++ ${displayPath}`]; + + const max = Math.max(before.length, after.length); + for (let index = 0; index < max; index++) { + const left = before[index]; + const right = after[index]; + if (left === right) { + if (left !== undefined) output.push(` ${left}`); + } else { + if (left !== undefined) output.push(`-${left}`); + if (right !== undefined) output.push(`+${right}`); + } + } + return output.join('\n'); +} diff --git a/src/cli-init-version-notice.ts b/src/cli-init-version-notice.ts new file mode 100644 index 0000000..62158e2 --- /dev/null +++ b/src/cli-init-version-notice.ts @@ -0,0 +1,95 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { homedir } from 'node:os'; + +const CACHE_DIR = '.codebase-context'; +const CACHE_FILE = 'init-update-notice.json'; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +type NoticeCache = { + checkedAtMs: number; + latestVersion?: string; +}; + +function skipUpdateNotice(env: NodeJS.ProcessEnv = process.env): boolean { + const flag = env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE ?? env.CODEBASE_CONTEXT_NO_UPDATE_NOTICE; + if (!flag) return false; + const normalized = flag.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} + +function resolveCachePath(env: NodeJS.ProcessEnv = process.env): string { + const home = env.HOME || env.USERPROFILE || homedir(); + return path.join(home, CACHE_DIR, CACHE_FILE); +} + +async function readCache(cachePath: string): Promise { + try { + const raw = await fs.readFile(cachePath, 'utf8'); + const parsed = JSON.parse(raw) as NoticeCache; + if (typeof parsed.checkedAtMs !== 'number') return null; + return parsed; + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + return null; + } + return null; + } +} + +async function writeCache(cachePath: string, cache: NoticeCache): Promise { + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, JSON.stringify(cache), 'utf8'); +} + +async function fetchLatestPublishedVersion( + packageName: string, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await fetchImpl(`https://registry.npmjs.org/${packageName}/latest`, { + headers: { accept: 'application/json' } + }); + if (!response.ok) return undefined; + const body = (await response.json()) as { version?: string }; + return typeof body.version === 'string' ? body.version : undefined; +} + +/** + * Print at most one npm update notice per day. Uses a cache under ~/.codebase-context/. + * Disable with CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1 or init --no-update-notice. + */ +export async function maybePrintInitUpdateNotice( + installedVersion: string, + options: { + disabled?: boolean; + env?: NodeJS.ProcessEnv; + nowMs?: number; + fetchImpl?: typeof fetch; + } = {} +): Promise { + if (options.disabled || skipUpdateNotice(options.env)) return; + + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const cachePath = resolveCachePath(env); + const cached = await readCache(cachePath); + if (cached && nowMs - cached.checkedAtMs < ONE_DAY_MS) { + return; + } + + let latestVersion: string | undefined; + try { + latestVersion = await fetchLatestPublishedVersion('codebase-context', options.fetchImpl); + } catch { + return; + } + if (!latestVersion) return; + + await writeCache(cachePath, { checkedAtMs: nowMs, latestVersion }); + if (latestVersion !== installedVersion) { + console.log( + `Update available: codebase-context@${latestVersion} is on npm (you have ${installedVersion}). ` + + 'Init writes npx -y codebase-context@latest; pin a version in MCP config if you prefer stability.' + ); + } +} diff --git a/src/cli-init.ts b/src/cli-init.ts index 1888986..4bad434 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -19,9 +19,18 @@ import { parse as parseToml } from 'smol-toml'; import { isDeepStrictEqual } from 'node:util'; import { promises as fs } from 'node:fs'; import { atomicWrite } from './utils/atomic-write.js'; -import { checkbox } from '@inquirer/prompts'; +import { checkbox, confirm } from '@inquirer/prompts'; import { homedir } from 'node:os'; -import { constants } from 'node:fs'; +import { constants, readFileSync } from 'node:fs'; +import { formatConfigPreview } from './cli-init-preview.js'; +import { maybePrintInitUpdateNotice } from './cli-init-version-notice.js'; + +export const INIT_NPX_PACKAGE = 'codebase-context@latest'; +export const CONFIG_BACKUP_MAX_BYTES = 16_384; + +const PKG_VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') +).version as string; export type McpConfigResult = { kind: 'file'; path: string; content: string }; @@ -29,6 +38,7 @@ export type McpConfigOptions = { rootPath?: string; /** Override home for user-scoped configs (tests). Defaults to os.homedir(). */ homeDirectory?: string; + platform?: NodeJS.Platform; /** Node binary written into the client config. Defaults to the current process. */ nodeExecutable?: string; /** @@ -70,6 +80,13 @@ export const CLIENTS = [ executable: 'opencode', directory: '.opencode', scope: 'project' + }, + { + id: 'claude-desktop', + name: 'Claude Desktop', + executable: '', + directory: 'Claude', + scope: 'user' } ] as const; @@ -77,14 +94,16 @@ export type Client = (typeof CLIENTS)[number]['id']; function resolveHome( env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform + _platform: NodeJS.Platform = process.platform ): string { - const paths = platform === 'win32' ? path.win32 : path.posix; return env.HOME || env.USERPROFILE || homedir(); } function jsonMcpServerKey( - client: Extract + client: Extract< + Client, + 'claude-code' | 'claude-desktop' | 'cursor' | 'opencode' | 'vscode' | 'windsurf' + > ): string { if (client === 'opencode') return 'mcp'; if (client === 'vscode') return 'servers'; @@ -101,6 +120,23 @@ export function resolveClientConfigPath( const home = resolveHome(env, platform); if (client === 'codex') return path.join(rootPath, '.codex', 'config.toml'); if (client === 'windsurf') return path.join(home, '.codeium', 'windsurf', 'mcp_config.json'); + if (client === 'claude-desktop') { + if (platform === 'darwin') { + return paths.join( + home, + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json' + ); + } + if (platform === 'win32') { + const appData = env.APPDATA || paths.join(home, 'AppData', 'Roaming'); + return paths.join(appData, 'Claude', 'claude_desktop_config.json'); + } + const configHome = env.XDG_CONFIG_HOME || paths.join(home, '.config'); + return paths.join(configHome, 'Claude', 'claude_desktop_config.json'); + } if (client === 'claude-code') return path.join(rootPath, '.mcp.json'); if (client === 'cursor') return path.join(rootPath, '.cursor', 'mcp.json'); if (client === 'vscode') return path.join(rootPath, '.vscode', 'mcp.json'); @@ -119,6 +155,25 @@ export async function detectClients( const extensions = platform === 'win32' ? ['', '.exe', '.cmd', '.bat', '.ps1'] : ['']; const detected: Client[] = []; for (const client of CLIENTS) { + if (client.id === 'claude-desktop') { + const configPath = resolveClientConfigPath('claude-desktop', rootPath, env, platform); + const desktopDir = path.dirname(configPath); + for (const candidate of [ + { path: configPath, directory: false }, + { path: desktopDir, directory: true } + ]) { + try { + await fs.access(candidate.path, candidate.directory ? constants.F_OK : constants.F_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)) throw error; + } + } + continue; + } const userDirectory = client.id === 'codex' ? env.CODEX_HOME || paths.join(home, '.codex') @@ -135,11 +190,13 @@ export async function detectClients( : paths.join(rootPath, client.directory); const directories = client.scope === 'user' ? [userDirectory] : [projectDirectory, userDirectory]; - const executables = searchPath - .filter(Boolean) - .flatMap((directory) => - extensions.map((extension) => paths.join(directory, client.executable + extension)) - ); + const executables = client.executable + ? 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 })) @@ -180,33 +237,71 @@ function resolveServerEntrypoint(explicit?: string): string { return path.resolve(entrypoint); } +function resolveNpxCommand(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +function usesDirectLaunch(options: McpConfigOptions): boolean { + return options.serverEntrypoint !== undefined || options.nodeExecutable !== undefined; +} + +function buildLaunchArgs( + rootPath: string, + options: McpConfigOptions +): { + command: string; + args: string[]; + opencodeCommand?: string[]; +} { + const platform = options.platform ?? process.platform; + if (usesDirectLaunch(options)) { + const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath); + const serverEntrypoint = resolveServerEntrypoint(options.serverEntrypoint); + const args = [serverEntrypoint, rootPath]; + return { + command: nodeExecutable, + args, + opencodeCommand: [nodeExecutable, ...args] + }; + } + const npxCommand = resolveNpxCommand(platform); + const args = ['-y', INIT_NPX_PACKAGE, rootPath]; + return { + command: npxCommand, + args, + opencodeCommand: [npxCommand, ...args] + }; +} + /** - * 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. + * Launch command saved for every client. Defaults to `npx -y codebase-context@latest` + * plus the project folder so installs auto-update. Pass nodeExecutable/serverEntrypoint + * only in tests or when pinning to the running checkout. */ export function generateMcpConfig(client: Client, options: McpConfigOptions = {}): McpConfigResult { const rootPath = path.resolve(options.rootPath ?? process.cwd()); const homeDirectory = options.homeDirectory ?? resolveHome(); - const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath); - const serverEntrypoint = resolveServerEntrypoint(options.serverEntrypoint); - const args = [serverEntrypoint, rootPath]; + const platform = options.platform ?? process.platform; + const launch = buildLaunchArgs(rootPath, options); if (client === 'codex') { + const npx = usesDirectLaunch(options) + ? { command: launch.command, args: launch.args } + : { command: 'npx', args: launch.args }; return { kind: 'file', - path: resolveClientConfigPath('codex', rootPath, { HOME: homeDirectory }), + path: resolveClientConfigPath('codex', rootPath, { HOME: homeDirectory }, platform), content: - `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(nodeExecutable)}\n` + - `args = [${args.map((value) => JSON.stringify(value)).join(', ')}]\nstartup_timeout_sec = 120\n` + `[mcp_servers.codebase-context]\ncommand = ${JSON.stringify(npx.command)}\n` + + `args = [${npx.args.map((value) => JSON.stringify(value)).join(', ')}]\nstartup_timeout_sec = 120\n` }; } - const configPath = resolveClientConfigPath(client, rootPath, { HOME: homeDirectory }); + const configPath = resolveClientConfigPath(client, rootPath, { HOME: homeDirectory }, platform); const serverPayload = client === 'opencode' - ? { type: 'local', command: [nodeExecutable, ...args] } + ? { type: 'local', command: launch.opencodeCommand ?? [launch.command, ...launch.args] } : client === 'cursor' - ? { type: 'stdio', command: nodeExecutable, args } - : { command: nodeExecutable, args }; + ? { type: 'stdio', command: launch.command, args: launch.args } + : { command: launch.command, args: launch.args }; const body = client === 'opencode' ? { @@ -305,40 +400,78 @@ function isNpxCacheEntrypoint(value: string): boolean { return /[\\/]node_modules[\\/]codebase-context[\\/]dist[\\/]index\.js$/.test(value); } +function isNpxPackageArg(value: string): boolean { + return ( + value === 'codebase-context' || + /^codebase-context@(latest|\d+\.\d+\.\d+(?:-[\w.-]+)?)$/.test(value) + ); +} + +function isNpxLaunchArgs(args: string[]): boolean { + const flag = args[0]; + if (flag !== '-y' && flag !== '--yes') return false; + if (!isNpxPackageArg(args[1] ?? '')) return false; + return args.length === 3 && isAbsolutePath(args[2] ?? ''); +} + +function isNpxCommandName(command: string): boolean { + const normalized = command.replace(/\\/g, '/'); + const base = path.basename(normalized).toLowerCase(); + return (base === 'npx' || base === 'npx.cmd') && !normalized.includes('/'); +} + +function isOpencodeNpxCommand(command: string[]): boolean { + if (!isNpxCommandName(command[0] ?? '')) return false; + if (command[1] !== '-y' && command[1] !== '--yes') return false; + if (!isNpxPackageArg(command[2] ?? '')) return false; + return command.length === 4 && isAbsolutePath(command[3] ?? ''); +} + /** * 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. + * Accepts npx @latest launches and legacy node launches pointing at this package. */ function isGeneratedLaunch(command: unknown, args: unknown, entrypoint?: string): boolean { if ( - typeof command !== 'string' || - !Array.isArray(args) || - !args.every((arg) => typeof arg === 'string') - ) + typeof command === 'string' && + Array.isArray(args) && + args.every((arg) => typeof arg === 'string') + ) { + if (isNpxCommandName(command)) { + return isNpxLaunchArgs(args); + } + 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))) + ); + } 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] ?? '') - ); + if (Array.isArray(command) && command.every((part) => typeof part === 'string')) { + if (isOpencodeNpxCommand(command)) return true; + const executable = path.basename(command[0]?.replace(/\\/g, '/') ?? '').toLowerCase(); + if (executable === 'node' || executable === 'node.exe') { + const script = command[1] ?? ''; + return ( + command.length === 3 && + isAbsolutePath(script) && + isAbsolutePath(command[2] ?? '') && + (isNpxCacheEntrypoint(script) || (entrypoint !== undefined && samePath(script, entrypoint))) + ); + } + } + return false; } function generatedEntrypoint(generated: unknown, client: Client): string | undefined { if (!isJsonObject(generated)) return undefined; if (client === 'opencode' && Array.isArray(generated.command)) { + if (isOpencodeNpxCommand(generated.command)) return undefined; return typeof generated.command[1] === 'string' ? generated.command[1] : undefined; } return Array.isArray(generated.args) && typeof generated.args[0] === 'string' @@ -371,7 +504,10 @@ function mergeGeneratedServer(existing: unknown, generated: unknown, client: Cli export async function _buildMergedMcpContent( filePath: string, generatedContent: string, - client: Extract + client: Extract< + Client, + 'claude-code' | 'claude-desktop' | 'cursor' | 'opencode' | 'vscode' | 'windsurf' + > ): Promise<{ content: string; mergedFromExisting: boolean }> { const existingText = await readExistingConfig(filePath); if (existingText === null) { @@ -530,8 +666,51 @@ type InitOptions = { help: boolean; preview: boolean; dryRun: boolean; + skipIndex: boolean; + noUpdateNotice: boolean; }; +async function backupTinyConfig( + configPath: string, + existingText: string +): Promise { + if (Buffer.byteLength(existingText, 'utf8') > CONFIG_BACKUP_MAX_BYTES) return undefined; + const backupPath = `${configPath}.bak`; + try { + await fs.access(backupPath); + return undefined; + } catch (error) { + if (!isFileNotFound(error)) throw error; + } + await fs.writeFile(backupPath, existingText, 'utf8'); + return backupPath; +} + +async function runInitialIndex(rootPath: string): Promise { + const config = await loadProjectConfig(rootPath); + console.log(`Indexing ${rootPath} (first run)...`); + let lastLoggedProgress = { phase: '', percentage: -1 }; + const indexer = new CodebaseIndexer({ + rootPath, + ...cliProjectOptions(config, true), + onProgress: (progress) => { + const shouldLog = + progress.phase !== lastLoggedProgress.phase || + (progress.percentage % 10 === 0 && progress.percentage !== lastLoggedProgress.percentage); + if (shouldLog) { + console.error(`[${progress.phase}] ${progress.percentage}%`); + lastLoggedProgress = { phase: progress.phase, percentage: progress.percentage }; + } + } + }); + const stats = await indexer.index(); + console.log( + `Index ready: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${( + stats.duration / 1000 + ).toFixed(1)}s` + ); +} + function formatDisplayPath(configPath: string, rootPath: string): string { const relative = path.relative(rootPath, configPath); if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; @@ -579,17 +758,23 @@ function parseInitOptions(args: string[]): InitOptions { yes: false, help: true, preview: false, - dryRun: false + dryRun: false, + skipIndex: false, + noUpdateNotice: false }; const clients: Client[] = []; let yes = false; let preview = false; let dryRun = false; + let skipIndex = false; + let noUpdateNotice = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--preview') preview = true; else if (arg === '--dry-run') dryRun = true; else if (arg === '--yes' || arg === '-y') yes = true; + else if (arg === '--skip-index') skipIndex = true; + else if (arg === '--no-update-notice') noUpdateNotice = true; else if (arg === '--client') { const value = args[++index]; if (!value || value.startsWith('--')) throw new Error('Expected a client after --client.'); @@ -605,7 +790,9 @@ function parseInitOptions(args: string[]): InitOptions { yes, help: false, preview, - dryRun + dryRun, + skipIndex, + noUpdateNotice }; } @@ -615,18 +802,21 @@ function printInitUsage(): void { '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.' + 'Each config saves `npx -y codebase-context@latest` plus your project folder (pin a version in MCP config to freeze updates).' ); - console.log('Codex setup line: codex mcp add codebase-context -- npx -y codebase-context'); + console.log('Codex setup line: codex mcp add codebase-context -- npx -y codebase-context@latest'); 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(' --preview Show selected files and exclusions without writing config'); - console.log(' --dry-run Show MCP config changes without writing files'); + console.log(' --dry-run Preview MCP config diffs without writing files'); + console.log(' --skip-index Skip the first index after writing configs'); + console.log(' --no-update-notice Skip the daily npm update notice'); console.log(' --yes, -y Configure every detected tool without prompts'); console.log(' --help, -h Show this help'); + console.log(' CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1 Disable the daily npm update notice'); } export async function handleInitCli(args: string[]): Promise { @@ -638,6 +828,7 @@ export async function handleInitCli(args: string[]): Promise { } if (!(await fs.stat(options.rootPath)).isDirectory()) throw new Error(`Project root is not a directory: ${options.rootPath}`); + await maybePrintInitUpdateNotice(PKG_VERSION, { disabled: options.noUpdateNotice }); if (options.preview) { const config = await loadProjectConfig(options.rootPath); const preview = await new CodebaseIndexer({ @@ -681,11 +872,41 @@ export async function handleInitCli(args: string[]): Promise { ...config, content: merged.content, action, - displayPath: formatDisplayPath(config.path, options.rootPath) + displayPath: formatDisplayPath(config.path, options.rootPath), + existingText }); } + + for (const config of prepared) { + if (config.action !== 'unchanged') { + console.log(formatConfigPreview(config.displayPath, config.existingText, config.content)); + console.log(''); + } + } + + const hasChanges = prepared.some((config) => config.action !== 'unchanged'); + if ( + hasChanges && + !options.yes && + !options.dryRun && + process.stdin.isTTY && + process.stdout.isTTY + ) { + const approved = await confirm({ message: 'Apply these MCP config changes?', default: true }); + if (!approved) { + console.log('Setup cancelled; no files written.'); + return; + } + } + for (const config of prepared) { if (config.action !== 'unchanged' && !options.dryRun) { + if (config.existingText !== null) { + const backupPath = await backupTinyConfig(config.path, config.existingText); + if (backupPath) { + console.log(`Backed up: ${formatDisplayPath(backupPath, options.rootPath)}`); + } + } await fs.mkdir(path.dirname(config.path), { recursive: true }); await atomicWrite(config.path, config.content); } @@ -695,8 +916,10 @@ export async function handleInitCli(args: string[]): Promise { console.log('Dry run complete; no files written.'); return; } + if (!options.skipIndex) { + await runInitialIndex(options.rootPath); + } 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 && diff --git a/src/cli.ts b/src/cli.ts index b074f43..75438d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,7 +83,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 [--client ] [--yes] [--dry-run] Configure detected coding tools'); + console.log(' init [--client ] [--yes] [--dry-run] Interactive MCP setup wizard'); console.log(' map [--json] [--pretty] Conventions map (indexes first if needed)'); console.log(''); console.log('Global flags:'); diff --git a/tests/cli-init-atomic.test.ts b/tests/cli-init-atomic.test.ts index b0d5f25..222988d 100644 --- a/tests/cli-init-atomic.test.ts +++ b/tests/cli-init-atomic.test.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const fault = vi.hoisted(() => ({ enabled: false, rename: false })); vi.mock('node:fs', async (importOriginal) => { @@ -30,7 +30,13 @@ import { handleInitCli } from '../src/cli-init.js'; describe('init interrupted writes', () => { let root: string; const previousExitCode = process.exitCode; + const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; + beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; + }); afterEach(async () => { + if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; + else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; fault.enabled = false; fault.rename = false; process.exitCode = previousExitCode; @@ -42,10 +48,12 @@ describe('init interrupted writes', () => { const original = '{ "customSetting": "keep" }\n'; await fs.writeFile(file, original); fault.rename = true; - await handleInitCli(['--client', 'claude-code', '--yes', '--root', root]); + await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', root]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); - expect(await fs.readdir(root)).toEqual(['.mcp.json']); + expect(await fs.readdir(root).then((names) => names.sort())).toEqual( + ['.mcp.json', '.mcp.json.bak'].sort() + ); }); it.each(['claude-code', 'codex'])( 'preserves the entire existing %s config on a partial write failure', @@ -62,10 +70,12 @@ describe('init interrupted writes', () => { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, original); fault.enabled = true; - await handleInitCli(['--client', client, '--yes', '--root', root]); + await handleInitCli(['--client', client, '--yes', '--skip-index', '--root', root]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); - expect(await fs.readdir(path.dirname(file))).toEqual([path.basename(file)]); + expect((await fs.readdir(path.dirname(file))).sort()).toEqual( + [path.basename(file), `${path.basename(file)}.bak`].sort() + ); } ); }); diff --git a/tests/cli-init-backup.test.ts b/tests/cli-init-backup.test.ts new file mode 100644 index 0000000..f78272a --- /dev/null +++ b/tests/cli-init-backup.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { handleInitCli } from '../src/cli-init.js'; + +describe('init config backup', () => { + let root = ''; + const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; + + beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; + }); + + afterEach(async () => { + if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; + else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; + if (root) await rm(root, { recursive: true, force: true }); + root = ''; + }); + + it('backs up a tiny existing config before updating', async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-backup-')); + const configPath = path.join(root, '.mcp.json'); + const { writeFile } = await import('node:fs/promises'); + await writeFile(configPath, '{ "note": "keep" }\n', 'utf8'); + + await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', root]); + + expect(await readFile(`${configPath}.bak`, 'utf8')).toBe('{ "note": "keep" }\n'); + const updated = JSON.parse(await readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(updated.mcpServers['codebase-context']).toBeDefined(); + }); +}); diff --git a/tests/cli-init-bootstrap.test.ts b/tests/cli-init-bootstrap.test.ts index 6134d67..084075c 100644 --- a/tests/cli-init-bootstrap.test.ts +++ b/tests/cli-init-bootstrap.test.ts @@ -132,7 +132,7 @@ describe('init preserves custom launchers', () => { await fs.mkdir(path.dirname(generated.path), { recursive: true }); await fs.writeFile(generated.path, original); try { - await handleInitCli(['--client', 'codex', '--yes', '--root', root]); + await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', root]); expect(await fs.readFile(generated.path, 'utf8')).toBe(original); } finally { await fs.rm(root, { recursive: true, force: true }); @@ -199,6 +199,7 @@ describe('init preserves custom launchers', () => { '--client', 'codex', '--yes', + '--skip-index', '--root', root ]); diff --git a/tests/cli-init-dry-run-home.test.ts b/tests/cli-init-dry-run-home.test.ts index ff95b43..b978595 100644 --- a/tests/cli-init-dry-run-home.test.ts +++ b/tests/cli-init-dry-run-home.test.ts @@ -7,6 +7,7 @@ import { handleInitCli } from '../src/cli-init.js'; describe('init dry-run and user-scoped configs', () => { const previousHome = process.env.HOME; const previousUserProfile = process.env.USERPROFILE; + const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; let tempHome = ''; let projectRoot = ''; let logs: string[] = []; @@ -14,6 +15,8 @@ describe('init dry-run and user-scoped configs', () => { afterEach(async () => { process.env.HOME = previousHome; process.env.USERPROFILE = previousUserProfile; + if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; + else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; if (tempHome) await fs.rm(tempHome, { recursive: true, force: true }); tempHome = ''; projectRoot = ''; @@ -21,6 +24,7 @@ describe('init dry-run and user-scoped configs', () => { }); async function runInit(args: string[]) { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; logs = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { logs.push(parts.map(String).join(' ')); diff --git a/tests/cli-init-preview.test.ts b/tests/cli-init-preview.test.ts new file mode 100644 index 0000000..69a3f03 --- /dev/null +++ b/tests/cli-init-preview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { formatConfigPreview } from '../src/cli-init-preview.js'; + +describe('formatConfigPreview', () => { + it('shows a unified diff for updates', () => { + const preview = formatConfigPreview('.cursor/mcp.json', '{"a":1}\n', '{"a":2}\n'); + expect(preview).toContain('--- .cursor/mcp.json'); + expect(preview).toContain('-{"a":1}'); + expect(preview).toContain('+{"a":2}'); + }); + + it('marks unchanged configs', () => { + expect(formatConfigPreview('mcp.json', '{"same":true}', '{"same":true}')).toContain( + '(unchanged)' + ); + }); +}); diff --git a/tests/cli-init-saved-file.test.ts b/tests/cli-init-saved-file.test.ts index a90beaf..01ec3d0 100644 --- a/tests/cli-init-saved-file.test.ts +++ b/tests/cli-init-saved-file.test.ts @@ -39,7 +39,7 @@ describe('Codex init with an existing saved config', () => { await writeFile(configPath, initialBytes); process.exitCode = undefined; - await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', projectRoot]); const persistedBytes = await readFile(configPath); expect({ exitCode: process.exitCode, persistedBytes }).toEqual({ diff --git a/tests/cli-init-version-notice.test.ts b/tests/cli-init-version-notice.test.ts new file mode 100644 index 0000000..2ada0bf --- /dev/null +++ b/tests/cli-init-version-notice.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { maybePrintInitUpdateNotice } from '../src/cli-init-version-notice.js'; + +describe('maybePrintInitUpdateNotice', () => { + let tempHome = ''; + + afterEach(async () => { + if (tempHome) await rm(tempHome, { recursive: true, force: true }); + tempHome = ''; + }); + + it('prints at most once per day using the cache', async () => { + tempHome = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-notice-')); + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((message) => { + logs.push(String(message)); + }); + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '9.9.9' }) + }); + const nowMs = Date.now(); + + try { + await maybePrintInitUpdateNotice('2.3.0', { + env: { HOME: tempHome }, + nowMs, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + await maybePrintInitUpdateNotice('2.3.0', { + env: { HOME: tempHome }, + nowMs: nowMs + 1000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + } finally { + logSpy.mockRestore(); + } + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('9.9.9'); + const cache = JSON.parse( + await readFile(path.join(tempHome, '.codebase-context', 'init-update-notice.json'), 'utf8') + ) as { latestVersion: string }; + expect(cache.latestVersion).toBe('9.9.9'); + }); + + it('respects CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE', async () => { + tempHome = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-notice-skip-')); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const fetchImpl = vi.fn(); + + try { + await maybePrintInitUpdateNotice('2.3.0', { + env: { HOME: tempHome, CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE: '1' }, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + } finally { + logSpy.mockRestore(); + } + + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index b397c7f..b684325 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -20,11 +20,12 @@ vi.mock('node:fs', async (importOriginal) => { }); vi.mock('@inquirer/prompts', () => ({ - checkbox: vi.fn() + checkbox: vi.fn(), + confirm: vi.fn().mockResolvedValue(true) })); import { promises as fsMod } from 'node:fs'; -import { checkbox } from '@inquirer/prompts'; +import { checkbox, confirm } from '@inquirer/prompts'; import { CLIENTS, generateMcpConfig, @@ -41,6 +42,11 @@ 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' ); +const npxCommand = 'npx.cmd'; + +function npxLaunch(rootPath: string) { + return { command: npxCommand, args: ['-y', 'codebase-context@latest', rootPath] as const }; +} function savedLaunch(rootPath: string) { return { @@ -52,10 +58,23 @@ function savedLaunch(rootPath: string) { describe('generateMcpConfig', () => { const projectRoot = path.resolve('C:/projects/Example Project'); + const win32 = { platform: 'win32' as const }; - it('saves the npx cache path for Claude Code', () => { - const result = generateMcpConfig('claude-code', savedLaunch(projectRoot)); + it('saves npx codebase-context@latest for Claude Code by default', () => { + const result = generateMcpConfig('claude-code', { rootPath: projectRoot, ...win32 }); + + expect(result.kind).toBe('file'); + if (result.kind !== 'file') return; + expect(result.path).toBe(path.join(projectRoot, '.mcp.json')); + expect(JSON.parse(result.content)).toEqual({ + mcpServers: { + 'codebase-context': npxLaunch(projectRoot) + } + }); + }); + it('saves the npx cache path when node and entrypoint are overridden', () => { + const result = generateMcpConfig('claude-code', { ...savedLaunch(projectRoot), ...win32 }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; expect(result.path).toBe(path.join(projectRoot, '.mcp.json')); @@ -69,8 +88,22 @@ describe('generateMcpConfig', () => { }); }); - it('Cursor gets a project stdio config that saves the npx cache path', () => { - const result = generateMcpConfig('cursor', savedLaunch(projectRoot)); + it('Cursor gets a project stdio config with npx by default', () => { + const result = generateMcpConfig('cursor', { rootPath: projectRoot, ...win32 }); + expect(result.kind).toBe('file'); + if (result.kind !== 'file') return; + 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', + ...npxLaunch(projectRoot) + }); + }); + + it('Cursor stdio config can pin the running package file', () => { + const result = generateMcpConfig('cursor', { ...savedLaunch(projectRoot), ...win32 }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; expect(result.path).toBe(path.join(projectRoot, '.cursor', 'mcp.json')); @@ -84,20 +117,30 @@ describe('generateMcpConfig', () => { }); }); - it('defaults to the current Node binary and the running package file', () => { - const result = generateMcpConfig('claude-code', { rootPath: projectRoot }); + it('defaults to npx codebase-context@latest and the project root', () => { + const result = generateMcpConfig('claude-code', { rootPath: projectRoot, ...win32 }); if (result.kind !== 'file') throw new Error('expected file config'); const parsed = JSON.parse(result.content) as { mcpServers: { 'codebase-context': { command: string; args: string[] } }; }; 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); + expect(server.command).toBe(npxCommand); + expect(server.args).toEqual(['-y', 'codebase-context@latest', projectRoot]); }); - it('Codex saves the node binary and the npx cache path', () => { - const result = generateMcpConfig('codex', savedLaunch(projectRoot)); + it('Codex saves npx by default', () => { + const result = generateMcpConfig('codex', { rootPath: projectRoot, ...win32 }); + + 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 = "npx"\nargs = ["-y", ${JSON.stringify('codebase-context@latest')}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` + ); + }); + + it('Codex can pin the node binary and the npx cache path', () => { + const result = generateMcpConfig('codex', { ...savedLaunch(projectRoot), ...win32 }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; @@ -107,8 +150,22 @@ describe('generateMcpConfig', () => { ); }); - it('OpenCode uses a local command that saves the npx cache path', () => { - const result = generateMcpConfig('opencode', savedLaunch(projectRoot)); + it('OpenCode uses a local npx command by default', () => { + const result = generateMcpConfig('opencode', { rootPath: projectRoot, ...win32 }); + expect(result.kind).toBe('file'); + if (result.kind !== 'file') return; + expect(result.path).toBe(path.join(projectRoot, 'opencode.json')); + const parsed = JSON.parse(result.content) as { + mcp: { 'codebase-context': { type: string; command: string[] } }; + }; + expect(parsed.mcp['codebase-context']).toEqual({ + type: 'local', + command: [npxCommand, '-y', 'codebase-context@latest', projectRoot] + }); + }); + + it('OpenCode can pin the running package file', () => { + const result = generateMcpConfig('opencode', { ...savedLaunch(projectRoot), ...win32 }); expect(result.kind).toBe('file'); if (result.kind !== 'file') return; expect(result.path).toBe(path.join(projectRoot, 'opencode.json')); @@ -121,29 +178,40 @@ describe('generateMcpConfig', () => { }); }); - it('VS Code writes workspace servers config with the saved launch', () => { - const result = generateMcpConfig('vscode', savedLaunch(projectRoot)); + it('VS Code writes workspace servers config with npx by default', () => { + const result = generateMcpConfig('vscode', { rootPath: projectRoot, ...win32 }); expect(result.path).toBe(path.join(projectRoot, '.vscode', 'mcp.json')); expect(JSON.parse(result.content)).toEqual({ servers: { - 'codebase-context': { - command: nodeExecutable, - args: [cacheEntrypoint, projectRoot] - } + 'codebase-context': npxLaunch(projectRoot) } }); }); - it('Windsurf writes user mcp_config.json with the saved launch', () => { + it('Windsurf writes user mcp_config.json with npx by default', () => { const home = path.resolve('C:/Users/Test User'); - const result = generateMcpConfig('windsurf', { ...savedLaunch(projectRoot), homeDirectory: home }); + const result = generateMcpConfig('windsurf', { rootPath: projectRoot, homeDirectory: home, ...win32 }); expect(result.path).toBe(path.join(home, '.codeium', 'windsurf', 'mcp_config.json')); expect(JSON.parse(result.content)).toEqual({ mcpServers: { - 'codebase-context': { - command: nodeExecutable, - args: [cacheEntrypoint, projectRoot] - } + 'codebase-context': npxLaunch(projectRoot) + } + }); + }); + + it('Claude Desktop writes user mcpServers config with npx', () => { + const home = path.resolve('C:/Users/Test User'); + const result = generateMcpConfig('claude-desktop', { + rootPath: projectRoot, + homeDirectory: home, + platform: 'win32' + }); + expect(result.path).toBe( + path.win32.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') + ); + expect(JSON.parse(result.content)).toEqual({ + mcpServers: { + 'codebase-context': npxLaunch(projectRoot) } }); }); @@ -179,6 +247,7 @@ describe('_buildMergedMcpContent', () => { const readFileMock = vi.mocked(fsMod.readFile); beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; vi.resetAllMocks(); }); @@ -192,7 +261,7 @@ describe('_buildMergedMcpContent', () => { }) as unknown as Buffer ); - const generated = generateMcpConfig('cursor', { platform: 'linux' }); + const generated = generateMcpConfig('cursor', { platform: 'win32', rootPath: '/test/app' }); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedMcpContent( @@ -213,7 +282,7 @@ describe('_buildMergedMcpContent', () => { }); expect(parsed.mcpServers['codebase-context']).toMatchObject({ type: 'stdio', - command: path.resolve(process.execPath) + command: npxCommand }); }); @@ -229,7 +298,7 @@ describe('_buildMergedMcpContent', () => { const generated = generateMcpConfig( 'claude-code', - savedLaunch(path.resolve('C:/projects/app')) + { ...savedLaunch(path.resolve('C:/projects/app')), platform: 'win32' } ); if (generated.kind !== 'file') throw new Error('expected file config'); @@ -329,7 +398,7 @@ describe('_buildMergedMcpContent', () => { } }) as unknown as Buffer ); - const generated = generateMcpConfig('claude-code', savedLaunch(newRoot)); + const generated = generateMcpConfig('claude-code', { rootPath: newRoot, platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedMcpContent( @@ -343,8 +412,7 @@ describe('_buildMergedMcpContent', () => { }; expect(parsed.customSetting).toBe(true); expect(parsed.mcpServers['codebase-context']).toEqual({ - command: nodeExecutable, - args: [cacheEntrypoint, newRoot], + ...npxLaunch(newRoot), customTimeout: 8 }); }); @@ -363,7 +431,7 @@ describe('_buildMergedMcpContent', () => { } }) as unknown as Buffer ); - const generated = generateMcpConfig('claude-code', savedLaunch(newRoot)); + const generated = generateMcpConfig('claude-code', { rootPath: newRoot, platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedMcpContent( @@ -375,8 +443,7 @@ describe('_buildMergedMcpContent', () => { mcpServers: { 'codebase-context': { command: string; args: string[]; customLabel: string } }; }; expect(parsed.mcpServers['codebase-context']).toEqual({ - command: nodeExecutable, - args: [cacheEntrypoint, newRoot], + ...npxLaunch(newRoot), customLabel: 'preserve' }); }); @@ -387,6 +454,7 @@ describe('detectClients', () => { const missing = () => Object.assign(new Error('missing'), { code: 'ENOENT' }); beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; vi.resetAllMocks(); accessMock.mockRejectedValue(missing()); vi.mocked(fsMod.stat).mockImplementation(async (candidate) => { @@ -415,7 +483,8 @@ describe('detectClients', () => { 'cursor', 'vscode', 'windsurf', - 'opencode' + 'opencode', + 'claude-desktop' ]); expect(clients).toContain('codex'); expect(clients).not.toContain('claude-code'); @@ -479,6 +548,7 @@ describe('handleInitCli', () => { }; beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; vi.resetAllMocks(); writeFileMock.mockResolvedValue(undefined); vi.mocked(fsMod.rename).mockResolvedValue(undefined); @@ -492,6 +562,7 @@ describe('handleInitCli', () => { process.exitCode = undefined; Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); + vi.mocked(confirm).mockResolvedValue(true); }); afterAll(() => { @@ -507,7 +578,7 @@ describe('handleInitCli', () => { const projectRoot = path.resolve('C:/projects/Configured App'); detectDirectories('claude', 'codex'); - await handleInitCli(['--yes', '--root', projectRoot]); + await handleInitCli(['--yes', '--skip-index', '--root', projectRoot]); expect(checkbox).not.toHaveBeenCalled(); expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( @@ -523,7 +594,7 @@ describe('handleInitCli', () => { const projectRoot = path.resolve('C:/projects/No Terminal'); detectDirectories('cursor', 'opencode'); - await handleInitCli(['--root', projectRoot]); + await handleInitCli(['--root', projectRoot, '--skip-index']); expect(checkbox).not.toHaveBeenCalled(); expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( @@ -543,7 +614,7 @@ describe('handleInitCli', () => { 'claude-code', '--client', 'codex', - '--yes', + '--yes', '--skip-index', '--root', projectRoot ]); @@ -559,7 +630,7 @@ describe('handleInitCli', () => { }); it('reports no detected clients without writing when there is no terminal', async () => { - await handleInitCli(['--yes', '--root', path.resolve('C:/projects/Empty')]); + await handleInitCli(['--yes', '--skip-index', '--root', path.resolve('C:/projects/Empty')]); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -572,7 +643,7 @@ describe('handleInitCli', () => { 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')]); + await handleInitCli(['--root', path.resolve('C:/projects/Interactive'), '--skip-index']); const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; expect(choices).toEqual( @@ -591,7 +662,7 @@ describe('handleInitCli', () => { 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')]); + await handleInitCli(['--root', path.resolve('C:/projects/Cancelled'), '--skip-index']); const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; expect(choices).toEqual( @@ -615,7 +686,7 @@ describe('handleInitCli', () => { 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/Prompt Cancelled')]); + await handleInitCli(['--root', path.resolve('C:/projects/Prompt Cancelled'), '--skip-index']); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -626,7 +697,7 @@ describe('handleInitCli', () => { ReturnType >); - await handleInitCli(['--client', 'codex', '--yes', '--root', path.resolve('C:/projects/File')]); + await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', path.resolve('C:/projects/File')]); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); @@ -639,7 +710,7 @@ describe('handleInitCli', () => { await handleInitCli([ '--client', 'codex', - '--yes', + '--yes', '--skip-index', '--root', path.resolve('C:/projects/Missing Directory') ]); @@ -654,7 +725,7 @@ describe('handleInitCli', () => { const generated = generateMcpConfig('claude-code', { rootPath: projectRoot }); readFileMock.mockResolvedValue(generated.content as unknown as Buffer); - await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); + await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', projectRoot]); expect(writeFileMock).not.toHaveBeenCalled(); }); @@ -676,7 +747,7 @@ describe('handleInitCli', () => { 'claude-code', '--client', 'opencode', - '--yes', + '--yes', '--skip-index', '--root', projectRoot ]); @@ -709,6 +780,7 @@ describe('_buildMergedCodexContent', () => { const statMock = vi.mocked(fsMod.stat); beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; vi.resetAllMocks(); writeFileMock.mockResolvedValue(undefined); vi.mocked(fsMod.rename).mockResolvedValue(undefined); @@ -732,7 +804,7 @@ describe('_buildMergedCodexContent', () => { const previousExitCode = process.exitCode; process.exitCode = undefined; try { - await handleInitCli(['--client', 'codex', '--yes', '--root', projectRoot]); + await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', projectRoot]); return { initialBytes, persistedBytes, exitCode: process.exitCode }; } finally { process.exitCode = previousExitCode; @@ -812,7 +884,7 @@ describe('_buildMergedCodexContent', () => { 'startup_timeout_sec = 75\n' + 'custom_label = "keep me"\n'; readFileMock.mockResolvedValue(existing as unknown as Buffer); - const generated = generateMcpConfig('codex', savedLaunch(newRoot)); + const generated = generateMcpConfig('codex', { ...savedLaunch(newRoot), platform: 'win32' }); if (generated.kind !== 'file') throw new Error('expected file config'); const merged = await _buildMergedCodexContent(generated.path, generated.content); diff --git a/tests/release-truth-surfaces.test.ts b/tests/release-truth-surfaces.test.ts index 7cd1707..8b73faa 100644 --- a/tests/release-truth-surfaces.test.ts +++ b/tests/release-truth-surfaces.test.ts @@ -168,7 +168,7 @@ describe('release truth surfaces', () => { expect(readText('README.md')).toContain('npx -y codebase-context'); expect(readText('docs/client-setup.md')).toContain('npx -y codebase-context'); expect(readText('docs/client-setup.md')).toContain( - 'node /path/to/codebase-context/dist/index.js init' + 'npx -y codebase-context@latest init' ); expect(readText('docs/client-setup.md')).not.toContain('codebase-context@2.4.0 init'); expect(readText('docs/review-context.md')).toContain('--package=codebase-context@2.4.0'); From 7bffa1eaf1bc1d1ea7b8d5e38c215698561dafea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:52:55 +0000 Subject: [PATCH 2/3] feat(cli): clack init via vendor CLIs, pinned MCP, update and smoke check Adopt @clack/prompts with --yes --clients and --scope project for CI. Configure Claude, Codex, and VS Code through their CLIs; merge JSON for Cursor and OpenCode only. Pin codebase-context@version by default with update command and opt-in --track-latest. Finish init with MCP initialize smoke test. Add server.json for registry metadata and README install buttons. --- README.md | 30 +-- docs/client-setup.md | 15 +- package.json | 4 +- pnpm-lock.yaml | 263 ++------------------ server.json | 23 ++ src/cli-init-package.ts | 24 ++ src/cli-init-register.ts | 135 ++++++++++ src/cli-init-runner.ts | 55 ++++ src/cli-init-smoke.ts | 84 +++++++ src/cli-init-types.ts | 59 +++++ src/cli-init-version-notice.ts | 3 +- src/cli-init.ts | 359 ++++++++++++++++----------- src/cli-update.ts | 101 ++++++++ src/cli.ts | 8 +- tests/cli-entrypoint-runtime.test.ts | 2 +- tests/cli-init-atomic.test.ts | 59 ++--- tests/cli-init-backup.test.ts | 15 +- tests/cli-init-dry-run-home.test.ts | 105 ++++---- tests/cli-init-saved-file.test.ts | 29 +-- tests/cli-init.test.ts | 166 +++++++------ tests/cli.test.ts | 44 +++- 21 files changed, 986 insertions(+), 597 deletions(-) create mode 100644 server.json create mode 100644 src/cli-init-package.ts create mode 100644 src/cli-init-register.ts create mode 100644 src/cli-init-runner.ts create mode 100644 src/cli-init-smoke.ts create mode 100644 src/cli-init-types.ts create mode 100644 src/cli-update.ts diff --git a/README.md b/README.md index 980af5f..389586b 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,21 @@ Codebase Context gives coding agents a map of your repository through MCP, for d Use Node.js 22 or newer. From your project root: ```bash -npx -y codebase-context@latest init +npx -y codebase-context init ``` -The wizard detects Claude Code, Codex, Cursor, VS Code (Copilot), Windsurf, OpenCode, and Claude Desktop when present, shows a diff preview for each MCP config change, backs up tiny existing configs as `*.bak`, writes `npx -y codebase-context@latest` plus your project folder, and runs a first index with progress. Non-interactive CI: +[![Install MCP Server in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/link/mcp%2Finstall?name=codebase-context&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImNvZGViYXNlLWNvbnRleHQiXX0%3D) [![Install in VS Code](https://img.shields.io/badge/VS%20Code-Install%20MCP-blue?logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22:%22codebase-context%22,%22command%22:%22npx%22,%22args%22:%5B%22-y%22,%22codebase-context%22%5D%7D) + +The wizard uses `@clack/prompts`, detects installed tools, runs each vendor CLI where available (`claude mcp add`, `codex mcp add`, `code --add-mcp`), merges JSON only for Cursor and OpenCode, pins `codebase-context@` by default, previews changes, runs a first index, and smoke-tests MCP `initialize`. Upgrades: `npx codebase-context update`. + +Non-interactive CI: ```bash -npx -y codebase-context@latest init --yes --dry-run -npx -y codebase-context@latest init --yes --skip-index +npx -y codebase-context init --yes --clients cursor,opencode --scope project --dry-run +npx -y codebase-context init --yes --clients cursor --scope project --skip-index --skip-server-check ``` -Pin a version in MCP config instead of `@latest` if you prefer frozen updates. Disable the daily npm update notice with `CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` or `init --no-update-notice`. +Use `init --track-latest` to opt into `codebase-context@latest` in written configs. Disable the daily npm update notice with `CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1`. ## Set up your AI client manually @@ -38,15 +42,7 @@ opencode mcp add codebase-context '--' npx -y codebase-context In the unreleased candidate, `--version` prints the installed package version and `map` builds a missing index before printing the map. A low-confidence search includes a hint that weak matches do not establish that code is absent. These additions require the built candidate; the unpinned commands above install published 2.2.0. -This repository's `init` command is the supported setup path (see **Install** above). It merges each tool's MCP config safely, previews diffs, and refuses to overwrite unrelated custom entries. `@inquirer/prompts` powers the terminal UI (~25KB); `@clack/prompts` was evaluated and left out to avoid a second prompt dependency. - -```bash -npx -y codebase-context@latest init # interactive wizard -npx -y codebase-context@latest init --yes # configure every detected tool -npx -y codebase-context@latest init --dry-run --yes -``` - -Until this ships on npm, build from source or use the manual client commands below. The [client setup guide](./docs/client-setup.md) marks unpublished commands. +This repository's `init` command is the supported setup path (see **Install** above). Registry metadata for future publishing lives in [`server.json`](./server.json) (not published to the MCP registry in this PR). Start a new agent session in your project, then ask: @@ -82,10 +78,10 @@ Other clients use their own setup commands: | Client | Shortest current setup | | --------------------------- | ---------------------------------------------------------------------------- | | Gemini CLI | `gemini mcp add --scope user codebase-context npx -y codebase-context` | -| Cursor | `codebase-context init --client cursor` or add `.cursor/mcp.json` | -| VS Code with GitHub Copilot | `codebase-context init --client vscode` or add `.vscode/mcp.json` | +| Cursor | `codebase-context init --clients cursor` or add `.cursor/mcp.json` | +| VS Code with GitHub Copilot | `init --clients vscode --scope user` (`code --add-mcp`) or add `.vscode/mcp.json` manually | | GitHub Copilot CLI | `copilot mcp add codebase-context -- npx -y codebase-context` | -| Windsurf | `codebase-context init --client windsurf` or add `~/.codeium/windsurf/mcp_config.json` | +| Windsurf | add `~/.codeium/windsurf/mcp_config.json` manually (not in `init` yet) | 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. diff --git a/docs/client-setup.md b/docs/client-setup.md index 96106d7..982a596 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -106,19 +106,20 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f The interactive wizard is not in published 2.2.0. From your project root: ```bash +npx -y codebase-context init npx -y codebase-context@latest init -npx -y codebase-context@latest init --yes --dry-run -npx -y codebase-context@latest init --client codex --root /path/to/project --yes --skip-index -npx -y codebase-context@latest init --root /path/to/project --preview +npx -y codebase-context init --yes --dry-run +npx -y codebase-context init --yes --clients codex --scope project --root /path/to/project --skip-index +npx -y codebase-context update ``` -With a TTY, omit `--yes` to pick clients (Space toggles, Enter confirms), review unified diffs for every config change, and confirm before writing. `--client` is repeatable. `--root` takes priority over `CODEBASE_ROOT` and the current folder. `--skip-index` skips the post-setup index; `--no-update-notice` skips the daily npm version hint (`CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` also works). +With a TTY, omit `--yes` to pick clients (Space toggles, Enter confirms), review JSON diffs for Cursor/OpenCode, and confirm before writing. `--clients` is repeatable (`--client` is an alias). `--root` takes priority over `CODEBASE_ROOT` and the current folder. `--scope project|user` applies to vendor CLIs (default `project`). `--skip-index` skips the post-setup index; `--skip-server-check` skips the MCP `initialize` smoke test; `--no-update-notice` skips the daily npm version hint (`CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` also works). Use `init --track-latest` to write `codebase-context@latest` instead of pinning the installed package version; run `codebase-context update` later to refresh pinned configs. -Clients: Claude Code (`.mcp.json`), Claude Desktop (`claude_desktop_config.json` under the OS Claude app folder), Codex (`.codex/config.toml`), Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json` with top-level `servers`), Windsurf (`~/.codeium/windsurf/mcp_config.json`), OpenCode (`opencode.json`). Generated launches use `npx -y codebase-context@latest` plus your project folder so patch releases roll out automatically; pin `codebase-context@x.y.z` in MCP config when you need a frozen version. +Init clients: Claude Code and Codex (`claude mcp add`, `codex mcp add`), VS Code user scope (`code --add-mcp`; project scope skips because workspace MCP uses `.vscode/mcp.json`), Cursor (`.cursor/mcp.json`), OpenCode (`opencode.json`). Detection also looks for Windsurf and Claude Desktop, but the wizard does not configure them yet. Default launches pin `codebase-context@` plus your project folder; opt into `@latest` with `--track-latest`. -Existing unrelated settings are preserved. Tiny configs (≤16KiB) are copied to `*.bak` beside the file before an update. A conflicting custom Codebase Context launch stops setup before selected config files are written. To replace a custom launch, remove only its Codebase Context entry, keep the other settings, and rerun setup. Restart the client and approve the server or project trust if asked. +Cursor and OpenCode merges preserve unrelated settings. Tiny JSON configs (≤16KiB) are copied to `*.bak` before an update. A conflicting custom Codebase Context entry in JSON or Codex TOML stops setup before writes. To replace a custom launch, remove only its Codebase Context entry and rerun setup or `update`. Restart the client and approve trust if asked. -New candidate indexes default to model-free search. The wizard runs a first index with progress unless `--skip-index` or `--dry-run` is set. +New candidate indexes default to model-free search. The wizard runs a first index with progress unless `--skip-index` or `--dry-run` is set, then smoke-tests MCP `initialize` on stdio unless `--skip-server-check` is set. Record the wizard for docs with [VHS](https://github.com/charmbracelet/vhs) using [`docs/demo/install.tape`](./demo/install.tape); generate `install.gif` locally and avoid committing large binaries. diff --git a/package.json b/package.json index a855889..8f5f02e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "codebase-context", + "mcpName": "io.github.patricksys/codebase-context", "version": "2.3.0", "description": "Bounded conventions map and local-pattern discovery for AI coding agents. Local-first MCP server with AST-backed hybrid search.", "type": "module", @@ -52,6 +53,7 @@ "grammars", "README.md", "LICENSE", + "server.json", "docs/cli.md", "docs/capabilities.md", "docs/client-setup.md", @@ -135,7 +137,7 @@ "prepare": "husky" }, "dependencies": { - "@inquirer/prompts": "^3.0.0", + "@clack/prompts": "^0.10.1", "@modelcontextprotocol/sdk": "^1.27.1", "@typescript-eslint/typescript-estree": "^8.51.0", "chokidar": "^4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468d100..89e1fad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: .: dependencies: - '@inquirer/prompts': - specifier: ^3.0.0 - version: 3.3.2 + '@clack/prompts': + specifier: ^0.10.1 + version: 0.10.1 '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.4) @@ -120,6 +120,12 @@ importers: packages: + '@clack/core@0.4.2': + resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} + + '@clack/prompts@0.10.1': + resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -490,50 +496,6 @@ packages: cpu: [x64] os: [win32] - '@inquirer/checkbox@1.5.2': - resolution: {integrity: sha512-CifrkgQjDkUkWexmgYYNyB5603HhTHI91vLFeQXh6qrTKiCMVASol01Rs1cv6LP/A2WccZSRlJKZhbaBIs/9ZA==} - engines: {node: '>=14.18.0'} - - '@inquirer/confirm@2.0.17': - resolution: {integrity: sha512-EqzhGryzmGpy2aJf6LxJVhndxYmFs+m8cxXzf8nejb1DE3sabf6mUgBcp4J0jAUEiAcYzqmkqRr7LPFh/WdnXA==} - engines: {node: '>=14.18.0'} - - '@inquirer/core@6.0.0': - resolution: {integrity: sha512-fKi63Khkisgda3ohnskNf5uZJj+zXOaBvOllHsOkdsXRA/ubQLJQrZchFFi57NKbZzkTunXiBMdvWOv71alonw==} - engines: {node: '>=14.18.0'} - - '@inquirer/editor@1.2.15': - resolution: {integrity: sha512-gQ77Ls09x5vKLVNMH9q/7xvYPT6sIs5f7URksw+a2iJZ0j48tVS6crLqm2ugG33tgXHIwiEqkytY60Zyh5GkJQ==} - engines: {node: '>=14.18.0'} - - '@inquirer/expand@1.1.16': - resolution: {integrity: sha512-TGLU9egcuo+s7PxphKUCnJnpCIVY32/EwPCLLuu+gTvYiD8hZgx8Z2niNQD36sa6xcfpdLY6xXDBiL/+g1r2XQ==} - engines: {node: '>=14.18.0'} - - '@inquirer/input@1.2.16': - resolution: {integrity: sha512-Ou0LaSWvj1ni+egnyQ+NBtfM1885UwhRCMtsRt2bBO47DoC1dwtCa+ZUNgrxlnCHHF0IXsbQHYtIIjFGAavI4g==} - engines: {node: '>=14.18.0'} - - '@inquirer/password@1.1.16': - resolution: {integrity: sha512-aZYZVHLUXZ2gbBot+i+zOJrks1WaiI95lvZCn1sKfcw6MtSSlYC8uDX8sTzQvAsQ8epHoP84UNvAIT0KVGOGqw==} - engines: {node: '>=14.18.0'} - - '@inquirer/prompts@3.3.2': - resolution: {integrity: sha512-k52mOMRvTUejrqyF1h8Z07chC+sbaoaUYzzr1KrJXyj7yaX7Nrh0a9vktv8TuocRwIJOQMaj5oZEmkspEcJFYQ==} - engines: {node: '>=14.18.0'} - - '@inquirer/rawlist@1.2.16': - resolution: {integrity: sha512-pZ6TRg2qMwZAOZAV6TvghCtkr53dGnK29GMNQ3vMZXSNguvGqtOVc4j/h1T8kqGJFagjyfBZhUPGwNS55O5qPQ==} - engines: {node: '>=14.18.0'} - - '@inquirer/select@1.3.3': - resolution: {integrity: sha512-RzlRISXWqIKEf83FDC9ZtJ3JvuK1l7aGpretf41BCWYrvla2wU8W8MTRNMiPrPJ+1SIqrRC1nZdZ60hD9hRXLg==} - engines: {node: '>=14.18.0'} - - '@inquirer/type@1.5.5': - resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} - engines: {node: '>=18'} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -782,9 +744,6 @@ packages: '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/mute-stream@0.0.4': - resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} - '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -797,9 +756,6 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - '@types/wrap-ansi@3.0.0': - resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - '@typescript-eslint/eslint-plugin@8.51.0': resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -928,10 +884,6 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1050,21 +1002,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1229,10 +1170,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1353,10 +1290,6 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1378,10 +1311,6 @@ packages: picomatch: optional: true - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1551,10 +1480,6 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1807,10 +1732,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2044,10 +1965,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2137,6 +2054,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.9.1: resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} engines: {node: '>= 18'} @@ -2225,10 +2145,6 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tmp@0.2.4: - resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==} - engines: {node: '>=14.14'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2264,10 +2180,6 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2459,10 +2371,6 @@ packages: resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} engines: {node: '>=12.17'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2488,6 +2396,17 @@ packages: snapshots: + '@clack/core@0.4.2': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.10.1': + dependencies: + '@clack/core': 0.4.2 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -2742,94 +2661,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/checkbox@1.5.2': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - figures: 3.2.0 - - '@inquirer/confirm@2.0.17': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - chalk: 4.1.2 - - '@inquirer/core@6.0.0': - dependencies: - '@inquirer/type': 1.5.5 - '@types/mute-stream': 0.0.4 - '@types/node': 20.19.25 - '@types/wrap-ansi': 3.0.0 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-spinners: 2.9.2 - cli-width: 4.1.0 - figures: 3.2.0 - mute-stream: 1.0.0 - run-async: 3.0.0 - signal-exit: 4.1.0 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - '@inquirer/editor@1.2.15': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - chalk: 4.1.2 - external-editor: 3.1.0 - - '@inquirer/expand@1.1.16': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - chalk: 4.1.2 - figures: 3.2.0 - - '@inquirer/input@1.2.16': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - chalk: 4.1.2 - - '@inquirer/password@1.1.16': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - - '@inquirer/prompts@3.3.2': - dependencies: - '@inquirer/checkbox': 1.5.2 - '@inquirer/confirm': 2.0.17 - '@inquirer/core': 6.0.0 - '@inquirer/editor': 1.2.15 - '@inquirer/expand': 1.1.16 - '@inquirer/input': 1.2.16 - '@inquirer/password': 1.1.16 - '@inquirer/rawlist': 1.2.16 - '@inquirer/select': 1.3.3 - - '@inquirer/rawlist@1.2.16': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - chalk: 4.1.2 - - '@inquirer/select@1.3.3': - dependencies: - '@inquirer/core': 6.0.0 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - figures: 3.2.0 - - '@inquirer/type@1.5.5': - dependencies: - mute-stream: 1.0.0 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3026,10 +2857,6 @@ snapshots: '@types/minimatch@5.1.2': {} - '@types/mute-stream@0.0.4': - dependencies: - '@types/node': 20.19.25 - '@types/node-fetch@2.6.13': dependencies: '@types/node': 20.19.25 @@ -3045,8 +2872,6 @@ snapshots: '@types/uuid@9.0.8': {} - '@types/wrap-ansi@3.0.0': {} - '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3216,10 +3041,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -3362,16 +3183,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chardet@0.7.0: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 - cli-spinners@2.9.2: {} - - cli-width@4.1.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3600,8 +3415,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@9.39.3): @@ -3775,12 +3588,6 @@ snapshots: transitivePeerDependencies: - supports-color - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.2.4 - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -3793,10 +3600,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - figures@3.2.0: - dependencies: - escape-string-regexp: 1.0.5 - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3981,10 +3784,6 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -4213,8 +4012,6 @@ snapshots: ms@2.1.3: {} - mute-stream@1.0.0: {} - nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -4490,8 +4287,6 @@ snapshots: transitivePeerDependencies: - supports-color - run-async@3.0.0: {} - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -4641,6 +4436,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + smol-toml@1.9.1: {} source-map-js@1.2.1: {} @@ -4727,8 +4524,6 @@ snapshots: tinyrainbow@3.0.3: {} - tmp@0.2.4: {} - toidentifier@1.0.1: {} tr46@0.0.3: {} @@ -4761,8 +4556,6 @@ snapshots: type-fest@0.13.1: {} - type-fest@0.21.3: {} - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -4955,12 +4748,6 @@ snapshots: wordwrapjs@5.1.1: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/server.json b/server.json new file mode 100644 index 0000000..5507fd3 --- /dev/null +++ b/server.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.patricksys/codebase-context", + "title": "Codebase Context", + "description": "Local MCP search, conventions map, and team patterns for coding agents.", + "version": "2.3.0", + "websiteUrl": "https://github.com/PatrickSys/codebase-context#readme", + "repository": { + "url": "https://github.com/PatrickSys/codebase-context", + "source": "github" + }, + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "codebase-context", + "version": "2.3.0", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/src/cli-init-package.ts b/src/cli-init-package.ts new file mode 100644 index 0000000..69587eb --- /dev/null +++ b/src/cli-init-package.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; + +export const PKG_VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') +).version as string; + +export function resolveMcpPackageSpec(trackLatest: boolean): string { + return trackLatest ? 'codebase-context@latest' : `codebase-context@${PKG_VERSION}`; +} + +export function buildNpxLaunchArgs( + rootPath: string, + trackLatest: boolean, + platform: NodeJS.Platform = process.platform +): { command: string; args: string[]; shellCommand: string[] } { + const npx = platform === 'win32' ? 'npx.cmd' : 'npx'; + const packageSpec = resolveMcpPackageSpec(trackLatest); + const args = ['-y', packageSpec, rootPath]; + return { + command: npx, + args, + shellCommand: [npx, ...args] + }; +} diff --git a/src/cli-init-register.ts b/src/cli-init-register.ts new file mode 100644 index 0000000..f54b055 --- /dev/null +++ b/src/cli-init-register.ts @@ -0,0 +1,135 @@ +import path from 'node:path'; +import type { Client } from './cli-init-types.js'; +import { buildNpxLaunchArgs, resolveMcpPackageSpec } from './cli-init-package.js'; +import { formatCommandPreview } from './cli-init-runner.js'; + +export type InitScope = 'user' | 'project'; + +export type CliRegistrationPlan = { + kind: 'cli'; + client: Client; + label: string; + file: string; + args: string[]; + cwd?: string; +}; + +export type JsonRegistrationPlan = { + kind: 'json'; + client: 'cursor' | 'opencode'; + label: string; + path: string; +}; + +export type SkippedRegistrationPlan = { + kind: 'skip'; + client: Client; + reason: string; +}; + +export type RegistrationPlan = CliRegistrationPlan | JsonRegistrationPlan | SkippedRegistrationPlan; + +export function isJsonConfigClient(client: Client): client is 'cursor' | 'opencode' { + return client === 'cursor' || client === 'opencode'; +} + +export function isCliConfigClient(client: Client): client is 'claude-code' | 'codex' | 'vscode' { + return client === 'claude-code' || client === 'codex' || client === 'vscode'; +} + +export function planClientRegistration( + client: Client, + options: { + rootPath: string; + scope: InitScope; + trackLatest: boolean; + platform?: NodeJS.Platform; + } +): RegistrationPlan { + const rootPath = path.resolve(options.rootPath); + const platform = options.platform ?? process.platform; + const launch = buildNpxLaunchArgs(rootPath, options.trackLatest, platform); + + if (client === 'cursor' || client === 'opencode') { + if (options.scope === 'user' && client === 'cursor') { + return { + kind: 'skip', + client, + reason: 'Cursor project MCP config lives in .cursor/mcp.json; rerun with --scope project.' + }; + } + return { + kind: 'json', + client, + label: client === 'cursor' ? '.cursor/mcp.json' : 'opencode.json', + path: + client === 'cursor' + ? path.join(rootPath, '.cursor', 'mcp.json') + : path.join(rootPath, 'opencode.json') + }; + } + + if (client === 'vscode' && options.scope === 'project') { + return { + kind: 'skip', + client, + reason: + 'VS Code workspace MCP uses .vscode/mcp.json; use --scope user for code --add-mcp or configure Cursor/OpenCode JSON manually.' + }; + } + + if (client === 'claude-code') { + return { + kind: 'cli', + client, + label: 'claude mcp add', + file: platform === 'win32' ? 'claude.exe' : 'claude', + args: [ + 'mcp', + 'add', + '--scope', + options.scope, + '--transport', + 'stdio', + 'codebase-context', + '--', + ...launch.shellCommand + ] + }; + } + + if (client === 'codex') { + return { + kind: 'cli', + client, + label: 'codex mcp add', + file: platform === 'win32' ? 'codex.exe' : 'codex', + args: ['mcp', 'add', 'codebase-context', '--', ...launch.shellCommand], + cwd: options.scope === 'project' ? rootPath : undefined + }; + } + + if (client === 'vscode') { + const payload = { + name: 'codebase-context', + command: 'npx', + args: ['-y', resolveMcpPackageSpec(options.trackLatest), rootPath] + }; + const code = platform === 'win32' ? 'code.cmd' : 'code'; + return { + kind: 'cli', + client, + label: 'code --add-mcp', + file: code, + args: ['--add-mcp', JSON.stringify(payload)] + }; + } + + return { kind: 'skip', client, reason: `Unsupported client "${client}".` }; +} + +export function describeRegistrationPlan(plan: RegistrationPlan): string { + if (plan.kind === 'skip') return `Skip ${plan.client}: ${plan.reason}`; + if (plan.kind === 'json') return `Edit ${plan.label} at ${plan.path}`; + return formatCommandPreview(plan.file, plan.args); +} diff --git a/src/cli-init-runner.ts b/src/cli-init-runner.ts new file mode 100644 index 0000000..3f902bd --- /dev/null +++ b/src/cli-init-runner.ts @@ -0,0 +1,55 @@ +import { spawnSync } from 'node:child_process'; + +export type CommandRunResult = { + status: number | null; + stdout: string; + stderr: string; + error: Error | null; +}; + +export type CommandRunner = ( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +) => CommandRunResult; + +function defaultRunner( + file: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {} +): CommandRunResult { + const result = spawnSync(file, args, { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: 'utf8', + shell: false + }); + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? null + }; +} + +let commandRunner: CommandRunner = defaultRunner; + +export function runCommand( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): CommandRunResult { + return commandRunner(file, args, options); +} + +export function _setCommandRunnerForTests(runner: CommandRunner): void { + commandRunner = runner; +} + +export function _resetCommandRunnerForTests(): void { + commandRunner = defaultRunner; +} + +export function formatCommandPreview(file: string, args: string[]): string { + return [file, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(' '); +} diff --git a/src/cli-init-smoke.ts b/src/cli-init-smoke.ts new file mode 100644 index 0000000..3093a9d --- /dev/null +++ b/src/cli-init-smoke.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const INIT_REQUEST = + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"codebase-context-init","version":"0"}}}'; + +function frameMessage(body: string): string { + const bytes = Buffer.byteLength(body, 'utf8'); + return `Content-Length: ${bytes}\r\n\r\n${body}`; +} + +/** + * Spawn the MCP server entrypoint and confirm it responds to initialize on stdio. + */ +export async function verifyMcpServerStarts( + serverEntrypoint: string, + rootPath: string, + timeoutMs = 20_000 +): Promise { + const entry = path.resolve(serverEntrypoint); + const root = path.resolve(rootPath); + const child = spawn(process.execPath, [entry, root], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE: '1' } + }); + + let buffer = ''; + const done = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for MCP initialize response.')); + }, timeoutMs); + + const fail = (error: Error) => { + clearTimeout(timer); + reject(error); + }; + + child.on('error', fail); + child.stderr?.on('data', () => undefined); + child.stdout?.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) break; + const header = buffer.slice(0, headerEnd); + const match = /Content-Length:\s*(\d+)/i.exec(header); + if (!match) { + buffer = buffer.slice(headerEnd + 4); + continue; + } + const length = Number.parseInt(match[1] ?? '', 10); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + length) break; + const body = buffer.slice(bodyStart, bodyStart + length); + buffer = buffer.slice(bodyStart + length); + try { + const parsed = JSON.parse(body) as { id?: number; result?: unknown; error?: unknown }; + if (parsed.id === 1 && (parsed.result !== undefined || parsed.error !== undefined)) { + clearTimeout(timer); + resolve(); + return; + } + } catch { + // keep reading + } + } + }); + + child.on('exit', (code) => { + if (code !== 0 && code !== null) { + fail(new Error(`MCP server exited before initialize (code ${code}).`)); + } + }); + }); + + child.stdin?.write(frameMessage(INIT_REQUEST)); + child.stdin?.write(frameMessage('{"jsonrpc":"2.0","method":"notifications/initialized"}')); + + try { + await done; + } finally { + child.kill('SIGTERM'); + } +} diff --git a/src/cli-init-types.ts b/src/cli-init-types.ts new file mode 100644 index 0000000..089a574 --- /dev/null +++ b/src/cli-init-types.ts @@ -0,0 +1,59 @@ +export const INIT_CLIENTS = [ + { + id: 'claude-code', + name: 'Claude Code', + executable: 'claude', + directory: '.claude', + scope: 'project' + }, + { id: 'codex', name: 'Codex', executable: 'codex', directory: '.codex', scope: 'project' }, + { id: 'cursor', name: 'Cursor', executable: 'cursor', directory: '.cursor', scope: 'project' }, + { + id: 'vscode', + name: 'VS Code (Copilot)', + executable: 'code', + directory: '.vscode', + scope: 'project' + }, + { + id: 'opencode', + name: 'OpenCode', + executable: 'opencode', + directory: '.opencode', + scope: 'project' + } +] as const; + +export type InitClient = (typeof INIT_CLIENTS)[number]['id']; + +export const DETECT_CLIENTS = [ + ...INIT_CLIENTS, + { + id: 'windsurf', + name: 'Windsurf', + executable: 'windsurf', + directory: '.codeium/windsurf', + scope: 'user' + }, + { + id: 'claude-desktop', + name: 'Claude Desktop', + executable: '', + directory: 'Claude', + scope: 'user' + } +] as const; + +export type Client = (typeof DETECT_CLIENTS)[number]['id']; + +export const CLIENTS = DETECT_CLIENTS; + +export type InitScope = 'user' | 'project'; + +export const INIT_CLIENT_ID_SET: ReadonlySet = new Set( + INIT_CLIENTS.map((client) => client.id) +); + +export function isInitClient(client: Client): client is InitClient { + return INIT_CLIENT_ID_SET.has(client); +} diff --git a/src/cli-init-version-notice.ts b/src/cli-init-version-notice.ts index 62158e2..75d58fd 100644 --- a/src/cli-init-version-notice.ts +++ b/src/cli-init-version-notice.ts @@ -88,8 +88,7 @@ export async function maybePrintInitUpdateNotice( await writeCache(cachePath, { checkedAtMs: nowMs, latestVersion }); if (latestVersion !== installedVersion) { console.log( - `Update available: codebase-context@${latestVersion} is on npm (you have ${installedVersion}). ` + - 'Init writes npx -y codebase-context@latest; pin a version in MCP config if you prefer stability.' + `Update available: codebase-context@${latestVersion} is on npm (you have ${installedVersion}). Run \`codebase-context update\` to refresh pinned configs, or init with --track-latest.` ); } } diff --git a/src/cli-init.ts b/src/cli-init.ts index 4bad434..4361b64 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -19,19 +19,35 @@ import { parse as parseToml } from 'smol-toml'; import { isDeepStrictEqual } from 'node:util'; import { promises as fs } from 'node:fs'; import { atomicWrite } from './utils/atomic-write.js'; -import { checkbox, confirm } from '@inquirer/prompts'; +import * as clack from '@clack/prompts'; import { homedir } from 'node:os'; -import { constants, readFileSync } from 'node:fs'; +import { constants } from 'node:fs'; import { formatConfigPreview } from './cli-init-preview.js'; import { maybePrintInitUpdateNotice } from './cli-init-version-notice.js'; - -export const INIT_NPX_PACKAGE = 'codebase-context@latest'; +import { PKG_VERSION, resolveMcpPackageSpec } from './cli-init-package.js'; +import { + describeRegistrationPlan, + planClientRegistration, + type RegistrationPlan +} from './cli-init-register.js'; +import { formatCommandPreview, runCommand } from './cli-init-runner.js'; +import { verifyMcpServerStarts } from './cli-init-smoke.js'; +import { + CLIENTS, + DETECT_CLIENTS, + INIT_CLIENTS, + type Client, + type InitClient, + type InitScope, + isInitClient +} from './cli-init-types.js'; + +export type { Client, InitClient, InitScope } from './cli-init-types.js'; +export { CLIENTS, DETECT_CLIENTS, INIT_CLIENTS, PKG_VERSION, resolveMcpPackageSpec }; + +export const INIT_NPX_PACKAGE = resolveMcpPackageSpec(false); export const CONFIG_BACKUP_MAX_BYTES = 16_384; -const PKG_VERSION: string = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8') -).version as string; - export type McpConfigResult = { kind: 'file'; path: string; content: string }; export type McpConfigOptions = { @@ -39,6 +55,8 @@ export type McpConfigOptions = { /** Override home for user-scoped configs (tests). Defaults to os.homedir(). */ homeDirectory?: string; platform?: NodeJS.Platform; + /** When true, write codebase-context@latest instead of pinning the running package version. */ + trackLatest?: boolean; /** Node binary written into the client config. Defaults to the current process. */ nodeExecutable?: string; /** @@ -50,48 +68,6 @@ export type McpConfigOptions = { type JsonObject = Record; -export const CLIENTS = [ - { - id: 'claude-code', - name: 'Claude Code', - executable: 'claude', - directory: '.claude', - scope: 'project' - }, - { id: 'codex', name: 'Codex', executable: 'codex', directory: '.codex', scope: 'project' }, - { id: 'cursor', name: 'Cursor', executable: 'cursor', directory: '.cursor', scope: 'project' }, - { - id: 'vscode', - name: 'VS Code (Copilot)', - executable: 'code', - directory: '.vscode', - scope: 'project' - }, - { - id: 'windsurf', - name: 'Windsurf', - executable: 'windsurf', - directory: '.codeium/windsurf', - scope: 'user' - }, - { - id: 'opencode', - name: 'OpenCode', - executable: 'opencode', - directory: '.opencode', - scope: 'project' - }, - { - id: 'claude-desktop', - name: 'Claude Desktop', - executable: '', - directory: 'Claude', - scope: 'user' - } -] as const; - -export type Client = (typeof CLIENTS)[number]['id']; - function resolveHome( env: NodeJS.ProcessEnv = process.env, _platform: NodeJS.Platform = process.platform @@ -154,7 +130,7 @@ export async function detectClients( 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) { + for (const client of DETECT_CLIENTS) { if (client.id === 'claude-desktop') { const configPath = resolveClientConfigPath('claude-desktop', rootPath, env, platform); const desktopDir = path.dirname(configPath); @@ -265,7 +241,8 @@ function buildLaunchArgs( }; } const npxCommand = resolveNpxCommand(platform); - const args = ['-y', INIT_NPX_PACKAGE, rootPath]; + const packageSpec = resolveMcpPackageSpec(options.trackLatest ?? false); + const args = ['-y', packageSpec, rootPath]; return { command: npxCommand, args, @@ -274,9 +251,8 @@ function buildLaunchArgs( } /** - * Launch command saved for every client. Defaults to `npx -y codebase-context@latest` - * plus the project folder so installs auto-update. Pass nodeExecutable/serverEntrypoint - * only in tests or when pinning to the running checkout. + * Launch command for JSON-backed clients (Cursor, OpenCode). Pins codebase-context@ + * unless trackLatest is set. Pass nodeExecutable/serverEntrypoint only in tests. */ export function generateMcpConfig(client: Client, options: McpConfigOptions = {}): McpConfigResult { const rootPath = path.resolve(options.rootPath ?? process.cwd()); @@ -660,16 +636,81 @@ export async function _buildMergedCodexContent( } type InitOptions = { - clients: Client[]; + clients: InitClient[]; rootPath: string; yes: boolean; help: boolean; preview: boolean; dryRun: boolean; skipIndex: boolean; + skipServerCheck: boolean; noUpdateNotice: boolean; + scope: InitScope; + trackLatest: boolean; }; +function parseInitClient(value: string): InitClient { + if (!isInitClient(value as Client)) { + throw new Error( + `Unknown or unsupported client "${value}". Choose ${INIT_CLIENTS.map((client) => client.id).join(', ')}.` + ); + } + return value as InitClient; +} + +function parseScope(value: string): InitScope { + if (value === 'user' || value === 'project') return value; + throw new Error('Expected --scope user or --scope project.'); +} + +async function applyJsonRegistration( + plan: Extract, + options: InitOptions +): Promise<'create' | 'update' | 'unchanged'> { + const config = generateMcpConfig(plan.client, { + rootPath: options.rootPath, + trackLatest: options.trackLatest + }); + const existingText = await readExistingConfig(config.path); + const merged = await _buildMergedMcpContent(config.path, config.content, plan.client); + const action = classifyInitChange(existingText, merged.content); + if (action !== 'unchanged' && !options.dryRun) { + if (existingText !== null) { + const backupPath = await backupTinyConfig(config.path, existingText); + if (backupPath) { + console.log(`Backed up: ${formatDisplayPath(backupPath, options.rootPath)}`); + } + } + await fs.mkdir(path.dirname(config.path), { recursive: true }); + await atomicWrite(config.path, merged.content); + } + console.log( + describeInitChange(options.dryRun, action, formatDisplayPath(config.path, options.rootPath)) + ); + return action; +} + +function applyCliRegistration( + plan: Extract, + options: InitOptions +): void { + const preview = formatCommandPreview(plan.file, plan.args); + if (options.dryRun) { + console.log(`Would run: ${preview}`); + return; + } + const result = runCommand(plan.file, plan.args, { cwd: plan.cwd ?? options.rootPath }); + if (result.error) { + throw new Error(`${plan.label} failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `${plan.label} exited with code ${result.status ?? 'unknown'}${result.stderr ? `: ${result.stderr.trim()}` : ''}` + ); + } + console.log(`Ran: ${preview}`); +} + async function backupTinyConfig( configPath: string, existingText: string @@ -742,14 +783,6 @@ function describeInitChange( return `${prefix}: ${displayPath}`; } -function parseClient(value: string): Client { - 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 { @@ -760,28 +793,41 @@ function parseInitOptions(args: string[]): InitOptions { preview: false, dryRun: false, skipIndex: false, - noUpdateNotice: false + skipServerCheck: false, + noUpdateNotice: false, + scope: 'project', + trackLatest: false }; - const clients: Client[] = []; + const clients: InitClient[] = []; let yes = false; let preview = false; let dryRun = false; let skipIndex = false; + let skipServerCheck = false; let noUpdateNotice = false; + let scope: InitScope = 'project'; + let trackLatest = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === '--preview') preview = true; else if (arg === '--dry-run') dryRun = true; else if (arg === '--yes' || arg === '-y') yes = true; else if (arg === '--skip-index') skipIndex = true; + else if (arg === '--skip-server-check') skipServerCheck = true; else if (arg === '--no-update-notice') noUpdateNotice = true; - else if (arg === '--client') { + else if (arg === '--track-latest') trackLatest = true; + else if (arg === '--scope') { 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++; + if (!value) throw new Error('Expected user or project after --scope.'); + scope = parseScope(value); + } else if (arg?.startsWith('--scope=')) scope = parseScope(arg.slice('--scope='.length)); + else if (arg === '--client' || arg === '--clients') { + const value = args[++index]; + if (!value || value.startsWith('--')) throw new Error(`Expected a client after ${arg}.`); + clients.push(parseInitClient(value)); + } else if (arg?.startsWith('--client=') || arg?.startsWith('--clients=')) { + clients.push(parseInitClient(arg.split('=')[1] ?? '')); + } else if (arg === '--root') index++; else if (!arg?.startsWith('--root=')) throw new Error(`Unknown init option "${arg}".`); } return { @@ -792,31 +838,34 @@ function parseInitOptions(args: string[]): InitOptions { preview, dryRun, skipIndex, - noUpdateNotice + skipServerCheck, + noUpdateNotice, + scope, + trackLatest }; } function printInitUsage(): void { console.log('codebase-context init [options]'); console.log( - 'Detect and configure coding tools together. With a terminal, Space toggles and Enter applies.' + 'Interactive setup: client CLIs for Claude Code, Codex, and VS Code; JSON merge for Cursor and OpenCode.' ); console.log( - 'Each config saves `npx -y codebase-context@latest` plus your project folder (pin a version in MCP config to freeze updates).' + `Default MCP launch: npx -y codebase-context@${PKG_VERSION} (use --track-latest for @latest).` ); - console.log('Codex setup line: codex mcp add codebase-context -- npx -y codebase-context@latest'); console.log( - ' --client Choose a tool (repeatable): ' + - CLIENTS.map((client) => client.id).join(', ') + ' --clients Repeatable client id (alias: --client): ' + + INIT_CLIENTS.map((client) => client.id).join(', ') ); - console.log(' --root Project root (default: CODEBASE_ROOT or cwd)'); - console.log(' --preview Show selected files and exclusions without writing config'); - console.log(' --dry-run Preview MCP config diffs without writing files'); - console.log(' --skip-index Skip the first index after writing configs'); - console.log(' --no-update-notice Skip the daily npm update notice'); - console.log(' --yes, -y Configure every detected tool without prompts'); - console.log(' --help, -h Show this help'); + console.log(' --scope MCP scope for client CLIs (default: project)'); + console.log(' --yes, -y Non-interactive (for CI scripts)'); + console.log(' --dry-run Preview commands and JSON diffs without applying'); + console.log(' --track-latest Write codebase-context@latest instead of pinning'); + console.log(' --skip-index Skip the first index after configuration'); + console.log(' --skip-server-check Skip MCP initialize smoke test'); + console.log(' --no-update-notice Skip the daily npm update notice'); console.log(' CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1 Disable the daily npm update notice'); + console.log('After pinning, run `codebase-context update` to refresh installed configs.'); } export async function handleInitCli(args: string[]): Promise { @@ -838,96 +887,112 @@ export async function handleInitCli(args: string[]): Promise { console.log(JSON.stringify(preview, null, 2)); return; } - console.log(`Set up Codebase Context in ${options.rootPath}`); - let clients = options.clients; + + clack.intro(`Codebase Context setup in ${options.rootPath}`); + let clients: InitClient[] = 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; + const detected = (await detectClients(options.rootPath)).filter(isInitClient); + if (!options.yes && process.stdin.isTTY && process.stdout.isTTY) { + const selected = await clack.multiselect({ + message: 'Choose tools to configure', + options: INIT_CLIENTS.map((client) => ({ + label: client.name + (detected.includes(client.id) ? ' (detected)' : ''), + value: client.id + })), + initialValues: detected + }); + if (clack.isCancel(selected)) { + clack.cancel('Setup cancelled.'); + return; + } + clients = selected; + } else { + clients = detected; + } } - if (!clients.length) + if (!clients.length) { throw new Error( - `No coding tools selected or detected. Choose with --client (${CLIENTS.map((client) => client.id).join(', ')}).` + `No tools selected or detected. Pass --clients (${INIT_CLIENTS.map((client) => client.id).join(', ')}).` ); - - const prepared = []; - for (const client of clients) { - const config = generateMcpConfig(client, { rootPath: options.rootPath }); - const existingText = await readExistingConfig(config.path); - const merged = - client === 'codex' - ? await _buildMergedCodexContent(config.path, config.content) - : await _buildMergedMcpContent(config.path, config.content, client); - const action = classifyInitChange(existingText, merged.content); - prepared.push({ - ...config, - content: merged.content, - action, - displayPath: formatDisplayPath(config.path, options.rootPath), - existingText - }); } - for (const config of prepared) { - if (config.action !== 'unchanged') { - console.log(formatConfigPreview(config.displayPath, config.existingText, config.content)); - console.log(''); + const plans = clients.map((client) => + planClientRegistration(client, { + rootPath: options.rootPath, + scope: options.scope, + trackLatest: options.trackLatest + }) + ); + + for (const plan of plans) { + console.log(describeRegistrationPlan(plan)); + if (plan.kind === 'json') { + const config = generateMcpConfig(plan.client, { + rootPath: options.rootPath, + trackLatest: options.trackLatest + }); + const existingText = await readExistingConfig(config.path); + const merged = await _buildMergedMcpContent(config.path, config.content, plan.client); + if (classifyInitChange(existingText, merged.content) !== 'unchanged') { + console.log( + formatConfigPreview( + formatDisplayPath(config.path, options.rootPath), + existingText, + merged.content + ) + ); + console.log(''); + } } } - const hasChanges = prepared.some((config) => config.action !== 'unchanged'); + const actionable = plans.filter((plan) => plan.kind !== 'skip'); if ( - hasChanges && + actionable.length && !options.yes && !options.dryRun && process.stdin.isTTY && process.stdout.isTTY ) { - const approved = await confirm({ message: 'Apply these MCP config changes?', default: true }); - if (!approved) { - console.log('Setup cancelled; no files written.'); + const approved = await clack.confirm({ message: 'Apply these changes?', initialValue: true }); + if (clack.isCancel(approved) || !approved) { + clack.cancel('Setup cancelled.'); return; } } - for (const config of prepared) { - if (config.action !== 'unchanged' && !options.dryRun) { - if (config.existingText !== null) { - const backupPath = await backupTinyConfig(config.path, config.existingText); - if (backupPath) { - console.log(`Backed up: ${formatDisplayPath(backupPath, options.rootPath)}`); - } - } - await fs.mkdir(path.dirname(config.path), { recursive: true }); - await atomicWrite(config.path, config.content); + for (const plan of plans) { + if (plan.kind === 'skip') { + console.log(describeRegistrationPlan(plan)); + continue; + } + if (plan.kind === 'cli') { + applyCliRegistration(plan, options); + } else { + await applyJsonRegistration(plan, options); } - console.log(describeInitChange(options.dryRun, config.action, config.displayPath)); } + if (options.dryRun) { - console.log('Dry run complete; no files written.'); + console.log('Dry run complete; no changes applied.'); + clack.outro('Dry run finished.'); return; } + if (!options.skipIndex) { await runInitialIndex(options.rootPath); } - console.log('Reopen your tools; approve the server or project trust if asked.'); + if (!options.skipServerCheck) { + const entrypoint = process.argv[1]; + if (!entrypoint) throw new Error('Cannot locate the codebase-context CLI entrypoint.'); + console.log('Checking MCP server startup...'); + await verifyMcpServerStarts(entrypoint, options.rootPath); + console.log('MCP server responded to initialize.'); + } + clack.outro('Setup complete. Reopen your tools and approve trust if prompted.'); } catch (error) { - if ( - error instanceof Error && - (error.name === 'ExitPromptError' || - error.name === 'AbortPromptError' || - error.name === 'CancelPromptError') - ) { - console.log('Setup cancelled; no files written.'); + if (clack.isCancel(error)) { + clack.cancel('Setup cancelled.'); return; } console.error(`Init failed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/cli-update.ts b/src/cli-update.ts new file mode 100644 index 0000000..853c40d --- /dev/null +++ b/src/cli-update.ts @@ -0,0 +1,101 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import { + _buildMergedCodexContent, + _buildMergedMcpContent, + generateMcpConfig, + resolveClientConfigPath +} from './cli-init.js'; +import type { Client } from './cli-init-types.js'; +import { PKG_VERSION, resolveMcpPackageSpec } from './cli-init-package.js'; +import { atomicWrite } from './utils/atomic-write.js'; + +export type UpdateOptions = { + rootPath: string; + clients: Client[]; + trackLatest: boolean; + dryRun: boolean; +}; + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export async function applyPinnedUpdate( + client: Client, + options: UpdateOptions +): Promise<'updated' | 'unchanged' | 'missing' | 'skipped'> { + if (client === 'vscode') { + return 'skipped'; + } + const generated = generateMcpConfig(client, { + rootPath: options.rootPath, + trackLatest: options.trackLatest + }); + if (generated.kind !== 'file') return 'skipped'; + + if (client === 'codex') { + const merged = await _buildMergedCodexContent(generated.path, generated.content); + const existing = await fs.readFile(generated.path, 'utf8').catch(() => null); + if (existing === null) return 'missing'; + if (merged.content === existing) return 'unchanged'; + if (options.dryRun) return 'updated'; + await atomicWrite(generated.path, merged.content); + return 'updated'; + } + + if (client === 'cursor' || client === 'opencode' || client === 'claude-code') { + const merged = await _buildMergedMcpContent(generated.path, generated.content, client); + const existing = await fs.readFile(generated.path, 'utf8').catch(() => null); + if (existing === null) return 'missing'; + if (merged.content === existing) return 'unchanged'; + if (options.dryRun) return 'updated'; + await atomicWrite(generated.path, merged.content); + return 'updated'; + } + + return 'skipped'; +} + +export async function handleUpdateCli(args: string[]): Promise { + const dryRun = args.includes('--dry-run'); + const trackLatest = args.includes('--track-latest'); + const help = args.includes('--help') || args.includes('-h'); + if (help) { + console.log('codebase-context update [options]'); + console.log(' Refresh pinned codebase-context@version entries written by init.'); + console.log(' --dry-run Preview updates without writing'); + console.log(' --track-latest Move configs to codebase-context@latest'); + console.log(' --client Repeatable (default: cursor, opencode, codex, claude-code)'); + console.log(` Default target version when pinned: ${PKG_VERSION}`); + return; + } + + const clients: Client[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--client' || arg === '--clients') { + const value = args[++index]; + if (!value) throw new Error(`Expected a client after ${arg}.`); + clients.push(value as Client); + } else if (arg?.startsWith('--client=') || arg?.startsWith('--clients=')) { + clients.push(arg.split('=')[1] as Client); + } + } + const selected = clients.length + ? [...new Set(clients)] + : (['cursor', 'opencode', 'codex', 'claude-code'] as Client[]); + const rootPath = path.resolve(process.env.CODEBASE_ROOT ?? process.cwd()); + + for (const client of selected) { + const result = await applyPinnedUpdate(client, { + rootPath, + clients: selected, + trackLatest, + dryRun + }); + const label = resolveClientConfigPath(client, rootPath); + console.log(`${dryRun ? 'Would update' : 'Updated'} ${client}: ${result} (${label})`); + } +} diff --git a/src/cli.ts b/src/cli.ts index 75438d3..59bc68c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ import { formatJson } from './cli-formatters.js'; import { handleMemoryCli } from './cli-memory.js'; export { handleMemoryCli } from './cli-memory.js'; import { handleInitCli } from './cli-init.js'; +import { handleUpdateCli } from './cli-update.js'; import { handleMapCli } from './cli-map.js'; import { loadProjectConfig } from './server/config.js'; import { cliProjectOptions } from './cli-project-options.js'; @@ -44,6 +45,7 @@ const _CLI_COMMANDS = [ 'refs', 'cycles', 'init', + 'update', 'map' ] as const; @@ -83,7 +85,8 @@ 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 [--client ] [--yes] [--dry-run] Interactive MCP setup wizard'); + console.log(' init [--clients ] [--yes] [--dry-run] Interactive MCP setup wizard'); + console.log(' update [--client ] [--dry-run] Refresh pinned MCP package versions'); console.log(' map [--json] [--pretty] Conventions map (indexes first if needed)'); console.log(''); console.log('Global flags:'); @@ -275,6 +278,9 @@ export async function handleCliCommand(argv: string[]): Promise { if (command === 'init') { return handleInitCli(argv.slice(1)); } + if (command === 'update') { + return handleUpdateCli(argv.slice(1)); + } if (command === 'map') { return handleMapCli(argv.slice(1)); diff --git a/tests/cli-entrypoint-runtime.test.ts b/tests/cli-entrypoint-runtime.test.ts index 3413363..1758389 100644 --- a/tests/cli-entrypoint-runtime.test.ts +++ b/tests/cli-entrypoint-runtime.test.ts @@ -174,7 +174,7 @@ describe('CLI entrypoint runtime', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('codebase-context [options]'); - expect(result.stdout).toContain('init [--client ]'); + expect(result.stdout).toContain('init [--clients ]'); expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND'); } finally { rmSync(tempDirectory, { recursive: true, force: true }); diff --git a/tests/cli-init-atomic.test.ts b/tests/cli-init-atomic.test.ts index 222988d..6e0735c 100644 --- a/tests/cli-init-atomic.test.ts +++ b/tests/cli-init-atomic.test.ts @@ -44,38 +44,41 @@ describe('init interrupted writes', () => { }); it('preserves the original config when publication fails after the temporary file is complete', async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-rename-')); - const file = path.join(root, '.mcp.json'); + const file = path.join(root, '.cursor', 'mcp.json'); const original = '{ "customSetting": "keep" }\n'; + await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, original); fault.rename = true; - await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', root]); + await handleInitCli([ + '--client', + 'cursor', + '--yes', + '--skip-index', + '--skip-server-check', + '--root', + root + ]); + expect(process.exitCode).toBe(1); + expect(await fs.readFile(file, 'utf8')).toBe(original); + expect(await fs.readFile(`${file}.bak`, 'utf8')).toBe(original); + }); + it('preserves cursor JSON on a partial write failure', async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-write-')); + const file = path.join(root, '.cursor', 'mcp.json'); + const original = '{ "customSetting": "keep" }\n'; + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, original); + fault.enabled = true; + await handleInitCli([ + '--client', + 'cursor', + '--yes', + '--skip-index', + '--skip-server-check', + '--root', + root + ]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); - expect(await fs.readdir(root).then((names) => names.sort())).toEqual( - ['.mcp.json', '.mcp.json.bak'].sort() - ); }); - it.each(['claude-code', 'codex'])( - 'preserves the entire existing %s config on a partial write failure', - async (client) => { - root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-write-')); - const file = - client === 'codex' - ? path.join(root, '.codex', 'config.toml') - : path.join(root, '.mcp.json'); - const original = - client === 'codex' - ? '# original instructions\nmodel = "keep"\n' - : '{ "customSetting": "keep" }\n'; - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, original); - fault.enabled = true; - await handleInitCli(['--client', client, '--yes', '--skip-index', '--root', root]); - expect(process.exitCode).toBe(1); - expect(await fs.readFile(file, 'utf8')).toBe(original); - expect((await fs.readdir(path.dirname(file))).sort()).toEqual( - [path.basename(file), `${path.basename(file)}.bak`].sort() - ); - } - ); }); diff --git a/tests/cli-init-backup.test.ts b/tests/cli-init-backup.test.ts index f78272a..b1584b9 100644 --- a/tests/cli-init-backup.test.ts +++ b/tests/cli-init-backup.test.ts @@ -21,11 +21,20 @@ describe('init config backup', () => { it('backs up a tiny existing config before updating', async () => { root = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-backup-')); - const configPath = path.join(root, '.mcp.json'); - const { writeFile } = await import('node:fs/promises'); + const configPath = path.join(root, '.cursor', 'mcp.json'); + const { mkdir, writeFile } = await import('node:fs/promises'); + await mkdir(path.dirname(configPath), { recursive: true }); await writeFile(configPath, '{ "note": "keep" }\n', 'utf8'); - await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', root]); + await handleInitCli([ + '--client', + 'cursor', + '--yes', + '--skip-index', + '--skip-server-check', + '--root', + root + ]); expect(await readFile(`${configPath}.bak`, 'utf8')).toBe('{ "note": "keep" }\n'); const updated = JSON.parse(await readFile(configPath, 'utf8')) as { diff --git a/tests/cli-init-dry-run-home.test.ts b/tests/cli-init-dry-run-home.test.ts index b978595..8761cc0 100644 --- a/tests/cli-init-dry-run-home.test.ts +++ b/tests/cli-init-dry-run-home.test.ts @@ -4,21 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import { handleInitCli } from '../src/cli-init.js'; -describe('init dry-run and user-scoped configs', () => { - const previousHome = process.env.HOME; - const previousUserProfile = process.env.USERPROFILE; +describe('init dry-run and scoped registration', () => { const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; - let tempHome = ''; let projectRoot = ''; let logs: string[] = []; afterEach(async () => { - process.env.HOME = previousHome; - process.env.USERPROFILE = previousUserProfile; if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; - if (tempHome) await fs.rm(tempHome, { recursive: true, force: true }); - tempHome = ''; + if (projectRoot) await fs.rm(projectRoot, { recursive: true, force: true }); projectRoot = ''; logs = []; }); @@ -37,59 +31,84 @@ describe('init dry-run and user-scoped configs', () => { return logs.join('\n'); } - it('dry-run writes Windsurf user config under a temp HOME without touching disk', async () => { - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-home-')); + it('dry-run previews Cursor JSON without writing files', async () => { projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - await fs.mkdir(path.join(tempHome, '.codeium', 'windsurf'), { recursive: true }); const output = await runInit([ '--client', - 'windsurf', + 'cursor', '--yes', '--dry-run', + '--skip-index', + '--skip-server-check', '--root', projectRoot ]); - expect(output).toContain(`Would create: ${path.join(tempHome, '.codeium', 'windsurf', 'mcp_config.json')}`); - expect(output).toContain('Dry run complete; no files written.'); - await expect( - fs.access(path.join(tempHome, '.codeium', 'windsurf', 'mcp_config.json')) - ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(output).toContain('Would create: .cursor/mcp.json'); + expect(output).toContain('Dry run complete; no changes applied.'); + await expect(fs.access(path.join(projectRoot, '.cursor', 'mcp.json'))).rejects.toMatchObject({ + code: 'ENOENT' + }); + }); + + it('dry-run previews VS Code user CLI without running code', async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); + + const output = await runInit([ + '--client', + 'vscode', + '--scope', + 'user', + '--yes', + '--dry-run', + '--skip-index', + '--skip-server-check', + '--root', + projectRoot + ]); + + expect(output).toContain('Would run:'); + expect(output).toContain('--add-mcp'); + expect(output).toContain('Dry run complete; no changes applied.'); }); - it('merges VS Code servers and reports an update on repeat dry-run', async () => { - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-home-')); + it('merges Cursor JSON and reports unchanged on repeat dry-run', async () => { projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - const vscodeDir = path.join(projectRoot, '.vscode'); - await fs.mkdir(vscodeDir, { recursive: true }); + const cursorDir = path.join(projectRoot, '.cursor'); + await fs.mkdir(cursorDir, { recursive: true }); await fs.writeFile( - path.join(vscodeDir, 'mcp.json'), - JSON.stringify( - { - servers: { - other: { command: 'echo', args: ['hi'] } - } - }, - null, - 2 - ) + path.join(cursorDir, 'mcp.json'), + JSON.stringify({ mcpServers: { other: { command: 'echo', args: ['hi'] } } }, null, 2) ); - const first = await runInit(['--client', 'vscode', '--yes', '--root', projectRoot]); - expect(first).toContain('Updated: .vscode/mcp.json'); + const first = await runInit([ + '--client', + 'cursor', + '--yes', + '--skip-index', + '--skip-server-check', + '--root', + projectRoot + ]); + expect(first).toContain('Updated: .cursor/mcp.json'); - const second = await runInit(['--client', 'vscode', '--yes', '--dry-run', '--root', projectRoot]); - expect(second).toContain('Unchanged: .vscode/mcp.json'); + const second = await runInit([ + '--client', + 'cursor', + '--yes', + '--dry-run', + '--skip-index', + '--skip-server-check', + '--root', + projectRoot + ]); + expect(second).toContain('Unchanged: .cursor/mcp.json'); - const saved = JSON.parse(await fs.readFile(path.join(vscodeDir, 'mcp.json'), 'utf8')) as { - servers: Record; + const saved = JSON.parse(await fs.readFile(path.join(cursorDir, 'mcp.json'), 'utf8')) as { + mcpServers: Record; }; - expect(saved.servers.other).toEqual({ command: 'echo', args: ['hi'] }); - expect(saved.servers['codebase-context']).toBeDefined(); + expect(saved.mcpServers.other).toEqual({ command: 'echo', args: ['hi'] }); + expect(saved.mcpServers['codebase-context']).toBeDefined(); }); }); diff --git a/tests/cli-init-saved-file.test.ts b/tests/cli-init-saved-file.test.ts index 01ec3d0..664b1cb 100644 --- a/tests/cli-init-saved-file.test.ts +++ b/tests/cli-init-saved-file.test.ts @@ -1,10 +1,10 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { handleInitCli } from '../src/cli-init.js'; +import { describe, expect, it } from 'vitest'; +import { _buildMergedCodexContent, generateMcpConfig } from '../src/cli-init.js'; -describe('Codex init with an existing saved config', () => { +describe('Codex merge with an existing saved config', () => { it.each([ { name: 'bare dotted keys', @@ -27,32 +27,23 @@ describe('Codex init with an existing saved config', () => { toml: "'mcp_servers'.'codebase-context' = { command = \"old\", args = [] }\n" } ])('refuses $name without changing the saved file', async ({ toml }) => { - const projectRoot = await mkdtemp(path.join(os.tmpdir(), 'cbc-codex-init-')); + const projectRoot = await mkdtemp(path.join(os.tmpdir(), 'cbc-codex-merge-')); const configPath = path.join(projectRoot, '.codex', 'config.toml'); const initialBytes = Buffer.from(toml, 'utf8'); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const previousExitCode = process.exitCode; try { await mkdir(path.dirname(configPath), { recursive: true }); await writeFile(configPath, initialBytes); - process.exitCode = undefined; + const generated = generateMcpConfig('codex', { rootPath: projectRoot }); + if (generated.kind !== 'file') throw new Error('expected file config'); - await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', projectRoot]); + await expect(_buildMergedCodexContent(generated.path, generated.content)).rejects.toThrow( + /already contains a different codebase-context configuration/i + ); const persistedBytes = await readFile(configPath); - expect({ exitCode: process.exitCode, persistedBytes }).toEqual({ - exitCode: 1, - persistedBytes: initialBytes - }); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('already contains a different codebase-context configuration') - ); + expect(persistedBytes).toEqual(initialBytes); } finally { - process.exitCode = previousExitCode; - errorSpy.mockRestore(); - logSpy.mockRestore(); await rm(projectRoot, { recursive: true, force: true }); } }); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index b684325..ae7b3b8 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -19,13 +19,29 @@ vi.mock('node:fs', async (importOriginal) => { }; }); -vi.mock('@inquirer/prompts', () => ({ - checkbox: vi.fn(), +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + multiselect: vi.fn(), confirm: vi.fn().mockResolvedValue(true) })); +vi.mock('../src/cli-init-smoke.js', () => ({ + verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('../src/cli-init-runner.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCommand: vi.fn(() => ({ status: 0, stdout: '', stderr: '', error: null })) + }; +}); + import { promises as fsMod } from 'node:fs'; -import { checkbox, confirm } from '@inquirer/prompts'; +import * as clack from '@clack/prompts'; import { CLIENTS, generateMcpConfig, @@ -35,6 +51,7 @@ import { _buildMergedCodexContent, handleInitCli } from '../src/cli-init.js'; +import { runCommand } from '../src/cli-init-runner.js'; // --- generateMcpConfig --- @@ -44,8 +61,10 @@ const cacheEntrypoint = path.resolve( ); const npxCommand = 'npx.cmd'; +const PINNED = 'codebase-context@2.3.0'; + function npxLaunch(rootPath: string) { - return { command: npxCommand, args: ['-y', 'codebase-context@latest', rootPath] as const }; + return { command: npxCommand, args: ['-y', PINNED, rootPath] as const }; } function savedLaunch(rootPath: string) { @@ -125,7 +144,7 @@ describe('generateMcpConfig', () => { }; const server = parsed.mcpServers['codebase-context']; expect(server.command).toBe(npxCommand); - expect(server.args).toEqual(['-y', 'codebase-context@latest', projectRoot]); + expect(server.args).toEqual(['-y', PINNED, projectRoot]); }); it('Codex saves npx by default', () => { @@ -135,7 +154,7 @@ describe('generateMcpConfig', () => { if (result.kind !== 'file') return; expect(result.path).toBe(path.join(projectRoot, '.codex', 'config.toml')); expect(result.content).toBe( - `[mcp_servers.codebase-context]\ncommand = "npx"\nargs = ["-y", ${JSON.stringify('codebase-context@latest')}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` + `[mcp_servers.codebase-context]\ncommand = "npx"\nargs = ["-y", ${JSON.stringify(PINNED)}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` ); }); @@ -160,7 +179,7 @@ describe('generateMcpConfig', () => { }; expect(parsed.mcp['codebase-context']).toEqual({ type: 'local', - command: [npxCommand, '-y', 'codebase-context@latest', projectRoot] + command: [npxCommand, '-y', PINNED, projectRoot] }); }); @@ -482,8 +501,8 @@ describe('detectClients', () => { 'codex', 'cursor', 'vscode', - 'windsurf', 'opencode', + 'windsurf', 'claude-desktop' ]); expect(clients).toContain('codex'); @@ -562,7 +581,7 @@ describe('handleInitCli', () => { process.exitCode = undefined; Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: false }); - vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(clack.confirm).mockResolvedValue(true); }); afterAll(() => { @@ -574,29 +593,33 @@ describe('handleInitCli', () => { }); }); - it('configures all detected clients with --yes', async () => { + it('configures detected CLI clients with --yes', async () => { const projectRoot = path.resolve('C:/projects/Configured App'); detectDirectories('claude', 'codex'); - await handleInitCli(['--yes', '--skip-index', '--root', projectRoot]); + await handleInitCli([ + '--yes', + '--skip-index', + '--skip-server-check', + '--clients', + 'claude-code', + '--clients', + 'codex', + '--root', + projectRoot + ]); - expect(checkbox).not.toHaveBeenCalled(); - expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( - expect.arrayContaining([ - path.join(projectRoot, '.mcp.json'), - path.join(projectRoot, '.codex', 'config.toml') - ]) - ); - expect(writeFileMock).toHaveBeenCalledTimes(2); + expect(clack.multiselect).not.toHaveBeenCalled(); + expect(runCommand).toHaveBeenCalledTimes(2); }); 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, '--skip-index']); + await handleInitCli(['--root', projectRoot, '--skip-index', '--skip-server-check']); - expect(checkbox).not.toHaveBeenCalled(); + expect(clack.multiselect).not.toHaveBeenCalled(); expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( expect.arrayContaining([ path.join(projectRoot, '.cursor', 'mcp.json'), @@ -606,15 +629,17 @@ describe('handleInitCli', () => { expect(writeFileMock).toHaveBeenCalledTimes(2); }); - it('lets repeated --client options override detection', async () => { + it('lets repeated --clients options configure JSON-backed tools', async () => { const projectRoot = path.resolve('C:/projects/Explicit Clients'); await handleInitCli([ - '--client', - 'claude-code', - '--client', - 'codex', - '--yes', '--skip-index', + '--clients', + 'cursor', + '--clients', + 'opencode', + '--yes', + '--skip-index', + '--skip-server-check', '--root', projectRoot ]); @@ -622,15 +647,15 @@ describe('handleInitCli', () => { expect(accessMock).not.toHaveBeenCalled(); expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( expect.arrayContaining([ - path.join(projectRoot, '.mcp.json'), - path.join(projectRoot, '.codex', 'config.toml') + path.join(projectRoot, '.cursor', 'mcp.json'), + path.join(projectRoot, 'opencode.json') ]) ); expect(writeFileMock).toHaveBeenCalledTimes(2); }); it('reports no detected clients without writing when there is no terminal', async () => { - await handleInitCli(['--yes', '--skip-index', '--root', path.resolve('C:/projects/Empty')]); + await handleInitCli(['--yes', '--skip-index', '--skip-server-check', '--root', path.resolve('C:/projects/Empty')]); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -639,38 +664,34 @@ describe('handleInitCli', () => { it('shows detected clients checked in the terminal multi-select', async () => { detectDirectories('claude', 'codex'); - vi.mocked(checkbox).mockResolvedValue(['claude-code']); + vi.mocked(clack.multiselect).mockResolvedValue(['cursor']); 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'), '--skip-index']); + await handleInitCli(['--root', path.resolve('C:/projects/Interactive'), '--skip-index', '--skip-server-check']); - const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; - expect(choices).toEqual( + const options = vi.mocked(clack.multiselect).mock.calls[0]?.[0].options; + expect(options).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.objectContaining({ value: 'claude-code' }), + expect.objectContaining({ value: 'codex' }) ]) ); expect(writeFileMock).toHaveBeenCalledOnce(); }); it('leaves all no-detection terminal choices unchecked and writes nothing on empty selection', async () => { - vi.mocked(checkbox).mockResolvedValue([]); + vi.mocked(clack.multiselect).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'), '--skip-index']); + await handleInitCli(['--root', path.resolve('C:/projects/Cancelled'), '--skip-index', '--skip-server-check']); - const choices = vi.mocked(checkbox).mock.calls[0]?.[0].choices; - expect(choices).toEqual( + const options = vi.mocked(clack.multiselect).mock.calls[0]?.[0].options; + expect(options).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.objectContaining({ value: 'claude-code' }), + expect.objectContaining({ value: 'codex' }) ]) ); expect(writeFileMock).not.toHaveBeenCalled(); @@ -680,13 +701,13 @@ describe('handleInitCli', () => { it('does not write when the terminal prompt throws its cancellation error', async () => { detectDirectories('claude'); - vi.mocked(checkbox).mockRejectedValue( + vi.mocked(clack.multiselect).mockRejectedValue( Object.assign(new Error('User force closed the prompt'), { name: 'ExitPromptError' }) ); 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/Prompt Cancelled'), '--skip-index']); + await handleInitCli(['--root', path.resolve('C:/projects/Prompt Cancelled'), '--skip-index', '--skip-server-check']); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -697,7 +718,7 @@ describe('handleInitCli', () => { ReturnType >); - await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', path.resolve('C:/projects/File')]); + await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--skip-server-check', '--root', path.resolve('C:/projects/File')]); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); @@ -710,7 +731,7 @@ describe('handleInitCli', () => { await handleInitCli([ '--client', 'codex', - '--yes', '--skip-index', + '--yes', '--skip-index', '--skip-server-check', '--root', path.resolve('C:/projects/Missing Directory') ]); @@ -725,7 +746,7 @@ describe('handleInitCli', () => { const generated = generateMcpConfig('claude-code', { rootPath: projectRoot }); readFileMock.mockResolvedValue(generated.content as unknown as Buffer); - await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--root', projectRoot]); + await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--skip-server-check', '--root', projectRoot]); expect(writeFileMock).not.toHaveBeenCalled(); }); @@ -747,7 +768,7 @@ describe('handleInitCli', () => { 'claude-code', '--client', 'opencode', - '--yes', '--skip-index', + '--yes', '--skip-index', '--skip-server-check', '--root', projectRoot ]); @@ -761,14 +782,14 @@ describe('handleInitCli', () => { 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' - ); + const help = log.mock.calls.flat().join('\n'); + expect(help).toContain('Default MCP launch: npx -y codebase-context@'); + expect(help).toContain('codebase-context update'); } finally { log.mockRestore(); } - expect(checkbox).not.toHaveBeenCalled(); + expect(clack.multiselect).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); }); @@ -792,23 +813,14 @@ describe('_buildMergedCodexContent', () => { readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - const runCodexSetupWithExistingConfig = async (existingToml: string) => { + const expectCodexMergeConflict = async (existingToml: string) => { const projectRoot = path.resolve('C:/projects/Codex Existing'); - const initialBytes = Buffer.from(existingToml, 'utf8'); - let persistedBytes = Buffer.from(initialBytes); readFileMock.mockResolvedValue(existingToml as unknown as Buffer); - writeFileMock.mockImplementation(async (_filePath, content) => { - persistedBytes = Buffer.from(content as string, 'utf8'); - }); - - const previousExitCode = process.exitCode; - process.exitCode = undefined; - try { - await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', projectRoot]); - return { initialBytes, persistedBytes, exitCode: process.exitCode }; - } finally { - process.exitCode = previousExitCode; - } + const generated = generateMcpConfig('codex', { rootPath: projectRoot, platform: 'win32' }); + if (generated.kind !== 'file') throw new Error('expected file config'); + await expect(_buildMergedCodexContent(generated.path, generated.content)).rejects.toThrow( + /already contains a different codebase-context configuration/i + ); }; it('preserves unrelated Codex TOML settings when adding the project server', async () => { @@ -909,11 +921,7 @@ describe('_buildMergedCodexContent', () => { ])( 'refuses to replace a Codex entry under $name without changing its bytes', async ({ toml }) => { - const result = await runCodexSetupWithExistingConfig(toml); - - expect(result.exitCode).toBe(1); - expect(writeFileMock).not.toHaveBeenCalled(); - expect(result.persistedBytes).toEqual(result.initialBytes); + await expectCodexMergeConflict(toml); } ); @@ -937,11 +945,7 @@ describe('_buildMergedCodexContent', () => { ])( 'refuses to replace a Codex entry under $name without changing its bytes', async ({ toml }) => { - const result = await runCodexSetupWithExistingConfig(toml); - - expect(result.exitCode).toBe(1); - expect(writeFileMock).not.toHaveBeenCalled(); - expect(result.persistedBytes).toEqual(result.initialBytes); + await expectCodexMergeConflict(toml); } ); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2debe67..1cb2c5a 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -21,11 +21,28 @@ vi.mock('../src/server/config.js', () => ({ loadProjectConfig: cliContextMocks.loadProjectConfig })); +vi.mock('../src/cli-init-smoke.js', () => ({ + verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('../src/cli-init-smoke.js', () => ({ + verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + multiselect: vi.fn(), + confirm: vi.fn().mockResolvedValue(true) +})); + vi.mock('../src/core/indexer.js', () => ({ - CodebaseIndexer: vi.fn().mockImplementation(() => ({ - index: cliContextMocks.indexer, - getProgress: vi.fn() - })) + CodebaseIndexer: class { + index = cliContextMocks.indexer; + getProgress = vi.fn(); + } })); import { handleCliCommand, handleMemoryCli } from '../src/cli.js'; @@ -64,16 +81,25 @@ describe('CLI', () => { else process.env.CODEBASE_ROOT = originalEnvRoot; }); - it('init configures the selected repository without preparing its index', async () => { + it('init configures cursor JSON without indexing when skipped', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-only-')); const previousExitCode = process.exitCode; + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; try { process.exitCode = undefined; - await handleCliCommand(['init', '--client', 'codex', '--yes', '--root', root]); + await handleCliCommand([ + 'init', + '--clients', + 'cursor', + '--yes', + '--root', + root, + '--skip-index', + '--skip-server-check' + ]); expect(cliContextMocks.indexer).not.toHaveBeenCalled(); - expect(cliContextMocks.loadProjectConfig).not.toHaveBeenCalled(); - expect(await fs.readFile(path.join(root, '.codex', 'config.toml'), 'utf8')).toContain( - '[mcp_servers.codebase-context]' + expect(await fs.readFile(path.join(root, '.cursor', 'mcp.json'), 'utf8')).toContain( + 'codebase-context' ); } finally { process.exitCode = previousExitCode; From a5702be2e0589309d1cccb2428af7335e39f2290 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 23:11:33 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor(cli):=20minimal=20init=20=E2=80=94?= =?UTF-8?q?=20detect,=20merge=20configs,=20dry-run=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove update command, npm notice cache, backups, indexing, smoke checks, and vendor CLI registration. Init only merges MCP config files for selected clients. Docs label wizard as unreleased candidate. Add dist/npm-pack exec tests. --- README.md | 19 +- docs/client-setup.md | 16 +- docs/demo/install.tape | 2 +- src/cli-init-package.ts | 15 -- src/cli-init-register.ts | 135 ------------- src/cli-init-runner.ts | 55 ----- src/cli-init-smoke.ts | 84 -------- src/cli-init-types.ts | 2 - src/cli-init-version-notice.ts | 94 --------- src/cli-init.ts | 276 ++++++-------------------- src/cli-update.ts | 101 ---------- src/cli.ts | 6 - tests/cli-init-atomic.test.ts | 21 +- tests/cli-init-backup.test.ts | 45 ----- tests/cli-init-bootstrap.test.ts | 2 +- tests/cli-init-built-exec.test.ts | 88 ++++++++ tests/cli-init-dry-run-home.test.ts | 43 +--- tests/cli-init-version-notice.test.ts | 67 ------- tests/cli-init.test.ts | 51 ++--- tests/cli.test.ts | 12 +- tests/indexer-preview.test.ts | 27 --- 21 files changed, 181 insertions(+), 980 deletions(-) delete mode 100644 src/cli-init-register.ts delete mode 100644 src/cli-init-runner.ts delete mode 100644 src/cli-init-smoke.ts delete mode 100644 src/cli-init-version-notice.ts delete mode 100644 src/cli-update.ts delete mode 100644 tests/cli-init-backup.test.ts create mode 100644 tests/cli-init-built-exec.test.ts delete mode 100644 tests/cli-init-version-notice.test.ts diff --git a/README.md b/README.md index 389586b..e2b2c50 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,25 @@ 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. -## Install (one command) +## Install (candidate wizard) -Use Node.js 22 or newer. From your project root: +Use Node.js 22 or newer. The interactive `init` wizard is **not in published 2.2.0**; it ships in the unreleased candidate. To try it after release (or from a local `npm pack`): ```bash -npx -y codebase-context init +npx -y codebase-context@latest init ``` [![Install MCP Server in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/link/mcp%2Finstall?name=codebase-context&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImNvZGViYXNlLWNvbnRleHQiXX0%3D) [![Install in VS Code](https://img.shields.io/badge/VS%20Code-Install%20MCP-blue?logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22:%22codebase-context%22,%22command%22:%22npx%22,%22args%22:%5B%22-y%22,%22codebase-context%22%5D%7D) -The wizard uses `@clack/prompts`, detects installed tools, runs each vendor CLI where available (`claude mcp add`, `codex mcp add`, `code --add-mcp`), merges JSON only for Cursor and OpenCode, pins `codebase-context@` by default, previews changes, runs a first index, and smoke-tests MCP `initialize`. Upgrades: `npx codebase-context update`. +The wizard detects installed tools, lets you choose clients (`@clack/prompts`), merges MCP config files under the project root, and supports `--dry-run` (print only, no writes). Default configs pin `codebase-context@`; use `--track-latest` for `@latest`. -Non-interactive CI: +CI example (candidate): ```bash -npx -y codebase-context init --yes --clients cursor,opencode --scope project --dry-run -npx -y codebase-context init --yes --clients cursor --scope project --skip-index --skip-server-check +npx -y codebase-context@latest init --yes --clients cursor --clients opencode --dry-run ``` -Use `init --track-latest` to opt into `codebase-context@latest` in written configs. Disable the daily npm update notice with `CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1`. +For the **current npm release**, use the manual client commands below — not `init`. ## Set up your AI client manually @@ -78,8 +77,8 @@ Other clients use their own setup commands: | Client | Shortest current setup | | --------------------------- | ---------------------------------------------------------------------------- | | Gemini CLI | `gemini mcp add --scope user codebase-context npx -y codebase-context` | -| Cursor | `codebase-context init --clients cursor` or add `.cursor/mcp.json` | -| VS Code with GitHub Copilot | `init --clients vscode --scope user` (`code --add-mcp`) or add `.vscode/mcp.json` manually | +| Cursor | `codebase-context init --clients cursor` (candidate) or add `.cursor/mcp.json` | +| VS Code with GitHub Copilot | add `.vscode/mcp.json` manually (see client setup guide) | | GitHub Copilot CLI | `copilot mcp add codebase-context -- npx -y codebase-context` | | Windsurf | add `~/.codeium/windsurf/mcp_config.json` manually (not in `init` yet) | diff --git a/docs/client-setup.md b/docs/client-setup.md index 982a596..5eeb8a6 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -103,23 +103,17 @@ On October 6 the exact published `2.2.0` `npx` entrypoint was tested without a f ## Setup wizard in the unreleased candidate -The interactive wizard is not in published 2.2.0. From your project root: +The interactive wizard is not in published 2.2.0. From your project root (candidate package or local build): ```bash -npx -y codebase-context init npx -y codebase-context@latest init -npx -y codebase-context init --yes --dry-run -npx -y codebase-context init --yes --clients codex --scope project --root /path/to/project --skip-index -npx -y codebase-context update +npx -y codebase-context@latest init --yes --dry-run +npx -y codebase-context@latest init --yes --clients codex --clients cursor --root /path/to/project ``` -With a TTY, omit `--yes` to pick clients (Space toggles, Enter confirms), review JSON diffs for Cursor/OpenCode, and confirm before writing. `--clients` is repeatable (`--client` is an alias). `--root` takes priority over `CODEBASE_ROOT` and the current folder. `--scope project|user` applies to vendor CLIs (default `project`). `--skip-index` skips the post-setup index; `--skip-server-check` skips the MCP `initialize` smoke test; `--no-update-notice` skips the daily npm version hint (`CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1` also works). Use `init --track-latest` to write `codebase-context@latest` instead of pinning the installed package version; run `codebase-context update` later to refresh pinned configs. +With a TTY, omit `--yes` to pick clients (Space toggles, Enter confirms), review diffs, and confirm before writing. `--clients` is repeatable (`--client` is an alias; comma-separated values work). `--root` takes priority over `CODEBASE_ROOT` and the current folder. `--dry-run` prints previews and writes nothing. `--track-latest` writes `codebase-context@latest` instead of pinning the installed package version. -Init clients: Claude Code and Codex (`claude mcp add`, `codex mcp add`), VS Code user scope (`code --add-mcp`; project scope skips because workspace MCP uses `.vscode/mcp.json`), Cursor (`.cursor/mcp.json`), OpenCode (`opencode.json`). Detection also looks for Windsurf and Claude Desktop, but the wizard does not configure them yet. Default launches pin `codebase-context@` plus your project folder; opt into `@latest` with `--track-latest`. - -Cursor and OpenCode merges preserve unrelated settings. Tiny JSON configs (≤16KiB) are copied to `*.bak` before an update. A conflicting custom Codebase Context entry in JSON or Codex TOML stops setup before writes. To replace a custom launch, remove only its Codebase Context entry and rerun setup or `update`. Restart the client and approve trust if asked. - -New candidate indexes default to model-free search. The wizard runs a first index with progress unless `--skip-index` or `--dry-run` is set, then smoke-tests MCP `initialize` on stdio unless `--skip-server-check` is set. +Init merges project MCP configs for Claude Code (`.mcp.json`), Codex (`.codex/config.toml`), Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), and OpenCode (`opencode.json`). Existing unrelated settings are preserved. A conflicting custom Codebase Context entry stops setup before writes. Restart clients and approve trust if asked. Record the wizard for docs with [VHS](https://github.com/charmbracelet/vhs) using [`docs/demo/install.tape`](./demo/install.tape); generate `install.gif` locally and avoid committing large binaries. diff --git a/docs/demo/install.tape b/docs/demo/install.tape index 0690ecf..959b386 100644 --- a/docs/demo/install.tape +++ b/docs/demo/install.tape @@ -11,7 +11,7 @@ Hide Type "cd /path/to/your/project" Enter Sleep 500ms -Type "npx -y codebase-context@latest init --yes --skip-index --no-update-notice" +Type "npx -y codebase-context@latest init --yes --dry-run" Enter Sleep 3s Show diff --git a/src/cli-init-package.ts b/src/cli-init-package.ts index 69587eb..4f97e69 100644 --- a/src/cli-init-package.ts +++ b/src/cli-init-package.ts @@ -7,18 +7,3 @@ export const PKG_VERSION: string = JSON.parse( export function resolveMcpPackageSpec(trackLatest: boolean): string { return trackLatest ? 'codebase-context@latest' : `codebase-context@${PKG_VERSION}`; } - -export function buildNpxLaunchArgs( - rootPath: string, - trackLatest: boolean, - platform: NodeJS.Platform = process.platform -): { command: string; args: string[]; shellCommand: string[] } { - const npx = platform === 'win32' ? 'npx.cmd' : 'npx'; - const packageSpec = resolveMcpPackageSpec(trackLatest); - const args = ['-y', packageSpec, rootPath]; - return { - command: npx, - args, - shellCommand: [npx, ...args] - }; -} diff --git a/src/cli-init-register.ts b/src/cli-init-register.ts deleted file mode 100644 index f54b055..0000000 --- a/src/cli-init-register.ts +++ /dev/null @@ -1,135 +0,0 @@ -import path from 'node:path'; -import type { Client } from './cli-init-types.js'; -import { buildNpxLaunchArgs, resolveMcpPackageSpec } from './cli-init-package.js'; -import { formatCommandPreview } from './cli-init-runner.js'; - -export type InitScope = 'user' | 'project'; - -export type CliRegistrationPlan = { - kind: 'cli'; - client: Client; - label: string; - file: string; - args: string[]; - cwd?: string; -}; - -export type JsonRegistrationPlan = { - kind: 'json'; - client: 'cursor' | 'opencode'; - label: string; - path: string; -}; - -export type SkippedRegistrationPlan = { - kind: 'skip'; - client: Client; - reason: string; -}; - -export type RegistrationPlan = CliRegistrationPlan | JsonRegistrationPlan | SkippedRegistrationPlan; - -export function isJsonConfigClient(client: Client): client is 'cursor' | 'opencode' { - return client === 'cursor' || client === 'opencode'; -} - -export function isCliConfigClient(client: Client): client is 'claude-code' | 'codex' | 'vscode' { - return client === 'claude-code' || client === 'codex' || client === 'vscode'; -} - -export function planClientRegistration( - client: Client, - options: { - rootPath: string; - scope: InitScope; - trackLatest: boolean; - platform?: NodeJS.Platform; - } -): RegistrationPlan { - const rootPath = path.resolve(options.rootPath); - const platform = options.platform ?? process.platform; - const launch = buildNpxLaunchArgs(rootPath, options.trackLatest, platform); - - if (client === 'cursor' || client === 'opencode') { - if (options.scope === 'user' && client === 'cursor') { - return { - kind: 'skip', - client, - reason: 'Cursor project MCP config lives in .cursor/mcp.json; rerun with --scope project.' - }; - } - return { - kind: 'json', - client, - label: client === 'cursor' ? '.cursor/mcp.json' : 'opencode.json', - path: - client === 'cursor' - ? path.join(rootPath, '.cursor', 'mcp.json') - : path.join(rootPath, 'opencode.json') - }; - } - - if (client === 'vscode' && options.scope === 'project') { - return { - kind: 'skip', - client, - reason: - 'VS Code workspace MCP uses .vscode/mcp.json; use --scope user for code --add-mcp or configure Cursor/OpenCode JSON manually.' - }; - } - - if (client === 'claude-code') { - return { - kind: 'cli', - client, - label: 'claude mcp add', - file: platform === 'win32' ? 'claude.exe' : 'claude', - args: [ - 'mcp', - 'add', - '--scope', - options.scope, - '--transport', - 'stdio', - 'codebase-context', - '--', - ...launch.shellCommand - ] - }; - } - - if (client === 'codex') { - return { - kind: 'cli', - client, - label: 'codex mcp add', - file: platform === 'win32' ? 'codex.exe' : 'codex', - args: ['mcp', 'add', 'codebase-context', '--', ...launch.shellCommand], - cwd: options.scope === 'project' ? rootPath : undefined - }; - } - - if (client === 'vscode') { - const payload = { - name: 'codebase-context', - command: 'npx', - args: ['-y', resolveMcpPackageSpec(options.trackLatest), rootPath] - }; - const code = platform === 'win32' ? 'code.cmd' : 'code'; - return { - kind: 'cli', - client, - label: 'code --add-mcp', - file: code, - args: ['--add-mcp', JSON.stringify(payload)] - }; - } - - return { kind: 'skip', client, reason: `Unsupported client "${client}".` }; -} - -export function describeRegistrationPlan(plan: RegistrationPlan): string { - if (plan.kind === 'skip') return `Skip ${plan.client}: ${plan.reason}`; - if (plan.kind === 'json') return `Edit ${plan.label} at ${plan.path}`; - return formatCommandPreview(plan.file, plan.args); -} diff --git a/src/cli-init-runner.ts b/src/cli-init-runner.ts deleted file mode 100644 index 3f902bd..0000000 --- a/src/cli-init-runner.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { spawnSync } from 'node:child_process'; - -export type CommandRunResult = { - status: number | null; - stdout: string; - stderr: string; - error: Error | null; -}; - -export type CommandRunner = ( - file: string, - args: string[], - options?: { cwd?: string; env?: NodeJS.ProcessEnv } -) => CommandRunResult; - -function defaultRunner( - file: string, - args: string[], - options: { cwd?: string; env?: NodeJS.ProcessEnv } = {} -): CommandRunResult { - const result = spawnSync(file, args, { - cwd: options.cwd, - env: options.env ?? process.env, - encoding: 'utf8', - shell: false - }); - return { - status: result.status, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - error: result.error ?? null - }; -} - -let commandRunner: CommandRunner = defaultRunner; - -export function runCommand( - file: string, - args: string[], - options?: { cwd?: string; env?: NodeJS.ProcessEnv } -): CommandRunResult { - return commandRunner(file, args, options); -} - -export function _setCommandRunnerForTests(runner: CommandRunner): void { - commandRunner = runner; -} - -export function _resetCommandRunnerForTests(): void { - commandRunner = defaultRunner; -} - -export function formatCommandPreview(file: string, args: string[]): string { - return [file, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(' '); -} diff --git a/src/cli-init-smoke.ts b/src/cli-init-smoke.ts deleted file mode 100644 index 3093a9d..0000000 --- a/src/cli-init-smoke.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { spawn } from 'node:child_process'; -import path from 'node:path'; - -const INIT_REQUEST = - '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"codebase-context-init","version":"0"}}}'; - -function frameMessage(body: string): string { - const bytes = Buffer.byteLength(body, 'utf8'); - return `Content-Length: ${bytes}\r\n\r\n${body}`; -} - -/** - * Spawn the MCP server entrypoint and confirm it responds to initialize on stdio. - */ -export async function verifyMcpServerStarts( - serverEntrypoint: string, - rootPath: string, - timeoutMs = 20_000 -): Promise { - const entry = path.resolve(serverEntrypoint); - const root = path.resolve(rootPath); - const child = spawn(process.execPath, [entry, root], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE: '1' } - }); - - let buffer = ''; - const done = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for MCP initialize response.')); - }, timeoutMs); - - const fail = (error: Error) => { - clearTimeout(timer); - reject(error); - }; - - child.on('error', fail); - child.stderr?.on('data', () => undefined); - child.stdout?.on('data', (chunk: Buffer) => { - buffer += chunk.toString('utf8'); - while (true) { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) break; - const header = buffer.slice(0, headerEnd); - const match = /Content-Length:\s*(\d+)/i.exec(header); - if (!match) { - buffer = buffer.slice(headerEnd + 4); - continue; - } - const length = Number.parseInt(match[1] ?? '', 10); - const bodyStart = headerEnd + 4; - if (buffer.length < bodyStart + length) break; - const body = buffer.slice(bodyStart, bodyStart + length); - buffer = buffer.slice(bodyStart + length); - try { - const parsed = JSON.parse(body) as { id?: number; result?: unknown; error?: unknown }; - if (parsed.id === 1 && (parsed.result !== undefined || parsed.error !== undefined)) { - clearTimeout(timer); - resolve(); - return; - } - } catch { - // keep reading - } - } - }); - - child.on('exit', (code) => { - if (code !== 0 && code !== null) { - fail(new Error(`MCP server exited before initialize (code ${code}).`)); - } - }); - }); - - child.stdin?.write(frameMessage(INIT_REQUEST)); - child.stdin?.write(frameMessage('{"jsonrpc":"2.0","method":"notifications/initialized"}')); - - try { - await done; - } finally { - child.kill('SIGTERM'); - } -} diff --git a/src/cli-init-types.ts b/src/cli-init-types.ts index 089a574..11178f4 100644 --- a/src/cli-init-types.ts +++ b/src/cli-init-types.ts @@ -48,8 +48,6 @@ export type Client = (typeof DETECT_CLIENTS)[number]['id']; export const CLIENTS = DETECT_CLIENTS; -export type InitScope = 'user' | 'project'; - export const INIT_CLIENT_ID_SET: ReadonlySet = new Set( INIT_CLIENTS.map((client) => client.id) ); diff --git a/src/cli-init-version-notice.ts b/src/cli-init-version-notice.ts deleted file mode 100644 index 75d58fd..0000000 --- a/src/cli-init-version-notice.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { homedir } from 'node:os'; - -const CACHE_DIR = '.codebase-context'; -const CACHE_FILE = 'init-update-notice.json'; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; - -type NoticeCache = { - checkedAtMs: number; - latestVersion?: string; -}; - -function skipUpdateNotice(env: NodeJS.ProcessEnv = process.env): boolean { - const flag = env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE ?? env.CODEBASE_CONTEXT_NO_UPDATE_NOTICE; - if (!flag) return false; - const normalized = flag.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; -} - -function resolveCachePath(env: NodeJS.ProcessEnv = process.env): string { - const home = env.HOME || env.USERPROFILE || homedir(); - return path.join(home, CACHE_DIR, CACHE_FILE); -} - -async function readCache(cachePath: string): Promise { - try { - const raw = await fs.readFile(cachePath, 'utf8'); - const parsed = JSON.parse(raw) as NoticeCache; - if (typeof parsed.checkedAtMs !== 'number') return null; - return parsed; - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { - return null; - } - return null; - } -} - -async function writeCache(cachePath: string, cache: NoticeCache): Promise { - await fs.mkdir(path.dirname(cachePath), { recursive: true }); - await fs.writeFile(cachePath, JSON.stringify(cache), 'utf8'); -} - -async function fetchLatestPublishedVersion( - packageName: string, - fetchImpl: typeof fetch = fetch -): Promise { - const response = await fetchImpl(`https://registry.npmjs.org/${packageName}/latest`, { - headers: { accept: 'application/json' } - }); - if (!response.ok) return undefined; - const body = (await response.json()) as { version?: string }; - return typeof body.version === 'string' ? body.version : undefined; -} - -/** - * Print at most one npm update notice per day. Uses a cache under ~/.codebase-context/. - * Disable with CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1 or init --no-update-notice. - */ -export async function maybePrintInitUpdateNotice( - installedVersion: string, - options: { - disabled?: boolean; - env?: NodeJS.ProcessEnv; - nowMs?: number; - fetchImpl?: typeof fetch; - } = {} -): Promise { - if (options.disabled || skipUpdateNotice(options.env)) return; - - const env = options.env ?? process.env; - const nowMs = options.nowMs ?? Date.now(); - const cachePath = resolveCachePath(env); - const cached = await readCache(cachePath); - if (cached && nowMs - cached.checkedAtMs < ONE_DAY_MS) { - return; - } - - let latestVersion: string | undefined; - try { - latestVersion = await fetchLatestPublishedVersion('codebase-context', options.fetchImpl); - } catch { - return; - } - if (!latestVersion) return; - - await writeCache(cachePath, { checkedAtMs: nowMs, latestVersion }); - if (latestVersion !== installedVersion) { - console.log( - `Update available: codebase-context@${latestVersion} is on npm (you have ${installedVersion}). Run \`codebase-context update\` to refresh pinned configs, or init with --track-latest.` - ); - } -} diff --git a/src/cli-init.ts b/src/cli-init.ts index 4361b64..d8993be 100644 --- a/src/cli-init.ts +++ b/src/cli-init.ts @@ -1,17 +1,7 @@ -import { cliProjectOptions } from './cli-project-options.js'; -import { CodebaseIndexer } from './core/indexer.js'; -import { loadProjectConfig } from './server/config.js'; /** * Interactive setup wizard for codebase-context. - * Handles `codebase-context init` — detects clients and merges project MCP configs - * for Claude Code, Cursor, Codex, VS Code (Copilot), Windsurf, and OpenCode. - * Config merge shapes follow each client's official docs (VS Code uses a top-level - * `servers` object per the MCP filesystem server workspace examples; Windsurf uses - * `~/.codeium/windsurf/mcp_config.json`). Use `--dry-run` to preview writes first, - * matching the preview-before-apply pattern from MCP filesystem `edit_file`. - * - * Config writes run only after all selected registrations pass preflight. - * File previews share the indexer's selection rules and project configuration. + * Detects MCP clients, lets the user choose tools, and merges project MCP config files. + * Use `--dry-run` to print previews without writing anything. */ import path from 'path'; @@ -23,30 +13,20 @@ import * as clack from '@clack/prompts'; import { homedir } from 'node:os'; import { constants } from 'node:fs'; import { formatConfigPreview } from './cli-init-preview.js'; -import { maybePrintInitUpdateNotice } from './cli-init-version-notice.js'; import { PKG_VERSION, resolveMcpPackageSpec } from './cli-init-package.js'; -import { - describeRegistrationPlan, - planClientRegistration, - type RegistrationPlan -} from './cli-init-register.js'; -import { formatCommandPreview, runCommand } from './cli-init-runner.js'; -import { verifyMcpServerStarts } from './cli-init-smoke.js'; import { CLIENTS, DETECT_CLIENTS, INIT_CLIENTS, type Client, type InitClient, - type InitScope, isInitClient } from './cli-init-types.js'; -export type { Client, InitClient, InitScope } from './cli-init-types.js'; +export type { Client, InitClient } from './cli-init-types.js'; export { CLIENTS, DETECT_CLIENTS, INIT_CLIENTS, PKG_VERSION, resolveMcpPackageSpec }; export const INIT_NPX_PACKAGE = resolveMcpPackageSpec(false); -export const CONFIG_BACKUP_MAX_BYTES = 16_384; export type McpConfigResult = { kind: 'file'; path: string; content: string }; @@ -640,12 +620,7 @@ type InitOptions = { rootPath: string; yes: boolean; help: boolean; - preview: boolean; dryRun: boolean; - skipIndex: boolean; - skipServerCheck: boolean; - noUpdateNotice: boolean; - scope: InitScope; trackLatest: boolean; }; @@ -658,100 +633,6 @@ function parseInitClient(value: string): InitClient { return value as InitClient; } -function parseScope(value: string): InitScope { - if (value === 'user' || value === 'project') return value; - throw new Error('Expected --scope user or --scope project.'); -} - -async function applyJsonRegistration( - plan: Extract, - options: InitOptions -): Promise<'create' | 'update' | 'unchanged'> { - const config = generateMcpConfig(plan.client, { - rootPath: options.rootPath, - trackLatest: options.trackLatest - }); - const existingText = await readExistingConfig(config.path); - const merged = await _buildMergedMcpContent(config.path, config.content, plan.client); - const action = classifyInitChange(existingText, merged.content); - if (action !== 'unchanged' && !options.dryRun) { - if (existingText !== null) { - const backupPath = await backupTinyConfig(config.path, existingText); - if (backupPath) { - console.log(`Backed up: ${formatDisplayPath(backupPath, options.rootPath)}`); - } - } - await fs.mkdir(path.dirname(config.path), { recursive: true }); - await atomicWrite(config.path, merged.content); - } - console.log( - describeInitChange(options.dryRun, action, formatDisplayPath(config.path, options.rootPath)) - ); - return action; -} - -function applyCliRegistration( - plan: Extract, - options: InitOptions -): void { - const preview = formatCommandPreview(plan.file, plan.args); - if (options.dryRun) { - console.log(`Would run: ${preview}`); - return; - } - const result = runCommand(plan.file, plan.args, { cwd: plan.cwd ?? options.rootPath }); - if (result.error) { - throw new Error(`${plan.label} failed: ${result.error.message}`); - } - if (result.status !== 0) { - throw new Error( - `${plan.label} exited with code ${result.status ?? 'unknown'}${result.stderr ? `: ${result.stderr.trim()}` : ''}` - ); - } - console.log(`Ran: ${preview}`); -} - -async function backupTinyConfig( - configPath: string, - existingText: string -): Promise { - if (Buffer.byteLength(existingText, 'utf8') > CONFIG_BACKUP_MAX_BYTES) return undefined; - const backupPath = `${configPath}.bak`; - try { - await fs.access(backupPath); - return undefined; - } catch (error) { - if (!isFileNotFound(error)) throw error; - } - await fs.writeFile(backupPath, existingText, 'utf8'); - return backupPath; -} - -async function runInitialIndex(rootPath: string): Promise { - const config = await loadProjectConfig(rootPath); - console.log(`Indexing ${rootPath} (first run)...`); - let lastLoggedProgress = { phase: '', percentage: -1 }; - const indexer = new CodebaseIndexer({ - rootPath, - ...cliProjectOptions(config, true), - onProgress: (progress) => { - const shouldLog = - progress.phase !== lastLoggedProgress.phase || - (progress.percentage % 10 === 0 && progress.percentage !== lastLoggedProgress.percentage); - if (shouldLog) { - console.error(`[${progress.phase}] ${progress.percentage}%`); - lastLoggedProgress = { phase: progress.phase, percentage: progress.percentage }; - } - } - }); - const stats = await indexer.index(); - console.log( - `Index ready: ${stats.indexedFiles} files, ${stats.totalChunks} chunks in ${( - stats.duration / 1000 - ).toFixed(1)}s` - ); -} - function formatDisplayPath(configPath: string, rootPath: string): string { const relative = path.relative(rootPath, configPath); if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; @@ -783,6 +664,35 @@ function describeInitChange( return `${prefix}: ${displayPath}`; } +function appendInitClients(clients: InitClient[], raw: string): void { + for (const part of raw.split(',')) { + const trimmed = part.trim(); + if (trimmed) clients.push(parseInitClient(trimmed)); + } +} + +async function mergeInitClient( + client: InitClient, + options: Pick +): Promise<{ + config: McpConfigResult; + merged: { content: string }; + existingText: string | null; + action: 'create' | 'update' | 'unchanged'; +}> { + const config = generateMcpConfig(client, { + rootPath: options.rootPath, + trackLatest: options.trackLatest + }); + const merged = + client === 'codex' + ? await _buildMergedCodexContent(config.path, config.content) + : await _buildMergedMcpContent(config.path, config.content, client); + const existingText = await readExistingConfig(config.path); + const action = classifyInitChange(existingText, merged.content); + return { config, merged, existingText, action }; +} + function parseInitOptions(args: string[]): InitOptions { if (args.includes('--help') || args.includes('-h')) return { @@ -790,43 +700,24 @@ function parseInitOptions(args: string[]): InitOptions { rootPath: process.cwd(), yes: false, help: true, - preview: false, dryRun: false, - skipIndex: false, - skipServerCheck: false, - noUpdateNotice: false, - scope: 'project', trackLatest: false }; const clients: InitClient[] = []; let yes = false; - let preview = false; let dryRun = false; - let skipIndex = false; - let skipServerCheck = false; - let noUpdateNotice = false; - let scope: InitScope = 'project'; let trackLatest = false; for (let index = 0; index < args.length; index++) { const arg = args[index]; - if (arg === '--preview') preview = true; - else if (arg === '--dry-run') dryRun = true; + if (arg === '--dry-run') dryRun = true; else if (arg === '--yes' || arg === '-y') yes = true; - else if (arg === '--skip-index') skipIndex = true; - else if (arg === '--skip-server-check') skipServerCheck = true; - else if (arg === '--no-update-notice') noUpdateNotice = true; else if (arg === '--track-latest') trackLatest = true; - else if (arg === '--scope') { - const value = args[++index]; - if (!value) throw new Error('Expected user or project after --scope.'); - scope = parseScope(value); - } else if (arg?.startsWith('--scope=')) scope = parseScope(arg.slice('--scope='.length)); else if (arg === '--client' || arg === '--clients') { const value = args[++index]; if (!value || value.startsWith('--')) throw new Error(`Expected a client after ${arg}.`); - clients.push(parseInitClient(value)); + appendInitClients(clients, value); } else if (arg?.startsWith('--client=') || arg?.startsWith('--clients=')) { - clients.push(parseInitClient(arg.split('=')[1] ?? '')); + appendInitClients(clients, arg.split('=').slice(1).join('=')); } else if (arg === '--root') index++; else if (!arg?.startsWith('--root=')) throw new Error(`Unknown init option "${arg}".`); } @@ -835,37 +726,24 @@ function parseInitOptions(args: string[]): InitOptions { rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT), yes, help: false, - preview, dryRun, - skipIndex, - skipServerCheck, - noUpdateNotice, - scope, trackLatest }; } function printInitUsage(): void { console.log('codebase-context init [options]'); + console.log('Detect tools, choose clients, merge MCP config files under the project root.'); console.log( - 'Interactive setup: client CLIs for Claude Code, Codex, and VS Code; JSON merge for Cursor and OpenCode.' - ); - console.log( - `Default MCP launch: npx -y codebase-context@${PKG_VERSION} (use --track-latest for @latest).` + `Default launch in configs: npx -y codebase-context@${PKG_VERSION} (--track-latest for @latest).` ); console.log( ' --clients Repeatable client id (alias: --client): ' + INIT_CLIENTS.map((client) => client.id).join(', ') ); - console.log(' --scope MCP scope for client CLIs (default: project)'); console.log(' --yes, -y Non-interactive (for CI scripts)'); - console.log(' --dry-run Preview commands and JSON diffs without applying'); + console.log(' --dry-run Print previews without writing any files'); console.log(' --track-latest Write codebase-context@latest instead of pinning'); - console.log(' --skip-index Skip the first index after configuration'); - console.log(' --skip-server-check Skip MCP initialize smoke test'); - console.log(' --no-update-notice Skip the daily npm update notice'); - console.log(' CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE=1 Disable the daily npm update notice'); - console.log('After pinning, run `codebase-context update` to refresh installed configs.'); } export async function handleInitCli(args: string[]): Promise { @@ -877,16 +755,6 @@ export async function handleInitCli(args: string[]): Promise { } if (!(await fs.stat(options.rootPath)).isDirectory()) throw new Error(`Project root is not a directory: ${options.rootPath}`); - await maybePrintInitUpdateNotice(PKG_VERSION, { disabled: options.noUpdateNotice }); - if (options.preview) { - const config = await loadProjectConfig(options.rootPath); - const preview = await new CodebaseIndexer({ - rootPath: options.rootPath, - ...cliProjectOptions(config, true) - }).previewFiles(); - console.log(JSON.stringify(preview, null, 2)); - return; - } clack.intro(`Codebase Context setup in ${options.rootPath}`); let clients: InitClient[] = options.clients; @@ -916,39 +784,22 @@ export async function handleInitCli(args: string[]): Promise { ); } - const plans = clients.map((client) => - planClientRegistration(client, { - rootPath: options.rootPath, - scope: options.scope, - trackLatest: options.trackLatest - }) - ); + const prepared = await Promise.all(clients.map((client) => mergeInitClient(client, options))); - for (const plan of plans) { - console.log(describeRegistrationPlan(plan)); - if (plan.kind === 'json') { - const config = generateMcpConfig(plan.client, { - rootPath: options.rootPath, - trackLatest: options.trackLatest - }); - const existingText = await readExistingConfig(config.path); - const merged = await _buildMergedMcpContent(config.path, config.content, plan.client); - if (classifyInitChange(existingText, merged.content) !== 'unchanged') { - console.log( - formatConfigPreview( - formatDisplayPath(config.path, options.rootPath), - existingText, - merged.content - ) - ); - console.log(''); - } - } + for (const item of prepared) { + if (item.action === 'unchanged') continue; + console.log( + formatConfigPreview( + formatDisplayPath(item.config.path, options.rootPath), + item.existingText, + item.merged.content + ) + ); + console.log(''); } - const actionable = plans.filter((plan) => plan.kind !== 'skip'); if ( - actionable.length && + prepared.some((item) => item.action !== 'unchanged') && !options.yes && !options.dryRun && process.stdin.isTTY && @@ -961,34 +812,21 @@ export async function handleInitCli(args: string[]): Promise { } } - for (const plan of plans) { - if (plan.kind === 'skip') { - console.log(describeRegistrationPlan(plan)); - continue; - } - if (plan.kind === 'cli') { - applyCliRegistration(plan, options); - } else { - await applyJsonRegistration(plan, options); + for (const item of prepared) { + const displayPath = formatDisplayPath(item.config.path, options.rootPath); + if (item.action !== 'unchanged' && !options.dryRun) { + await fs.mkdir(path.dirname(item.config.path), { recursive: true }); + await atomicWrite(item.config.path, item.merged.content); } + console.log(describeInitChange(options.dryRun, item.action, displayPath)); } if (options.dryRun) { - console.log('Dry run complete; no changes applied.'); + console.log('Dry run complete; no files written.'); clack.outro('Dry run finished.'); return; } - if (!options.skipIndex) { - await runInitialIndex(options.rootPath); - } - if (!options.skipServerCheck) { - const entrypoint = process.argv[1]; - if (!entrypoint) throw new Error('Cannot locate the codebase-context CLI entrypoint.'); - console.log('Checking MCP server startup...'); - await verifyMcpServerStarts(entrypoint, options.rootPath); - console.log('MCP server responded to initialize.'); - } clack.outro('Setup complete. Reopen your tools and approve trust if prompted.'); } catch (error) { if (clack.isCancel(error)) { diff --git a/src/cli-update.ts b/src/cli-update.ts deleted file mode 100644 index 853c40d..0000000 --- a/src/cli-update.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; -import { - _buildMergedCodexContent, - _buildMergedMcpContent, - generateMcpConfig, - resolveClientConfigPath -} from './cli-init.js'; -import type { Client } from './cli-init-types.js'; -import { PKG_VERSION, resolveMcpPackageSpec } from './cli-init-package.js'; -import { atomicWrite } from './utils/atomic-write.js'; - -export type UpdateOptions = { - rootPath: string; - clients: Client[]; - trackLatest: boolean; - dryRun: boolean; -}; - -function isJsonObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -export async function applyPinnedUpdate( - client: Client, - options: UpdateOptions -): Promise<'updated' | 'unchanged' | 'missing' | 'skipped'> { - if (client === 'vscode') { - return 'skipped'; - } - const generated = generateMcpConfig(client, { - rootPath: options.rootPath, - trackLatest: options.trackLatest - }); - if (generated.kind !== 'file') return 'skipped'; - - if (client === 'codex') { - const merged = await _buildMergedCodexContent(generated.path, generated.content); - const existing = await fs.readFile(generated.path, 'utf8').catch(() => null); - if (existing === null) return 'missing'; - if (merged.content === existing) return 'unchanged'; - if (options.dryRun) return 'updated'; - await atomicWrite(generated.path, merged.content); - return 'updated'; - } - - if (client === 'cursor' || client === 'opencode' || client === 'claude-code') { - const merged = await _buildMergedMcpContent(generated.path, generated.content, client); - const existing = await fs.readFile(generated.path, 'utf8').catch(() => null); - if (existing === null) return 'missing'; - if (merged.content === existing) return 'unchanged'; - if (options.dryRun) return 'updated'; - await atomicWrite(generated.path, merged.content); - return 'updated'; - } - - return 'skipped'; -} - -export async function handleUpdateCli(args: string[]): Promise { - const dryRun = args.includes('--dry-run'); - const trackLatest = args.includes('--track-latest'); - const help = args.includes('--help') || args.includes('-h'); - if (help) { - console.log('codebase-context update [options]'); - console.log(' Refresh pinned codebase-context@version entries written by init.'); - console.log(' --dry-run Preview updates without writing'); - console.log(' --track-latest Move configs to codebase-context@latest'); - console.log(' --client Repeatable (default: cursor, opencode, codex, claude-code)'); - console.log(` Default target version when pinned: ${PKG_VERSION}`); - return; - } - - const clients: Client[] = []; - for (let index = 0; index < args.length; index++) { - const arg = args[index]; - if (arg === '--client' || arg === '--clients') { - const value = args[++index]; - if (!value) throw new Error(`Expected a client after ${arg}.`); - clients.push(value as Client); - } else if (arg?.startsWith('--client=') || arg?.startsWith('--clients=')) { - clients.push(arg.split('=')[1] as Client); - } - } - const selected = clients.length - ? [...new Set(clients)] - : (['cursor', 'opencode', 'codex', 'claude-code'] as Client[]); - const rootPath = path.resolve(process.env.CODEBASE_ROOT ?? process.cwd()); - - for (const client of selected) { - const result = await applyPinnedUpdate(client, { - rootPath, - clients: selected, - trackLatest, - dryRun - }); - const label = resolveClientConfigPath(client, rootPath); - console.log(`${dryRun ? 'Would update' : 'Updated'} ${client}: ${result} (${label})`); - } -} diff --git a/src/cli.ts b/src/cli.ts index 59bc68c..164a7c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,6 @@ import { formatJson } from './cli-formatters.js'; import { handleMemoryCli } from './cli-memory.js'; export { handleMemoryCli } from './cli-memory.js'; import { handleInitCli } from './cli-init.js'; -import { handleUpdateCli } from './cli-update.js'; import { handleMapCli } from './cli-map.js'; import { loadProjectConfig } from './server/config.js'; import { cliProjectOptions } from './cli-project-options.js'; @@ -45,7 +44,6 @@ const _CLI_COMMANDS = [ 'refs', 'cycles', 'init', - 'update', 'map' ] as const; @@ -86,7 +84,6 @@ function printUsage(): void { console.log(' refs --symbol [--limit ] Symbol references'); console.log(' cycles [--scope ] Circular dependency detection'); console.log(' init [--clients ] [--yes] [--dry-run] Interactive MCP setup wizard'); - console.log(' update [--client ] [--dry-run] Refresh pinned MCP package versions'); console.log(' map [--json] [--pretty] Conventions map (indexes first if needed)'); console.log(''); console.log('Global flags:'); @@ -278,9 +275,6 @@ export async function handleCliCommand(argv: string[]): Promise { if (command === 'init') { return handleInitCli(argv.slice(1)); } - if (command === 'update') { - return handleUpdateCli(argv.slice(1)); - } if (command === 'map') { return handleMapCli(argv.slice(1)); diff --git a/tests/cli-init-atomic.test.ts b/tests/cli-init-atomic.test.ts index 6e0735c..693597b 100644 --- a/tests/cli-init-atomic.test.ts +++ b/tests/cli-init-atomic.test.ts @@ -49,18 +49,9 @@ describe('init interrupted writes', () => { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, original); fault.rename = true; - await handleInitCli([ - '--client', - 'cursor', - '--yes', - '--skip-index', - '--skip-server-check', - '--root', - root - ]); + await handleInitCli(['--client', 'cursor', '--yes', '--root', root]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); - expect(await fs.readFile(`${file}.bak`, 'utf8')).toBe(original); }); it('preserves cursor JSON on a partial write failure', async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-write-')); @@ -69,15 +60,7 @@ describe('init interrupted writes', () => { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, original); fault.enabled = true; - await handleInitCli([ - '--client', - 'cursor', - '--yes', - '--skip-index', - '--skip-server-check', - '--root', - root - ]); + await handleInitCli(['--client', 'cursor', '--yes', '--root', root]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); }); diff --git a/tests/cli-init-backup.test.ts b/tests/cli-init-backup.test.ts deleted file mode 100644 index b1584b9..0000000 --- a/tests/cli-init-backup.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { handleInitCli } from '../src/cli-init.js'; - -describe('init config backup', () => { - let root = ''; - const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; - - beforeEach(() => { - process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; - }); - - afterEach(async () => { - if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; - else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; - if (root) await rm(root, { recursive: true, force: true }); - root = ''; - }); - - it('backs up a tiny existing config before updating', async () => { - root = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-backup-')); - const configPath = path.join(root, '.cursor', 'mcp.json'); - const { mkdir, writeFile } = await import('node:fs/promises'); - await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, '{ "note": "keep" }\n', 'utf8'); - - await handleInitCli([ - '--client', - 'cursor', - '--yes', - '--skip-index', - '--skip-server-check', - '--root', - root - ]); - - expect(await readFile(`${configPath}.bak`, 'utf8')).toBe('{ "note": "keep" }\n'); - const updated = JSON.parse(await readFile(configPath, 'utf8')) as { - mcpServers: Record; - }; - expect(updated.mcpServers['codebase-context']).toBeDefined(); - }); -}); diff --git a/tests/cli-init-bootstrap.test.ts b/tests/cli-init-bootstrap.test.ts index 084075c..b38b471 100644 --- a/tests/cli-init-bootstrap.test.ts +++ b/tests/cli-init-bootstrap.test.ts @@ -132,7 +132,7 @@ describe('init preserves custom launchers', () => { await fs.mkdir(path.dirname(generated.path), { recursive: true }); await fs.writeFile(generated.path, original); try { - await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--root', root]); + 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 }); diff --git a/tests/cli-init-built-exec.test.ts b/tests/cli-init-built-exec.test.ts new file mode 100644 index 0000000..6cd579e --- /dev/null +++ b/tests/cli-init-built-exec.test.ts @@ -0,0 +1,88 @@ +import { execSync, spawnSync } from 'node:child_process'; +import { mkdtemp, rm, stat, mkdir } from 'node:fs/promises'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +let distEntry = ''; +let packedTarball = ''; + +beforeAll(() => { + execSync('pnpm run build', { cwd: repoRoot, stdio: 'ignore' }); + distEntry = path.join(repoRoot, 'dist', 'index.js'); + const packOutput = execSync('npm pack --silent', { cwd: repoRoot, encoding: 'utf8' }); + const tarballName = packOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.endsWith('.tgz')) + .at(-1); + if (!tarballName) throw new Error(`npm pack did not produce a tarball: ${packOutput}`); + packedTarball = path.join(repoRoot, tarballName); +}, 120_000); + +describe('built CLI init', () => { + it('init --dry-run via dist/index.js writes no config (Windows-style project path)', async () => { + const projectRoot = await mkdtemp(path.join(tmpdir(), 'cbc-dist-init-')); + const windowsStyleRoot = path.join(projectRoot, 'Example App'); + await mkdir(windowsStyleRoot, { recursive: true }); + try { + const result = spawnSync( + process.execPath, + [ + distEntry, + 'init', + '--yes', + '--clients', + 'cursor', + '--dry-run', + '--root', + windowsStyleRoot + ], + { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 } + ); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Dry run complete'); + expect(result.stdout).toContain('no files written'); + await expect(stat(path.join(windowsStyleRoot, '.cursor', 'mcp.json'))).rejects.toThrow(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it('init --dry-run via npm pack install writes nothing', async () => { + const installRoot = mkdtempSync(path.join(tmpdir(), 'cbc-packed-install-')); + const projectRoot = await mkdtemp(path.join(tmpdir(), 'cbc-packed-project-')); + try { + execSync(`npm install --no-save --ignore-scripts "${packedTarball}"`, { + cwd: installRoot, + stdio: 'pipe' + }); + const entry = path.join(installRoot, 'node_modules', 'codebase-context', 'dist', 'index.js'); + const result = spawnSync( + process.execPath, + [ + entry, + 'init', + '--yes', + '--clients', + 'cursor', + '--dry-run', + '--root', + projectRoot + ], + { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 } + ); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout); + } + expect(result.stdout).toContain('Dry run complete'); + await expect(stat(path.join(projectRoot, '.cursor', 'mcp.json'))).rejects.toThrow(); + } finally { + rmSync(installRoot, { recursive: true, force: true }); + await rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli-init-dry-run-home.test.ts b/tests/cli-init-dry-run-home.test.ts index 8761cc0..561bfbd 100644 --- a/tests/cli-init-dry-run-home.test.ts +++ b/tests/cli-init-dry-run-home.test.ts @@ -4,21 +4,17 @@ import os from 'node:os'; import path from 'node:path'; import { handleInitCli } from '../src/cli-init.js'; -describe('init dry-run and scoped registration', () => { - const previousSkipNotice = process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; +describe('init dry-run', () => { let projectRoot = ''; let logs: string[] = []; afterEach(async () => { - if (previousSkipNotice === undefined) delete process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE; - else process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = previousSkipNotice; if (projectRoot) await fs.rm(projectRoot, { recursive: true, force: true }); projectRoot = ''; logs = []; }); async function runInit(args: string[]) { - process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; logs = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { logs.push(parts.map(String).join(' ')); @@ -39,40 +35,17 @@ describe('init dry-run and scoped registration', () => { 'cursor', '--yes', '--dry-run', - '--skip-index', - '--skip-server-check', '--root', projectRoot ]); expect(output).toContain('Would create: .cursor/mcp.json'); - expect(output).toContain('Dry run complete; no changes applied.'); + expect(output).toContain('Dry run complete; no files written.'); await expect(fs.access(path.join(projectRoot, '.cursor', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('dry-run previews VS Code user CLI without running code', async () => { - projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); - - const output = await runInit([ - '--client', - 'vscode', - '--scope', - 'user', - '--yes', - '--dry-run', - '--skip-index', - '--skip-server-check', - '--root', - projectRoot - ]); - - expect(output).toContain('Would run:'); - expect(output).toContain('--add-mcp'); - expect(output).toContain('Dry run complete; no changes applied.'); - }); - it('merges Cursor JSON and reports unchanged on repeat dry-run', async () => { projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-init-project-')); const cursorDir = path.join(projectRoot, '.cursor'); @@ -82,15 +55,7 @@ describe('init dry-run and scoped registration', () => { JSON.stringify({ mcpServers: { other: { command: 'echo', args: ['hi'] } } }, null, 2) ); - const first = await runInit([ - '--client', - 'cursor', - '--yes', - '--skip-index', - '--skip-server-check', - '--root', - projectRoot - ]); + const first = await runInit(['--client', 'cursor', '--yes', '--root', projectRoot]); expect(first).toContain('Updated: .cursor/mcp.json'); const second = await runInit([ @@ -98,8 +63,6 @@ describe('init dry-run and scoped registration', () => { 'cursor', '--yes', '--dry-run', - '--skip-index', - '--skip-server-check', '--root', projectRoot ]); diff --git a/tests/cli-init-version-notice.test.ts b/tests/cli-init-version-notice.test.ts deleted file mode 100644 index 2ada0bf..0000000 --- a/tests/cli-init-version-notice.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { maybePrintInitUpdateNotice } from '../src/cli-init-version-notice.js'; - -describe('maybePrintInitUpdateNotice', () => { - let tempHome = ''; - - afterEach(async () => { - if (tempHome) await rm(tempHome, { recursive: true, force: true }); - tempHome = ''; - }); - - it('prints at most once per day using the cache', async () => { - tempHome = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-notice-')); - const logs: string[] = []; - const logSpy = vi.spyOn(console, 'log').mockImplementation((message) => { - logs.push(String(message)); - }); - const fetchImpl = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ version: '9.9.9' }) - }); - const nowMs = Date.now(); - - try { - await maybePrintInitUpdateNotice('2.3.0', { - env: { HOME: tempHome }, - nowMs, - fetchImpl: fetchImpl as unknown as typeof fetch - }); - await maybePrintInitUpdateNotice('2.3.0', { - env: { HOME: tempHome }, - nowMs: nowMs + 1000, - fetchImpl: fetchImpl as unknown as typeof fetch - }); - } finally { - logSpy.mockRestore(); - } - - expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(logs).toHaveLength(1); - expect(logs[0]).toContain('9.9.9'); - const cache = JSON.parse( - await readFile(path.join(tempHome, '.codebase-context', 'init-update-notice.json'), 'utf8') - ) as { latestVersion: string }; - expect(cache.latestVersion).toBe('9.9.9'); - }); - - it('respects CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE', async () => { - tempHome = await mkdtemp(path.join(os.tmpdir(), 'cbc-init-notice-skip-')); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const fetchImpl = vi.fn(); - - try { - await maybePrintInitUpdateNotice('2.3.0', { - env: { HOME: tempHome, CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE: '1' }, - fetchImpl: fetchImpl as unknown as typeof fetch - }); - } finally { - logSpy.mockRestore(); - } - - expect(fetchImpl).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/cli-init.test.ts b/tests/cli-init.test.ts index ae7b3b8..23536c9 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -28,18 +28,6 @@ vi.mock('@clack/prompts', () => ({ confirm: vi.fn().mockResolvedValue(true) })); -vi.mock('../src/cli-init-smoke.js', () => ({ - verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) -})); - -vi.mock('../src/cli-init-runner.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCommand: vi.fn(() => ({ status: 0, stdout: '', stderr: '', error: null })) - }; -}); - import { promises as fsMod } from 'node:fs'; import * as clack from '@clack/prompts'; import { @@ -51,7 +39,6 @@ import { _buildMergedCodexContent, handleInitCli } from '../src/cli-init.js'; -import { runCommand } from '../src/cli-init-runner.js'; // --- generateMcpConfig --- @@ -593,14 +580,12 @@ describe('handleInitCli', () => { }); }); - it('configures detected CLI clients with --yes', async () => { + it('configures detected clients with --yes via config files', async () => { const projectRoot = path.resolve('C:/projects/Configured App'); detectDirectories('claude', 'codex'); await handleInitCli([ '--yes', - '--skip-index', - '--skip-server-check', '--clients', 'claude-code', '--clients', @@ -610,23 +595,18 @@ describe('handleInitCli', () => { ]); expect(clack.multiselect).not.toHaveBeenCalled(); - expect(runCommand).toHaveBeenCalledTimes(2); + expect(vi.mocked(fsMod.rename).mock.calls.length).toBeGreaterThan(0); }); 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, '--skip-index', '--skip-server-check']); + await handleInitCli(['--root', projectRoot, '--dry-run']); expect(clack.multiselect).not.toHaveBeenCalled(); - expect(vi.mocked(fsMod.rename).mock.calls.map(([, filePath]) => filePath)).toEqual( - expect.arrayContaining([ - path.join(projectRoot, '.cursor', 'mcp.json'), - path.join(projectRoot, 'opencode.json') - ]) - ); - expect(writeFileMock).toHaveBeenCalledTimes(2); + expect(vi.mocked(fsMod.rename)).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); }); it('lets repeated --clients options configure JSON-backed tools', async () => { @@ -638,8 +618,6 @@ describe('handleInitCli', () => { '--clients', 'opencode', '--yes', - '--skip-index', - '--skip-server-check', '--root', projectRoot ]); @@ -655,7 +633,7 @@ describe('handleInitCli', () => { }); it('reports no detected clients without writing when there is no terminal', async () => { - await handleInitCli(['--yes', '--skip-index', '--skip-server-check', '--root', path.resolve('C:/projects/Empty')]); + await handleInitCli(['--yes', '--root', path.resolve('C:/projects/Empty')]); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -668,7 +646,7 @@ describe('handleInitCli', () => { 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'), '--skip-index', '--skip-server-check']); + await handleInitCli(['--root', path.resolve('C:/projects/Interactive')]); const options = vi.mocked(clack.multiselect).mock.calls[0]?.[0].options; expect(options).toEqual( @@ -685,7 +663,7 @@ describe('handleInitCli', () => { 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'), '--skip-index', '--skip-server-check']); + await handleInitCli(['--root', path.resolve('C:/projects/Cancelled')]); const options = vi.mocked(clack.multiselect).mock.calls[0]?.[0].options; expect(options).toEqual( @@ -707,7 +685,7 @@ describe('handleInitCli', () => { 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/Prompt Cancelled'), '--skip-index', '--skip-server-check']); + await handleInitCli(['--root', path.resolve('C:/projects/Prompt Cancelled')]); expect(writeFileMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); @@ -718,7 +696,7 @@ describe('handleInitCli', () => { ReturnType >); - await handleInitCli(['--client', 'codex', '--yes', '--skip-index', '--skip-server-check', '--root', path.resolve('C:/projects/File')]); + await handleInitCli(['--client', 'codex', '--yes', '--root', path.resolve('C:/projects/File')]); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); @@ -731,7 +709,7 @@ describe('handleInitCli', () => { await handleInitCli([ '--client', 'codex', - '--yes', '--skip-index', '--skip-server-check', + '--yes', '--root', path.resolve('C:/projects/Missing Directory') ]); @@ -746,7 +724,7 @@ describe('handleInitCli', () => { const generated = generateMcpConfig('claude-code', { rootPath: projectRoot }); readFileMock.mockResolvedValue(generated.content as unknown as Buffer); - await handleInitCli(['--client', 'claude-code', '--yes', '--skip-index', '--skip-server-check', '--root', projectRoot]); + await handleInitCli(['--client', 'claude-code', '--yes', '--root', projectRoot]); expect(writeFileMock).not.toHaveBeenCalled(); }); @@ -768,7 +746,7 @@ describe('handleInitCli', () => { 'claude-code', '--client', 'opencode', - '--yes', '--skip-index', '--skip-server-check', + '--yes', '--root', projectRoot ]); @@ -783,8 +761,7 @@ describe('handleInitCli', () => { try { await handleInitCli(['--help']); const help = log.mock.calls.flat().join('\n'); - expect(help).toContain('Default MCP launch: npx -y codebase-context@'); - expect(help).toContain('codebase-context update'); + expect(help).toContain('--dry-run'); } finally { log.mockRestore(); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1cb2c5a..dcd3974 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -21,14 +21,6 @@ vi.mock('../src/server/config.js', () => ({ loadProjectConfig: cliContextMocks.loadProjectConfig })); -vi.mock('../src/cli-init-smoke.js', () => ({ - verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) -})); - -vi.mock('../src/cli-init-smoke.js', () => ({ - verifyMcpServerStarts: vi.fn().mockResolvedValue(undefined) -})); - vi.mock('@clack/prompts', () => ({ intro: vi.fn(), outro: vi.fn(), @@ -93,9 +85,7 @@ describe('CLI', () => { 'cursor', '--yes', '--root', - root, - '--skip-index', - '--skip-server-check' + root ]); expect(cliContextMocks.indexer).not.toHaveBeenCalled(); expect(await fs.readFile(path.join(root, '.cursor', 'mcp.json'), 'utf8')).toContain( diff --git a/tests/indexer-preview.test.ts b/tests/indexer-preview.test.ts index 140eb72..7e98590 100644 --- a/tests/indexer-preview.test.ts +++ b/tests/indexer-preview.test.ts @@ -96,33 +96,6 @@ describe('index preview enumeration', () => { }); }); - it('honors project config exclusions in the Codex init preview', async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), 'indexer-preview-config-')); - await fs.mkdir(path.join(root, 'src'), { recursive: true }); - await fs.mkdir(path.join(root, 'private'), { recursive: true }); - await fs.writeFile(path.join(root, 'src', 'keep.ts'), 'export const keep = 1;\n'); - await fs.writeFile(path.join(root, 'private', 'secret.ts'), 'export const secret = 1;\n'); - const configPath = path.join(root, 'server-config.json'); - await fs.writeFile( - configPath, - JSON.stringify({ projects: [{ root, excludePatterns: ['**/private/**'] }] }) - ); - const previousConfigPath = process.env.CODEBASE_CONTEXT_CONFIG_PATH; - process.env.CODEBASE_CONTEXT_CONFIG_PATH = configPath; - const output = vi.spyOn(console, 'log').mockImplementation(() => undefined); - try { - await handleCliCommand(['init', '--client', 'codex', '--root', root, '--preview']); - const rendered = output.mock.calls.flat().join('\n'); - expect(rendered).toContain('src/keep.ts'); - expect(rendered).not.toContain('private/secret.ts'); - expect(rendered).toContain('**/private/**'); - } finally { - output.mockRestore(); - if (previousConfigPath === undefined) delete process.env.CODEBASE_CONTEXT_CONFIG_PATH; - else process.env.CODEBASE_CONTEXT_CONFIG_PATH = previousConfigPath; - } - }); - it('reports overflow with a count and bounded examples, without materializing an overflow list', () => { const files = Array.from({ length: 10025 }, (_, index) => `file-${index}.ts`);