Help us improve
Share bugs, ideas, or general feedback.
From claude-mods
Provides patterns for building production CLI tools in Python with Typer/Click, featuring parseable JSON output, predictable command structure, and composability for agentic AI workflows.
npx claudepluginhub 0xdarkmatter/claude-mods --plugin claude-modsHow this skill is triggered — by the user, by Claude, or both
Slash command
/claude-mods:cli-opsThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Patterns for building CLI tools that AI assistants and power users can chain, parse, and rely on.
Designs CLIs for both human users and LLM agents, covering subcommand structure, output streams, exit codes, JSON modes, TTY-aware color, and structured errors. Use when building or refactoring a CLI, adding machine-readable output, or making a tool agent-friendly.
Designs, reviews, and improves CLI user interfaces: command structures, subcommands, flags, arguments, help text, and terminal output formatting. For new CLI tools or usability enhancements.
Designs CLI surfaces including args/flags/subcommands/help/output/errors/config for new tools. Audits existing CLIs for consistency, composability, and agent ergonomics.
Share bugs, ideas, or general feedback.
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