diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts index f74e62fe8a..d152718493 100644 --- a/packages/gittensory-miner/lib/laptop-init.d.ts +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -16,6 +16,8 @@ export function initLaptopState(env?: Record): Lapto export function checkLaptopStateSqlite(env?: Record): DoctorCheck; +export function findExecutableOnPath(name: string, env?: Record): string | null; + export function checkDockerPresent(options?: { env?: Record; resolveDockerPath?: () => string | null; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js index 051dc7daf0..b9c47bc557 100644 --- a/packages/gittensory-miner/lib/laptop-init.js +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -68,7 +68,9 @@ export function checkLaptopStateSqlite(env = process.env) { } } -function findExecutableOnPath(name, env = process.env) { +/** Exported so callers that only need a presence boolean (e.g. status.js's `driver` section, #5164) can reuse + * this PATH scan directly instead of duplicating it or parsing a DoctorCheck's detail string. */ +export function findExecutableOnPath(name, env = process.env) { const pathValue = typeof env.PATH === "string" ? env.PATH : ""; for (const pathEntry of pathValue.split(delimiter)) { if (!pathEntry) continue; diff --git a/packages/gittensory-miner/lib/status.d.ts b/packages/gittensory-miner/lib/status.d.ts index 0cd0eb71ba..63a1205fd9 100644 --- a/packages/gittensory-miner/lib/status.d.ts +++ b/packages/gittensory-miner/lib/status.d.ts @@ -1,9 +1,16 @@ +export type MinerDriverStatus = { + provider: string | null; + modelEnvVar: string | null; + cliPresent: boolean | null; +}; + export type MinerStatus = { package: { name: string; version: string | null }; engine: { name: string; version: string | null }; node: string; stateDir: string; configFile: string | null; + driver: MinerDriverStatus; }; export type DoctorCheck = { diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 159f08c684..7d50754b4a 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -2,7 +2,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; -import { checkClaudeCliPresent, checkCodexCliPresent, checkDockerPresent, checkLaptopStateSqlite } from "./laptop-init.js"; +import { CODING_AGENT_DRIVER_CONFIG_ENV, resolveFirstConfiguredCodingAgentDriverName } from "@jsonbored/gittensory-engine"; +import { + checkClaudeCliPresent, + checkCodexCliPresent, + checkDockerPresent, + checkLaptopStateSqlite, + findExecutableOnPath, +} 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 @@ -193,6 +200,23 @@ function discoverConfigFile(cwd) { return null; } +// CLI names driver-factory.ts's resolved provider values that actually spawn a local subprocess -- "noop" and +// "agent-sdk" have no separate CLI binary to check presence for, so cliPresent is null (not applicable) for them. +const PROVIDER_CLI_BINARY = Object.freeze({ "claude-cli": "claude", "codex-cli": "codex" }); + +/** The `driver` section of `status`/`status --json` (#5164): which coding-agent provider is configured, the + * NAME (never the value) of its model env var, and whether its CLI binary is on PATH. Reuses + * `resolveFirstConfiguredCodingAgentDriverName`/`CODING_AGENT_DRIVER_CONFIG_ENV` (the same resolution + * driver-factory.ts uses) and `findExecutableOnPath` (the same PATH scan the doctor CLI-presence checks use) + * rather than duplicating either. Never reads or returns an env var's actual value. */ +function resolveDriverStatus(env) { + const provider = resolveFirstConfiguredCodingAgentDriverName(env) ?? null; + const modelEnvVar = provider ? (CODING_AGENT_DRIVER_CONFIG_ENV[provider]?.model ?? null) : null; + const cliBinary = provider ? (PROVIDER_CLI_BINARY[provider] ?? null) : null; + const cliPresent = cliBinary ? Boolean(findExecutableOnPath(cliBinary, env)) : null; + return { provider, modelEnvVar, cliPresent }; +} + /** Gather the read-only status snapshot. Pure w.r.t. its (env, cwd) inputs — no writes, no network. */ export function collectStatus(env = process.env, cwd = process.cwd()) { const stateDir = resolveMinerStateDir(env); @@ -202,15 +226,24 @@ export function collectStatus(env = process.env, cwd = process.cwd()) { node: process.version, stateDir, configFile: discoverConfigFile(cwd), + driver: resolveDriverStatus(env), }; } +function renderDriverLine(driver) { + if (!driver.provider) return "driver: none configured"; + const cliText = driver.cliPresent === null ? "n/a" : driver.cliPresent ? "yes" : "no"; + const modelText = driver.modelEnvVar ? `, model env: ${driver.modelEnvVar}` : ""; + return `driver: ${driver.provider} (CLI present: ${cliText}${modelText})`; +} + function renderStatusText(status) { return [ `${status.package.name} ${status.package.version ?? "unknown"} (node ${status.node})`, `engine: ${status.engine.name} ${status.engine.version ?? "unresolved"}`, `state dir: ${status.stateDir}`, `config file: ${status.configFile ?? "none found"}`, + renderDriverLine(status.driver), ].join("\n"); } diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index ef63365f86..1c562fd3d3 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -31,6 +31,14 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +/** Creates an executable file `name` in a fresh bin dir and returns that dir (usable directly as PATH). */ +function fakeBinDir(name: string): string { + const dir = tempRoot(); + writeFileSync(join(dir, name), "#!/bin/sh\n"); + chmodSync(join(dir, name), 0o755); + return dir; +} + describe("gittensory-miner status/doctor (#2288)", () => { it("resolves the state dir from the config-dir override, XDG, then the home default", () => { expect(resolveMinerStateDir({ GITTENSORY_MINER_CONFIG_DIR: "/custom/state" })).toBe("/custom/state"); @@ -195,4 +203,114 @@ describe("gittensory-miner status/doctor (#2288)", () => { runDoctor([], env); expect(fetchStub).not.toHaveBeenCalled(); }); + + describe("driver section of status/status --json (#5164)", () => { + it("reports provider: null, modelEnvVar: null, cliPresent: null when no provider is configured", () => { + const status = collectStatus({ GITTENSORY_MINER_CONFIG_DIR: "/s", PATH: "" }, tempRoot()); + expect(status.driver).toEqual({ provider: null, modelEnvVar: null, cliPresent: null }); + }); + + it("reports the noop provider with no model env var and no CLI to check (cliPresent: null)", () => { + const status = collectStatus( + { GITTENSORY_MINER_CONFIG_DIR: "/s", PATH: "", MINER_CODING_AGENT_PROVIDER: "noop" }, + tempRoot(), + ); + expect(status.driver).toEqual({ provider: "noop", modelEnvVar: null, cliPresent: null }); + }); + + it("reports the agent-sdk provider with no model env var and no CLI to check (cliPresent: null)", () => { + const status = collectStatus( + { GITTENSORY_MINER_CONFIG_DIR: "/s", PATH: "", MINER_CODING_AGENT_PROVIDER: "agent-sdk" }, + tempRoot(), + ); + expect(status.driver).toEqual({ provider: "agent-sdk", modelEnvVar: null, cliPresent: null }); + }); + + it("claude-cli configured + CLI present on PATH: reports the model env-var name and cliPresent: true", () => { + const status = collectStatus( + { + GITTENSORY_MINER_CONFIG_DIR: "/s", + MINER_CODING_AGENT_PROVIDER: "claude-cli", + PATH: fakeBinDir("claude"), + }, + tempRoot(), + ); + expect(status.driver).toEqual({ + provider: "claude-cli", + modelEnvVar: "MINER_CODING_AGENT_CLAUDE_MODEL", + cliPresent: true, + }); + }); + + it("claude-cli configured + CLI absent from PATH: cliPresent: false", () => { + const status = collectStatus( + { GITTENSORY_MINER_CONFIG_DIR: "/s", MINER_CODING_AGENT_PROVIDER: "claude-cli", PATH: tempRoot() }, + tempRoot(), + ); + expect(status.driver.cliPresent).toBe(false); + }); + + it("codex-cli configured + CLI present on PATH: reports the model env-var name and cliPresent: true", () => { + const status = collectStatus( + { + GITTENSORY_MINER_CONFIG_DIR: "/s", + MINER_CODING_AGENT_PROVIDER: "codex-cli", + PATH: fakeBinDir("codex"), + }, + tempRoot(), + ); + expect(status.driver).toEqual({ + provider: "codex-cli", + modelEnvVar: "MINER_CODING_AGENT_CODEX_MODEL", + cliPresent: true, + }); + }); + + it("codex-cli configured + CLI absent from PATH: cliPresent: false", () => { + const status = collectStatus( + { GITTENSORY_MINER_CONFIG_DIR: "/s", MINER_CODING_AGENT_PROVIDER: "codex-cli", PATH: tempRoot() }, + tempRoot(), + ); + expect(status.driver.cliPresent).toBe(false); + }); + + it("human-readable status text renders the driver line for both configured and unconfigured cases", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + runStatus([], { GITTENSORY_MINER_CONFIG_DIR: "/s", PATH: "" }, tempRoot()); + expect(String(log.mock.calls[0]?.[0])).toContain("driver: none configured"); + log.mockClear(); + runStatus( + [], + { GITTENSORY_MINER_CONFIG_DIR: "/s", MINER_CODING_AGENT_PROVIDER: "codex-cli", PATH: fakeBinDir("codex") }, + tempRoot(), + ); + expect(String(log.mock.calls[0]?.[0])).toContain( + "driver: codex-cli (CLI present: yes, model env: MINER_CODING_AGENT_CODEX_MODEL)", + ); + }); + + it("invariant: no env-var VALUE or secret-shaped string ever appears in status --json output across provider permutations", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const secretModelValue = "sk-ant-should-never-appear-in-output"; + const providers = [undefined, "noop", "claude-cli", "codex-cli", "agent-sdk"]; + for (const provider of providers) { + log.mockClear(); + runStatus( + ["--json"], + { + GITTENSORY_MINER_CONFIG_DIR: "/s", + ...(provider ? { MINER_CODING_AGENT_PROVIDER: provider } : {}), + MINER_CODING_AGENT_CLAUDE_MODEL: secretModelValue, + MINER_CODING_AGENT_CODEX_MODEL: secretModelValue, + PATH: fakeBinDir("claude"), + }, + tempRoot(), + ); + const output = String(log.mock.calls[0]?.[0]); + expect(output).not.toContain(secretModelValue); + const parsed = JSON.parse(output); + expect(typeof parsed.driver.cliPresent === "boolean" || parsed.driver.cliPresent === null).toBe(true); + } + }); + }); });