From 2be9b336fef00b77986fcb56a0292de05f7764d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 23 May 2026 18:26:12 +0000 Subject: [PATCH] Add gh-style --json CLI mode for LLM agents Introduce machine-readable output for generate and doctor commands with explicit action flags (--commit, --push, --adjust, --dry-run, --yes). Interactive Clack flow remains the default when --json is not set. Co-authored-by: Rafael Ricco --- AGENTS.md | 89 +++++++++++++++ README.md | 13 +++ index.ts | 4 +- src/cli/commit.ts | 180 +++++++++++++++++++++++++++-- src/cli/doctor.ts | 202 +++++++++++++++++++++++---------- src/cli/generate-flags.ts | 138 ++++++++++++++++++++++ src/cli/json-io.ts | 175 ++++++++++++++++++++++++++++ src/cli/parser.ts | 97 +++++++++++----- src/infra/ui/spinner.ts | 12 +- test/cli/commit.test.ts | 3 +- test/cli/doctor-json.test.ts | 51 +++++++++ test/cli/doctor.test.ts | 2 +- test/cli/generate-json.test.ts | 125 ++++++++++++++++++++ test/cli/parser.test.ts | 50 +++++++- 14 files changed, 1037 insertions(+), 104 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/cli/generate-flags.ts create mode 100644 src/cli/json-io.ts create mode 100644 test/cli/doctor-json.test.ts create mode 100644 test/cli/generate-json.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2cdea20 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# commit-tools — agent integration + +This document describes the machine-readable CLI contract for LLM agents and automation. + +## Commands + +| Intent | Command | +|--------|---------| +| Generate message | `commit generate --json` | +| Generate + commit | `commit generate --json --commit` | +| Generate + commit + push | `commit generate --json --commit --push` | +| Refine + preview | `commit generate --json --adjust "shorter subject" --dry-run` | +| Health check | `commit doctor --json` | + +Flags can appear without the `generate` subcommand when they start with `--json`: + +```bash +commit --json --commit +``` + +## stdout / stderr + +- **Success:** one JSON object on **stdout** (no extra lines, no ANSI). +- **Failure:** `{ "ok": false, "error": { "code", "message" } }` on **stderr**, exit code `1`. +- Progress spinners are suppressed in `--json` mode. + +## Generate flags + +| Flag | Requires | Effect | +|------|----------|--------| +| `--json` | — | Enable machine mode (no interactive prompts) | +| `--adjust ` | `--json` | Refine the message once before output/git actions | +| `--dry-run` | `--json` | Run LLM only; do not commit or push | +| `--commit` | `--json` | Commit with the generated message | +| `--push` | `--json`, `--commit` | Push after commit | +| `--yes` | `--push` | Publish branch or force-with-lease without confirm | + +## Error codes + +| Code | Meaning | +|------|---------| +| `INVALID_FLAGS` | Invalid or incompatible flag combination | +| `NOT_GIT_REPO` | Not inside a git repository | +| `NO_STAGED_CHANGES` | No staged changes to commit | +| `NO_CONFIG` | Missing config (run `commit setup` first) | +| `AUTH_FAILED` | Provider authentication failed | +| `LLM_ERROR` | LLM provider error | +| `PUSH_NO_UPSTREAM` | No upstream branch (use `--yes` or `git push -u`) | +| `PUSH_REJECTED` | Non-fast-forward push rejected (use `--yes` for force-with-lease) | + +## generate success JSON + +```json +{ + "ok": true, + "command": "generate", + "message": "feat: example", + "actions": { + "adjusted": false, + "committed": false, + "pushed": false, + "dryRun": false + }, + "metadata": { + "durationMs": 1200, + "model": { "provider": "openai", "model": "gpt-4.1-mini", "effort": "medium" }, + "tokens": null + }, + "commit": null +} +``` + +After `--commit`, `commit` contains hash, short, subject, authorName, authorEmail, and date (ISO 8601). + +## doctor success JSON + +```json +{ + "ok": true, + "command": "doctor", + "checks": [ + { "name": "CLI Version", "status": "ok", "level": "ok", "info": "@rafaeelricco/commit-tools 0.x" } + ], + "ready": true, + "elapsedMs": 42 +} +``` + +Schema definitions live in `src/cli/json-io.ts`. diff --git a/README.md b/README.md index 7f60f4d..4ab03a5 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,19 @@ NO_UPDATE_NOTIFIER=true The banner is also suppressed automatically in non-interactive shells and when `CI=true`. +## Agent / CI usage + +For LLM agents and scripts, use `--json` for structured output on stdout: + +```bash +commit generate --json | jq -r '.message' +commit generate --json --commit +commit generate --json --commit --push +commit doctor --json | jq '.ready' +``` + +Automation flags (`--commit`, `--push`, `--adjust`, `--dry-run`, `--yes`) require `--json`. See [AGENTS.md](AGENTS.md) for the full contract. + ## Commands To see all available commands at any time, run: diff --git a/index.ts b/index.ts index 3eba024..d1a347b 100755 --- a/index.ts +++ b/index.ts @@ -26,11 +26,11 @@ const main = () => { switch (command.type) { case "generate": - return Commit.create().chain((c) => c.run()); + return Commit.create(command.mode).chain((c) => c.run(command.mode)); case "setup": return Setup.create().chain((s) => s.run()); case "doctor": - return Doctor.create().run(); + return Doctor.create().run({ json: command.json }); case "model": return ModelCommand.create().chain((m) => m.run()); case "effort": diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 88042c8..5c9b330 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -13,6 +13,8 @@ import { generateCommitMessage, refineCommitMessage, type GeneratedContent, type import { Nothing, type Maybe, Just } from "@/libs/maybe"; import { loading } from "@/infra/ui/spinner"; import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; +import type { GenerateRunMode } from "@/cli/generate-flags"; +import { CliError, cliError, buildGenerateSuccess, writeGenerateSuccess, writeCliFailure } from "@/cli/json-io"; import color from "picocolors"; import { isNonFastForwardError } from "@/cli/commit-errors"; @@ -20,44 +22,206 @@ import { isNonFastForwardError } from "@/cli/commit-errors"; const USER_ACTIONS = ["commit_push", "commit", "regenerate", "adjust", "cancel"] as const; type UserAction = (typeof USER_ACTIONS)[number]; +type JsonRunMode = Extract; + class Commit { private constructor( private readonly config: Config, private readonly providerConfig: ProviderConfig ) {} - static create(): Future { + static create(mode: GenerateRunMode): Future { + const setupOnMissing = mode.type === "interactive"; + return loadConfig() .chainRej((): Future => { + if (!setupOnMissing) { + return Future.reject(cliError("NO_CONFIG", "No configuration found. Run 'commit setup' first.")); + } p.log.warn(color.yellow("No configuration found. Let's set you up first.")); return Setup.create() .chain((s) => s.run()) .chain(() => loadConfig()); }) - .chain((config) => resolveProvider(config).map((ai) => new Commit(config, ai))); + .chain((config) => + resolveProvider(config) + .mapRej((e) => cliError("AUTH_FAILED", e.message)) + .map((ai) => new Commit(config, ai)) + ); } - run(): Future { + run(mode: GenerateRunMode): Future { + switch (mode.type) { + case "interactive": + return this.runInteractive(); + case "json": + return this.runJson(mode); + default: { + const _exhaustive: never = mode; + return Future.reject(new Error(`Unknown run mode: ${JSON.stringify(_exhaustive)}`)); + } + } + } + + private runInteractive(): Future { return repo .checkIsGitRepo() .chain(() => this.diff()) - .chain((diff) => this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message))) + .chain((diff) => + this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message)) + ) .mapRej((e) => { p.log.error(color.red(e.message)); return e; }); } + private runJson(mode: JsonRunMode): Future { + return repo + .checkIsGitRepo() + .chain(() => this.diff()) + .chain((diff) => this.generateContentForJson(mode, diff)) + .chain((content) => this.applyJsonGitActions(mode, content)) + .map((payload) => writeGenerateSuccess(payload)) + .mapRej((e) => this.failJson(e)); + } + + private generateContentForJson(mode: JsonRunMode, diff: string): Future { + const silent = { silent: true }; + return this.generate(diff, this.config.commit_convention, this.config.custom_template, silent).chain((content) => + mode.adjust instanceof Just ? this.refine(content.text, mode.adjust.value, diff, silent) : Future.resolve(content) + ); + } + + private applyJsonGitActions(mode: JsonRunMode, content: GeneratedContent): Future> { + const adjusted = mode.adjust instanceof Just; + const dryRun = mode.dryRun; + + if (dryRun) { + return Future.resolve( + buildGenerateSuccess({ + message: content.text, + metadata: content.metadata, + adjusted, + committed: false, + pushed: false, + dryRun: true, + commit: Nothing() + }) + ); + } + + switch (mode.git.type) { + case "none": + return Future.resolve( + buildGenerateSuccess({ + message: content.text, + metadata: content.metadata, + adjusted, + committed: false, + pushed: false, + dryRun: false, + commit: Nothing() + }) + ); + case "commit": + return this.commit(content.text).chain(() => + repo.findCommitMetadata().map((commit) => + buildGenerateSuccess({ + message: content.text, + metadata: content.metadata, + adjusted, + committed: true, + pushed: false, + dryRun: false, + commit + }) + ) + ); + case "commit_push": { + const yes = mode.git.yes; + return this.commit(content.text) + .chain(() => this.pushJson(Just(content.metadata), yes)) + .chain(() => + repo.findCommitMetadata().map((commit) => + buildGenerateSuccess({ + message: content.text, + metadata: content.metadata, + adjusted, + committed: true, + pushed: true, + dryRun: false, + commit + }) + ) + ); + } + default: { + const _exhaustive: never = mode.git; + return Future.reject(new Error(`Unknown git mode: ${JSON.stringify(_exhaustive)}`)); + } + } + } + + private pushJson(request: Maybe, yes: boolean): Future { + return repo.hasUpstream().chain((exists) => { + if (exists) { + return this.pushSilent(request).chainRej((err) => + isNonFastForwardError(err) && yes ? + this.pushSilent(request, undefined, false, true) + : isNonFastForwardError(err) ? + Future.reject(cliError("PUSH_REJECTED", err.message)) + : Future.reject(err) + ); + } + if (!yes) { + return Future.reject( + cliError("PUSH_NO_UPSTREAM", "Branch has no upstream. Use --yes to publish, or run git push -u origin .") + ); + } + return repo.getCurrentBranch().chain((branch) => this.pushSilent(request, branch, true)); + }); + } + + private pushSilent( + _request: Maybe, + branch?: string, + publish = false, + forceWithLease = false + ): Future { + return loading("Pushing...", "Pushed.", repo.performPush(branch, publish, forceWithLease), { silent: true }).map(() => undefined); + } + + private failJson(err: Error): Error { + const cli = + err instanceof CliError ? err + : err.message.includes("Not a git repository") ? cliError("NOT_GIT_REPO", err.message) + : err.message.includes("No staged changes") ? cliError("NO_STAGED_CHANGES", err.message) + : cliError("LLM_ERROR", err.message); + writeCliFailure(cli.code, cli.message); + return cli; + } + diff(): Future { return repo.getStagedDiff(); } - generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { - return loading("Generating commit message...", "Message generated!", generateCommitMessage(this.providerConfig, diff, convention, template)); + generate( + diff: string, + convention: CommitConvention, + template: Maybe = Nothing(), + options: { silent?: boolean } = {} + ): Future { + return loading( + "Generating commit message...", + "Message generated!", + generateCommitMessage(this.providerConfig, diff, convention, template), + options + ); } - refine(message: string, adjustment: string, diff: string): Future { - return loading("Refining...", "Refined!", refineCommitMessage(this.providerConfig, message, adjustment, diff)); + refine(message: string, adjustment: string, diff: string, options: { silent?: boolean } = {}): Future { + return loading("Refining...", "Refined!", refineCommitMessage(this.providerConfig, message, adjustment, diff), options); } commit(message: string): Future { diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index e6f3db2..42226b3 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -11,11 +11,23 @@ import { absurd } from "@/libs/types"; import { access } from "node:fs/promises"; import { environment } from "@/infra/env"; import { name as packageName, version as packageVersion } from "@/package.json"; +import { writeDoctorSuccess, type DoctorSuccess } from "@/cli/json-io"; import color from "picocolors"; import Table from "cli-table3"; -type CheckRow = [string, string, string]; +type CheckLevel = "ok" | "warn" | "error" | "skip"; + +type Check = { + readonly name: string; + readonly status: string; + readonly level: CheckLevel; + readonly info: string; +}; + +type DoctorRunOptions = { + readonly json: boolean; +}; class Doctor { private constructor() {} @@ -24,144 +36,218 @@ class Doctor { return new Doctor(); } - run(): Future { + run(options: DoctorRunOptions): Future { const start = performance.now(); - return this.checkOAuthCredentials().chain((oauthRow) => - this.checkConfig().chain((configRows) => - this.checkGitContext().map((gitRows) => { - const rows: CheckRow[] = [["CLI Version", color.green(packageVersion), packageName], this.checkRuntime(), this.checkPlatform(), oauthRow]; - this.renderTable(rows.concat(configRows, gitRows), performance.now() - start); + return this.checkOAuthCredentials().chain((oauthCheck) => + this.checkConfig().chain((configChecks) => + this.checkGitContext().map((gitChecks) => { + const checks: Check[] = [ + { name: "CLI Version", status: "ok", level: "ok", info: `${packageName} ${packageVersion}` }, + this.checkRuntime(), + this.checkPlatform(), + oauthCheck, + ...configChecks, + ...gitChecks + ]; + const elapsedMs = performance.now() - start; + + if (options.json) { + writeDoctorSuccess(toDoctorSuccess(checks, elapsedMs)); + return; + } + + this.renderTable(checks, elapsedMs); }) ) ); } - private checkRuntime(): CheckRow { - return ["Runtime", color.green("Node.js"), process.version]; + private checkRuntime(): Check { + return { name: "Runtime", status: "ok", level: "ok", info: `Node.js ${process.version}` }; } - private checkPlatform(): CheckRow { + private checkPlatform(): Check { const label = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : process.platform === "linux" ? "Linux" : process.platform; - return ["Platform", color.green(label), process.platform]; + return { name: "Platform", status: "ok", level: "ok", info: label }; } - private checkOAuthCredentials(): Future { + private checkOAuthCredentials(): Future { const clientId = environment.GOOGLE_CLIENT_ID; - const row: CheckRow = ["OAuth Credentials", color.green("Configured"), `Client ID: ${clientId.slice(0, 12)}...`]; - return Future.resolve(row); + return Future.resolve({ + name: "OAuth Credentials", + status: "ok", + level: "ok", + info: `Client ID: ${clientId.slice(0, 12)}...` + }); } - private checkConfig(): Future { + private checkConfig(): Future { return Future.attemptP(() => access(configFile()) .then(() => true) .catch(() => false) ).chain((configExists) => { - const row: CheckRow = [ - "Configuration", - configExists ? color.green("Found") : color.yellow("Missing"), - configExists ? configFile() : "Run 'commit-tools setup' to create" - ]; + const missingCheck: Check = { + name: "Configuration", + status: "missing", + level: "warn", + info: "Run 'commit setup' to create" + }; if (!configExists) { - return Future.resolve([row]); + return Future.resolve([missingCheck]); } + const foundCheck: Check = { + name: "Configuration", + status: "found", + level: "ok", + info: configFile() + }; + return loadConfig() .map((config) => { - const rows: CheckRow[] = [row]; + const rows: Check[] = [foundCheck]; const ai = config.ai; - rows.push(["Provider", color.green(ai.provider), renderModelInfo(ai)]); + rows.push({ + name: "Provider", + status: "ok", + level: "ok", + info: renderModelInfo(ai) + }); - const authMethod = ai.auth_method.type; - - rows.push(["Auth Method", color.green(authMethodLabel(authMethod)), authMethodDescription(ai)]); + rows.push({ + name: "Auth Method", + status: "ok", + level: "ok", + info: `${authMethodLabel(ai.auth_method.type)} — ${authMethodDescription(ai)}` + }); if (ai.auth_method.type === "google_oauth" || ai.auth_method.type === "openai_oauth") { const now = Date.now(); - const expiryDate = ai.auth_method.content.expiry_date; // For API Key we don't have this, that's why we have this `if (...) {}` block + const expiryDate = ai.auth_method.content.expiry_date; const isExpired = expiryDate <= now; - const expiryStr = new Date(expiryDate).toLocaleString(); - - rows.push([ - "Token Status", - isExpired ? color.yellow("Expired") : color.green("Valid"), - isExpired ? `Expired at ${expiryStr} (will auto-refresh)` : `Expires at ${expiryStr}` - ]); + rows.push({ + name: "Token Status", + status: isExpired ? "expired" : "valid", + level: isExpired ? "warn" : "ok", + info: + isExpired ? + `Expired at ${new Date(expiryDate).toISOString()}` + : `Expires at ${new Date(expiryDate).toISOString()}` + }); } return rows; }) - .chainRej((): Future => Future.resolve([row])); + .chainRej((): Future => Future.resolve([foundCheck])); }); } - private checkGitContext(): Future { + private checkGitContext(): Future { return repo .checkIsGitRepo() - .chain((): Future => this.collectGitRows()) - .chainRej((): Future => Future.resolve([["Git Repository", color.yellow("Outside"), "Not a git repository"]])); + .chain((): Future => this.collectGitChecks()) + .chainRej((): Future => + Future.resolve([ + { + name: "Git Repository", + status: "outside", + level: "warn", + info: "Not a git repository" + } + ]) + ); } - private collectGitRows(): Future { + private collectGitChecks(): Future { return Future.concurrently; base: Maybe; pr: pr.PrLookup }>({ branch: repo.findCurrentBranch(), base: repo.findBaseBranch(), pr: pr.getOpenPullRequest() - }).map(({ branch, base, pr: prLookup }): CheckRow[] => [renderBranchRow(branch), renderBaseRow(base), renderPrRow(prLookup)]); + }).map(({ branch, base, pr: prLookup }): Check[] => [renderBranchCheck(branch), renderBaseCheck(base), renderPrCheck(prLookup)]); } - private renderTable(rows: CheckRow[], elapsedMs: number): void { + private renderTable(checks: Check[], elapsedMs: number): void { const table = new Table({ head: [color.cyan("Check"), color.cyan("Status"), color.cyan("Info")], colWidths: [20, 15, 40] }); - for (const row of rows) { - table.push(row); + for (const check of checks) { + table.push([check.name, colorizeStatus(check), check.info]); } process.stdout.write("\n" + table.toString() + "\n\n"); - const hasConfig = rows.some(([check, status]) => check === "Configuration" && status.includes("Found")); - if (!hasConfig) { - process.stdout.write(color.yellow("! Please run 'commit-tools setup' to configure your API key.\n\n")); + const ready = checks.some((c) => c.name === "Configuration" && c.status === "found"); + if (!ready) { + process.stdout.write(color.yellow("! Please run 'commit setup' to configure your API key.\n\n")); } else { process.stdout.write(color.green(`System is ready to generate commits! Done in ${(elapsedMs / 1000).toFixed(2)}s`)); } } } -function renderBranchRow(branch: Maybe): CheckRow { - return branch instanceof Just ? ["Branch", color.green("Current"), branch.value] : ["Branch", color.yellow("Unknown"), "Could not read current branch"]; +const toDoctorSuccess = (checks: Check[], elapsedMs: number): DoctorSuccess => ({ + ok: true, + command: "doctor", + checks: checks.map((c) => ({ name: c.name, status: c.status, level: c.level, info: c.info })), + ready: checks.some((c) => c.name === "Configuration" && c.status === "found"), + elapsedMs +}); + +const colorizeStatus = (check: Check): string => { + switch (check.level) { + case "ok": + return color.green(check.status); + case "warn": + return color.yellow(check.status); + case "error": + return color.red(check.status); + case "skip": + return color.gray(check.status); + default: { + const _exhaustive: never = check.level; + return _exhaustive; + } + } +}; + +function renderBranchCheck(branch: Maybe): Check { + return branch instanceof Just ? + { name: "Branch", status: "current", level: "ok", info: branch.value } + : { name: "Branch", status: "unknown", level: "warn", info: "Could not read current branch" }; } -function renderBaseRow(base: Maybe): CheckRow { - return base instanceof Just ? ["Base", color.green("Detected"), base.value] : ["Base", color.yellow("Unknown"), "Could not resolve base branch"]; +function renderBaseCheck(base: Maybe): Check { + return base instanceof Just ? + { name: "Base", status: "detected", level: "ok", info: base.value } + : { name: "Base", status: "unknown", level: "warn", info: "Could not resolve base branch" }; } -function renderPrRow(lookup: pr.PrLookup): CheckRow { +function renderPrCheck(lookup: pr.PrLookup): Check { switch (lookup.type) { case "found": - return ["Pull Request", color.green("Open"), `#${lookup.pr.number} ${lookup.pr.url}`]; + return { name: "Pull Request", status: "open", level: "ok", info: `#${lookup.pr.number} ${lookup.pr.url}` }; case "not-found": - return ["Pull Request", color.yellow("None"), "No open PR for this branch"]; + return { name: "Pull Request", status: "none", level: "warn", info: "No open PR for this branch" }; case "unauthenticated": - return ["Pull Request", color.yellow("Auth"), "Run 'gh auth login' to enable PR lookup"]; + return { name: "Pull Request", status: "auth", level: "warn", info: "Run 'gh auth login' to enable PR lookup" }; case "unavailable": - return ["Pull Request", color.gray("Skipped"), "gh not installed or remote is not GitHub"]; + return { name: "Pull Request", status: "skipped", level: "skip", info: "gh not installed or remote is not GitHub" }; default: return absurd(lookup, "PrLookup"); } } function renderModelInfo(ai: ProviderConfig): string { - const base = `${ai.model}`; + const base = `${ai.provider} / ${ai.model}`; return ai.effort instanceof Just ? `${base} (${ai.effort.value} effort)` : base; } @@ -175,7 +261,7 @@ function authMethodLabel(authMethod: AuthMethod): string { case "api_key": return "API Key"; default: - return "This should never happen. Please run 'commit-tools setup' to create a new configuration."; + return "Unknown"; } } @@ -197,6 +283,6 @@ function authMethodDescription(ai: ProviderConfig): string { return "Google AI Studio API Key"; } default: - return "This should never happen. Please run 'commit-tools setup' to create a new configuration."; + return "Unknown"; } } diff --git a/src/cli/generate-flags.ts b/src/cli/generate-flags.ts new file mode 100644 index 0000000..d0c9773 --- /dev/null +++ b/src/cli/generate-flags.ts @@ -0,0 +1,138 @@ +export { type GenerateRunMode, type MachineGit, defaultInteractiveMode, parseGenerateFlags, isAutomationFlag }; + +import { Failure, Success, type Result } from "@/libs/result"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { cliError } from "@/cli/json-io"; + +type MachineGit = + | { type: "none" } + | { type: "commit" } + | { type: "commit_push"; readonly yes: boolean }; + +type GenerateRunMode = + | { type: "interactive" } + | { + type: "json"; + readonly adjust: Maybe; + readonly dryRun: boolean; + readonly git: MachineGit; + }; + +type ParsedFlags = { + json: boolean; + commit: boolean; + push: boolean; + yes: boolean; + dryRun: boolean; + adjust: Maybe; +}; + +const defaultInteractiveMode = (): GenerateRunMode => ({ type: "interactive" }); + +const isAutomationFlag = (arg: string): boolean => + arg === "--json" || + arg === "--commit" || + arg === "--push" || + arg === "--yes" || + arg === "--dry-run" || + arg === "--adjust"; + +const parseFlagToken = (argv: readonly string[], index: number): Result => { + const arg = argv[index]; + if (arg === "--adjust") { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + return Failure(cliError("INVALID_FLAGS", "--adjust requires a value")); + } + return Success({ key: `adjust:${value}`, nextIndex: index + 1 }); + } + if (arg === undefined || !isAutomationFlag(arg)) { + return Failure(cliError("INVALID_FLAGS", `Unknown flag: ${arg ?? ""}`)); + } + return Success({ key: arg, nextIndex: index }); +}; + +const collectFlags = (argv: readonly string[]): Result => { + const flags: ParsedFlags = { + json: false, + commit: false, + push: false, + yes: false, + dryRun: false, + adjust: Nothing() + }; + + for (let i = 0; i < argv.length; i++) { + const parsed = parseFlagToken(argv, i); + if (parsed instanceof Failure) return Failure(parsed.error); + + const { key, nextIndex } = parsed.unwrap((e) => e.message); + i = nextIndex; + + if (key.startsWith("adjust:")) { + flags.adjust = Just(key.slice("adjust:".length)); + continue; + } + + switch (key) { + case "--json": + flags.json = true; + break; + case "--commit": + flags.commit = true; + break; + case "--push": + flags.push = true; + break; + case "--yes": + flags.yes = true; + break; + case "--dry-run": + flags.dryRun = true; + break; + default: + return Failure(cliError("INVALID_FLAGS", `Unknown flag: ${key}`)); + } + } + + return Success(flags); +}; + +const validateFlags = (flags: ParsedFlags): Result => { + const hasAutomation = flags.commit || flags.push || flags.yes || flags.dryRun || flags.adjust instanceof Just; + if (hasAutomation && !flags.json) { + return Failure(cliError("INVALID_FLAGS", "Automation flags require --json")); + } + if (flags.push && !flags.commit) { + return Failure(cliError("INVALID_FLAGS", "--push requires --commit")); + } + if (flags.yes && !flags.push) { + return Failure(cliError("INVALID_FLAGS", "--yes requires --push")); + } + return Success(undefined); +}; + +const toMachineGit = (flags: ParsedFlags): MachineGit => + flags.push ? { type: "commit_push", yes: flags.yes } + : flags.commit ? { type: "commit" } + : { type: "none" }; + +const parseGenerateFlags = (argv: readonly string[]): Result => { + const collected = collectFlags(argv); + if (collected instanceof Failure) return Failure(collected.error); + + const flags = collected.unwrap((e) => e.message); + const valid = validateFlags(flags); + if (valid instanceof Failure) return Failure(valid.error); + + if (!flags.json) { + return Success(defaultInteractiveMode()); + } + + return Success({ + type: "json", + adjust: flags.adjust, + dryRun: flags.dryRun, + git: toMachineGit(flags) + }); +}; diff --git a/src/cli/json-io.ts b/src/cli/json-io.ts new file mode 100644 index 0000000..0e5cf43 --- /dev/null +++ b/src/cli/json-io.ts @@ -0,0 +1,175 @@ +export { + CliErrorCode, + CliError, + cliError, + schema_GenerateSuccess, + schema_DoctorSuccess, + schema_CliFailure, + type GenerateSuccess, + type DoctorSuccess, + type CliErrorCode as CliErrorCodeType, + buildGenerateSuccess, + writeGenerateSuccess, + writeDoctorSuccess, + writeCliFailure, + encodeCommitMeta, + encodeRequestMetadata +}; + +import * as s from "@/libs/json/schema"; +import type { LlmRequestMetadata, TokenUsage } from "@/domain/llm/router"; +import type { CommitMetadata } from "@/infra/git/repo"; +import { type Maybe } from "@/libs/maybe"; + +const CliErrorCode = { + INVALID_FLAGS: "INVALID_FLAGS", + NOT_GIT_REPO: "NOT_GIT_REPO", + NO_STAGED_CHANGES: "NO_STAGED_CHANGES", + NO_CONFIG: "NO_CONFIG", + AUTH_FAILED: "AUTH_FAILED", + LLM_ERROR: "LLM_ERROR", + PUSH_NO_UPSTREAM: "PUSH_NO_UPSTREAM", + PUSH_REJECTED: "PUSH_REJECTED" +} as const; +type CliErrorCode = (typeof CliErrorCode)[keyof typeof CliErrorCode]; + +class CliError extends Error { + readonly code: CliErrorCode; + + constructor(code: CliErrorCode, message: string) { + super(message); + this.code = code; + } +} + +const cliError = (code: CliErrorCode, message: string): CliError => new CliError(code, message); + +const schema_TokenUsage = s.object({ + input: s.nullable(s.number), + output: s.nullable(s.number), + total: s.nullable(s.number) +}); + +const schema_ModelMeta = s.object({ + provider: s.string, + model: s.string, + effort: s.string +}); + +const schema_RequestMetadata = s.object({ + durationMs: s.number, + model: schema_ModelMeta, + tokens: s.nullable(schema_TokenUsage) +}); + +const schema_GenerateActions = s.object({ + adjusted: s.boolean, + committed: s.boolean, + pushed: s.boolean, + dryRun: s.boolean +}); + +const schema_CommitMeta = s.object({ + hash: s.string, + short: s.string, + subject: s.string, + authorName: s.string, + authorEmail: s.string, + date: s.string +}); + +const schema_GenerateSuccess = s.object({ + ok: s.boolean, + command: s.stringLiteral("generate"), + message: s.string, + actions: schema_GenerateActions, + metadata: schema_RequestMetadata, + commit: s.nullable(schema_CommitMeta) +}); + +type GenerateSuccess = s.Infer; + +const schema_DoctorCheck = s.object({ + name: s.string, + status: s.string, + level: s.string, + info: s.string +}); + +const schema_DoctorSuccess = s.object({ + ok: s.boolean, + command: s.stringLiteral("doctor"), + checks: s.array(schema_DoctorCheck), + ready: s.boolean, + elapsedMs: s.number +}); + +type DoctorSuccess = s.Infer; + +const schema_CliFailure = s.object({ + ok: s.boolean, + error: s.object({ + code: s.string, + message: s.string + }) +}); + +const encodeTokenUsage = (tokens: Maybe) => + tokens.maybe(null, (t) => ({ + input: t.input.maybe(null, (n) => n), + output: t.output.maybe(null, (n) => n), + total: t.total.maybe(null, (n) => n) + })); + +const encodeRequestMetadata = (meta: LlmRequestMetadata) => ({ + durationMs: meta.durationMs, + model: { + provider: meta.model.provider, + model: meta.model.model, + effort: meta.model.effort + }, + tokens: encodeTokenUsage(meta.tokens) +}); + +const encodeCommitMeta = (commit: CommitMetadata) => ({ + hash: commit.hash, + short: commit.short, + subject: commit.subject, + authorName: commit.authorName, + authorEmail: commit.authorEmail, + date: commit.date.toISOString() +}); + +const buildGenerateSuccess = (input: { + message: string; + metadata: LlmRequestMetadata; + adjusted: boolean; + committed: boolean; + pushed: boolean; + dryRun: boolean; + commit: Maybe; +}): GenerateSuccess => ({ + ok: true, + command: "generate", + message: input.message, + actions: { + adjusted: input.adjusted, + committed: input.committed, + pushed: input.pushed, + dryRun: input.dryRun + }, + metadata: encodeRequestMetadata(input.metadata), + commit: input.commit.maybe(null, encodeCommitMeta) +}); + +const writeGenerateSuccess = (value: GenerateSuccess): void => { + process.stdout.write(JSON.stringify(s.encode(schema_GenerateSuccess, value)) + "\n"); +}; + +const writeDoctorSuccess = (value: DoctorSuccess): void => { + process.stdout.write(JSON.stringify(s.encode(schema_DoctorSuccess, value)) + "\n"); +}; + +const writeCliFailure = (code: CliErrorCode, message: string): void => { + process.stderr.write(JSON.stringify(s.encode(schema_CliFailure, { ok: false, error: { code, message } })) + "\n"); +}; diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 6e8590b..499ccfc 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -1,64 +1,101 @@ export { type CliCommand, parseArgs, showHelp, showVersion }; -import * as D from "@/libs/json/decoder"; - -import { Result } from "@/libs/result"; +import { defaultInteractiveMode, isAutomationFlag, parseGenerateFlags, type GenerateRunMode } from "@/cli/generate-flags"; +import { cliError } from "@/cli/json-io"; +import { Failure, Success, type Result } from "@/libs/result"; import { version as packageVersion } from "@/package.json"; type CliCommand = - | { type: "generate" } + | { type: "generate"; mode: GenerateRunMode } | { type: "setup" } - | { type: "doctor" } + | { type: "doctor"; json: boolean } | { type: "model" } | { type: "effort" } | { type: "update" } | { type: "version" } | { type: "help" }; -const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { - const cmd = args[0] || "generate"; +const GLOBAL_COMMANDS = new Set(["--version", "-v", "--help", "-h", "setup", "login", "doctor", "model", "effort", "update", "generate"]); + +const parseDoctorFlags = (argv: readonly string[]): Result => { + if (argv.length === 0) return Success(false); + if (argv.length === 1 && argv[0] === "--json") return Success(true); + return Failure(cliError("INVALID_FLAGS", `Unknown flag for doctor: ${argv[0]}`)); +}; - switch (cmd) { +const parseGenerateCommand = (argv: readonly string[]): Result => + parseGenerateFlags(argv).map((mode) => ({ type: "generate", mode })); + +const parseArgs = (args: readonly string[]): Result => { + if (args.length === 0) { + return Success({ type: "generate", mode: defaultInteractiveMode() }); + } + + const first = args[0]!; + + if (first === "--version" || first === "-v") return Success({ type: "version" }); + if (first === "--help" || first === "-h") return Success({ type: "help" }); + + if (isAutomationFlag(first)) { + return parseGenerateCommand(args); + } + + if (!GLOBAL_COMMANDS.has(first)) { + return Failure(new Error(`Unknown command: ${first}`)); + } + + const rest = args.slice(1); + + switch (first) { case "generate": - return D.succeed({ type: "generate" }); + return parseGenerateCommand(rest); case "setup": case "login": - return D.succeed({ type: "setup" }); + return Success({ type: "setup" }); case "doctor": - return D.succeed({ type: "doctor" }); + return parseDoctorFlags(rest).map((json) => ({ type: "doctor", json })); case "model": - return D.succeed({ type: "model" }); + return Success({ type: "model" }); case "effort": - return D.succeed({ type: "effort" }); + return Success({ type: "effort" }); case "update": - return D.succeed({ type: "update" }); - case "--version": - case "-v": - return D.succeed({ type: "version" }); - case "--help": - case "-h": - return D.succeed({ type: "help" }); - default: - return D.fail(`Unknown command: ${cmd}`); + return Success({ type: "update" }); + default: { + const _exhaustive: never = first as never; + return Failure(new Error(`Unknown command: ${_exhaustive}`)); + } } -}); - -const parseArgs = (args: string[]): Result => D.decode(args, cliCommandDecoder).mapFailure((err) => new Error(err)); +}; const showHelp = (): void => { console.log(` -Usage: commit-tools [command] +Usage: commit [command] [flags] Commands: - generate (default) Generate a commit message + generate (default) Generate a commit message (interactive) setup Configure authentication and conventions - login Alias for setup (re-authenticate) + login Alias for setup doctor Check installation and environment model Select a different AI model - effort Adjust the reasoning effort for the current model - update Install the latest version from npm + effort Adjust reasoning effort + update Install the latest version --version, -v Show version --help, -h Show help + +Generate flags (require --json except --json itself): + --json Machine output (JSON on stdout, no prompts) + --adjust Refine the generated message + --dry-run Generate only; do not commit or push + --commit Commit with the generated message + --push Push after commit (requires --commit) + --yes Publish branch or force-with-lease without confirm (requires --push) + +Agent examples: + commit generate --json + commit generate --json --commit + commit generate --json --commit --push + commit --json --adjust "shorter subject" --dry-run + commit doctor --json `); }; diff --git a/src/infra/ui/spinner.ts b/src/infra/ui/spinner.ts index 96c08ff..7b23047 100644 --- a/src/infra/ui/spinner.ts +++ b/src/infra/ui/spinner.ts @@ -1,4 +1,4 @@ -export { loading, bracketStatus, type StatusMessageSink, type BracketStatus }; +export { loading, bracketStatus, type StatusMessageSink, type BracketStatus, type LoadingOptions }; import * as p from "@clack/prompts"; @@ -8,9 +8,17 @@ type StatusMessageSink = { readonly message: (msg: string) => void; }; +type LoadingOptions = { + readonly silent?: boolean; +}; + type BracketStatus = (startLabel: string, stopLabel: string, body: (status: StatusMessageSink) => Future) => Future; -const loading = (label: string, stopLabel: string, f: Future): Future => { +const loading = (label: string, stopLabel: string, f: Future, options: LoadingOptions = {}): Future => { + if (options.silent) { + return f; + } + const s = p.spinner(); s.start(label); return f diff --git a/test/cli/commit.test.ts b/test/cli/commit.test.ts index 712146e..1bb9356 100644 --- a/test/cli/commit.test.ts +++ b/test/cli/commit.test.ts @@ -69,7 +69,8 @@ describe("Commit.run", () => { }); it("commits when user selects commit", async () => { - await runFuture(Commit.create().chain((c) => c.run())); + const mode = { type: "interactive" as const }; + await runFuture(Commit.create(mode).chain((c) => c.run(mode))); const repo = await import("@/infra/git/repo"); expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); }); diff --git a/test/cli/doctor-json.test.ts b/test/cli/doctor-json.test.ts new file mode 100644 index 0000000..8724139 --- /dev/null +++ b/test/cli/doctor-json.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test-client-id", GOOGLE_CLIENT_SECRET: "test" } +})); + +import { Doctor } from "@/cli/doctor"; +import { Future } from "@/libs/future"; +import { runFuture } from "@test/helpers/run-future"; + +vi.mock("@/infra/storage/config", () => ({ + configFile: () => "/tmp/config.json", + loadConfig: vi.fn() +})); +vi.mock("@/infra/git/repo", () => ({ + checkIsGitRepo: vi.fn(() => Future.reject(new Error("not a repo"))), + findCurrentBranch: vi.fn(), + findBaseBranch: vi.fn() +})); +vi.mock("@/infra/github/pr", () => ({ + getOpenPullRequest: vi.fn(() => Future.resolve({ type: "unavailable" })) +})); +vi.mock("node:fs/promises", () => ({ + access: vi.fn(() => Promise.reject(new Error("missing"))) +})); + +describe("Doctor.run --json", () => { + let stdout: string; + + beforeEach(() => { + stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits checks JSON on stdout", async () => { + await runFuture(Doctor.create().run({ json: true })); + const line = stdout.trim().split("\n").find((l) => l.startsWith("{")); + expect(line).toBeDefined(); + const parsed = JSON.parse(line!); + expect(parsed.command).toBe("doctor"); + expect(Array.isArray(parsed.checks)).toBe(true); + expect(parsed.checks.some((c: { name: string }) => c.name === "CLI Version")).toBe(true); + }); +}); diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index 234d1f5..30b90e6 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -29,6 +29,6 @@ describe("Doctor.run", () => { }); it("completes when config file is missing", async () => { - await expect(runFuture(Doctor.create().run())).resolves.toBeUndefined(); + await expect(runFuture(Doctor.create().run({ json: false }))).resolves.toBeUndefined(); }); }); diff --git a/test/cli/generate-json.test.ts b/test/cli/generate-json.test.ts new file mode 100644 index 0000000..498652d --- /dev/null +++ b/test/cli/generate-json.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/infra/env", () => ({ + environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } +})); + +import { Commit } from "@/cli/commit"; +import { Future } from "@/libs/future"; +import { Nothing, Just } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; +import * as s from "@/libs/json/schema"; +import { Config } from "@/domain/config/config"; + +type ConfigValue = s.Infer; + +vi.mock("@/infra/storage/config", () => ({ + loadConfig: vi.fn() +})); +vi.mock("@/domain/llm/auth-resolver", () => ({ + resolveProvider: vi.fn((c: ConfigValue) => Future.resolve(c.ai)) +})); +vi.mock("@/infra/git/repo", () => ({ + checkIsGitRepo: vi.fn(() => Future.resolve(undefined)), + getStagedDiff: vi.fn(() => Future.resolve("diff")), + performCommit: vi.fn(() => Future.resolve("")), + performPush: vi.fn(() => Future.resolve({ output: "", range: Nothing() })), + hasUpstream: vi.fn(() => Future.resolve(true)), + findCommitMetadata: vi.fn() +})); +vi.mock("@/domain/llm/router", () => ({ + generateCommitMessage: vi.fn(() => + Future.resolve({ + text: "feat: generated", + metadata: { + durationMs: 1, + model: { provider: "openai", model: "m", effort: "medium" }, + tokens: Nothing() + } + }) + ), + refineCommitMessage: vi.fn(() => + Future.resolve({ + text: "feat: refined", + metadata: { + durationMs: 2, + model: { provider: "openai", model: "m", effort: "medium" }, + tokens: Nothing() + } + }) + ) +})); +vi.mock("@clack/prompts", () => ({ + note: vi.fn(), + select: vi.fn(), + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + outro: vi.fn(), + log: { warn: vi.fn(), error: vi.fn() } +})); + +const config = (): ConfigValue => ({ + commit_convention: "conventional", + custom_template: Nothing(), + ai: { provider: "openai", model: "gpt-4.1-mini", effort: Nothing(), auth_method: { type: "api_key", content: "sk" } } +}); + +const jsonMode = (overrides: Partial<{ dryRun: boolean; git: { type: "none" } | { type: "commit" } }> = {}) => ({ + type: "json" as const, + adjust: Nothing(), + dryRun: false, + git: { type: "none" as const }, + ...overrides +}); + +describe("Commit.run json mode", () => { + let stdout: string; + + beforeEach(async () => { + vi.clearAllMocks(); + stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const storage = await import("@/infra/storage/config"); + vi.mocked(storage.loadConfig).mockReturnValue(Future.resolve(config())); + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.findCommitMetadata).mockReturnValue( + Future.resolve(Just({ hash: "h", short: "h", subject: "feat: generated", authorName: "t", authorEmail: "t@t.com", date: new Date("2020-01-01") })) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("writes JSON to stdout without calling select", async () => { + const clack = await import("@clack/prompts"); + const mode = jsonMode(); + await runFuture(Commit.create(mode).chain((c) => c.run(mode))); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.ok).toBe(true); + expect(parsed.message).toBe("feat: generated"); + expect(parsed.actions.committed).toBe(false); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it("commits when --commit", async () => { + const repo = await import("@/infra/git/repo"); + const mode = jsonMode({ git: { type: "commit" } }); + await runFuture(Commit.create(mode).chain((c) => c.run(mode))); + expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.actions.committed).toBe(true); + }); + + it("dry-run skips commit", async () => { + const repo = await import("@/infra/git/repo"); + const mode = { ...jsonMode(), dryRun: true }; + await runFuture(Commit.create(mode).chain((c) => c.run(mode))); + expect(repo.performCommit).not.toHaveBeenCalled(); + expect(JSON.parse(stdout.trim()).actions.dryRun).toBe(true); + }); +}); diff --git a/test/cli/parser.test.ts b/test/cli/parser.test.ts index a67d2dc..028c528 100644 --- a/test/cli/parser.test.ts +++ b/test/cli/parser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseArgs } from "@/cli/parser"; import { Failure, Success } from "@/libs/result"; +import { Nothing } from "@/libs/maybe"; describe("parseArgs", () => { it.each([ @@ -27,9 +28,54 @@ describe("parseArgs", () => { if (result instanceof Failure) expect(result.error.message).toContain("Unknown command"); }); - it("defaults bare invocation to generate", () => { + it("defaults bare invocation to interactive generate", () => { const result = parseArgs([]); expect(result.isSuccess()).toBe(true); - if (result instanceof Success) expect(result.value.type).toBe("generate"); + if (result instanceof Success) { + expect(result.value.type).toBe("generate"); + if (result.value.type === "generate") expect(result.value.mode.type).toBe("interactive"); + } + }); + + it("parses generate --json", () => { + const result = parseArgs(["generate", "--json"]); + expect(result.isSuccess()).toBe(true); + if (result instanceof Success && result.value.type === "generate") { + expect(result.value.mode).toEqual({ + type: "json", + adjust: Nothing(), + dryRun: false, + git: { type: "none" } + }); + } + }); + + it("parses commit --json --commit --push as generate", () => { + const result = parseArgs(["--json", "--commit", "--push"]); + expect(result.isSuccess()).toBe(true); + if (result instanceof Success && result.value.type === "generate") { + expect(result.value.mode.type).toBe("json"); + if (result.value.mode.type === "json") { + expect(result.value.mode.git).toEqual({ type: "commit_push", yes: false }); + } + } + }); + + it("rejects --push without --commit", () => { + const result = parseArgs(["generate", "--json", "--push"]); + expect(result.isFailure()).toBe(true); + }); + + it("rejects --commit without --json", () => { + const result = parseArgs(["generate", "--commit"]); + expect(result.isFailure()).toBe(true); + }); + + it("parses doctor --json", () => { + const result = parseArgs(["doctor", "--json"]); + expect(result.isSuccess()).toBe(true); + if (result instanceof Success && result.value.type === "doctor") { + expect(result.value.json).toBe(true); + } }); });