diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 6a9cc284..ed48a82b 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -35,6 +35,7 @@ gittensory-mcp whoami gittensory-mcp status gittensory-mcp changelog gittensory-mcp doctor +gittensory-mcp doctor --exit-code gittensory-mcp profile list gittensory-mcp profile create work gittensory-mcp profile switch work @@ -123,6 +124,8 @@ gittensory-mcp logout --profile work Use `--profile ` on `login`, `logout`, `whoami`, `status`, and `doctor`, or set `GITTENSORY_PROFILE`. `logout` only clears the selected local profile unless `--all` is passed. Profile output redacts session tokens and local config paths. +By default `gittensory-mcp doctor` always exits 0. Pass `--exit-code` to make it exit non-zero when a diagnostic check fails (`status: "needs_attention"`), so it can gate a CI step or pre-commit hook. Warnings still exit 0. + ## Base-Agent Mode The agent commands are copilot-only. They rank, explain, preflight, and draft public-safe packets, but they do not edit code, open PRs, post comments, close, merge, or label from the local wrapper. diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index ef027aff..51c6aa3e 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -185,8 +185,8 @@ const agentRunIdShape = { }; if (cliArgs[0] && cliArgs[0] !== "--stdio") { - await runCli(cliArgs); - process.exit(0); + const exitCode = await runCli(cliArgs); + process.exit(typeof exitCode === "number" ? exitCode : 0); } const server = new McpServer({ @@ -1394,7 +1394,7 @@ function printHelp() { gittensory-mcp status [--profile name] [--json] gittensory-mcp profile list|create|switch|remove [name] [--json] gittensory-mcp changelog [--json] - gittensory-mcp doctor [--profile name] [--cwd path] [--json] + gittensory-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] gittensory-mcp cache status|clear [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp [--json] gittensory-mcp decision-pack --login [--json] @@ -1820,6 +1820,9 @@ async function doctor(options) { } } } + // Opt-in: let `doctor` gate CI/pre-commit by exiting non-zero when a check fails. The default + // stays exit 0 so existing scripts that ignore the exit code keep working. + return options.exitCode && payload.status === "needs_attention" ? 1 : 0; } function doctorStatus(checks) { diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index c214fe86..ba47adbb 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1064,6 +1064,63 @@ describe("gittensory-mcp CLI", () => { expect(() => run(["completion"])).toThrow(/Usage: gittensory-mcp completion /); expect(() => run(["completion", "powershell"])).toThrow(/Unsupported shell: powershell/); }); + + it("keeps doctor exit code 0 by default even when a check fails", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + // No token configured -> the auth check fails -> status "needs_attention". + const payload = JSON.parse( + await runAsync(["doctor", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { status: string; checks: Array<{ name: string; status: string }> }; + expect(payload.status).toBe("needs_attention"); + expect(payload.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "auth", status: "fail" })])); + }); + + it("exits non-zero from doctor --exit-code when a check fails", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + let exitCode = 0; + let stdout = ""; + try { + stdout = execFileSync("node", [bin, "doctor", "--exit-code", "--json"], { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_API_TIMEOUT_MS: "1000", + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const execError = error as { status?: number | null; stdout?: string }; + exitCode = execError.status ?? 0; + stdout = execError.stdout ?? ""; + } + expect(exitCode).toBe(1); + // The diagnostic report is still printed; only the process exit code changes. + expect((JSON.parse(stdout) as { status: string }).status).toBe("needs_attention"); + }); + + it("keeps doctor --exit-code at 0 when checks pass", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + // runAsync resolves only on a zero exit code, so reaching the assertion proves exit 0. + const payload = JSON.parse( + await runAsync(["doctor", "--exit-code", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { status: string }; + expect(payload.status).toMatch(/ok|warnings/); + }); }); function run(args: string[], env: Record = {}) {