Learn-skills.dev cli-patterns

Patterns for building production-quality CLI tools with predictable behavior, parseable output, and agentic workflows. Triggers: cli tool, command line tool, build cli, cli patterns, agentic cli, cli design, typer cli, click cli.

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/0xdarkmatter/claude-mods/cli-patterns" ~/.claude/skills/neversight-learn-skills-dev-cli-patterns && rm -rf "$T"
manifest: data/skills-md/0xdarkmatter/claude-mods/cli-patterns/SKILL.md
source content

CLI Patterns for Agentic Workflows

Patterns for building CLI tools that AI assistants and power users can chain, parse, and rely on.

Philosophy

Build CLIs for agentic workflows - AI assistants and power users who chain commands, parse output programmatically, and expect predictable behavior.

Core Principles

PrincipleMeaningWhy It Matters
Self-documenting
--help
is comprehensive and always current
LLMs discover capabilities without external docs
PredictableSame patterns across all commandsLearn once, use everywhere
ComposableUnix philosophy - do one thing wellTools chain together naturally
Parseable
--json
always available, always valid
Machine consumption without parsing hacks
Quiet by defaultData only, no decoration unless requestedScripts don't break on unexpected output
Fail fastInvalid input = immediate errorNo silent failures or partial results

Design Axioms

  1. stdout is sacred - Only data. Never progress, never logging, never decoration.
  2. stderr is for humans - Progress bars, colors, tables, warnings live here.
  3. Exit codes have meaning - Scripts can branch on failure mode.
  4. Help includes examples - The fastest path to understanding.
  5. JSON shape is predictable - Same structure across all commands.

Command Architecture

Structural Pattern

<tool> [global-options] <resource> <action> [options] [arguments]

Every CLI follows this hierarchy:

<tool>
├── --version, --help              # Global flags
├── auth                           # Authentication (if required)
│   ├── login
│   ├── status
│   └── logout
└── <resource>                     # Domain resources (plural nouns)
    ├── list                       # Get many
    ├── get <id>                   # Get one by ID
    ├── create                     # Make new (if supported)
    ├── update <id>                # Modify existing (if supported)
    ├── delete <id>                # Remove (if supported)
    └── <custom-action>            # Domain-specific verbs

Naming Conventions

ElementConventionValid ExamplesInvalid Examples
Tool namelowercase, 2-12 chars
mytool
,
datactl
MyTool
,
my-tool-cli
Resourceplural noun, lowercase
invoices
,
users
Invoice
,
user
Actionverb, lowercase
list
,
get
,
sync
listing
,
getter
Long flagskebab-case
--dry-run
,
--output-format
--dryRun
,
--output_format
Short flagssingle letter
-n
,
-q
,
-v
-num
,
-quiet

Standard Resource Actions

ActionHTTP EquivReturnsIdempotent
list
GET /resourcesArrayYes
get <id>
GET /resources/:idObjectYes
create
POST /resourcesCreated objectNo
update <id>
PATCH /resources/:idUpdated objectYes
delete <id>
DELETE /resources/:idConfirmationYes
search
GET /resources?q=ArrayYes

Flags & Options

Mandatory Flags

Every command MUST support:

FlagShortBehaviorOutput
--help
-h
Show help with examplesHelp text to stdout, exit 0
--json
Machine-readable outputJSON to stdout

Root command MUST additionally support:

FlagShortBehaviorOutput
--version
-V
Show version
<tool> <version>
to stdout, exit 0

Recommended Flags

FlagShortTypePurposeDefault
--quiet
-q
boolSuppress non-essential stderrfalse
--verbose
-v
boolIncrease detail levelfalse
--dry-run
boolPreview without executingfalse
--limit
-n
intMax results to return20
--output
-o
pathWrite output to filestdout
--format
-f
enumOutput formatvaries

Flag Behavior Rules

  1. Boolean flags take no value:
    --json
    not
    --json=true
  2. Short flags can combine:
    -vq
    equals
    -v -q
  3. Unknown flags are errors: Never silently ignore
  4. Repeated flags: Last value wins (or error if inappropriate)

Output Specification

Stream Separation

This is the most critical rule:

StreamContentWhen
stdoutData onlyAlways
stderrEverything elseInteractive mode

stdout receives:

  • JSON when
    --json
    is set
  • Minimal text output when interactive
  • Nothing else. Ever.

stderr receives:

  • Progress indicators (spinners, bars)
  • Status messages ("Fetching...", "Done")
  • Warnings
  • Rich formatted tables
  • Colors and decoration
  • Debug information (
    --verbose
    )

Interactive Detection

import sys

def is_interactive() -> bool:
    """True if connected to a terminal, not piped."""
    return sys.stdout.isatty() and sys.stderr.isatty()
Contextstdout.isatty()Behavior
TerminalTrueRich output to stderr, summary to stdout
Piped (
| jq
)
FalseMinimal/JSON to stdout
Redirected (
> file
)
FalseMinimal to stdout
--json
flag
AnyJSON to stdout, suppress stderr noise

JSON Output Schema

See references/json-schemas.md for complete JSON response patterns.

Key conventions:

  • List responses:
    {"data": [...], "meta": {...}}
  • Single item:
    {"data": {...}}
  • Errors:
    {"error": {"code": "...", "message": "..."}}
  • ISO 8601 dates, decimal money, string IDs

Exit Codes

Semantic exit codes that scripts can rely on:

CodeNameMeaningWhen
0SUCCESSOperation completedEverything worked
1ERRORGeneral/unknown errorUnexpected failures
2AUTH_REQUIREDNot authenticatedNo token, token expired
3NOT_FOUNDResource missingID doesn't exist
4VALIDATIONInvalid inputBad arguments, failed validation
5FORBIDDENPermission deniedAuthenticated but not authorized
6RATE_LIMITEDToo many requestsAPI throttling
7CONFLICTState conflictConcurrent modification, duplicate

Usage

# Script can branch on exit code
mytool items get item-001 --json
case $? in
  0) echo "Success" ;;
  2) echo "Need to authenticate" && mytool auth login ;;
  3) echo "Item not found" ;;
  *) echo "Error occurred" ;;
esac

Implementation

# Constants
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_AUTH_REQUIRED = 2
EXIT_NOT_FOUND = 3
EXIT_VALIDATION = 4
EXIT_FORBIDDEN = 5
EXIT_RATE_LIMITED = 6
EXIT_CONFLICT = 7

# Usage
raise typer.Exit(EXIT_NOT_FOUND)

Error Handling

Error Output Format

With

--json
, errors output structured JSON to stdout AND a message to stderr:

stderr:

Error: Item not found

stdout:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Item not found",
    "details": {
      "item_id": "bad-id"
    }
  }
}

Error Codes

CodeExitMeaning
AUTH_REQUIRED
2Must authenticate first
TOKEN_EXPIRED
2Token needs refresh
FORBIDDEN
5Insufficient permissions
NOT_FOUND
3Resource doesn't exist
VALIDATION_ERROR
4Invalid input
INVALID_ARGUMENT
4Bad argument value
MISSING_ARGUMENT
4Required argument missing
RATE_LIMITED
6Too many requests
CONFLICT
7State conflict
ALREADY_EXISTS
7Duplicate resource
INTERNAL_ERROR
1Unexpected error
API_ERROR
1Upstream API failed
NETWORK_ERROR
1Connection failed

Implementation Pattern

def _error(
    message: str,
    code: str = "ERROR",
    exit_code: int = EXIT_ERROR,
    details: dict = None,
    as_json: bool = False,
):
    """Output error and exit."""
    error_obj = {"error": {"code": code, "message": message}}
    if details:
        error_obj["error"]["details"] = details

    if as_json:
        print(json.dumps(error_obj, indent=2))

    # Always print human message to stderr
    console.print(f"[red]Error:[/red] {message}")
    raise typer.Exit(exit_code)

Help System

Help Requirements

Every

--help
output MUST include:

  1. Brief description (one line)
  2. Usage syntax
  3. Options with descriptions
  4. Examples (critical for discovery)

Help Format Template

<one-line description>

Usage: <tool> <resource> <action> [OPTIONS] [ARGS]

Arguments:
  <arg>          Description of positional argument

Options:
  -s, --status TEXT    Filter by status
  -n, --limit INTEGER  Max results [default: 20]
  --json               Output as JSON
  -h, --help           Show this help

Examples:
  <tool> <resource> <action>
  <tool> <resource> <action> --status active
  <tool> <resource> <action> --json | jq '.[0]'

Examples Are Critical

Examples should show:

  1. Basic usage - Simplest invocation
  2. Common filters - Most-used options
  3. JSON piping - How to chain with
    jq
  4. Real-world scenarios - Actual use cases

Authentication

Auth Commands

Tools requiring authentication MUST implement:

<tool> auth login      # Interactive authentication
<tool> auth status     # Check current state
<tool> auth logout     # Clear credentials

Credential Storage Priority

Recommended: OS keyring with fallbacks for maximum security

  1. Environment variable (CI/CD, testing)

    • MYTOOL_API_TOKEN
      or similar
    • Highest priority, overrides all other sources
  2. OS Keyring (primary storage - secure)

    • Windows: Credential Manager
    • macOS: Keychain
    • Linux: Secret Service (GNOME Keyring, KWallet)
    • Encrypted at rest, per-user isolation
  3. .env file (development fallback)

    • Plain text in current directory
    • Convenient for local development
    • Must be in
      .gitignore

Dependencies:

dependencies = [
    "keyring>=24.0.0",      # OS keyring access
    "python-dotenv>=1.0.0", # .env file support
]

Simple alternative: Just config file in

~/.config/<tool>/

  • Good for tools without sensitive credentials
  • Or when OS keyring adds too much complexity

See references/implementation.md for complete credential storage implementations.

Unauthenticated Behavior

When auth is required but missing:

$ mytool items list
Error: Not authenticated. Run: mytool auth login
# exit code: 2
$ mytool items list --json
# stderr: Error: Not authenticated. Run: mytool auth login
{"error": {"code": "AUTH_REQUIRED", "message": "Not authenticated. Run: mytool auth login"}}
# exit code: 2

Data Conventions

Date Handling

Input (Flexible): Accept multiple formats for user convenience

FormatExampleInterpretation
ISO date
2025-01-15
Exact date
ISO datetime
2025-01-15T10:30:00Z
Exact datetime
Relative
today
,
yesterday
,
tomorrow
Current/previous/next day
Relative
last
,
this
(with context)
Previous/current period

Output (Strict): Always output ISO 8601

{
  "created_at": "2025-01-15T10:30:00Z",
  "due_date": "2025-02-15",
  "month": "2025-01"
}

Money

  • Store as decimal number, not cents
  • Include currency when ambiguous
  • Never format (no "$" or "," in JSON)
{
  "total": 1250.50,
  "currency": "USD"
}

IDs

  • Always strings (even if numeric)
  • Preserve exact format from source
{
  "id": "abc_123",
  "legacy_id": "12345"
}

Enums

  • UPPER_SNAKE_CASE in JSON
  • Case-insensitive input
# All equivalent
--status DRAFT
--status draft
--status Draft
{"status": "IN_PROGRESS"}

Filtering & Pagination

Common Filter Patterns

# By status
--status DRAFT
--status active,pending    # Multiple values

# By date range
--from 2025-01-01 --to 2025-01-31
--month 2025-01
--month last

# By related entity
--user "Alice"
--project "Project X"

# Text search
--search "keyword"
-q "keyword"

# Boolean filters
--archived
--no-archived
--include-deleted

Pagination

# Limit results
--limit 50
-n 50

# Offset-based
--page 2
--offset 20

# Cursor-based
--cursor "eyJpZCI6MTIzfQ=="
--after "item_123"

Implementation

See references/implementation.md for complete Python implementation templates including:

  • CLI skeleton with Typer
  • Client pattern with httpx
  • Error handling
  • Authentication flows
  • Testing patterns

Anti-Patterns

❌ Output Pollution

# BAD: Progress to stdout
$ bad-tool items list --json
Fetching items...
[{"id": "1"}]
Done!

# GOOD: Only JSON to stdout
$ good-tool items list --json
[{"id": "1"}]

❌ Interactive Prompts

# BAD: Prompts in non-interactive context
$ bad-tool items create
Enter name: _

# GOOD: Fail fast with required flags
$ good-tool items create
Error: --name is required

❌ Inconsistent Flags

# BAD: Different flags for same concept
$ tool1 list -j
$ tool2 list --format=json

# GOOD: Same flags everywhere
$ tool1 list --json
$ tool2 list --json

❌ Silent Failures

# BAD: Success exit code on failure
$ bad-tool items delete bad-id
Item not found
$ echo $?
0

# GOOD: Semantic exit code
$ good-tool items delete bad-id
Error: Item not found: bad-id
$ echo $?
3

Quick Reference

Must-Have Checklist

  • <tool> --version
  • <tool> --help
    with examples
  • <tool> <resource> list [--json]
  • <tool> <resource> get <id> [--json]
  • Semantic exit codes (0, 1, 2, 3, 4, 5, 6, 7)
  • Errors to stderr, data to stdout
  • Valid JSON on
    --json
  • Stream separation (stdout = data, stderr = UI)

Recommended Additions

  • Authentication commands (
    auth login
    ,
    auth status
    ,
    auth logout
    )
  • Create/Update/Delete operations
  • --quiet
    and
    --verbose
    modes
  • --dry-run
    for mutations
  • Pagination (
    --limit
    ,
    --page
    )
  • Filtering (status, date range, search)
  • Automated tests

Framework Choice

Typer (preferred for new tools):

  • Type hints provide automatic validation
  • Built-in help generation
  • Rich integration for beautiful output
  • Less boilerplate than Click

Click (acceptable for existing tools):

  • Typer is built on Click (100% compatible)
  • Well-structured Click code doesn't need migration
  • Both must follow same output conventions
# Typer (preferred)
import typer
from rich.console import Console

app = typer.Typer()
console = Console(stderr=True)  # UI to stderr

# Click (acceptable)
import click
from rich.console import Console

console = Console(stderr=True)  # Same pattern