diff --git a/src/agents/memory-extractor.ts b/src/agents/memory-extractor.ts index dfaec1f..35c25a4 100644 --- a/src/agents/memory-extractor.ts +++ b/src/agents/memory-extractor.ts @@ -12,7 +12,7 @@ import type { Memory } from "../types.js"; import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js"; import { toMemorySlug } from "../storage/memory.js"; -import { findClaudePath } from "../utils/agent-options.js"; +import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js"; export interface MemoryExtractionResult { memories: Memory[]; @@ -78,6 +78,7 @@ export async function runMemoryExtraction(opts: { allowDangerouslySkipPermissions: true, allowedTools: [] as string[], disallowedTools: ["Write", "Edit", "Bash", "Glob", "Grep", "Read", "Agent", "NotebookEdit", "Skill", "TodoWrite"], + env: buildAgentEnv(), }; const prompt = `${EXTRACTION_PROMPT}\n\nSession ID: ${opts.sessionId}\n\nSession transcript:\n${opts.sessionEvents}`; diff --git a/src/agents/session-auditor.ts b/src/agents/session-auditor.ts index 1a4cebb..7fb2c27 100644 --- a/src/agents/session-auditor.ts +++ b/src/agents/session-auditor.ts @@ -21,7 +21,7 @@ import { basename, relative } from "node:path"; import type { Memory, Decision, SessionHandoff, WorkspaceInfo } from "../types.js"; import { DEFAULT_AUDITOR_MODEL } from "../types.js"; import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js"; -import { findClaudePath } from "../utils/agent-options.js"; +import { buildAgentEnv, findClaudePath } from "../utils/agent-options.js"; import { toMemorySlug } from "../storage/memory.js"; import { toSlug, listDecisions } from "../storage/decisions.js"; import { listMemories } from "../storage/memory.js"; @@ -647,7 +647,7 @@ async function runSingleAuditCall(opts: { // auto-loading the project's .claude/settings.json, but users or CI may // register hooks via environment or other means, so the belt-and-braces // env check in every hook handler is what actually stops the recursion. - env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" }, + env: buildAgentEnv(), }; const isMultiChunk = opts.totalChunks > 1; @@ -901,7 +901,7 @@ ${freeTextAnalysis}`; "Read", "Grep", "Glob", "Write", "Edit", "NotebookEdit", "Agent", "Skill", "TodoWrite", "WebFetch", "WebSearch", "Bash", "ToolSearch", ], - env: { ...process.env, AXME_SKIP_HOOKS: "1", AXME_TELEMETRY_DISABLED: "1" }, + env: buildAgentEnv(), }; const q = sdk.query({ prompt: formatPrompt, options: queryOpts }); diff --git a/src/cli.ts b/src/cli.ts index bd3fcb3..59207d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,10 @@ import { statusTool } from "./tools/status.js"; import { detectWorkspace } from "./utils/workspace-detector.js"; import { atomicWrite, ensureDir } from "./storage/engine.js"; import { saveMemory, toMemorySlug } from "./storage/memory.js"; -import type { WorkspaceInfo } from "./types.js"; +import { detectAuthOptions } from "./utils/auth-detect.js"; +import { authConfigPath, loadAuthConfig, saveAuthConfig } from "./utils/auth-config.js"; +import { formatDetectionBlock, hasAnyAuth, promptAuthChoice } from "./utils/auth-prompt.js"; +import type { AuthMode, WorkspaceInfo } from "./types.js"; import { AXME_CODE_DIR } from "./types.js"; const args = process.argv.slice(2); @@ -150,6 +153,49 @@ function hasAuth(): boolean { return false; } +/** + * Print detection block + saved choice (if any). + */ +function printAuthStatus(): void { + const options = detectAuthOptions(); + console.log(formatDetectionBlock(options)); + const saved = loadAuthConfig(); + if (saved) { + console.log(`\nCurrent mode: ${saved.mode} (saved ${saved.chosenAt})`); + console.log(`Config file: ${authConfigPath()}`); + } else { + console.log("\nCurrent mode: not configured (using heuristic fallback)"); + console.log(`Config file: ${authConfigPath()} (will be created on first choice)`); + } +} + +/** + * Called from `axme-code setup` before LLM scanners launch. Persists a user + * choice to ~/.config/axme-code/auth.yaml exactly once on first setup, so + * subsequent runs (and all MCP server scanner/auditor subprocesses) use the + * same mode without re-prompting. In non-TTY contexts (CI, scripts) we skip + * the prompt and let `resolveAuthMode()` fall back to its heuristic — we do + * NOT persist a guessed choice silently. + */ +async function ensureAuthConfiguredForSetup(): Promise { + if (loadAuthConfig()) return; + if (!process.stdin.isTTY) return; + + const options = detectAuthOptions(); + if (!hasAnyAuth(options)) return; // hasAuth preflight will fail anyway + + console.log("\nAuthentication setup for LLM scanners"); + console.log(formatDetectionBlock(options)); + console.log(""); + const choice = await promptAuthChoice(options); + if (!choice) { + console.log(" Auth selection cancelled. Heuristic fallback will be used."); + return; + } + saveAuthConfig(choice); + console.log(` Saved auth mode: ${choice} (${authConfigPath()})`); +} + function generateWorkspaceYaml(workspacePath: string, ws: WorkspaceInfo): void { const wsYaml = yaml.dump({ name: workspacePath.split("/").pop(), @@ -256,6 +302,9 @@ Usage: axme-code setup [path] [--force] Initialize project (LLM scan + .mcp.json + CLAUDE.md) axme-code serve Start MCP server (stdio transport) axme-code status [path] Show project status + axme-code auth Re-detect and choose auth mode (subscription/api_key) + axme-code auth status Show current auth mode + detected options + axme-code auth use Set auth mode non-interactively axme-code cleanup legacy-artifacts [--dry-run] Remove pre-PR#7 sessions/logs axme-code cleanup decisions-normalize [--dry-run] Add status:active to decisions axme-code audit-kb [path] [--all-repos] KB audit: dedup, conflicts, compaction @@ -339,6 +388,11 @@ async function main() { process.exit(1); } + // Auth mode selection — prompt once on first interactive setup, persist + // to ~/.config/axme-code/auth.yaml. Later runs and scanner subprocesses + // read the saved mode via resolveAuthMode() in buildAgentEnv(). + await ensureAuthConfiguredForSetup(); + // Init with LLM scanners (parallel) try { if (isWorkspace) { @@ -649,6 +703,51 @@ Do NOT skip — without context you will miss critical project rules. break; } + case "auth": { + const sub = args[1]; + if (sub === "status" || sub === "show") { + printAuthStatus(); + break; + } + if (sub === "use" || sub === "set") { + const mode = args[2]; + if (mode !== "subscription" && mode !== "api_key") { + console.error("Usage: axme-code auth use "); + process.exit(1); + } + saveAuthConfig(mode as AuthMode); + console.log(`Saved auth mode: ${mode} (${authConfigPath()})`); + break; + } + if (sub === undefined || sub === "choose") { + const options = detectAuthOptions(); + console.log("Authentication setup for LLM scanners"); + console.log(formatDetectionBlock(options)); + const saved = loadAuthConfig(); + if (saved) console.log(`\nCurrent mode: ${saved.mode} (saved ${saved.chosenAt})`); + console.log(""); + if (!hasAnyAuth(options)) { + console.error("No authentication detected. Set ANTHROPIC_API_KEY or run `claude /login`, then re-run `axme-code auth`."); + process.exit(1); + } + if (!process.stdin.isTTY) { + console.error("`axme-code auth` requires an interactive terminal. Use `axme-code auth use ` non-interactively."); + process.exit(1); + } + const choice = await promptAuthChoice(options); + if (!choice) { + console.log("Cancelled. No change."); + break; + } + saveAuthConfig(choice); + console.log(`Saved auth mode: ${choice} (${authConfigPath()})`); + break; + } + console.error(`Unknown 'auth' subcommand: ${sub}`); + console.error("Available: (none)|choose, status|show, use|set "); + process.exit(1); + } + case "help": case "--help": case "-h": diff --git a/src/types.ts b/src/types.ts index 1502fc7..9c8115c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -370,3 +370,12 @@ export const DEFAULT_AGENT_PERMISSIONS: AgentPermissions = { }; export type E2EMode = "after-task" | "after-stage" | "manual"; + +// --- Auth --- + +export type AuthMode = "subscription" | "api_key"; + +export interface AuthConfig { + mode: AuthMode; + chosenAt: string; +} diff --git a/src/utils/agent-options.ts b/src/utils/agent-options.ts index 9d4aeba..4715f01 100644 --- a/src/utils/agent-options.ts +++ b/src/utils/agent-options.ts @@ -3,6 +3,7 @@ */ import { execSync } from "node:child_process"; +import { resolveAuthMode } from "./auth-config.js"; type Options = import("@anthropic-ai/claude-agent-sdk").Options; @@ -54,6 +55,35 @@ const ROLE_TOOLS: Record }, }; +/** + * Build the env passed to every Claude Code subprocess we spawn for LLM work. + * + * Two things happen here: + * 1. `AXME_TELEMETRY_DISABLED` and `AXME_SKIP_HOOKS` are set to suppress + * recursive startup events and ghost AXME sessions when the sub-claude + * inadvertently launches axme-code as its own MCP server. + * 2. If the user has selected `subscription` as the auth mode (either via + * `axme-code auth` / `axme-code setup`, or by heuristic when only the + * subscription is detected), we delete `ANTHROPIC_API_KEY` before + * handing env to the subprocess. Claude Code checks the env var before + * its OAuth credentials, so leaving an empty-balance key in env would + * surface as "Credit balance is too low" or 401 auth errors even when + * the user has an active subscription. Delete, not empty string: Claude + * Code treats an empty-string value as "set" and still prefers it over + * OAuth. + */ +export function buildAgentEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + AXME_TELEMETRY_DISABLED: "1", + AXME_SKIP_HOOKS: "1", + }; + if (resolveAuthMode() === "subscription") { + delete env.ANTHROPIC_API_KEY; + } + return env; +} + export function buildAgentQueryOptions(base: { cwd: string; model: string; @@ -78,11 +108,6 @@ export function buildAgentQueryOptions(base: { allowedTools: tools.allowed, disallowedTools: tools.disallowed, includePartialMessages: true, - // Disable telemetry in spawned subprocesses. Sub-claude sessions started - // by scanners/auditors may pick up the parent's .mcp.json and re-launch - // axme-code as an MCP server. Each re-launch would otherwise fire its - // own startup event, inflating DAU and skewing scanner cost metrics. - // The parent process owns the lifecycle event for this user action. - env: { ...process.env, AXME_TELEMETRY_DISABLED: "1", AXME_SKIP_HOOKS: "1" }, + env: buildAgentEnv(), }; } diff --git a/src/utils/auth-config.ts b/src/utils/auth-config.ts new file mode 100644 index 0000000..22bf3d1 --- /dev/null +++ b/src/utils/auth-config.ts @@ -0,0 +1,77 @@ +/** + * User-level authentication mode config at ~/.config/axme-code/auth.yaml. + * + * Auth mode is a per-machine concern (which credential should Claude Code + * subprocesses use), not a per-project one, so it lives outside the repo's + * .axme-code/ storage. One choice applies to every project on this machine. + * + * The file stores only the selected mode and the timestamp of the choice. + * `resolveAuthMode()` returns the persisted mode when present, otherwise + * falls back to a detection-based heuristic without writing anything — so + * non-interactive callers (scanner subprocesses, auditor) never surprise + * the user by persisting a guessed choice. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import yaml from "js-yaml"; +import { atomicWrite, ensureDir, readSafe, pathExists } from "../storage/engine.js"; +import type { AuthConfig, AuthMode } from "../types.js"; +import { detectAuthOptions, type AuthOptions } from "./auth-detect.js"; + +/** + * Resolve paths lazily (not at module load) so tests can swap $HOME between + * cases without needing to bust the ESM module cache. + */ +function configDir(): string { + return join(homedir(), ".config", "axme-code"); +} + +export function authConfigPath(): string { + return join(configDir(), "auth.yaml"); +} + +export function loadAuthConfig(): AuthConfig | null { + const file = authConfigPath(); + if (!pathExists(file)) return null; + const raw = readSafe(file); + if (!raw) return null; + try { + const parsed = yaml.load(raw) as Partial | null; + if (!parsed || typeof parsed !== "object") return null; + if (parsed.mode !== "subscription" && parsed.mode !== "api_key") return null; + const chosenAt = typeof parsed.chosenAt === "string" ? parsed.chosenAt : new Date().toISOString(); + return { mode: parsed.mode, chosenAt }; + } catch { + return null; + } +} + +export function saveAuthConfig(mode: AuthMode): AuthConfig { + ensureDir(configDir()); + const config: AuthConfig = { mode, chosenAt: new Date().toISOString() }; + atomicWrite(authConfigPath(), yaml.dump(config)); + return config; +} + +/** + * Choose the sensible default when no saved choice exists and we can't ask + * the user. If an API key is set (regardless of subscription state) we keep + * the existing behavior: pass env through to Claude Code and let it decide. + * If only subscription is available, prefer it. If neither, return api_key + * so we fail the same way Claude Code would fail on its own. + */ +function heuristicMode(options: AuthOptions): AuthMode { + if (options.subscription.present && !options.apiKey.present) return "subscription"; + return "api_key"; +} + +/** + * Effective auth mode for a scanner call. Reads the saved config if present, + * otherwise returns a heuristic without persisting anything. + */ +export function resolveAuthMode(): AuthMode { + const saved = loadAuthConfig(); + if (saved) return saved.mode; + return heuristicMode(detectAuthOptions()); +} diff --git a/src/utils/auth-detect.ts b/src/utils/auth-detect.ts new file mode 100644 index 0000000..5bd11d0 --- /dev/null +++ b/src/utils/auth-detect.ts @@ -0,0 +1,110 @@ +/** + * Detect available authentication options for LLM scanner subprocesses. + * + * Two mechanisms can authenticate the Claude Code binary that our scanners + * spawn: (1) ANTHROPIC_API_KEY in env, which Claude Code picks up before + * checking OAuth; (2) a Claude Code subscription session, stored either in + * macOS Keychain or a credentials file under ~/.claude/ or ~/.config/claude/. + * + * Detection is offline and cheap — we do not probe the API, only check + * environment variables, run `security` on macOS, and stat known paths. + */ + +import { execFileSync } from "node:child_process"; +import { homedir, userInfo } from "node:os"; +import { join } from "node:path"; +import { pathExists } from "../storage/engine.js"; +import { findClaudePath } from "./agent-options.js"; + +export interface ApiKeyOption { + present: boolean; + /** Masked representation for display, e.g. "sk-ant-...XXXX". */ + masked?: string; +} + +export interface SubscriptionOption { + present: boolean; + /** Where we found evidence of a logged-in Claude Code session. */ + source?: "keychain" | "filesystem"; + /** Human-readable detail — Keychain service name or file path. */ + details?: string; + /** True if the `claude` binary was found on PATH. */ + binaryFound: boolean; +} + +export interface AuthOptions { + apiKey: ApiKeyOption; + subscription: SubscriptionOption; +} + +function maskApiKey(key: string): string { + const trimmed = key.trim(); + const last4 = trimmed.slice(-4); + const prefix = trimmed.startsWith("sk-ant-") ? "sk-ant-" : ""; + return `${prefix}...${last4}`; +} + +function detectApiKey(): ApiKeyOption { + const key = process.env.ANTHROPIC_API_KEY; + if (!key || !key.trim()) return { present: false }; + return { present: true, masked: maskApiKey(key) }; +} + +function detectSubscriptionFromKeychain(): SubscriptionOption | null { + if (process.platform !== "darwin") return null; + try { + const user = userInfo().username; + // `security find-generic-password -s -a ` exits 0 if + // the item exists, non-zero otherwise. We do NOT reveal the secret. + execFileSync("security", ["find-generic-password", "-s", "Claude Code", "-a", user], { + stdio: "ignore", + }); + return { + present: true, + source: "keychain", + details: 'macOS Keychain entry "Claude Code"', + binaryFound: true, + }; + } catch { + return null; + } +} + +function detectSubscriptionFromFilesystem(): SubscriptionOption | null { + const home = homedir(); + const candidates = [ + join(home, ".claude", ".credentials.json"), + join(home, ".config", "claude", ".credentials.json"), + ]; + for (const path of candidates) { + if (pathExists(path)) { + return { + present: true, + source: "filesystem", + details: path, + binaryFound: true, + }; + } + } + return null; +} + +function detectSubscription(): SubscriptionOption { + const binaryFound = Boolean(findClaudePath()); + if (!binaryFound) return { present: false, binaryFound: false }; + + const fromKeychain = detectSubscriptionFromKeychain(); + if (fromKeychain) return fromKeychain; + + const fromFs = detectSubscriptionFromFilesystem(); + if (fromFs) return fromFs; + + return { present: false, binaryFound: true }; +} + +export function detectAuthOptions(): AuthOptions { + return { + apiKey: detectApiKey(), + subscription: detectSubscription(), + }; +} diff --git a/src/utils/auth-prompt.ts b/src/utils/auth-prompt.ts new file mode 100644 index 0000000..d56b803 --- /dev/null +++ b/src/utils/auth-prompt.ts @@ -0,0 +1,69 @@ +/** + * Interactive and non-interactive helpers for selecting the auth mode used + * by Claude Code subprocesses. + * + * Split from auth-config.ts so that config.ts stays dependency-free + * (no readline, no stdio) and can be imported by scanner/auditor code paths + * that must never block or prompt. + */ + +import { createInterface } from "node:readline"; +import type { AuthMode } from "../types.js"; +import type { AuthOptions } from "./auth-detect.js"; + +export function formatDetectionBlock(options: AuthOptions): string { + const lines: string[] = []; + lines.push("Detected on this machine:"); + if (options.apiKey.present) { + lines.push(` [1] Anthropic API key: ${options.apiKey.masked} (ANTHROPIC_API_KEY)`); + } else { + lines.push(" [1] Anthropic API key — not set"); + } + if (options.subscription.present) { + const detail = options.subscription.details ? ` (${options.subscription.details})` : ""; + lines.push(` [2] Claude Code subscription${detail}`); + } else if (options.subscription.binaryFound) { + lines.push(" [2] Claude Code subscription — binary found but no saved login"); + lines.push(" (run `claude` then `/login` to authenticate)"); + } else { + lines.push(" [2] Claude Code subscription — claude binary not found on PATH"); + } + return lines.join("\n"); +} + +function defaultChoice(options: AuthOptions): AuthMode { + if (options.subscription.present && !options.apiKey.present) return "subscription"; + if (options.apiKey.present && !options.subscription.present) return "api_key"; + return options.subscription.present ? "subscription" : "api_key"; +} + +export function hasAnyAuth(options: AuthOptions): boolean { + return options.apiKey.present || options.subscription.present; +} + +/** + * Interactive prompt. Returns the chosen mode or null if the user aborted. + * Requires a TTY on stdin — callers must check `process.stdin.isTTY` first + * and fall back to a non-interactive path otherwise. + */ +export async function promptAuthChoice(options: AuthOptions): Promise { + const def = defaultChoice(options); + const defLabel = def === "subscription" ? "2" : "1"; + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + while (true) { + const answer = await new Promise((resolve) => { + rl.question(`Which should axme-code use? [1=api_key, 2=subscription, default ${defLabel}]: `, resolve); + }); + const trimmed = answer.trim().toLowerCase(); + if (trimmed === "") return def; + if (trimmed === "1" || trimmed === "api_key" || trimmed === "key") return "api_key"; + if (trimmed === "2" || trimmed === "subscription" || trimmed === "sub") return "subscription"; + if (trimmed === "q" || trimmed === "quit" || trimmed === "cancel") return null; + process.stdout.write(" Enter 1, 2, or q to cancel.\n"); + } + } finally { + rl.close(); + } +} diff --git a/test/auth-config.test.ts b/test/auth-config.test.ts new file mode 100644 index 0000000..391845f --- /dev/null +++ b/test/auth-config.test.ts @@ -0,0 +1,89 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + authConfigPath, + loadAuthConfig, + resolveAuthMode, + saveAuthConfig, +} from "../src/utils/auth-config.js"; + +const originalHome = process.env.HOME; +const originalKey = process.env.ANTHROPIC_API_KEY; +let tmpHome: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "axme-auth-")); + process.env.HOME = tmpHome; + delete process.env.ANTHROPIC_API_KEY; +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = originalKey; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe("auth-config — load/save roundtrip", () => { + it("returns null when no config exists", () => { + assert.equal(loadAuthConfig(), null); + }); + + it("saves and loads subscription mode", () => { + const saved = saveAuthConfig("subscription"); + assert.equal(saved.mode, "subscription"); + assert.ok(saved.chosenAt); + + const loaded = loadAuthConfig(); + assert.equal(loaded?.mode, "subscription"); + assert.equal(loaded?.chosenAt, saved.chosenAt); + }); + + it("saves and loads api_key mode", () => { + saveAuthConfig("api_key"); + const loaded = loadAuthConfig(); + assert.equal(loaded?.mode, "api_key"); + }); + + it("overwrites existing config on save", () => { + saveAuthConfig("subscription"); + saveAuthConfig("api_key"); + assert.equal(loadAuthConfig()?.mode, "api_key"); + }); + + it("returns null for corrupt YAML", () => { + const configDir = join(tmpHome, ".config", "axme-code"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "auth.yaml"), "not: valid: yaml: [[["); + assert.equal(loadAuthConfig(), null); + }); + + it("returns null for unknown mode value", () => { + const configDir = join(tmpHome, ".config", "axme-code"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "auth.yaml"), "mode: gibberish\nchosenAt: 2026-01-01\n"); + assert.equal(loadAuthConfig(), null); + }); + + it("authConfigPath reflects $HOME", () => { + assert.equal(authConfigPath(), join(tmpHome, ".config", "axme-code", "auth.yaml")); + }); +}); + +describe("auth-config — resolveAuthMode heuristic", () => { + it("falls back to a concrete mode when no config exists", () => { + const mode = resolveAuthMode(); + assert.ok(mode === "subscription" || mode === "api_key"); + }); + + it("reads saved config when present regardless of detection", () => { + saveAuthConfig("subscription"); + assert.equal(resolveAuthMode(), "subscription"); + saveAuthConfig("api_key"); + assert.equal(resolveAuthMode(), "api_key"); + }); +}); diff --git a/test/auth-detect.test.ts b/test/auth-detect.test.ts new file mode 100644 index 0000000..3dafc2a --- /dev/null +++ b/test/auth-detect.test.ts @@ -0,0 +1,52 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { detectAuthOptions } from "../src/utils/auth-detect.js"; + +const originalKey = process.env.ANTHROPIC_API_KEY; + +beforeEach(() => { + delete process.env.ANTHROPIC_API_KEY; +}); + +afterEach(() => { + if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = originalKey; +}); + +describe("detectAuthOptions — API key", () => { + it("reports not present when ANTHROPIC_API_KEY is unset", () => { + const options = detectAuthOptions(); + assert.equal(options.apiKey.present, false); + assert.equal(options.apiKey.masked, undefined); + }); + + it("reports present when ANTHROPIC_API_KEY is set", () => { + process.env.ANTHROPIC_API_KEY = "sk-ant-api03-abcdef1234567890"; + const options = detectAuthOptions(); + assert.equal(options.apiKey.present, true); + assert.ok(options.apiKey.masked?.startsWith("sk-ant-...")); + assert.ok(options.apiKey.masked?.endsWith("7890")); + }); + + it("treats empty string and whitespace as not present", () => { + process.env.ANTHROPIC_API_KEY = ""; + assert.equal(detectAuthOptions().apiKey.present, false); + process.env.ANTHROPIC_API_KEY = " "; + assert.equal(detectAuthOptions().apiKey.present, false); + }); + + it("masks keys without the sk-ant- prefix too", () => { + process.env.ANTHROPIC_API_KEY = "some-other-format-XYZ12345"; + const { apiKey } = detectAuthOptions(); + assert.equal(apiKey.present, true); + assert.equal(apiKey.masked, "...2345"); + }); +}); + +describe("detectAuthOptions — subscription", () => { + it("returns an object regardless of subscription state (no throw)", () => { + const options = detectAuthOptions(); + assert.ok(typeof options.subscription.present === "boolean"); + assert.ok(typeof options.subscription.binaryFound === "boolean"); + }); +});