---
name: forge-cli-design
description: CLI design discipline. Unix-philosophy small composable commands, exit codes that mean things, subcommands with `noun verb` shape, --help that fits one screen, stdout for data + stderr for chatter, JSON output mode for piping, dry-run for destructive operations, config from flag > env > file. Contains paste-ready argument-parser patterns and a complete worked CLI. Use when designing a new CLI tool or auditing one.
license: MIT
---

# forge-cli-design

You are writing a CLI tool that other people will pipe into shell scripts, schedule via cron, and run in CI. Default agent-written CLIs print mixed prose-and-data to stdout, return exit code 0 on every outcome, accept config only via flags, and produce no machine-readable output. This skill exists to fix that.

The mental model: **a CLI is a small program with a wire protocol of arguments, stdin, stdout, stderr, and exit codes.** Stick to the protocol and the CLI composes with everything else. Break it and the CLI is hostile to scripts.

## Quick reference (the things you must never ship)

1. Exit 0 on failure (or exit 1 on success).
2. Progress chatter / debug logs on stdout.
3. Data output that mixes a "header" line and the data rows (`grep` then has to skip the header).
4. No `--help`, or a `--help` that opens an interactive prompt.
5. Destructive command without `--dry-run` and without a confirmation step in interactive mode.
6. Config only via flags (no env var override, no config file).
7. `--verbose` that adds 100x the output instead of being parseable.
8. Subcommand named with a verb-first English phrase: `do-thing` instead of `thing do`.
9. No `--version` flag.
10. Reading stdin without a `-` convention (e.g., `cat foo.txt | tool process` should work).

## Hard rules

### Exit codes

**1. Exit 0 = success. Non-zero = failure.** No exceptions.

| Code | Meaning |
| --- | --- |
| 0 | Success |
| 1 | General error (catch-all) |
| 2 | Misuse / invalid arguments |
| 64-78 | (sysexits.h) - more specific if you want them |

**2. Distinct exit codes for distinct failure modes.** A linter:

```
0   no issues
1   issues found (lint job failed)
2   misuse (bad flag)
3   internal error (the tool crashed)
```

**3. Document exit codes in `--help` and the README.**

### Subcommand shape

**4. `tool noun verb`, not `tool verb-noun`.** Read as "git remote add", "kubectl get pods" - the noun groups the verbs.

```
GOOD                       BAD
tool order create          tool create-order
tool order list            tool list-orders
tool order show <id>       tool show-order <id>
tool migration apply       tool apply-migration
```

**5. Use `--help` on every level.** `tool --help`, `tool order --help`, `tool order create --help`.

**6. `--help` fits in one screen for the top level.** A 200-line `--help` is a manual; put that in `man` or `tool docs`.

### Stdout vs stderr

**7. Stdout is for the data. Stderr is for the chatter.** Progress, info, warnings, debug all go to stderr.

```bash
# this should work:
tool order list --json | jq '.[] | .total_cents' | awk '...'

# if you logged "Fetching orders..." to stdout, the jq fails.
```

**8. Errors go to stderr, never stdout.**

```ts
// reference: typed output
function outData(json: unknown) {
  process.stdout.write(JSON.stringify(json) + "\n");
}
function outLog(msg: string) {
  process.stderr.write(msg + "\n");
}
function outErr(msg: string) {
  process.stderr.write(`error: ${msg}\n`);
}
```

**9. Errors include enough context to act on.**

```
BAD:  error: invalid argument
GOOD: error: --limit must be an integer 1-200, got "abc"
```

### Output formats

**10. Default: human-readable.** Tables, colors when terminal supports it.

**11. Always support `--json` for machine-readable output.**

```bash
tool order list                     # pretty table for humans
tool order list --json              # newline-delimited JSON or a JSON array
```

**12. Newline-delimited JSON (`jsonl`) over JSON arrays for streaming.** Each line is parseable independently; the consumer can `tail -f`.

**13. `--quiet` / `-q` suppresses chatter. `--verbose` / `-v` adds detail.** Both leave the DATA on stdout unchanged.

**14. Disable color on `NO_COLOR=1` or when stdout is not a TTY.**

```ts
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
```

### Flags and config precedence

**15. Config precedence: flag > env > config file > built-in default.**

```ts
const limit =
  parsedFlags.limit                       // 1. --limit 50
  ?? process.env.TOOL_LIMIT               // 2. TOOL_LIMIT=50
  ?? config.limit                         // 3. ~/.tool/config.toml
  ?? 50;                                  // 4. built-in default
```

**16. Long flags are `--kebab-case`, short flags are single `-x`.**

```
--limit 50
-l 50           (if reserved for limit; do not reuse for other things)
--dry-run
-n              (the de-facto standard short for --dry-run)
```

**17. Use `--` to terminate option parsing.**

```bash
tool delete -- --weird-filename
```

### Destructive operations

**18. Destructive commands support `--dry-run`.** Output what would happen, do not do it.

```bash
tool order delete 01HXY...                    # actually deletes
tool order delete 01HXY... --dry-run          # prints what would be deleted, no action
```

**19. Interactive confirmation by default for destructive commands. `--yes` / `-y` skips.**

```
$ tool order delete 01HXY...
This will permanently delete order 01HXY... and 12 related records.
Continue? [y/N]:
```

**20. Default to "no" on the confirm prompt.** Capital `[y/N]`, default `N`.

### Reading input

**21. Reading stdin uses the `-` convention.**

```bash
tool lint file.sql              # read file.sql
tool lint -                     # read from stdin
cat file.sql | tool lint -      # piped
```

**22. Pipe-friendly: no progress bar when stdout is not a TTY.**

### Versioning

**23. `--version` prints `<tool-name> <semver>` and exits 0.** Not "Welcome to tool!"

```
$ tool --version
forge-skill 0.5.0
```

### Configuration files

**24. Config file location follows the platform conventions.**
- Linux: `$XDG_CONFIG_HOME/<tool>/config.toml` (default `~/.config/<tool>/config.toml`)
- macOS: same, or `~/Library/Application Support/<tool>/`
- Windows: `%APPDATA%\<tool>\config.toml`

**25. Config file format is TOML or YAML, not JSON.** JSON forbids comments; config files need comments.

### Error handling

**26. Never crash on a typed exception. Print the error, exit non-zero.**

**27. `--debug` shows the full stack trace. Without it, show a one-line error.**

```
$ tool order create --customer 999
error: customer 999 not found

$ tool order create --customer 999 --debug
error: customer 999 not found
  at CustomerRepository.findById (src/repos/customer.ts:42)
  at createOrder (src/handlers/order.ts:18)
  ...
```

### Help and discoverability

**28. `--help` lists subcommands with one-line descriptions.**

```
tool — manage orders and customers

Usage:
  tool <command> [options]

Commands:
  order        Manage orders (list, create, show, delete)
  customer     Manage customers (list, create)
  migrate      Apply / roll back schema migrations
  config       View or edit configuration

Options:
  --json       Output machine-readable JSON
  --quiet, -q  Suppress non-error output
  --debug      Print stack traces on error
  --version    Print version and exit

Run `tool <command> --help` for command-specific help.
```

**29. List exit codes in `tool --help` or `man tool`.**

### Performance

**30. Startup time matters.** A CLI that takes 300ms to print `--help` is annoying in pipelines. Lazy-load.

**31. Long operations show progress (to STDERR), but only if stdout is a TTY.** No progress bar when piping.

## Common AI-output patterns to reject

| Pattern | Why bad | Fix |
| --- | --- | --- |
| `console.log("Fetching...")` | Chatter on stdout corrupts pipelines | Stderr for chatter |
| `exit 0` on every code path | Pipelines cannot detect failure | Distinct exit codes |
| No `--json` | Not pipeable | Always offer machine-readable mode |
| Header + data on same stream | `grep` skips data + header | Either no header in `--json`, or header on stderr |
| `verb-noun` subcommand shape | Hard to scan | `noun verb` |
| Destructive command no `--dry-run` | Foot-gun | `--dry-run` first |
| No confirmation on `delete --all` | Foot-gun | Default interactive confirm + `--yes` to skip |
| Progress bar always on | Garbles when piped | TTY-detect |
| Config only via flags | Cannot set defaults | flag > env > file precedence |
| Color in `\033[...]` always | Garbled in logs | TTY-detect + honor `NO_COLOR` |
| `--help` is 200 lines | Manual, not help | Move to docs / man |

## Worked example: a small CLI structure

```ts
// src/cli.ts (entry point)
import { Command } from "commander";   // or @clack/prompts, @cliffy, citty, etc

const program = new Command();

program
  .name("forge")
  .description("Forge skills + verifiers")
  .version("0.5.0")
  .option("--json", "machine-readable JSON output")
  .option("--quiet, -q", "suppress non-error output")
  .option("--debug", "print stack traces on error");

// `forge skill list`
const skill = program.command("skill").description("Manage skills");

skill
  .command("list")
  .description("List installed skills")
  .action(async (_args, cmd) => {
    const opts = cmd.parent!.parent!.opts();
    const skills = await loadAllSkills();
    if (opts.json) {
      for (const s of skills) {
        process.stdout.write(JSON.stringify({ name: s.name, verifiers: s.verifiers.length }) + "\n");
      }
    } else {
      for (const s of skills) {
        process.stdout.write(`${s.name.padEnd(30)} ${s.verifiers.length} verifier(s)\n`);
      }
    }
  });

// `forge verify run <skill> <file>`
const verify = program.command("verify").description("Run verifiers");

verify
  .command("run <skill> [files...]")
  .description("Run a skill's verifiers against the given files")
  .option("--diff", "use git diff to pick files")
  .action(async (skill, files, options, cmd) => {
    const opts = cmd.parent!.parent!.opts();
    const targets = options.diff ? await filesFromDiff() : files;
    if (targets.length === 0) {
      process.stderr.write("error: no files to verify\n");
      process.exit(2);
    }
    const violations = await runVerifiers(skill, targets);
    if (opts.json) {
      for (const v of violations) process.stdout.write(JSON.stringify(v) + "\n");
    } else if (violations.length === 0) {
      if (!opts.quiet) process.stderr.write(`pass · ${skill}\n`);
    } else {
      for (const v of violations) {
        process.stdout.write(`${v.file}:${v.line}: ${v.rule}\n`);
      }
      process.exit(1);  // violations found
    }
    process.exit(0);
  });

// `forge skill delete <name> --dry-run`
skill
  .command("delete <name>")
  .description("Delete a skill (destructive)")
  .option("--dry-run", "show what would be deleted, do not delete")
  .option("--yes, -y", "skip confirmation")
  .action(async (name, options) => {
    const targets = await locateSkillFiles(name);
    if (options.dryRun) {
      for (const t of targets) process.stdout.write(`would delete: ${t}\n`);
      process.exit(0);
    }
    if (!options.yes) {
      const ok = await confirm(`Delete skill ${name} (${targets.length} files)? [y/N]: `);
      if (!ok) {
        process.stderr.write("aborted\n");
        process.exit(0);
      }
    }
    for (const t of targets) await fs.unlink(t);
    if (!program.opts().quiet) process.stderr.write(`deleted ${targets.length} files\n`);
  });

program.parseAsync().catch((err) => {
  if (program.opts().debug) {
    process.stderr.write(err.stack + "\n");
  } else {
    process.stderr.write(`error: ${err.message}\n`);
  }
  process.exit(1);
});
```

What this demonstrates: `noun verb` subcommands (rule 4); JSON mode on stdout, chatter on stderr (rule 7); destructive command supports `--dry-run` + `--yes` (rules 18-19); `--debug` toggles stack traces (rule 27); distinct exit codes per outcome (rule 2); `--version` returns just version (rule 23); errors go to stderr (rule 8).

## Workflow

When designing a CLI:

1. **List the use cases.** What scripts will invoke it? What humans interactively?
2. **Pick subcommand shape: `noun verb`. Write the subcommand tree on paper first.**
3. **Pick output formats. Always JSON. Pretty-print for TTY.**
4. **Pick exit codes per outcome. Document.**
5. **Implement `--help` on every level. Keep top-level short.**
6. **Implement `--dry-run` and `--yes` on every destructive command.**
7. **Read from stdin via `-` where it makes sense.**
8. **Test pipeline-friendliness: `tool ... | jq` should work.**

## Verification

Manual checklist:

- [ ] `tool --help` fits one screen.
- [ ] `tool --version` prints just the version.
- [ ] `tool subcommand --help` exists for every subcommand.
- [ ] `--json` mode exists and outputs newline-delimited JSON or a JSON array.
- [ ] Chatter goes to stderr; data goes to stdout.
- [ ] Destructive commands support `--dry-run` and `--yes`.
- [ ] Exit codes are distinct per outcome and documented.
- [ ] TTY-detect for color and progress bars.
- [ ] Stdin support via `-` for at least the main "process input" commands.

## When to skip this skill

- Internal scripts that no one else runs.
- One-off code generation / migration tools.
- GUI / TUI applications where this stdin/stdout/stderr discipline does not apply.

## Related skills

- [`forge-naming`](../forge-naming/SKILL.md) - subcommand and flag naming.
- [`forge-readme`](../../docs/forge-readme/SKILL.md) - documenting the CLI usage.
- [`forge-error-handling`](../../backend/forge-error-handling/SKILL.md) - the same error-surfacing discipline applies.
