skills/cli-ops/SKILL.md
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.
npx skillsauth add 0xDarkMatter/claude-mods cli-opsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for building CLI tools that AI assistants and power users can chain, parse, and rely on.
Build CLIs for agentic workflows - AI assistants and power users who chain commands, parse output programmatically, and expect predictable behavior.
| Principle | Meaning | Why It Matters |
|-----------|---------|----------------|
| Self-documenting | --help is comprehensive and always current | LLMs discover capabilities without external docs |
| Predictable | Same patterns across all commands | Learn once, use everywhere |
| Composable | Unix philosophy - do one thing well | Tools chain together naturally |
| Parseable | --json always available, always valid | Machine consumption without parsing hacks |
| Quiet by default | Data only, no decoration unless requested | Scripts don't break on unexpected output |
| Fail fast | Invalid input = immediate error | No silent failures or partial results |
<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
| Element | Convention | Valid Examples | Invalid Examples |
|---------|------------|----------------|------------------|
| Tool name | lowercase, 2-12 chars | mytool, datactl | MyTool, my-tool-cli |
| Resource | plural noun, lowercase | invoices, users | Invoice, user |
| Action | verb, lowercase | list, get, sync | listing, getter |
| Long flags | kebab-case | --dry-run, --output-format | --dryRun, --output_format |
| Short flags | single letter | -n, -q, -v | -num, -quiet |
| Action | HTTP Equiv | Returns | Idempotent |
|--------|------------|---------|------------|
| list | GET /resources | Array | Yes |
| get <id> | GET /resources/:id | Object | Yes |
| create | POST /resources | Created object | No |
| update <id> | PATCH /resources/:id | Updated object | Yes |
| delete <id> | DELETE /resources/:id | Confirmation | Yes |
| search | GET /resources?q= | Array | Yes |
Every command MUST support:
| Flag | Short | Behavior | Output |
|------|-------|----------|--------|
| --help | -h | Show help with examples | Help text to stdout, exit 0 |
| --json | | Machine-readable output | JSON to stdout |
Root command MUST additionally support:
| Flag | Short | Behavior | Output |
|------|-------|----------|--------|
| --version | -V | Show version | <tool> <version> to stdout, exit 0 |
| Flag | Short | Type | Purpose | Default |
|------|-------|------|---------|---------|
| --quiet | -q | bool | Suppress non-essential stderr | false |
| --verbose | -v | bool | Increase detail level | false |
| --dry-run | | bool | Preview without executing | false |
| --limit | -n | int | Max results to return | 20 |
| --output | -o | path | Write output to file | stdout |
| --format | -f | enum | Output format | varies |
--json not --json=true-vq equals -v -qThis is the most critical rule:
| Stream | Content | When | |--------|---------|------| | stdout | Data only | Always | | stderr | Everything else | Interactive mode |
stdout receives:
--json is setstderr receives:
--verbose)import sys
def is_interactive() -> bool:
"""True if connected to a terminal, not piped."""
return sys.stdout.isatty() and sys.stderr.isatty()
| Context | stdout.isatty() | Behavior |
|---------|-----------------|----------|
| Terminal | True | Rich output to stderr, summary to stdout |
| Piped (\| jq) | False | Minimal/JSON to stdout |
| Redirected (> file) | False | Minimal to stdout |
| --json flag | Any | JSON to stdout, suppress stderr noise |
See references/json-schemas.md for complete JSON response patterns.
Key conventions:
{"data": [...], "meta": {...}}{"data": {...}}{"error": {"code": "...", "message": "..."}}Semantic exit codes that scripts can rely on:
| Code | Name | Meaning | When | |------|------|---------|------| | 0 | SUCCESS | Operation completed | Everything worked | | 1 | ERROR | General/unknown error | Unexpected failures | | 2 | AUTH_REQUIRED | Not authenticated | No token, token expired | | 3 | NOT_FOUND | Resource missing | ID doesn't exist | | 4 | VALIDATION | Invalid input | Bad arguments, failed validation | | 5 | FORBIDDEN | Permission denied | Authenticated but not authorized | | 6 | RATE_LIMITED | Too many requests | API throttling | | 7 | CONFLICT | State conflict | Concurrent modification, duplicate |
# 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
# 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)
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"
}
}
}
| Code | Exit | Meaning |
|------|------|---------|
| AUTH_REQUIRED | 2 | Must authenticate first |
| TOKEN_EXPIRED | 2 | Token needs refresh |
| FORBIDDEN | 5 | Insufficient permissions |
| NOT_FOUND | 3 | Resource doesn't exist |
| VALIDATION_ERROR | 4 | Invalid input |
| INVALID_ARGUMENT | 4 | Bad argument value |
| MISSING_ARGUMENT | 4 | Required argument missing |
| RATE_LIMITED | 6 | Too many requests |
| CONFLICT | 7 | State conflict |
| ALREADY_EXISTS | 7 | Duplicate resource |
| INTERNAL_ERROR | 1 | Unexpected error |
| API_ERROR | 1 | Upstream API failed |
| NETWORK_ERROR | 1 | Connection failed |
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)
Every --help output MUST include:
<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 should show:
jqTools requiring authentication MUST implement:
<tool> auth login # Interactive authentication
<tool> auth status # Check current state
<tool> auth logout # Clear credentials
Recommended: OS keyring with fallbacks for maximum security
Environment variable (CI/CD, testing)
MYTOOL_API_TOKEN or similarOS Keyring (primary storage - secure)
.env file (development fallback)
.gitignoreDependencies:
dependencies = [
"keyring>=24.0.0", # OS keyring access
"python-dotenv>=1.0.0", # .env file support
]
Simple alternative: Just config file in ~/.config/<tool>/
See references/implementation.md for complete credential storage implementations.
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
Input (Flexible): Accept multiple formats for user convenience
| Format | Example | Interpretation |
|--------|---------|----------------|
| 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"
}
{
"total": 1250.50,
"currency": "USD"
}
{
"id": "abc_123",
"legacy_id": "12345"
}
# All equivalent
--status DRAFT
--status draft
--status Draft
{"status": "IN_PROGRESS"}
# 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
# Limit results
--limit 50
-n 50
# Offset-based
--page 2
--offset 20
# Cursor-based
--cursor "eyJpZCI6MTIzfQ=="
--after "item_123"
See references/implementation.md for complete Python implementation templates including:
# 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"}]
# 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
# BAD: Different flags for same concept
$ tool1 list -j
$ tool2 list --format=json
# GOOD: Same flags everywhere
$ tool1 list --json
$ tool2 list --json
# 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
<tool> --version<tool> --help with examples<tool> <resource> list [--json]<tool> <resource> get <id> [--json]--jsonauth login, auth status, auth logout)--quiet and --verbose modes--dry-run for mutations--limit, --page)Typer (preferred for new tools):
Click (acceptable for existing tools):
# 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
tools
yt-dlp operations - the media ACQUISITION layer that feeds ffmpeg-ops: format selection (-S sort vs -f filters) that avoids post-download transcodes, --download-sections clip-at-download, audio-only extraction for STT pipelines (-x --audio-format opus), playlists + --download-archive incremental channel syncs, cookies/auth (--cookies-from-browser), rate limiting and politeness, SponsorBlock mark/remove, output templates (-o), subtitle download (--write-subs/--write-auto-subs), remux-vs-recode doctrine, and failure triage (403s, throttling, geo blocks, the nsig-extraction class that means yt-dlp is outdated). Triggers on: yt-dlp, ytdlp, youtube-dl, download video, download youtube, download from youtube, download playlist, download channel, archive channel, channel sync, rip audio, youtube to mp3, youtube to mp4, save video, grab video, video downloader, download subtitles, download transcript, clip from youtube, download section, sponsorblock, cookies-from-browser, download-archive, nsig, requested format is not available, sign in to confirm, download livestream, record stream, live-from-start, premiere, impersonate.
tools
Comprehensive ffmpeg/ffprobe operations - probe-first media processing: transcode and compress (H.264/H.265/AV1/Opus), frame-accurate cut/trim/concat, EDL-driven editing, color grading and .cube LUTs, audio loudnorm and mixing, STT/Whisper audio prep, subtitles, GIF and thumbnails, HLS packaging, hardware encoding (NVENC/QSV/AMF/VideoToolbox), restoration, scene and silence detection, VMAF quality gates, screen capture, yt-dlp interop. Triggers on: ffmpeg, ffprobe, transcode, convert video, compress video, encode video, extract audio, trim video, cut video, concat videos, video to gif, thumbnail, contact sheet, burn subtitles, watermark, resize video, crop video, change fps, slow motion, timelapse, loudnorm, normalize audio, audio for whisper, transcription prep, scene detection, silence detection, remove silence, color grade, LUT, tonemap HDR, vmaf, nvenc, hardware encode, hls, remux, faststart, deinterlace, stabilize video, denoise video, screen record, EDL, keyframes.
development
Payload CMS 3 (Next.js-native) architecture - collections, globals, fields, access control, hooks, Local API, storage adapters, and database (Postgres/MongoDB/SQLite). Use for: payload, payloadcms, payload cms, payload 3, collection config, access control, payload hooks, local api, payload fields, multi-tenant payload, payload nextjs, payload s3, payload r2, payloadcms architecture, headless cms typescript.
testing
Cypress end-to-end and component testing operations - selector/retry-ability strategy, cy.intercept network stubbing, cy.session auth, component vs e2e, flake diagnosis, CI, Test Replay. Use for: cypress, e2e test, component test, cy.get, cy.intercept, cy.session, data-cy, data-test, retry-ability, flake, flaky test, cypress.config, cy.mount, Test Replay, custom commands, fixtures.