Browse Source

feat(skills): Add cli-patterns skill for production CLI tools

Add comprehensive CLI tool patterns for agentic workflows:

- Core skill (568 lines): Philosophy, architecture, stream separation
- JSON schemas reference (128 lines): Response patterns, field conventions
- Implementation reference (449 lines): Complete Python templates

Key patterns:
- Stream separation (stdout=data, stderr=UI)
- Semantic exit codes (0-7 with specific meanings)
- Mandatory flags (--help, --json, --version)
- Examples-first help system
- Typer/Click implementation templates

Generic and framework-agnostic - no vendor-specific references.
Based on battle-tested patterns for building tools that AI assistants
and power users can chain, parse, and rely on.

Total: 1,145 lines of production CLI guidance.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
0xDarkMatter 4 months ago
parent
commit
2d4c15c912

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **22 expert agents** for specialized domains (React, Python, Go, Rust, AWS, etc.)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
-- **40 skills** for CLI tools, patterns, workflows, and development tasks
+- **41 skills** for CLI tools, patterns, workflows, and development tasks
 - **Custom output styles** for response personality (e.g., Vesper)
 
 ## Installation

+ 2 - 1
README.md

@@ -9,7 +9,7 @@ Claude Code is brilliant - until your session ends and it forgets everything. Yo
 
 **claude-mods fixes that.** It's a plugin that adds session persistence, expert-level domain knowledge, and the modern CLI tools that Claude should've been using all along. Save your work with `/save`, pick up where you left off with `/sync`, and let 22 specialized agents handle everything from React hooks to PostgreSQL optimization. No more "I don't have access to that" - just a smarter, more capable coding assistant that actually remembers.
 
-**22 agents. 40 skills. 3 commands. One install.**
+**22 agents. 41 skills. 3 commands. One install.**
 
 ## Why claude-mods?
 
@@ -136,6 +136,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 #### Pattern Reference Skills
 | Skill | Description |
 |-------|-------------|
+| [cli-patterns](skills/cli-patterns/) | Production CLI tool patterns - agentic workflows, stream separation, semantic exit codes |
 | [rest-patterns](skills/rest-patterns/) | HTTP methods, status codes, REST design patterns |
 | [tailwind-patterns](skills/tailwind-patterns/) | Tailwind utilities, responsive breakpoints, config |
 | [sql-patterns](skills/sql-patterns/) | CTEs, window functions, JOIN patterns, indexing |

+ 1 - 1
docs/PLAN.md

@@ -13,7 +13,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 22 | Domain experts (Python, Go, Rust, React, etc.) |
-| Skills | 40 | Pattern libraries, CLI tools, workflows, dev tasks |
+| Skills | 41 | Pattern libraries, CLI tools, workflows, dev tasks |
 | Commands | 3 | Session management (sync, save) + experimental (canvas) |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Output Styles | 1 | Vesper personality |

+ 568 - 0
skills/cli-patterns/SKILL.md

@@ -0,0 +1,568 @@
+---
+name: cli-patterns
+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."
+compatibility: "Python 3.11+, Typer, Click"
+allowed-tools: "Read, Write, Edit"
+depends-on: []
+related-skills: [python-cli-patterns, python-async-patterns]
+---
+
+# 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
+
+---
+
+## 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
+```

+ 0 - 0
skills/cli-patterns/assets/.gitkeep


+ 449 - 0
skills/cli-patterns/references/implementation.md

@@ -0,0 +1,449 @@
+# 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
+
+```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"
+```

+ 128 - 0
skills/cli-patterns/references/json-schemas.md

@@ -0,0 +1,128 @@
+# 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"
+  }
+}
+```

+ 0 - 0
skills/cli-patterns/scripts/.gitkeep