Claude Code Plugins

Community-maintained marketplace

Feedback

|

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name cli-tool
description 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".

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