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, + }; +}