import { parseArgs, type ParseArgsConfig } from "node:util" type OptionConfig = NonNullable interface LeafCommand { readonly usage: string readonly minArgs: number readonly maxArgs: number readonly options?: readonly string[] readonly required?: readonly string[] readonly repeatable?: readonly string[] readonly conflicts?: readonly (readonly [string, string])[] /** Minimum positionals when every target selector slot is explicit. */ readonly targetMinArgs?: number } interface CommandDefinition { readonly usage: string readonly options: OptionConfig readonly command?: LeafCommand readonly subcommands?: Readonly> } const commonOptions = { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "V" }, silent: { type: "boolean", short: "s" }, verbose: { type: "boolean", short: "v" }, "no-input": { type: "boolean" }, workbase: { type: "string" }, cwd: { type: "string" }, } satisfies OptionConfig const entitySelectorOptions = { epic: { type: "string" }, task: { type: "string" }, phase: { type: "string" }, } satisfies OptionConfig const outputOptions = { ...commonOptions, json: { type: "boolean" }, } satisfies OptionConfig const viewOptions = { status: { type: "string", multiple: true }, repository: { type: "string", multiple: true }, ready: { type: "boolean" }, blocked: { type: "boolean" }, pr: { type: "boolean" }, "no-pr": { type: "boolean" }, } satisfies OptionConfig const viewOptionNames = [ "status", "repository", "ready", "blocked", "pr", "no-pr", ] as const const viewConflicts = [ ["ready", "blocked"], ["pr", "no-pr"], ] as const const createOptions = { ...outputOptions, "ticket-url": { type: "string" }, description: { type: "string" }, repo: { type: "string", multiple: true }, } satisfies OptionConfig const newWorkOptions = { work: { type: "boolean" }, auto: { type: "boolean" }, } satisfies OptionConfig const taskCreateOptions = { ...createOptions, reference: { type: "string", multiple: true }, epic: { type: "string" }, branch: { type: "string" }, base: { type: "string" }, "multi-phase": { type: "boolean" }, review: { type: "string" }, "pull-request": { type: "string" }, ref: { type: "string" }, "context-repo": { type: "string" }, "context-base": { type: "string" }, "context-slug": { type: "string" }, "authoritative-source": { type: "string", multiple: true }, purpose: { type: "string" }, "source-phase": { type: "string" }, } satisfies OptionConfig const phaseCreateOptions = { ...outputOptions, description: { type: "string" }, repo: { type: "string", multiple: true }, reference: { type: "string", multiple: true }, branch: { type: "string" }, base: { type: "string" }, "depends-on": { type: "string", multiple: true }, "first-phase": { type: "string" }, } satisfies OptionConfig const mutationOptions = { "if-revision": { type: "string" }, "clear-description": { type: "boolean" }, "clear-ticket": { type: "boolean" }, "clear-references": { type: "boolean" }, "pr-url": { type: "string" }, "clear-pr": { type: "boolean" }, "no-epic": { type: "boolean" }, } satisfies OptionConfig const nonPrCompletionOptions = { "no-pull-request": { type: "boolean" }, summary: { type: "string" }, "evidence-url": { type: "string" }, } satisfies OptionConfig const commands = { usage: { usage: "agency usage export", options: commonOptions, subcommands: { export: { usage: "agency usage export", minArgs: 0, maxArgs: 0, }, }, }, init: { usage: "agency init [path] [--json]", options: outputOptions, command: { usage: "agency init [path] [--json]", minArgs: 0, maxArgs: 1, options: ["json"], }, }, act: { usage: "agency act [ | --epic | --task [--phase ]] [--action ] [--dry-run | --json] [--auto] [--draft]", options: { ...outputOptions, ...entitySelectorOptions, "dry-run": { type: "boolean" }, auto: { type: "boolean" }, draft: { type: "boolean" }, action: { type: "string" }, }, command: { usage: "agency act [ | --epic | --task [--phase ]] [--action ] [--dry-run | --json] [--auto] [--draft]", minArgs: 0, maxArgs: 1, options: [ "epic", "task", "phase", "dry-run", "json", "auto", "draft", "action", ], conflicts: [ ["dry-run", "json"], ["epic", "task"], ["epic", "phase"], ], }, }, workbase: { usage: "agency workbase ", options: { ...outputOptions, name: { type: "string" }, clear: { type: "boolean" }, }, subcommands: { init: { usage: "agency workbase init [path] [--json]", minArgs: 0, maxArgs: 1, options: ["json"], }, add: { usage: "agency workbase add [--json]", minArgs: 1, maxArgs: 1, options: ["json", "name"], }, list: { usage: "agency workbase list [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, show: { usage: "agency workbase show [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, name: { usage: "agency workbase name | --clear [--json]", minArgs: 1, maxArgs: 2, options: ["json", "clear"], }, remove: { usage: "agency workbase remove [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, prune: { usage: "agency workbase prune [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, default: { usage: "agency workbase default [selector | --clear] [--json]", minArgs: 0, maxArgs: 1, options: ["json", "clear"], conflicts: [["clear", "$positional"]], }, }, }, integration: { usage: "agency integration ", options: outputOptions, subcommands: { status: { usage: "agency integration status [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, sync: { usage: "agency integration sync [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, }, }, repo: { usage: "agency repo ", options: { ...outputOptions, "dry-run": { type: "boolean" }, apply: { type: "boolean" }, }, subcommands: { setup: { usage: "agency repo setup [--dry-run | --apply] [--json]", minArgs: 0, maxArgs: 0, options: ["dry-run", "apply", "json"], conflicts: [["dry-run", "apply"]], }, add: { usage: "agency repo add [--json]", minArgs: 2, maxArgs: 2, options: ["json"], }, link: { usage: "agency repo link [--json]", minArgs: 2, maxArgs: 2, options: ["json"], }, materialize: { usage: "agency repo materialize [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, list: { usage: "agency repo list [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, show: { usage: "agency repo show [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, fetch: { usage: "agency repo fetch [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, remove: { usage: "agency repo remove [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, unlink: { usage: "agency repo unlink [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, rename: { usage: "agency repo rename [--json]", minArgs: 2, maxArgs: 2, options: ["json"], }, remote: { usage: "agency repo remote [remote] [--json]", minArgs: 1, maxArgs: 2, options: ["json"], }, verify: { usage: "agency repo verify [--json]", minArgs: 1, maxArgs: 1, options: ["json"], }, }, }, epic: { usage: "agency epic ", options: { ...createOptions, ...newWorkOptions, ...viewOptions, ...mutationOptions, epic: { type: "string" }, }, subcommands: { new: { usage: "agency epic new --ticket-url --repo : [--repo :...] [--work [--auto]]", minArgs: 1, maxArgs: 1, options: ["ticket-url", "description", "repo", "work", "auto", "json"], required: ["ticket-url", "repo"], repeatable: ["repo"], conflicts: [["work", "json"]], }, create: { usage: "agency epic create --ticket-url --repo : [--repo :...]", minArgs: 1, maxArgs: 1, options: ["ticket-url", "description", "repo", "json"], required: ["ticket-url", "repo"], repeatable: ["repo"], }, list: { usage: "agency epic list [filters] [--json]", minArgs: 0, maxArgs: 0, options: ["json", ...viewOptionNames], repeatable: ["status", "repository"], conflicts: viewConflicts, }, show: { usage: "agency epic show [epic] [--json]", minArgs: 0, maxArgs: 1, options: ["json", "epic"], }, update: { usage: "agency epic update [epic] [options] [--json]", minArgs: 0, maxArgs: 1, options: [ "description", "clear-description", "ticket-url", "repo", "if-revision", "json", ], repeatable: ["repo"], conflicts: [["description", "clear-description"]], }, rename: { usage: "agency epic rename [epic] [--json]", minArgs: 1, maxArgs: 2, options: ["if-revision", "json"], }, }, }, task: { usage: "agency task ", options: { ...taskCreateOptions, ...newWorkOptions, ...viewOptions, ...mutationOptions, ...nonPrCompletionOptions, "pr-url": { type: "string" }, task: { type: "string" }, }, subcommands: { new: { usage: "agency task new [id] [options] [--work [--auto]]", minArgs: 0, maxArgs: 1, options: [ "ticket-url", "description", "epic", "repo", "reference", "branch", "base", "purpose", "multi-phase", "work", "auto", "json", ], repeatable: ["reference"], conflicts: [["work", "json"]], }, create: { usage: "agency task create (--repo | --context-repo | --multi-phase | --review (--pull-request | --ref )) [options]", minArgs: 1, maxArgs: 1, options: [ "ticket-url", "description", "epic", "repo", "reference", "branch", "base", "purpose", "multi-phase", "review", "pull-request", "ref", "context-repo", "context-base", "context-slug", "authoritative-source", "json", ], repeatable: ["reference", "authoritative-source"], }, handoff: { usage: "agency task handoff [source-task] --repo [--source-phase ] [options]", minArgs: 1, maxArgs: 2, options: [ "ticket-url", "description", "epic", "repo", "reference", "branch", "base", "source-phase", "json", ], required: ["repo"], repeatable: ["reference"], }, list: { usage: "agency task list [filters] [--json]", minArgs: 0, maxArgs: 0, options: ["json", ...viewOptionNames], repeatable: ["status", "repository"], conflicts: viewConflicts, }, show: { usage: "agency task show [task] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["json", "task"], }, status: { usage: "agency task status [task] [options] [--json]", minArgs: 1, targetMinArgs: 2, maxArgs: 2, options: [ "json", "task", "if-revision", "no-pull-request", "summary", "evidence-url", ], }, update: { usage: "agency task update [task] [options] [--json]", minArgs: 0, maxArgs: 1, options: [ "ticket-url", "clear-ticket", "description", "clear-description", "repo", "reference", "clear-references", "branch", "base", "pr-url", "clear-pr", "if-revision", "json", ], repeatable: ["reference"], conflicts: [ ["ticket-url", "clear-ticket"], ["description", "clear-description"], ["reference", "clear-references"], ["pr-url", "clear-pr"], ], }, rename: { usage: "agency task rename [task] [--json]", minArgs: 1, maxArgs: 2, options: ["if-revision", "json"], }, move: { usage: "agency task move [task] (--epic | --no-epic) [--json]", minArgs: 0, maxArgs: 1, options: ["epic", "no-epic", "if-revision", "json"], conflicts: [["epic", "no-epic"]], }, dependency: { usage: "agency task dependency [task] [--json]", minArgs: 2, maxArgs: 3, options: ["if-revision", "json"], }, }, }, phase: { usage: "agency phase ", options: { ...phaseCreateOptions, ...newWorkOptions, ...viewOptions, ...mutationOptions, ...nonPrCompletionOptions, "pr-url": { type: "string" }, task: { type: "string" }, phase: { type: "string" }, }, subcommands: { new: { usage: "agency phase new [task] --repo --base [--branch ] [options] [--work [--auto]]", minArgs: 1, targetMinArgs: 2, maxArgs: 2, options: [ "description", "repo", "reference", "branch", "base", "depends-on", "first-phase", "work", "auto", "json", "task", "phase", ], required: ["repo", "branch", "base"], repeatable: ["reference", "depends-on"], conflicts: [["work", "json"]], }, create: { usage: "agency phase create [task] --repo --base [--branch ] [options]", minArgs: 1, targetMinArgs: 2, maxArgs: 2, options: [ "description", "repo", "reference", "branch", "base", "depends-on", "first-phase", "json", "task", "phase", ], required: ["repo", "branch", "base"], repeatable: ["reference", "depends-on"], }, list: { usage: "agency phase list [task] [filters] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["json", "task", ...viewOptionNames], repeatable: ["status", "repository"], conflicts: viewConflicts, }, show: { usage: "agency phase show [ | ] [--json]", minArgs: 0, targetMinArgs: 2, maxArgs: 2, options: ["json", "task", "phase"], }, status: { usage: "agency phase status [ | ] [options] [--json]", minArgs: 1, targetMinArgs: 3, maxArgs: 3, options: [ "json", "task", "phase", "if-revision", "no-pull-request", "summary", "evidence-url", ], }, update: { usage: "agency phase update [ | ] [options] [--json]", minArgs: 0, maxArgs: 2, options: [ "description", "clear-description", "repo", "reference", "clear-references", "branch", "base", "pr-url", "clear-pr", "if-revision", "json", ], repeatable: ["reference"], conflicts: [ ["description", "clear-description"], ["reference", "clear-references"], ["pr-url", "clear-pr"], ], }, rename: { usage: "agency phase rename [ | ] [--json]", minArgs: 1, maxArgs: 3, options: ["if-revision", "json"], }, dependency: { usage: "agency phase dependency [ | ] [--json]", minArgs: 2, maxArgs: 4, options: ["if-revision", "json"], }, }, }, sync: { usage: "agency sync [ [phase-id]] [--dry-run] [--json]", options: { ...outputOptions, ...entitySelectorOptions, "dry-run": { type: "boolean" }, }, command: { usage: "agency sync [ [phase-id]] [--dry-run] [--json]", minArgs: 0, maxArgs: 2, options: ["dry-run", "json", "task", "phase"], }, }, archive: { usage: "agency archive [path|list|show|epic|task|tasks|phase]", options: { ...outputOptions, ...entitySelectorOptions, "dry-run": { type: "boolean" }, kind: { type: "string", multiple: true }, status: { type: "string", multiple: true }, repository: { type: "string", multiple: true }, }, command: { usage: "agency archive [path] [--dry-run] [--json]", minArgs: 0, maxArgs: 1, options: ["dry-run", "json"], }, subcommands: { list: { usage: "agency archive list [--kind ] [--status ] [--repository ] [--json]", minArgs: 0, maxArgs: 0, options: ["kind", "status", "repository", "json"], repeatable: ["kind", "status", "repository"], }, show: { usage: "agency archive show [id-or-path] | phase [ | ] [--json]", minArgs: 1, maxArgs: 3, options: ["json"], }, epic: { usage: "agency archive epic [epic] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["dry-run", "json", "epic"], }, task: { usage: "agency archive task [task] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["dry-run", "json", "task"], }, tasks: { usage: "agency archive tasks [--dry-run] [--json]", minArgs: 0, maxArgs: 0, options: ["dry-run", "json"], }, phase: { usage: "agency archive phase [ | ] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 2, maxArgs: 2, options: ["dry-run", "json", "task", "phase"], }, }, }, restore: { usage: "agency restore ", options: { ...outputOptions, ...entitySelectorOptions, "dry-run": { type: "boolean" }, }, subcommands: { epic: { usage: "agency restore epic [epic] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["dry-run", "json", "epic"], }, task: { usage: "agency restore task [task] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 1, options: ["dry-run", "json", "task"], }, phase: { usage: "agency restore phase [ | ] [--dry-run] [--json]", minArgs: 0, targetMinArgs: 2, maxArgs: 2, options: ["dry-run", "json", "task", "phase"], }, }, }, review: { usage: "agency review [task] [--if-revision ] [--json]", options: { ...outputOptions, "if-revision": { type: "string" }, }, subcommands: { refresh: { usage: "agency review refresh [task] [--if-revision ] [--json]", minArgs: 0, maxArgs: 1, options: ["if-revision", "json"], }, finish: { usage: "agency review finish [task] [--if-revision ] [--json]", minArgs: 0, maxArgs: 1, options: ["if-revision", "json"], }, }, }, worktree: { usage: "agency worktree ", options: { ...outputOptions, ...entitySelectorOptions, "dry-run": { type: "boolean" }, force: { type: "boolean" }, }, subcommands: { list: { usage: "agency worktree list [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, inspect: { usage: "agency worktree inspect [task [phase-id]] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 2, options: ["json", "task", "phase"], }, prepare: { usage: "agency worktree prepare [task [phase-id]] [--dry-run] [--force] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 2, options: ["dry-run", "force", "json", "task", "phase"], }, remove: { usage: "agency worktree remove [task [phase-id]] [--dry-run] [--force] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 2, options: ["dry-run", "force", "json", "task", "phase"], }, rebuild: { usage: "agency worktree rebuild [task [phase-id]] [--dry-run] [--force] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 2, options: ["dry-run", "force", "json", "task", "phase"], }, repair: { usage: "agency worktree repair [task [phase-id]] [--dry-run] [--force] [--json]", minArgs: 0, targetMinArgs: 1, maxArgs: 2, options: ["dry-run", "force", "json", "task", "phase"], }, }, }, work: { usage: "agency work [ | --epic ] [--agent ] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]", options: { ...commonOptions, ...entitySelectorOptions, json: { type: "boolean" }, "dry-run": { type: "boolean" }, agent: { type: "string" }, auto: { type: "boolean" }, "print-command": { type: "boolean" }, opencode: { type: "boolean" }, claude: { type: "boolean" }, force: { type: "boolean" }, "allow-working-dependencies": { type: "boolean" }, evidence: { type: "string" }, instructions: { type: "string" }, }, command: { usage: "agency work [ | --epic ] [--agent ] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]", minArgs: 0, maxArgs: 2, options: [ "json", "dry-run", "epic", "task", "phase", "agent", "auto", "print-command", "opencode", "claude", "force", "evidence", "allow-working-dependencies", "instructions", ], conflicts: [ ["opencode", "claude"], ["agent", "opencode"], ["agent", "claude"], ["epic", "$positional"], ["force", "allow-working-dependencies"], ], }, }, pr: { usage: "agency pr create [phase-id] | agency pr [args...]", options: { ...outputOptions, draft: { type: "boolean" }, force: { type: "boolean" }, "force-with-lease": { type: "boolean" }, task: { type: "string" }, phase: { type: "string" }, title: { type: "string" }, head: { type: "string" }, base: { type: "string" }, label: { type: "string", multiple: true }, }, subcommands: { create: { usage: "agency pr create [phase-id] [options]", minArgs: 1, maxArgs: 2, options: [ "draft", "force", "force-with-lease", "json", "task", "phase", "title", "head", "base", "label", ], repeatable: ["label"], }, }, }, push: { usage: "agency push [--force-with-lease] [--json]", options: { ...outputOptions, "force-with-lease": { type: "boolean" }, }, command: { usage: "agency push [--force-with-lease] [--json]", minArgs: 0, maxArgs: 0, options: ["force-with-lease", "json"], }, }, rebase: { usage: "agency rebase [task [phase-id]] [--onto ] | --continue | --abort", options: { ...outputOptions, onto: { type: "string" }, from: { type: "string" }, "dry-run": { type: "boolean" }, continue: { type: "boolean" }, abort: { type: "boolean" }, "if-revision": { type: "string" }, }, command: { usage: "agency rebase [task [phase-id]] [--onto ] [--from ] [--dry-run] [--if-revision ] | --continue | --abort [--json]", minArgs: 0, maxArgs: 2, options: [ "onto", "from", "dry-run", "continue", "abort", "if-revision", "json", ], conflicts: [ ["continue", "abort"], ["continue", "onto"], ["continue", "from"], ["continue", "dry-run"], ["continue", "if-revision"], ["abort", "onto"], ["abort", "from"], ["abort", "dry-run"], ["abort", "if-revision"], ], }, }, attention: { usage: "agency attention [--archived] [--json]", options: { ...outputOptions, archived: { type: "boolean" }, }, command: { usage: "agency attention [--archived] [--json]", minArgs: 0, maxArgs: 0, options: ["archived", "json"], }, }, triage: { usage: "agency triage [--item ] [--archived] [--json]", options: { ...outputOptions, item: { type: "string" }, archived: { type: "boolean" }, }, command: { usage: "agency triage [--item ] [--archived] [--json]", minArgs: 0, maxArgs: 0, options: ["item", "archived", "json"], }, }, supervise: { usage: "agency supervise [--workbase ]", options: commonOptions, command: { usage: "agency supervise [--workbase ]", minArgs: 0, maxArgs: 0, options: [], }, }, next: { usage: "agency next [--select] [--json]", options: { ...outputOptions, select: { type: "boolean" }, }, command: { usage: "agency next [--select] [--json]", minArgs: 0, maxArgs: 0, options: ["select", "json"], }, }, config: { usage: "agency config auto-archive [on|off] [--json]", options: outputOptions, subcommands: { "auto-archive": { usage: "agency config auto-archive [on|off] [--json]", minArgs: 0, maxArgs: 1, options: ["json"], }, }, }, status: { usage: "agency status [filters] [--json]", options: { ...outputOptions, ...viewOptions }, command: { usage: "agency status [filters] [--json]", minArgs: 0, maxArgs: 0, options: ["json", ...viewOptionNames], repeatable: ["status", "repository"], conflicts: viewConflicts, }, }, doctor: { usage: "agency doctor [--json]", options: outputOptions, command: { usage: "agency doctor [--json]", minArgs: 0, maxArgs: 0, options: ["json"], }, }, validate: { usage: "agency validate [path] [--json] [--no-input]", options: { ...outputOptions, }, command: { usage: "agency validate [path] [--json] [--no-input]", minArgs: 0, maxArgs: 1, options: ["json"], }, }, context: { usage: "agency context [target] [--json] [--compact | --full] [--include ]", options: { ...outputOptions, ...entitySelectorOptions, compact: { type: "boolean" }, full: { type: "boolean" }, include: { type: "string", multiple: true }, }, command: { usage: "agency context [target] [--json] [--compact | --full] [--include ]", minArgs: 0, maxArgs: 1, options: ["json", "compact", "full", "epic", "task", "phase", "include"], repeatable: ["include"], conflicts: [["compact", "full"]], }, }, graph: { usage: "agency graph [options]", options: { ...outputOptions, jsonl: { type: "boolean" }, ready: { type: "boolean" }, blocked: { type: "boolean" }, status: { type: "string", multiple: true }, repository: { type: "string", multiple: true }, kind: { type: "string", multiple: true }, include: { type: "string", multiple: true }, }, command: { usage: "agency graph [options]", minArgs: 0, maxArgs: 0, options: [ "json", "jsonl", "ready", "blocked", "status", "repository", "kind", "include", ], repeatable: ["status", "repository", "kind", "include"], conflicts: [ ["json", "jsonl"], ["ready", "blocked"], ], }, }, } satisfies Readonly> const rootOptions = commonOptions const commonOptionNames = new Set(Object.keys(commonOptions)) const preCommandOptions = new Set([ "--help", "-h", "--version", "-V", "--silent", "-s", "--verbose", "-v", "--no-input", ]) const preCommandValueOptions = new Set(["--workbase", "--cwd"]) export interface ParsedCli { readonly commandName?: keyof typeof commands readonly commandPath: string readonly args: string[] readonly passthrough?: boolean readonly values: Record< string, boolean | string | (boolean | string)[] | undefined > } const canonicalCommandPath = ( commandName: string, subcommand: string | undefined, ) => (subcommand ? `${commandName}/${subcommand}` : commandName) class CliUsageError extends Error { readonly _tag = "CliUsageError" readonly commandPath: string constructor( readonly detail: string, readonly usage: string, ) { super(`${detail}\n\nUsage: ${usage}`) this.name = "CliUsageError" const [, commandName, subcommand] = usage.split(/\s+/) const definition: CommandDefinition | undefined = commands[commandName as keyof typeof commands] this.commandPath = !definition ? "invalid" : subcommand && definition.subcommands?.[subcommand] ? `${commandName}/${subcommand}` : commandName! Object.defineProperty(this, "commandPath", { enumerable: false }) } } const usageError = (message: string, usage: string) => new CliUsageError(message, usage) const optionLabel = (name: string) => `--${name}` function findCommandIndex(args: readonly string[]) { for (let index = 0; index < args.length; index++) { const argument = args[index]! if (!argument.startsWith("-")) return index if (argument.startsWith("--workbase=") || argument.startsWith("--cwd=")) { continue } if (preCommandValueOptions.has(argument)) { if (args[index + 1] === undefined) { throw usageError( `Option '${argument}' expects a value.`, "agency [options]", ) } index++ continue } if (!preCommandOptions.has(argument) && !/^-[hVsv]+$/.test(argument)) { throw usageError( `Unknown option '${argument}'.`, "agency [options]", ) } } return -1 } const targetSlots = ( commandName: string, subcommand: string | undefined, ): readonly ("epic" | "task" | "phase")[] => { if (commandName === "epic" && subcommand === "show") return ["epic"] if (commandName === "task" && ["show", "status"].includes(subcommand ?? "")) return ["task"] if (commandName === "phase") return subcommand === "list" ? ["task"] : ["task", "phase"] if (["archive", "restore"].includes(commandName)) return subcommand === "epic" ? ["epic"] : subcommand === "task" ? ["task"] : subcommand === "phase" ? ["task", "phase"] : [] if (commandName === "worktree" && subcommand !== "list") { return ["task", "phase"] } if (commandName === "pr" && subcommand === "create") return ["task", "phase"] return [] } function applyEntitySelectors( commandName: string, subcommand: string | undefined, positionals: readonly string[], values: ParsedCli["values"], spec: LeafCommand, ) { const supplied = ["epic", "task", "phase"].filter( (name) => values[name] !== undefined, ) if (supplied.length === 0) return [...positionals] if (values.phase !== undefined && values.task === undefined) { throw usageError("Option '--phase' requires '--task'.", spec.usage) } if (values.epic !== undefined && (values.task || values.phase)) { throw usageError( "Option '--epic' cannot be combined with '--task' or '--phase'.", spec.usage, ) } if (["act", "work", "context"].includes(commandName)) { if ( positionals.length > (commandName === "work" && positionals[0] === "prepare" ? 1 : 0) ) { throw usageError( "Entity selector options cannot be combined with a positional target.", spec.usage, ) } return [...positionals] } const slots = targetSlots(commandName, subcommand) const selectedSlots = slots.filter((slot) => values[slot] !== undefined) if (selectedSlots.length === 0) return [...positionals] const trailingCount = spec.maxArgs - slots.length if (positionals.length > trailingCount) { throw usageError( "Entity selector options cannot be combined with positional target IDs.", spec.usage, ) } const requiredSlots = slots.slice( 0, Math.max(0, (spec.targetMinArgs ?? spec.minArgs) - trailingCount), ) for (const slot of requiredSlots) { if (values[slot] === undefined) { throw usageError( `Option '--${slot}' is required with explicit selectors.`, spec.usage, ) } } return [ ...slots.flatMap((slot) => typeof values[slot] === "string" ? [values[slot] as string] : [], ), ...positionals, ] } function assertNoDuplicateOptions( tokens: readonly { readonly kind: string; readonly name?: string }[], repeatable: ReadonlySet, usage: string, ) { const counts = new Map() for (const token of tokens) { if (token.kind !== "option" || !token.name) continue const count = (counts.get(token.name) ?? 0) + 1 counts.set(token.name, count) if (count > 1 && !repeatable.has(token.name)) { throw usageError( `Option '${optionLabel(token.name)}' may only be specified once.`, usage, ) } } } function parse(args: readonly string[], options: OptionConfig, usage: string) { try { return parseArgs({ args: [...args], options, strict: true, allowPositionals: true, tokens: true, }) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw usageError(message, usage) } } function validateTaskCreate( values: ParsedCli["values"], spec: LeafCommand, requireRepo: boolean, ) { if (values.purpose !== undefined && values.purpose !== "investigation") { throw usageError( `Invalid '--purpose' value '${String(values.purpose)}'. Expected: investigation.`, spec.usage, ) } const review = values.review !== undefined const pullRequest = values["pull-request"] !== undefined const ref = values.ref !== undefined if (review) { if (pullRequest === ref) { throw usageError( "Option '--review' requires exactly one of '--pull-request' or '--ref'.", spec.usage, ) } for (const option of [ "repo", "reference", "branch", "base", "multi-phase", ] as const) { if (values[option] !== undefined) { throw usageError( `Option '--review' cannot be combined with '${optionLabel(option)}'.`, spec.usage, ) } } return } if (pullRequest || ref) { throw usageError( `Option '${pullRequest ? "--pull-request" : "--ref"}' requires '--review'.`, spec.usage, ) } if (values["multi-phase"]) { for (const option of ["repo", "reference", "branch", "base"] as const) { if (values[option] !== undefined) { throw usageError( `Option '--multi-phase' cannot be combined with '${optionLabel(option)}'.`, spec.usage, ) } } } else if ( requireRepo && values.repo === undefined && values["context-repo"] === undefined ) { throw usageError( "Option '--repo' or '--context-repo' is required unless '--multi-phase' is used.", spec.usage, ) } } function validateGraphOptions(values: ParsedCli["values"], spec: LeafCommand) { const allowed = { status: new Set(["open", "working", "delegated", "done", "dropped"]), kind: new Set(["epic", "task", "phase", "repository", "execution-unit"]), include: new Set(["bodies", "workspace", "git", "pr", "ticket", "runtime"]), } for (const [name, accepted] of Object.entries(allowed)) { const supplied = values[name] const entries = Array.isArray(supplied) ? supplied : supplied === undefined ? [] : [supplied] for (const value of entries) { if (typeof value !== "string" || !accepted.has(value)) { throw usageError( `Invalid '${optionLabel(name)}' value '${String(value)}'. Expected one of: ${[...accepted].join(", ")}.`, spec.usage, ) } } } } function validateContextOptions( values: ParsedCli["values"], spec: LeafCommand, ) { const supplied = values.include const entries = Array.isArray(supplied) ? supplied : supplied === undefined ? [] : [supplied] for (const value of entries) { if (value !== "ticket" && value !== "runtime") { throw usageError( `Invalid '--include' value '${String(value)}'. Expected one of: ticket, runtime.`, spec.usage, ) } } } function validateViewOptions(values: ParsedCli["values"], spec: LeafCommand) { const supplied = values.status const statuses = Array.isArray(supplied) ? supplied : supplied === undefined ? [] : [supplied] const accepted = new Set(["open", "working", "delegated", "done", "dropped"]) for (const status of statuses) { if (typeof status !== "string" || !accepted.has(status)) { throw usageError( `Invalid '--status' value '${String(status)}'. Expected one of: ${[...accepted].join(", ")}.`, spec.usage, ) } } } export function parseCli(args: readonly string[]): ParsedCli { const commandIndex = findCommandIndex(args) if (commandIndex === -1) { const parsed = parse(args, rootOptions, "agency [options]") assertNoDuplicateOptions( parsed.tokens, new Set(), "agency [options]", ) if (parsed.values.silent && parsed.values.verbose) { throw usageError( "Options '--silent' and '--verbose' cannot be combined.", "agency [options]", ) } return { commandPath: "root", args: [], values: parsed.values } } const suppliedCommandName = args[commandIndex]! const commandName = suppliedCommandName === "." ? "act" : suppliedCommandName const definition: CommandDefinition | undefined = commands[commandName as keyof typeof commands] if (!definition) { throw usageError( `Unknown command '${suppliedCommandName}'.`, "agency [options]", ) } const prArgs = args.slice(commandIndex + 1) const taskAwarePrCreate = commandName === "pr" && prArgs[0] === "create" && ((prArgs[1] !== undefined && !prArgs[1].startsWith("-")) || prArgs.some( (argument) => argument === "--task" || argument.startsWith("--task="), )) if (commandName === "pr" && !taskAwarePrCreate) { const parsed = parse( args.slice(0, commandIndex), rootOptions, "agency [options]", ) assertNoDuplicateOptions( parsed.tokens, new Set(), "agency [options]", ) if (parsed.values.silent && parsed.values.verbose) { throw usageError( "Options '--silent' and '--verbose' cannot be combined.", "agency [options]", ) } if (parsed.values.workbase && parsed.values.cwd) { throw usageError( "Options '--workbase' and '--cwd' cannot be combined.", "agency [options]", ) } return { commandName, commandPath: "pr", args: prArgs, passthrough: true, values: parsed.values, } } const commandArgs = [ ...args.slice(0, commandIndex), ...(suppliedCommandName === "." ? ["."] : []), ...args.slice(commandIndex + 1), ] const parsed = parse(commandArgs, definition.options, definition.usage) const subcommand = definition.subcommands ? parsed.positionals[0] : undefined if (definition.subcommands && !subcommand && parsed.values.help) { for (const token of parsed.tokens) { if (token.kind === "option" && !commonOptionNames.has(token.name)) { throw usageError( `Option '${optionLabel(token.name)}' is not valid for this command.`, definition.usage, ) } } assertNoDuplicateOptions(parsed.tokens, new Set(), definition.usage) return { commandName: commandName as keyof typeof commands, commandPath: commandName, args: parsed.positionals, values: parsed.values, } } const selectedSubcommand = definition.subcommands?.[subcommand ?? ""] const spec = selectedSubcommand ?? definition.command if (!spec) { const message = subcommand ? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.` : `A subcommand is required for 'agency ${commandName}'.` throw usageError(message, definition.usage) } let commandPositionals = selectedSubcommand ? parsed.positionals.slice(1) : parsed.positionals const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])]) for (const token of parsed.tokens) { if (token.kind === "option" && !allowed.has(token.name)) { throw usageError( `Option '${optionLabel(token.name)}' is not valid for this command.`, spec.usage, ) } } assertNoDuplicateOptions(parsed.tokens, new Set(spec.repeatable), spec.usage) if (parsed.values.silent && parsed.values.verbose) { throw usageError( "Options '--silent' and '--verbose' cannot be combined.", spec.usage, ) } for (const selector of ["workbase", "cwd", "epic", "task", "phase"]) { if (parsed.values[selector] === "") { throw usageError( `Option '--${selector}' requires a non-empty value.`, spec.usage, ) } } if (parsed.values.workbase && parsed.values.cwd) { throw usageError( "Options '--workbase' and '--cwd' cannot be combined.", spec.usage, ) } if (parsed.values.version) { return { commandName: commandName as keyof typeof commands, commandPath: canonicalCommandPath( commandName, selectedSubcommand ? subcommand : undefined, ), args: parsed.positionals, values: parsed.values, } } if (parsed.values.help) { return { commandName: commandName as keyof typeof commands, commandPath: canonicalCommandPath( commandName, selectedSubcommand ? subcommand : undefined, ), args: parsed.positionals, values: parsed.values, } } commandPositionals = applyEntitySelectors( commandName, selectedSubcommand ? subcommand : undefined, commandPositionals, parsed.values, spec, ) if ( commandPositionals.length < spec.minArgs || commandPositionals.length > spec.maxArgs ) { throw usageError( `Expected ${spec.minArgs === spec.maxArgs ? spec.minArgs : `${spec.minArgs}-${spec.maxArgs}`} positional argument${spec.maxArgs === 1 ? "" : "s"}, received ${commandPositionals.length}.`, spec.usage, ) } for (const name of spec.required ?? []) { if (parsed.values[name] === undefined) { throw usageError(`Option '${optionLabel(name)}' is required.`, spec.usage) } } for (const [left, right] of spec.conflicts ?? []) { const leftSet = parsed.values[left] !== undefined const rightSet = right === "$positional" ? commandPositionals.length > 0 : parsed.values[right] !== undefined if (leftSet && rightSet) { throw usageError( `Option '${optionLabel(left)}' cannot be combined with ${right === "$positional" ? "a positional argument" : `'${optionLabel(right)}'`}.`, spec.usage, ) } } if ( commandName === "task" && (subcommand === "new" || subcommand === "create") ) { validateTaskCreate(parsed.values, spec, subcommand === "create") } if ( ["epic", "task", "phase"].includes(commandName) && subcommand === "new" && parsed.values.auto && !parsed.values.work ) { throw usageError("Option '--auto' requires '--work'.", spec.usage) } if (["task", "phase"].includes(commandName) && subcommand === "dependency") { if (!["add", "remove"].includes(commandPositionals[0] ?? "")) { throw usageError( "Dependency operation must be 'add' or 'remove'.", spec.usage, ) } } if (commandName === "task" && subcommand === "move") { if ( parsed.values.epic === undefined && parsed.values["no-epic"] === undefined ) { throw usageError( "One of '--epic' or '--no-epic' is required.", spec.usage, ) } } if ( ["epic", "task", "phase"].includes(commandName) && subcommand === "update" ) { const mutationNames = (spec.options ?? []).filter( (name) => name !== "json" && name !== "if-revision", ) if (!mutationNames.some((name) => parsed.values[name] !== undefined)) { throw usageError("At least one update option is required.", spec.usage) } } if ( typeof parsed.values["if-revision"] === "string" && !/^[a-f0-9]{64}$/.test(parsed.values["if-revision"]) ) { throw usageError( "Option '--if-revision' must be a 64-character SHA-256 hash.", spec.usage, ) } if (commandName === "graph") { validateGraphOptions(parsed.values, spec) } if (commandName === "context") { validateContextOptions(parsed.values, spec) } if ( commandName === "status" || (commandName === "archive" && subcommand === "list") || (["epic", "task", "phase"].includes(commandName) && subcommand === "list") ) { validateViewOptions(parsed.values, spec) } const nonPrCompletion = parsed.values["no-pull-request"] === true const completionSummary = parsed.values.summary const completionEvidenceUrl = parsed.values["evidence-url"] if ( (completionSummary !== undefined || completionEvidenceUrl !== undefined) && !nonPrCompletion ) { throw usageError( "Options '--summary' and '--evidence-url' require '--no-pull-request'.", spec.usage, ) } if (nonPrCompletion) { if ( typeof completionSummary !== "string" || completionSummary.trim().length === 0 ) { throw usageError( "Option '--summary' is required with '--no-pull-request'.", spec.usage, ) } const completesDone = ["task", "phase"].includes(commandName) && subcommand === "status" && commandPositionals.at(-1) === "done" if (!completesDone) { throw usageError( "Option '--no-pull-request' is valid only with a done status.", spec.usage, ) } } if (commandName === "work") { const preparing = commandPositionals[0] === "prepare" if (preparing && parsed.values.phase && !parsed.values.task) { throw usageError( "Option '--phase' requires '--task' with work preparation.", spec.usage, ) } if ( parsed.values["allow-working-dependencies"] && (parsed.values.epic || (!parsed.values.task && !commandPositionals[preparing ? 1 : 0])) ) { throw usageError( "Option '--allow-working-dependencies' requires an explicit execution-unit target (task ID, directory, or --task with optional --phase).", spec.usage, ) } if ( (!preparing && commandPositionals.length > 1) || (preparing && (parsed.values.epic || parsed.values.opencode || parsed.values.claude || parsed.values.agent || parsed.values.auto || parsed.values["print-command"])) ) { throw usageError( preparing ? "Work preparation cannot be combined with agent or epic options." : "The work command accepts at most one target.", spec.usage, ) } if (parsed.values.instructions !== undefined && !parsed.values.auto) { throw usageError("Option '--instructions' requires '--auto'.", spec.usage) } if ( !preparing && (parsed.values.json || parsed.values["dry-run"] || parsed.values.evidence) ) { throw usageError( "Options '--json', '--dry-run', and '--evidence' are only valid with 'agency work prepare'.", spec.usage, ) } } return { commandName: commandName as keyof typeof commands, commandPath: canonicalCommandPath( commandName, selectedSubcommand ? subcommand : commandName === "work" && commandPositionals[0] === "prepare" ? "prepare" : undefined, ), args: selectedSubcommand ? [subcommand!, ...commandPositionals] : commandPositionals, values: parsed.values, } }