diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index 8a1a46c48a..f74e62fe8a 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -21,4 +21,15 @@ export function checkDockerPresent(options?: { resolveDockerPath?: () => string | null; }): DoctorCheck; +export function checkClaudeCliPresent(options?: { + env?: Record; + resolveClaudePath?: () => string | null; +}): DoctorCheck; + +export function checkCodexCliPresent(options?: { + env?: Record; + resolveCodexPath?: () => string | null; + resolveCodexAuthPath?: () => string; +}): DoctorCheck; + export function runInit(args?: string[], env?: Record): number; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index 62ff273f53..6cd4c7c228 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -95,6 +95,55 @@ export function checkDockerPresent(options = {}) { }; } +// Codex stores credentials at `$CODEX_HOME/auth.json`, else `$HOME/.codex/auth.json` — mirrors +// resolveCodexAuthPath in src/selfhost/ai.ts, kept local so the offline miner package never imports the +// Worker AI module. +function resolveCodexAuthPath(env = process.env) { + const base = env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"); + return join(base, "auth.json"); +} + +/** Informational only — a coding-agent CLI is only needed once a driver provider is configured (#4289), so a + * missing or unauthenticated CLI is advisory (`ok: true`), mirroring checkDockerPresent's optional tone. The + * auth probe is read-only and never spawns the CLI: it surfaces, proactively, the SAME condition claude + * checks at call time — `CLAUDE_CODE_OAUTH_TOKEN` present (see createClaudeCodeAi, src/selfhost/ai.ts). */ +export function checkClaudeCliPresent(options = {}) { + const env = options.env ?? process.env; + const claudePath = (options.resolveClaudePath ?? (() => findExecutableOnPath("claude", env)))(); + if (!claudePath) { + return { name: "claude-cli-present", ok: true, detail: "not installed (optional until a coding-agent driver is configured)" }; + } + const authed = typeof env.CLAUDE_CODE_OAUTH_TOKEN === "string" && env.CLAUDE_CODE_OAUTH_TOKEN.length > 0; + return { + name: "claude-cli-present", + ok: true, + detail: authed ? `found at ${claudePath} (authenticated)` : `found at ${claudePath} (not authenticated: set CLAUDE_CODE_OAUTH_TOKEN)`, + }; +} + +/** Informational only — mirrors {@link checkClaudeCliPresent} for the codex CLI. The auth probe checks the + * same read-only condition assertCodexAuthConfigured uses at call time: codex's `auth.json` is readable. */ +export function checkCodexCliPresent(options = {}) { + const env = options.env ?? process.env; + const codexPath = (options.resolveCodexPath ?? (() => findExecutableOnPath("codex", env)))(); + if (!codexPath) { + return { name: "codex-cli-present", ok: true, detail: "not installed (optional until a coding-agent driver is configured)" }; + } + const authPath = (options.resolveCodexAuthPath ?? (() => resolveCodexAuthPath(env)))(); + let authed = false; + try { + accessSync(authPath, constants.R_OK); + authed = true; + } catch { + // auth.json missing or unreadable — codex would fail for lack of credentials at call time. + } + return { + name: "codex-cli-present", + ok: true, + detail: authed ? `found at ${codexPath} (authenticated)` : `found at ${codexPath} (not authenticated: run \`codex auth\`)`, + }; +} + export function runInit(args = [], env = process.env) { const result = initLaptopState(env); if (args.includes("--json")) { diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index fbe1f286cc..5ca4755b15 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; -import { checkDockerPresent, checkLaptopStateSqlite } from "./laptop-init.js"; +import { checkClaudeCliPresent, checkCodexCliPresent, checkDockerPresent, checkLaptopStateSqlite } from "./laptop-init.js"; import { resolveMinerVersion } from "./version.js"; // Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is @@ -125,6 +125,8 @@ export function runDoctorChecks(env = process.env) { checkStateDirWritable(resolveMinerStateDir(env)), checkLaptopStateSqlite(env), checkDockerPresent(), + checkClaudeCliPresent({ env }), + checkCodexCliPresent({ env }), ]; } diff --git a/test/unit/miner-cli-doctor-checks.test.ts b/test/unit/miner-cli-doctor-checks.test.ts new file mode 100644 index 0000000000..74d7e2a487 --- /dev/null +++ b/test/unit/miner-cli-doctor-checks.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { checkClaudeCliPresent, checkCodexCliPresent } from "../../packages/gittensory-miner/lib/laptop-init.js"; +import { runDoctorChecks } from "../../packages/gittensory-miner/lib/status.js"; + +const roots: string[] = []; +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-clicheck-")); + roots.push(root); + return root; +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner doctor — coding-agent CLI checks (#4304)", () => { + it("claude: present + authenticated when the OAuth token is set", () => { + const check = checkClaudeCliPresent({ env: { CLAUDE_CODE_OAUTH_TOKEN: "present" }, resolveClaudePath: () => "/usr/bin/claude" }); + expect(check).toMatchObject({ name: "claude-cli-present", ok: true }); + expect(check.detail).toBe("found at /usr/bin/claude (authenticated)"); + }); + + it("claude: present but not authenticated when the OAuth token is absent (still advisory)", () => { + const check = checkClaudeCliPresent({ env: {}, resolveClaudePath: () => "/usr/bin/claude" }); + expect(check.ok).toBe(true); + expect(check.detail).toMatch(/found at \/usr\/bin\/claude \(not authenticated: set CLAUDE_CODE_OAUTH_TOKEN\)/); + }); + + it("claude: absent → advisory (ok true, optional)", () => { + const check = checkClaudeCliPresent({ env: {}, resolveClaudePath: () => null }); + expect(check.ok).toBe(true); + expect(check.detail).toMatch(/^not installed \(optional/); + }); + + it("codex: present + authenticated when auth.json is readable", () => { + const authFile = join(tempRoot(), "auth.json"); + writeFileSync(authFile, "{}"); + const check = checkCodexCliPresent({ env: {}, resolveCodexPath: () => "/usr/bin/codex", resolveCodexAuthPath: () => authFile }); + expect(check.detail).toBe("found at /usr/bin/codex (authenticated)"); + }); + + it("codex: present but not authenticated when auth.json is missing (still advisory)", () => { + const check = checkCodexCliPresent({ env: {}, resolveCodexPath: () => "/usr/bin/codex", resolveCodexAuthPath: () => join(tempRoot(), "does-not-exist.json") }); + expect(check.ok).toBe(true); + expect(check.detail).toMatch(/found at \/usr\/bin\/codex \(not authenticated: run `codex auth`\)/); + }); + + it("codex: absent → advisory (ok true, optional)", () => { + const check = checkCodexCliPresent({ env: {}, resolveCodexPath: () => null }); + expect(check.ok).toBe(true); + expect(check.detail).toMatch(/^not installed \(optional/); + }); + + it("runDoctorChecks includes both coding-agent CLI checks", () => { + const names = runDoctorChecks({ GITTENSORY_MINER_CONFIG_DIR: tempRoot() }).map((check) => check.name); + expect(names).toContain("claude-cli-present"); + expect(names).toContain("codex-cli-present"); + }); +}); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 4ce4bb0ca7..de811f1cce 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -75,6 +75,8 @@ describe("gittensory-miner status/doctor (#2288)", () => { "state-dir-writable", "laptop-state-sqlite", "docker-present", + "claude-cli-present", + "codex-cli-present", ]); expect(runDoctor([], env)).toBe(0); expect(log).toHaveBeenCalled();