diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 945f47ffe2..7f4f68190d 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -37,6 +37,7 @@ gittensory-mcp config --json 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 @@ -128,6 +129,8 @@ Use `--profile ` on `login`, `logout`, `whoami`, `config`, `status`, and ` `gittensory-mcp config` prints the resolved effective configuration and the source that supplied each value (`environment`, `profile`, `config`, or `default`): the active API URL and its source, active profile and profile count, whether a config file is present and which environment variable steers its location, the cache-dir source, whether a token is configured and where it came from, and whether `GITTENSORY_UPLOAD_SOURCE` has enabled the unsupported source-upload setting. It never prints token values or local absolute paths. Add `--json` for machine-readable output. +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 31ba73fa59..b905f0fd0c 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -316,8 +316,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({ @@ -1747,7 +1747,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 [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] gittensory-mcp decision-pack --login [--json] @@ -2173,6 +2173,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-doctor.test.ts b/test/unit/mcp-cli-doctor.test.ts index b744bffb6d..76efa78076 100644 --- a/test/unit/mcp-cli-doctor.test.ts +++ b/test/unit/mcp-cli-doctor.test.ts @@ -1,8 +1,9 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { closeFixtureServer, createPacketRepo, git, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; +import { bin, closeFixtureServer, createPacketRepo, git, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; describe("gittensory-mcp CLI — doctor", () => { let tempDir: string | null = null; @@ -409,4 +410,61 @@ describe("gittensory-mcp CLI — doctor", () => { // Sanity: upgrade guidance still surfaces in human-readable output. expect(statusOutput).toContain("npm install -g @jsonbored/gittensory-mcp@latest"); }); + + 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/); + }); });