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 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/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..b6f70f6e 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -8,10 +8,27 @@ The CLI exposes three commands (`search`, `languages`, `feedback`) that mirror t | Command | Required Args | Options | Description | |---|---|---|---| +| `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 | +### `githits init` + +``` +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 +``` + +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. + +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. + ### `githits search` ``` @@ -75,6 +92,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 +122,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 f07ef1cd..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", @@ -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..db42c53d --- /dev/null +++ b/src/commands/init/agent-definitions.test.ts @@ -0,0 +1,1300 @@ +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 { + agentDefinitions, + buildCheckboxChoices, + detectAgents, + scanAgents, +} from "./agent-definitions.js"; + +describe("agentDefinitions", () => { + it("defines 10 agents", () => { + expect(agentDefinitions).toHaveLength(10); + }); + + 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("detection configuration", () => { + it("claude-code uses binary detection only", () => { + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); + }); + + 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 binary detection only", () => { + const agent = agentDefinitions.find((a) => a.id === "codex-cli")!; + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); + }); + + 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 binary detection only", () => { + const agent = agentDefinitions.find((a) => a.id === "gemini-cli")!; + expect(agent.detectBinary).toBeDefined(); + expect(agent.detectPaths).toBeUndefined(); + }); + + it("google-antigravity uses ~/.gemini/antigravity/", () => { + const fs = createMockFileSystemService({ + getHomeDir: mock(() => "/home/test"), + joinPath: mock((...segments: string[]) => segments.join("/")), + }); + const agent = agentDefinitions.find((a) => a.id === "google-antigravity")!; + const paths = agent.detectPaths?.(fs); + expect(paths).toEqual(["/home/test/.gemini/antigravity"]); + }); + + 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", () => { + 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).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"); + 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).toBeDefined(); + if (!paths) throw new Error("expected claude-desktop paths"); + 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).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"); + } 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 () => { + 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("path-detected 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); + if (!paths) { + continue; + } + 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 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.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 with native url", () => { + 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("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 with mcp-remote", () => { + 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"); + expect(config.serverConfig).toHaveProperty("command", "npx"); + const args = config.serverConfig.args as string[]; + expect(args).toContain("mcp-remote"); + } + }); + + 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 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.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("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 === "cline")!; + const config = agent.getSetupConfig(fs); + expect(config.method).toBe("config-file"); + if (config.method === "config-file") { + expect(config.configPath).toBe( + "/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("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 === "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("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("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"), + 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") { + if (agent.id === "claude-desktop") { + expect(config.serverConfig).toHaveProperty("command", "npx"); + const args = config.serverConfig.args as string[]; + expect(args).toContain("mcp-remote"); + } else { + // 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"); + } + } + } + } + }); + + 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") { + expect(cursorConfig.serverConfig).toHaveProperty( + "url", + "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("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 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); + // 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[]; + 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: 1, 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 lookupCmd = lookupCommandFor(); + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, + "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 lookupCmd = lookupCommandFor(); + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, + "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 lookupCmd = lookupCommandFor(); + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + [`${lookupCmd} claude`]: { + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + }, + "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("detects agent via detectBinary when directory does not exist", async () => { + const lookupCmd = lookupCommandFor(); + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + [`${lookupCmd} 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("does not detect opencode from config directory when binary is missing", 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.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, + configurable: 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 lookupCmd = lookupCommandFor(); + const { fs, execService } = createScanMocks({ + 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", + 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); + }); + + 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. + */ + function defineComprehensiveTests(opts: { + platform: string; + appDataPrefix: string; + envSetup?: () => void; + envTeardown?: () => void; + }) { + const { platform, appDataPrefix } = opts; + + // Platform-independent detect dirs for path-detected agents + const homeDirs = [ + "/home/test/.cursor", + "/home/test/.codeium/windsurf", + "/home/test/.cline", + "/home/test/.gemini/antigravity", + ]; + // Platform-dependent detect dirs + const vscodePath = `${appDataPrefix}/Code`; + const claudeDesktopPath = `${appDataPrefix}/Claude`; + 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 = { + "/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" } }, + }), + [`${opencodePath}/opencode.json`]: JSON.stringify({ + mcp: { + GitHits: { + type: "local", + command: ["npx", "-y", "githits@latest", "mcp", "start"], + enabled: true, + }, + }, + }), + }; + + // Binary detection command varies by platform + const whichCmd = platform === "win32" ? "where" : "which"; + + // 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", + stderr: "", + }, + [`${whichCmd} opencode`]: { + exitCode: 0, + stdout: "/usr/bin/opencode\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(10); + 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: {}, + }), + [`${opencodePath}/opencode.json`]: JSON.stringify({ + mcp: {}, + }), + }; + const { fs, execService } = createScanMocks({ + 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", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.alreadyConfigured).toHaveLength(0); + expect(result.needsSetup).toHaveLength(10); + 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(10); + }); + + it("mixed: 3 configured, 4 unconfigured, 3 not detected", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [ + // Configured: cursor, claude-desktop, claude-code + "/home/test/.cursor", + claudeDesktopPath, + // Unconfigured: windsurf, vscode + "/home/test/.codeium/windsurf", + vscodePath, + // CLI tools are detected via binary checks below + // 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: { + [`${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, + stdout: "/usr/bin/opencode\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + 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"].sort(), + ); + expect(result.needsSetup.map((a) => a.id).sort()).toEqual( + ["codex-cli", "opencode", "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: [], + 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, + }, + }); + 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, [ + "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..faf1695d --- /dev/null +++ b/src/commands/init/agent-definitions.ts @@ -0,0 +1,513 @@ +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"; + +/** 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. + */ +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; + +/** 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 available + * 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; + /** 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"; + /** Returns the setup config for this agent. Uses FileSystemService for path resolution. */ + getSetupConfig: (fs: FileSystemService) => SetupConfig; +} + +/** + * 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); + } +} + +/** + * 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", + detectBinary: async (exec) => isExecutableAvailable(exec, "claude"), + getSetupConfig: () => ({ + method: "cli", + 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 with native OAuth */ +const cursor: AgentDefinition = { + name: "Cursor", + id: "cursor", + detectionMethod: "path", + 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: { url: getMcpUrl() }, + }), +}; + +/** + * Windsurf: detected by ~/.codeium/windsurf/ directory. + * Uses native serverUrl (no mcp-remote needed). + */ +const windsurf: AgentDefinition = { + name: "Windsurf", + id: "windsurf", + detectionMethod: "path", + 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: { serverUrl: getMcpUrl() }, + }), +}; + +/** Claude Desktop: detected by platform-specific Claude directory, uses mcp-remote */ +const claudeDesktop: AgentDefinition = { + name: "Claude Desktop", + id: "claude-desktop", + detectionMethod: "path", + 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) => { + const appData = getAppDataPath(fs, "Claude"); + return { + method: "config-file", + configPath: fs.joinPath(appData, "claude_desktop_config.json"), + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { + command: "npx", + args: ["-y", "mcp-remote", getMcpUrl()], + }, + }; + }, +}; + +/** Codex CLI: detected by codex executable, configured via npm/stdio */ +const codexCli: AgentDefinition = { + name: "Codex CLI", + id: "codex-cli", + detectionMethod: "binary", + setupMethod: "cli", + detectBinary: async (exec) => isExecutableAvailable(exec, "codex"), + getSetupConfig: () => ({ + method: "cli", + 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", + detectionMethod: "path", + 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", + detectionMethod: "path", + 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 executable, configured via extensions install */ +const geminiCli: AgentDefinition = { + name: "Gemini CLI", + id: "gemini-cli", + detectionMethod: "binary", + setupMethod: "cli", + detectBinary: async (exec) => isExecutableAvailable(exec, "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", + detectionMethod: "path", + 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() }, + }), +}; + +/** OpenCode: detected by opencode executable, configured via config file */ +const openCode: AgentDefinition = { + name: "OpenCode", + id: "opencode", + detectionMethod: "binary", + setupMethod: "config-file", + detectBinary: async (exec) => isExecutableAvailable(exec, "opencode"), + getSetupConfig: (fs) => ({ + method: "config-file", + 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: { + 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. + */ +export const agentDefinitions: AgentDefinition[] = [ + claudeCode, + cursor, + windsurf, + vscode, + cline, + claudeDesktop, + codexCli, + geminiCli, + googleAntigravity, + openCode, +]; + +/** + * 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( + definitions: AgentDefinition[], + fs: FileSystemService, +): 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)) { + detected.push(agent.id); + break; + } + } + } + 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 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. + */ +export async function scanAgents( + definitions: AgentDefinition[], + fs: FileSystemService, + execService: ExecService, +): Promise { + const result: ScanResult = { + needsSetup: [], + alreadyConfigured: [], + notDetected: [], + }; + + for (const agent of definitions) { + // Check if available using the agent's declared detection contract + let detected = false; + + if (agent.detectionMethod === "binary" && agent.detectBinary) { + try { + detected = await agent.detectBinary(execService); + } catch { + detected = false; + } + } else if (agent.detectionMethod === "path" && agent.detectPaths) { + const paths = agent.detectPaths(fs); + 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[], + 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..d2904b29 --- /dev/null +++ b/src/commands/init/index.ts @@ -0,0 +1,24 @@ +export { + type AgentDefinition, + agentDefinitions, + buildCheckboxChoices, + type CliCommand, + 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..08af4242 --- /dev/null +++ b/src/commands/init/init.test.ts @@ -0,0 +1,711 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import { ExitPromptError } from "@inquirer/core"; +import type { ConfirmChoice } 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 { initAction } from "./init.js"; + +/** Suppress console.log during tests */ +let logSpy: ReturnType; + +beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); + +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 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("/")), + isDirectory: mock(async (path: string) => detectedDirs.includes(path)), + getDirname: mock( + (path: string) => path.split("/").slice(0, -1).join("/") || "/", + ), + ensureDir: mock(() => Promise.resolve()), + readFile: mock(async (path: string) => { + if (path in configFiles) { + return configFiles[path]!; + } + throw 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] ?? "")); +} + +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 + 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(), + }, + ); + + // 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 () => { + const lookupCmd = lookupCommandFor(); + // Detect Claude Code (CLI) and Cursor (config-file), both configured + 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, + stdout: "githits-plugin\n", + stderr: "", + }); + } + // Default: command not found (e.g., which opencode) + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); + }), + }); + const promptService = createMockPromptService(); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + expect(promptService.confirm3).not.toHaveBeenCalled(); + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Nothing to do"))).toBe(true); + }); + + it("sets up CLI agents that are not yet configured", async () => { + 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({ + exitCode: 0, + stdout: "other-plugin\n", + 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: "" }); + }), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // 4 binary detections + 1 check + 2 setup commands = 7 exec calls + expect(execService.exec).toHaveBeenCalledTimes(7); + 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/.cursor", + "/home/test/.codeium/windsurf", + ]); + const confirm3 = mock(() => Promise.resolve("always" as ConfirmChoice)); + const promptService = createMockPromptService({ confirm3 }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // 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/.cursor"]); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("no" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + expect(fs.atomicWriteFile).not.toHaveBeenCalled(); + }); + + it("--yes flag skips all prompts and configures all unconfigured agents", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService(); + const execService = createMockExecService(); + + await initAction( + { yes: true }, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + expect(promptService.checkbox).not.toHaveBeenCalled(); + expect(promptService.confirm3).not.toHaveBeenCalled(); + expect(fs.atomicWriteFile).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, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // 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")), + ).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 () => { + const lookupCmd = lookupCommandFor(); + // Claude (CLI, will fail) + cursor (config-file, should still work) + const fs = createFsWithDetection(["/home/test/.cursor"]); + 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, stdout: "", stderr: "" }); + } + // Setup commands: fail + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "error" }); + }), + }); + const promptService = createMockPromptService({ + confirm3: mock(() => Promise.resolve("always" as ConfirmChoice)), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService, + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + // Both should be attempted + expect(execService.exec).toHaveBeenCalled(); + 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({ + confirm3: mock(() => + Promise.reject(new ExitPromptError("User force closed")), + ), + }); + + await initAction( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("cancelled"))).toBe(true); + }); + + it("rethrows non-ExitPromptError from confirm3", async () => { + const fs = createFsWithDetection(["/home/test/.cursor"]); + const promptService = createMockPromptService({ + 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( + {}, + { + fileSystemService: fs, + promptService, + execService: createMockExecService(), + createLoginDeps: createAlreadyAuthLoginDeps(), + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Setup skipped"))).toBe(true); + }); + + 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(); + }); + + 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, + }, + ); + + const logCalls = getLogOutput(); + expect( + logCalls.some((msg) => msg.includes("Logged in successfully")), + ).toBe(true); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + 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, + }, + ); + + const logCalls = getLogOutput(); + expect(logCalls.some((msg) => msg.includes("Login failed"))).toBe(true); + expect(fs.atomicWriteFile).toHaveBeenCalled(); + }); + + 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 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 new file mode 100644 index 00000000..40ff89b6 --- /dev/null +++ b/src/commands/init/init.ts @@ -0,0 +1,275 @@ +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"; +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 type { LoginDependencies, LoginFlowResult } from "../login.js"; +import { loginFlow } from "../login.js"; +import { agentDefinitions, scanAgents } 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; + /** Skip the login step */ + skipLogin?: boolean; +} + +/** 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 */ +interface AgentOutcome { + name: string; + status: "success" | "already_configured" | "failed" | "skipped"; +} + +/** + * Core init logic, separated from CLI registration for testability. + * Scans for available agents, prompts for selection, configures each sequentially. + */ +export async function initAction( + options: InitOptions, + deps: InitDependencies, +): Promise { + const useColors = shouldUseColors(); + const { fileSystemService, promptService, execService, createLoginDeps } = + deps; + + // Header + console.log( + `\n ${colorize("GitHits", "bold", useColors)} — Set up MCP server for your coding agents\n`, + ); + + // 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 (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( + ` ${warning(`Login failed: ${loginResult.message}`, useColors)}\n`, + ); + 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; + } + } + console.log(" Continuing without authentication...\n"); + } + } + + // Scan for available agents and check configuration status + console.log(" Scanning for available 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; + } + + // 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 agent of toSetup) { + 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 + + scan.alreadyConfigured.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(); +} + +const INIT_DESCRIPTION = `Set up GitHits MCP server for your coding 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. + +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 lightweight dependencies for tool setup, plus auth deps for login. + */ +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") + .option("--skip-login", "Skip authentication step") + .action(async (options: InitOptions) => { + const fileSystemService = new FileSystemServiceImpl(); + const promptService = new PromptServiceImpl(); + const execService = new ExecServiceImpl(); + await initAction(options, { + fileSystemService, + promptService, + execService, + createLoginDeps: () => createContainer(), + }); + }); +} diff --git a/src/commands/init/setup-handlers.test.ts b/src/commands/init/setup-handlers.test.ts new file mode 100644 index 00000000..87cab998 --- /dev/null +++ b/src/commands/init/setup-handlers.test.ts @@ -0,0 +1,750 @@ +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 { CliCheckCommand, MergeResult } from "./setup-handlers.js"; +import { + executeCliSetup, + executeConfigFileSetup, + formatSetupPreview, + isAlreadyConfigured, + isCliAlreadyConfigured, + 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; +} + +// -- 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", () => { + 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); + }); + + 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 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", + 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).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", () => { + const setup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.cursor/mcp.json", + serversKey: "mcpServers", + serverName: "GitHits", + 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('"url"'); + }); +}); + +// -- executeCliSetup -- + +describe("executeCliSetup", () => { + const singleStepSetup: CliSetup = { + method: "cli", + 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 for single-step", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ exitCode: 0, stdout: "Added.\n", stderr: "" }), + ), + }); + const result = await executeCliSetup(singleStepSetup, execService); + expect(result.status).toBe("success"); + expect(execService.exec).toHaveBeenCalledWith("codex", [ + "mcp", + "add", + "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(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "Unknown command\n", + }), + ), + }); + const result = await executeCliSetup(singleStepSetup, 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(singleStepSetup, execService); + expect(result.status).toBe("failed"); + 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(singleStepSetup, execService); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Unexpected error"); + }); + + it("detects already-exists on non-zero exit", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 1, + stdout: "", + stderr: "MCP server GitHits already exists in user config\n", + }), + ), + }); + const result = await executeCliSetup(singleStepSetup, execService); + expect(result.status).toBe("already_configured"); + }); + + it("detects already-exists on zero exit (codex pattern)", async () => { + const execService = createMockExecService({ + exec: mock(() => + Promise.resolve({ + exitCode: 0, + stdout: "Server GitHits already added\n", + stderr: "", + }), + ), + }); + 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); + }); +}); + +// -- executeConfigFileSetup -- + +describe("executeConfigFileSetup", () => { + const configSetup: ConfigFileSetup = { + method: "config-file", + configPath: "/home/test/.cursor/mcp.json", + serversKey: "mcpServers", + serverName: "GitHits", + serverConfig: { url: "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..9069bd0b --- /dev/null +++ b/src/commands/init/setup-handlers.ts @@ -0,0 +1,357 @@ +import type { ExecService } from "../../services/exec-service.js"; +import type { FileSystemService } from "../../services/filesystem-service.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 = + | { 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 config.commands + .map((cmd) => `Will run: ${cmd.command} ${cmd.args.join(" ")}`) + .join("\n"); + } + const snippet = JSON.stringify( + { [config.serverName]: config.serverConfig }, + null, + 2, + ); + 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, + /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 single CLI command step. + * Returns a result object — does not throw on failure. + */ +async function executeCliCommand( + cmd: CliCommand, + execService: ExecService, +): Promise { + try { + 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 ${cmd.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: `"${cmd.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 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. + */ +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/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."); } 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 new file mode 100644 index 00000000..2a9730da --- /dev/null +++ b/src/services/exec-service.ts @@ -0,0 +1,54 @@ +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. + * + * 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 { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + ...(process.platform === "win32" && { shell: true }), + }); + + 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..55075ee8 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -12,9 +12,11 @@ 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 { ConfirmChoice, PromptService } from "./prompt-service.js"; import type { TokenProvider } from "./token-manager.js"; /** @@ -136,6 +138,7 @@ export function createMockFileSystemService( ), readdir: mock(() => Promise.resolve([])), isDirectory: mock(() => Promise.resolve(false)), + atomicWriteFile: mock(() => Promise.resolve()), ...impl, }; } @@ -223,3 +226,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: 1, stdout: "", stderr: "" } as ExecResult), + ), + ...impl, + }; +}