diff --git a/README.md b/README.md index 0e62882..e2b2c50 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,27 @@ 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 (candidate wizard) + +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@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 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`. + +CI example (candidate): + +```bash +npx -y codebase-context@latest init --yes --clients cursor --clients opencode --dry-run +``` + +For the **current npm release**, use the manual client commands below — not `init`. + +## Set up your AI client manually Use Node.js 22 or newer. These commands use the current npm release of Codebase Context: @@ -21,14 +41,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 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. - -```bash -npx codebase-context init --yes # configure detected tools -npx codebase-context init --dry-run --yes # show Would create/update lines only -``` - -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. +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: @@ -64,10 +77,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` (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 | `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 d992460..5eeb8a6 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -103,33 +103,25 @@ 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 (candidate package or local build): ```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 - -# Configure one client for an explicit project. -node /path/to/codebase-context/dist/index.js init --client codex --root /path/to/project --yes - -# Inspect selected source files and exclusions without writing client config. -node /path/to/codebase-context/dist/index.js init --root /path/to/project --preview +npx -y codebase-context@latest init +npx -y codebase-context@latest init --yes --dry-run +npx -y codebase-context@latest init --yes --clients codex --clients cursor --root /path/to/project ``` -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. +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. -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. +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. -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 +129,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..959b386 --- /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 --dry-run" +Enter +Sleep 3s +Show + +Sleep 2s 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..4f97e69 --- /dev/null +++ b/src/cli-init-package.ts @@ -0,0 +1,9 @@ +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}`; +} 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-types.ts b/src/cli-init-types.ts new file mode 100644 index 0000000..11178f4 --- /dev/null +++ b/src/cli-init-types.ts @@ -0,0 +1,57 @@ +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 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.ts b/src/cli-init.ts index 1888986..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'; @@ -19,9 +9,24 @@ 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 * as clack from '@clack/prompts'; import { homedir } from 'node:os'; import { constants } from 'node:fs'; +import { formatConfigPreview } from './cli-init-preview.js'; +import { PKG_VERSION, resolveMcpPackageSpec } from './cli-init-package.js'; +import { + CLIENTS, + DETECT_CLIENTS, + INIT_CLIENTS, + type Client, + type InitClient, + isInitClient +} 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 type McpConfigResult = { kind: 'file'; path: string; content: string }; @@ -29,6 +34,9 @@ export type McpConfigOptions = { rootPath?: string; /** 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; /** @@ -40,51 +48,18 @@ 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' - } -] as const; - -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 +76,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'); @@ -118,7 +110,26 @@ 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); + 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 +146,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 +193,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 packageSpec = resolveMcpPackageSpec(options.trackLatest ?? false); + const args = ['-y', packageSpec, 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 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()); 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 +356,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 +460,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) { @@ -524,14 +616,23 @@ export async function _buildMergedCodexContent( } type InitOptions = { - clients: Client[]; + clients: InitClient[]; rootPath: string; yes: boolean; help: boolean; - preview: boolean; dryRun: boolean; + 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 formatDisplayPath(configPath: string, rootPath: string): string { const relative = path.relative(rootPath, configPath); if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) return relative; @@ -563,12 +664,33 @@ 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 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 { @@ -578,25 +700,25 @@ function parseInitOptions(args: string[]): InitOptions { rootPath: process.cwd(), yes: false, help: true, - preview: false, - dryRun: false + dryRun: false, + trackLatest: false }; - const clients: Client[] = []; + const clients: InitClient[] = []; let yes = false; - let preview = false; let dryRun = false; + 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 === '--client') { + else if (arg === '--track-latest') trackLatest = true; + else if (arg === '--client' || arg === '--clients') { 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 || value.startsWith('--')) throw new Error(`Expected a client after ${arg}.`); + appendInitClients(clients, value); + } else if (arg?.startsWith('--client=') || arg?.startsWith('--clients=')) { + appendInitClients(clients, arg.split('=').slice(1).join('=')); + } else if (arg === '--root') index++; else if (!arg?.startsWith('--root=')) throw new Error(`Unknown init option "${arg}".`); } return { @@ -604,29 +726,24 @@ function parseInitOptions(args: string[]): InitOptions { rootPath: resolveInitRoot(args, process.env.CODEBASE_ROOT), yes, help: false, - preview, - dryRun + dryRun, + 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( - '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.' + `Default launch in configs: npx -y codebase-context@${PKG_VERSION} (--track-latest for @latest).` ); - console.log('Codex setup line: codex mcp add codebase-context -- npx -y codebase-context'); console.log( - ' --client Choose a tool (repeatable): ' + - CLIENTS.map((client) => client.id).join(', ') + ' --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 Show MCP config changes without writing files'); - console.log(' --yes, -y Configure every detected tool without prompts'); - console.log(' --help, -h Show this help'); + console.log(' --yes, -y Non-interactive (for CI scripts)'); + console.log(' --dry-run Print previews without writing any files'); + console.log(' --track-latest Write codebase-context@latest instead of pinning'); } export async function handleInitCli(args: string[]): Promise { @@ -638,73 +755,82 @@ 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}`); - 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; - } - 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 = await Promise.all(clients.map((client) => mergeInitClient(client, options))); - 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) - }); + 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(''); } - for (const config of prepared) { - if (config.action !== 'unchanged' && !options.dryRun) { - await fs.mkdir(path.dirname(config.path), { recursive: true }); - await atomicWrite(config.path, config.content); + + if ( + prepared.some((item) => item.action !== 'unchanged') && + !options.yes && + !options.dryRun && + process.stdin.isTTY && + process.stdout.isTTY + ) { + const approved = await clack.confirm({ message: 'Apply these changes?', initialValue: true }); + if (clack.isCancel(approved) || !approved) { + clack.cancel('Setup cancelled.'); + return; + } + } + + 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, config.action, config.displayPath)); + console.log(describeInitChange(options.dryRun, item.action, displayPath)); } + if (options.dryRun) { console.log('Dry run complete; no files written.'); + clack.outro('Dry run finished.'); return; } - console.log('Reopen your tools; approve the server or project trust if asked.'); - console.log("The first search builds this repository's index."); + + 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.ts b/src/cli.ts index b074f43..164a7c1 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 [--clients ] [--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-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 b0d5f25..693597b 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; @@ -38,34 +44,24 @@ 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', '--root', root]); + await handleInitCli(['--client', 'cursor', '--yes', '--root', root]); + expect(process.exitCode).toBe(1); + expect(await fs.readFile(file, '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', '--root', root]); expect(process.exitCode).toBe(1); expect(await fs.readFile(file, 'utf8')).toBe(original); - expect(await fs.readdir(root)).toEqual(['.mcp.json']); }); - 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', '--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)]); - } - ); }); diff --git a/tests/cli-init-bootstrap.test.ts b/tests/cli-init-bootstrap.test.ts index 6134d67..b38b471 100644 --- a/tests/cli-init-bootstrap.test.ts +++ b/tests/cli-init-bootstrap.test.ts @@ -199,6 +199,7 @@ describe('init preserves custom launchers', () => { '--client', 'codex', '--yes', + '--skip-index', '--root', root ]); 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 ff95b43..561bfbd 100644 --- a/tests/cli-init-dry-run-home.test.ts +++ b/tests/cli-init-dry-run-home.test.ts @@ -4,18 +4,12 @@ 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; - let tempHome = ''; +describe('init dry-run', () => { let projectRoot = ''; let logs: string[] = []; afterEach(async () => { - process.env.HOME = previousHome; - process.env.USERPROFILE = previousUserProfile; - if (tempHome) await fs.rm(tempHome, { recursive: true, force: true }); - tempHome = ''; + if (projectRoot) await fs.rm(projectRoot, { recursive: true, force: true }); projectRoot = ''; logs = []; }); @@ -33,59 +27,51 @@ 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', '--root', projectRoot ]); - expect(output).toContain(`Would create: ${path.join(tempHome, '.codeium', 'windsurf', 'mcp_config.json')}`); + expect(output).toContain('Would create: .cursor/mcp.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' }); + await expect(fs.access(path.join(projectRoot, '.cursor', 'mcp.json'))).rejects.toMatchObject({ + code: 'ENOENT' + }); }); - 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', '--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', + '--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-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..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', '--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 b397c7f..23536c9 100644 --- a/tests/cli-init.test.ts +++ b/tests/cli-init.test.ts @@ -19,12 +19,17 @@ 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) })); import { promises as fsMod } from 'node:fs'; -import { checkbox } from '@inquirer/prompts'; +import * as clack from '@clack/prompts'; import { CLIENTS, generateMcpConfig, @@ -41,6 +46,13 @@ 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'; + +const PINNED = 'codebase-context@2.3.0'; + +function npxLaunch(rootPath: string) { + return { command: npxCommand, args: ['-y', PINNED, rootPath] as const }; +} function savedLaunch(rootPath: string) { return { @@ -52,10 +64,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 +94,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 +123,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', PINNED, 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(PINNED)}, ${JSON.stringify(projectRoot)}]\nstartup_timeout_sec = 120\n` + ); }); - it('Codex saves the node binary and the npx cache path', () => { - const result = generateMcpConfig('codex', savedLaunch(projectRoot)); + 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 +156,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', PINNED, 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 +184,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 +253,7 @@ describe('_buildMergedMcpContent', () => { const readFileMock = vi.mocked(fsMod.readFile); beforeEach(() => { + process.env.CODEBASE_CONTEXT_SKIP_UPDATE_NOTICE = '1'; vi.resetAllMocks(); }); @@ -192,7 +267,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 +288,7 @@ describe('_buildMergedMcpContent', () => { }); expect(parsed.mcpServers['codebase-context']).toMatchObject({ type: 'stdio', - command: path.resolve(process.execPath) + command: npxCommand }); }); @@ -229,7 +304,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 +404,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 +418,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 +437,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 +449,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 +460,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) => { @@ -414,8 +488,9 @@ describe('detectClients', () => { 'codex', 'cursor', 'vscode', + 'opencode', 'windsurf', - 'opencode' + 'claude-desktop' ]); expect(clients).toContain('codex'); expect(clients).not.toContain('claude-code'); @@ -479,6 +554,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 +568,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(clack.confirm).mockResolvedValue(true); }); afterAll(() => { @@ -503,46 +580,43 @@ describe('handleInitCli', () => { }); }); - it('configures all detected 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', '--root', projectRoot]); + await handleInitCli([ + '--yes', + '--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(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]); + await handleInitCli(['--root', projectRoot, '--dry-run']); - expect(checkbox).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(clack.multiselect).not.toHaveBeenCalled(); + expect(vi.mocked(fsMod.rename)).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); }); - 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', + '--clients', + 'cursor', + '--clients', + 'opencode', '--yes', '--root', projectRoot @@ -551,8 +625,8 @@ 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); @@ -568,38 +642,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')]); - 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')]); - 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(); @@ -609,7 +679,7 @@ 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 }); @@ -690,14 +760,13 @@ 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('--dry-run'); } finally { log.mockRestore(); } - expect(checkbox).not.toHaveBeenCalled(); + expect(clack.multiselect).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); }); @@ -709,6 +778,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); @@ -720,23 +790,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', '--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 () => { @@ -812,7 +873,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); @@ -837,11 +898,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); } ); @@ -865,11 +922,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..dcd3974 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -21,11 +21,20 @@ vi.mock('../src/server/config.js', () => ({ loadProjectConfig: cliContextMocks.loadProjectConfig })); +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 +73,23 @@ 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 + ]); 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; 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`); 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');