#!/usr/bin/env bun import { Effect, Result, Layer } from "effect" import { join, resolve } from "node:path" import { parseCli } from "./src/cli-parser" import { init, help as initHelp } from "./src/commands/init" import { task, help as taskHelp } from "./src/commands/task" import { pr, prCreate, help as prHelp } from "./src/commands/pr" import { push, help as pushHelp } from "./src/commands/push" import { rebase, help as rebaseHelp } from "./src/commands/rebase" import { work, workPrepare, help as workHelp } from "./src/commands/work" import { worktree, help as worktreeHelp } from "./src/commands/worktree" import { status, help as statusHelp } from "./src/commands/status" import { config, help as configHelp } from "./src/commands/config" import { doctor, help as doctorHelp } from "./src/commands/doctor" import { validate, help as validateHelp } from "./src/commands/validate" import { context, help as contextHelp } from "./src/commands/context" import { graph, help as graphHelp } from "./src/commands/graph" import { next, help as nextHelp } from "./src/commands/next" import { attention, help as attentionHelp } from "./src/commands/attention" import { triage, help as triageHelp } from "./src/commands/triage" import { supervise, help as superviseHelp } from "./src/commands/supervise" import { sync, help as syncHelp } from "./src/commands/sync" import { repo, help as repoHelp } from "./src/commands/repo" import { epic, help as epicHelp } from "./src/commands/epic" import { phase, help as phaseHelp } from "./src/commands/phase" import { archive, help as archiveHelp } from "./src/commands/archive" import { restore, help as restoreHelp } from "./src/commands/restore" import { workbase, help as workbaseHelp } from "./src/commands/workbase" import { integration, help as integrationHelp, } from "./src/commands/integration" import type { Command } from "./src/types" import { FileSystemService } from "./src/services/FileSystemService" import { WorkbaseService } from "./src/services/WorkbaseService" import { RepositoryService } from "./src/services/RepositoryService" import { EpicService } from "./src/services/EpicService" import { TaskService } from "./src/services/TaskService" import { PhaseService } from "./src/services/PhaseService" import { WorktreeService } from "./src/services/WorktreeService" import { PullRequestService } from "./src/services/PullRequestService" import { PushService } from "./src/services/PushService" import { RebaseService } from "./src/services/RebaseService" import { ArchiveService } from "./src/services/ArchiveService" import { IntegrationService } from "./src/services/IntegrationService" import { ContextService } from "./src/services/ContextService" import { GraphService } from "./src/services/GraphService" import { SyncService } from "./src/services/SyncService" import { ReadinessService } from "./src/services/ReadinessService" import { GraphMutationService } from "./src/services/GraphMutationService" import { DoctorService } from "./src/services/DoctorService" import { ReviewService } from "./src/services/ReviewService" import { TicketService } from "./src/services/TicketService" import { RuntimeService } from "./src/services/RuntimeService" import { RuntimeBindingService } from "./src/services/RuntimeBindingService" import { PullRequestStatusService } from "./src/services/PullRequestStatusService" import { SupervisionService } from "./src/services/SupervisionService" import { SupervisionStateService } from "./src/services/SupervisionStateService" import { TriageService } from "./src/services/TriageService" import { SupervisorExecutor } from "./src/services/SupervisorExecutor" import { GitVersionControlService, VersionControlService, } from "./src/services/VersionControlService" import { review, help as reviewHelp } from "./src/commands/review" import { act, help as actHelp } from "./src/commands/act" import { collectCommandResult, errorEnvelope, successEnvelope, writeEnvelope, } from "./src/protocol" import { exportUsageEvents, recordUsageEvent, usageOutcomeCode, } from "./src/usage-log" // Create CLI layer with all services const CliLayer = Layer.mergeAll( FileSystemService.layer, WorkbaseService.layer, GitVersionControlService.layer, VersionControlService.layer, RepositoryService.layer, EpicService.layer, TaskService.layer, PhaseService.layer, WorktreeService.layer, PullRequestService.layer, PushService.layer, RebaseService.layer, ArchiveService.layer, IntegrationService.layer, ContextService.layer, GraphService.layer, SyncService.layer, ReadinessService.layer, GraphMutationService.layer, DoctorService.layer, ReviewService.layer, TicketService.layer, RuntimeService.layer, RuntimeBindingService.layer, PullRequestStatusService.layer, SupervisionService.layer, SupervisionStateService.layer, TriageService.layer, SupervisorExecutor.layer, ) /** * Run a command Effect with all services provided */ interface SignalOptions { /** Interrupt the command on SIGINT, SIGTERM, or SIGHUP so finalizers run. */ readonly interruptOnSignal?: boolean /** The exit status after such an interruption completes (default 130). */ readonly interruptedExitCode?: number } async function runEffect( effect: Effect.Effect, options: SignalOptions = {}, ): Promise { const providedEffect = Effect.provide(effect, CliLayer) as Effect.Effect< A, E, never > const toError = (error: unknown) => { if (error instanceof Error) { return error } if ( typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" ) { return new Error(error.message) } return new Error(String(error)) } // Optionally interrupt the command on SIGINT, SIGTERM, or SIGHUP (a closed // terminal) so its finalizers run: child processes in their own process // group are killed, temporary files removed, and a TUI's terminal restored. // A second signal exits immediately. const interrupt = new AbortController() const onSignal = () => { if (interrupt.signal.aborted) process.exit(130) interrupt.abort() } const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const if (options.interruptOnSignal) for (const signal of signals) process.on(signal, onSignal) try { const result = await Effect.runPromise( providedEffect.pipe( Effect.catchDefect((defect) => Effect.fail(toError(defect))), Effect.result, ), { signal: interrupt.signal }, ).catch((error: unknown) => { if (interrupt.signal.aborted) process.exit(options.interruptedExitCode ?? 130) throw error }) if (Result.isFailure(result)) throw result.failure return result.success } finally { for (const signal of signals) process.off(signal, onSignal) } } const runCommand = ( effect: Effect.Effect, options?: SignalOptions, ) => runEffect(effect, options) const resolveInvocationCwd = ( commandName: string, options: Record, ) => runEffect( Effect.gen(function* () { if (commandName === "pr" && options.passthrough) { return resolve(options.cwd ?? process.cwd()) } if ( options.help || commandName === "init" || commandName === "workbase" || commandName === "attention" || commandName === "triage" ) { return resolve(options.cwd ?? process.cwd()) } const workbases = yield* WorkbaseService if (options.workbase) { return yield* workbases.resolveRegistered(options.workbase) } if (options.cwd) { const selectedCwd = resolve(options.cwd) yield* workbases.discover(selectedCwd) return selectedCwd } return yield* workbases.discover(process.cwd()).pipe( Effect.as(process.cwd()), Effect.catchTag("WorkbaseNotFoundError", () => workbases .getDefault() .pipe(Effect.map((entry) => entry?.path ?? process.cwd())), ), ) }), ) // Read version from package.json const packageJson = await Bun.file( new URL("./package.json", import.meta.url), ).json() const VERSION = packageJson.version // Define commands const commands: Record = { usage: { run: async () => { for (const event of await exportUsageEvents()) console.log(JSON.stringify(event)) }, }, act: { run: async (args: string[], options: Record) => { if (options.help) return console.log(actHelp) await runCommand( act({ action: options.action, directory: args[0], auto: options.auto, draft: options.draft, dryRun: options["dry-run"], json: options.json, epicId: options.epic, taskId: options.task, phaseId: options.phase, inputAllowed: options.inputAllowed, exitOnEscape: options.exitOnEscape, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, init: { run: async (args: string[], options: Record) => { if (options.help) { console.log(initHelp) return } await runCommand( init({ path: args[0], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, epic: { run: async (args: string[], options: Record) => { if (options.help) { console.log(epicHelp) return } await runCommand( epic({ subcommand: args[0], args: args.slice(1), ticketUrl: options["ticket-url"], description: options.description, clearDescription: options["clear-description"], ifRevision: options["if-revision"], repos: options.repo, json: options.json, statuses: options.status, repositories: options.repository, ready: options.ready, blocked: options.blocked, pr: options.pr ? true : options["no-pr"] ? false : undefined, work: options.work, auto: options.auto, silent: options.silent, verbose: options.verbose, inputAllowed: options.inputAllowed, cwd: options.cwd, }), ) }, }, pr: { run: async (args: string[], options: Record) => { if (options.help) { console.log(prHelp) return } if (options.passthrough) { process.exitCode = await runEffect(pr(args, options.cwd)) return } await runCommand( prCreate({ taskId: args[1]!, phaseId: args[2], draft: options.draft, force: options.force, forceWithLease: options["force-with-lease"], title: options.title, head: options.head, base: options.base, labels: options.label, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, push: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(pushHelp) return } await runCommand( push({ forceWithLease: options["force-with-lease"], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, rebase: { run: async (args: string[], options: Record) => { if (options.help) { console.log(rebaseHelp) return } await runCommand( rebase({ taskId: args[0], phaseId: args[1], onto: options.onto, from: options.from, dryRun: options["dry-run"], continue: options.continue, abort: options.abort, ifRevision: options["if-revision"], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, phase: { run: async (args: string[], options: Record) => { if (options.help) return console.log(phaseHelp) await runCommand( phase({ subcommand: args[0], args: args.slice(1), description: options.description, clearDescription: options["clear-description"], repo: options.repo?.[0], references: options.reference, branch: options.branch, base: options.base, clearReferences: options["clear-references"], prUrl: options["pr-url"], clearPr: options["clear-pr"], noPullRequest: options["no-pull-request"], summary: options.summary, evidenceUrl: options["evidence-url"], ifRevision: options["if-revision"], dependsOn: options["depends-on"], firstPhase: options["first-phase"], json: options.json, statuses: options.status, repositories: options.repository, ready: options.ready, blocked: options.blocked, pr: options.pr ? true : options["no-pr"] ? false : undefined, work: options.work, auto: options.auto, silent: options.silent, verbose: options.verbose, inputAllowed: options.inputAllowed, cwd: options.cwd, }), ) }, }, archive: { run: async (args: string[], options: Record) => { if (options.help) { console.log(archiveHelp) return } const explicitType = [ "list", "show", "epic", "task", "tasks", "phase", ].includes(args[0] ?? "") await runCommand( archive({ type: explicitType ? args[0] : undefined, args: explicitType ? args.slice(1) : args, json: options.json, dryRun: options["dry-run"], kinds: options.kind, statuses: options.status, repositories: options.repository, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, restore: { run: async (args: string[], options: Record) => { if (options.help) { console.log(restoreHelp) return } await runCommand( restore({ type: args[0], args: args.slice(1), json: options.json, dryRun: options["dry-run"], silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, workbase: { run: async (args: string[], options: Record) => { if (options.help) { console.log(workbaseHelp) return } if (args[0] === "init") { await runCommand( init({ path: args[1], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) return } await runCommand( workbase({ subcommand: args[0], args: args.slice(1), json: options.json, name: options.name, clear: options.clear, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, integration: { run: async (args: string[], options: Record) => { if (options.help) { console.log(integrationHelp) return } await runCommand( integration({ subcommand: args[0], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, repo: { run: async (args: string[], options: Record) => { if (options.help) { console.log(repoHelp) return } await runCommand( repo({ subcommand: args[0], args: args.slice(1), silent: options.silent, verbose: options.verbose, json: options.json, dryRun: options["dry-run"], apply: options.apply, cwd: options.cwd, }), ) }, }, task: { run: async (args: string[], options: Record) => { if (options.help) { console.log(taskHelp) return } await runCommand( task({ subcommand: args[0], args: args.slice(1), ticketUrl: options["ticket-url"], clearTicket: options["clear-ticket"], description: options.description, clearDescription: options["clear-description"], epic: options.epic, repo: options.repo?.[0], review: options.review, pullRequest: options["pull-request"], ref: options.ref, references: options.reference, branch: options.branch, base: options.base, contextRepo: options["context-repo"], contextBase: options["context-base"], contextSlug: options["context-slug"], authoritativeSources: options["authoritative-source"], purpose: options.purpose, sourcePhase: options["source-phase"], clearReferences: options["clear-references"], prUrl: options["pr-url"], clearPr: options["clear-pr"], noPullRequest: options["no-pull-request"], summary: options.summary, evidenceUrl: options["evidence-url"], ifRevision: options["if-revision"], noEpic: options["no-epic"], multiPhase: options["multi-phase"], json: options.json, statuses: options.status, repositories: options.repository, ready: options.ready, blocked: options.blocked, pr: options.pr ? true : options["no-pr"] ? false : undefined, work: options.work, auto: options.auto, silent: options.silent, verbose: options.verbose, inputAllowed: options.inputAllowed, cwd: options.cwd, }), ) }, }, review: { run: async (args: string[], options: Record) => { if (options.help) return console.log(reviewHelp) await runCommand( review({ subcommand: args[0], taskId: args[1], ifRevision: options["if-revision"], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, work: { run: async (args: string[], options: Record) => { if (options.help) { console.log(workHelp) return } const preparing = args[0] === "prepare" await runCommand( (preparing ? workPrepare : work)({ directory: args[preparing ? 1 : 0], epicId: options.epic, json: options.json, dryRun: options["dry-run"], silent: options.silent, verbose: options.verbose, opencode: options.opencode, claude: options.claude, agent: options.agent, auto: options.auto, instructions: options.instructions, printCommand: options["print-command"], force: options.force, allowWorkingDependencies: options["allow-working-dependencies"], inputAllowed: options.inputAllowed, cwd: options.cwd, taskId: options.task, phaseId: options.phase, evidence: options.evidence, }), ) }, }, worktree: { run: async (args: string[], options: Record) => { if (options.help) { console.log(worktreeHelp) return } await runCommand( worktree({ subcommand: args[0], args: args.slice(1), dryRun: options["dry-run"], force: options.force, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, next: { run: async (_args: string[], options: Record) => { if (options.help) return console.log(nextHelp) await runCommand( next({ select: options.select, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, config: { async run(args: string[], options: Record) { if (options.help) { console.log(configHelp) return } await runCommand( config({ silent: options.silent, verbose: options.verbose, json: options.json, cwd: options.cwd, args, }), ) }, }, status: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(statusHelp) return } await runCommand( status({ silent: options.silent, verbose: options.verbose, json: options.json, statuses: options.status, repositories: options.repository, ready: options.ready, blocked: options.blocked, pr: options.pr ? true : options["no-pr"] ? false : undefined, cwd: options.cwd, }), ) }, }, doctor: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(doctorHelp) return } await runCommand( doctor({ silent: options.silent, verbose: options.verbose, json: options.json, cwd: options.cwd, }), ) }, }, validate: { run: async (args: string[], options: Record) => { if (options.help) { console.log(validateHelp) return } await runCommand( validate({ path: args[0], silent: options.silent, verbose: options.verbose, json: options.json, inputAllowed: options.inputAllowed, cwd: options.cwd, }), ) }, }, context: { run: async (args: string[], options: Record) => { if (options.help) { console.log(contextHelp) return } await runCommand( context({ target: options.epic ? join("epics", options.epic) : options.phase ? join("tasks", options.task, "phases", options.phase) : options.task ? join("tasks", options.task) : args[0], compact: options.compact, full: options.full, include: options.include, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, graph: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(graphHelp) return } await runCommand( graph({ json: options.json, jsonl: options.jsonl, ready: options.ready, blocked: options.blocked, statuses: options.status, repositories: options.repository, kinds: options.kind, include: options.include, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, attention: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(attentionHelp) return } await runCommand( attention({ workbase: options.workbase, archived: options.archived, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, triage: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(triageHelp) return } await runCommand( triage({ workbase: options.workbase, archived: options.archived, item: options.item, json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), { interruptOnSignal: true }, ) }, }, supervise: { run: async (_args: string[], options: Record) => { if (options.help) { console.log(superviseHelp) return } await runCommand( supervise({ workbase: options.workbase, inputAllowed: options.inputAllowed, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), // Background triage runs stop with their process groups, the // terminal is restored, and the supervisor exits cleanly. { interruptOnSignal: true, interruptedExitCode: 0 }, ) }, }, sync: { run: async (args: string[], options: Record) => { if (options.help) { console.log(syncHelp) return } await runCommand( sync({ taskId: args[0], phaseId: args[1], dryRun: options["dry-run"], json: options.json, silent: options.silent, verbose: options.verbose, cwd: options.cwd, }), ) }, }, } function showMainHelp() { console.log(` agency v${VERSION} Usage: agency [options] Run agency without a command to open the TUI. Commands: act Open the interactive workbase init [path] Initialize an Agency workbase workbase Manage registered workbases integration Inspect or sync managed integration files usage export Export local usage events as JSON Lines epic Manage epics phase Manage task phases archive Archive a work item task Manage tasks work [directory|task] Work on an epic, task, or phase worktree Inspect and maintain managed workspaces next List or select ready execution units attention Report work and sessions that need attention triage Assess supervised work and rank next actions supervise Open act on its Supervise tab pr create / pr [...] Create an Agency PR or run gh pr with repository focus push Validate and publish the current execution unit rebase [phase] Rebase a checkout onto a new base and record it review refresh|finish Refresh or finish a pinned review task repo Manage workbase repositories status Show status for the current workbase config Show or change workbase settings doctor Diagnose workbase health and integrations validate [path] Validate a workbase context [target] Return complete target context graph Export the complete workbase graph sync Reconcile declarations with external state Global Options: -h, --help Show help for a command -V, --version Show version number -s, --silent Suppress output messages -v, --verbose Show verbose output including detailed debugging info --no-input Never open an interactive prompt or selector --workbase Use a registered workbase ID, name, or path --cwd Resolve context from this directory Examples: agency init # Initialize the current directory agency task list # List tasks agency work tasks/refresh-cli-copy # Start working on a task For more information about a command, run: agency --help `) } const machineMode = process.argv .slice(2) .some((argument) => argument === "--json" || argument === "--jsonl") const invocationStartedAt = performance.now() const rawArguments = process.argv.slice(2) let usageCommandPath = "invalid" let usageFlagNames: string[] = [] const pushUsageDetails = (error?: unknown) => { if (usageCommandPath !== "push") return {} const seen = new Set() const visit = ( value: unknown, ): { stage?: string; category?: string } | null => { if (typeof value !== "object" || value === null || seen.has(value)) return null seen.add(value) if ( "stage" in value && typeof value.stage === "string" && "category" in value && typeof value.category === "string" ) { return { stage: value.stage, category: value.category } } for (const nestedValue of [ ...Object.values(value), ...Object.getOwnPropertySymbols(value).map( (symbol) => (value as Record)[symbol], ), ]) { const nested = visit(nestedValue) if (nested) return nested } return null } const details = error ? visit(error) : null return { vcs: "git" as const, terminalStage: details?.stage ?? (error ? "unknown" : "publish"), category: details?.category ?? (error ? "unknown" : "success"), } } try { const parsed = parseCli(rawArguments) const { commandName, commandPath, args: commandArgs, passthrough, values, } = { ...parsed, ...(!parsed.commandName && !parsed.values.help && !parsed.values.version ? { commandName: "act", commandPath: "act" } : {}), } usageCommandPath = commandPath usageFlagNames = Object.entries(values) .filter(([, value]) => value !== undefined && value !== false) .map(([name]) => name) // Handle global flags if (values.version) { if (machineMode) { writeEnvelope(successEnvelope({ version: VERSION })) } else { console.log(`v${VERSION}`) } await recordUsageEvent( { commandPath: "version", flagNames: usageFlagNames, durationMs: performance.now() - invocationStartedAt, outcome: "success", outcomeCode: "SUCCESS", exitStatus: 0, }, VERSION, ) process.exit(0) } // Get command // Show help if no command if (!commandName) { showMainHelp() const exitStatus = values.help ? 0 : 1 await recordUsageEvent( { commandPath: "help", flagNames: usageFlagNames, durationMs: performance.now() - invocationStartedAt, outcome: exitStatus === 0 ? "success" : "failure", outcomeCode: exitStatus === 0 ? "SUCCESS" : "NONZERO_EXIT", exitStatus, }, VERSION, ) process.exit(exitStatus) } const command = commands[commandName]! const inputAllowed = !values.json && !values["no-input"] && Boolean(process.stdin.isTTY && process.stdout.isTTY) const cwd = await resolveInvocationCwd(commandName, { ...values, passthrough, }) if (values.json || (values.jsonl && values.help)) { const result = await collectCommandResult(() => command.run(commandArgs, { ...values, cwd, inputAllowed, passthrough }), ) writeEnvelope(successEnvelope(result)) } else if (values.jsonl) { await command.run(commandArgs, { ...values, cwd, inputAllowed: false, passthrough, }) } else { await command.run(commandArgs, { ...values, cwd, inputAllowed, exitOnEscape: Boolean(parsed.commandName), passthrough, }) } const exitStatus = Number(process.exitCode ?? 0) await recordUsageEvent( { commandPath: usageCommandPath, flagNames: usageFlagNames, durationMs: performance.now() - invocationStartedAt, outcome: exitStatus === 0 ? "success" : "failure", outcomeCode: exitStatus === 0 ? "SUCCESS" : "NONZERO_EXIT", exitStatus, ...pushUsageDetails(), }, VERSION, ) } catch (error) { if ( typeof error === "object" && error !== null && "commandPath" in error && typeof error.commandPath === "string" ) { usageCommandPath = error.commandPath } await recordUsageEvent( { commandPath: usageCommandPath, flagNames: usageFlagNames, durationMs: performance.now() - invocationStartedAt, outcome: "failure", outcomeCode: usageOutcomeCode(error), exitStatus: 1, ...pushUsageDetails(error), }, VERSION, ) if (machineMode) { writeEnvelope(errorEnvelope(error)) process.exit(1) } if (error instanceof Error) { let message = error.message const details: any = error for (const [field, label] of [ ["completed", "Completed"], ["rolledBack", "Rolled back"], ["manualRecovery", "Manual recovery"], ] as const) { if (Array.isArray(details[field]) && details[field].length > 0) message += `\n${label}: ${details[field].join("; ")}` } if (typeof details.remediation === "string") message += `\nRemediation: ${details.remediation}` console.error(`ⓘ ${message}`) } else { console.error("An unexpected error occurred:", error) } process.exit(1) }