#!/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