Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/gittensory-miner/lib/laptop-init.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,15 @@ export function checkDockerPresent(options?: {
resolveDockerPath?: () => string | null;
}): DoctorCheck;

export function checkClaudeCliPresent(options?: {
env?: Record<string, string | undefined>;
resolveClaudePath?: () => string | null;
}): DoctorCheck;

export function checkCodexCliPresent(options?: {
env?: Record<string, string | undefined>;
resolveCodexPath?: () => string | null;
resolveCodexAuthPath?: () => string;
}): DoctorCheck;

export function runInit(args?: string[], env?: Record<string, string | undefined>): number;
49 changes: 49 additions & 0 deletions packages/gittensory-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
4 changes: 3 additions & 1 deletion packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,6 +125,8 @@ export function runDoctorChecks(env = process.env) {
checkStateDirWritable(resolveMinerStateDir(env)),
checkLaptopStateSqlite(env),
checkDockerPresent(),
checkClaudeCliPresent({ env }),
checkCodexCliPresent({ env }),
];
}

Expand Down
61 changes: 61 additions & 0 deletions test/unit/miner-cli-doctor-checks.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 2 additions & 0 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down