cli-design

$npx mdskill add joelhooks/joelclaw/cli-design

Designs agent-first CLIs with JSON responses, HATEOAS navigation, and self-documenting commands for tool creation or review.

  • Helps build new CLI tools or add commands to existing ones like joelclaw and slog.
  • Integrates with HATEOAS principles and structured JSON output for agent parsing.
  • Triggers on tasks involving CLI design, command addition, or agent-friendly output.
  • Presents results as JSON with next-action templates for seamless agent workflows.
SKILL.md
.github/skills/cli-designView on GitHub ↗
---
name: cli-design
displayName: CLI Design
description: "Design and build agent-first CLIs with HATEOAS JSON responses, context-protecting output, and self-documenting command trees. Use when creating new CLI tools, adding commands to existing CLIs (joelclaw, slog), or reviewing CLI design for agent-friendliness. Triggers on 'build a CLI', 'add a command', 'CLI design', 'agent-friendly output', or any task involving command-line tool creation."
version: 1.1.0
author: Joel Hooks
tags: [joelclaw, cli, agentic, ux, json]
---

# Agent-First CLI Design

CLIs in this system are **agent-first, human-distant-second**. Every command returns structured JSON that an agent can parse, act on, and follow. Humans are welcome to pipe through `jq`.

## Core Principles

### 1. JSON always

Every command returns JSON. No plain text. No tables. No color codes. Agents parse JSON; they don't parse prose.

```bash
# This is the ONLY output format
joelclaw status
# → { "ok": true, "command": "joelclaw status", "result": {...}, "next_actions": [...] }
```

No `--json` flag. No `--human` flag. JSON is the default and only format.

### 2. HATEOAS — every response tells you what to do next

Every response includes `next_actions` — an array of command **templates** the agent can run next. Templates use standard POSIX/docopt placeholder syntax:

- `<placeholder>` — required argument
- `[--flag <value>]` — optional flag with value
- `[--flag]` — optional boolean flag
- No `params` field — literal command (run as-is)
- `params` present — template (agent fills placeholders)
- `params.*.value` — pre-filled from context (agent can override)
- `params.*.default` — value if omitted
- `params.*.enum` — valid choices

```json
{
  "ok": true,
  "command": "joelclaw send pipeline/video.download",
  "result": {
    "event_id": "01KHF98SKZ7RE6HC2BH8PW2HB2",
    "status": "accepted"
  },
  "next_actions": [
    {
      "command": "joelclaw run <run-id>",
      "description": "Check run status for this event",
      "params": {
        "run-id": { "value": "01KHF98SKZ7RE6HC2BH8PW2HB2", "description": "Run ID (ULID)" }
      }
    },
    {
      "command": "joelclaw logs <source> [--lines <lines>] [--grep <text>] [--follow]",
      "description": "View worker logs",
      "params": {
        "source": { "enum": ["worker", "errors", "server"], "default": "worker" }
      }
    },
    {
      "command": "joelclaw status",
      "description": "Check system health"
    }
  ]
}
```

`next_actions` are **contextual** — they change based on what just happened. A failed command suggests different next steps than a successful one. Templates are the agent's **affordances** — they show what's parameterizable, what values are valid, and what the current context pre-fills.

### 3. Self-documenting command tree

Agents discover commands via **two paths**: the root command (JSON tree) and `--help` (Effect CLI auto-generated). Both must be useful.

**Root command (no args)** returns the full command tree as JSON:



```json
{
  "ok": true,
  "command": "joelclaw",
  "result": {
    "description": "JoelClaw — personal AI system CLI",
    "health": { "server": {...}, "worker": {...} },
    "commands": [
      { "name": "send", "description": "Send event to Inngest", "usage": "joelclaw send <event> -d '<json>'" },
      { "name": "status", "description": "System status", "usage": "joelclaw status" },
      { "name": "gateway", "description": "Gateway operations", "usage": "joelclaw gateway status" }
    ]
  },
  "next_actions": [...]
}
```

**`--help` output** is auto-generated by Effect CLI from `Command.withDescription()`. Every subcommand **must** have a description — agents always call `--help` and a bare command list with no descriptions is useless.

```typescript
// ❌ Agents see a blank command list
const status = Command.make("status", {}, () => ...)

// ✅ Agents see what each command does
const status = Command.make("status", {}, () => ...).pipe(
  Command.withDescription("Active sessions, queue depths, Redis health")
)
```

```
COMMANDS
  - status                          Active sessions, queue depths, Redis health
  - diagnose [--hours integer]      Layer-by-layer health check
  - review [--hours integer]        Recent session context
```

### 4. Context-protecting output

Agents have finite context windows. CLI output must not blow them up.

**Rules:**
- Terse by default — minimum viable output
- Auto-truncate large outputs (logs, lists) at a reasonable limit
- When truncated, include a file path to the full output
- Never dump raw logs, full transcripts, or unbounded lists

```json
{
  "ok": true,
  "command": "joelclaw logs",
  "result": {
    "lines": 20,
    "total": 4582,
    "truncated": true,
    "full_output": "/var/folders/.../joelclaw-logs-abc123.log",
    "entries": ["...last 20 lines..."]
  },
  "next_actions": [
    {
      "command": "joelclaw logs <source> [--lines <lines>]",
      "description": "Show more log lines",
      "params": {
        "source": { "enum": ["worker", "errors", "server"], "default": "worker" },
        "lines": { "default": 20, "description": "Number of lines" }
      }
    }
  ]
}
```

### 5. Errors suggest fixes

When something fails, the response includes a `fix` field — plain language telling the agent what to do about it.

```json
{
  "ok": false,
  "command": "joelclaw send pipeline/video.download",
  "error": {
    "message": "Inngest server not responding",
    "code": "SERVER_UNREACHABLE"
  },
  "fix": "Start the Inngest server pod: kubectl rollout restart statefulset/inngest -n joelclaw",
  "next_actions": [
    { "command": "joelclaw status", "description": "Re-check system health after fix" },
    {
      "command": "kubectl get pods [--namespace <ns>]",
      "description": "Check pod status",
      "params": { "ns": { "default": "joelclaw" } }
    }
  ]
}
```

## Response Envelope

Every command uses this exact shape:

### Success

```typescript
{
  ok: true,
  command: string,          // the command that was run
  result: object,           // command-specific payload
  next_actions: Array<{
    command: string,        // command template (POSIX syntax) or literal
    description: string,    // what it does
    params?: Record<string, {   // presence = command is a template
      description?: string,     // what this param means
      value?: string | number,  // pre-filled from current context
      default?: string | number,// value if omitted
      enum?: string[],          // valid choices
      required?: boolean        // true for <positional> args
    }>
  }>
}
```

### Error

```typescript
{
  ok: false,
  command: string,
  error: {
    message: string,        // what went wrong
    code: string            // machine-readable error code
  },
  fix: string,              // plain-language suggested fix
  next_actions: Array<{
    command: string,        // command template or literal
    description: string,
    params?: Record<string, { ... }>  // same schema as success
  }>
}
```

### Reference implementations

- `joelclaw` — `~/Code/joelhooks/joelclaw/packages/cli/` (Effect CLI, operational surface)
- `slog` — system log CLI (same envelope patterns)

Use these as the current envelope source-of-truth.

## Implementation

### Framework: Effect CLI (@effect/cli)

All CLIs use `@effect/cli` with Bun. This is non-negotiable — consistency across the system matters more than framework preference.

```typescript
import { Command, Options } from "@effect/cli"
import { NodeContext, NodeRuntime } from "@effect/platform-node"

const send = Command.make("send", {
  event: Options.text("event"),
  data: Options.optional(Options.text("data").pipe(Options.withAlias("d"))),
}, ({ event, data }) => {
  // ... execute, return JSON envelope
})

const root = Command.make("joelclaw", {}, () => {
  // Root: return health + command tree
}).pipe(Command.withSubcommands([send, status, logs]))
```

### Binary distribution

Build with Bun, install to `~/.bun/bin/`:

```bash
bun build src/cli.ts --compile --outfile joelclaw
cp joelclaw ~/.bun/bin/
```

### Adding a new command

1. Define the command with `Command.make`
2. Return the standard JSON envelope (ok, command, result, next_actions)
3. Include contextual `next_actions` — what makes sense AFTER this specific command
4. Handle errors with the error envelope (ok: false, error, fix, next_actions)
5. Add to the root command's subcommands
6. Add to the root command's `commands` array in the self-documenting output
7. Rebuild and install

## Streaming Protocol (NDJSON) — ADR-0058

Request-response covers the **spatial** dimension (what's the state now?). Streamed NDJSON covers the **temporal** dimension (what's happening over time?). Together they make the full system observable through one protocol.

### When to stream

Stream when the command involves **temporal operations** — watching, following, tailing. Not every command needs streaming. Point-in-time queries (`status`, `functions`, `runs`) stay as single envelopes.

Streaming is activated by command semantics (`--follow`, `watch`, `gateway stream`), never by a global `--stream` flag.

### Protocol: typed NDJSON with HATEOAS terminal

Each line is a self-contained JSON object with a `type` discriminator. The **last line is always the standard HATEOAS envelope** (`result` or `error`). Tools that don't understand streaming read the last line and get exactly what they expect.

```
{"type":"start","command":"joelclaw send video/download --follow","ts":"2026-02-19T08:25:00Z"}
{"type":"step","name":"download","status":"started","ts":"..."}
{"type":"progress","name":"download","percent":45,"ts":"..."}
{"type":"step","name":"download","status":"completed","duration_ms":3200,"ts":"..."}
{"type":"step","name":"transcribe","status":"started","ts":"..."}
{"type":"log","level":"warn","message":"Large file, chunked transcription","ts":"..."}
{"type":"step","name":"transcribe","status":"completed","duration_ms":45000,"ts":"..."}
{"type":"result","ok":true,"command":"...","result":{...},"next_actions":[...]}
```

### Stream event types

| Type | Meaning | Terminal? |
|------|---------|-----------|
| `start` | Stream begun, echoes command | No |
| `step` | Inngest step lifecycle (started/completed/failed) | No |
| `progress` | Progress update (percent, bytes, message) | No |
| `log` | Diagnostic message (info/warn/error level) | No |
| `event` | An Inngest event was emitted (fan-out visibility) | No |
| `result` | HATEOAS success envelope — always last | **Yes** |
| `error` | HATEOAS error envelope — always last | **Yes** |

### TypeScript types

```typescript
import type { NextAction } from "./response"

type StreamEvent =
  | { type: "start"; command: string; ts: string }
  | { type: "step"; name: string; status: "started" | "completed" | "failed"; duration_ms?: number; error?: string; ts: string }
  | { type: "progress"; name: string; percent?: number; message?: string; ts: string }
  | { type: "log"; level: "info" | "warn" | "error"; message: string; ts: string }
  | { type: "event"; name: string; data: unknown; ts: string }
  | { type: "result"; ok: true; command: string; result: unknown; next_actions: NextAction[] }
  | { type: "error"; ok: false; command: string; error: { message: string; code: string }; fix: string; next_actions: NextAction[] }
```

### Emitting stream events

Use the `emit()` helper — one JSON line per call, flushed immediately:

```typescript
import { emit, emitResult, emitError } from "../stream"

// Progress events
emit({ type: "start", command: "joelclaw send video/download --follow", ts: new Date().toISOString() })
emit({ type: "step", name: "download", status: "started", ts: new Date().toISOString() })
emit({ type: "step", name: "download", status: "completed", duration_ms: 3200, ts: new Date().toISOString() })

// Terminal — always last
emitResult("send --follow", { videoId: "abc123" }, [
  { command: "joelclaw run abc123", description: "Inspect the completed run" },
])
```

### Redis subscription pattern

Streaming commands subscribe to the same Redis pub/sub channels the gateway extension uses. `pushGatewayEvent()` middleware is the emission point — the CLI is just another subscriber.

```typescript
import { streamFromRedis } from "../stream"

// Subscribe to a channel, transform events, emit NDJSON
await streamFromRedis({
  channel: `joelclaw:notify:gateway`,
  command: "joelclaw gateway stream",
  transform: (event) => ({
    type: "event" as const,
    name: event.type,
    data: event.data,
    ts: new Date().toISOString(),
  }),
  // Optional: end condition
  until: (event) => event.type === "loop.complete",
})
```

### Composable with Unix tools

NDJSON is pipe-native. Agents and humans can filter streams:

```bash
# Only step events
joelclaw watch | jq --unbuffered 'select(.type == "step")'

# Only failures
joelclaw send video/download --follow | jq --unbuffered 'select(.type == "error" or (.type == "step" and .status == "failed"))'

# Count steps
joelclaw send pipeline/run --follow | jq --unbuffered 'select(.type == "step" and .status == "completed")' | wc -l
```

### Agent consumption pattern

Agents consuming streams read lines as they arrive and can make decisions mid-execution:

1. Start the stream: `joelclaw send video/download --follow`
2. Read lines incrementally
3. React to early signals (cancel if error, escalate if slow, log progress)
4. The terminal `result`/`error` line contains `next_actions` for what to do after

This eliminates the **polling tax** — no wasted tool calls checking "is it done yet?"

### Cleanup

Streaming commands hold a Redis connection. They **must**:
- Handle SIGINT/SIGTERM gracefully (disconnect Redis, emit terminal event)
- Use `connectTimeout` and `commandTimeout` to prevent hangs
- Clean up the subscription on stream end (success, error, or signal)

## Anti-Patterns

| Don't | Do |
|-------|-----|
| Plain text output | JSON envelope |
| Tables with ANSI colors | JSON arrays |
| `--json` flag to opt into JSON | JSON is the only format |
| Dump 10,000 lines | Truncate + file pointer |
| `Error: something went wrong` | `{ ok: false, error: {...}, fix: "..." }` |
| Undiscoverable commands | Root returns full command tree |
| Static help text | HATEOAS next_actions |
| `console.log("Success!")` | `{ ok: true, result: {...} }` |
| Exit code as the only error signal | Error in JSON + exit code |
| Require the agent to read --help | Root command self-documents |
| Subcommand with no `withDescription` | Every command gets a description for `--help` |
| Poll in a loop for temporal data | Stream NDJSON via Redis sub (ADR-0058) |
| Plain text in streaming commands | Every line is a typed JSON object |
| Hold Redis connections without cleanup | SIGINT handler + connection timeout |

## Naming Conventions

- Commands are **nouns or verbs**, lowercase, no hyphens: `send`, `status`, `logs`, `gateway`
- Subcommands follow naturally: `joelclaw search "query"`, `joelclaw loop start`
- Flags use `--kebab-case`: `--max-quality`, `--follow`
- Short flags for common options: `-d` for `--data`, `-f` for `--follow`
- Event names use `domain/action`: `pipeline/video.download`, `content/summarize`

## Checklist for New Commands

- [ ] Returns JSON envelope (ok, command, result, next_actions)
- [ ] `Command.withDescription()` set (shows in `--help`)
- [ ] Error responses include fix field
- [ ] Root command lists this command in its tree
- [ ] Output is context-safe (truncated if potentially large)
- [ ] next_actions are contextual to what just happened
- [ ] next_actions with variable parts use template syntax (`<required>`, `[--flag <value>]`) + `params`
- [ ] Context-specific values pre-filled via `params.*.value`
- [ ] No plain text output anywhere
- [ ] No ANSI colors or formatting
- [ ] Works when piped (no TTY detection)
- [ ] Builds and installs to ~/.bun/bin/

## TODO

- [ ] **OAuth device flow pattern for CLI auth**: Document the GitHub device flow → broker → session token → env materialization pattern proven in the `skillrecordings/support` repo. Covers: device code polling loop, org/team membership gating, short-lived AES-GCM session tokens, age-encrypted secret delivery to CLI ephemeral keypairs, in-memory-only env injection. Reference implementation: `apps/front/lib/broker/` + `apps/front/app/api/auth/device/` in `skillrecordings/support`. This eliminates 1Password CLI as a developer dependency while keeping server-side secret management intact.
More from joelhooks/joelclaw