Imported from organvm-iv-taxis/a-i--skills (
skills/development/cli-tool-design/SKILL.md). Install upstream withnpx skills add organvm-iv-taxis/a-i--skills --skill cli-tool-design. Copyright stays with the author (MIT).
CLI Tool Design
Build command-line tools that are discoverable, composable, and pleasant to use.
Design Principles
The UNIX Philosophy Applied
- Do one thing well — Each command has a clear, singular purpose
- Compose through pipes — Support stdin/stdout for chaining
- Fail loudly — Non-zero exit codes and stderr for errors
- Be predictable — Consistent flags, consistent output format
Command Structure
program [global-options] command [command-options] [arguments]
Example:
organvm --verbose registry update --organ IV a-i--skills
Framework Selection
| Framework | Language | Best For |
|---|---|---|
| Typer | Python | Modern CLIs with type hints, auto-completion |
| Click | Python | Complex CLIs, plugins, nested groups |
| argparse | Python | Zero-dependency, stdlib-only |
| clap | Rust | High-performance, compiled CLIs |
| cobra | Go | Go microservice CLIs |
Building with Typer (Recommended for Python)
Basic Command
import typer
app = typer.Typer(help="ORGANVM system management CLI")
@app.command()
def status(
organ: str = typer.Argument(help="Organ number (I-VII or META)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
):
"""Show the status of an organ's repositories."""
# Implementation here
Subcommand Groups
app = typer.Typer()
registry_app = typer.Typer(help="Registry operations")
app.add_typer(registry_app, name="registry")
@registry_app.command("update")
def registry_update(
organ: str = typer.Argument(help="Target organ"),
dry_run: bool = typer.Option(False, "--dry-run", "-n"),
):
"""Update registry entries for an organ."""
Rich Output Integration
from rich.console import Console
from rich.table import Table
console = Console(stderr=True) # Status to stderr
def show_repos(repos: list[dict]):
table = Table(title="Repositories")
table.add_column("Name", style="cyan")
table.add_column("Status", style="green")
table.add_column("Tier")
for repo in repos:
table.add_row(repo["name"], repo["status"], repo["tier"])
console.print(table)
Argument Design
Positional vs Optional
| Use Case | Type | Example |
|---|---|---|
| Required input | Positional | program FILE |
| Behavior modifier | Flag | --verbose, --dry-run |
| Configuration | Option | --output FORMAT |
| Multiple inputs | Variadic | program FILE... |
Flag Conventions
-v, --verbose Increase output verbosity
-q, --quiet Suppress non-error output
-n, --dry-run Show what would happen without doing it
-f, --force Skip confirmation prompts
-o, --output FILE Write output to FILE instead of stdout
--json Machine-readable JSON output
--no-color Disable colored output
Boolean Flags with Negation
@app.command()
def deploy(
color: bool = typer.Option(True, "--color/--no-color"),
interactive: bool = typer.Option(True, "--interactive/--no-interactive"),
):
Output Design
Human vs Machine Output
import json
import sys
def output_results(results: list[dict], json_mode: bool = False):
if json_mode:
# Machine output to stdout
json.dump(results, sys.stdout, indent=2)
else:
# Human output with formatting
for r in results:
console.print(f"[cyan]{r['name']}[/] — {r['status']}")
Progress Indicators
from rich.progress import track
for item in track(items, description="Processing..."):
process(item)
Stderr vs Stdout
- stdout: Data output (pipeable)
- stderr: Status messages, progress, errors
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Usage error (bad arguments) |
| 64-78 | BSD sysexits conventions |
| 130 | Interrupted (Ctrl+C) |
import sys
def main():
try:
result = run_command()
if not result.success:
console.print(f"[red]Error:[/] {result.error}", file=sys.stderr)
raise SystemExit(1)
except KeyboardInterrupt:
raise SystemExit(130)
Configuration Loading
Priority order (highest to lowest):
- Command-line arguments
- Environment variables
- Project-level config file (
.tool.yaml) - User-level config (
~/.config/tool/config.yaml) - System defaults
def get_config(cli_value: str | None = None) -> str:
return (
cli_value
or os.environ.get("TOOL_CONFIG")
or load_project_config()
or load_user_config()
or DEFAULT_VALUE
)
Shell Completion
Typer Auto-Completion
# Generate completion script
my-cli --install-completion
# Or manually
_MY_CLI_COMPLETE=bash_source my-cli > ~/.my-cli-complete.bash
source ~/.my-cli-complete.bash
Custom Completions
def complete_organ(incomplete: str) -> list[str]:
organs = ["I", "II", "III", "IV", "V", "VI", "VII", "META"]
return [o for o in organs if o.startswith(incomplete.upper())]
@app.command()
def status(organ: str = typer.Argument(autocompletion=complete_organ)):
...
Testing CLIs
from typer.testing import CliRunner
runner = CliRunner()
def test_status_command():
result = runner.invoke(app, ["status", "IV"])
assert result.exit_code == 0
assert "a-i--skills" in result.stdout
def test_invalid_organ():
result = runner.invoke(app, ["status", "INVALID"])
assert result.exit_code == 2
Anti-Patterns
- Requiring interactive input in scripts — Always support
--yes/--no-interactiveflags - Mixing data and status on stdout — Use stderr for progress and status messages
- Inconsistent flag naming — Pick a convention and stick to it across all subcommands
- No help text — Every command, argument, and option needs a help string
- Swallowing errors — Always exit with appropriate non-zero codes on failure