cli-tool
Build command-line tools with argument parsing, subcommands, and nice UX.Use when: creating CLI, terminal app, command-line utility.Triggers: "cli", "command line", "terminal", "clap", "typer", "commander".
When & Why to Use This Skill
This Claude skill streamlines the end-to-end development of professional command-line interfaces (CLI) and terminal applications. It provides comprehensive templates and best practices for argument parsing, subcommand architecture, and interactive UX across multiple ecosystems including Rust (clap), Python (typer), and Node.js (commander), ensuring robust, secure, and user-friendly terminal utilities.
Use Cases
- Rapidly bootstrapping a system administration tool with complex nested subcommands and argument validation.
- Developing data processing utilities that require progress bars, rich table outputs, and multiple export formats like JSON.
- Creating interactive setup wizards or local development environment managers using prompts and configuration file handling.
- Implementing secure and tested CLI tools with integrated pre-commit hooks, shell completions, and standardized exit codes.
| name | cli-tool |
|---|---|
| description | | |
| Use when | creating CLI, terminal app, command-line utility. |
| Triggers | "cli", "command line", "terminal", "clap", "typer", "commander". |
CLI Tool Development
Project Protection Setup
MANDATORY before writing any code:
# 1. Create .gitignore
cat >> .gitignore << 'EOF'
# Build
target/
node_modules/
__pycache__/
dist/
# Config with secrets
config.toml
*.key
credentials.json
# IDE
.idea/
.vscode/
.DS_Store
EOF
# 2. Setup pre-commit hooks
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: detect-private-key
- id: check-added-large-files
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
EOF
pre-commit install
Stack Options
| Language | Framework | Best For |
|---|---|---|
| Rust | clap (derive) | Fast binaries, type safety |
| Python | typer / click | Rapid development |
| Node | commander / yargs | JS ecosystem |
Quick Start
Rust (clap derive)
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
use clap::Parser;
#[derive(Parser)]
#[command(name = "mytool")]
#[command(about = "A sample CLI tool")]
struct Cli {
/// Input file
input: String,
/// Output file
#[arg(short, long, default_value = "output.txt")]
output: String,
/// Verbose output
#[arg(short, long)]
verbose: bool,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.verbose {
println!("Input: {}", cli.input);
println!("Output: {}", cli.output);
}
// Do work...
Ok(())
}
Python (typer)
# requirements.txt
typer[all]>=0.9
import typer
app = typer.Typer()
@app.command()
def main(
input: str = typer.Argument(..., help="Input file"),
output: str = typer.Option("output.txt", "--output", "-o", help="Output file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""A sample CLI tool."""
if verbose:
typer.echo(f"Input: {input}")
typer.echo(f"Output: {output}")
if __name__ == "__main__":
app()
Node (commander)
// package.json: "commander": "^12"
import { program } from 'commander';
program
.name('mytool')
.description('A sample CLI tool')
.argument('<input>', 'Input file')
.option('-o, --output <file>', 'Output file', 'output.txt')
.option('-v, --verbose', 'Verbose output')
.action((input, options) => {
if (options.verbose) {
console.log(`Input: ${input}`);
console.log(`Output: ${options.output}`);
}
});
program.parse();
Subcommands
Rust
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mytool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add a new item
Add {
/// Item name
name: String,
},
/// List all items
List {
/// Show detailed info
#[arg(short, long)]
detailed: bool,
},
/// Remove an item
Remove {
/// Item ID
id: u32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Add { name } => println!("Adding: {}", name),
Commands::List { detailed } => println!("Listing (detailed: {})", detailed),
Commands::Remove { id } => println!("Removing: {}", id),
}
}
Python
import typer
app = typer.Typer()
@app.command()
def add(name: str):
"""Add a new item."""
typer.echo(f"Adding: {name}")
@app.command()
def list(detailed: bool = typer.Option(False, "--detailed", "-d")):
"""List all items."""
typer.echo(f"Listing (detailed: {detailed})")
@app.command()
def remove(id: int):
"""Remove an item."""
typer.echo(f"Removing: {id}")
if __name__ == "__main__":
app()
Output Formats
Text / JSON / Table
use clap::ValueEnum;
use serde::Serialize;
#[derive(ValueEnum, Clone)]
enum OutputFormat {
Text,
Json,
Table,
}
#[derive(Serialize)]
struct Item {
id: u32,
name: String,
}
fn output(items: &[Item], format: OutputFormat) {
match format {
OutputFormat::Text => {
for item in items {
println!("{}: {}", item.id, item.name);
}
}
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(items).unwrap());
}
OutputFormat::Table => {
println!("{:<5} {}", "ID", "Name");
println!("{}", "-".repeat(20));
for item in items {
println!("{:<5} {}", item.id, item.name);
}
}
}
}
Python (rich)
from rich.console import Console
from rich.table import Table
import json
console = Console()
def output(items: list, format: str):
if format == "text":
for item in items:
console.print(f"{item['id']}: {item['name']}")
elif format == "json":
console.print_json(json.dumps(items))
elif format == "table":
table = Table()
table.add_column("ID")
table.add_column("Name")
for item in items:
table.add_row(str(item["id"]), item["name"])
console.print(table)
Progress Bars
Rust (indicatif)
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(100);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap());
for i in 0..100 {
pb.set_position(i);
pb.set_message(format!("Processing item {}", i));
std::thread::sleep(std::time::Duration::from_millis(50));
}
pb.finish_with_message("Done!");
Python (rich)
from rich.progress import track
for item in track(range(100), description="Processing..."):
# Do work
pass
Config Files
Rust (config crate)
use config::{Config, File};
use serde::Deserialize;
#[derive(Deserialize)]
struct Settings {
api_key: String,
timeout: u64,
}
fn load_config() -> anyhow::Result<Settings> {
let settings = Config::builder()
.add_source(File::with_name("config.toml").required(false))
.add_source(config::Environment::with_prefix("MYTOOL"))
.build()?;
Ok(settings.try_deserialize()?)
}
# config.toml
api_key = "your-key"
timeout = 30
Python
import tomllib
from pathlib import Path
def load_config():
config_path = Path.home() / ".config" / "mytool" / "config.toml"
if config_path.exists():
return tomllib.loads(config_path.read_text())
return {}
Exit Codes
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(_) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
}
}
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of command |
| 126 | Permission denied |
| 127 | Command not found |
Shell Completions
Rust (clap)
use clap::CommandFactory;
use clap_complete::{generate, Shell};
#[derive(Parser)]
struct Cli {
#[arg(long, value_enum)]
completions: Option<Shell>,
}
fn main() {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "mytool", &mut std::io::stdout());
return;
}
}
Usage:
# Generate completions
mytool --completions bash > ~/.local/share/bash-completion/completions/mytool
mytool --completions zsh > ~/.zfunc/_mytool
Interactive Prompts
Rust (dialoguer)
use dialoguer::{Confirm, Input, Select};
let name: String = Input::new()
.with_prompt("Your name")
.interact_text()?;
let proceed = Confirm::new()
.with_prompt("Continue?")
.interact()?;
let options = vec!["Option 1", "Option 2", "Option 3"];
let selection = Select::new()
.with_prompt("Choose")
.items(&options)
.interact()?;
Python (rich)
from rich.prompt import Prompt, Confirm
name = Prompt.ask("Your name")
proceed = Confirm.ask("Continue?")
Common Pitfalls
| Pitfall | Solution |
|---|---|
| No help text | Add descriptions to all args |
| Poor error messages | Use anyhow/thiserror with context |
| No colors in pipes | Detect TTY, use --color=always |
| Slow startup | Lazy init, avoid heavy deps |
| No config file | Support ~/.config/tool/config.toml |
Testing
Rust (clap)
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_cli_help() {
Command::cargo_bin("mytool")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Usage"));
}
#[test]
fn test_cli_version() {
Command::cargo_bin("mytool")
.unwrap()
.arg("--version")
.assert()
.success();
}
#[test]
fn test_add_command() {
Command::cargo_bin("mytool")
.unwrap()
.args(["add", "test-item"])
.assert()
.success()
.stdout(predicate::str::contains("Added"));
}
#[test]
fn test_invalid_input_fails() {
Command::cargo_bin("mytool")
.unwrap()
.args(["add"]) // Missing required arg
.assert()
.failure();
}
}
Python (typer)
from typer.testing import CliRunner
from myapp import app
runner = CliRunner()
def test_help():
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "Usage" in result.output
def test_add_command():
result = runner.invoke(app, ["add", "test-item"])
assert result.exit_code == 0
assert "Added" in result.output
def test_invalid_input():
result = runner.invoke(app, ["add"]) # Missing arg
assert result.exit_code != 0
Node (commander)
import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';
describe('CLI', () => {
it('shows help', () => {
const output = execSync('node dist/cli.js --help').toString();
expect(output).toContain('Usage');
});
it('adds item', () => {
const output = execSync('node dist/cli.js add test-item').toString();
expect(output).toContain('Added');
});
});
TDD Workflow
1. Task[tdd-test-writer]: "Create 'add' subcommand"
→ Writes assert_cmd test
→ cargo test → FAILS (RED)
2. Task[rust-developer]: "Implement 'add' subcommand"
→ Implements minimal code
→ cargo test → PASSES (GREEN)
3. Repeat for each subcommand
4. Task[code-reviewer]: "Review CLI implementation"
→ Checks error messages, exit codes, edge cases
Security Checklist
- No secrets in default config
- Config file permissions checked (600 for sensitive)
- Input sanitized before shell execution
- No command injection in subprocesses
- Secure temp file handling
- Credentials stored in OS keyring (if needed)
Project Structure
mytool/
├── src/
│ ├── main.rs
│ ├── cli.rs # Argument definitions
│ ├── commands/ # Subcommand implementations
│ │ ├── mod.rs
│ │ ├── add.rs
│ │ └── list.rs
│ └── config.rs
├── tests/
│ └── cli_tests.rs # Integration tests
├── Cargo.toml
├── config.example.toml
└── README.md