From 80d09a0cddc5d8571a3e9d7963af121bb9ee0d00 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sun, 17 May 2026 19:27:37 +0200 Subject: [PATCH] feat(cli): --json parity for 9 remaining read commands (X4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends Theme N3's --json envelope from the five core commands to every remaining read-shaped command per Theme X4: config, daemon --once, forecast, savings, hero, export, import, parallel --list, kiro (no args). - BaseCommand subclasses (config, daemon, forecast, savings, hero, export, import) opt in via `...BaseCommand.jsonFlag` and emit through `emit()`. Validation errors converted from `this.error(...)` to thrown AuthmuxError subclasses so they fall through to the structured handler. - parallel and kiro intentionally bypass BaseCommand (per 01-ARCHITECTURE.md §1.3) and wire --json manually via `writeJsonEnvelope`. The on-the-wire shape is identical. - daemon --watch --json is rejected with a structured error envelope and flagged for the X3 streaming-observability work; --once is the only watch-mode subset that gets --json today. Snapshot test (`src/tests/json-parity.test.ts`) spawns the built CLI for each command with a sandboxed HOME/CODEX_AUTH_CODEX_DIR and asserts: stdout is exactly one valid JSON document, the envelope matches `{ok:true,data}` or `{ok:false,error:{code,severity,message}}`, and no banner/color/prompt chrome leaks into stdout. 138/138 tests pass. `docs/future/02-COMMANDS.md` lands as the JSON-schema reference (just the schemas — full command audit is a later doc-only PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/future/02-COMMANDS.md | 349 ++++++++++++++++++++++++++++++++++ docs/future/17-ROADMAP.md | 6 +- src/commands/config.ts | 57 ++++-- src/commands/daemon.ts | 26 ++- src/commands/export.ts | 54 ++++-- src/commands/forecast.ts | 46 +++-- src/commands/hero.ts | 63 +++++- src/commands/import.ts | 147 ++++++++++---- src/commands/kiro.ts | 56 +++++- src/commands/parallel.ts | 80 +++++++- src/commands/savings.ts | 40 ++-- src/tests/json-parity.test.ts | 252 ++++++++++++++++++++++++ 12 files changed, 1064 insertions(+), 112 deletions(-) create mode 100644 docs/future/02-COMMANDS.md create mode 100644 src/tests/json-parity.test.ts diff --git a/docs/future/02-COMMANDS.md b/docs/future/02-COMMANDS.md new file mode 100644 index 00000000..99babd96 --- /dev/null +++ b/docs/future/02-COMMANDS.md @@ -0,0 +1,349 @@ +# 02 — Commands: `--json` schema reference + +Status: in progress. + +This file is the JSON-schema reference for every command that supports the +`--json` flag. The full command audit (one section per command, with human +output, flag table, exit-code behavior, examples) is out of scope for the +X4 roadmap item — that ships under a later doc-only PR. The X4 exit +criteria require this file to **list the JSON schema for each command**, +which is what this file does and nothing more. + +All `--json` output follows the envelope shape defined in +`src/lib/cli/json-envelope.ts`: + +```ts +type Envelope = + | { ok: true; data: T } + | { + ok: false; + error: { + code: ErrorCode; // see src/lib/accounts/errors.ts + severity: "fatal" | "warn" | "info"; + message: string; + hint?: string; + details?: Record; + }; + }; +``` + +`ErrorCode` enumerates the §6.2 allowlist (see +`01-ARCHITECTURE.md`). Exit codes follow the §6.3 table — see +`exitCodeForErrorCode` in `json-envelope.ts`. + +## Core read commands (Theme N3) + +### `authmux list --json` + +```ts +{ + ok: true; + data: { + accounts: Array<{ + name: string; + active: boolean; + email?: string; + accountId?: string; + userId?: string; + planType?: string; + lastUsageAt?: string; + usageSource?: "api" | "local" | "cached" | "proxy"; + remaining5hPercent?: number; + remainingWeeklyPercent?: number; + }>; + detailed: boolean; + }; +} +``` + +### `authmux current --json` + +```ts +{ ok: true; data: { active: string | null } } +``` + +### `authmux status --json` + +```ts +{ + ok: true; + data: { + autoSwitchEnabled: boolean; + serviceState: "active" | "inactive" | "unknown"; + threshold5hPercent: number; + thresholdWeeklyPercent: number; + usageMode: "api" | "local"; + }; +} +``` + +### `authmux use --json` + +```ts +{ + ok: true; + data: { + activated: string; + kiro: { + attempted: boolean; + switched: boolean; + active: string | null; + reason: string | null; + }; + }; +} +``` + +Under `--json`, `authmux use` (no positional argument) does **not** +prompt; it returns `{ ok: false, error: { code: "E_PROMPT_CANCELLED", ... } }` +so the caller can supply the name explicitly. + +### `authmux save [name] --json` + +```ts +{ + ok: true; + data: { + saved: string; + source: "explicit" | "active" | "existing" | "inferred"; + forced: boolean; + }; +} +``` + +## Theme X4 additions + +The nine commands below ship `--json` parity in Theme X4. Each entry lists +the success-envelope `data` payload; error responses follow the standard +envelope shape above. + +### `authmux config [action] --json` + +```ts +{ + ok: true; + data: { + section: "auto" | "api"; + action: "enable" | "disable" | "thresholds"; + status: { + autoSwitchEnabled: boolean; + serviceState: "active" | "inactive" | "unknown"; + threshold5hPercent: number; + thresholdWeeklyPercent: number; + usageMode: "api" | "local"; + }; + }; +} +``` + +Validation failures (missing action, mixing thresholds with enable/disable, +etc.) emit an `E_AUTOSWITCH_CONFIG` error envelope. + +### `authmux daemon --once --json` + +```ts +{ + ok: true; + data: { + switched: boolean; + fromAccount?: string; + toAccount?: string; + reason: string; + }; +} +``` + +`daemon --watch` does not yet support `--json`; the long-running event +stream is tracked under roadmap Theme X3 (Observability v1). Passing +`--watch --json` returns an error envelope explaining the gap. + +### `authmux forecast --json` + +```ts +{ + ok: true; + data: { + accounts: Array<{ + name: string; + score: number; + circuitState: "closed" | "open" | "half-open"; + tokensAvailable: number; + usable: boolean; + }>; + }; +} +``` + +When no accounts are saved, `accounts` is `[]`. + +### `authmux savings --json` + +```ts +{ + ok: true; + data: { + totalSwitches: number; + autoSwitches: number; + rateLimitsAvoided: number; + estimatedMinutesSaved: number; + lastUpdated: string; // ISO-8601 + autoSwitchRatePercent: number; // 0 when totalSwitches === 0 + }; +} +``` + +### `authmux hero --json` + +```ts +{ + ok: true; + data: { + sections: Array<{ + title: string; + items: Array<{ command: string; description: string }>; + }>; + }; +} +``` + +`hero` is the tutorial command; the JSON form is intended for documentation +generators that want the example list without scraping ANSI. + +### `authmux export [dir] --json` + +```ts +{ + ok: true; + data: { + exported: number; + targetDir: string; + files: string[]; + }; +} +``` + +Failure modes: `E_NO_ACCOUNTS` when `~/.codex/accounts/` is missing or +empty. + +### `authmux import [--alias ] [--purge] --json` + +Single-file import: + +```ts +{ + ok: true; + data: { + mode: "file"; + imported: Array<{ + name: string; + action: "imported" | "updated" | "skipped"; + source: string; + reason?: string; + }>; + }; +} +``` + +Directory import: + +```ts +{ + ok: true; + data: { + mode: "directory"; + imported: Array; // same shape as above + dir: string; + total: number; // number of *.json files seen + succeeded: number; // total - skipped + }; +} +``` + +Purge mode (`--purge`): + +```ts +{ + ok: true; + data: { + mode: "purge"; + dir: string; + scanned: number; + rebuilt: number; + includedAuthJson: boolean; + }; +} +``` + +### `authmux parallel --json` + +The `parallel` command bypasses `BaseCommand` (see `01-ARCHITECTURE.md` +§1.3) because it does not touch the Codex registry. The envelope shape is +identical, written manually via `writeJsonEnvelope`. + +```ts +// --add +{ ok: true; data: { action: "add"; profile: string; dir: string; created: boolean } } + +// --remove +{ ok: true; data: { action: "remove"; profile: string; dir: string } } + +// --list (or no flag) +{ + ok: true; + data: { + action: "list"; + profiles: Array<{ name: string; configDir: string }>; + }; +} + +// --aliases +{ ok: true; data: { action: "aliases"; profiles: string[]; aliases: string } } + +// --install +{ ok: true; data: { action: "install"; rc: string; profiles: string[] } } +``` + +### `authmux kiro --json` + +Like `parallel`, `kiro` is provider-specific and bypasses `BaseCommand`. + +```ts +// No args → list +{ + ok: true; + data: { + action: "list"; + accounts: Array<{ name: string; active: boolean }>; + active: string | null; + dataDir: string; + }; +} + +// Positional name → switch +{ ok: true; data: { action: "switch"; active: string; target: string } } + +// --new +{ + ok: true; + data: { + action: "prep-new"; + removed: boolean; + reason?: "no-data-file"; + }; +} +``` + +## Notes on `--json` semantics + +- **stdout is exactly one JSON document.** No banner, no color codes, no + progress chrome. The `src/tests/json-parity.test.ts` snapshot test + enforces this for every command listed above. +- **Prompts are suppressed.** Commands that would normally drop into an + interactive picker (`use`, `kiro`) return `E_PROMPT_CANCELLED` (or the + equivalent) under `--json` instead of hanging. +- **Human-mode output is unchanged.** Existing scripts that grep + human-readable output keep working; the JSON layer is purely additive. +- **Error envelopes are uniform.** Any `AuthmuxError` thrown from a + `--json` command is rendered by `BaseCommand.handleError` (or, for the + two bypass commands, by an explicit `writeJsonEnvelope` call) using the + shape at the top of this document. diff --git a/docs/future/17-ROADMAP.md b/docs/future/17-ROADMAP.md index bcc74998..d5a223c1 100644 --- a/docs/future/17-ROADMAP.md +++ b/docs/future/17-ROADMAP.md @@ -196,10 +196,10 @@ that prints user-facing data (`config`, `daemon --once`, `forecast`, `kiro` without args). **Exit criteria.** -- [ ] Every read command has `--json`. -- [ ] A snapshot test asserts every `--json` output is valid JSON for at +- [x] Every read command has `--json`. +- [x] A snapshot test asserts every `--json` output is valid JSON for at least one fixture per command. -- [ ] `docs/future/02-COMMANDS.md` (when it lands) lists the JSON schema for +- [x] `docs/future/02-COMMANDS.md` (when it lands) lists the JSON schema for each command. **Dependencies.** N3. diff --git a/src/commands/config.ts b/src/commands/config.ts index ac611fe3..43e1f47d 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,5 +1,6 @@ import { Args, Flags } from "@oclif/core"; import { BaseCommand } from "../lib/base-command"; +import { AutoSwitchConfigError, CodexAuthError } from "../lib/accounts"; export default class ConfigCommand extends BaseCommand { static description = "Manage auto-switch and usage API configuration"; @@ -27,11 +28,13 @@ export default class ConfigCommand extends BaseCommand { description: "Set weekly threshold percent (1-100)", required: false, }), + ...BaseCommand.jsonFlag, } as const; async run(): Promise { await this.runSafe(async () => { const { args, flags } = await this.parse(ConfigCommand); + this.setJsonMode(flags); const section = args.section as "auto" | "api"; const action = (args.action as string | undefined)?.toLowerCase(); @@ -53,30 +56,48 @@ export default class ConfigCommand extends BaseCommand { if (action === "enable") { if (hasThresholds) { - this.error("`config auto` cannot mix enable/disable with threshold flags."); + throw new AutoSwitchConfigError( + "`config auto` cannot mix enable/disable with threshold flags.", + ); } const status = await this.accounts.setAutoSwitchEnabled(true); - this.log( - `auto-switch enabled; usage mode: ${status.usageMode === "api" ? "api" : "local-only"}`, + this.emit( + { section: "auto" as const, action: "enable" as const, status }, + (data) => { + this.log( + `auto-switch enabled; usage mode: ${data.status.usageMode === "api" ? "api" : "local-only"}`, + ); + }, ); return; } if (action === "disable") { if (hasThresholds) { - this.error("`config auto` cannot mix enable/disable with threshold flags."); + throw new AutoSwitchConfigError( + "`config auto` cannot mix enable/disable with threshold flags.", + ); } - await this.accounts.setAutoSwitchEnabled(false); - this.log("auto-switch disabled"); + const status = await this.accounts.setAutoSwitchEnabled(false); + this.emit( + { section: "auto" as const, action: "disable" as const, status }, + () => { + this.log("auto-switch disabled"); + }, + ); return; } if (action) { - this.error(`Unknown action \"${action}\" for \`config auto\`.`); + throw new AutoSwitchConfigError( + `Unknown action "${action}" for \`config auto\`.`, + ); } if (!hasThresholds) { - this.error("`config auto` requires `enable`, `disable`, or threshold flags."); + throw new AutoSwitchConfigError( + "`config auto` requires `enable`, `disable`, or threshold flags.", + ); } const status = await this.accounts.configureAutoSwitchThresholds({ @@ -84,17 +105,29 @@ export default class ConfigCommand extends BaseCommand { thresholdWeeklyPercent: thresholdWeekly, }); - this.log( - `auto-switch thresholds updated: 5h<${status.threshold5hPercent}%, weekly<${status.thresholdWeeklyPercent}%`, + this.emit( + { section: "auto" as const, action: "thresholds" as const, status }, + (data) => { + this.log( + `auto-switch thresholds updated: 5h<${data.status.threshold5hPercent}%, weekly<${data.status.thresholdWeeklyPercent}%`, + ); + }, ); } private async handleApiConfig(action: string | undefined): Promise { if (action !== "enable" && action !== "disable") { - this.error("`config api` requires `enable` or `disable`."); + throw new CodexAuthError( + "`config api` requires `enable` or `disable`.", + ); } const status = await this.accounts.setApiUsageEnabled(action === "enable"); - this.log(`usage mode: ${status.usageMode}`); + this.emit( + { section: "api" as const, action: action as "enable" | "disable", status }, + (data) => { + this.log(`usage mode: ${data.status.usageMode}`); + }, + ); } } diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 3513aa7f..f56ac43d 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -1,5 +1,6 @@ import { Flags } from "@oclif/core"; import { BaseCommand } from "../lib/base-command"; +import { CodexAuthError } from "../lib/accounts"; export default class DaemonCommand extends BaseCommand { static description = "Run the background auto-switch daemon"; @@ -13,28 +14,41 @@ export default class DaemonCommand extends BaseCommand { description: "Run one evaluation pass and exit", default: false, }), + ...BaseCommand.jsonFlag, } as const; async run(): Promise { await this.runSafe(async () => { const { flags } = await this.parse(DaemonCommand); + this.setJsonMode(flags); const watch = Boolean(flags.watch); const once = Boolean(flags.once); if (watch === once) { - this.error("`daemon` requires exactly one of `--watch` or `--once`."); + throw new CodexAuthError( + "`daemon` requires exactly one of `--watch` or `--once`.", + ); } if (once) { const result = await this.accounts.runAutoSwitchOnce(); - if (result.switched) { - this.log(`switched: ${result.fromAccount} -> ${result.toAccount}`); - } else { - this.log(`no switch: ${result.reason}`); - } + this.emit(result, (data) => { + if (data.switched) { + this.log(`switched: ${data.fromAccount} -> ${data.toAccount}`); + } else { + this.log(`no switch: ${data.reason}`); + } + }); return; } + // Watch mode streams events over time; X3 will land --json streaming. + // For X4 we leave it on the human path only. + if (this.jsonMode) { + throw new CodexAuthError( + "`daemon --watch --json` is not yet supported. Use `daemon --once --json`.", + ); + } await this.accounts.runDaemon("watch"); }); } diff --git a/src/commands/export.ts b/src/commands/export.ts index cc24b4f1..f2a24a80 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -1,34 +1,58 @@ -import { Args, Command } from "@oclif/core"; +import { Args } from "@oclif/core"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import { BaseCommand } from "../lib/base-command"; +import { CodexAuthError } from "../lib/accounts"; const ACCOUNTS_DIR = path.join(os.homedir(), ".codex", "accounts"); -export default class Export extends Command { +export default class Export extends BaseCommand { static description = "Export stored account auth files to a directory"; static args = { dir: Args.string({ description: "Target directory (default: ./agent-auth-export/)" }), } as const; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + + // Read-snapshot-files command; no need to resync ~/.codex/auth.json. + protected readonly syncExternalAuthBeforeRun = false; + async run(): Promise { - const { args } = await this.parse(Export); + const { args, flags } = await this.parse(Export); + this.setJsonMode(flags); const targetDir = args.dir || path.join(process.cwd(), "agent-auth-export"); - if (!fs.existsSync(ACCOUNTS_DIR)) { - this.error("No accounts found in ~/.codex/accounts/"); - } + await this.runSafe(async () => { + if (!fs.existsSync(ACCOUNTS_DIR)) { + throw new CodexAuthError( + "No accounts found in ~/.codex/accounts/", + "E_NO_ACCOUNTS", + ); + } + + const files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json")); + if (!files.length) { + throw new CodexAuthError( + "No account snapshots to export.", + "E_NO_ACCOUNTS", + ); + } - const files = fs.readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith(".json")); - if (!files.length) { - this.error("No account snapshots to export."); - } + fs.mkdirSync(targetDir, { recursive: true }); + for (const file of files) { + fs.copyFileSync(path.join(ACCOUNTS_DIR, file), path.join(targetDir, file)); + } - fs.mkdirSync(targetDir, { recursive: true }); - for (const file of files) { - fs.copyFileSync(path.join(ACCOUNTS_DIR, file), path.join(targetDir, file)); - } - this.log(`Exported ${files.length} accounts to ${targetDir}`); + this.emit( + { exported: files.length, targetDir, files }, + (data) => { + this.log(`Exported ${data.exported} accounts to ${data.targetDir}`); + }, + ); + }); } } diff --git a/src/commands/forecast.ts b/src/commands/forecast.ts index 0abe64db..f39f642c 100644 --- a/src/commands/forecast.ts +++ b/src/commands/forecast.ts @@ -1,25 +1,41 @@ -import { Command } from "@oclif/core"; +import { BaseCommand } from "../lib/base-command"; import { AccountService } from "../lib/accounts/account-service.js"; import { forecastAccounts } from "../lib/account-health.js"; -export default class Forecast extends Command { +export default class Forecast extends BaseCommand { static description = "Show health forecast for all saved accounts (best-first)"; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + + // Forecast does not require the codex auth snapshot sync; it only reads + // the per-account health/circuit state stored in ~/.codex/multi-auth. + protected readonly syncExternalAuthBeforeRun = false; + async run(): Promise { - const service = new AccountService(); - const names = await service.listAccountNames(); + const { flags } = await this.parse(Forecast); + this.setJsonMode(flags); - if (!names.length) { - this.log("No saved accounts found."); - return; - } + await this.runSafe(async () => { + const service = new AccountService(); + const names = await service.listAccountNames(); + const forecasts = names.length ? forecastAccounts(names) : []; - const forecasts = forecastAccounts(names); - this.log("Account Health Forecast (best first):\n"); - for (let i = 0; i < forecasts.length; i++) { - const h = forecasts[i]; - const status = h.usable ? "✓" : "✗"; - this.log(` [${i + 1}] ${status} ${h.name}: score=${Math.round(h.score)} circuit=${h.circuitState} tokens=${Math.round(h.tokensAvailable)}`); - } + this.emit({ accounts: forecasts }, (data) => { + if (!data.accounts.length) { + this.log("No saved accounts found."); + return; + } + this.log("Account Health Forecast (best first):\n"); + for (let i = 0; i < data.accounts.length; i++) { + const h = data.accounts[i]; + const status = h.usable ? "✓" : "✗"; + this.log( + ` [${i + 1}] ${status} ${h.name}: score=${Math.round(h.score)} circuit=${h.circuitState} tokens=${Math.round(h.tokensAvailable)}`, + ); + } + }); + }); } } diff --git a/src/commands/hero.ts b/src/commands/hero.ts index b413b23c..20edc35e 100644 --- a/src/commands/hero.ts +++ b/src/commands/hero.ts @@ -1,11 +1,66 @@ -import { Command } from "@oclif/core"; +import { BaseCommand } from "../lib/base-command"; -export default class Hero extends Command { +interface HeroSection { + title: string; + items: Array<{ command: string; description: string }>; +} + +const HERO_SECTIONS: HeroSection[] = [ + { + title: "Quick Start", + items: [ + { command: "agent-auth save work", description: "Snapshot current session" }, + { command: "agent-auth login personal", description: "Login + save in one step" }, + { command: "agent-auth use work", description: "Switch active account" }, + { command: "agent-auth use", description: "Interactive picker" }, + { command: "agent-auth list", description: "All accounts + usage %" }, + ], + }, + { + title: "Parallel Claude Code", + items: [ + { command: "agent-auth parallel --add work", description: "" }, + { command: "agent-auth parallel --add personal", description: "" }, + { command: "agent-auth parallel --install", description: "" }, + ], + }, + { + title: "Kiro CLI", + items: [ + { command: "agent-auth kiro", description: "Switch Kiro accounts" }, + { command: "agent-auth kiro-login", description: "Add new Kiro account" }, + ], + }, + { + title: "More", + items: [ + { command: "agent-auth config", description: "Auto-switch thresholds" }, + { command: "agent-auth status", description: "Service & usage status" }, + { command: "agent-auth remove", description: "Delete saved accounts" }, + { command: "agent-auth update", description: "Check for new version" }, + { command: "agent-auth --help", description: "Full command reference" }, + ], + }, +]; + +export default class Hero extends BaseCommand { static description = "Show usage tutorial and quick-start guide"; static hidden = true; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + + // Hero is a printed tutorial; no auth snapshot sync required. + protected readonly syncExternalAuthBeforeRun = false; + async run(): Promise { - this.log(` + const { flags } = await this.parse(Hero); + this.setJsonMode(flags); + + await this.runSafe(async () => { + this.emit({ sections: HERO_SECTIONS }, () => { + this.log(` \x1b[1m\x1b[36m╭─────────────────────────────────────────────────────╮\x1b[0m \x1b[1m\x1b[36m│\x1b[0m 🔐 \x1b[1magent-auth\x1b[0m \x1b[1m\x1b[36m│\x1b[0m \x1b[1m\x1b[36m│\x1b[0m Multi-account manager for AI CLI agents \x1b[1m\x1b[36m│\x1b[0m @@ -40,5 +95,7 @@ export default class Hero extends Command { \x1b[33m$\x1b[0m agent-auth update \x1b[2mCheck for new version\x1b[0m \x1b[33m$\x1b[0m agent-auth --help \x1b[2mFull command reference\x1b[0m `); + }); + }); } } diff --git a/src/commands/import.ts b/src/commands/import.ts index d39ddc6e..7a104965 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -1,11 +1,20 @@ -import { Args, Flags, Command } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import { BaseCommand } from "../lib/base-command"; +import { CodexAuthError } from "../lib/accounts"; const ACCOUNTS_DIR = path.join(os.homedir(), ".codex", "accounts"); -export default class Import extends Command { +interface ImportedAccount { + name: string; + action: "imported" | "updated" | "skipped"; + source: string; + reason?: string; +} + +export default class Import extends BaseCommand { static description = "Import auth file(s) into managed accounts (single file, directory, or --purge to rebuild registry)"; static args = { @@ -15,81 +24,127 @@ export default class Import extends Command { static flags = { alias: Flags.string({ description: "Alias name for the imported account (single file only)" }), purge: Flags.boolean({ description: "Rebuild registry from existing auth snapshots in ~/.codex/accounts/" }), + ...BaseCommand.jsonFlag, } as const; + // Import writes snapshot files directly; do not let the base sync clobber + // any in-flight state before the import runs. + protected readonly syncExternalAuthBeforeRun = false; + async run(): Promise { const { args, flags } = await this.parse(Import); + this.setJsonMode(flags); - if (flags.purge) { - this.purgeRebuild(args.path); - return; - } - - const target = args.path; - if (!target) { - this.error("Provide a path to an auth JSON file or directory."); - } + await this.runSafe(async () => { + if (flags.purge) { + this.purgeRebuild(args.path); + return; + } - const stat = fs.statSync(target, { throwIfNoEntry: false }); - if (!stat) { - this.error(`Path not found: ${target}`); - } + const target = args.path; + if (!target) { + throw new CodexAuthError( + "Provide a path to an auth JSON file or directory.", + ); + } - fs.mkdirSync(ACCOUNTS_DIR, { recursive: true }); + const stat = fs.statSync(target, { throwIfNoEntry: false }); + if (!stat) { + throw new CodexAuthError(`Path not found: ${target}`); + } - if (stat.isDirectory()) { - this.importDirectory(target); - } else { - this.importFile(target, flags.alias); - } + fs.mkdirSync(ACCOUNTS_DIR, { recursive: true }); + + if (stat.isDirectory()) { + this.importDirectory(target); + } else { + const record = this.importFile(target, flags.alias); + this.emit( + { mode: "file" as const, imported: [record] }, + (data) => { + for (const r of data.imported) { + if (r.action === "imported") this.log(` Imported: ${r.name}`); + else if (r.action === "updated") this.log(` Updated: ${r.name}`); + } + }, + ); + } + }); } - private importFile(filePath: string, alias?: string): void { + private importFile(filePath: string, alias?: string): ImportedAccount { const raw = fs.readFileSync(filePath, "utf-8"); let parsed: Record; try { parsed = JSON.parse(raw); } catch { - this.error(`Malformed JSON: ${filePath}`); - return; + throw new CodexAuthError(`Malformed JSON: ${filePath}`); } const name = alias || this.extractName(parsed, filePath); const dest = path.join(ACCOUNTS_DIR, `${name}.json`); - - if (fs.existsSync(dest)) { - this.log(` Updated: ${name}`); - } else { - this.log(` Imported: ${name}`); - } + const existed = fs.existsSync(dest); fs.writeFileSync(dest, raw); + return { + name, + action: existed ? "updated" : "imported", + source: filePath, + }; } private importDirectory(dirPath: string): void { const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".json")); + const imported: ImportedAccount[] = []; + if (!files.length) { - this.log(`No .json files found in ${dirPath}`); + this.emit( + { mode: "directory" as const, imported, dir: dirPath, total: 0 }, + () => { + this.log(`No .json files found in ${dirPath}`); + }, + ); return; } - let count = 0; + for (const file of files) { try { - this.importFile(path.join(dirPath, file)); - count++; + imported.push(this.importFile(path.join(dirPath, file))); } catch (err) { - this.warn(`Skipped ${file}: ${err}`); + imported.push({ + name: file, + action: "skipped", + source: path.join(dirPath, file), + reason: String(err), + }); } } - this.log(`\nImported ${count}/${files.length} files.`); + + const succeeded = imported.filter((r) => r.action !== "skipped").length; + this.emit( + { + mode: "directory" as const, + imported, + dir: dirPath, + total: files.length, + succeeded, + }, + (data) => { + for (const r of data.imported) { + if (r.action === "imported") this.log(` Imported: ${r.name}`); + else if (r.action === "updated") this.log(` Updated: ${r.name}`); + else if (r.action === "skipped") this.warn(`Skipped ${r.name}: ${r.reason}`); + } + this.log(`\nImported ${data.succeeded}/${data.total} files.`); + }, + ); } private purgeRebuild(scanPath?: string): void { const dir = scanPath || ACCOUNTS_DIR; if (!fs.existsSync(dir)) { - this.error(`Directory not found: ${dir}`); + throw new CodexAuthError(`Directory not found: ${dir}`); } const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); - this.log(`Rebuilding registry from ${files.length} files in ${dir}...`); // Re-import each file to ensure registry consistency fs.mkdirSync(ACCOUNTS_DIR, { recursive: true }); @@ -105,13 +160,27 @@ export default class Import extends Command { // Also import current auth.json if it exists const authJson = path.join(os.homedir(), ".codex", "auth.json"); + let includedAuthJson = false; if (fs.existsSync(authJson) && !fs.lstatSync(authJson).isSymbolicLink()) { const dest = path.join(ACCOUNTS_DIR, "current.json"); fs.copyFileSync(authJson, dest); count++; + includedAuthJson = true; } - this.log(`Registry rebuilt: ${count} accounts.`); + this.emit( + { + mode: "purge" as const, + dir, + scanned: files.length, + rebuilt: count, + includedAuthJson, + }, + (data) => { + this.log(`Rebuilding registry from ${data.scanned} files in ${data.dir}...`); + this.log(`Registry rebuilt: ${data.rebuilt} accounts.`); + }, + ); } private extractName(parsed: Record, filePath: string): string { diff --git a/src/commands/kiro.ts b/src/commands/kiro.ts index 3a1b397f..f5522581 100644 --- a/src/commands/kiro.ts +++ b/src/commands/kiro.ts @@ -1,8 +1,17 @@ +// Theme X4 divergence: `kiro` bypasses BaseCommand per +// `01-ARCHITECTURE.md` §1.3 (provider-specific; no Codex auth coupling). We +// wire --json manually using `json-envelope.ts` to keep the wire shape +// consistent with BaseCommand commands. + import { Command, Flags } from "@oclif/core"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import * as readline from "node:readline"; +import { + jsonSuccess, + writeJsonEnvelope, +} from "../lib/cli/json-envelope"; const DATA_DIR = path.join(os.homedir(), ".local/share/kiro-cli"); const DATA_FILE = path.join(DATA_DIR, "data.sqlite3"); @@ -29,13 +38,20 @@ export default class KiroSwitch extends Command { static flags = { new: Flags.boolean({ description: "Remove symlink to prep for a new kiro-cli login" }), + json: Flags.boolean({ + description: "Emit a single JSON envelope to stdout (Theme X4).", + default: false, + }), } as const; static args = {} as const; static strict = false; + private jsonMode = false; + async run(): Promise { const { flags, argv } = await this.parse(KiroSwitch); + this.jsonMode = Boolean(flags.json); if (flags.new) { this.prepNew(); @@ -49,13 +65,25 @@ export default class KiroSwitch extends Command { return; } - // Interactive pick + // No-arg invocation: list accounts (+ active marker). In JSON mode this + // is purely a read; in human mode it falls back to the interactive picker. const accounts = getAccounts(); + const active = this.getActive(); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "list" as const, + accounts: accounts.map((name) => ({ name, active: name === active })), + active: active ?? null, + dataDir: DATA_DIR, + })); + return; + } + if (!accounts.length) { this.error(`No Kiro account snapshots in ${DATA_DIR}. Run: agent-auth kiro-login`); } - const active = this.getActive(); this.log("Kiro accounts:\n"); for (let i = 0; i < accounts.length; i++) { const mark = accounts[i] === active ? " *" : ""; @@ -90,17 +118,41 @@ export default class KiroSwitch extends Command { fs.symlinkSync(target, DATA_FILE); fs.mkdirSync(SWITCHER_DIR, { recursive: true }); fs.writeFileSync(ACTIVE_FILE, name); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "switch" as const, + active: name, + target, + })); + return; + } this.log(`Switched Kiro to: ${name}`); } private prepNew(): void { if (!fs.existsSync(DATA_FILE)) { + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "prep-new" as const, + removed: false, + reason: "no-data-file", + })); + return; + } this.log("No data.sqlite3 to remove. Run: kiro-cli login"); return; } const stat = fs.lstatSync(DATA_FILE); if (stat.isSymbolicLink()) { fs.unlinkSync(DATA_FILE); + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "prep-new" as const, + removed: true, + })); + return; + } this.log("Removed symlink. Now run: agent-auth kiro-login"); } else { this.error(`${DATA_FILE} is a regular file. Run: agent-auth kiro-login --name `); diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index 2a7f134f..89bf59d1 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -1,7 +1,16 @@ +// Theme X4 divergence: `parallel` bypasses BaseCommand per +// `01-ARCHITECTURE.md` §1.3 (no auth/registry coupling). We wire --json +// manually using `json-envelope.ts` to keep the on-the-wire shape consistent +// with BaseCommand commands. + import { Command, Flags } from "@oclif/core"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import { + jsonSuccess, + writeJsonEnvelope, +} from "../lib/cli/json-envelope"; const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts"); @@ -28,6 +37,10 @@ export default class ClaudeParallel extends Command { aliases: Flags.boolean({ description: "Print shell aliases for all profiles" }), install: Flags.boolean({ description: "Install aliases into shell rc file" }), list: Flags.boolean({ char: "l", description: "List profiles" }), + json: Flags.boolean({ + description: "Emit a single JSON envelope to stdout (Theme X4).", + default: false, + }), } as const; static examples = [ @@ -38,8 +51,11 @@ export default class ClaudeParallel extends Command { "agent-auth parallel --install", ]; + private jsonMode = false; + async run(): Promise { const { flags } = await this.parse(ClaudeParallel); + this.jsonMode = Boolean(flags.json); if (flags.add) { this.addProfile(flags.add); @@ -56,11 +72,25 @@ export default class ClaudeParallel extends Command { private addProfile(name: string): void { const dir = path.join(CLAUDE_PARALLEL_DIR, name); - if (fs.existsSync(dir)) { + const existed = fs.existsSync(dir); + if (!existed) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "add" as const, + profile: name, + dir, + created: !existed, + })); + return; + } + + if (existed) { this.log(`Profile "${name}" already exists at ${dir}`); return; } - fs.mkdirSync(dir, { recursive: true }); this.log(`Created profile: ${name}`); this.log(` Config dir: ${dir}`); this.log(` Run: CLAUDE_CONFIG_DIR=${dir} claude`); @@ -73,19 +103,41 @@ export default class ClaudeParallel extends Command { this.error(`Profile "${name}" not found.`); } fs.rmSync(dir, { recursive: true }); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "remove" as const, + profile: name, + dir, + })); + return; + } this.log(`Removed profile: ${name}`); } private listProfiles(): void { const profiles = getProfiles(); + const entries = profiles.map((p) => ({ + name: p, + configDir: path.join(CLAUDE_PARALLEL_DIR, p), + })); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "list" as const, + profiles: entries, + })); + return; + } + if (!profiles.length) { this.log("No Claude Code parallel profiles configured."); this.log("Add one: agent-auth parallel --add "); return; } this.log("Claude Code parallel profiles:\n"); - for (const p of profiles) { - this.log(` • ${p} → ${path.join(CLAUDE_PARALLEL_DIR, p)}`); + for (const p of entries) { + this.log(` • ${p.name} → ${p.configDir}`); } this.log(`\nRun any profile: claude- (after installing aliases)`); } @@ -104,6 +156,17 @@ export default class ClaudeParallel extends Command { private printAliases(): void { const aliases = this.generateAliases(); + const profiles = getProfiles(); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "aliases" as const, + profiles, + aliases, + })); + return; + } + if (!aliases) { this.log("No profiles. Add one first: agent-auth parallel --add "); return; @@ -134,6 +197,15 @@ export default class ClaudeParallel extends Command { content = content.trimEnd() + "\n\n" + block + "\n"; fs.writeFileSync(rc, content); + + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "install" as const, + rc, + profiles, + })); + return; + } this.log(`Installed aliases in ${rc}`); this.log(`Run: source ${rc}`); this.log(`\nAvailable commands:`); diff --git a/src/commands/savings.ts b/src/commands/savings.ts index c21b48a5..775fdfef 100644 --- a/src/commands/savings.ts +++ b/src/commands/savings.ts @@ -1,22 +1,36 @@ -import { Command } from "@oclif/core"; +import { BaseCommand } from "../lib/base-command"; import { getSavingsReport } from "../lib/account-savings.js"; -export default class Savings extends Command { +export default class Savings extends BaseCommand { static description = "Show account rotation savings and efficiency stats"; + static flags = { + ...BaseCommand.jsonFlag, + } as const; + + // Read-only ledger; no auth snapshot sync required. + protected readonly syncExternalAuthBeforeRun = false; + async run(): Promise { - const s = getSavingsReport(); + const { flags } = await this.parse(Savings); + this.setJsonMode(flags); - this.log("Account Rotation Savings:\n"); - this.log(` Total switches: ${s.totalSwitches}`); - this.log(` Auto-switches: ${s.autoSwitches}`); - this.log(` Rate limits avoided: ${s.rateLimitsAvoided}`); - this.log(` Cooldown saved: ~${s.estimatedMinutesSaved} minutes`); - this.log(` Last updated: ${s.lastUpdated}`); + await this.runSafe(async () => { + const s = getSavingsReport(); + const autoRate = + s.totalSwitches > 0 ? Math.round((s.autoSwitches / s.totalSwitches) * 100) : 0; - if (s.totalSwitches > 0) { - const autoRate = Math.round((s.autoSwitches / s.totalSwitches) * 100); - this.log(`\n Auto-switch rate: ${autoRate}%`); - } + this.emit({ ...s, autoSwitchRatePercent: autoRate }, (data) => { + this.log("Account Rotation Savings:\n"); + this.log(` Total switches: ${data.totalSwitches}`); + this.log(` Auto-switches: ${data.autoSwitches}`); + this.log(` Rate limits avoided: ${data.rateLimitsAvoided}`); + this.log(` Cooldown saved: ~${data.estimatedMinutesSaved} minutes`); + this.log(` Last updated: ${data.lastUpdated}`); + if (data.totalSwitches > 0) { + this.log(`\n Auto-switch rate: ${data.autoSwitchRatePercent}%`); + } + }); + }); } } diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts new file mode 100644 index 00000000..348b0b59 --- /dev/null +++ b/src/tests/json-parity.test.ts @@ -0,0 +1,252 @@ +// Theme X4 — snapshot guard for `--json` parity. +// +// Spawns the built CLI (dist/index.js) for each command that ships under +// X4's exit-criteria list, with a fresh ~/.codex sandbox, and asserts: +// +// 1. stdout is exactly one valid JSON document +// 2. The top-level shape is `{ ok: true, data: ... }` OR +// `{ ok: false, error: { code, severity, message } }` +// 3. No banner / colors / prompt chrome leaks into stdout +// +// This protects every X4 command from regressing back to mixed plaintext. +// Per the spec, some commands need a fake `~/.codex` to run cleanly — we +// use `CODEX_AUTH_CODEX_DIR` + `HOME` overrides for that. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// This test file is compiled to CommonJS (see tsconfig.json:module=Node16 +// without a top-level `"type": "module"` in package.json). `__dirname` is +// therefore available natively. +// dist/tests/json-parity.test.js → dist/index.js +const CLI_ENTRY = path.resolve(__dirname, "..", "index.js"); + +interface CommandCase { + // Human-readable id for the test name + result table. + name: string; + // argv passed after `node dist/index.js`. + argv: string[]; + // If true, we expect `{ ok: true, data: ... }`. If false, error envelope. + expectOk: boolean; + // Optional payload shape assertion run when `ok` matches. + assertPayload?: (data: unknown) => void; + // Optional error-envelope assertion (only used when expectOk = false). + assertError?: (err: { + code: string; + severity: string; + message: string; + }) => void; +} + +const CASES: CommandCase[] = [ + { + name: "config (no args is a usage error)", + argv: ["config", "auto", "--json"], + expectOk: false, + assertError: (err) => { + assert.equal(err.code, "E_AUTOSWITCH_CONFIG"); + }, + }, + { + name: "daemon --once", + argv: ["daemon", "--once", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { switched: boolean; reason: string }; + assert.equal(typeof d.switched, "boolean"); + assert.equal(typeof d.reason, "string"); + }, + }, + { + name: "forecast (no accounts → empty list)", + argv: ["forecast", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { accounts: unknown[] }; + assert.ok(Array.isArray(d.accounts)); + }, + }, + { + name: "savings (fresh ledger)", + argv: ["savings", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { + totalSwitches: number; + autoSwitches: number; + rateLimitsAvoided: number; + estimatedMinutesSaved: number; + lastUpdated: string; + autoSwitchRatePercent: number; + }; + assert.equal(typeof d.totalSwitches, "number"); + assert.equal(typeof d.autoSwitches, "number"); + assert.equal(typeof d.rateLimitsAvoided, "number"); + assert.equal(typeof d.estimatedMinutesSaved, "number"); + assert.equal(typeof d.lastUpdated, "string"); + assert.equal(typeof d.autoSwitchRatePercent, "number"); + }, + }, + { + name: "hero", + argv: ["hero", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { sections: Array<{ title: string; items: unknown[] }> }; + assert.ok(Array.isArray(d.sections)); + assert.ok(d.sections.length > 0); + for (const section of d.sections) { + assert.equal(typeof section.title, "string"); + assert.ok(Array.isArray(section.items)); + } + }, + }, + { + name: "export (no accounts dir → error envelope)", + argv: ["export", "--json"], + expectOk: false, + assertError: (err) => { + assert.equal(err.code, "E_NO_ACCOUNTS"); + }, + }, + { + name: "import (missing path → error envelope)", + argv: ["import", "--json"], + expectOk: false, + assertError: (err) => { + assert.equal(err.code, "E_AUTH_INVALID"); + assert.match(err.message, /Provide a path/); + }, + }, + { + name: "parallel --list", + argv: ["parallel", "--list", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { action: string; profiles: unknown[] }; + assert.equal(d.action, "list"); + assert.ok(Array.isArray(d.profiles)); + }, + }, + { + name: "kiro (no args → list)", + argv: ["kiro", "--json"], + expectOk: true, + assertPayload: (data) => { + const d = data as { + action: string; + accounts: unknown[]; + active: string | null; + }; + assert.equal(d.action, "list"); + assert.ok(Array.isArray(d.accounts)); + assert.ok(d.active === null || typeof d.active === "string"); + }, + }, +]; + +async function withSandbox(fn: (env: NodeJS.ProcessEnv) => Promise): Promise { + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), "authmux-x4-")); + const codexDir = path.join(tempHome, ".codex"); + await fsp.mkdir(codexDir, { recursive: true }); + + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tempHome, + CODEX_AUTH_CODEX_DIR: codexDir, + // Make oclif treat stdout as non-TTY so the update-notifier hook stays + // quiet and no color codes leak into stdout. + NO_COLOR: "1", + CI: "1", + }; + + try { + return await fn(env); + } finally { + await fsp.rm(tempHome, { recursive: true, force: true }); + } +} + +function runCli(argv: string[], env: NodeJS.ProcessEnv): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [CLI_ENTRY, ...argv], { + env, + encoding: "utf-8", + timeout: 15_000, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + }; +} + +for (const tc of CASES) { + test(`--json parity: ${tc.name}`, async () => { + await withSandbox(async (env) => { + const out = runCli(tc.argv, env); + + // stdout must be exactly one valid JSON document. We parse the trimmed + // string; anything else (banner, color codes, prompt chrome) would + // break parse. + const trimmed = out.stdout.trim(); + assert.ok( + trimmed.length > 0, + `stdout empty for ${tc.argv.join(" ")}; stderr=${out.stderr}`, + ); + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + assert.fail( + `stdout for ${tc.argv.join(" ")} is not valid JSON: ${(err as Error).message}\n` + + `--- stdout ---\n${out.stdout}\n--- stderr ---\n${out.stderr}`, + ); + } + + // Top-level shape. + assert.equal(typeof parsed, "object"); + assert.notEqual(parsed, null); + const env_ = parsed as { ok: unknown }; + assert.equal(typeof env_.ok, "boolean"); + + if (tc.expectOk) { + assert.equal( + env_.ok, + true, + `expected ok=true for ${tc.argv.join(" ")}, got ${JSON.stringify(parsed)}`, + ); + const data = (parsed as { data: unknown }).data; + assert.notEqual(data, undefined, "ok envelope must carry a `data` field"); + if (tc.assertPayload) tc.assertPayload(data); + } else { + assert.equal( + env_.ok, + false, + `expected ok=false for ${tc.argv.join(" ")}, got ${JSON.stringify(parsed)}`, + ); + const error = (parsed as { error: { code: string; severity: string; message: string } }).error; + assert.equal(typeof error.code, "string"); + assert.equal(typeof error.severity, "string"); + assert.equal(typeof error.message, "string"); + if (tc.assertError) tc.assertError(error); + } + + // Also assert that the trimmed stdout is exactly one document — no + // trailing extra lines, no leading banner. JSON.parse already enforced + // it's a complete object; this checks no extra characters either side. + const reSerialised = JSON.stringify(parsed); + // The CLI writes a trailing newline; we already trimmed. The trimmed + // value should now match the canonical re-serialisation. + assert.equal( + trimmed, + reSerialised, + `stdout for ${tc.argv.join(" ")} carries extra chrome around the JSON envelope`, + ); + }); + }); +}