Skip to content

CLI Architecture

The herdctl CLI is a thin wrapper around @herdctl/core. It contains no business logic. Every command parses user arguments, delegates to FleetManager (or JobManager for read-only job queries), formats the result for the terminal, and exits. This page describes how the CLI achieves that and what patterns it uses.

For a bird’s-eye view of how the CLI fits into the broader system, see System Architecture Overview.

CLI command mapping showing each herdctl command delegating to its corresponding FleetManager API method

The CLI follows the project-wide library-first architecture. All fleet management logic lives in @herdctl/core. The CLI adds only:

  • Argument parsing via Commander.js
  • Output formatting with ANSI colors, tables, and relative timestamps
  • Process lifecycle management (PID files, signal handlers, exit codes)
  • Interactive prompts via @inquirer/prompts (for init and cancel confirmation)

If a feature involves decision-making about agents, schedules, jobs, or configuration, it belongs in core, not here.

packages/cli/
├── bin/
│ └── herdctl.js # Entry point (#!/usr/bin/env node)
├── src/
│ ├── index.ts # Commander program definition, all command routing
│ ├── commands/
│ │ ├── init.ts # herdctl init (project scaffolding)
│ │ ├── start.ts # herdctl start (fleet lifecycle)
│ │ ├── stop.ts # herdctl stop (PID-based process signaling)
│ │ ├── status.ts # herdctl status [agent]
│ │ ├── logs.ts # herdctl logs [agent]
│ │ ├── trigger.ts # herdctl trigger <agent>
│ │ ├── config.ts # herdctl config validate / show
│ │ ├── jobs.ts # herdctl jobs (list with filters)
│ │ ├── job.ts # herdctl job <id> (detail and logs)
│ │ ├── cancel.ts # herdctl cancel <id>
│ │ └── sessions.ts # herdctl sessions / sessions resume
│ └── utils/
│ └── colors.ts # ANSI color helpers, NO_COLOR support
├── __tests__/
│ ├── smoke.test.ts # CLI smoke tests
│ └── commands/
│ └── *.test.ts # Per-command unit tests
├── package.json
└── tsconfig.json
DependencyPurpose
@herdctl/coreAll business logic: FleetManager, JobManager, config loading, error types
commanderCommand parsing, option definitions, help generation
@inquirer/promptsInteractive prompts for init and cancel confirmation
@herdctl/webOptional web dashboard, started via --web flag on herdctl start
@herdctl/discordOptional Discord connector, loaded by FleetManager when configured
@herdctl/slackOptional Slack connector, loaded by FleetManager when configured

The CLI does not use chalk or cli-table3. Terminal colors are implemented with raw ANSI escape codes in utils/colors.ts, and tables are built with string padding.

All commands are defined in src/index.ts using Commander.js. The program structure is flat with two command groups:

herdctl
├── init # Scaffold new project
├── start # Start fleet (long-running)
├── stop # Stop fleet via PID signal
├── status [agent] # Fleet overview or agent detail
├── logs [agent] # Log viewing and streaming
├── trigger <agent> # Manual agent trigger
├── jobs # List recent jobs
├── job <id> # Job detail
├── cancel <id> # Cancel running job
├── sessions # List Claude Code sessions
│ └── resume [session-id] # Resume session interactively
└── config # Configuration group
├── validate # Validate configuration
└── show # Show resolved configuration

Each command’s action handler follows the same pattern:

  1. Parse options from Commander
  2. Wrap the command function call in a try/catch
  3. Handle User force closed errors (from inquirer prompts) by exiting cleanly
  4. Re-throw other errors

The actual command logic lives in individual files under src/commands/. Each exports an async function that receives typed options.

Every command delegates to @herdctl/core APIs. The CLI never reads configuration files, manages state, or executes agents directly.

CommandCore API
herdctl initFile system scaffolding (no FleetManager needed)
herdctl startnew FleetManager() then initialize(), start(), streamLogs()
herdctl stopReads PID file, sends OS signals (SIGTERM/SIGKILL)
herdctl statusFleetManager.initialize(), getFleetStatus(), getAgentInfo(), getAgentInfoByName()
herdctl logsFleetManager.streamLogs(), streamAgentLogs(), or JobManager.streamJobOutput()
herdctl triggerFleetManager.initialize(), trigger(), optionally streamJobOutput()
herdctl jobsJobManager.getJobs() with filter
herdctl job <id>JobManager.getJob(), optionally streamJobOutput()
herdctl cancelFleetManager.initialize(), cancelJob()
herdctl config validatesafeLoadConfig() (config loading without FleetManager)
herdctl config showsafeLoadConfig() then formats the ResolvedConfig
herdctl sessionslistSessions() and optionally loadConfig() for workspace paths
herdctl sessions resumelistSessions(), then spawns claude --resume <session-id>

Commands that only need to read job data (jobs, job) use JobManager directly rather than creating a full FleetManager. This avoids configuration validation overhead for read-only queries against the .herdctl/jobs/ directory.

CLI lifecycle flow showing start command, PID file management, signal handling, and graceful shutdown

The start and stop commands use a PID file to coordinate the fleet process lifecycle.

On start:

  1. herdctl start creates a FleetManager, calls initialize() and start()
  2. Writes the current process PID to .herdctl/herdctl.pid
  3. Enters the log streaming loop (streamLogs()), which keeps the process alive
  4. On shutdown (signal or error), removes the PID file

On stop:

  1. herdctl stop reads the PID from .herdctl/herdctl.pid
  2. Checks whether that process is still running via process.kill(pid, 0)
  3. Sends SIGTERM for graceful shutdown (or SIGKILL with --force)
  4. Polls every 100ms to wait for the process to exit (up to --timeout seconds, default 30)
  5. If the timeout expires, escalates to SIGKILL
  6. Removes the PID file after the process has stopped
  7. Cleans up stale PID files if the referenced process is no longer running

The CLI registers signal handlers for graceful shutdown in commands that run indefinitely or stream output.

Registers handlers for both SIGINT and SIGTERM. On signal:

  1. Sets a shutdown guard flag to prevent re-entrant shutdown
  2. Calls manager.stop({ waitForJobs: true, timeout: 30000, cancelOnTimeout: true })
  3. Removes the PID file
  4. Exits with code 0 on success, 1 on error

Registers SIGINT/SIGTERM handlers that set a shutdown flag and exit with code 0. The async log iteration loop checks this flag and breaks cleanly.

Registers SIGINT/SIGTERM handlers that exit with code 130 (128 + SIGINT signal number 2). The job continues running in the background; only the CLI’s wait loop is interrupted.

When streaming live output from a running job, registers signal handlers that exit with code 130.

The CLI supports three output modes depending on context and user flags.

Terminal output uses ANSI color codes for readability. Colors are applied through the shared colorize() function, which checks the NO_COLOR environment variable, FORCE_COLOR, and TTY detection before emitting escape sequences.

Status colors use a consistent scheme:

StatusColor
runningGreen
idle, stopped, initializedYellow
pendingYellow
completedCyan
error, failedRed
cancelledGray

Fleet status displays a structured overview with sections for counts, scheduler state, and an agent table. When agents belong to sub-fleets (composed fleet configurations), the display switches to a hierarchical tree view grouped by fleet path.

Agent detail shows configuration, job history, and per-schedule status with relative timestamps (e.g., “5m ago”, “in 45m”).

Job tables use padded columns with dynamic widths based on content. Column headers are static strings; rows are padded to align.

Log entries are formatted as: <timestamp> <LEVEL> [<source>] (<job-id-prefix>) <message>, with each component individually colored. The source label identifies the agent, scheduler component, or connector platform. Job output types (assistant, tool, result, error, system) each get distinct colors.

Brand colors for connector platforms use 24-bit RGB ANSI sequences for accurate branding: Discord Blurple, Slack Blue, and Web Green. Terminals that do not support true color fall back automatically.

Most commands support a --json flag that outputs structured JSON to stdout. This mode is designed for scripting and CI/CD pipelines:

  • Status output includes the full FleetStatus and AgentInfo[] objects
  • Job lists use a structured { jobs, total, limit } envelope
  • Trigger results include job ID, agent name, schedule, and timing
  • Errors use a consistent { error: { code, message, ... } } envelope
  • Log streaming outputs newline-delimited JSON (NDJSON), one entry per line

JSON output writes to stdout; errors still go to stderr. This allows piping JSON to jq or other processors while still seeing error messages.

The start, logs --follow, trigger --wait, and job --logs commands use async iterables from FleetManager to stream output continuously:

  • FleetManager.streamLogs() yields LogEntry objects via an async iterable
  • FleetManager.streamAgentLogs() filters the log stream to a specific agent
  • JobManager.streamJobOutput() yields job output messages via an event emitter
  • The trigger command receives messages through an onMessage callback during execution, then optionally follows up with streamJobOutput() in wait mode

The streaming loop runs until the iterator completes (fleet stops, job finishes) or is interrupted by a signal.

Errors follow a consistent pattern across all commands. The CLI catches typed error classes from @herdctl/core and formats them with context and actionable suggestions.

Human-readable errors include the error message, optional error code, and a suggested next action:

Error: No configuration file found.
Searched from: /home/user/project
Run 'herdctl init' to create a configuration file.
Error: Agent 'unknown-agent' not found.
Run 'herdctl status' to see all agents.

When --json is active, errors are returned as structured JSON on stdout:

{
"error": {
"code": "AGENT_NOT_FOUND",
"message": "Agent 'unknown-agent' not found in configuration",
"agentName": "unknown-agent"
}
}

Each command handles the error types relevant to its operation using instanceof checks and type guard functions from @herdctl/core:

Error TypeCommandsUser Message
ConfigNotFoundErrorstart, status, logs, trigger, cancelSuggests herdctl init
AgentNotFoundErrorstatus, logs, triggerSuggests herdctl status to list agents
JobNotFoundErrorlogs, job, cancel, jobsSuggests herdctl jobs to list jobs
ScheduleNotFoundErrortriggerSuggests herdctl status <agent> for schedules
ConcurrencyLimitErrortriggerExplains the limit and suggests waiting
SchemaValidationErrorconfig validateShows all validation issues with paths
YamlSyntaxErrorconfig validateShows line/column and common fixes
UndefinedVariableErrorconfig validateSuggests export VAR=value
FleetManagerError (generic)allShows error code and message

The config validate command with --fix provides additional repair suggestions for each validation issue, including type mismatches, missing required fields, unrecognized keys, and invalid enum values.

CodeMeaning
0Success
1General error (config not found, agent not found, job failed, etc.)
130Interrupted by signal (128 + SIGINT signal number 2)

The trigger --wait command exits with the job’s own exit code, propagating success or failure from the agent execution.

Two commands use interactive prompts via @inquirer/prompts:

herdctl init prompts for fleet name, description, and template selection when not running with --yes. The --yes flag accepts all defaults without prompting, making it suitable for scripted setup.

Three built-in templates are available:

TemplateDescription
simpleBasic fleet with one scheduled agent (default)
quickstartMinimal single agent that runs every 30 seconds
githubAgent configured for GitHub Issues work source

herdctl cancel prompts for confirmation before cancelling a job (unless --yes is passed). The prompt shows job details (ID, agent, status, schedule) and warns about force cancellation.

The CLI respects the no-color.org convention. Color output is controlled by three signals, checked in priority order:

  1. NO_COLOR environment variable (any non-empty value disables color)
  2. FORCE_COLOR environment variable (any value other than "0" forces color on)
  3. TTY detection (process.stdout.isTTY) — colors are disabled when piping to a file or another command

Each command module includes its own shouldUseColor() check. The shared utils/colors.ts module provides the canonical implementation along with color constants and helper functions used by start and logs.

The start command accepts --web and --web-port flags to enable the web dashboard alongside the fleet. These flags are translated into FleetConfigOverrides and passed to the FleetManager constructor:

const manager = new FleetManager({
configPath: options.config,
stateDir,
configOverrides: {
web: {
enabled: options.web,
port: options.webPort,
},
},
});

FleetManager handles the dynamic import and initialization of @herdctl/web internally. The CLI does not interact with the web package directly.

The --verbose flag on herdctl start sets HERDCTL_LOG_LEVEL=debug before creating the FleetManager. The start command also registers a global log handler via setLogHandler() from @herdctl/core, which intercepts all createLogger output and formats it with colors, log level badges, and source labels for the terminal.

The sessions command provides visibility into Claude Code sessions created by agents with session persistence. It reads session data directly from .herdctl/sessions/ without requiring a running fleet.

The sessions resume subcommand spawns claude --resume <session-id> as a child process with stdio: "inherit", giving the user an interactive Claude Code session in the agent’s workspace directory. It supports partial session ID matching and agent name lookup for convenience.