cli-tool

timequity's avatarfrom timequity

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".

⭐0stars🔀0forks📁View on GitHub🕐Updated Dec 18, 2025

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.
namecli-tool
description|
Use whencreating 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