From 39c4522e145c964eb9981b9d7aece9198b0960f0 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 26 Mar 2026 10:38:31 +0200 Subject: [PATCH 1/7] feat: add `githits init` command for MCP server setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive command that scans for installed coding agents (Claude Code, Cursor, Windsurf, Claude Desktop, Codex CLI), lets users select which to configure, and sets up each with confirmation. Key design decisions: - CLI-based setup for agents with native commands (claude, codex) - Atomic config file writes (temp + rename) for others to prevent corruption - Full DI with ExecService, PromptService, FileSystemService for safe testing - Pure mergeServerConfig() for JSON operations, extensively tested - Does not require auth — uses lightweight deps, not createContainer() - Only catches ExitPromptError (Ctrl+C), rethrows unexpected errors Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 45 ++ docs/implementation/cli-commands.md | 21 + package.json | 1 + src/cli.ts | 5 + src/commands/index.ts | 6 + src/commands/init/agent-definitions.test.ts | 391 ++++++++++++++++ src/commands/init/agent-definitions.ts | 222 +++++++++ src/commands/init/index.ts | 23 + src/commands/init/init.test.ts | 363 +++++++++++++++ src/commands/init/init.ts | 227 +++++++++ src/commands/init/setup-handlers.test.ts | 491 ++++++++++++++++++++ src/commands/init/setup-handlers.ts | 242 ++++++++++ src/services/exec-service.ts | 50 ++ src/services/filesystem-service.ts | 34 ++ src/services/index.ts | 8 + src/services/prompt-service.ts | 55 +++ src/services/test-helpers.ts | 34 ++ 17 files changed, 2218 insertions(+) create mode 100644 src/commands/init/agent-definitions.test.ts create mode 100644 src/commands/init/agent-definitions.ts create mode 100644 src/commands/init/index.ts create mode 100644 src/commands/init/init.test.ts create mode 100644 src/commands/init/init.ts create mode 100644 src/commands/init/setup-handlers.test.ts create mode 100644 src/commands/init/setup-handlers.ts create mode 100644 src/services/exec-service.ts create mode 100644 src/services/prompt-service.ts diff --git a/bun.lock b/bun.lock index 6605e436..8fa77f13 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "githits", "dependencies": { + "@inquirer/prompts": "^8.3.2", "@modelcontextprotocol/sdk": "^1.23.0", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.2", @@ -60,6 +61,38 @@ "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.4", "", {}, "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ=="], + + "@inquirer/core": ["@inquirer/core@11.1.7", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ=="], + + "@inquirer/editor": ["@inquirer/editor@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/external-editor": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA=="], + + "@inquirer/expand": ["@inquirer/expand@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@2.0.4", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.4", "", {}, "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ=="], + + "@inquirer/input": ["@inquirer/input@5.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ=="], + + "@inquirer/number": ["@inquirer/number@4.0.10", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA=="], + + "@inquirer/password": ["@inquirer/password@5.0.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.3.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.2", "@inquirer/confirm": "^6.0.10", "@inquirer/editor": "^5.0.10", "@inquirer/expand": "^5.0.10", "@inquirer/input": "^5.0.10", "@inquirer/number": "^4.0.10", "@inquirer/password": "^5.0.10", "@inquirer/rawlist": "^5.2.6", "@inquirer/search": "^4.1.6", "@inquirer/select": "^5.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.2.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w=="], + + "@inquirer/search": ["@inquirer/search@4.1.6", "", { "dependencies": { "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ=="], + + "@inquirer/select": ["@inquirer/select@5.1.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/figures": "^2.0.4", "@inquirer/type": "^4.0.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA=="], + + "@inquirer/type": ["@inquirer/type@4.0.4", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.2.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.2.0", "@napi-rs/keyring-darwin-x64": "1.2.0", "@napi-rs/keyring-freebsd-x64": "1.2.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", "@napi-rs/keyring-linux-arm64-musl": "1.2.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-musl": "1.2.0", "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", "@napi-rs/keyring-win32-x64-msvc": "1.2.0" } }, "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg=="], @@ -224,12 +257,16 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "coffi": ["coffi@0.1.37", "", { "dependencies": { "strip-json-comments": "^5.0.3" } }, "sha512-ewO5Xis7sw7g54yI/3lJ/nNV90Er4ZnENeDORZjrs58T70MmwKFLZgevraNCz+RmB4KDKsYT1ui1wDB36iPWqQ=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], @@ -292,8 +329,14 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -398,6 +441,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 2a544331..ea4fa1cb 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -8,10 +8,24 @@ The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror t | Command | Required Args | Options | Description | |---|---|---|---| +| `init` | — | `-y, --yes` | Set up MCP server for coding agents | | `search ` | `-l, --lang ` | `--license `, `--explain`, `--json` | Search for code examples | | `languages [query]` | — | `--json` | List or filter supported languages | | `feedback ` | `--accept` or `--reject` | `-m, --message `, `--json` | Submit feedback on a search result | +### `githits init` + +``` +githits init # Interactive: scan, select agents, confirm each +githits init --yes # Non-interactive: configure all detected agents +``` + +Scans for installed coding agents and sets up GitHits MCP server for each. Supports Claude Code, Cursor, Windsurf, Claude Desktop, and Codex CLI. Uses CLI commands for agents that support them (Claude Code, Codex) and atomic config file writes for others (Cursor, Windsurf, Claude Desktop). + +This command does NOT use `createContainer()` — it creates its own lightweight dependencies since it doesn't need auth or API access. This is intentional: init should work before the user has authenticated. + +**File structure:** The init command uses a subdirectory (`src/commands/init/`) because it has distinct submodules (agent definitions, setup handlers, orchestrator). This is an accepted variation for commands with significant internal complexity. + ### `githits search` ``` @@ -75,6 +89,8 @@ Each command follows this pattern: 4. **Register in CLI** — Import and call `registerXxxCommand(program)` in `src/cli.ts` 5. **Update help text** — If the command is a primary workflow, add it to the `addHelpText("after", ...)` block +For complex commands with multiple submodules, a subdirectory (`src/commands/xxx/`) with an `index.ts` barrel is acceptable (see `init` command for example). + ## Error Handling - **Auth errors** — `requireAuth()` prints instructions and calls `process.exit(1)` @@ -103,6 +119,11 @@ All commands support two output modes: | `src/shared/require-auth.ts` | Auth guard shared with MCP server | | `src/shared/colors.ts` | ANSI color utilities and `shouldUseColors()` | | `src/container.ts` | Dependency container with `githitsService` | +| `src/commands/init/init.ts` | Init command orchestrator | +| `src/commands/init/agent-definitions.ts` | Agent detection and setup config | +| `src/commands/init/setup-handlers.ts` | CLI exec and config file merge logic | +| `src/services/prompt-service.ts` | Interactive prompt abstraction | +| `src/services/exec-service.ts` | CLI command execution abstraction | ## Related Documentation diff --git a/package.json b/package.json index c04797fd..9c3a3ea0 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "access": "public" }, "dependencies": { + "@inquirer/prompts": "^8.3.2", "@modelcontextprotocol/sdk": "^1.23.0", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.2", diff --git a/src/cli.ts b/src/cli.ts index 4d9ad04c..f7d2ce47 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { version } from "../package.json"; import { registerAuthStatusCommand, registerFeedbackCommand, + registerInitCommand, registerLanguagesCommand, registerLoginCommand, registerLogoutCommand, @@ -27,6 +28,7 @@ program "after", ` Getting started: + githits init Set up MCP for your coding agents githits login Authenticate with your GitHits account githits mcp Start MCP server for your AI assistant githits search "query" --lang python Search for code examples @@ -36,6 +38,9 @@ Docs: https://app.githits.com/docs/ Support: support@githits.com`, ); +// Setup command +registerInitCommand(program); + // Auth commands registerLoginCommand(program); registerLogoutCommand(program); diff --git a/src/commands/index.ts b/src/commands/index.ts index 3f812fd1..61457c2c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -9,6 +9,12 @@ export { feedbackAction, registerFeedbackCommand, } from "./feedback.js"; +export { + type InitDependencies, + type InitOptions, + initAction, + registerInitCommand, +} from "./init/index.js"; export { type LanguagesDependencies, type LanguagesOptions, diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts new file mode 100644 index 00000000..bfdaf84d --- /dev/null +++ b/src/commands/init/agent-definitions.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createMockFileSystemService } from "../../services/test-helpers.js"; +import { + type AgentDefinition, + agentDefinitions, + buildCheckboxChoices, + detectAgents, +} from "./agent-definitions.js"; + +describe("agentDefinitions", () => { + it("defines 5 agents", () => { + expect(agentDefinitions).toHaveLength(5); + }); + + it("has unique ids", () => { + const ids = agentDefinitions.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("has unique names", () => { + const names = agentDefinitions.map((a) => a.name); + expect(new Set(names).size).toBe(names.length); + }); +}); + +describe("detectPaths", () => { + it("claude-code uses ~/.claude/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.claude"]); + }); + + it("cursor uses ~/.cursor/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "cursor")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.cursor"]); + }); + + it("codex-cli uses ~/.codex/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "codex-cli")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.codex"]); + }); + + it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/custom/home"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + for (const agent of agentDefinitions) { + const paths = agent.detectPaths(fs); + for (const path of paths) { + expect(path).toContain("/custom/home"); + } + } + }); +}); + +describe("getSetupConfig", () => { + it("claude-code returns CLI setup with claude command", () => { + const fs = createMockFileSystemService(); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("cli"); + if (config.method === "cli") { + expect(config.command).toBe("claude"); + expect(config.args).toContain("mcp"); + expect(config.args).toContain("add"); + expect(config.args).toContain("--transport"); + expect(config.args).toContain("http"); + expect(config.args).toContain("GitHits"); + expect(config.args).toContain("--scope"); + expect(config.args).toContain("user"); + } + }); + + it("cursor returns config-file setup targeting mcp.json", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "cursor")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe("/home/test/.cursor/mcp.json"); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toHaveProperty("command", "npx"); + } + }); + + it("claude-desktop returns config-file setup", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toContain("claude_desktop_config.json"); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + } + }); + + it("claude-desktop uses Library/Application Support on darwin", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const config = agent.getSetupConfig(fs); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/Library/Application Support/Claude/claude_desktop_config.json", + ); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("claude-desktop uses .config on linux", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const config = agent.getSetupConfig(fs); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/.config/Claude/claude_desktop_config.json", + ); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("claude-desktop uses APPDATA on win32", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const config = agent.getSetupConfig(fs); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "C:\\Users\\test\\AppData\\Roaming/Claude/claude_desktop_config.json", + ); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } + }); + + it("claude-desktop falls back to AppData/Roaming on win32 without APPDATA", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + delete process.env.APPDATA; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const config = agent.getSetupConfig(fs); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "C:\\Users\\test/AppData/Roaming/Claude/claude_desktop_config.json", + ); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } + }); + + it("codex-cli returns CLI setup with codex command", () => { + const fs = createMockFileSystemService(); + const agent = agentDefinitions.find((a) => a.id === "codex-cli")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("cli"); + if (config.method === "cli") { + expect(config.command).toBe("codex"); + expect(config.args).toContain("mcp"); + expect(config.args).toContain("add"); + expect(config.args).toContain("GitHits"); + } + }); + + it("windsurf returns config-file setup targeting ~/.codeium/windsurf/mcp_config.json", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "windsurf")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/.codeium/windsurf/mcp_config.json", + ); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + } + }); + + it("windsurf detects via ~/.codeium/windsurf/ directory", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "windsurf")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.codeium/windsurf"]); + }); + + it("config-file agents use mcp-remote with getMcpUrl()", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const configFileAgents = agentDefinitions.filter( + (a) => a.setupMethod === "config-file", + ); + for (const agent of configFileAgents) { + const config = agent.getSetupConfig(fs); + if (config.method === "config-file") { + expect(config.serverConfig).toHaveProperty("command", "npx"); + const args = config.serverConfig.args as string[]; + expect(args).toContain("mcp-remote"); + } + } + }); + + it("uses GITHITS_MCP_URL env var when set", () => { + const originalUrl = process.env.GITHITS_MCP_URL; + process.env.GITHITS_MCP_URL = "https://staging.mcp.example.com"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + // Check a config-file agent + const cursor = agentDefinitions.find((a) => a.id === "cursor")!; + const cursorConfig = cursor.getSetupConfig(fs); + if (cursorConfig.method === "config-file") { + const args = cursorConfig.serverConfig.args as string[]; + expect(args).toContain("https://staging.mcp.example.com"); + } + // Check a CLI agent + const claude = agentDefinitions.find((a) => a.id === "claude-code")!; + const claudeConfig = claude.getSetupConfig(fs); + if (claudeConfig.method === "cli") { + expect(claudeConfig.args).toContain("https://staging.mcp.example.com"); + } + } finally { + if (originalUrl !== undefined) { + process.env.GITHITS_MCP_URL = originalUrl; + } else { + delete process.env.GITHITS_MCP_URL; + } + } + }); +}); + +describe("detectAgents", () => { + it("returns ids of agents whose directories exist", async () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(async (path: string) => { + return path === "/home/test/.claude" || path === "/home/test/.cursor"; + }), + }); + const detected = await detectAgents(agentDefinitions, fs); + expect(detected).toContain("claude-code"); + expect(detected).toContain("cursor"); + expect(detected).not.toContain("windsurf"); + expect(detected).not.toContain("claude-desktop"); + expect(detected).not.toContain("codex-cli"); + }); + + it("returns empty array when no agents detected", async () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(() => Promise.resolve(false)), + }); + const detected = await detectAgents(agentDefinitions, fs); + expect(detected).toEqual([]); + }); + + it("returns all ids when all agents detected", async () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(() => Promise.resolve(true)), + }); + const detected = await detectAgents(agentDefinitions, fs); + expect(detected).toHaveLength(agentDefinitions.length); + }); +}); + +describe("buildCheckboxChoices", () => { + it("marks detected agents as checked", () => { + const choices = buildCheckboxChoices(agentDefinitions, [ + "claude-code", + "cursor", + ]); + const claudeChoice = choices.find((c) => c.value === "claude-code")!; + const cursorChoice = choices.find((c) => c.value === "cursor")!; + const windsurfChoice = choices.find((c) => c.value === "windsurf")!; + expect(claudeChoice.checked).toBe(true); + expect(cursorChoice.checked).toBe(true); + expect(windsurfChoice.checked).toBe(false); + }); + + it("appends (detected) to detected agent names", () => { + const choices = buildCheckboxChoices(agentDefinitions, ["claude-code"]); + const claudeChoice = choices.find((c) => c.value === "claude-code")!; + const cursorChoice = choices.find((c) => c.value === "cursor")!; + expect(claudeChoice.name).toContain("(detected)"); + expect(cursorChoice.name).not.toContain("(detected)"); + }); + + it("returns one choice per agent definition", () => { + const choices = buildCheckboxChoices(agentDefinitions, []); + expect(choices).toHaveLength(agentDefinitions.length); + }); +}); diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts new file mode 100644 index 00000000..a52f8468 --- /dev/null +++ b/src/commands/init/agent-definitions.ts @@ -0,0 +1,222 @@ +import { getMcpUrl } from "../../services/config.js"; +import type { FileSystemService } from "../../services/index.js"; + +/** + * Setup configuration for agents that use a CLI command. + */ +export interface CliSetup { + method: "cli"; + /** Command to execute (e.g., "claude") */ + command: string; + /** Command arguments */ + args: string[]; +} + +/** + * Setup configuration for agents that need config file editing. + */ +export interface ConfigFileSetup { + method: "config-file"; + /** Absolute path to the config file */ + configPath: string; + /** Key in the config where MCP servers live (e.g., "mcpServers") */ + serversKey: string; + /** Server name to add */ + serverName: string; + /** Server config value to add */ + serverConfig: Record; +} + +export type SetupConfig = CliSetup | ConfigFileSetup; + +/** + * Represents a coding agent that can be configured with GitHits MCP server. + * Each definition knows how to detect whether the agent is installed + * and how to configure it. + */ +export interface AgentDefinition { + /** Display name shown to the user (e.g., "Claude Code") */ + name: string; + /** Unique identifier (e.g., "claude-code") */ + id: string; + /** Directories to check for detection. Uses FileSystemService for testability. */ + detectPaths: (fs: FileSystemService) => string[]; + /** How this agent is configured */ + setupMethod: "cli" | "config-file"; + /** Returns the setup config for this agent. Uses FileSystemService for path resolution. */ + getSetupConfig: (fs: FileSystemService) => SetupConfig; +} + +/** Returns the mcp-remote stdio config used by agents without native HTTP+OAuth */ +function mcpRemoteConfig(mcpUrl: string): Record { + return { + command: "npx", + args: ["-y", "mcp-remote", mcpUrl], + }; +} + +/** + * Returns platform-specific application data directory path. + * macOS: ~/Library/Application Support/ + * Windows: %APPDATA%/ + * Linux: ~/.config/ + */ +function getAppDataPath(fs: FileSystemService, appName: string): string { + const home = fs.getHomeDir(); + switch (process.platform) { + case "win32": + return fs.joinPath( + process.env.APPDATA ?? fs.joinPath(home, "AppData", "Roaming"), + appName, + ); + case "darwin": + return fs.joinPath(home, "Library", "Application Support", appName); + default: + return fs.joinPath(home, ".config", appName); + } +} + +/** Claude Code: detected by ~/.claude/ directory, configured via `claude` CLI */ +const claudeCode: AgentDefinition = { + name: "Claude Code", + id: "claude-code", + setupMethod: "cli", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".claude")], + getSetupConfig: () => ({ + method: "cli", + command: "claude", + args: [ + "mcp", + "add", + "--transport", + "http", + "GitHits", + "--scope", + "user", + getMcpUrl(), + ], + }), +}; + +/** Cursor: detected by ~/.cursor/ directory, configured via mcp.json */ +const cursor: AgentDefinition = { + name: "Cursor", + id: "cursor", + setupMethod: "config-file", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".cursor")], + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath(fs.getHomeDir(), ".cursor", "mcp.json"), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: mcpRemoteConfig(getMcpUrl()), + }), +}; + +/** + * Windsurf: detected by ~/.codeium/windsurf/ directory. + * Config path is ~/.codeium/windsurf/mcp_config.json on all platforms + * (per official Windsurf docs: https://docs.windsurf.com/windsurf/cascade/mcp). + */ +const windsurf: AgentDefinition = { + name: "Windsurf", + id: "windsurf", + setupMethod: "config-file", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codeium", "windsurf")], + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath( + fs.getHomeDir(), + ".codeium", + "windsurf", + "mcp_config.json", + ), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: mcpRemoteConfig(getMcpUrl()), + }), +}; + +/** Claude Desktop: detected by platform-specific Claude directory */ +const claudeDesktop: AgentDefinition = { + name: "Claude Desktop", + id: "claude-desktop", + setupMethod: "config-file", + detectPaths: (fs) => { + const appData = getAppDataPath(fs, "Claude"); + return [appData]; + }, + getSetupConfig: (fs) => { + const appData = getAppDataPath(fs, "Claude"); + return { + method: "config-file", + configPath: fs.joinPath(appData, "claude_desktop_config.json"), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: mcpRemoteConfig(getMcpUrl()), + }; + }, +}; + +/** Codex CLI: detected by ~/.codex/ directory, configured via `codex` CLI */ +const codexCli: AgentDefinition = { + name: "Codex CLI", + id: "codex-cli", + setupMethod: "cli", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codex")], + getSetupConfig: () => ({ + method: "cli", + command: "codex", + args: ["mcp", "add", "GitHits", "--url", getMcpUrl()], + }), +}; + +/** + * All supported agent definitions, ordered by popularity/likelihood. + * New agents should be added here. + */ +export const agentDefinitions: AgentDefinition[] = [ + claudeCode, + cursor, + windsurf, + claudeDesktop, + codexCli, +]; + +/** + * Detect which agents are installed by checking if their detection paths exist. + * Returns the IDs of agents whose directories were found. + */ +export async function detectAgents( + definitions: AgentDefinition[], + fs: FileSystemService, +): Promise { + const detected: string[] = []; + for (const agent of definitions) { + const paths = agent.detectPaths(fs); + for (const path of paths) { + if (await fs.isDirectory(path)) { + detected.push(agent.id); + break; + } + } + } + return detected; +} + +/** + * Build checkbox choices for the agent selection prompt. + * Detected agents are pre-checked. + */ +export function buildCheckboxChoices( + definitions: AgentDefinition[], + detectedIds: string[], +): { name: string; value: string; checked: boolean }[] { + return definitions.map((agent) => ({ + name: detectedIds.includes(agent.id) + ? `${agent.name} (detected)` + : agent.name, + value: agent.id, + checked: detectedIds.includes(agent.id), + })); +} diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts new file mode 100644 index 00000000..b137e5c0 --- /dev/null +++ b/src/commands/init/index.ts @@ -0,0 +1,23 @@ +export { + type AgentDefinition, + agentDefinitions, + buildCheckboxChoices, + type CliSetup, + type ConfigFileSetup, + detectAgents, + type SetupConfig, +} from "./agent-definitions.js"; +export { + type InitDependencies, + type InitOptions, + initAction, + registerInitCommand, +} from "./init.js"; +export { + executeCliSetup, + executeConfigFileSetup, + formatSetupPreview, + type MergeResult, + mergeServerConfig, + type SetupResult, +} from "./setup-handlers.js"; diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts new file mode 100644 index 00000000..9ec6be03 --- /dev/null +++ b/src/commands/init/init.test.ts @@ -0,0 +1,363 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import { ExitPromptError } from "@inquirer/core"; +import type { + ConfirmChoice, + PromptService, +} from "../../services/prompt-service.js"; +import { + createMockExecService, + createMockFileSystemService, + createMockPromptService, +} from "../../services/test-helpers.js"; +import type { InitDependencies } from "./init.js"; +import { initAction } from "./init.js"; + +/** Type-safe cast for checkbox mock overrides */ +type CheckboxMock = PromptService["checkbox"]; + +/** Suppress console.log during tests */ +let logSpy: ReturnType; + +beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy.mockRestore(); +}); + +/** Create default deps with overrides */ +function createDeps( + overrides: Partial = {}, +): InitDependencies { + return { + fileSystemService: createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(() => Promise.resolve(false)), + }), + promptService: createMockPromptService(), + execService: createMockExecService(), + ...overrides, + }; +} + +/** Create a FileSystemService mock that detects specific agents */ +function createFsWithDetection(detectedDirs: string[]) { + return createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(async (path: string) => detectedDirs.includes(path)), + getDirname: mock( + (path: string) => path.split("/").slice(0, -1).join("/") || "/", + ), + ensureDir: mock(() => Promise.resolve()), + readFile: mock(() => + Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + ), + atomicWriteFile: mock(() => Promise.resolve()), + }); +} + +/** Helper to extract log output as string array */ +function getLogOutput(): string[] { + return (logSpy.mock.calls as unknown[][]).map((c) => String(c[0] ?? "")); +} + +describe("initAction", () => { + it("detects agents, prompts, and configures selected ones", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + ), + }); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + expect(promptService.checkbox).toHaveBeenCalled(); + expect(promptService.confirm3).toHaveBeenCalled(); + expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); + }); + + it("only sets up selected agents, not all detected", async () => { + const fs = createFsWithDetection([ + "/home/test/.claude", + "/home/test/.cursor", + ]); + // User selects only cursor, not claude-code + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["cursor"])) as CheckboxMock, + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const execService = createMockExecService(); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + // exec should NOT be called (cursor uses config-file, not CLI) + expect(execService.exec).not.toHaveBeenCalled(); + // atomicWriteFile should be called for cursor config + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + it("stops prompting after 'always' response", async () => { + const fs = createFsWithDetection([ + "/home/test/.claude", + "/home/test/.cursor", + ]); + const confirm3 = mock(() => Promise.resolve("always" as ConfirmChoice)); + const promptService = createMockPromptService({ + checkbox: mock(() => + Promise.resolve(["claude-code", "cursor"]), + ) as CheckboxMock, + confirm3, + }); + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + ), + }); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + // confirm3 should be called only once (for first agent, then "always" kicks in) + expect(confirm3).toHaveBeenCalledTimes(1); + }); + + it("skips agent when user responds 'no'", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, + confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), + }); + const execService = createMockExecService(); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + // exec should NOT be called because user said no + expect(execService.exec).not.toHaveBeenCalled(); + }); + + it("--yes flag skips all prompts and configures all detected agents", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService(); + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + ), + }); + + await initAction( + { yes: true }, + { fileSystemService: fs, promptService, execService }, + ); + + expect(promptService.checkbox).not.toHaveBeenCalled(); + expect(promptService.confirm3).not.toHaveBeenCalled(); + expect(execService.exec).toHaveBeenCalled(); + }); + + it("--yes with no agents detected prints message and returns", async () => { + const fs = createFsWithDetection([]); + const promptService = createMockPromptService(); + const execService = createMockExecService(); + + await initAction( + { yes: true }, + { fileSystemService: fs, promptService, execService }, + ); + + expect(promptService.checkbox).not.toHaveBeenCalled(); + expect(execService.exec).not.toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("No coding agents detected")), + ).toBe(true); + }); + + it("continues to next agent when one fails", async () => { + const fs = createFsWithDetection([ + "/home/test/.claude", + "/home/test/.cursor", + ]); + // Claude exec will fail, cursor config write should still happen + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 1, stdout: "", stderr: "error" }), + ), + }); + const promptService = createMockPromptService({ + checkbox: mock(() => + Promise.resolve(["claude-code", "cursor"]), + ) as CheckboxMock, + confirm3: mock(() => Promise.resolve("always" as ConfirmChoice)), + }); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + // Both should be attempted: exec for claude, atomicWriteFile for cursor + expect(execService.exec).toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + it("handles all agents already configured", async () => { + // Cursor config already has GitHits + const existing = JSON.stringify({ + mcpServers: { GitHits: { command: "old" } }, + }); + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(async (path: string) => path === "/home/test/.cursor"), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + readFile: mock(() => Promise.resolve(existing)), + atomicWriteFile: mock(() => Promise.resolve()), + }); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["cursor"])) as CheckboxMock, + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ); + + // Should not write (already configured) + expect(fs.atomicWriteFile).not.toHaveBeenCalled(); + // Should mention "already configured" in output + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("already configured"))).toBe( + true, + ); + }); + + it("handles empty selection gracefully", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve([])) as CheckboxMock, + }); + const execService = createMockExecService(); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + expect(execService.exec).not.toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("No agents selected"))).toBe( + true, + ); + }); + + it("handles Ctrl+C on checkbox prompt gracefully", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => + Promise.reject(new ExitPromptError("User force closed")), + ) as CheckboxMock, + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("cancelled"))).toBe(true); + }); + + it("handles Ctrl+C on confirm3 prompt gracefully", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, + confirm3: mock(() => + Promise.reject(new ExitPromptError("User force closed")), + ), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("cancelled"))).toBe(true); + }); + + it("rethrows non-ExitPromptError from checkbox", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => + Promise.reject(new Error("Unexpected error")), + ) as CheckboxMock, + }); + + await expect( + initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ), + ).rejects.toThrow("Unexpected error"); + }); + + it("rethrows non-ExitPromptError from confirm3", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, + confirm3: mock(() => Promise.reject(new Error("Unexpected error"))), + }); + + await expect( + initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ), + ).rejects.toThrow("Unexpected error"); + }); + + it("shows 'Setup skipped' when all agents are skipped", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); + const promptService = createMockPromptService({ + checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, + confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), + }); + const execService = createMockExecService(); + + await initAction({}, { fileSystemService: fs, promptService, execService }); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Setup skipped"))).toBe(true); + }); +}); diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts new file mode 100644 index 00000000..0608cab8 --- /dev/null +++ b/src/commands/init/init.ts @@ -0,0 +1,227 @@ +import { ExitPromptError } from "@inquirer/core"; +import type { Command } from "commander"; +import type { ExecService } from "../../services/exec-service.js"; +import { ExecServiceImpl } from "../../services/exec-service.js"; +import type { FileSystemService } from "../../services/filesystem-service.js"; +import { FileSystemServiceImpl } from "../../services/filesystem-service.js"; +import type { + ConfirmChoice, + PromptService, +} from "../../services/prompt-service.js"; +import { PromptServiceImpl } from "../../services/prompt-service.js"; +import { + colorize, + error as errorFmt, + shouldUseColors, + success, + warning, +} from "../../shared/colors.js"; +import { + agentDefinitions, + buildCheckboxChoices, + detectAgents, +} from "./agent-definitions.js"; +import { + executeCliSetup, + executeConfigFileSetup, + formatSetupPreview, +} from "./setup-handlers.js"; + +/** Options for the init command */ +export interface InitOptions { + /** Skip all prompts, configure all detected agents */ + yes?: boolean; +} + +/** Dependencies for the init command (not from createContainer) */ +export interface InitDependencies { + fileSystemService: FileSystemService; + promptService: PromptService; + execService: ExecService; +} + +/** Tracks per-agent setup outcome for the summary */ +interface AgentOutcome { + name: string; + status: "success" | "already_configured" | "failed" | "skipped"; +} + +/** + * Core init logic, separated from CLI registration for testability. + * Scans for installed agents, prompts for selection, configures each sequentially. + */ +export async function initAction( + options: InitOptions, + deps: InitDependencies, +): Promise { + const useColors = shouldUseColors(); + const { fileSystemService, promptService, execService } = deps; + + // Header + console.log( + `\n ${colorize("GitHits", "bold", useColors)} — Set up MCP server for your coding agents\n`, + ); + + // Detect installed agents + console.log(" Scanning for installed agents...\n"); + const detectedIds = await detectAgents(agentDefinitions, fileSystemService); + + // Determine which agents to configure + let selectedIds: string[]; + + if (options.yes) { + // Non-interactive: use all detected agents + if (detectedIds.length === 0) { + console.log( + " No coding agents detected. Install an agent and try again.\n", + ); + return; + } + const names = agentDefinitions + .filter((a) => detectedIds.includes(a.id)) + .map((a) => a.name) + .join(", "); + console.log(` Detected: ${colorize(names, "cyan", useColors)}\n`); + selectedIds = detectedIds; + } else { + // Interactive: show checkbox + const choices = buildCheckboxChoices(agentDefinitions, detectedIds); + try { + selectedIds = await promptService.checkbox( + "Select agents to configure", + choices, + ); + } catch (err) { + if (err instanceof ExitPromptError) { + console.log("\n Setup cancelled.\n"); + return; + } + throw err; + } + } + + if (selectedIds.length === 0) { + console.log(" No agents selected.\n"); + return; + } + + // Sequential setup with confirmation + const outcomes: AgentOutcome[] = []; + let alwaysMode = options.yes ?? false; + + for (const agentId of selectedIds) { + const agent = agentDefinitions.find((a) => a.id === agentId); + if (!agent) continue; + + console.log(` Setting up ${colorize(agent.name, "bold", useColors)}...\n`); + + const config = agent.getSetupConfig(fileSystemService); + + // Show preview + const preview = formatSetupPreview(config); + for (const line of preview.split("\n")) { + console.log(` ${line}`); + } + console.log(); + + // Confirm (unless --yes or "always" mode) + if (!alwaysMode) { + let choice: ConfirmChoice; + try { + choice = await promptService.confirm3("Proceed?"); + } catch (err) { + if (err instanceof ExitPromptError) { + console.log("\n Setup cancelled.\n"); + return; + } + throw err; + } + + if (choice === "no") { + outcomes.push({ name: agent.name, status: "skipped" }); + console.log(); + continue; + } + if (choice === "always") { + alwaysMode = true; + } + } + + // Execute setup + const result = + config.method === "cli" + ? await executeCliSetup(config, execService) + : await executeConfigFileSetup(config, fileSystemService); + + // Record and display outcome + outcomes.push({ name: agent.name, status: result.status }); + + if (result.status === "success") { + console.log(` ${success(`${agent.name} configured`, useColors)}\n`); + } else if (result.status === "already_configured") { + console.log( + ` ${warning(`${agent.name} already configured`, useColors)}\n`, + ); + } else { + console.log(` ${errorFmt(result.message, useColors)}\n`); + } + } + + // Summary + const configured = outcomes.filter((o) => o.status === "success").length; + const alreadyDone = outcomes.filter( + (o) => o.status === "already_configured", + ).length; + const failed = outcomes.filter((o) => o.status === "failed").length; + const skipped = outcomes.filter((o) => o.status === "skipped").length; + + if (configured > 0 || alreadyDone > 0) { + console.log(" Done! GitHits is ready."); + } else if (failed > 0) { + console.log(" Setup completed with errors."); + } else if (skipped > 0) { + console.log(" Setup skipped."); + } + + if (failed > 0) { + console.log( + ` ${failed} agent${failed !== 1 ? "s" : ""} failed to configure.`, + ); + } + if (skipped > 0) { + console.log(` ${skipped} agent${skipped !== 1 ? "s" : ""} skipped.`); + } + + console.log(" Run `githits login` if you haven't authenticated yet.\n"); +} + +const INIT_DESCRIPTION = `Set up GitHits MCP server for your coding agents. + +Scans for installed agents (Claude Code, Cursor, Windsurf, Claude Desktop, +Codex CLI), lets you select which to configure, and sets up each one +with your confirmation. + +Supports both CLI-based setup (Claude Code, Codex) and config file +editing (Cursor, Windsurf, Claude Desktop) with atomic writes.`; + +/** + * Register the init command on the given program. + * Creates its own lightweight dependencies (no auth needed). + */ +export function registerInitCommand(program: Command) { + program + .command("init") + .summary("Set up MCP server for your coding agents") + .description(INIT_DESCRIPTION) + .option("-y, --yes", "Skip prompts, configure all detected agents") + .action(async (options: InitOptions) => { + const fileSystemService = new FileSystemServiceImpl(); + const promptService = new PromptServiceImpl(); + const execService = new ExecServiceImpl(); + await initAction(options, { + fileSystemService, + promptService, + execService, + }); + }); +} diff --git a/src/commands/init/setup-handlers.test.ts b/src/commands/init/setup-handlers.test.ts new file mode 100644 index 00000000..5ae1812d --- /dev/null +++ b/src/commands/init/setup-handlers.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + createMockExecService, + createMockFileSystemService, +} from "../../services/test-helpers.js"; +import type { CliSetup, ConfigFileSetup } from "./agent-definitions.js"; +import type { MergeResult } from "./setup-handlers.js"; +import { + executeCliSetup, + executeConfigFileSetup, + formatSetupPreview, + mergeServerConfig, +} from "./setup-handlers.js"; + +/** Assert that a MergeResult is "added" and return its content */ +function expectAdded(result: MergeResult): string { + expect(result.status).toBe("added"); + if (result.status !== "added") throw new Error("unreachable"); + return result.content; +} + +/** Assert that a MergeResult is "parse_error" and return its error */ +function expectParseError(result: MergeResult): string { + expect(result.status).toBe("parse_error"); + if (result.status !== "parse_error") throw new Error("unreachable"); + return result.error; +} + +// -- mergeServerConfig (pure function) -- + +describe("mergeServerConfig", () => { + const serverConfig = { + command: "npx", + args: ["-y", "mcp-remote", "https://mcp.githits.com"], + }; + + it("adds server to empty string input", () => { + const result = mergeServerConfig("", "mcpServers", "GitHits", serverConfig); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("adds server to whitespace-only content", () => { + const result = mergeServerConfig( + " \n ", + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("adds server to empty object", () => { + const result = mergeServerConfig( + "{}", + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("preserves existing servers", () => { + const existing = JSON.stringify({ + mcpServers: { + other: { command: "other-cmd" }, + }, + }); + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.other).toEqual({ command: "other-cmd" }); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("preserves other top-level keys", () => { + const existing = JSON.stringify({ + someOtherSetting: true, + mcpServers: {}, + }); + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.someOtherSetting).toBe(true); + }); + + it("returns already_configured when server exists", () => { + const existing = JSON.stringify({ + mcpServers: { + GitHits: { command: "old-cmd" }, + }, + }); + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + expect(result.status).toBe("already_configured"); + }); + + it("returns parse_error for malformed JSON", () => { + const result = mergeServerConfig( + "{invalid json", + "mcpServers", + "GitHits", + serverConfig, + ); + const error = expectParseError(result); + expect(error).toContain("Invalid JSON"); + }); + + it("returns parse_error when root is not an object", () => { + const result = mergeServerConfig( + "[1,2,3]", + "mcpServers", + "GitHits", + serverConfig, + ); + const error = expectParseError(result); + expect(error).toContain("not a JSON object"); + }); + + it("returns parse_error when serversKey is not an object", () => { + const result = mergeServerConfig( + '{"mcpServers": "not-an-object"}', + "mcpServers", + "GitHits", + serverConfig, + ); + const error = expectParseError(result); + expect(error).toContain("not a JSON object"); + }); + + it("strips BOM prefix before parsing", () => { + const bom = "\uFEFF"; + const existing = `${bom}{"mcpServers": {}}`; + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("creates serversKey if it does not exist", () => { + const existing = '{"otherKey": 42}'; + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.otherKey).toBe(42); + expect(parsed.mcpServers.GitHits).toEqual(serverConfig); + }); + + it("outputs 2-space indentation with trailing newline", () => { + const result = mergeServerConfig( + "{}", + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + expect(content).toMatch(/^{\n {2}/); // starts with 2-space indent + expect(content).toEndWith("}\n"); // trailing newline + }); + + it("handles deeply nested existing config", () => { + const existing = JSON.stringify({ + mcpServers: {}, + settings: { nested: { deep: { value: true } } }, + }); + const result = mergeServerConfig( + existing, + "mcpServers", + "GitHits", + serverConfig, + ); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.settings.nested.deep.value).toBe(true); + }); +}); + +// -- formatSetupPreview -- + +describe("formatSetupPreview", () => { + it("formats CLI setup as a command", () => { + const setup: CliSetup = { + method: "cli", + command: "claude", + args: ["mcp", "add", "GitHits"], + }; + const preview = formatSetupPreview(setup); + expect(preview).toBe("Will run: claude mcp add GitHits"); + }); + + it("formats config file setup with path and JSON snippet", () => { + const setup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.cursor/mcp.json", + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { command: "npx" }, + }; + const preview = formatSetupPreview(setup); + expect(preview).toContain("Will add to /home/test/.cursor/mcp.json:"); + expect(preview).toContain('"GitHits"'); + expect(preview).toContain('"command": "npx"'); + }); +}); + +// -- executeCliSetup -- + +describe("executeCliSetup", () => { + const cliSetup: CliSetup = { + method: "cli", + command: "claude", + args: ["mcp", "add", "GitHits"], + }; + + it("returns success on exit code 0", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "Added.\n", stderr: "" }), + ), + }); + const result = await executeCliSetup(cliSetup, execService); + expect(result.status).toBe("success"); + expect(execService.exec).toHaveBeenCalledWith("claude", [ + "mcp", + "add", + "GitHits", + ]); + }); + + it("returns failed with stderr on non-zero exit", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "Unknown command\n", + }), + ), + }); + const result = await executeCliSetup(cliSetup, execService); + expect(result.status).toBe("failed"); + expect(result.message).toContain("code 1"); + expect(result.message).toContain("Unknown command"); + }); + + it("returns failed with CLI not found on ENOENT", async () => { + const enoent = Object.assign(new Error("spawn ENOENT"), { + code: "ENOENT", + }); + const execService = createMockExecService({ + exec: mock(() => Promise.reject(enoent)), + }); + const result = await executeCliSetup(cliSetup, execService); + expect(result.status).toBe("failed"); + expect(result.message).toContain('"claude" not found on PATH'); + }); + + it("returns failed with message on other errors", async () => { + const execService = createMockExecService({ + exec: mock(() => Promise.reject(new Error("Unexpected error"))), + }); + const result = await executeCliSetup(cliSetup, execService); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Unexpected error"); + }); + + it("detects already-exists on non-zero exit (claude pattern)", async () => { + // claude mcp add exits 1 with "already exists" on stderr + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "MCP server GitHits already exists in user config\n", + }), + ), + }); + const result = await executeCliSetup(cliSetup, execService); + expect(result.status).toBe("already_configured"); + }); + + it("detects already-exists on zero exit (codex pattern)", async () => { + // codex mcp add exits 0 with "already added" on stdout + const codexSetup: CliSetup = { + method: "cli", + command: "codex", + args: ["mcp", "add", "GitHits"], + }; + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 0, + stdout: "Server GitHits already added\n", + stderr: "", + }), + ), + }); + const result = await executeCliSetup(codexSetup, execService); + expect(result.status).toBe("already_configured"); + }); +}); + +// -- executeConfigFileSetup -- + +describe("executeConfigFileSetup", () => { + const configSetup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.cursor/mcp.json", + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { + command: "npx", + args: ["-y", "mcp-remote", "https://mcp.githits.com"], + }, + }; + + it("creates new config when file does not exist", async () => { + const enoent = Object.assign(new Error("File not found"), { + code: "ENOENT", + }); + const atomicWrite = mock( + (_path: string, _content: string) => Promise.resolve() as Promise, + ); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(enoent)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("success"); + expect(atomicWrite).toHaveBeenCalled(); + // Verify the written content is valid JSON with the server entry + const writtenContent = atomicWrite.mock.calls[0]![1]; + const parsed = JSON.parse(writtenContent); + expect(parsed.mcpServers.GitHits).toEqual(configSetup.serverConfig); + }); + + it("merges into existing config preserving other entries", async () => { + const existing = JSON.stringify({ + mcpServers: { other: { command: "other" } }, + }); + const atomicWrite = mock( + (_path: string, _content: string) => Promise.resolve() as Promise, + ); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("success"); + const writtenContent = atomicWrite.mock.calls[0]![1]; + const parsed = JSON.parse(writtenContent); + expect(parsed.mcpServers.other).toEqual({ command: "other" }); + expect(parsed.mcpServers.GitHits).toEqual(configSetup.serverConfig); + }); + + it("returns already_configured when GitHits already present", async () => { + const existing = JSON.stringify({ + mcpServers: { GitHits: { command: "old" } }, + }); + const atomicWrite = mock(() => Promise.resolve()); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("already_configured"); + expect(atomicWrite).not.toHaveBeenCalled(); + }); + + it("returns failed on malformed JSON without writing", async () => { + const atomicWrite = mock(() => Promise.resolve()); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve("{invalid")), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Cannot parse"); + expect(atomicWrite).not.toHaveBeenCalled(); + }); + + it("ensures parent directory exists before writing", async () => { + const enoent = Object.assign(new Error("File not found"), { + code: "ENOENT", + }); + const ensureDir = mock(() => Promise.resolve()); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(enoent)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir, + atomicWriteFile: mock(() => Promise.resolve()), + }); + + await executeConfigFileSetup(configSetup, fs); + expect(ensureDir).toHaveBeenCalledWith("/home/test/.cursor"); + }); + + it("returns failed on permission denied", async () => { + const enoent = Object.assign(new Error("File not found"), { + code: "ENOENT", + }); + const eacces = Object.assign(new Error("Permission denied"), { + code: "EACCES", + }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(enoent)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: mock(() => Promise.reject(eacces)), + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Permission denied"); + }); + + it("returns failed on non-ENOENT read errors", async () => { + const err = Object.assign(new Error("Disk error"), { code: "EIO" }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(err)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: mock(() => Promise.resolve()), + }); + + const result = await executeConfigFileSetup(configSetup, fs); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Cannot read"); + }); + + it("uses atomicWriteFile for writing", async () => { + const enoent = Object.assign(new Error("File not found"), { + code: "ENOENT", + }); + const atomicWrite = mock(() => Promise.resolve()); + const writeFile = mock(() => Promise.resolve()); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(enoent)), + getDirname: mock(() => "/home/test/.cursor"), + ensureDir: mock(() => Promise.resolve()), + atomicWriteFile: atomicWrite, + writeFile, + }); + + await executeConfigFileSetup(configSetup, fs); + // Should use atomic write, not regular write + expect(atomicWrite).toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/init/setup-handlers.ts b/src/commands/init/setup-handlers.ts new file mode 100644 index 00000000..80dd9a57 --- /dev/null +++ b/src/commands/init/setup-handlers.ts @@ -0,0 +1,242 @@ +import type { ExecService } from "../../services/exec-service.js"; +import type { FileSystemService } from "../../services/filesystem-service.js"; +import type { CliSetup, ConfigFileSetup } from "./agent-definitions.js"; + +/** Result of merging server config into an existing config file */ +export type MergeResult = + | { status: "added"; content: string } + | { status: "already_configured" } + | { status: "parse_error"; error: string }; + +/** Result of executing a setup operation */ +export interface SetupResult { + status: "success" | "already_configured" | "failed"; + /** Human-readable message describing the outcome */ + message: string; +} + +/** + * Merge a new MCP server entry into existing JSON config content. + * Pure function — no IO, no side effects. + * + * Handles edge cases: + * - Empty or missing content (starts from {}) + * - Existing config with other servers (preserves them) + * - Server already configured (returns already_configured) + * - Malformed JSON (returns parse_error, never destroys content) + * - BOM prefix (strips before parsing) + */ +export function mergeServerConfig( + existingContent: string, + serversKey: string, + serverName: string, + serverConfig: Record, +): MergeResult { + // Strip BOM if present + let content = existingContent; + if (content.charCodeAt(0) === 0xfeff) { + content = content.slice(1); + } + + // Handle empty content + const trimmed = content.trim(); + if (trimmed === "") { + content = "{}"; + } + + // Parse existing JSON + let config: Record; + try { + config = JSON.parse(content); + } catch (err) { + return { + status: "parse_error", + error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // Ensure it's an object + if (typeof config !== "object" || config === null || Array.isArray(config)) { + return { + status: "parse_error", + error: "Config file root is not a JSON object", + }; + } + + // Get or create the servers section + if (!(serversKey in config)) { + config[serversKey] = {}; + } + + const servers = config[serversKey]; + if ( + typeof servers !== "object" || + servers === null || + Array.isArray(servers) + ) { + return { + status: "parse_error", + error: `"${serversKey}" is not a JSON object`, + }; + } + + // Check if already configured + const serversObj = servers as Record; + if (serverName in serversObj) { + return { status: "already_configured" }; + } + + // Add server entry + serversObj[serverName] = serverConfig; + + return { + status: "added", + content: `${JSON.stringify(config, null, 2)}\n`, + }; +} + +/** + * Format a setup config for display to the user before confirmation. + * Returns human-readable description of what will happen. + */ +export function formatSetupPreview(config: CliSetup | ConfigFileSetup): string { + if (config.method === "cli") { + return `Will run: ${config.command} ${config.args.join(" ")}`; + } + const snippet = JSON.stringify( + { [config.serverName]: config.serverConfig }, + null, + 2, + ); + return `Will add to ${config.configPath}:\n\n${snippet}`; +} + +/** Patterns in CLI output that indicate the server was already configured */ +const ALREADY_EXISTS_PATTERNS = [ + /already exists/i, + /already configured/i, + /already added/i, +]; + +/** Check if CLI output indicates the server is already configured */ +function isAlreadyConfiguredOutput(output: string): boolean { + return ALREADY_EXISTS_PATTERNS.some((pattern) => pattern.test(output)); +} + +/** + * Execute a CLI-based setup (e.g., `claude mcp add`). + * Returns a result object — does not throw on failure. + * + * Handles idempotency: `claude mcp add` exits 1 with "already exists" on + * duplicate, while `codex mcp add` exits 0. Both are detected and mapped + * to "already_configured". + */ +export async function executeCliSetup( + setup: CliSetup, + execService: ExecService, +): Promise { + try { + const result = await execService.exec(setup.command, setup.args); + const combined = `${result.stdout} ${result.stderr}`; + + // Check for "already exists" in output regardless of exit code + if (isAlreadyConfiguredOutput(combined)) { + return { + status: "already_configured", + message: `GitHits already configured via ${setup.command}`, + }; + } + + if (result.exitCode === 0) { + return { status: "success", message: "Configured successfully" }; + } + const detail = result.stderr.trim() || result.stdout.trim(); + return { + status: "failed", + message: `Command exited with code ${result.exitCode}${detail ? `: ${detail}` : ""}`, + }; + } catch (err) { + // ENOENT means the CLI binary is not installed/on PATH + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return { + status: "failed", + message: `"${setup.command}" not found on PATH. Install it or configure manually.`, + }; + } + return { + status: "failed", + message: `Failed to run command: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +/** + * Execute a config-file-based setup (read/merge/atomic-write). + * Returns a result object — does not throw on failure. + */ +export async function executeConfigFileSetup( + setup: ConfigFileSetup, + fs: FileSystemService, +): Promise { + try { + // Ensure parent directory exists + const parentDir = fs.getDirname(setup.configPath); + await fs.ensureDir(parentDir); + + // Read existing content or start fresh + let existingContent = ""; + try { + existingContent = await fs.readFile(setup.configPath); + } catch (err) { + // ENOENT is expected for new files + if ( + !(err instanceof Error) || + !("code" in err) || + err.code !== "ENOENT" + ) { + return { + status: "failed", + message: `Cannot read ${setup.configPath}: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + + // Merge config + const result = mergeServerConfig( + existingContent, + setup.serversKey, + setup.serverName, + setup.serverConfig, + ); + + if (result.status === "already_configured") { + return { + status: "already_configured", + message: `GitHits already configured in ${setup.configPath}`, + }; + } + + if (result.status === "parse_error") { + return { + status: "failed", + message: `Cannot parse ${setup.configPath}: ${result.error}. File left unchanged.`, + }; + } + + // Atomic write — result.status is "added" here (other statuses returned above) + await fs.atomicWriteFile(setup.configPath, result.content); + + return { status: "success", message: "Configured successfully" }; + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "EACCES") { + return { + status: "failed", + message: `Permission denied writing to ${setup.configPath}. Check file permissions.`, + }; + } + return { + status: "failed", + message: `Failed to configure: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/src/services/exec-service.ts b/src/services/exec-service.ts new file mode 100644 index 00000000..40395f06 --- /dev/null +++ b/src/services/exec-service.ts @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; + +/** Result of executing a CLI command */ +export interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Service interface for executing CLI commands. + * Abstraction allows for easy testing with mock implementations. + */ +export interface ExecService { + /** Execute a command with arguments and return the result */ + exec(command: string, args: string[]): Promise; +} + +/** + * Production implementation using node:child_process.spawn. + * Collects stdout/stderr and resolves with exit code. + */ +export class ExecServiceImpl implements ExecService { + async exec(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + resolve({ + exitCode: code ?? 1, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + }); + }); + }); + } +} diff --git a/src/services/filesystem-service.ts b/src/services/filesystem-service.ts index be295ee8..6cba98aa 100644 --- a/src/services/filesystem-service.ts +++ b/src/services/filesystem-service.ts @@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, + rename, stat, unlink, writeFile, @@ -46,6 +47,14 @@ export interface FileSystemService { /** Check if path is a directory */ isDirectory(path: string): Promise; + + /** + * Write file atomically by writing to a temp file then renaming. + * Ensures the target file is never left in a half-written state. + * The temp file is created in the same directory as the target + * so rename() is atomic on the same filesystem. + */ + atomicWriteFile(path: string, contents: string): Promise; } /** @@ -116,4 +125,29 @@ export class FileSystemServiceImpl implements FileSystemService { return false; } } + + async atomicWriteFile(path: string, contents: string): Promise { + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + // Preserve existing file permissions; default to 0o600 for new files + // (config files may contain sensitive data from other MCP servers) + let mode = 0o600; + try { + const existing = await stat(path); + mode = existing.mode & 0o777; + } catch { + // File doesn't exist yet — use default + } + try { + await writeFile(tmpPath, contents, { mode }); + await rename(tmpPath, path); + } catch (error) { + // Clean up temp file on failure + try { + await unlink(tmpPath); + } catch { + // Ignore cleanup errors + } + throw error; + } + } } diff --git a/src/services/index.ts b/src/services/index.ts index 1fc7c251..0bbb3e8d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -24,6 +24,8 @@ export { WINDOWS_MAX_ENTRY_SIZE, } from "./chunking-keyring-service.js"; export { getApiUrl, getEnvApiToken, getMcpUrl } from "./config.js"; +export type { ExecResult, ExecService } from "./exec-service.js"; +export { ExecServiceImpl } from "./exec-service.js"; export type { FileSystemService } from "./filesystem-service.js"; export { FileSystemServiceImpl } from "./filesystem-service.js"; export type { @@ -41,6 +43,12 @@ export { KeyringServiceImpl, } from "./keyring-service.js"; export { MigratingAuthStorage } from "./migrating-auth-storage.js"; +export type { + CheckboxChoice, + ConfirmChoice, + PromptService, +} from "./prompt-service.js"; +export { PromptServiceImpl } from "./prompt-service.js"; export { RefreshingGitHitsService } from "./refreshing-githits-service.js"; export type { TokenProvider } from "./token-manager.js"; export { refreshExpiredToken, TokenManager } from "./token-manager.js"; diff --git a/src/services/prompt-service.ts b/src/services/prompt-service.ts new file mode 100644 index 00000000..ca45bff6 --- /dev/null +++ b/src/services/prompt-service.ts @@ -0,0 +1,55 @@ +import { checkbox, select } from "@inquirer/prompts"; + +/** Choice for checkbox prompt */ +export interface CheckboxChoice { + /** Display name */ + name: string; + /** Value returned when selected */ + value: T; + /** Whether pre-selected */ + checked?: boolean; + /** Optional description shown below the choice */ + description?: string; +} + +/** User's confirmation choice for sequential setup */ +export type ConfirmChoice = "yes" | "no" | "always"; + +/** + * Service interface for interactive terminal prompts. + * Wraps @inquirer/prompts for dependency injection and testability. + */ +export interface PromptService { + /** Multi-select with pre-checked items */ + checkbox(message: string, choices: CheckboxChoice[]): Promise; + + /** Three-way confirmation: yes / no / always */ + confirm3(message: string): Promise; +} + +/** + * Production implementation using @inquirer/prompts. + */ +export class PromptServiceImpl implements PromptService { + async checkbox( + message: string, + choices: CheckboxChoice[], + ): Promise { + return checkbox({ message, choices }); + } + + async confirm3(message: string): Promise { + return select({ + message, + choices: [ + { value: "yes" as ConfirmChoice, name: "Yes" }, + { value: "no" as ConfirmChoice, name: "No" }, + { + value: "always" as ConfirmChoice, + name: "Yes to all", + description: "Skip confirmation for remaining agents", + }, + ], + }); + } +} diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index a62d09e3..a5ff1fe4 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -12,9 +12,15 @@ import type { TokenData, } from "./auth-storage.js"; import type { BrowserService } from "./browser-service.js"; +import type { ExecResult, ExecService } from "./exec-service.js"; import type { FileSystemService } from "./filesystem-service.js"; import type { GitHitsService } from "./githits-service.js"; import type { KeyringService } from "./keyring-service.js"; +import type { + CheckboxChoice, + ConfirmChoice, + PromptService, +} from "./prompt-service.js"; import type { TokenProvider } from "./token-manager.js"; /** @@ -136,6 +142,7 @@ export function createMockFileSystemService( ), readdir: mock(() => Promise.resolve([])), isDirectory: mock(() => Promise.resolve(false)), + atomicWriteFile: mock(() => Promise.resolve()), ...impl, }; } @@ -223,3 +230,30 @@ export function createValidTokenData( ...overrides, }; } + +/** + * Creates a mock PromptService with default implementations. + */ +export function createMockPromptService( + impl: Partial = {}, +): PromptService { + return { + checkbox: mock(() => Promise.resolve([])) as PromptService["checkbox"], + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + ...impl, + }; +} + +/** + * Creates a mock ExecService with default implementations. + */ +export function createMockExecService( + impl: Partial = {}, +): ExecService { + return { + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "", stderr: "" } as ExecResult), + ), + ...impl, + }; +} From cbf28217f38ac48a718981c626d1135244d0eb49 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Tue, 31 Mar 2026 16:44:48 -0700 Subject: [PATCH 2/7] feat: add init command with agent scanning and cross-platform setup Implements the `githits init` CLI command that scans for installed coding agents (Claude Code, Cursor, VS Code, Windsurf, Cline, Claude Desktop, Codex CLI, Gemini CLI, Google Antigravity), detects their configuration status, and sets up GitHits MCP integration via config-file or CLI methods. Includes cross-platform support (macOS, Linux, Windows) for agent detection paths using getAppDataPath(), comprehensive test coverage with platform stubbing for all three OS variants, and proper environment variable isolation for APPDATA on Windows. Co-Authored-By: Claude Opus 4.6 --- README.md | 38 +- docs/implementation/cli-commands.md | 13 +- package.json | 2 +- src/commands/init/agent-definitions.test.ts | 641 ++++++++++++++++++-- src/commands/init/agent-definitions.ts | 275 +++++++-- src/commands/init/index.ts | 1 + src/commands/init/init.test.ts | 625 ++++++++++++++----- src/commands/init/init.ts | 158 +++-- src/commands/init/setup-handlers.test.ts | 323 +++++++++- src/commands/init/setup-handlers.ts | 139 ++++- src/commands/login.test.ts | 86 ++- src/commands/login.ts | 84 ++- 12 files changed, 1994 insertions(+), 391 deletions(-) diff --git a/README.md b/README.md index 751e5743..5bd4cf00 100644 --- a/README.md +++ b/README.md @@ -7,31 +7,12 @@ GitHits gives your AI coding assistant access to verified, canonical code exampl ## Quick Start ```sh -npx githits login +npx githits init ``` -This opens your browser to authenticate with your [GitHits](https://githits.com) account. Once logged in, set up your AI assistant: - -**Claude Code** +`init` authenticates with your [GitHits](https://githits.com) account, then auto-detects your installed coding tools and configures each one with GitHits MCP. -```sh -claude mcp add githits -- npx -y githits mcp start -``` - -**Cursor / VS Code** - -Add to your MCP settings JSON: - -```json -{ - "mcpServers": { - "githits": { - "command": "npx", - "args": ["-y", "githits", "mcp", "start"] - } - } -} -``` +Supported tools: Claude Code, Cursor, Windsurf, VS Code / Copilot, Cline, Claude Desktop, Codex CLI, Gemini CLI, and Google Antigravity. That's it. Your assistant now has a `search` tool it will use automatically when it needs code examples. @@ -84,7 +65,8 @@ export GITHITS_API_TOKEN=ghi-your-token-here ## Commands ``` -githits login Authenticate with your GitHits account +githits init Authenticate and configure your coding tools with GitHits MCP +githits login Authenticate with your GitHits account (also runs as part of init) githits logout Remove stored credentials githits mcp Show setup instructions in a terminal; starts MCP server when piped githits mcp start Always start MCP server (for use in MCP config files) @@ -99,6 +81,16 @@ githits auth status Show current authentication status | `GITHITS_MCP_URL` | Override MCP server URL | `https://mcp.githits.com` | | `GITHITS_API_URL` | Override REST API URL | `https://api.githits.com` | +## Development + +```sh +bun run build +npm link +githits --version +``` + +After the initial `npm link`, only `bun run build` is needed for subsequent changes. + ## Requirements - Node.js 20 or later diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index ea4fa1cb..c305d3a9 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -8,7 +8,7 @@ The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror t | Command | Required Args | Options | Description | |---|---|---|---| -| `init` | — | `-y, --yes` | Set up MCP server for coding agents | +| `init` | — | `-y, --yes`, `--skip-login` | Authenticate and set up MCP server for coding agents | | `search ` | `-l, --lang ` | `--license `, `--explain`, `--json` | Search for code examples | | `languages [query]` | — | `--json` | List or filter supported languages | | `feedback ` | `--accept` or `--reject` | `-m, --message `, `--json` | Submit feedback on a search result | @@ -16,13 +16,16 @@ The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror t ### `githits init` ``` -githits init # Interactive: scan, select agents, confirm each -githits init --yes # Non-interactive: configure all detected agents +githits init # Interactive: authenticate, scan, configure unconfigured agents +githits init --yes # Non-interactive: authenticate, configure all unconfigured agents +githits init --skip-login # Skip authentication, configure tools only ``` -Scans for installed coding agents and sets up GitHits MCP server for each. Supports Claude Code, Cursor, Windsurf, Claude Desktop, and Codex CLI. Uses CLI commands for agents that support them (Claude Code, Codex) and atomic config file writes for others (Cursor, Windsurf, Claude Desktop). +Authenticates with GitHits (via OAuth in the browser), then scans for installed coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. All agents — including CLI-based ones — are pre-checked before any setup begins, so the status display is fully resolved. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. -This command does NOT use `createContainer()` — it creates its own lightweight dependencies since it doesn't need auth or API access. This is intentional: init should work before the user has authenticated. +Supports Claude Code, Cursor, Windsurf, VS Code / Copilot, Cline, Claude Desktop, Codex CLI, Gemini CLI, and Google Antigravity. Uses plugin install (Claude Code), CLI commands (Codex, Gemini CLI), and atomic config file writes (Cursor, Windsurf, VS Code, Cline, Claude Desktop, Google Antigravity). CLI agents use read-only check commands (e.g., `claude plugin list`) to determine configuration status before prompting. + +The command uses `createContainer()` lazily for the login step. Tool detection and configuration use lightweight dependencies that don't require auth. **File structure:** The init command uses a subdirectory (`src/commands/init/`) because it has distinct submodules (agent definitions, setup handlers, orchestrator). This is an accepted variation for commands with significant internal complexity. diff --git a/package.json b/package.json index abb753b2..f9f8088e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "githits", "description": "CLI companion for GitHits - code examples from global open source for developers and AI assistants", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "files": [ "dist", diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index bfdaf84d..b35a7cb7 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -1,15 +1,20 @@ -import { describe, expect, it, mock } from "bun:test"; -import { createMockFileSystemService } from "../../services/test-helpers.js"; +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import type { ExecResult } from "../../services/exec-service.js"; +import { + createMockExecService, + createMockFileSystemService, +} from "../../services/test-helpers.js"; import { type AgentDefinition, agentDefinitions, buildCheckboxChoices, detectAgents, + scanAgents, } from "./agent-definitions.js"; describe("agentDefinitions", () => { - it("defines 5 agents", () => { - expect(agentDefinitions).toHaveLength(5); + it("defines 9 agents", () => { + expect(agentDefinitions).toHaveLength(9); }); it("has unique ids", () => { @@ -54,39 +59,102 @@ describe("detectPaths", () => { expect(paths).toEqual(["/home/test/.codex"]); }); - it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { + it("windsurf detects via ~/.codeium/windsurf/ directory", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "windsurf")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.codeium/windsurf"]); + }); + + it("cline uses ~/.cline/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "cline")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.cline"]); + }); + + it("gemini-cli uses ~/.gemini/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "gemini-cli")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.gemini"]); + }); + + it("google-antigravity uses ~/.gemini/antigravity/", () => { const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/custom/home"), + getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), }); - for (const agent of agentDefinitions) { - const paths = agent.detectPaths(fs); - for (const path of paths) { - expect(path).toContain("/custom/home"); + const agent = agentDefinitions.find((a) => a.id === "google-antigravity")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.gemini/antigravity"]); + }); + + it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + delete process.env.APPDATA; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/custom/home"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + for (const agent of agentDefinitions) { + const paths = agent.detectPaths(fs); + for (const path of paths) { + expect(path).toContain("/custom/home"); + } + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; } } }); }); describe("getSetupConfig", () => { - it("claude-code returns CLI setup with claude command", () => { + it("claude-code returns CLI setup with plugin install commands", () => { const fs = createMockFileSystemService(); const agent = agentDefinitions.find((a) => a.id === "claude-code")!; const config = agent.getSetupConfig(fs); expect(config.method).toBe("cli"); if (config.method === "cli") { - expect(config.command).toBe("claude"); - expect(config.args).toContain("mcp"); - expect(config.args).toContain("add"); - expect(config.args).toContain("--transport"); - expect(config.args).toContain("http"); - expect(config.args).toContain("GitHits"); - expect(config.args).toContain("--scope"); - expect(config.args).toContain("user"); + expect(config.commands).toHaveLength(2); + expect(config.commands[0]!.command).toBe("claude"); + expect(config.commands[0]!.args).toContain("plugin"); + expect(config.commands[0]!.args).toContain("marketplace"); + expect(config.commands[0]!.args).toContain("add"); + expect(config.commands[0]!.args).toContain( + "githits-com/githits-claude-code-plugin", + ); + expect(config.commands[1]!.command).toBe("claude"); + expect(config.commands[1]!.args).toContain("plugin"); + expect(config.commands[1]!.args).toContain("install"); + expect(config.commands[1]!.args).toContain("githits@githits-plugins"); } }); - it("cursor returns config-file setup targeting mcp.json", () => { + it("cursor returns config-file setup with native url", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), @@ -98,11 +166,31 @@ describe("getSetupConfig", () => { expect(config.configPath).toBe("/home/test/.cursor/mcp.json"); expect(config.serversKey).toBe("mcpServers"); expect(config.serverName).toBe("GitHits"); - expect(config.serverConfig).toHaveProperty("command", "npx"); + expect(config.serverConfig).toHaveProperty("url"); + expect(config.serverConfig).not.toHaveProperty("command"); + } + }); + + it("windsurf returns config-file setup with native serverUrl", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "windsurf")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/.codeium/windsurf/mcp_config.json", + ); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toHaveProperty("serverUrl"); + expect(config.serverConfig).not.toHaveProperty("command"); } }); - it("claude-desktop returns config-file setup", () => { + it("claude-desktop returns config-file setup with mcp-remote", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), @@ -114,6 +202,9 @@ describe("getSetupConfig", () => { expect(config.configPath).toContain("claude_desktop_config.json"); expect(config.serversKey).toBe("mcpServers"); expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toHaveProperty("command", "npx"); + const args = config.serverConfig.args as string[]; + expect(args).toContain("mcp-remote"); } }); @@ -235,47 +326,112 @@ describe("getSetupConfig", () => { } }); - it("codex-cli returns CLI setup with codex command", () => { + it("codex-cli returns CLI setup with npm/stdio command", () => { const fs = createMockFileSystemService(); const agent = agentDefinitions.find((a) => a.id === "codex-cli")!; const config = agent.getSetupConfig(fs); expect(config.method).toBe("cli"); if (config.method === "cli") { - expect(config.command).toBe("codex"); - expect(config.args).toContain("mcp"); - expect(config.args).toContain("add"); - expect(config.args).toContain("GitHits"); + expect(config.commands).toHaveLength(1); + expect(config.commands[0]!.command).toBe("codex"); + expect(config.commands[0]!.args).toContain("mcp"); + expect(config.commands[0]!.args).toContain("add"); + expect(config.commands[0]!.args).toContain("githits"); + expect(config.commands[0]!.args).toContain("githits@latest"); + } + }); + + it("vscode returns config-file setup with servers key and http type", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "vscode")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/Library/Application Support/Code/User/mcp.json", + ); + expect(config.serversKey).toBe("servers"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toEqual({ + url: "https://mcp.githits.com", + type: "http", + }); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); } }); - it("windsurf returns config-file setup targeting ~/.codeium/windsurf/mcp_config.json", () => { + it("cline returns config-file setup with streamableHttp type", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), }); - const agent = agentDefinitions.find((a) => a.id === "windsurf")!; + const agent = agentDefinitions.find((a) => a.id === "cline")!; const config = agent.getSetupConfig(fs); expect(config.method).toBe("config-file"); if (config.method === "config-file") { expect(config.configPath).toBe( - "/home/test/.codeium/windsurf/mcp_config.json", + "/home/test/.cline/data/settings/cline_mcp_settings.json", ); expect(config.serversKey).toBe("mcpServers"); expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toEqual({ + url: "https://mcp.githits.com", + type: "streamableHttp", + }); } }); - it("windsurf detects via ~/.codeium/windsurf/ directory", () => { + it("gemini-cli returns CLI setup with extensions install", () => { + const fs = createMockFileSystemService(); + const agent = agentDefinitions.find((a) => a.id === "gemini-cli")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("cli"); + if (config.method === "cli") { + expect(config.commands).toHaveLength(1); + expect(config.commands[0]!.command).toBe("gemini"); + expect(config.commands[0]!.args).toContain("extensions"); + expect(config.commands[0]!.args).toContain("install"); + expect(config.commands[0]!.args).toContain( + "https://github.com/githits-com/githits-gemini-cli", + ); + } + }); + + it("google-antigravity returns config-file setup with serverUrl", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), }); - const agent = agentDefinitions.find((a) => a.id === "windsurf")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["/home/test/.codeium/windsurf"]); + const agent = agentDefinitions.find((a) => a.id === "google-antigravity")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/.gemini/antigravity/mcp_config.json", + ); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toEqual({ + serverUrl: "https://mcp.githits.com", + }); + } }); - it("config-file agents use mcp-remote with getMcpUrl()", () => { + it("claude-desktop is the only config-file agent using mcp-remote", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), @@ -286,9 +442,13 @@ describe("getSetupConfig", () => { for (const agent of configFileAgents) { const config = agent.getSetupConfig(fs); if (config.method === "config-file") { - expect(config.serverConfig).toHaveProperty("command", "npx"); - const args = config.serverConfig.args as string[]; - expect(args).toContain("mcp-remote"); + if (agent.id === "claude-desktop") { + expect(config.serverConfig).toHaveProperty("command", "npx"); + const args = config.serverConfig.args as string[]; + expect(args).toContain("mcp-remote"); + } else { + expect(config.serverConfig).not.toHaveProperty("command"); + } } } }); @@ -305,14 +465,10 @@ describe("getSetupConfig", () => { const cursor = agentDefinitions.find((a) => a.id === "cursor")!; const cursorConfig = cursor.getSetupConfig(fs); if (cursorConfig.method === "config-file") { - const args = cursorConfig.serverConfig.args as string[]; - expect(args).toContain("https://staging.mcp.example.com"); - } - // Check a CLI agent - const claude = agentDefinitions.find((a) => a.id === "claude-code")!; - const claudeConfig = claude.getSetupConfig(fs); - if (claudeConfig.method === "cli") { - expect(claudeConfig.args).toContain("https://staging.mcp.example.com"); + expect(cursorConfig.serverConfig).toHaveProperty( + "url", + "https://staging.mcp.example.com", + ); } } finally { if (originalUrl !== undefined) { @@ -362,6 +518,399 @@ describe("detectAgents", () => { }); }); +describe("scanAgents", () => { + /** Helper to create fs + exec mocks for scan tests */ + function createScanMocks(opts: { + detectedDirs: string[]; + configFiles?: Record; + execResults?: Record; + }) { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + isDirectory: mock(async (path: string) => + opts.detectedDirs.includes(path), + ), + readFile: mock(async (path: string) => { + if (opts.configFiles && path in opts.configFiles) { + return opts.configFiles[path]!; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }), + }); + const execService = createMockExecService({ + exec: mock(async (cmd: string, args: string[]) => { + const key = `${cmd} ${args.join(" ")}`; + if (opts.execResults && key in opts.execResults) { + const val = opts.execResults[key]!; + if (val instanceof Error) throw val; + return val; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }), + }); + return { fs, execService }; + } + + it("categorizes config-file agent as alreadyConfigured when config has GitHits", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.cursor"], + configFiles: { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured.some((a) => a.id === "cursor")).toBe(true); + expect(result.needsSetup.some((a) => a.id === "cursor")).toBe(false); + }); + + it("categorizes config-file agent as needsSetup when config file is missing", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.cursor"], + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "cursor")).toBe(true); + expect(result.alreadyConfigured.some((a) => a.id === "cursor")).toBe(false); + }); + + it("categorizes CLI agent as alreadyConfigured when check command matches", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.claude"], + execResults: { + "claude plugin list": { + exitCode: 0, + stdout: "githits-plugin\nother\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured.some((a) => a.id === "claude-code")).toBe( + true, + ); + expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe(false); + }); + + it("categorizes CLI agent as needsSetup when check command does not match", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.claude"], + execResults: { + "claude plugin list": { + exitCode: 0, + stdout: "other-plugin\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe(true); + expect(result.alreadyConfigured.some((a) => a.id === "claude-code")).toBe( + false, + ); + }); + + it("categorizes CLI agent as needsSetup when check command fails (ENOENT)", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.claude"], + execResults: { + "claude plugin list": Object.assign(new Error("spawn ENOENT"), { + code: "ENOENT", + }), + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe(true); + }); + + it("categorizes undetected agent as notDetected", async () => { + const { fs, execService } = createScanMocks({ detectedDirs: [] }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.notDetected).toHaveLength(agentDefinitions.length); + expect(result.needsSetup).toHaveLength(0); + expect(result.alreadyConfigured).toHaveLength(0); + }); + + it("handles mixed scenario correctly", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [ + "/home/test/.claude", + "/home/test/.cursor", + "/home/test/.codeium/windsurf", + ], + configFiles: { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }, + execResults: { + "claude plugin list": { + exitCode: 0, + stdout: "githits-plugin\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + // claude-code: CLI + check matches -> alreadyConfigured + expect(result.alreadyConfigured.some((a) => a.id === "claude-code")).toBe( + true, + ); + // cursor: config-file + configured -> alreadyConfigured + expect(result.alreadyConfigured.some((a) => a.id === "cursor")).toBe(true); + // windsurf: config-file + not configured -> needsSetup + expect(result.needsSetup.some((a) => a.id === "windsurf")).toBe(true); + // others: notDetected + expect(result.notDetected.length).toBeGreaterThan(0); + }); + + /** + * Generates the 5 comprehensive test cases for a given platform. + * Platform-dependent paths (VS Code, Claude Desktop) vary; all others are home-relative dotdirs. + */ + function defineComprehensiveTests(opts: { + platform: string; + appDataPrefix: string; + envSetup?: () => void; + envTeardown?: () => void; + }) { + const { platform, appDataPrefix } = opts; + + // Platform-independent detect dirs (home-relative dotdirs) + const homeDirs = [ + "/home/test/.claude", + "/home/test/.cursor", + "/home/test/.codeium/windsurf", + "/home/test/.cline", + "/home/test/.codex", + "/home/test/.gemini", + "/home/test/.gemini/antigravity", + ]; + // Platform-dependent detect dirs + const vscodePath = `${appDataPrefix}/Code`; + const claudeDesktopPath = `${appDataPrefix}/Claude`; + const allDetectDirs = [...homeDirs, vscodePath, claudeDesktopPath]; + + // Config files for all config-file agents with GitHits configured + const allConfiguredFiles: Record = { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + "/home/test/.codeium/windsurf/mcp_config.json": JSON.stringify({ + mcpServers: { GitHits: { serverUrl: "https://mcp.githits.com" } }, + }), + [`${vscodePath}/User/mcp.json`]: JSON.stringify({ + servers: { GitHits: { url: "https://mcp.githits.com", type: "http" } }, + }), + "/home/test/.cline/data/settings/cline_mcp_settings.json": JSON.stringify( + { + mcpServers: { + GitHits: { url: "https://mcp.githits.com", type: "streamableHttp" }, + }, + }, + ), + [`${claudeDesktopPath}/claude_desktop_config.json`]: JSON.stringify({ + mcpServers: { + GitHits: { + command: "npx", + args: ["-y", "mcp-remote", "https://mcp.githits.com"], + }, + }, + }), + "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ + mcpServers: { GitHits: { serverUrl: "https://mcp.githits.com" } }, + }), + }; + + // Exec results for all CLI agents reporting configured + const allCliConfigured: Record = { + "claude plugin list": { + exitCode: 0, + stdout: "githits-plugin\n", + stderr: "", + }, + "codex mcp list": { + exitCode: 0, + stdout: "githits npx -y githits@latest mcp start\n", + stderr: "", + }, + "gemini extensions list": { + exitCode: 0, + stdout: "githits-gemini-cli\n", + stderr: "", + }, + }; + + describe(`comprehensive all-agents scenarios (${platform})`, () => { + const originalPlatform = process.platform; + beforeAll(() => { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); + opts.envSetup?.(); + }); + afterAll(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + opts.envTeardown?.(); + }); + + it("all agents configured", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: allDetectDirs, + configFiles: allConfiguredFiles, + execResults: allCliConfigured, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured).toHaveLength(9); + expect(result.needsSetup).toHaveLength(0); + expect(result.notDetected).toHaveLength(0); + }); + + it("all agents detected but none configured", async () => { + const unconfiguredFiles: Record = { + "/home/test/.cursor/mcp.json": JSON.stringify({ mcpServers: {} }), + "/home/test/.codeium/windsurf/mcp_config.json": JSON.stringify({ + mcpServers: {}, + }), + [`${vscodePath}/User/mcp.json`]: JSON.stringify({ servers: {} }), + "/home/test/.cline/data/settings/cline_mcp_settings.json": + JSON.stringify({ mcpServers: {} }), + [`${claudeDesktopPath}/claude_desktop_config.json`]: JSON.stringify({ + mcpServers: {}, + }), + "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ + mcpServers: {}, + }), + }; + const { fs, execService } = createScanMocks({ + detectedDirs: allDetectDirs, + configFiles: unconfiguredFiles, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured).toHaveLength(0); + expect(result.needsSetup).toHaveLength(9); + expect(result.notDetected).toHaveLength(0); + }); + + it("no agents detected", async () => { + const { fs, execService } = createScanMocks({ detectedDirs: [] }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured).toHaveLength(0); + expect(result.needsSetup).toHaveLength(0); + expect(result.notDetected).toHaveLength(9); + }); + + it("mixed: 3 configured, 3 unconfigured, 3 not detected", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [ + // Configured: cursor, claude-desktop, claude-code + "/home/test/.cursor", + claudeDesktopPath, + "/home/test/.claude", + // Unconfigured: windsurf, vscode, codex-cli + "/home/test/.codeium/windsurf", + vscodePath, + "/home/test/.codex", + // Not detected: cline, gemini-cli, google-antigravity + ], + configFiles: { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + [`${claudeDesktopPath}/claude_desktop_config.json`]: JSON.stringify( + { + mcpServers: { GitHits: { command: "npx" } }, + }, + ), + }, + execResults: { + "claude plugin list": { + exitCode: 0, + stdout: "githits-plugin\n", + stderr: "", + }, + "codex mcp list": { exitCode: 0, stdout: "", stderr: "" }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured).toHaveLength(3); + expect(result.needsSetup).toHaveLength(3); + expect(result.notDetected).toHaveLength(3); + + expect(result.alreadyConfigured.map((a) => a.id).sort()).toEqual( + ["claude-code", "claude-desktop", "cursor"].sort(), + ); + expect(result.needsSetup.map((a) => a.id).sort()).toEqual( + ["codex-cli", "vscode", "windsurf"].sort(), + ); + expect(result.notDetected.map((a) => a.id).sort()).toEqual( + ["cline", "gemini-cli", "google-antigravity"].sort(), + ); + }); + + it("CLI agent with ENOENT on check falls to needsSetup", async () => { + const enoent = Object.assign(new Error("spawn ENOENT"), { + code: "ENOENT", + }); + const { fs, execService } = createScanMocks({ + detectedDirs: [ + "/home/test/.claude", + "/home/test/.codex", + "/home/test/.gemini", + ], + execResults: { + "claude plugin list": enoent, + "codex mcp list": enoent, + "gemini extensions list": enoent, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe( + true, + ); + expect(result.needsSetup.some((a) => a.id === "codex-cli")).toBe(true); + expect(result.needsSetup.some((a) => a.id === "gemini-cli")).toBe(true); + expect(result.alreadyConfigured).toHaveLength(0); + }); + }); + } + + // macOS: ~/Library/Application Support/ + defineComprehensiveTests({ + platform: "darwin", + appDataPrefix: "/home/test/Library/Application Support", + }); + + // Linux: ~/.config/ + defineComprehensiveTests({ + platform: "linux", + appDataPrefix: "/home/test/.config", + }); + + // Windows: %APPDATA%/ (mock joinPath still uses /) + let originalAppdata: string | undefined; + defineComprehensiveTests({ + platform: "win32", + appDataPrefix: "C:\\Users\\test\\AppData\\Roaming", + envSetup: () => { + originalAppdata = process.env.APPDATA; + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + }, + envTeardown: () => { + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + }, + }); +}); + describe("buildCheckboxChoices", () => { it("marks detected agents as checked", () => { const choices = buildCheckboxChoices(agentDefinitions, [ diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index a52f8468..2e02dbb6 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -1,17 +1,32 @@ import { getMcpUrl } from "../../services/config.js"; +import type { ExecService } from "../../services/exec-service.js"; import type { FileSystemService } from "../../services/index.js"; +import { + type CliCheckCommand, + isAlreadyConfigured, + isCliAlreadyConfigured, +} from "./setup-handlers.js"; -/** - * Setup configuration for agents that use a CLI command. - */ -export interface CliSetup { - method: "cli"; +/** A single CLI command step (command + arguments). */ +export interface CliCommand { /** Command to execute (e.g., "claude") */ command: string; /** Command arguments */ args: string[]; } +/** + * Setup configuration for agents that use CLI commands. + * Supports multi-step installs (e.g., plugin marketplace add + plugin install). + */ +export interface CliSetup { + method: "cli"; + /** One or more commands to execute sequentially */ + commands: CliCommand[]; + /** Optional read-only command to check if already configured before setup. */ + checkCommand?: CliCheckCommand; +} + /** * Setup configuration for agents that need config file editing. */ @@ -47,14 +62,6 @@ export interface AgentDefinition { getSetupConfig: (fs: FileSystemService) => SetupConfig; } -/** Returns the mcp-remote stdio config used by agents without native HTTP+OAuth */ -function mcpRemoteConfig(mcpUrl: string): Record { - return { - command: "npx", - args: ["-y", "mcp-remote", mcpUrl], - }; -} - /** * Returns platform-specific application data directory path. * macOS: ~/Library/Application Support/ @@ -76,7 +83,7 @@ function getAppDataPath(fs: FileSystemService, appName: string): string { } } -/** Claude Code: detected by ~/.claude/ directory, configured via `claude` CLI */ +/** Claude Code: detected by ~/.claude/ directory, configured via plugin install */ const claudeCode: AgentDefinition = { name: "Claude Code", id: "claude-code", @@ -84,21 +91,30 @@ const claudeCode: AgentDefinition = { detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".claude")], getSetupConfig: () => ({ method: "cli", - command: "claude", - args: [ - "mcp", - "add", - "--transport", - "http", - "GitHits", - "--scope", - "user", - getMcpUrl(), + commands: [ + { + command: "claude", + args: [ + "plugin", + "marketplace", + "add", + "githits-com/githits-claude-code-plugin", + ], + }, + { + command: "claude", + args: ["plugin", "install", "githits@githits-plugins"], + }, ], + checkCommand: { + command: "claude", + args: ["plugin", "list"], + configuredPattern: /githits/i, + }, }), }; -/** Cursor: detected by ~/.cursor/ directory, configured via mcp.json */ +/** Cursor: detected by ~/.cursor/ directory, configured via mcp.json with native OAuth */ const cursor: AgentDefinition = { name: "Cursor", id: "cursor", @@ -109,14 +125,13 @@ const cursor: AgentDefinition = { configPath: fs.joinPath(fs.getHomeDir(), ".cursor", "mcp.json"), serversKey: "mcpServers", serverName: "GitHits", - serverConfig: mcpRemoteConfig(getMcpUrl()), + serverConfig: { url: getMcpUrl() }, }), }; /** * Windsurf: detected by ~/.codeium/windsurf/ directory. - * Config path is ~/.codeium/windsurf/mcp_config.json on all platforms - * (per official Windsurf docs: https://docs.windsurf.com/windsurf/cascade/mcp). + * Uses native serverUrl (no mcp-remote needed). */ const windsurf: AgentDefinition = { name: "Windsurf", @@ -133,11 +148,11 @@ const windsurf: AgentDefinition = { ), serversKey: "mcpServers", serverName: "GitHits", - serverConfig: mcpRemoteConfig(getMcpUrl()), + serverConfig: { serverUrl: getMcpUrl() }, }), }; -/** Claude Desktop: detected by platform-specific Claude directory */ +/** Claude Desktop: detected by platform-specific Claude directory, uses mcp-remote */ const claudeDesktop: AgentDefinition = { name: "Claude Desktop", id: "claude-desktop", @@ -153,12 +168,15 @@ const claudeDesktop: AgentDefinition = { configPath: fs.joinPath(appData, "claude_desktop_config.json"), serversKey: "mcpServers", serverName: "GitHits", - serverConfig: mcpRemoteConfig(getMcpUrl()), + serverConfig: { + command: "npx", + args: ["-y", "mcp-remote", getMcpUrl()], + }, }; }, }; -/** Codex CLI: detected by ~/.codex/ directory, configured via `codex` CLI */ +/** Codex CLI: detected by ~/.codex/ directory, configured via npm/stdio */ const codexCli: AgentDefinition = { name: "Codex CLI", id: "codex-cli", @@ -166,8 +184,115 @@ const codexCli: AgentDefinition = { detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codex")], getSetupConfig: () => ({ method: "cli", - command: "codex", - args: ["mcp", "add", "GitHits", "--url", getMcpUrl()], + commands: [ + { + command: "codex", + args: [ + "mcp", + "add", + "githits", + "--", + "npx", + "-y", + "githits@latest", + "mcp", + "start", + ], + }, + ], + checkCommand: { + command: "codex", + args: ["mcp", "list"], + configuredPattern: /githits/i, + }, + }), +}; + +/** VS Code / Copilot: detected by platform-specific Code directory, uses native HTTP */ +const vscode: AgentDefinition = { + name: "VS Code / Copilot", + id: "vscode", + setupMethod: "config-file", + detectPaths: (fs) => { + const appData = getAppDataPath(fs, "Code"); + return [appData]; + }, + getSetupConfig: (fs) => { + const appData = getAppDataPath(fs, "Code"); + return { + method: "config-file", + configPath: fs.joinPath(appData, "User", "mcp.json"), + serversKey: "servers", + serverName: "GitHits", + serverConfig: { url: getMcpUrl(), type: "http" }, + }; + }, +}; + +/** Cline: detected by ~/.cline/ directory, uses streamable HTTP */ +const cline: AgentDefinition = { + name: "Cline", + id: "cline", + setupMethod: "config-file", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".cline")], + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath( + fs.getHomeDir(), + ".cline", + "data", + "settings", + "cline_mcp_settings.json", + ), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { url: getMcpUrl(), type: "streamableHttp" }, + }), +}; + +/** Gemini CLI: detected by ~/.gemini/ directory, configured via extensions install */ +const geminiCli: AgentDefinition = { + name: "Gemini CLI", + id: "gemini-cli", + setupMethod: "cli", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".gemini")], + getSetupConfig: () => ({ + method: "cli", + commands: [ + { + command: "gemini", + args: [ + "extensions", + "install", + "https://github.com/githits-com/githits-gemini-cli", + ], + }, + ], + checkCommand: { + command: "gemini", + args: ["extensions", "list"], + configuredPattern: /githits/i, + }, + }), +}; + +/** Google Antigravity: detected by ~/.gemini/antigravity/ directory */ +const googleAntigravity: AgentDefinition = { + name: "Google Antigravity", + id: "google-antigravity", + setupMethod: "config-file", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".gemini", "antigravity")], + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath( + fs.getHomeDir(), + ".gemini", + "antigravity", + "mcp_config.json", + ), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { serverUrl: getMcpUrl() }, }), }; @@ -179,13 +304,18 @@ export const agentDefinitions: AgentDefinition[] = [ claudeCode, cursor, windsurf, + vscode, + cline, claudeDesktop, codexCli, + geminiCli, + googleAntigravity, ]; /** * Detect which agents are installed by checking if their detection paths exist. * Returns the IDs of agents whose directories were found. + * @deprecated Use scanAgents() instead, which also checks configuration status. */ export async function detectAgents( definitions: AgentDefinition[], @@ -204,9 +334,86 @@ export async function detectAgents( return detected; } +/** Result of scanning all agents for install and configuration status */ +export interface ScanResult { + /** Detected and not yet configured */ + needsSetup: AgentDefinition[]; + /** Detected and already configured */ + alreadyConfigured: AgentDefinition[]; + /** Not installed */ + notDetected: AgentDefinition[]; +} + +/** + * Scan all agents: detect installation and check configuration status. + * Config-file agents get a pre-check via isAlreadyConfigured(). + * CLI agents with a checkCommand get a pre-check via isCliAlreadyConfigured(). + * CLI agents without a checkCommand are treated as needsSetup. + */ +export async function scanAgents( + definitions: AgentDefinition[], + fs: FileSystemService, + execService: ExecService, +): Promise { + const result: ScanResult = { + needsSetup: [], + alreadyConfigured: [], + notDetected: [], + }; + + for (const agent of definitions) { + // Check if installed + const paths = agent.detectPaths(fs); + let detected = false; + for (const path of paths) { + if (await fs.isDirectory(path)) { + detected = true; + break; + } + } + + if (!detected) { + result.notDetected.push(agent); + continue; + } + + // Detected — check configuration status + if (agent.setupMethod === "config-file") { + const config = agent.getSetupConfig(fs); + if ( + config.method === "config-file" && + (await isAlreadyConfigured(config, fs)) + ) { + result.alreadyConfigured.push(agent); + } else { + result.needsSetup.push(agent); + } + } else { + // CLI agent — try checkCommand if available + const config = agent.getSetupConfig(fs); + if (config.method === "cli" && config.checkCommand) { + const configured = await isCliAlreadyConfigured( + config.checkCommand, + execService, + ); + if (configured) { + result.alreadyConfigured.push(agent); + } else { + result.needsSetup.push(agent); + } + } else { + result.needsSetup.push(agent); + } + } + } + + return result; +} + /** * Build checkbox choices for the agent selection prompt. * Detected agents are pre-checked. + * @deprecated Use scanAgents() instead, which checks configuration status during detection. */ export function buildCheckboxChoices( definitions: AgentDefinition[], diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index b137e5c0..d2904b29 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -2,6 +2,7 @@ export { type AgentDefinition, agentDefinitions, buildCheckboxChoices, + type CliCommand, type CliSetup, type ConfigFileSetup, detectAgents, diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index 9ec6be03..2200d8dc 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -13,16 +13,18 @@ import type { PromptService, } from "../../services/prompt-service.js"; import { + createMockAuthService, + createMockAuthStorage, + createMockBrowserService, createMockExecService, createMockFileSystemService, createMockPromptService, + createValidTokenData, } from "../../services/test-helpers.js"; +import type { LoginDependencies } from "../login.js"; import type { InitDependencies } from "./init.js"; import { initAction } from "./init.js"; -/** Type-safe cast for checkbox mock overrides */ -type CheckboxMock = PromptService["checkbox"]; - /** Suppress console.log during tests */ let logSpy: ReturnType; @@ -34,6 +36,26 @@ afterEach(() => { logSpy.mockRestore(); }); +/** Create mock login deps that report already authenticated */ +function createAlreadyAuthLoginDeps(): () => Promise { + return mock(() => + Promise.resolve({ + authService: createMockAuthService(), + authStorage: createMockAuthStorage({ + loadTokens: mock(() => + Promise.resolve( + createValidTokenData({ + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ), + ), + }), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + }), + ); +} + /** Create default deps with overrides */ function createDeps( overrides: Partial = {}, @@ -46,12 +68,19 @@ function createDeps( }), promptService: createMockPromptService(), execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), ...overrides, }; } -/** Create a FileSystemService mock that detects specific agents */ -function createFsWithDetection(detectedDirs: string[]) { +/** + * Create a FileSystemService mock that detects specific agents + * and optionally has config files with content. + */ +function createFsWithDetection( + detectedDirs: string[], + configFiles: Record = {}, +) { return createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), @@ -60,9 +89,12 @@ function createFsWithDetection(detectedDirs: string[]) { (path: string) => path.split("/").slice(0, -1).join("/") || "/", ), ensureDir: mock(() => Promise.resolve()), - readFile: mock(() => - Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), - ), + readFile: mock(async (path: string) => { + if (path in configFiles) { + return configFiles[path]!; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }), atomicWriteFile: mock(() => Promise.resolve()), }); } @@ -73,100 +105,231 @@ function getLogOutput(): string[] { } describe("initAction", () => { - it("detects agents, prompts, and configures selected ones", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + it("scans agents and sets up unconfigured ones", async () => { + // Cursor detected but not configured + const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // Should attempt to write cursor config (no checkbox prompt) + expect(promptService.checkbox).not.toHaveBeenCalled(); + expect(promptService.confirm3).toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + it("skips already-configured agents without prompting", async () => { + // Cursor detected AND already configured + const fs = createFsWithDetection(["/home/test/.cursor"], { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }); + const promptService = createMockPromptService(); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // Should not prompt or write anything + expect(promptService.confirm3).not.toHaveBeenCalled(); + expect(fs.atomicWriteFile).not.toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("already configured"))).toBe( + true, + ); + expect(logCalls.some((msg) => msg.includes("Nothing to do"))).toBe(true); + }); + + it("handles mixed status: configured + unconfigured", async () => { + // Cursor configured, windsurf not configured + const fs = createFsWithDetection( + ["/home/test/.cursor", "/home/test/.codeium/windsurf"], + { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }, + ); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // Should only set up windsurf + expect(promptService.confirm3).toHaveBeenCalledTimes(1); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect( + logCalls.some( + (msg) => msg.includes("Cursor") && msg.includes("already configured"), + ), + ).toBe(true); + expect( + logCalls.some( + (msg) => msg.includes("Windsurf") && msg.includes("needs setup"), + ), + ).toBe(true); + }); + + it("shows all agents as already configured when all check commands match", async () => { + // Detect Claude Code (CLI) and Cursor (config-file), both configured + const fs = createFsWithDetection( + ["/home/test/.claude", "/home/test/.cursor"], + { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }, + ); const execService = createMockExecService({ exec: mock(() => - Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + Promise.resolve({ + exitCode: 0, + stdout: "githits-plugin\n", + stderr: "", + }), ), }); + const promptService = createMockPromptService(); - await initAction({}, { fileSystemService: fs, promptService, execService }); + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); - expect(promptService.checkbox).toHaveBeenCalled(); - expect(promptService.confirm3).toHaveBeenCalled(); - expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); + expect(promptService.confirm3).not.toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Nothing to do"))).toBe(true); }); - it("only sets up selected agents, not all detected", async () => { - const fs = createFsWithDetection([ - "/home/test/.claude", - "/home/test/.cursor", - ]); - // User selects only cursor, not claude-code + it("sets up CLI agents that are not yet configured", async () => { + const fs = createFsWithDetection(["/home/test/.claude"]); const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["cursor"])) as CheckboxMock, confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); - const execService = createMockExecService(); + let callCount = 0; + const execService = createMockExecService({ + exec: mock(() => { + callCount++; + // First call: check command (no match = needs setup) + if (callCount === 1) { + return Promise.resolve({ + exitCode: 0, + stdout: "other-plugin\n", + stderr: "", + }); + } + // Subsequent calls: setup commands + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + }), + }); - await initAction({}, { fileSystemService: fs, promptService, execService }); + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); - // exec should NOT be called (cursor uses config-file, not CLI) - expect(execService.exec).not.toHaveBeenCalled(); - // atomicWriteFile should be called for cursor config - expect(fs.atomicWriteFile).toHaveBeenCalled(); + // 1 check + 2 setup commands = 3 exec calls + expect(execService.exec).toHaveBeenCalledTimes(3); + expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); }); it("stops prompting after 'always' response", async () => { + // Two unconfigured config-file agents const fs = createFsWithDetection([ - "/home/test/.claude", "/home/test/.cursor", + "/home/test/.codeium/windsurf", ]); const confirm3 = mock(() => Promise.resolve("always" as ConfirmChoice)); - const promptService = createMockPromptService({ - checkbox: mock(() => - Promise.resolve(["claude-code", "cursor"]), - ) as CheckboxMock, - confirm3, - }); - const execService = createMockExecService({ - exec: mock(() => - Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), - ), - }); + const promptService = createMockPromptService({ confirm3 }); - await initAction({}, { fileSystemService: fs, promptService, execService }); + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); - // confirm3 should be called only once (for first agent, then "always" kicks in) + // confirm3 called once (first agent), then "always" kicks in expect(confirm3).toHaveBeenCalledTimes(1); }); it("skips agent when user responds 'no'", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), }); - const execService = createMockExecService(); - await initAction({}, { fileSystemService: fs, promptService, execService }); + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); - // exec should NOT be called because user said no - expect(execService.exec).not.toHaveBeenCalled(); + expect(fs.atomicWriteFile).not.toHaveBeenCalled(); }); - it("--yes flag skips all prompts and configures all detected agents", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + it("--yes flag skips all prompts and configures all unconfigured agents", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService(); - const execService = createMockExecService({ - exec: mock(() => - Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), - ), - }); + const execService = createMockExecService(); await initAction( { yes: true }, - { fileSystemService: fs, promptService, execService }, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, ); expect(promptService.checkbox).not.toHaveBeenCalled(); expect(promptService.confirm3).not.toHaveBeenCalled(); - expect(execService.exec).toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); }); it("--yes with no agents detected prints message and returns", async () => { @@ -176,10 +339,14 @@ describe("initAction", () => { await initAction( { yes: true }, - { fileSystemService: fs, promptService, execService }, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, ); - expect(promptService.checkbox).not.toHaveBeenCalled(); expect(execService.exec).not.toHaveBeenCalled(); const logCalls = getLogOutput(); expect( @@ -187,90 +354,70 @@ describe("initAction", () => { ).toBe(true); }); + it("--yes with all agents already configured shows nothing to do", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"], { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }); + + await initAction( + { yes: true }, + { + fileSystemService: fs, + promptService: createMockPromptService(), + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Nothing to do"))).toBe(true); + }); + it("continues to next agent when one fails", async () => { + // Claude (CLI, will fail) + cursor (config-file, should still work) const fs = createFsWithDetection([ "/home/test/.claude", "/home/test/.cursor", ]); - // Claude exec will fail, cursor config write should still happen + let callCount = 0; const execService = createMockExecService({ - exec: mock(() => - Promise.resolve({ exitCode: 1, stdout: "", stderr: "error" }), - ), + exec: mock(() => { + callCount++; + // First call: check command for claude (no match = needs setup) + if (callCount === 1) { + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + } + // Setup commands: fail + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "error" }); + }), }); const promptService = createMockPromptService({ - checkbox: mock(() => - Promise.resolve(["claude-code", "cursor"]), - ) as CheckboxMock, confirm3: mock(() => Promise.resolve("always" as ConfirmChoice)), }); - await initAction({}, { fileSystemService: fs, promptService, execService }); - - // Both should be attempted: exec for claude, atomicWriteFile for cursor - expect(execService.exec).toHaveBeenCalled(); - expect(fs.atomicWriteFile).toHaveBeenCalled(); - }); - - it("handles all agents already configured", async () => { - // Cursor config already has GitHits - const existing = JSON.stringify({ - mcpServers: { GitHits: { command: "old" } }, - }); - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - isDirectory: mock(async (path: string) => path === "/home/test/.cursor"), - getDirname: mock(() => "/home/test/.cursor"), - ensureDir: mock(() => Promise.resolve()), - readFile: mock(() => Promise.resolve(existing)), - atomicWriteFile: mock(() => Promise.resolve()), - }); - const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["cursor"])) as CheckboxMock, - confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), - }); - await initAction( {}, { fileSystemService: fs, promptService, - execService: createMockExecService(), + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), }, ); - // Should not write (already configured) - expect(fs.atomicWriteFile).not.toHaveBeenCalled(); - // Should mention "already configured" in output - const logCalls = getLogOutput(); - expect(logCalls.some((msg) => msg.includes("already configured"))).toBe( - true, - ); - }); - - it("handles empty selection gracefully", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); - const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve([])) as CheckboxMock, - }); - const execService = createMockExecService(); - - await initAction({}, { fileSystemService: fs, promptService, execService }); - - expect(execService.exec).not.toHaveBeenCalled(); - const logCalls = getLogOutput(); - expect(logCalls.some((msg) => msg.includes("No agents selected"))).toBe( - true, - ); + // Both should be attempted + expect(execService.exec).toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); }); - it("handles Ctrl+C on checkbox prompt gracefully", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + it("handles Ctrl+C on confirm3 prompt gracefully", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ - checkbox: mock(() => + confirm3: mock(() => Promise.reject(new ExitPromptError("User force closed")), - ) as CheckboxMock, + ), }); await initAction( @@ -279,6 +426,7 @@ describe("initAction", () => { fileSystemService: fs, promptService, execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), }, ); @@ -286,13 +434,29 @@ describe("initAction", () => { expect(logCalls.some((msg) => msg.includes("cancelled"))).toBe(true); }); - it("handles Ctrl+C on confirm3 prompt gracefully", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + it("rethrows non-ExitPromptError from confirm3", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, - confirm3: mock(() => - Promise.reject(new ExitPromptError("User force closed")), + confirm3: mock(() => Promise.reject(new Error("Unexpected error"))), + }); + + await expect( + initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, ), + ).rejects.toThrow("Unexpected error"); + }); + + it("shows 'Setup skipped' when all unconfigured agents are skipped", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), }); await initAction( @@ -301,63 +465,210 @@ describe("initAction", () => { fileSystemService: fs, promptService, execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), }, ); const logCalls = getLogOutput(); - expect(logCalls.some((msg) => msg.includes("cancelled"))).toBe(true); + expect(logCalls.some((msg) => msg.includes("Setup skipped"))).toBe(true); }); - it("rethrows non-ExitPromptError from checkbox", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); - const promptService = createMockPromptService({ - checkbox: mock(() => - Promise.reject(new Error("Unexpected error")), - ) as CheckboxMock, + describe("login integration", () => { + it("runs login flow and proceeds when already authenticated", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("Already authenticated")), + ).toBe(true); + expect(fs.atomicWriteFile).toHaveBeenCalled(); }); - await expect( - initAction( + it("runs login flow and proceeds on success", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.resolve({ + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( {}, { fileSystemService: fs, promptService, execService: createMockExecService(), + createLoginDeps, }, - ), - ).rejects.toThrow("Unexpected error"); - }); + ); - it("rethrows non-ExitPromptError from confirm3", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); - const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, - confirm3: mock(() => Promise.reject(new Error("Unexpected error"))), + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("Logged in successfully")), + ).toBe(true); + expect(fs.atomicWriteFile).toHaveBeenCalled(); }); - await expect( - initAction( + it("prompts to continue when login fails", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.resolve({ + authService: createMockAuthService({ + discoverEndpoints: mock(() => + Promise.reject(new Error("Network error")), + ), + }), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( {}, { fileSystemService: fs, promptService, execService: createMockExecService(), + createLoginDeps, }, - ), - ).rejects.toThrow("Unexpected error"); - }); + ); - it("shows 'Setup skipped' when all agents are skipped", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); - const promptService = createMockPromptService({ - checkbox: mock(() => Promise.resolve(["claude-code"])) as CheckboxMock, - confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Login failed"))).toBe(true); + expect(fs.atomicWriteFile).toHaveBeenCalled(); }); - const execService = createMockExecService(); - await initAction({}, { fileSystemService: fs, promptService, execService }); + it("cancels setup when login fails and user declines to continue", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.resolve({ + authService: createMockAuthService({ + discoverEndpoints: mock(() => + Promise.reject(new Error("Network error")), + ), + }), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps, + }, + ); - const logCalls = getLogOutput(); - expect(logCalls.some((msg) => msg.includes("Setup skipped"))).toBe(true); + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Setup cancelled"))).toBe( + true, + ); + expect(fs.atomicWriteFile).not.toHaveBeenCalled(); + }); + + it("--skip-login skips authentication step", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + const createLoginDeps = mock(() => + Promise.reject(new Error("should not be called")), + ); + + await initAction( + { skipLogin: true }, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps, + }, + ); + + expect(createLoginDeps).not.toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + it("--yes mode continues on login failure without prompting", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService(); + const createLoginDeps = mock(() => + Promise.resolve({ + authService: createMockAuthService({ + discoverEndpoints: mock(() => + Promise.reject(new Error("Network error")), + ), + }), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl: "https://mcp.githits.com", + }), + ); + + await initAction( + { yes: true }, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps, + }, + ); + + expect(promptService.confirm3).not.toHaveBeenCalled(); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + it("skips login when createLoginDeps is not provided", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("Checking authentication")), + ).toBe(false); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); }); }); diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 0608cab8..ac2fb7a4 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -1,5 +1,6 @@ import { ExitPromptError } from "@inquirer/core"; import type { Command } from "commander"; +import { createContainer } from "../../container.js"; import type { ExecService } from "../../services/exec-service.js"; import { ExecServiceImpl } from "../../services/exec-service.js"; import type { FileSystemService } from "../../services/filesystem-service.js"; @@ -16,11 +17,9 @@ import { success, warning, } from "../../shared/colors.js"; -import { - agentDefinitions, - buildCheckboxChoices, - detectAgents, -} from "./agent-definitions.js"; +import type { LoginDependencies, LoginFlowResult } from "../login.js"; +import { loginFlow } from "../login.js"; +import { agentDefinitions, scanAgents } from "./agent-definitions.js"; import { executeCliSetup, executeConfigFileSetup, @@ -31,13 +30,17 @@ import { export interface InitOptions { /** Skip all prompts, configure all detected agents */ yes?: boolean; + /** Skip the login step */ + skipLogin?: boolean; } -/** Dependencies for the init command (not from createContainer) */ +/** Dependencies for the init command */ export interface InitDependencies { fileSystemService: FileSystemService; promptService: PromptService; execService: ExecService; + /** Factory to create auth deps for the login step. Omit to skip login. */ + createLoginDeps?: () => Promise; } /** Tracks per-agent setup outcome for the summary */ @@ -55,64 +58,105 @@ export async function initAction( deps: InitDependencies, ): Promise { const useColors = shouldUseColors(); - const { fileSystemService, promptService, execService } = deps; + const { fileSystemService, promptService, execService, createLoginDeps } = + deps; // Header console.log( `\n ${colorize("GitHits", "bold", useColors)} — Set up MCP server for your coding agents\n`, ); - // Detect installed agents - console.log(" Scanning for installed agents...\n"); - const detectedIds = await detectAgents(agentDefinitions, fileSystemService); - - // Determine which agents to configure - let selectedIds: string[]; + // Login step (before tool configuration) + if (!options.skipLogin && createLoginDeps) { + console.log(" Checking authentication...\n"); + let loginResult: LoginFlowResult; + try { + const loginDeps = await createLoginDeps(); + loginResult = await loginFlow({}, loginDeps); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + loginResult = { status: "failed", message: msg }; + } - if (options.yes) { - // Non-interactive: use all detected agents - if (detectedIds.length === 0) { + if (loginResult.status === "already_authenticated") { + console.log(` ${success("Already authenticated", useColors)}\n`); + } else if (loginResult.status === "success") { + console.log(` ${success("Logged in successfully", useColors)}\n`); + } else { console.log( - " No coding agents detected. Install an agent and try again.\n", - ); - return; - } - const names = agentDefinitions - .filter((a) => detectedIds.includes(a.id)) - .map((a) => a.name) - .join(", "); - console.log(` Detected: ${colorize(names, "cyan", useColors)}\n`); - selectedIds = detectedIds; - } else { - // Interactive: show checkbox - const choices = buildCheckboxChoices(agentDefinitions, detectedIds); - try { - selectedIds = await promptService.checkbox( - "Select agents to configure", - choices, + ` ${warning(`Login failed: ${loginResult.message}`, useColors)}\n`, ); - } catch (err) { - if (err instanceof ExitPromptError) { - console.log("\n Setup cancelled.\n"); - return; + if (!options.yes) { + try { + const choice = await promptService.confirm3( + "Continue without authentication?", + ); + if (choice === "no") { + console.log( + "\n Setup cancelled. Run `githits login` to authenticate.\n", + ); + return; + } + } catch (err) { + if (err instanceof ExitPromptError) { + console.log("\n Setup cancelled.\n"); + return; + } + throw err; + } } - throw err; + console.log(" Continuing without authentication...\n"); } } - if (selectedIds.length === 0) { - console.log(" No agents selected.\n"); + // Scan for installed agents and check configuration status + console.log(" Scanning for installed agents...\n"); + const scan = await scanAgents( + agentDefinitions, + fileSystemService, + execService, + ); + + // Display status list + for (const agent of scan.alreadyConfigured) { + console.log( + ` ${success(`${agent.name} — already configured`, useColors)}`, + ); + } + for (const agent of scan.needsSetup) { + console.log( + ` ${colorize(`● ${agent.name} — needs setup`, "cyan", useColors)}`, + ); + } + for (const agent of scan.notDetected) { + console.log( + ` ${colorize(`${agent.name} — not detected`, "dim", useColors)}`, + ); + } + console.log(); + + // All agents not detected + if (scan.needsSetup.length === 0 && scan.alreadyConfigured.length === 0) { + console.log( + " No coding agents detected. Install an agent and try again.\n", + ); return; } - // Sequential setup with confirmation + // All detected agents already configured + if (scan.needsSetup.length === 0) { + console.log( + " All detected agents are already configured. Nothing to do.\n", + ); + return; + } + + // Sequential setup for unconfigured agents + const toSetup = scan.needsSetup; const outcomes: AgentOutcome[] = []; let alwaysMode = options.yes ?? false; - for (const agentId of selectedIds) { - const agent = agentDefinitions.find((a) => a.id === agentId); - if (!agent) continue; - + for (const agent of toSetup) { console.log(` Setting up ${colorize(agent.name, "bold", useColors)}...\n`); const config = agent.getSetupConfig(fileSystemService); @@ -169,9 +213,9 @@ export async function initAction( // Summary const configured = outcomes.filter((o) => o.status === "success").length; - const alreadyDone = outcomes.filter( - (o) => o.status === "already_configured", - ).length; + const alreadyDone = + outcomes.filter((o) => o.status === "already_configured").length + + scan.alreadyConfigured.length; const failed = outcomes.filter((o) => o.status === "failed").length; const skipped = outcomes.filter((o) => o.status === "skipped").length; @@ -192,21 +236,23 @@ export async function initAction( console.log(` ${skipped} agent${skipped !== 1 ? "s" : ""} skipped.`); } - console.log(" Run `githits login` if you haven't authenticated yet.\n"); + console.log(); } const INIT_DESCRIPTION = `Set up GitHits MCP server for your coding agents. -Scans for installed agents (Claude Code, Cursor, Windsurf, Claude Desktop, -Codex CLI), lets you select which to configure, and sets up each one -with your confirmation. +Authenticates with your GitHits account, then scans for installed agents +(Claude Code, Cursor, Windsurf, VS Code, Cline, Claude Desktop, Codex CLI, +Gemini CLI, Google Antigravity), checks which are already configured, +and sets up unconfigured ones with your confirmation. -Supports both CLI-based setup (Claude Code, Codex) and config file -editing (Cursor, Windsurf, Claude Desktop) with atomic writes.`; +Supports CLI-based setup (Claude Code, Codex, Gemini CLI) and config +file editing (Cursor, Windsurf, VS Code, Cline, Claude Desktop, +Google Antigravity) with atomic writes.`; /** * Register the init command on the given program. - * Creates its own lightweight dependencies (no auth needed). + * Creates lightweight dependencies for tool setup, plus auth deps for login. */ export function registerInitCommand(program: Command) { program @@ -214,6 +260,7 @@ export function registerInitCommand(program: Command) { .summary("Set up MCP server for your coding agents") .description(INIT_DESCRIPTION) .option("-y, --yes", "Skip prompts, configure all detected agents") + .option("--skip-login", "Skip authentication step") .action(async (options: InitOptions) => { const fileSystemService = new FileSystemServiceImpl(); const promptService = new PromptServiceImpl(); @@ -222,6 +269,7 @@ export function registerInitCommand(program: Command) { fileSystemService, promptService, execService, + createLoginDeps: () => createContainer(), }); }); } diff --git a/src/commands/init/setup-handlers.test.ts b/src/commands/init/setup-handlers.test.ts index 5ae1812d..87cab998 100644 --- a/src/commands/init/setup-handlers.test.ts +++ b/src/commands/init/setup-handlers.test.ts @@ -4,11 +4,13 @@ import { createMockFileSystemService, } from "../../services/test-helpers.js"; import type { CliSetup, ConfigFileSetup } from "./agent-definitions.js"; -import type { MergeResult } from "./setup-handlers.js"; +import type { CliCheckCommand, MergeResult } from "./setup-handlers.js"; import { executeCliSetup, executeConfigFileSetup, formatSetupPreview, + isAlreadyConfigured, + isCliAlreadyConfigured, mergeServerConfig, } from "./setup-handlers.js"; @@ -26,6 +28,167 @@ function expectParseError(result: MergeResult): string { return result.error; } +// -- isAlreadyConfigured (read-only check) -- + +describe("isAlreadyConfigured", () => { + const configSetup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.cursor/mcp.json", + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { url: "https://mcp.githits.com/" }, + }; + + it("returns true when config file contains the server entry", async () => { + const existing = JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com/" } }, + }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(true); + }); + + it("returns false when config file exists but server entry is missing", async () => { + const existing = JSON.stringify({ mcpServers: { Other: {} } }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); + }); + + it("returns false when config file does not exist", async () => { + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.reject(enoent)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); + }); + + it("returns false when config file has malformed JSON", async () => { + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve("{invalid json")), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); + }); + + it("returns false when serversKey is missing", async () => { + const existing = JSON.stringify({ otherKey: {} }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); + }); + + it("works with 'servers' key for VS Code config", async () => { + const vscodeSetup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.vscode/mcp.json", + serversKey: "servers", + serverName: "GitHits", + serverConfig: { url: "https://mcp.githits.com/", type: "http" }, + }; + const existing = JSON.stringify({ + servers: { GitHits: { url: "https://mcp.githits.com/", type: "http" } }, + }); + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(vscodeSetup, fs)).toBe(true); + }); + + it("returns false on empty file", async () => { + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve("")), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(false); + }); + + it("handles BOM prefix", async () => { + const bom = "\uFEFF"; + const existing = `${bom}${JSON.stringify({ mcpServers: { GitHits: {} } })}`; + const fs = createMockFileSystemService({ + readFile: mock(() => Promise.resolve(existing)), + }); + expect(await isAlreadyConfigured(configSetup, fs)).toBe(true); + }); +}); + +// -- isCliAlreadyConfigured (read-only CLI check) -- + +describe("isCliAlreadyConfigured", () => { + const check: CliCheckCommand = { + command: "claude", + args: ["plugin", "list"], + configuredPattern: /githits/i, + }; + + it("returns true when pattern matches stdout", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 0, + stdout: "githits-plugin\nother-plugin\n", + stderr: "", + }), + ), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(true); + }); + + it("returns true when pattern matches stderr", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 0, + stdout: "", + stderr: "GitHits is installed", + }), + ), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(true); + }); + + it("returns false when pattern does not match output", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "other-plugin\n", stderr: "" }), + ), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(false); + }); + + it("returns false when command not found (ENOENT)", async () => { + const enoent = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + const execService = createMockExecService({ + exec: mock(() => Promise.reject(enoent)), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(false); + }); + + it("returns false on non-zero exit code with no pattern match", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 1, stdout: "", stderr: "error occurred" }), + ), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(false); + }); + + it("returns true on non-zero exit code when pattern matches", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "GitHits already installed", + }), + ), + }); + expect(await isCliAlreadyConfigured(check, execService)).toBe(true); + }); +}); + // -- mergeServerConfig (pure function) -- describe("mergeServerConfig", () => { @@ -202,19 +365,55 @@ describe("mergeServerConfig", () => { const parsed = JSON.parse(content); expect(parsed.settings.nested.deep.value).toBe(true); }); + + it("works with 'servers' key for VS Code config", () => { + const vscodeConfig = { url: "https://mcp.githits.com", type: "http" }; + const result = mergeServerConfig("{}", "servers", "GitHits", vscodeConfig); + const content = expectAdded(result); + const parsed = JSON.parse(content); + expect(parsed.servers.GitHits).toEqual(vscodeConfig); + }); }); // -- formatSetupPreview -- describe("formatSetupPreview", () => { - it("formats CLI setup as a command", () => { + it("formats single-step CLI setup as a command", () => { + const setup: CliSetup = { + method: "cli", + commands: [{ command: "codex", args: ["mcp", "add", "githits"] }], + }; + const preview = formatSetupPreview(setup); + expect(preview).toBe("Will run: codex mcp add githits"); + }); + + it("formats multi-step CLI setup as multiple lines", () => { const setup: CliSetup = { method: "cli", - command: "claude", - args: ["mcp", "add", "GitHits"], + commands: [ + { + command: "claude", + args: [ + "plugin", + "marketplace", + "add", + "githits-com/githits-claude-code-plugin", + ], + }, + { + command: "claude", + args: ["plugin", "install", "githits@githits-plugins"], + }, + ], }; const preview = formatSetupPreview(setup); - expect(preview).toBe("Will run: claude mcp add GitHits"); + expect(preview).toContain( + "Will run: claude plugin marketplace add githits-com/githits-claude-code-plugin", + ); + expect(preview).toContain( + "Will run: claude plugin install githits@githits-plugins", + ); + expect(preview.split("\n")).toHaveLength(2); }); it("formats config file setup with path and JSON snippet", () => { @@ -223,39 +422,68 @@ describe("formatSetupPreview", () => { configPath: "/home/test/.cursor/mcp.json", serversKey: "mcpServers", serverName: "GitHits", - serverConfig: { command: "npx" }, + serverConfig: { url: "https://mcp.githits.com/" }, }; const preview = formatSetupPreview(setup); expect(preview).toContain("Will add to /home/test/.cursor/mcp.json:"); expect(preview).toContain('"GitHits"'); - expect(preview).toContain('"command": "npx"'); + expect(preview).toContain('"url"'); }); }); // -- executeCliSetup -- describe("executeCliSetup", () => { - const cliSetup: CliSetup = { + const singleStepSetup: CliSetup = { method: "cli", - command: "claude", - args: ["mcp", "add", "GitHits"], + commands: [{ command: "codex", args: ["mcp", "add", "githits"] }], + }; + + const multiStepSetup: CliSetup = { + method: "cli", + commands: [ + { + command: "claude", + args: [ + "plugin", + "marketplace", + "add", + "githits-com/githits-claude-code-plugin", + ], + }, + { + command: "claude", + args: ["plugin", "install", "githits@githits-plugins"], + }, + ], }; - it("returns success on exit code 0", async () => { + it("returns success on exit code 0 for single-step", async () => { const execService = createMockExecService({ exec: mock(() => Promise.resolve({ exitCode: 0, stdout: "Added.\n", stderr: "" }), ), }); - const result = await executeCliSetup(cliSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); expect(result.status).toBe("success"); - expect(execService.exec).toHaveBeenCalledWith("claude", [ + expect(execService.exec).toHaveBeenCalledWith("codex", [ "mcp", "add", - "GitHits", + "githits", ]); }); + it("returns success when all multi-step commands succeed", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "OK\n", stderr: "" }), + ), + }); + const result = await executeCliSetup(multiStepSetup, execService); + expect(result.status).toBe("success"); + expect(execService.exec).toHaveBeenCalledTimes(2); + }); + it("returns failed with stderr on non-zero exit", async () => { const execService = createMockExecService({ exec: mock(() => @@ -266,7 +494,7 @@ describe("executeCliSetup", () => { }), ), }); - const result = await executeCliSetup(cliSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); expect(result.status).toBe("failed"); expect(result.message).toContain("code 1"); expect(result.message).toContain("Unknown command"); @@ -279,22 +507,21 @@ describe("executeCliSetup", () => { const execService = createMockExecService({ exec: mock(() => Promise.reject(enoent)), }); - const result = await executeCliSetup(cliSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); expect(result.status).toBe("failed"); - expect(result.message).toContain('"claude" not found on PATH'); + expect(result.message).toContain('"codex" not found on PATH'); }); it("returns failed with message on other errors", async () => { const execService = createMockExecService({ exec: mock(() => Promise.reject(new Error("Unexpected error"))), }); - const result = await executeCliSetup(cliSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); expect(result.status).toBe("failed"); expect(result.message).toContain("Unexpected error"); }); - it("detects already-exists on non-zero exit (claude pattern)", async () => { - // claude mcp add exits 1 with "already exists" on stderr + it("detects already-exists on non-zero exit", async () => { const execService = createMockExecService({ exec: mock(() => Promise.resolve({ @@ -304,17 +531,11 @@ describe("executeCliSetup", () => { }), ), }); - const result = await executeCliSetup(cliSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); expect(result.status).toBe("already_configured"); }); it("detects already-exists on zero exit (codex pattern)", async () => { - // codex mcp add exits 0 with "already added" on stdout - const codexSetup: CliSetup = { - method: "cli", - command: "codex", - args: ["mcp", "add", "GitHits"], - }; const execService = createMockExecService({ exec: mock(() => Promise.resolve({ @@ -324,8 +545,49 @@ describe("executeCliSetup", () => { }), ), }); - const result = await executeCliSetup(codexSetup, execService); + const result = await executeCliSetup(singleStepSetup, execService); + expect(result.status).toBe("already_configured"); + }); + + it("stops on first failure in multi-step setup", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "command not found\n", + }), + ), + }); + const result = await executeCliSetup(multiStepSetup, execService); + expect(result.status).toBe("failed"); + // Only the first command should have been attempted + expect(execService.exec).toHaveBeenCalledTimes(1); + }); + + it("returns already_configured when any step reports it", async () => { + let callCount = 0; + const execService = createMockExecService({ + exec: mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + exitCode: 0, + stdout: "already exists\n", + stderr: "", + }); + } + return Promise.resolve({ + exitCode: 0, + stdout: "OK\n", + stderr: "", + }); + }), + }); + const result = await executeCliSetup(multiStepSetup, execService); expect(result.status).toBe("already_configured"); + // Both commands should still run + expect(execService.exec).toHaveBeenCalledTimes(2); }); }); @@ -337,10 +599,7 @@ describe("executeConfigFileSetup", () => { configPath: "/home/test/.cursor/mcp.json", serversKey: "mcpServers", serverName: "GitHits", - serverConfig: { - command: "npx", - args: ["-y", "mcp-remote", "https://mcp.githits.com"], - }, + serverConfig: { url: "https://mcp.githits.com/" }, }; it("creates new config when file does not exist", async () => { diff --git a/src/commands/init/setup-handlers.ts b/src/commands/init/setup-handlers.ts index 80dd9a57..047f1a49 100644 --- a/src/commands/init/setup-handlers.ts +++ b/src/commands/init/setup-handlers.ts @@ -1,6 +1,20 @@ import type { ExecService } from "../../services/exec-service.js"; import type { FileSystemService } from "../../services/filesystem-service.js"; -import type { CliSetup, ConfigFileSetup } from "./agent-definitions.js"; +import type { + CliCommand, + CliSetup, + ConfigFileSetup, +} from "./agent-definitions.js"; + +/** A read-only command to check if a CLI agent is already configured. */ +export interface CliCheckCommand { + /** Command to execute (e.g., "claude") */ + command: string; + /** Command arguments (e.g., ["plugin", "list"]) */ + args: string[]; + /** Pattern to search for in combined stdout+stderr. If found, agent is configured. */ + configuredPattern: RegExp; +} /** Result of merging server config into an existing config file */ export type MergeResult = @@ -101,7 +115,9 @@ export function mergeServerConfig( */ export function formatSetupPreview(config: CliSetup | ConfigFileSetup): string { if (config.method === "cli") { - return `Will run: ${config.command} ${config.args.join(" ")}`; + return config.commands + .map((cmd) => `Will run: ${cmd.command} ${cmd.args.join(" ")}`) + .join("\n"); } const snippet = JSON.stringify( { [config.serverName]: config.serverConfig }, @@ -111,6 +127,74 @@ export function formatSetupPreview(config: CliSetup | ConfigFileSetup): string { return `Will add to ${config.configPath}:\n\n${snippet}`; } +/** + * Check if GitHits is already configured in a config file. + * Read-only — never writes. Returns false on any error (file missing, parse failure). + */ +export async function isAlreadyConfigured( + config: ConfigFileSetup, + fs: FileSystemService, +): Promise { + try { + let content: string; + try { + content = await fs.readFile(config.configPath); + } catch { + return false; + } + + // Strip BOM if present + if (content.charCodeAt(0) === 0xfeff) { + content = content.slice(1); + } + + const trimmed = content.trim(); + if (trimmed === "") { + return false; + } + + const parsed = JSON.parse(trimmed); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return false; + } + + const servers = parsed[config.serversKey]; + if ( + typeof servers !== "object" || + servers === null || + Array.isArray(servers) + ) { + return false; + } + + return config.serverName in servers; + } catch { + return false; + } +} + +/** + * Check if a CLI agent is already configured by running a read-only check command. + * Checks pattern against combined stdout+stderr regardless of exit code. + * Returns false on ENOENT or when pattern does not match. + */ +export async function isCliAlreadyConfigured( + check: CliCheckCommand, + execService: ExecService, +): Promise { + try { + const result = await execService.exec(check.command, check.args); + const combined = `${result.stdout} ${result.stderr}`; + return check.configuredPattern.test(combined); + } catch { + return false; + } +} + /** Patterns in CLI output that indicate the server was already configured */ const ALREADY_EXISTS_PATTERNS = [ /already exists/i, @@ -124,26 +208,22 @@ function isAlreadyConfiguredOutput(output: string): boolean { } /** - * Execute a CLI-based setup (e.g., `claude mcp add`). + * Execute a single CLI command step. * Returns a result object — does not throw on failure. - * - * Handles idempotency: `claude mcp add` exits 1 with "already exists" on - * duplicate, while `codex mcp add` exits 0. Both are detected and mapped - * to "already_configured". */ -export async function executeCliSetup( - setup: CliSetup, +async function executeCliCommand( + cmd: CliCommand, execService: ExecService, ): Promise { try { - const result = await execService.exec(setup.command, setup.args); + const result = await execService.exec(cmd.command, cmd.args); const combined = `${result.stdout} ${result.stderr}`; // Check for "already exists" in output regardless of exit code if (isAlreadyConfiguredOutput(combined)) { return { status: "already_configured", - message: `GitHits already configured via ${setup.command}`, + message: `GitHits already configured via ${cmd.command}`, }; } @@ -160,7 +240,7 @@ export async function executeCliSetup( if (err instanceof Error && "code" in err && err.code === "ENOENT") { return { status: "failed", - message: `"${setup.command}" not found on PATH. Install it or configure manually.`, + message: `"${cmd.command}" not found on PATH. Install it or configure manually.`, }; } return { @@ -170,6 +250,41 @@ export async function executeCliSetup( } } +/** + * Execute a CLI-based setup with one or more sequential commands. + * Returns a result object — does not throw on failure. + * + * For multi-step setups (e.g., plugin marketplace add + plugin install), + * commands run sequentially and stop on first failure. + * If any step reports "already_configured", the overall result is "already_configured". + */ +export async function executeCliSetup( + setup: CliSetup, + execService: ExecService, +): Promise { + let anyAlreadyConfigured = false; + + for (const cmd of setup.commands) { + const result = await executeCliCommand(cmd, execService); + + if (result.status === "failed") { + return result; + } + if (result.status === "already_configured") { + anyAlreadyConfigured = true; + } + } + + if (anyAlreadyConfigured) { + return { + status: "already_configured", + message: `GitHits already configured via ${setup.commands[0]!.command}`, + }; + } + + return { status: "success", message: "Configured successfully" }; +} + /** * Execute a config-file-based setup (read/merge/atomic-write). * Returns a result object — does not throw on failure. diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 9043586c..72aa0e70 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -5,7 +5,7 @@ import { createMockBrowserService, createValidTokenData, } from "../services/test-helpers.js"; -import { loginAction } from "./login.js"; +import { loginAction, loginFlow } from "./login.js"; describe("loginAction", () => { const mcpUrl = "https://mcp.githits.com"; @@ -336,3 +336,87 @@ describe("loginAction", () => { consoleSpy.mockRestore(); }); }); + +describe("loginFlow", () => { + const mcpUrl = "https://mcp.githits.com"; + + it("returns success after completing OAuth flow", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + const result = await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + ); + + expect(result.status).toBe("success"); + expect(result.message).toContain("Logged in successfully"); + consoleSpy.mockRestore(); + }); + + it("returns already_authenticated when valid tokens exist", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + const result = await loginFlow( + {}, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage({ + loadTokens: mock(() => + Promise.resolve( + createValidTokenData({ + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ), + ), + }), + browserService: createMockBrowserService(), + mcpUrl, + }, + ); + + expect(result.status).toBe("already_authenticated"); + consoleSpy.mockRestore(); + }); + + it("returns failed on invalid port", async () => { + const result = await loginFlow( + { port: -1 }, + { + authService: createMockAuthService(), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain("Invalid port"); + }); + + it("returns failed when token exchange throws", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + const result = await loginFlow( + { port: 8080 }, + { + authService: createMockAuthService({ + exchangeCodeForTokens: mock(() => + Promise.reject(new Error("Token exchange failed")), + ), + }), + authStorage: createMockAuthStorage(), + browserService: createMockBrowserService(), + mcpUrl, + }, + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain("Token exchange failed"); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index e6b5b146..8534baab 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -12,6 +12,12 @@ export interface LoginOptions { force?: boolean; } +/** Result of the login flow, used by init to handle outcomes without process.exit */ +export interface LoginFlowResult { + status: "success" | "already_authenticated" | "failed"; + message: string; +} + const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes function randomPort(): number { @@ -26,12 +32,13 @@ export interface LoginDependencies { } /** - * Core login logic, separated for testability. + * Core login logic that returns a result instead of calling process.exit. + * Used by both the standalone `login` command and the `init` command. */ -export async function loginAction( +export async function loginFlow( options: LoginOptions, deps: LoginDependencies, -): Promise { +): Promise { const { authService, authStorage, browserService, mcpUrl } = deps; // Validate port if provided @@ -39,8 +46,10 @@ export async function loginAction( options.port !== undefined && (Number.isNaN(options.port) || options.port < 1 || options.port > 65535) ) { - console.error("Invalid port number. Must be between 1 and 65535."); - process.exit(1); + return { + status: "failed", + message: "Invalid port number. Must be between 1 and 65535.", + }; } // Check if already logged in @@ -49,10 +58,7 @@ export async function loginAction( const isExpired = existing.expiresAt && new Date(existing.expiresAt) < new Date(); if (!isExpired) { - console.log("Already logged in.\n"); - console.log(` Environment: ${mcpUrl}\n`); - console.log("To re-authenticate, use `githits login --force`."); - return; + return { status: "already_authenticated", message: "Already logged in." }; } console.log("Token expired. Starting new login...\n"); } else if (existing && options.force) { @@ -159,20 +165,19 @@ export async function loginAction( if (timeoutId) clearTimeout(timeoutId); } catch (error) { if (timeoutId) clearTimeout(timeoutId); - if (error instanceof Error) { - console.log(`${error.message}.\n`); - console.log("Run `githits login` to try again."); - } - process.exit(1); + const msg = + error instanceof Error ? error.message : "Authentication failed"; + return { status: "failed", message: `${msg}.` }; } // Step 7: Handle callback outcome if (callback.type !== "success") { - console.log(`${callback.message}\n`); - console.log("Run `githits login` to try again."); // Let the callback server finish sending the error page to the browser await new Promise((r) => setTimeout(r, 2000)); - process.exit(1); + return { + status: "failed", + message: callback.message ?? "Authentication callback failed.", + }; } // Step 8: Exchange code for tokens @@ -190,17 +195,16 @@ export async function loginAction( }); } catch (error) { // Best-effort: clear potentially stale client so next login starts fresh. - // Swallow clearClient errors to ensure we always reach the user-facing error path. try { await authStorage.clearClient(mcpUrl); } catch { // Ignore -- client cleanup is best-effort } - console.error( - `Failed to complete authentication: ${error instanceof Error ? error.message : error}\n`, - ); - console.log("Run `githits login` to try again."); - process.exit(1); + const msg = error instanceof Error ? error.message : String(error); + return { + status: "failed", + message: `Failed to complete authentication: ${msg}`, + }; } // Step 9: Save tokens @@ -216,9 +220,39 @@ export async function loginAction( // Success message const hours = Math.round(tokenResponse.expiresIn / 3600); + return { + status: "success", + message: `Logged in successfully. Token expires in ${hours} hour${hours !== 1 ? "s" : ""}.`, + }; +} + +/** + * Standalone login command action. + * Wraps loginFlow with console output and process.exit on failure. + */ +export async function loginAction( + options: LoginOptions, + deps: LoginDependencies, +): Promise { + const result = await loginFlow(options, deps); + + if (result.status === "already_authenticated") { + console.log("Already logged in.\n"); + console.log(` Environment: ${deps.mcpUrl}\n`); + console.log("To re-authenticate, use `githits login --force`."); + return; + } + + if (result.status === "failed") { + console.error(`${result.message}\n`); + console.log("Run `githits login` to try again."); + process.exit(1); + } + + // success console.log("Logged in successfully.\n"); - console.log(` Environment: ${mcpUrl}`); - console.log(` Token expires in: ${hours} hour${hours !== 1 ? "s" : ""}`); + console.log(` Environment: ${deps.mcpUrl}`); + console.log(result.message.replace("Logged in successfully. ", " ")); console.log("\nYou're ready to use githits with your AI assistant."); } From 95e163fafa0c1af890030f6ee13c26c423920a00 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Thu, 2 Apr 2026 18:30:12 -0700 Subject: [PATCH 3/7] feat: add Kiro and OpenCode agent definitions with binary detection Adds two new agents to init command scanning: - Kiro (config-file, ~/.kiro/, mcpServers, remote URL) - OpenCode (config-file, binary detection via which/where, "mcp" serversKey, array-based command format) Introduces optional detectBinary field on AgentDefinition for agents that should be detected by binary presence on PATH rather than config directory existence. Updates scanAgents to try binary detection before directory detection. Changes default mock exec to return exitCode 1 to prevent false positives in binary detection tests. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agent-definitions.test.ts | 186 ++++++++++++++++++-- src/commands/init/agent-definitions.ts | 75 +++++++- src/commands/init/init.test.ts | 43 +++-- src/services/test-helpers.ts | 2 +- 4 files changed, 266 insertions(+), 40 deletions(-) diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index b35a7cb7..d8e97bc5 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -13,8 +13,8 @@ import { } from "./agent-definitions.js"; describe("agentDefinitions", () => { - it("defines 9 agents", () => { - expect(agentDefinitions).toHaveLength(9); + it("defines 11 agents", () => { + expect(agentDefinitions).toHaveLength(11); }); it("has unique ids", () => { @@ -99,6 +99,52 @@ describe("detectPaths", () => { expect(paths).toEqual(["/home/test/.gemini/antigravity"]); }); + it("kiro uses ~/.kiro/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "kiro")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["/home/test/.kiro"]); + }); + + it("opencode uses binary detection (no detect paths)", () => { + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(agent.detectPaths(createMockFileSystemService())).toEqual([]); + expect(agent.detectBinary).toBeDefined(); + }); + + it("opencode detectBinary returns true when binary found", async () => { + const exec = createMockExecService({ + exec: mock(async () => ({ + exitCode: 0, + stdout: "/usr/bin/opencode\n", + stderr: "", + })), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(await agent.detectBinary!(exec)).toBe(true); + }); + + it("opencode detectBinary returns false when binary not found", async () => { + const exec = createMockExecService({ + exec: mock(async () => ({ exitCode: 1, stdout: "", stderr: "" })), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(await agent.detectBinary!(exec)).toBe(false); + }); + + it("opencode detectBinary returns false on exec error", async () => { + const exec = createMockExecService({ + exec: mock(async () => { + throw new Error("spawn ENOENT"); + }), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(await agent.detectBinary!(exec)).toBe(false); + }); + it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { const originalPlatform = process.platform; const originalAppdata = process.env.APPDATA; @@ -431,6 +477,45 @@ describe("getSetupConfig", () => { } }); + it("kiro returns config-file setup with url", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "kiro")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe("/home/test/.kiro/settings/mcp.json"); + expect(config.serversKey).toBe("mcpServers"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toHaveProperty("url"); + expect(config.serverConfig).not.toHaveProperty("command"); + } + }); + + it("opencode returns config-file setup with mcp serversKey and array command", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/home/test/.config/opencode/opencode.json", + ); + expect(config.serversKey).toBe("mcp"); + expect(config.serverName).toBe("GitHits"); + expect(config.serverConfig).toEqual({ + type: "local", + command: ["npx", "-y", "githits@latest", "mcp", "start"], + enabled: true, + }); + } + }); + it("claude-desktop is the only config-file agent using mcp-remote", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), @@ -447,7 +532,11 @@ describe("getSetupConfig", () => { const args = config.serverConfig.args as string[]; expect(args).toContain("mcp-remote"); } else { - expect(config.serverConfig).not.toHaveProperty("command"); + // No agent other than claude-desktop should use mcp-remote + const args = config.serverConfig.args; + if (Array.isArray(args)) { + expect(args).not.toContain("mcp-remote"); + } } } } @@ -507,14 +596,19 @@ describe("detectAgents", () => { expect(detected).toEqual([]); }); - it("returns all ids when all agents detected", async () => { + it("returns ids of all directory-detectable agents when all dirs exist", async () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), joinPath: mock((...segments: string[]) => segments.join("/")), isDirectory: mock(() => Promise.resolve(true)), }); const detected = await detectAgents(agentDefinitions, fs); - expect(detected).toHaveLength(agentDefinitions.length); + // detectAgents (deprecated) only checks detectPaths, not detectBinary + const dirDetectable = agentDefinitions.filter( + (a) => a.detectPaths(fs).length > 0, + ); + expect(detected).toHaveLength(dirDetectable.length); + expect(detected).not.toContain("opencode"); }); }); @@ -546,7 +640,7 @@ describe("scanAgents", () => { if (val instanceof Error) throw val; return val; } - return { exitCode: 0, stdout: "", stderr: "" }; + return { exitCode: 1, stdout: "", stderr: "" }; }), }); return { fs, execService }; @@ -624,6 +718,22 @@ describe("scanAgents", () => { expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe(true); }); + it("detects agent via detectBinary when directory does not exist", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + "which opencode": { + exitCode: 0, + stdout: "/usr/bin/opencode\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + }); + it("categorizes undetected agent as notDetected", async () => { const { fs, execService } = createScanMocks({ detectedDirs: [] }); const result = await scanAgents(agentDefinitions, fs, execService); @@ -686,6 +796,7 @@ describe("scanAgents", () => { "/home/test/.codex", "/home/test/.gemini", "/home/test/.gemini/antigravity", + "/home/test/.kiro", ]; // Platform-dependent detect dirs const vscodePath = `${appDataPrefix}/Code`; @@ -721,9 +832,24 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: { GitHits: { serverUrl: "https://mcp.githits.com" } }, }), + "/home/test/.kiro/settings/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + "/home/test/.config/opencode/opencode.json": JSON.stringify({ + mcp: { + GitHits: { + type: "local", + command: ["npx", "-y", "githits@latest", "mcp", "start"], + enabled: true, + }, + }, + }), }; - // Exec results for all CLI agents reporting configured + // Binary detection command varies by platform + const whichCmd = platform === "win32" ? "where" : "which"; + + // Exec results for all CLI agents reporting configured + binary detection const allCliConfigured: Record = { "claude plugin list": { exitCode: 0, @@ -740,6 +866,11 @@ describe("scanAgents", () => { stdout: "githits-gemini-cli\n", stderr: "", }, + [`${whichCmd} opencode`]: { + exitCode: 0, + stdout: "/usr/bin/opencode\n", + stderr: "", + }, }; describe(`comprehensive all-agents scenarios (${platform})`, () => { @@ -766,7 +897,7 @@ describe("scanAgents", () => { execResults: allCliConfigured, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.alreadyConfigured).toHaveLength(9); + expect(result.alreadyConfigured).toHaveLength(11); expect(result.needsSetup).toHaveLength(0); expect(result.notDetected).toHaveLength(0); }); @@ -786,14 +917,27 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: {}, }), + "/home/test/.kiro/settings/mcp.json": JSON.stringify({ + mcpServers: {}, + }), + "/home/test/.config/opencode/opencode.json": JSON.stringify({ + mcp: {}, + }), }; const { fs, execService } = createScanMocks({ detectedDirs: allDetectDirs, configFiles: unconfiguredFiles, + execResults: { + [`${whichCmd} opencode`]: { + exitCode: 0, + stdout: "/usr/bin/opencode\n", + stderr: "", + }, + }, }); const result = await scanAgents(agentDefinitions, fs, execService); expect(result.alreadyConfigured).toHaveLength(0); - expect(result.needsSetup).toHaveLength(9); + expect(result.needsSetup).toHaveLength(11); expect(result.notDetected).toHaveLength(0); }); @@ -802,20 +946,22 @@ describe("scanAgents", () => { const result = await scanAgents(agentDefinitions, fs, execService); expect(result.alreadyConfigured).toHaveLength(0); expect(result.needsSetup).toHaveLength(0); - expect(result.notDetected).toHaveLength(9); + expect(result.notDetected).toHaveLength(11); }); - it("mixed: 3 configured, 3 unconfigured, 3 not detected", async () => { + it("mixed: 4 configured, 4 unconfigured, 3 not detected", async () => { const { fs, execService } = createScanMocks({ detectedDirs: [ - // Configured: cursor, claude-desktop, claude-code + // Configured: cursor, claude-desktop, claude-code, kiro "/home/test/.cursor", claudeDesktopPath, "/home/test/.claude", + "/home/test/.kiro", // Unconfigured: windsurf, vscode, codex-cli "/home/test/.codeium/windsurf", vscodePath, "/home/test/.codex", + // opencode detected via binary (below), not directory // Not detected: cline, gemini-cli, google-antigravity ], configFiles: { @@ -827,6 +973,9 @@ describe("scanAgents", () => { mcpServers: { GitHits: { command: "npx" } }, }, ), + "/home/test/.kiro/settings/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), }, execResults: { "claude plugin list": { @@ -835,18 +984,23 @@ describe("scanAgents", () => { stderr: "", }, "codex mcp list": { exitCode: 0, stdout: "", stderr: "" }, + [`${whichCmd} opencode`]: { + exitCode: 0, + stdout: "/usr/bin/opencode\n", + stderr: "", + }, }, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.alreadyConfigured).toHaveLength(3); - expect(result.needsSetup).toHaveLength(3); + expect(result.alreadyConfigured).toHaveLength(4); + expect(result.needsSetup).toHaveLength(4); expect(result.notDetected).toHaveLength(3); expect(result.alreadyConfigured.map((a) => a.id).sort()).toEqual( - ["claude-code", "claude-desktop", "cursor"].sort(), + ["claude-code", "claude-desktop", "cursor", "kiro"].sort(), ); expect(result.needsSetup.map((a) => a.id).sort()).toEqual( - ["codex-cli", "vscode", "windsurf"].sort(), + ["codex-cli", "opencode", "vscode", "windsurf"].sort(), ); expect(result.notDetected.map((a) => a.id).sort()).toEqual( ["cline", "gemini-cli", "google-antigravity"].sort(), diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index 2e02dbb6..b17d23d7 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -56,6 +56,8 @@ export interface AgentDefinition { id: string; /** Directories to check for detection. Uses FileSystemService for testability. */ detectPaths: (fs: FileSystemService) => string[]; + /** Optional binary detection — checked before detectPaths. */ + detectBinary?: (exec: ExecService) => Promise; /** How this agent is configured */ setupMethod: "cli" | "config-file"; /** Returns the setup config for this agent. Uses FileSystemService for path resolution. */ @@ -296,6 +298,54 @@ const googleAntigravity: AgentDefinition = { }), }; +/** Kiro: detected by ~/.kiro/ directory */ +const kiro: AgentDefinition = { + name: "Kiro", + id: "kiro", + setupMethod: "config-file", + detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".kiro")], + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath(fs.getHomeDir(), ".kiro", "settings", "mcp.json"), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { url: getMcpUrl() }, + }), +}; + +/** OpenCode: detected by opencode binary on PATH */ +const openCode: AgentDefinition = { + name: "OpenCode", + id: "opencode", + setupMethod: "config-file", + detectPaths: () => [], + detectBinary: async (exec) => { + try { + const cmd = process.platform === "win32" ? "where" : "which"; + const result = await exec.exec(cmd, ["opencode"]); + return result.exitCode === 0; + } catch { + return false; + } + }, + getSetupConfig: (fs) => ({ + method: "config-file", + configPath: fs.joinPath( + fs.getHomeDir(), + ".config", + "opencode", + "opencode.json", + ), + serversKey: "mcp", + serverName: "GitHits", + serverConfig: { + type: "local", + command: ["npx", "-y", "githits@latest", "mcp", "start"], + enabled: true, + }, + }), +}; + /** * All supported agent definitions, ordered by popularity/likelihood. * New agents should be added here. @@ -310,6 +360,8 @@ export const agentDefinitions: AgentDefinition[] = [ codexCli, geminiCli, googleAntigravity, + kiro, + openCode, ]; /** @@ -362,13 +414,24 @@ export async function scanAgents( }; for (const agent of definitions) { - // Check if installed - const paths = agent.detectPaths(fs); + // Check if installed — try binary detection first, then directory detection let detected = false; - for (const path of paths) { - if (await fs.isDirectory(path)) { - detected = true; - break; + + if (agent.detectBinary) { + try { + detected = await agent.detectBinary(execService); + } catch { + // Fall through to directory detection + } + } + + if (!detected) { + const paths = agent.detectPaths(fs); + for (const path of paths) { + if (await fs.isDirectory(path)) { + detected = true; + break; + } } } diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index 2200d8dc..f151db6d 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -208,13 +208,18 @@ describe("initAction", () => { }, ); const execService = createMockExecService({ - exec: mock(() => - Promise.resolve({ - exitCode: 0, - stdout: "githits-plugin\n", - stderr: "", - }), - ), + exec: mock((cmd: string, args: string[]) => { + const key = `${cmd} ${args.join(" ")}`; + if (key === "claude plugin list") { + return Promise.resolve({ + exitCode: 0, + stdout: "githits-plugin\n", + stderr: "", + }); + } + // Default: command not found (e.g., which opencode) + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + }), }); const promptService = createMockPromptService(); @@ -238,20 +243,23 @@ describe("initAction", () => { const promptService = createMockPromptService({ confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); - let callCount = 0; const execService = createMockExecService({ - exec: mock(() => { - callCount++; - // First call: check command (no match = needs setup) - if (callCount === 1) { + exec: mock((cmd: string, args: string[]) => { + const key = `${cmd} ${args.join(" ")}`; + if (key === "claude plugin list") { + // Check command: no match = needs setup return Promise.resolve({ exitCode: 0, stdout: "other-plugin\n", stderr: "", }); } - // Subsequent calls: setup commands - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + if (cmd === "claude") { + // Setup commands + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + } + // Default: command not found (e.g., which opencode) + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); }), }); @@ -265,8 +273,8 @@ describe("initAction", () => { }, ); - // 1 check + 2 setup commands = 3 exec calls - expect(execService.exec).toHaveBeenCalledTimes(3); + // 1 binary detection (which opencode) + 1 check + 2 setup commands = 4 exec calls + expect(execService.exec).toHaveBeenCalledTimes(4); expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); }); @@ -347,7 +355,8 @@ describe("initAction", () => { }, ); - expect(execService.exec).not.toHaveBeenCalled(); + // Only exec call should be the opencode binary detection (which opencode) + expect(execService.exec).toHaveBeenCalledTimes(1); const logCalls = getLogOutput(); expect( logCalls.some((msg) => msg.includes("No coding agents detected")), diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index a5ff1fe4..963c2c1b 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -252,7 +252,7 @@ export function createMockExecService( ): ExecService { return { exec: mock(() => - Promise.resolve({ exitCode: 0, stdout: "", stderr: "" } as ExecResult), + Promise.resolve({ exitCode: 1, stdout: "", stderr: "" } as ExecResult), ), ...impl, }; From 7d312fb5fca3d58426e5442287c545e0fc168e83 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Thu, 2 Apr 2026 18:32:43 -0700 Subject: [PATCH 4/7] chore: add package-lock.json to gitignore Project uses bun (bun.lock), so npm's lockfile should not be tracked. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b41a5b0a..9288a86f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies (bun install) node_modules +package-lock.json # output out From 4703e783143c11321b9ef7dc1e0325629aff73e6 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Thu, 2 Apr 2026 18:50:00 -0700 Subject: [PATCH 5/7] refactor: remove Kiro agent definition Removes Kiro from the init command's agent scanning and all associated test fixtures and assertions. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agent-definitions.test.ts | 56 ++++----------------- src/commands/init/agent-definitions.ts | 16 ------ 2 files changed, 9 insertions(+), 63 deletions(-) diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index d8e97bc5..f0fd8fb7 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -13,8 +13,8 @@ import { } from "./agent-definitions.js"; describe("agentDefinitions", () => { - it("defines 11 agents", () => { - expect(agentDefinitions).toHaveLength(11); + it("defines 10 agents", () => { + expect(agentDefinitions).toHaveLength(10); }); it("has unique ids", () => { @@ -99,16 +99,6 @@ describe("detectPaths", () => { expect(paths).toEqual(["/home/test/.gemini/antigravity"]); }); - it("kiro uses ~/.kiro/", () => { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); - const agent = agentDefinitions.find((a) => a.id === "kiro")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["/home/test/.kiro"]); - }); - it("opencode uses binary detection (no detect paths)", () => { const agent = agentDefinitions.find((a) => a.id === "opencode")!; expect(agent.detectPaths(createMockFileSystemService())).toEqual([]); @@ -477,23 +467,6 @@ describe("getSetupConfig", () => { } }); - it("kiro returns config-file setup with url", () => { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); - const agent = agentDefinitions.find((a) => a.id === "kiro")!; - const config = agent.getSetupConfig(fs); - expect(config.method).toBe("config-file"); - if (config.method === "config-file") { - expect(config.configPath).toBe("/home/test/.kiro/settings/mcp.json"); - expect(config.serversKey).toBe("mcpServers"); - expect(config.serverName).toBe("GitHits"); - expect(config.serverConfig).toHaveProperty("url"); - expect(config.serverConfig).not.toHaveProperty("command"); - } - }); - it("opencode returns config-file setup with mcp serversKey and array command", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), @@ -796,7 +769,6 @@ describe("scanAgents", () => { "/home/test/.codex", "/home/test/.gemini", "/home/test/.gemini/antigravity", - "/home/test/.kiro", ]; // Platform-dependent detect dirs const vscodePath = `${appDataPrefix}/Code`; @@ -832,9 +804,6 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: { GitHits: { serverUrl: "https://mcp.githits.com" } }, }), - "/home/test/.kiro/settings/mcp.json": JSON.stringify({ - mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, - }), "/home/test/.config/opencode/opencode.json": JSON.stringify({ mcp: { GitHits: { @@ -897,7 +866,7 @@ describe("scanAgents", () => { execResults: allCliConfigured, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.alreadyConfigured).toHaveLength(11); + expect(result.alreadyConfigured).toHaveLength(10); expect(result.needsSetup).toHaveLength(0); expect(result.notDetected).toHaveLength(0); }); @@ -917,9 +886,6 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: {}, }), - "/home/test/.kiro/settings/mcp.json": JSON.stringify({ - mcpServers: {}, - }), "/home/test/.config/opencode/opencode.json": JSON.stringify({ mcp: {}, }), @@ -937,7 +903,7 @@ describe("scanAgents", () => { }); const result = await scanAgents(agentDefinitions, fs, execService); expect(result.alreadyConfigured).toHaveLength(0); - expect(result.needsSetup).toHaveLength(11); + expect(result.needsSetup).toHaveLength(10); expect(result.notDetected).toHaveLength(0); }); @@ -946,17 +912,16 @@ describe("scanAgents", () => { const result = await scanAgents(agentDefinitions, fs, execService); expect(result.alreadyConfigured).toHaveLength(0); expect(result.needsSetup).toHaveLength(0); - expect(result.notDetected).toHaveLength(11); + expect(result.notDetected).toHaveLength(10); }); - it("mixed: 4 configured, 4 unconfigured, 3 not detected", async () => { + it("mixed: 3 configured, 4 unconfigured, 3 not detected", async () => { const { fs, execService } = createScanMocks({ detectedDirs: [ - // Configured: cursor, claude-desktop, claude-code, kiro + // Configured: cursor, claude-desktop, claude-code "/home/test/.cursor", claudeDesktopPath, "/home/test/.claude", - "/home/test/.kiro", // Unconfigured: windsurf, vscode, codex-cli "/home/test/.codeium/windsurf", vscodePath, @@ -973,9 +938,6 @@ describe("scanAgents", () => { mcpServers: { GitHits: { command: "npx" } }, }, ), - "/home/test/.kiro/settings/mcp.json": JSON.stringify({ - mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, - }), }, execResults: { "claude plugin list": { @@ -992,12 +954,12 @@ describe("scanAgents", () => { }, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.alreadyConfigured).toHaveLength(4); + expect(result.alreadyConfigured).toHaveLength(3); expect(result.needsSetup).toHaveLength(4); expect(result.notDetected).toHaveLength(3); expect(result.alreadyConfigured.map((a) => a.id).sort()).toEqual( - ["claude-code", "claude-desktop", "cursor", "kiro"].sort(), + ["claude-code", "claude-desktop", "cursor"].sort(), ); expect(result.needsSetup.map((a) => a.id).sort()).toEqual( ["codex-cli", "opencode", "vscode", "windsurf"].sort(), diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index b17d23d7..f27a844d 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -298,21 +298,6 @@ const googleAntigravity: AgentDefinition = { }), }; -/** Kiro: detected by ~/.kiro/ directory */ -const kiro: AgentDefinition = { - name: "Kiro", - id: "kiro", - setupMethod: "config-file", - detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".kiro")], - getSetupConfig: (fs) => ({ - method: "config-file", - configPath: fs.joinPath(fs.getHomeDir(), ".kiro", "settings", "mcp.json"), - serversKey: "mcpServers", - serverName: "GitHits", - serverConfig: { url: getMcpUrl() }, - }), -}; - /** OpenCode: detected by opencode binary on PATH */ const openCode: AgentDefinition = { name: "OpenCode", @@ -360,7 +345,6 @@ export const agentDefinitions: AgentDefinition[] = [ codexCli, geminiCli, googleAntigravity, - kiro, openCode, ]; From 7de67c7635c015238b2c23528c273e0d2db041ee Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Tue, 7 Apr 2026 18:42:32 -0700 Subject: [PATCH 6/7] feat: harden Windows support and broaden agent detection Add shell:true on Windows for .cmd/.ps1 shim resolution in exec-service. Broaden Claude Desktop detection with LOCALAPPDATA and Programs paths. Add OpenCode directory fallback detection alongside binary detection. Fix lint issues (unused imports, non-null assertion). Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agent-definitions.test.ts | 191 ++++++++++++++++++-- src/commands/init/agent-definitions.ts | 25 ++- src/commands/init/init.test.ts | 23 +-- src/commands/init/setup-handlers.ts | 2 +- src/services/exec-service.test.ts | 33 ++++ src/services/exec-service.ts | 4 + src/services/test-helpers.ts | 6 +- 7 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 src/services/exec-service.test.ts diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index f0fd8fb7..8c381fef 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -5,7 +5,6 @@ import { createMockFileSystemService, } from "../../services/test-helpers.js"; import { - type AgentDefinition, agentDefinitions, buildCheckboxChoices, detectAgents, @@ -99,10 +98,147 @@ describe("detectPaths", () => { expect(paths).toEqual(["/home/test/.gemini/antigravity"]); }); - it("opencode uses binary detection (no detect paths)", () => { - const agent = agentDefinitions.find((a) => a.id === "opencode")!; - expect(agent.detectPaths(createMockFileSystemService())).toEqual([]); - expect(agent.detectBinary).toBeDefined(); + it("opencode has both directory and binary detection", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(agent.detectPaths(fs)).toEqual(["/home/test/.config/opencode"]); + expect(agent.detectBinary).toBeDefined(); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("opencode returns APPDATA path on win32", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const paths = agent.detectPaths(fs); + expect(paths).toEqual(["C:\\Users\\test\\AppData\\Roaming/opencode"]); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } + }); + + it("claude-desktop checks multiple Windows paths on win32", () => { + const originalPlatform = process.platform; + const originalLocalAppdata = process.env.LOCALAPPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.LOCALAPPDATA = "C:\\Users\\test\\AppData\\Local"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const paths = agent.detectPaths(fs); + expect(paths).toHaveLength(3); + expect(paths[0]).toContain("Roaming"); + expect(paths[1]).toContain("Local/Claude"); + expect(paths[2]).toContain("Local/Programs/Claude"); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalLocalAppdata !== undefined) { + process.env.LOCALAPPDATA = originalLocalAppdata; + } else { + delete process.env.LOCALAPPDATA; + } + } + }); + + it("claude-desktop returns single path on non-Windows", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const paths = agent.detectPaths(fs); + expect(paths).toHaveLength(1); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("claude-desktop falls back to AppData/Local when LOCALAPPDATA is unset on win32", () => { + const originalPlatform = process.platform; + const originalLocalAppdata = process.env.LOCALAPPDATA; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + delete process.env.LOCALAPPDATA; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; + const paths = agent.detectPaths(fs); + expect(paths).toHaveLength(3); + expect(paths[1]).toBe("C:\\Users\\test/AppData/Local/Claude"); + expect(paths[2]).toBe("C:\\Users\\test/AppData/Local/Programs/Claude"); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalLocalAppdata !== undefined) { + process.env.LOCALAPPDATA = originalLocalAppdata; + } else { + delete process.env.LOCALAPPDATA; + } + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } }); it("opencode detectBinary returns true when binary found", async () => { @@ -577,11 +713,9 @@ describe("detectAgents", () => { }); const detected = await detectAgents(agentDefinitions, fs); // detectAgents (deprecated) only checks detectPaths, not detectBinary - const dirDetectable = agentDefinitions.filter( - (a) => a.detectPaths(fs).length > 0, - ); - expect(detected).toHaveLength(dirDetectable.length); - expect(detected).not.toContain("opencode"); + // All agents now have detectPaths, so all should be detected + expect(detected).toHaveLength(agentDefinitions.length); + expect(detected).toContain("opencode"); }); }); @@ -707,6 +841,32 @@ describe("scanAgents", () => { expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); }); + it("detects opencode via directory fallback when binary not on PATH", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + try { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.config/opencode"], + configFiles: { + "/home/test/.config/opencode/opencode.json": JSON.stringify({ + mcp: {}, + }), + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + it("categorizes undetected agent as notDetected", async () => { const { fs, execService } = createScanMocks({ detectedDirs: [] }); const result = await scanAgents(agentDefinitions, fs, execService); @@ -773,7 +933,16 @@ describe("scanAgents", () => { // Platform-dependent detect dirs const vscodePath = `${appDataPrefix}/Code`; const claudeDesktopPath = `${appDataPrefix}/Claude`; - const allDetectDirs = [...homeDirs, vscodePath, claudeDesktopPath]; + const opencodePath = + platform === "win32" + ? `${appDataPrefix}/opencode` + : "/home/test/.config/opencode"; + const allDetectDirs = [ + ...homeDirs, + vscodePath, + claudeDesktopPath, + opencodePath, + ]; // Config files for all config-file agents with GitHits configured const allConfiguredFiles: Record = { diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index f27a844d..6a8509b4 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -161,6 +161,16 @@ const claudeDesktop: AgentDefinition = { setupMethod: "config-file", detectPaths: (fs) => { const appData = getAppDataPath(fs, "Claude"); + if (process.platform === "win32") { + const home = fs.getHomeDir(); + const localAppData = + process.env.LOCALAPPDATA ?? fs.joinPath(home, "AppData", "Local"); + return [ + appData, + fs.joinPath(localAppData, "Claude"), + fs.joinPath(localAppData, "Programs", "Claude"), + ]; + } return [appData]; }, getSetupConfig: (fs) => { @@ -298,12 +308,23 @@ const googleAntigravity: AgentDefinition = { }), }; -/** OpenCode: detected by opencode binary on PATH */ +/** OpenCode: detected by opencode binary on PATH or config directory */ const openCode: AgentDefinition = { name: "OpenCode", id: "opencode", setupMethod: "config-file", - detectPaths: () => [], + detectPaths: (fs) => { + const home = fs.getHomeDir(); + if (process.platform === "win32") { + return [ + fs.joinPath( + process.env.APPDATA ?? fs.joinPath(home, "AppData", "Roaming"), + "opencode", + ), + ]; + } + return [fs.joinPath(home, ".config", "opencode")]; + }, detectBinary: async (exec) => { try { const cmd = process.platform === "win32" ? "where" : "which"; diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index f151db6d..04ff7216 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -8,10 +8,7 @@ import { spyOn, } from "bun:test"; import { ExitPromptError } from "@inquirer/core"; -import type { - ConfirmChoice, - PromptService, -} from "../../services/prompt-service.js"; +import type { ConfirmChoice } from "../../services/prompt-service.js"; import { createMockAuthService, createMockAuthStorage, @@ -22,7 +19,6 @@ import { createValidTokenData, } from "../../services/test-helpers.js"; import type { LoginDependencies } from "../login.js"; -import type { InitDependencies } from "./init.js"; import { initAction } from "./init.js"; /** Suppress console.log during tests */ @@ -56,23 +52,6 @@ function createAlreadyAuthLoginDeps(): () => Promise { ); } -/** Create default deps with overrides */ -function createDeps( - overrides: Partial = {}, -): InitDependencies { - return { - fileSystemService: createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - isDirectory: mock(() => Promise.resolve(false)), - }), - promptService: createMockPromptService(), - execService: createMockExecService(), - createLoginDeps: createAlreadyAuthLoginDeps(), - ...overrides, - }; -} - /** * Create a FileSystemService mock that detects specific agents * and optionally has config files with content. diff --git a/src/commands/init/setup-handlers.ts b/src/commands/init/setup-handlers.ts index 047f1a49..9069bd0b 100644 --- a/src/commands/init/setup-handlers.ts +++ b/src/commands/init/setup-handlers.ts @@ -278,7 +278,7 @@ export async function executeCliSetup( if (anyAlreadyConfigured) { return { status: "already_configured", - message: `GitHits already configured via ${setup.commands[0]!.command}`, + message: `GitHits already configured via ${setup.commands[0]?.command}`, }; } diff --git a/src/services/exec-service.test.ts b/src/services/exec-service.test.ts new file mode 100644 index 00000000..1e014fad --- /dev/null +++ b/src/services/exec-service.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { ExecServiceImpl } from "./exec-service.js"; + +describe("ExecServiceImpl", () => { + it("executes a command and returns stdout", async () => { + const service = new ExecServiceImpl(); + const result = await service.exec("node", ["--version"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/^v\d+/); + }); + + it("returns non-zero exit code for failing command", async () => { + const service = new ExecServiceImpl(); + const result = await service.exec("node", ["-e", "process.exit(42)"]); + expect(result.exitCode).toBe(42); + }); + + it("rejects with error for non-existent command", async () => { + const service = new ExecServiceImpl(); + await expect( + service.exec("nonexistent-command-xyz-12345", []), + ).rejects.toThrow(); + }); + + it("captures stderr output", async () => { + const service = new ExecServiceImpl(); + const result = await service.exec("node", [ + "-e", + "process.stderr.write('err-msg')", + ]); + expect(result.stderr).toContain("err-msg"); + }); +}); diff --git a/src/services/exec-service.ts b/src/services/exec-service.ts index 40395f06..2a9730da 100644 --- a/src/services/exec-service.ts +++ b/src/services/exec-service.ts @@ -19,6 +19,9 @@ export interface ExecService { /** * Production implementation using node:child_process.spawn. * Collects stdout/stderr and resolves with exit code. + * + * On Windows, uses shell: true to resolve .cmd/.ps1 shims via cmd.exe. + * Callers must not pass untrusted input as command or args. */ export class ExecServiceImpl implements ExecService { async exec(command: string, args: string[]): Promise { @@ -26,6 +29,7 @@ export class ExecServiceImpl implements ExecService { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, + ...(process.platform === "win32" && { shell: true }), }); const stdoutChunks: Buffer[] = []; diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 963c2c1b..55075ee8 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -16,11 +16,7 @@ import type { ExecResult, ExecService } from "./exec-service.js"; import type { FileSystemService } from "./filesystem-service.js"; import type { GitHitsService } from "./githits-service.js"; import type { KeyringService } from "./keyring-service.js"; -import type { - CheckboxChoice, - ConfirmChoice, - PromptService, -} from "./prompt-service.js"; +import type { ConfirmChoice, PromptService } from "./prompt-service.js"; import type { TokenProvider } from "./token-manager.js"; /** From 8c599e6c68b9aa43072d8ec8a9ea7574c39af9ac Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Thu, 9 Apr 2026 15:49:08 -0700 Subject: [PATCH 7/7] fix: tighten init agent detection Require CLI agents to be detected via executable availability instead of dot-directories. Align OpenCode Windows config paths and update init tests/docs to match the new detection contract. --- docs/implementation/cli-commands.md | 2 +- src/commands/init/agent-definitions.test.ts | 301 ++++++++++++-------- src/commands/init/agent-definitions.ts | 110 ++++--- src/commands/init/init.test.ts | 93 ++++-- src/commands/init/init.ts | 8 +- 5 files changed, 327 insertions(+), 187 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index c305d3a9..b6f70f6e 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -21,7 +21,7 @@ githits init --yes # Non-interactive: authenticate, configure all unconfi githits init --skip-login # Skip authentication, configure tools only ``` -Authenticates with GitHits (via OAuth in the browser), then scans for installed coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. All agents — including CLI-based ones — are pre-checked before any setup begins, so the status display is fully resolved. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. +Authenticates with GitHits (via OAuth in the browser), then scans for available coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. All agents are pre-checked before any setup begins, so the status display is fully resolved. CLI agents are considered available only when their executable is on `PATH`; related dot-directories alone do not count. Config-file agents remain filesystem-detected using their known app/config directories. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. Supports Claude Code, Cursor, Windsurf, VS Code / Copilot, Cline, Claude Desktop, Codex CLI, Gemini CLI, and Google Antigravity. Uses plugin install (Claude Code), CLI commands (Codex, Gemini CLI), and atomic config file writes (Cursor, Windsurf, VS Code, Cline, Claude Desktop, Google Antigravity). CLI agents use read-only check commands (e.g., `claude plugin list`) to determine configuration status before prompting. diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index 8c381fef..db42c53d 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -27,15 +27,11 @@ describe("agentDefinitions", () => { }); }); -describe("detectPaths", () => { - it("claude-code uses ~/.claude/", () => { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); +describe("detection configuration", () => { + it("claude-code uses binary detection only", () => { const agent = agentDefinitions.find((a) => a.id === "claude-code")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["/home/test/.claude"]); + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); }); it("cursor uses ~/.cursor/", () => { @@ -44,18 +40,14 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "cursor")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); expect(paths).toEqual(["/home/test/.cursor"]); }); - it("codex-cli uses ~/.codex/", () => { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); + it("codex-cli uses binary detection only", () => { const agent = agentDefinitions.find((a) => a.id === "codex-cli")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["/home/test/.codex"]); + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); }); it("windsurf detects via ~/.codeium/windsurf/ directory", () => { @@ -64,7 +56,7 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "windsurf")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); expect(paths).toEqual(["/home/test/.codeium/windsurf"]); }); @@ -74,18 +66,14 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "cline")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); expect(paths).toEqual(["/home/test/.cline"]); }); - it("gemini-cli uses ~/.gemini/", () => { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); + it("gemini-cli uses binary detection only", () => { const agent = agentDefinitions.find((a) => a.id === "gemini-cli")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["/home/test/.gemini"]); + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); }); it("google-antigravity uses ~/.gemini/antigravity/", () => { @@ -94,59 +82,14 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "google-antigravity")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); expect(paths).toEqual(["/home/test/.gemini/antigravity"]); }); - it("opencode has both directory and binary detection", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { - value: "linux", - configurable: true, - }); - try { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "/home/test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); - const agent = agentDefinitions.find((a) => a.id === "opencode")!; - expect(agent.detectPaths(fs)).toEqual(["/home/test/.config/opencode"]); - expect(agent.detectBinary).toBeDefined(); - } finally { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - } - }); - - it("opencode returns APPDATA path on win32", () => { - const originalPlatform = process.platform; - const originalAppdata = process.env.APPDATA; - Object.defineProperty(process, "platform", { - value: "win32", - configurable: true, - }); - process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; - try { - const fs = createMockFileSystemService({ - getHomeDir: mock(() => "C:\\Users\\test"), - joinPath: mock((...segments: string[]) => segments.join("/")), - }); - const agent = agentDefinitions.find((a) => a.id === "opencode")!; - const paths = agent.detectPaths(fs); - expect(paths).toEqual(["C:\\Users\\test\\AppData\\Roaming/opencode"]); - } finally { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - if (originalAppdata !== undefined) { - process.env.APPDATA = originalAppdata; - } else { - delete process.env.APPDATA; - } - } + it("opencode uses binary detection only", () => { + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); }); it("claude-desktop checks multiple Windows paths on win32", () => { @@ -163,7 +106,9 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); + expect(paths).toBeDefined(); + if (!paths) throw new Error("expected claude-desktop paths"); expect(paths).toHaveLength(3); expect(paths[0]).toContain("Roaming"); expect(paths[1]).toContain("Local/Claude"); @@ -193,7 +138,9 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); + expect(paths).toBeDefined(); + if (!paths) throw new Error("expected claude-desktop paths"); expect(paths).toHaveLength(1); } finally { Object.defineProperty(process, "platform", { @@ -219,7 +166,9 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); const agent = agentDefinitions.find((a) => a.id === "claude-desktop")!; - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); + expect(paths).toBeDefined(); + if (!paths) throw new Error("expected claude-desktop paths"); expect(paths).toHaveLength(3); expect(paths[1]).toBe("C:\\Users\\test/AppData/Local/Claude"); expect(paths[2]).toBe("C:\\Users\\test/AppData/Local/Programs/Claude"); @@ -271,7 +220,7 @@ describe("detectPaths", () => { expect(await agent.detectBinary!(exec)).toBe(false); }); - it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { + it("path-detected agents use FileSystemService.getHomeDir (not hardcoded)", () => { const originalPlatform = process.platform; const originalAppdata = process.env.APPDATA; Object.defineProperty(process, "platform", { @@ -285,7 +234,10 @@ describe("detectPaths", () => { joinPath: mock((...segments: string[]) => segments.join("/")), }); for (const agent of agentDefinitions) { - const paths = agent.detectPaths(fs); + const paths = agent.detectPaths?.(fs); + if (!paths) { + continue; + } for (const path of paths) { expect(path).toContain("/custom/home"); } @@ -625,6 +577,40 @@ describe("getSetupConfig", () => { } }); + it("opencode uses APPDATA on win32 for config path", () => { + const originalPlatform = process.platform; + const originalAppdata = process.env.APPDATA; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + try { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "C:\\Users\\test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "opencode")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "C:\\Users\\test\\AppData\\Roaming/opencode/opencode.json", + ); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + if (originalAppdata !== undefined) { + process.env.APPDATA = originalAppdata; + } else { + delete process.env.APPDATA; + } + } + }); + it("claude-desktop is the only config-file agent using mcp-remote", () => { const fs = createMockFileSystemService({ getHomeDir: mock(() => "/home/test"), @@ -688,7 +674,6 @@ describe("detectAgents", () => { }), }); const detected = await detectAgents(agentDefinitions, fs); - expect(detected).toContain("claude-code"); expect(detected).toContain("cursor"); expect(detected).not.toContain("windsurf"); expect(detected).not.toContain("claude-desktop"); @@ -712,14 +697,20 @@ describe("detectAgents", () => { isDirectory: mock(() => Promise.resolve(true)), }); const detected = await detectAgents(agentDefinitions, fs); - // detectAgents (deprecated) only checks detectPaths, not detectBinary - // All agents now have detectPaths, so all should be detected - expect(detected).toHaveLength(agentDefinitions.length); - expect(detected).toContain("opencode"); + // detectAgents (deprecated) only checks path-based agents + expect(detected).toHaveLength(6); + expect(detected).not.toContain("claude-code"); + expect(detected).not.toContain("codex-cli"); + expect(detected).not.toContain("gemini-cli"); + expect(detected).not.toContain("opencode"); }); }); describe("scanAgents", () => { + function lookupCommandFor(platform: string = process.platform): string { + return platform === "win32" ? "where" : "which"; + } + /** Helper to create fs + exec mocks for scan tests */ function createScanMocks(opts: { detectedDirs: string[]; @@ -777,9 +768,15 @@ describe("scanAgents", () => { }); it("categorizes CLI agent as alreadyConfigured when check command matches", async () => { + const lookupCmd = lookupCommandFor(); const { fs, execService } = createScanMocks({ - detectedDirs: ["/home/test/.claude"], + detectedDirs: [], execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": { exitCode: 0, stdout: "githits-plugin\nother\n", @@ -795,9 +792,15 @@ describe("scanAgents", () => { }); it("categorizes CLI agent as needsSetup when check command does not match", async () => { + const lookupCmd = lookupCommandFor(); const { fs, execService } = createScanMocks({ - detectedDirs: ["/home/test/.claude"], + detectedDirs: [], execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": { exitCode: 0, stdout: "other-plugin\n", @@ -813,9 +816,15 @@ describe("scanAgents", () => { }); it("categorizes CLI agent as needsSetup when check command fails (ENOENT)", async () => { + const lookupCmd = lookupCommandFor(); const { fs, execService } = createScanMocks({ - detectedDirs: ["/home/test/.claude"], + detectedDirs: [], execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": Object.assign(new Error("spawn ENOENT"), { code: "ENOENT", }), @@ -826,10 +835,11 @@ describe("scanAgents", () => { }); it("detects agent via detectBinary when directory does not exist", async () => { + const lookupCmd = lookupCommandFor(); const { fs, execService } = createScanMocks({ detectedDirs: [], execResults: { - "which opencode": { + [`${lookupCmd} opencode`]: { exitCode: 0, stdout: "/usr/bin/opencode\n", stderr: "", @@ -841,7 +851,7 @@ describe("scanAgents", () => { expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); }); - it("detects opencode via directory fallback when binary not on PATH", async () => { + it("does not detect opencode from config directory when binary is missing", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "linux", @@ -857,8 +867,8 @@ describe("scanAgents", () => { }, }); const result = await scanAgents(agentDefinitions, fs, execService); - expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(true); - expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); + expect(result.notDetected.some((a) => a.id === "opencode")).toBe(true); + expect(result.needsSetup.some((a) => a.id === "opencode")).toBe(false); } finally { Object.defineProperty(process, "platform", { value: originalPlatform, @@ -876,18 +886,20 @@ describe("scanAgents", () => { }); it("handles mixed scenario correctly", async () => { + const lookupCmd = lookupCommandFor(); const { fs, execService } = createScanMocks({ - detectedDirs: [ - "/home/test/.claude", - "/home/test/.cursor", - "/home/test/.codeium/windsurf", - ], + detectedDirs: ["/home/test/.cursor", "/home/test/.codeium/windsurf"], configFiles: { "/home/test/.cursor/mcp.json": JSON.stringify({ mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, }), }, execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": { exitCode: 0, stdout: "githits-plugin\n", @@ -908,6 +920,23 @@ describe("scanAgents", () => { expect(result.notDetected.length).toBeGreaterThan(0); }); + it("does not detect CLI agents from stale dot-directories when binaries are missing", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [ + "/home/test/.claude", + "/home/test/.codex", + "/home/test/.gemini", + ], + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.notDetected.some((a) => a.id === "claude-code")).toBe(true); + expect(result.notDetected.some((a) => a.id === "codex-cli")).toBe(true); + expect(result.notDetected.some((a) => a.id === "gemini-cli")).toBe(true); + expect(result.needsSetup.some((a) => a.id === "claude-code")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "codex-cli")).toBe(false); + expect(result.needsSetup.some((a) => a.id === "gemini-cli")).toBe(false); + }); + /** * Generates the 5 comprehensive test cases for a given platform. * Platform-dependent paths (VS Code, Claude Desktop) vary; all others are home-relative dotdirs. @@ -920,14 +949,11 @@ describe("scanAgents", () => { }) { const { platform, appDataPrefix } = opts; - // Platform-independent detect dirs (home-relative dotdirs) + // Platform-independent detect dirs for path-detected agents const homeDirs = [ - "/home/test/.claude", "/home/test/.cursor", "/home/test/.codeium/windsurf", "/home/test/.cline", - "/home/test/.codex", - "/home/test/.gemini", "/home/test/.gemini/antigravity", ]; // Platform-dependent detect dirs @@ -973,7 +999,7 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: { GitHits: { serverUrl: "https://mcp.githits.com" } }, }), - "/home/test/.config/opencode/opencode.json": JSON.stringify({ + [`${opencodePath}/opencode.json`]: JSON.stringify({ mcp: { GitHits: { type: "local", @@ -987,18 +1013,33 @@ describe("scanAgents", () => { // Binary detection command varies by platform const whichCmd = platform === "win32" ? "where" : "which"; - // Exec results for all CLI agents reporting configured + binary detection + // Exec results for CLI-like agents reporting configured + binary detection const allCliConfigured: Record = { + [`${whichCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": { exitCode: 0, stdout: "githits-plugin\n", stderr: "", }, + [`${whichCmd} codex`]: { + exitCode: 0, + stdout: "/usr/bin/codex\n", + stderr: "", + }, "codex mcp list": { exitCode: 0, stdout: "githits npx -y githits@latest mcp start\n", stderr: "", }, + [`${whichCmd} gemini`]: { + exitCode: 0, + stdout: "/usr/bin/gemini\n", + stderr: "", + }, "gemini extensions list": { exitCode: 0, stdout: "githits-gemini-cli\n", @@ -1055,7 +1096,7 @@ describe("scanAgents", () => { "/home/test/.gemini/antigravity/mcp_config.json": JSON.stringify({ mcpServers: {}, }), - "/home/test/.config/opencode/opencode.json": JSON.stringify({ + [`${opencodePath}/opencode.json`]: JSON.stringify({ mcp: {}, }), }; @@ -1063,6 +1104,21 @@ describe("scanAgents", () => { detectedDirs: allDetectDirs, configFiles: unconfiguredFiles, execResults: { + [`${whichCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, + [`${whichCmd} codex`]: { + exitCode: 0, + stdout: "/usr/bin/codex\n", + stderr: "", + }, + [`${whichCmd} gemini`]: { + exitCode: 0, + stdout: "/usr/bin/gemini\n", + stderr: "", + }, [`${whichCmd} opencode`]: { exitCode: 0, stdout: "/usr/bin/opencode\n", @@ -1090,12 +1146,10 @@ describe("scanAgents", () => { // Configured: cursor, claude-desktop, claude-code "/home/test/.cursor", claudeDesktopPath, - "/home/test/.claude", - // Unconfigured: windsurf, vscode, codex-cli + // Unconfigured: windsurf, vscode "/home/test/.codeium/windsurf", vscodePath, - "/home/test/.codex", - // opencode detected via binary (below), not directory + // CLI tools are detected via binary checks below // Not detected: cline, gemini-cli, google-antigravity ], configFiles: { @@ -1109,11 +1163,21 @@ describe("scanAgents", () => { ), }, execResults: { + [`${whichCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, "claude plugin list": { exitCode: 0, stdout: "githits-plugin\n", stderr: "", }, + [`${whichCmd} codex`]: { + exitCode: 0, + stdout: "/usr/bin/codex\n", + stderr: "", + }, "codex mcp list": { exitCode: 0, stdout: "", stderr: "" }, [`${whichCmd} opencode`]: { exitCode: 0, @@ -1143,12 +1207,23 @@ describe("scanAgents", () => { code: "ENOENT", }); const { fs, execService } = createScanMocks({ - detectedDirs: [ - "/home/test/.claude", - "/home/test/.codex", - "/home/test/.gemini", - ], + detectedDirs: [], execResults: { + [`${whichCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, + [`${whichCmd} codex`]: { + exitCode: 0, + stdout: "/usr/bin/codex\n", + stderr: "", + }, + [`${whichCmd} gemini`]: { + exitCode: 0, + stdout: "/usr/bin/gemini\n", + stderr: "", + }, "claude plugin list": enoent, "codex mcp list": enoent, "gemini extensions list": enoent, diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index 6a8509b4..faf1695d 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -44,9 +44,12 @@ export interface ConfigFileSetup { export type SetupConfig = CliSetup | ConfigFileSetup; +/** How an agent is considered present on the machine. */ +type DetectionMethod = "binary" | "path"; + /** * Represents a coding agent that can be configured with GitHits MCP server. - * Each definition knows how to detect whether the agent is installed + * Each definition knows how to detect whether the agent is available * and how to configure it. */ export interface AgentDefinition { @@ -54,9 +57,11 @@ export interface AgentDefinition { name: string; /** Unique identifier (e.g., "claude-code") */ id: string; - /** Directories to check for detection. Uses FileSystemService for testability. */ - detectPaths: (fs: FileSystemService) => string[]; - /** Optional binary detection — checked before detectPaths. */ + /** Detection contract for the agent. */ + detectionMethod: DetectionMethod; + /** Directories to check for path-based detection. */ + detectPaths?: (fs: FileSystemService) => string[]; + /** Executable detection for binary-based agents. */ detectBinary?: (exec: ExecService) => Promise; /** How this agent is configured */ setupMethod: "cli" | "config-file"; @@ -85,12 +90,30 @@ function getAppDataPath(fs: FileSystemService, appName: string): string { } } -/** Claude Code: detected by ~/.claude/ directory, configured via plugin install */ +/** + * Detect whether an executable is available on PATH. + * This is the install signal for CLI-configured agents. + */ +async function isExecutableAvailable( + exec: ExecService, + executable: string, +): Promise { + try { + const lookupCommand = process.platform === "win32" ? "where" : "which"; + const result = await exec.exec(lookupCommand, [executable]); + return result.exitCode === 0; + } catch { + return false; + } +} + +/** Claude Code: detected by claude executable, configured via plugin install */ const claudeCode: AgentDefinition = { name: "Claude Code", id: "claude-code", + detectionMethod: "binary", setupMethod: "cli", - detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".claude")], + detectBinary: async (exec) => isExecutableAvailable(exec, "claude"), getSetupConfig: () => ({ method: "cli", commands: [ @@ -120,6 +143,7 @@ const claudeCode: AgentDefinition = { const cursor: AgentDefinition = { name: "Cursor", id: "cursor", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".cursor")], getSetupConfig: (fs) => ({ @@ -138,6 +162,7 @@ const cursor: AgentDefinition = { const windsurf: AgentDefinition = { name: "Windsurf", id: "windsurf", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codeium", "windsurf")], getSetupConfig: (fs) => ({ @@ -158,6 +183,7 @@ const windsurf: AgentDefinition = { const claudeDesktop: AgentDefinition = { name: "Claude Desktop", id: "claude-desktop", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => { const appData = getAppDataPath(fs, "Claude"); @@ -188,12 +214,13 @@ const claudeDesktop: AgentDefinition = { }, }; -/** Codex CLI: detected by ~/.codex/ directory, configured via npm/stdio */ +/** Codex CLI: detected by codex executable, configured via npm/stdio */ const codexCli: AgentDefinition = { name: "Codex CLI", id: "codex-cli", + detectionMethod: "binary", setupMethod: "cli", - detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codex")], + detectBinary: async (exec) => isExecutableAvailable(exec, "codex"), getSetupConfig: () => ({ method: "cli", commands: [ @@ -224,6 +251,7 @@ const codexCli: AgentDefinition = { const vscode: AgentDefinition = { name: "VS Code / Copilot", id: "vscode", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => { const appData = getAppDataPath(fs, "Code"); @@ -245,6 +273,7 @@ const vscode: AgentDefinition = { const cline: AgentDefinition = { name: "Cline", id: "cline", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".cline")], getSetupConfig: (fs) => ({ @@ -262,12 +291,13 @@ const cline: AgentDefinition = { }), }; -/** Gemini CLI: detected by ~/.gemini/ directory, configured via extensions install */ +/** Gemini CLI: detected by gemini executable, configured via extensions install */ const geminiCli: AgentDefinition = { name: "Gemini CLI", id: "gemini-cli", + detectionMethod: "binary", setupMethod: "cli", - detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".gemini")], + detectBinary: async (exec) => isExecutableAvailable(exec, "gemini"), getSetupConfig: () => ({ method: "cli", commands: [ @@ -292,6 +322,7 @@ const geminiCli: AgentDefinition = { const googleAntigravity: AgentDefinition = { name: "Google Antigravity", id: "google-antigravity", + detectionMethod: "path", setupMethod: "config-file", detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".gemini", "antigravity")], getSetupConfig: (fs) => ({ @@ -308,40 +339,24 @@ const googleAntigravity: AgentDefinition = { }), }; -/** OpenCode: detected by opencode binary on PATH or config directory */ +/** OpenCode: detected by opencode executable, configured via config file */ const openCode: AgentDefinition = { name: "OpenCode", id: "opencode", + detectionMethod: "binary", setupMethod: "config-file", - detectPaths: (fs) => { - const home = fs.getHomeDir(); - if (process.platform === "win32") { - return [ - fs.joinPath( - process.env.APPDATA ?? fs.joinPath(home, "AppData", "Roaming"), - "opencode", - ), - ]; - } - return [fs.joinPath(home, ".config", "opencode")]; - }, - detectBinary: async (exec) => { - try { - const cmd = process.platform === "win32" ? "where" : "which"; - const result = await exec.exec(cmd, ["opencode"]); - return result.exitCode === 0; - } catch { - return false; - } - }, + detectBinary: async (exec) => isExecutableAvailable(exec, "opencode"), getSetupConfig: (fs) => ({ method: "config-file", - configPath: fs.joinPath( - fs.getHomeDir(), - ".config", - "opencode", - "opencode.json", - ), + configPath: + process.platform === "win32" + ? fs.joinPath( + process.env.APPDATA ?? + fs.joinPath(fs.getHomeDir(), "AppData", "Roaming"), + "opencode", + "opencode.json", + ) + : fs.joinPath(fs.getHomeDir(), ".config", "opencode", "opencode.json"), serversKey: "mcp", serverName: "GitHits", serverConfig: { @@ -370,8 +385,8 @@ export const agentDefinitions: AgentDefinition[] = [ ]; /** - * Detect which agents are installed by checking if their detection paths exist. - * Returns the IDs of agents whose directories were found. + * Detect which path-based agents are present by checking if their detection + * directories exist. Binary-based agents are intentionally ignored here. * @deprecated Use scanAgents() instead, which also checks configuration status. */ export async function detectAgents( @@ -380,6 +395,9 @@ export async function detectAgents( ): Promise { const detected: string[] = []; for (const agent of definitions) { + if (agent.detectionMethod !== "path" || !agent.detectPaths) { + continue; + } const paths = agent.detectPaths(fs); for (const path of paths) { if (await fs.isDirectory(path)) { @@ -402,7 +420,7 @@ export interface ScanResult { } /** - * Scan all agents: detect installation and check configuration status. + * Scan all agents: detect availability and check configuration status. * Config-file agents get a pre-check via isAlreadyConfigured(). * CLI agents with a checkCommand get a pre-check via isCliAlreadyConfigured(). * CLI agents without a checkCommand are treated as needsSetup. @@ -419,18 +437,16 @@ export async function scanAgents( }; for (const agent of definitions) { - // Check if installed — try binary detection first, then directory detection + // Check if available using the agent's declared detection contract let detected = false; - if (agent.detectBinary) { + if (agent.detectionMethod === "binary" && agent.detectBinary) { try { detected = await agent.detectBinary(execService); } catch { - // Fall through to directory detection + detected = false; } - } - - if (!detected) { + } else if (agent.detectionMethod === "path" && agent.detectPaths) { const paths = agent.detectPaths(fs); for (const path of paths) { if (await fs.isDirectory(path)) { diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index 04ff7216..08af4242 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -83,6 +83,10 @@ function getLogOutput(): string[] { return (logSpy.mock.calls as unknown[][]).map((c) => String(c[0] ?? "")); } +function lookupCommandFor(platform: string = process.platform): string { + return platform === "win32" ? "where" : "which"; +} + describe("initAction", () => { it("scans agents and sets up unconfigured ones", async () => { // Cursor detected but not configured @@ -177,18 +181,23 @@ describe("initAction", () => { }); it("shows all agents as already configured when all check commands match", async () => { + const lookupCmd = lookupCommandFor(); // Detect Claude Code (CLI) and Cursor (config-file), both configured - const fs = createFsWithDetection( - ["/home/test/.claude", "/home/test/.cursor"], - { - "/home/test/.cursor/mcp.json": JSON.stringify({ - mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, - }), - }, - ); + const fs = createFsWithDetection(["/home/test/.cursor"], { + "/home/test/.cursor/mcp.json": JSON.stringify({ + mcpServers: { GitHits: { url: "https://mcp.githits.com" } }, + }), + }); const execService = createMockExecService({ exec: mock((cmd: string, args: string[]) => { const key = `${cmd} ${args.join(" ")}`; + if (key === `${lookupCmd} claude`) { + return Promise.resolve({ + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }); + } if (key === "claude plugin list") { return Promise.resolve({ exitCode: 0, @@ -218,13 +227,21 @@ describe("initAction", () => { }); it("sets up CLI agents that are not yet configured", async () => { - const fs = createFsWithDetection(["/home/test/.claude"]); + const lookupCmd = lookupCommandFor(); + const fs = createFsWithDetection([]); const promptService = createMockPromptService({ confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); const execService = createMockExecService({ exec: mock((cmd: string, args: string[]) => { const key = `${cmd} ${args.join(" ")}`; + if (key === `${lookupCmd} claude`) { + return Promise.resolve({ + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }); + } if (key === "claude plugin list") { // Check command: no match = needs setup return Promise.resolve({ @@ -252,8 +269,8 @@ describe("initAction", () => { }, ); - // 1 binary detection (which opencode) + 1 check + 2 setup commands = 4 exec calls - expect(execService.exec).toHaveBeenCalledTimes(4); + // 4 binary detections + 1 check + 2 setup commands = 7 exec calls + expect(execService.exec).toHaveBeenCalledTimes(7); expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); }); @@ -334,8 +351,8 @@ describe("initAction", () => { }, ); - // Only exec call should be the opencode binary detection (which opencode) - expect(execService.exec).toHaveBeenCalledTimes(1); + // One PATH lookup is attempted for each binary-detected agent + expect(execService.exec).toHaveBeenCalledTimes(4); const logCalls = getLogOutput(); expect( logCalls.some((msg) => msg.includes("No coding agents detected")), @@ -364,17 +381,20 @@ describe("initAction", () => { }); it("continues to next agent when one fails", async () => { + const lookupCmd = lookupCommandFor(); // Claude (CLI, will fail) + cursor (config-file, should still work) - const fs = createFsWithDetection([ - "/home/test/.claude", - "/home/test/.cursor", - ]); - let callCount = 0; + const fs = createFsWithDetection(["/home/test/.cursor"]); const execService = createMockExecService({ - exec: mock(() => { - callCount++; - // First call: check command for claude (no match = needs setup) - if (callCount === 1) { + exec: mock((cmd: string, args: string[]) => { + const key = `${cmd} ${args.join(" ")}`; + if (key === `${lookupCmd} claude`) { + return Promise.resolve({ + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }); + } + if (key === "claude plugin list") { return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } // Setup commands: fail @@ -400,6 +420,35 @@ describe("initAction", () => { expect(fs.atomicWriteFile).toHaveBeenCalled(); }); + it("does not attempt Gemini setup when only .gemini directory exists", async () => { + const fs = createFsWithDetection(["/home/test/.gemini"]); + const promptService = createMockPromptService(); + const execService = createMockExecService(); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some( + (msg) => msg.includes("Gemini CLI") && msg.includes("not detected"), + ), + ).toBe(true); + expect( + logCalls.some( + (msg) => msg.includes("Setting up") && msg.includes("Gemini CLI"), + ), + ).toBe(false); + expect(promptService.confirm3).not.toHaveBeenCalled(); + }); + it("handles Ctrl+C on confirm3 prompt gracefully", async () => { const fs = createFsWithDetection(["/home/test/.cursor"]); const promptService = createMockPromptService({ diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index ac2fb7a4..40ff89b6 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -51,7 +51,7 @@ interface AgentOutcome { /** * Core init logic, separated from CLI registration for testability. - * Scans for installed agents, prompts for selection, configures each sequentially. + * Scans for available agents, prompts for selection, configures each sequentially. */ export async function initAction( options: InitOptions, @@ -109,8 +109,8 @@ export async function initAction( } } - // Scan for installed agents and check configuration status - console.log(" Scanning for installed agents...\n"); + // Scan for available agents and check configuration status + console.log(" Scanning for available agents...\n"); const scan = await scanAgents( agentDefinitions, fileSystemService, @@ -241,7 +241,7 @@ export async function initAction( const INIT_DESCRIPTION = `Set up GitHits MCP server for your coding agents. -Authenticates with your GitHits account, then scans for installed agents +Authenticates with your GitHits account, then scans for available agents (Claude Code, Cursor, Windsurf, VS Code, Cline, Claude Desktop, Codex CLI, Gemini CLI, Google Antigravity), checks which are already configured, and sets up unconfigured ones with your confirmation.