Claude Skill

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.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_cli-ops-3dfaf0b.zip · 11 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/cli-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git 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

  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

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:

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

Help Format Template

<one-line description>

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

Arguments:
  <arg>          Description of positional argument

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

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

Examples Are Critical

Examples should show:

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

Authentication

Auth Commands

Tools requiring authentication MUST implement:

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

Credential Storage Priority

Recommended: OS keyring with fallbacks for maximum security

  1. Environment variable (CI/CD, testing)

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

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

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

Dependencies:

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

Simple alternative: Just config file in ~/.config/<tool>/

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

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

Unauthenticated Behavior

When auth is required but missing:

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

Data Conventions

Date Handling

Input (Flexible): Accept multiple formats for user convenience

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

Recommended Additions

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

Framework Choice

Typer (preferred for new tools):

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

Click (acceptable for existing tools):

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

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

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

console = Console(stderr=True)  # Same pattern
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.

No comments yet.

Reviews (0)

No reviews yet.

Related