cli-ops
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
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/cli-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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
| 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 |
Design Axioms
- stdout is sacred - Only data. Never progress, never logging, never decoration.
- stderr is for humans - Progress bars, colors, tables, warnings live here.
- Exit codes have meaning - Scripts can branch on failure mode.
- Help includes examples - The fastest path to understanding.
- 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
| 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 |
Standard Resource Actions
| 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 |
Flags & Options
Mandatory Flags
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 |
Recommended Flags
| 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 |
Flag Behavior Rules
- Boolean flags take no value:
--jsonnot--json=true - Short flags can combine:
-vqequals-v -q - Unknown flags are errors: Never silently ignore
- Repeated flags: Last value wins (or error if inappropriate)
Output Specification
Stream Separation
This is the most critical rule:
| Stream | Content | When |
|---|---|---|
| stdout | Data only | Always |
| stderr | Everything else | Interactive mode |
stdout receives:
- JSON when
--jsonis 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()
| 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 |
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:
| 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 |
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
| 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 |
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:
- Brief description (one line)
- Usage syntax
- Options with descriptions
- 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:
- Basic usage - Simplest invocation
- Common filters - Most-used options
- JSON piping - How to chain with
jq - 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
Environment variable (CI/CD, testing)
MYTOOL_API_TOKENor similar- Highest priority, overrides all other sources
OS Keyring (primary storage - secure)
- Windows: Credential Manager
- macOS: Keychain
- Linux: Secret Service (GNOME Keyring, KWallet)
- Encrypted at rest, per-user isolation
.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
| 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"
}
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> --helpwith 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
-
--quietand--verbosemodes -
--dry-runfor 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
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
implementation.md 13.4 KB
# Implementation Templates Complete Python implementation patterns for CLI tools. ## CLI Skeleton (Typer) ```python # src/<package>/cli.py from __future__ import annotations import json from typing import Annotated, Optional import typer from rich.console import Console from rich.table import Table from . import __version__ from .client import Client from .config import get_token app = typer.Typer( name="<tool>", help="<description>", no_args_is_help=True, ) # stderr for human output console = Console(stderr=True) # Exit codes 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 def _output_json(data) -> None: """Output JSON to stdout.""" print(json.dumps(data, indent=2, default=str)) 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: _output_json(error_obj) console.print(f"[red]Error:[/red] {message}") raise typer.Exit(exit_code) def _require_auth(as_json: bool = False): """Check authentication, exit if not authenticated.""" if not get_token(): _error( "Not authenticated. Run: <tool> auth login", "AUTH_REQUIRED", EXIT_AUTH_REQUIRED, as_json=as_json, ) # Version callback def version_callback(value: bool): if value: print(f"<tool> {__version__}") raise typer.Exit() @app.callback() def main( version: Annotated[ Optional[bool], typer.Option("--version", "-V", callback=version_callback, is_eager=True), ] = None, ): """<description>""" pass # ============================================================ # AUTH COMMANDS # ============================================================ auth_app = typer.Typer(help="Authentication") app.add_typer(auth_app, name="auth") @auth_app.command("login") def auth_login(): """Authenticate with service.""" # Implementation... console.print("[green]Authenticated[/green]") @auth_app.command("status") def auth_status( json_output: Annotated[bool, typer.Option("--json")] = False, ): """ Check authentication status. Examples: <tool> auth status <tool> auth status --json """ token = get_token() status = {"authenticated": token is not None} if json_output: _output_json(status) return if status["authenticated"]: console.print("Authenticated: [green]yes[/green]") else: console.print("Authenticated: [red]no[/red]") @auth_app.command("logout") def auth_logout(): """Clear stored credentials.""" # Implementation... console.print("[green]Logged out[/green]") # ============================================================ # RESOURCE COMMANDS # ============================================================ items_app = typer.Typer(help="Item operations") app.add_typer(items_app, name="items") @items_app.command("list") def items_list( status: Annotated[ Optional[str], typer.Option("--status", "-s", help="Filter by status"), ] = None, limit: Annotated[ int, typer.Option("--limit", "-n", help="Max results"), ] = 20, json_output: Annotated[bool, typer.Option("--json")] = False, ): """ List items with optional filtering. Examples: <tool> items list <tool> items list --status active <tool> items list --limit 50 --json <tool> items list --json | jq '.data[].name' """ _require_auth(json_output) client = Client() items = client.list_items(status=status, limit=limit) if json_output: _output_json({ "data": items, "meta": {"count": len(items)}, }) return table = Table(title="Items") table.add_column("ID") table.add_column("Name") table.add_column("Status") for item in items: table.add_row(item["id"], item["name"], item.get("status", "")) console.print(table) @items_app.command("get") def items_get( item_id: Annotated[str, typer.Argument(help="Item ID")], json_output: Annotated[bool, typer.Option("--json")] = False, ): """ Get a specific item by ID. Examples: <tool> items get abc123 <tool> items get abc123 --json """ _require_auth(json_output) client = Client() item = client.get_item(item_id) if item is None: _error( f"Item not found: {item_id}", "NOT_FOUND", EXIT_NOT_FOUND, {"item_id": item_id}, json_output, ) if json_output: _output_json({"data": item}) return console.print(f"[bold]{item['name']}[/bold]") console.print(f" ID: {item['id']}") console.print(f" Status: {item.get('status', 'N/A')}") if __name__ == "__main__": app() ``` ## Client Pattern ```python # src/<package>/client.py from typing import Optional import httpx from .config import get_token class Client: """API client.""" BASE_URL = "https://api.example.com/v1" TIMEOUT = 30 def __init__(self): self.token = get_token() def _headers(self) -> dict: return { "Authorization": f"Bearer {self.token}", "Accept": "application/json", "Content-Type": "application/json", } def _get(self, endpoint: str, params: dict = None) -> Optional[dict]: """Make GET request.""" response = httpx.get( f"{self.BASE_URL}/{endpoint}", headers=self._headers(), params=params, timeout=self.TIMEOUT, ) response.raise_for_status() return response.json() def _post(self, endpoint: str, data: dict) -> Optional[dict]: """Make POST request.""" response = httpx.post( f"{self.BASE_URL}/{endpoint}", headers=self._headers(), json=data, timeout=self.TIMEOUT, ) response.raise_for_status() return response.json() def list_items(self, status: str = None, limit: int = 20) -> list: """List items with optional filters.""" params = {"limit": limit} if status: params["status"] = status data = self._get("items", params) return data.get("items", []) def get_item(self, item_id: str) -> Optional[dict]: """Get single item by ID.""" try: data = self._get(f"items/{item_id}") return data.get("item") except httpx.HTTPStatusError as e: if e.response.status_code == 404: return None raise ``` ## Config & Token Storage ### Recommended: OS Keyring with Fallbacks Use OS keyring for secure credential storage with fallbacks: ```python # src/<package>/config.py import os from pathlib import Path import keyring from dotenv import load_dotenv # Load .env file if it exists load_dotenv() SERVICE_NAME = "mytool" TOKEN_KEY = "api_token" def get_token() -> str | None: """ Get API token with priority: 1. Environment variable (CI/CD, testing) 2. OS keyring (secure storage) 3. .env file (local development fallback) """ # 1. Environment variable (highest priority) token = os.getenv("MYTOOL_API_TOKEN") if token: return token # 2. OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service) try: token = keyring.get_password(SERVICE_NAME, TOKEN_KEY) if token: return token except Exception: # Keyring not available (headless, CI, etc.) pass # 3. .env file fallback # Already loaded by load_dotenv() above, so check env again token = os.getenv("MYTOOL_API_TOKEN") if token: return token return None def save_token(token: str) -> None: """Save API token to OS keyring.""" try: keyring.set_password(SERVICE_NAME, TOKEN_KEY, token) except Exception as e: # Keyring not available, fallback to .env file _save_to_dotenv(token) raise RuntimeWarning( f"Keyring unavailable, saved to .env file instead: {e}" ) def clear_token() -> None: """Remove stored token from all locations.""" # Clear from keyring try: keyring.delete_password(SERVICE_NAME, TOKEN_KEY) except Exception: pass # Clear from .env file env_file = Path.cwd() / ".env" if env_file.exists(): lines = env_file.read_text().splitlines() lines = [l for l in lines if not l.startswith("MYTOOL_API_TOKEN=")] env_file.write_text("\n".join(lines)) def get_token_source() -> str: """Get where the token is stored: 'environment', 'keyring', 'dotenv', or 'none'.""" if os.getenv("MYTOOL_API_TOKEN"): # Could be from env or .env, check if .env exists env_file = Path.cwd() / ".env" if env_file.exists() and "MYTOOL_API_TOKEN" in env_file.read_text(): return "dotenv" return "environment" try: token = keyring.get_password(SERVICE_NAME, TOKEN_KEY) if token: return "keyring" except Exception: pass return "none" def _save_to_dotenv(token: str) -> None: """Fallback: save to .env file.""" env_file = Path.cwd() / ".env" # Read existing content if env_file.exists(): lines = env_file.read_text().splitlines() # Remove existing MYTOOL_API_TOKEN lines lines = [l for l in lines if not l.startswith("MYTOOL_API_TOKEN=")] else: lines = [] # Add new token lines.append(f"MYTOOL_API_TOKEN={token}") # Write back env_file.write_text("\n".join(lines) + "\n") env_file.chmod(0o600) # Restrict permissions ``` **Dependencies:** ```toml # pyproject.toml dependencies = [ "keyring>=24.0.0", "python-dotenv>=1.0.0", ] ``` ### Simple: Config File Only For tools that don't need OS keyring: ```python # src/<package>/config.py import os from pathlib import Path def get_token() -> str | None: """Get API token from environment or config file.""" # 1. Environment variable (highest priority) token = os.getenv("MYTOOL_API_TOKEN") if token: return token # 2. Config file config_file = Path.home() / ".config" / "mytool" / "token" if config_file.exists(): return config_file.read_text().strip() return None def save_token(token: str) -> None: """Save API token to config file.""" config_dir = Path.home() / ".config" / "mytool" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "token" config_file.write_text(token) config_file.chmod(0o600) # Restrict permissions def clear_token() -> None: """Remove stored token.""" config_file = Path.home() / ".config" / "mytool" / "token" if config_file.exists(): config_file.unlink() ``` ## Testing Pattern ```python # tests/test_cli.py import json from typer.testing import CliRunner from <package>.cli import app runner = CliRunner() def test_help(): """--help shows usage.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "<tool>" in result.stdout def test_version(): """--version shows version.""" result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 assert "0.1.0" in result.stdout def test_list_json(): """list --json outputs valid JSON.""" result = runner.invoke(app, ["items", "list", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert "data" in data def test_not_found(): """get nonexistent returns exit code 3.""" result = runner.invoke(app, ["items", "get", "nonexistent-id"]) assert result.exit_code == 3 def test_json_error(): """Errors output valid JSON with --json.""" result = runner.invoke(app, ["items", "get", "bad-id", "--json"]) assert result.exit_code == 3 data = json.loads(result.stdout) assert "error" in data assert data["error"]["code"] == "NOT_FOUND" ``` ## Project Structure ``` <tool>/ ├── README.md # User documentation ├── pyproject.toml # Package config ├── src/<package>/ │ ├── __init__.py # Version │ ├── cli.py # Typer CLI entry point │ ├── client.py # API client │ ├── config.py # Settings & token storage │ └── models.py # Pydantic models (optional) └── tests/ ├── conftest.py ├── test_cli.py └── test_client.py ``` ## pyproject.toml ```toml [project] name = "<tool>-cli" version = "0.1.0" description = "What this tool does" readme = "README.md" requires-python = ">=3.11" dependencies = [ "typer>=0.9.0", "rich>=13.0.0", "httpx>=0.25.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", "ruff>=0.3.0", ] [project.scripts] <tool> = "<package>.cli:app" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/<package>"] [tool.ruff] line-length = 100 target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" ``` -
json-schemas.md 2.5 KB
# JSON Output Schemas Complete JSON response patterns for CLI tools. ## List Response (Paginated) ```json { "data": [ {"id": "item-001", "name": "First Item", "status": "ACTIVE"}, {"id": "item-002", "name": "Second Item", "status": "DRAFT"} ], "meta": { "count": 2, "total": 156, "page": 1, "per_page": 20, "has_more": true, "next_cursor": "eyJpZCI6Iml0ZW0tMDAyIn0=" } } ``` ## List Response (Simple) For tools where pagination metadata isn't relevant: ```json { "data": [ {"id": "1", "name": "Item 1"}, {"id": "2", "name": "Item 2"} ] } ``` Or minimal array form: ```json [ {"id": "1", "name": "Item 1"}, {"id": "2", "name": "Item 2"} ] ``` ## Single Item Response ```json { "data": { "id": "item-001", "name": "Example Item", "description": "A sample item", "status": "ACTIVE", "metadata": { "created_by": "user-123", "tags": ["important", "urgent"] }, "created_at": "2025-01-15T10:30:00Z", "updated_at": "2025-01-15T14:22:00Z" } } ``` ## Mutation Response ```json { "data": { "id": "item-003", "name": "New Item", "status": "DRAFT", "created_at": "2025-01-27T09:15:00Z" }, "meta": { "action": "created" } } ``` ## Field Conventions | Type | JSON Type | Format | Example | |------|-----------|--------|---------| | Identifiers | string | Any format | `"id": "item_abc123"` | | Timestamps | string | ISO 8601 with timezone | `"created_at": "2025-01-15T10:30:00Z"` | | Dates (no time) | string | ISO 8601 date | `"due_date": "2025-02-15"` | | Money | number | Decimal, not cents | `"total": 1250.50` | | Currency | string | ISO 4217 code | `"currency": "USD"` | | Booleans | boolean | true/false | `"is_active": true` | | Nulls | null | Explicit, not omitted | `"deleted_at": null` | | Enums | string | UPPER_SNAKE_CASE | `"status": "IN_PROGRESS"` | | Arrays | array | Even if empty | `"tags": []` | | Nested objects | object | Embedded, not ID-only | `"user": {"id": "...", "name": "..."}` | ## Error Response ```json { "error": { "code": "VALIDATION_ERROR", "message": "Invalid input provided", "details": { "field": "amount", "reason": "must be positive", "value": -50 } } } ``` The `details` object is optional and contains context-specific information. ## Pagination in Response ```json { "data": [...], "meta": { "count": 20, "total": 156, "page": 1, "per_page": 20, "has_more": true, "next_cursor": "eyJpZCI6ImFiYzEyMyJ9" } } ```
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 15.2 KB
--- name: cli-ops description: "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." license: MIT compatibility: "Python 3.11+, Typer, Click" allowed-tools: "Read, Write, Edit" metadata: author: claude-mods related-skills: python-cli-ops, python-async-ops --- # 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 | 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 | ### 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 | 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` | ### Standard Resource Actions | 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 | --- ## Flags & Options ### Mandatory Flags 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 | ### Recommended Flags | 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 | ### 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: | Stream | Content | When | |--------|---------|------| | **stdout** | Data only | Always | | **stderr** | Everything else | Interactive 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 ```python 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 | ### JSON Output Schema See [references/json-schemas.md](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: | 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 | ### Usage ```bash # 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 ```python # 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:** ```json { "error": { "code": "NOT_FOUND", "message": "Item not found", "details": { "item_id": "bad-id" } } } ``` ### Error Codes | 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 | ### Implementation Pattern ```python 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:** ```toml 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](references/implementation.md) for complete credential storage implementations. ### Unauthenticated Behavior When auth is required but missing: ```bash $ mytool items list Error: Not authenticated. Run: mytool auth login # exit code: 2 ``` ```bash $ 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 | 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 ```json { "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) ```json { "total": 1250.50, "currency": "USD" } ``` ### IDs - Always strings (even if numeric) - Preserve exact format from source ```json { "id": "abc_123", "legacy_id": "12345" } ``` ### Enums - UPPER_SNAKE_CASE in JSON - Case-insensitive input ```bash # All equivalent --status DRAFT --status draft --status Draft ``` ```json {"status": "IN_PROGRESS"} ``` --- ## Filtering & Pagination ### Common Filter Patterns ```bash # 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 ```bash # Limit results --limit 50 -n 50 # Offset-based --page 2 --offset 20 # Cursor-based --cursor "eyJpZCI6MTIzfQ==" --after "item_123" ``` --- ## Implementation See [references/implementation.md](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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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 ```python # 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 ```
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.