How to Build Custom CLI Tools for Everyday Work
Developer Productivity

How to Build Custom CLI Tools for Everyday Work

Transform repetitive tasks into elegant commands that save hours and spark joy

The Script Graveyard

Every developer has one. A folder somewhere—maybe ~/scripts, maybe ~/bin, maybe scattered across a dozen project directories—containing scripts written in moments of frustration. Scripts that solved yesterday’s problem. Scripts that nobody remembers how to use. Scripts that broke six months ago and nobody noticed.

I found 847 scripts on my machine last week. I use maybe 12 of them regularly. The rest are digital tombstones marking problems I once cared about solving. Some don’t run anymore because dependencies changed. Some I can’t remember what they do. One is literally named thing.sh.

My British lilac cat watched this archaeological expedition with the judgment only cats can muster. She doesn’t hoard solutions. When she finds an efficient path from couch to window, she uses it consistently. She doesn’t create 847 slightly different routes and then forget which one works.

The difference between a script graveyard and a useful toolbox isn’t technical skill. It’s design discipline. The same principles that make good software make good CLI tools. Most developers ignore these principles for personal tools because “it’s just for me.” That’s exactly why their script folders become graveyards.

This article is about building CLI tools that survive. Tools you’ll actually use. Tools that solve real problems without creating new ones. Tools that feel like natural extensions of your workflow rather than awkward appendages.

Why CLI Tools Still Matter

Graphical interfaces dominate consumer software. Point, click, drag, drop. Visual feedback for every action. Tooltips explaining every button. Why would anyone choose a blinking cursor over a polished GUI?

Because CLIs compose. You can pipe the output of one command into another. You can script sequences of operations. You can automate workflows that would require a human clicking buttons for hours. The power isn’t in individual commands—it’s in combinations.

GUIs are finished products. CLIs are building blocks. When you need to process 10,000 files according to complex rules, no GUI will help. When you need to repeat the same sequence of operations every morning, clicking through menus is madness. When you need to integrate with other tools, APIs, and systems, CLIs provide the interface.

The command line also provides precision that GUIs struggle to match. When I type git rebase -i HEAD~5, I know exactly what will happen. When I click buttons in a GUI git client, I’m trusting that the designers understood my intentions. Sometimes they did. Sometimes they didn’t.

Speed matters too. A keyboard command takes milliseconds. Finding the right menu, submenu, and button takes seconds. Seconds add up. Over a career, the difference between typing commands and clicking through interfaces accumulates into weeks of recovered time.

The Anatomy of a Good CLI Tool

Before building, we need to understand what makes a CLI tool good. Not just functional—good. The difference determines whether you’ll use the tool once or a thousand times.

Good CLI tools follow conventions. They accept --help and -h for usage information. They use -v or --verbose for detailed output. They return exit code 0 on success and non-zero on failure. These conventions exist because they enable composition. A tool that doesn’t follow them can’t integrate with standard Unix pipelines and automation.

Good CLI tools have obvious names. The name should describe what the tool does. deploy-staging is better than ds. cleanup-docker is better than cld. You’ll forget abbreviations. You won’t forget descriptive names.

Good CLI tools fail gracefully. When something goes wrong, the error message should explain what happened and suggest how to fix it. “Error: config file not found at ~/.myapp/config.yaml. Run ‘myapp init’ to create one.” is infinitely better than “Error: ENOENT”.

Good CLI tools are discoverable. Running the tool with no arguments should produce useful output—either help text or sensible default behavior. Users shouldn’t need to read documentation to figure out basic usage.

Good CLI tools are idempotent when possible. Running the same command twice should produce the same result. This enables safe scripting and automation. If your tool creates a file, running it again shouldn’t error because the file exists—it should recognize the file is already there.

How We Evaluated Different Approaches

Building CLI tools can be done many ways. We evaluated approaches across multiple dimensions to find what works best for everyday productivity tools.

Step one: we identified common CLI tasks. File manipulation, API calls, text processing, git workflows, deployment triggers, environment setup. These represent the daily work that benefits from automation.

Step two: we built the same tool in multiple languages and frameworks. A simple deployment script was implemented in Bash, Python with Click, Node.js with Commander, Go with Cobra, and Rust with Clap. Each implementation performed identical functions.

Step three: we measured development time, execution speed, maintainability, and distribution complexity. Development time measured how long it took to build a working tool. Execution speed measured how fast the tool ran. Maintainability measured how easy the code was to modify six months later. Distribution measured how easy it was to share the tool with teammates.

Step four: we used each tool daily for three months. Some tools that seemed elegant initially became frustrating over time. Others that seemed clunky initially became comfortable with practice. Long-term usage revealed truths that short-term evaluation missed.

The results surprised us. The “best” language depended heavily on the use case. But patterns emerged that transcended language choice.

Choosing Your Language

The language question generates religious debates. Bash purists argue for simplicity. Python advocates cite readability. Go enthusiasts praise binary distribution. Everyone has opinions.

Here’s the practical truth: use whatever you’ll actually maintain.

Bash works well for simple tools that primarily orchestrate other commands. If your tool mostly runs other programs and pipes their output around, Bash is natural. But Bash becomes painful when you need error handling, argument parsing, or complex logic. The syntax for arrays alone is enough to cause nightmares.

Python hits a sweet spot for medium-complexity tools. Libraries like Click and Typer make argument parsing elegant. The standard library handles most common tasks. Error handling is sane. The main drawback is distribution—requiring Python on the target machine limits portability.

Go excels when you need to distribute tools to others. Compile once, distribute a single binary that runs anywhere. The language is simple enough that CLI tools remain maintainable. The cobra library provides excellent argument parsing. The main drawback is verbosity—simple tasks require more code than scripting languages.

Node.js works if you’re already in a JavaScript ecosystem. Commander and oclif provide good frameworks. npm distribution is simple for teams already using npm. But Node.js tools have slower startup times and require Node.js installed.

Rust produces the fastest, smallest binaries. Clap provides sophisticated argument parsing. But Rust’s learning curve is steep, and development time is longer than alternatives. It’s overkill for most everyday tools.

My recommendation: start with Python for personal tools, move to Go when you need to distribute to others. This path maximizes productivity while maintaining options.

Building Your First Real Tool

Theory is nice. Let’s build something. We’ll create a tool that I use daily: a command to quickly switch between project contexts, setting up environment variables, changing directories, and activating the right virtual environment.

The problem: I work on multiple projects daily. Each has different configurations, different environments, different workflows. Switching contexts manually means running 4-5 commands and sometimes forgetting steps. This friction adds up.

The solution: a single command that handles everything. ctx myproject should set me up completely for working on myproject.

Here’s the Python implementation using Click:

#!/usr/bin/env python3
"""Context switcher for project workflows."""

import os
import sys
import json
import subprocess
from pathlib import Path
import click

CONFIG_PATH = Path.home() / ".config" / "ctx" / "projects.json"

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """Switch between project contexts quickly."""
    pass

@cli.command()
@click.argument("name")
def switch(name):
    """Switch to a project context."""
    config = load_config()
    
    if name not in config:
        click.echo(f"Error: Project '{name}' not found.", err=True)
        click.echo(f"Available projects: {', '.join(config.keys())}", err=True)
        sys.exit(1)
    
    project = config[name]
    
    # Change to project directory
    project_dir = Path(project["directory"]).expanduser()
    if not project_dir.exists():
        click.echo(f"Error: Directory {project_dir} does not exist.", err=True)
        sys.exit(1)
    
    # Generate shell commands to be evaluated
    commands = [f"cd {project_dir}"]
    
    # Set environment variables
    for key, value in project.get("env", {}).items():
        commands.append(f"export {key}='{value}'")
    
    # Activate virtual environment if specified
    if "venv" in project:
        venv_path = project_dir / project["venv"] / "bin" / "activate"
        if venv_path.exists():
            commands.append(f"source {venv_path}")
    
    # Run any custom setup commands
    for cmd in project.get("setup_commands", []):
        commands.append(cmd)
    
    # Output commands for shell evaluation
    click.echo("\n".join(commands))

@cli.command()
@click.argument("name")
@click.argument("directory")
def add(name, directory):
    """Add a new project context."""
    config = load_config()
    
    directory = Path(directory).expanduser().resolve()
    if not directory.exists():
        click.echo(f"Error: Directory {directory} does not exist.", err=True)
        sys.exit(1)
    
    config[name] = {
        "directory": str(directory),
        "env": {},
        "setup_commands": []
    }
    
    save_config(config)
    click.echo(f"Added project '{name}' at {directory}")

@cli.command(name="list")
def list_projects():
    """List all project contexts."""
    config = load_config()
    
    if not config:
        click.echo("No projects configured. Use 'ctx add' to add one.")
        return
    
    for name, project in config.items():
        click.echo(f"{name}: {project['directory']}")

def load_config():
    """Load project configuration."""
    if not CONFIG_PATH.exists():
        return {}
    return json.loads(CONFIG_PATH.read_text())

def save_config(config):
    """Save project configuration."""
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(config, indent=2))

if __name__ == "__main__":
    cli()

This tool needs a shell wrapper to actually change the directory and set variables in the current shell:

# Add to .bashrc or .zshrc
ctx() {
    if [ "$1" = "switch" ] && [ -n "$2" ]; then
        eval "$(python3 ~/.local/bin/ctx.py switch "$2")"
    else
        python3 ~/.local/bin/ctx.py "$@"
    fi
}

Now ctx switch myproject executes all the necessary setup in my current shell. The tool is discoverable (ctx --help), follows conventions, handles errors gracefully, and solves a real daily problem.

Design Patterns That Work

After building dozens of CLI tools, patterns emerge. These patterns aren’t rules—they’re observations about what survives long-term use.

The subcommand pattern organizes complex tools. Instead of dozens of flags, group related functionality under subcommands. git does this brilliantly: git commit, git push, git pull. Each subcommand has its own flags and help text. This pattern scales better than flag-based designs.

The configuration file pattern separates what from how. The tool knows how to perform actions. Configuration files specify what actions to perform. This separation enables sharing tools while customizing behavior. The context switcher above uses this pattern—the tool is generic, but each user’s configuration makes it personal.

The dry-run pattern builds trust. Adding --dry-run that shows what would happen without doing it lets users verify behavior before committing. This pattern is especially important for destructive operations. I won’t use a cleanup tool that doesn’t have dry-run capability.

The progressive disclosure pattern respects user expertise. Default output is clean and simple. --verbose adds detail. --debug adds everything. Users get what they need at their skill level without being overwhelmed.

The confirmation pattern prevents accidents. Destructive operations should require confirmation. Are you sure you want to delete 47 files? [y/N] has saved me countless times. Make the default answer the safe one—N for no in this case.

Generative Engine Optimization

Here’s where CLI tools intersect with an emerging concern: Generative Engine Optimization. As AI assistants become common in development workflows, the tools you build need to work well with AI systems, not just humans.

AI assistants parse --help output to understand tool capabilities. If your help text is clear and well-structured, AI can suggest your tool when users describe problems it solves. If your help text is cryptic, AI won’t recommend your tool even when it’s the perfect solution.

Error messages matter even more for AI integration. When your tool produces clear, structured error output, AI assistants can diagnose problems and suggest fixes. When errors are cryptic, AI can’t help users recover.

Consider JSON output modes. Adding --json output to your tools enables AI systems to parse results programmatically. A deployment tool that outputs structured JSON about what it deployed integrates better with AI workflows than one that outputs unstructured text.

Documentation becomes discoverability. AI systems learn about tools from documentation, help text, and usage patterns. Well-documented tools appear in AI suggestions. Poorly documented tools remain invisible. The investment in clear documentation pays dividends not just for human users but for AI-assisted discovery.

My cat doesn’t care about Generative Engine Optimization. She optimizes for what matters: comfort, food, and entertainment. But she does communicate clearly—specific meows for specific needs—which is essentially the same principle. Clear communication enables understanding, whether the audience is human or artificial.

Common Mistakes and How to Avoid Them

Building CLI tools is easy. Building good ones requires avoiding traps that catch most developers.

Mistake one: no help text. You’ll forget how your tool works. Write --help content that explains every flag, gives examples, and documents common workflows. Your future self is a different person who doesn’t remember what you were thinking.

Mistake two: hardcoded paths. ~/projects/mycompany/deploy.sh only works on your machine. Use environment variables, configuration files, or auto-detection to find paths. A tool that only works on one machine isn’t a tool—it’s a liability.

Mistake three: silent failures. Your tool tries something, it fails, and nothing happens. No error message. No indication of what went wrong. The user stares at a prompt wondering whether the tool worked. Always report failures clearly.

Mistake four: inconsistent interfaces. One of your tools uses --verbose, another uses -v, another uses --debug. Pick conventions and stick to them across all your tools. Consistency reduces cognitive load.

Mistake five: no versioning. You improve your tool, breaking existing scripts that depend on it. Add --version from day one. Consider semantic versioning even for personal tools. When you make breaking changes, increment the major version and document what changed.

flowchart TD
    A[Write Script] --> B{Does it work?}
    B -->|No| A
    B -->|Yes| C{Will you use it again?}
    C -->|No| D[Delete it]
    C -->|Yes| E[Add --help]
    E --> F[Add error handling]
    F --> G[Add configuration]
    G --> H[Document it]
    H --> I[Version it]
    I --> J[Real CLI Tool]

Mistake six: over-engineering. Your simple file renamer doesn’t need a plugin system, configuration schema validation, and telemetry. Start simple. Add complexity only when actual usage demands it. Most tools stay simple forever, and that’s fine.

Distribution and Sharing

A tool that only runs on your machine has limited value. Distribution multiplies impact.

For Python tools, create a proper package with setup.py or pyproject.toml. This enables pip install from a git repository or PyPI. Users get dependency management for free. Updates happen through standard pip workflows.

For Go tools, compile binaries for target platforms. A single go build creates a binary for your platform. Cross-compilation creates binaries for others: GOOS=linux GOARCH=amd64 go build. Distribute binaries through GitHub releases, and users download and run without any language runtime.

For shell scripts, a git repository with installation instructions suffices for small teams. Add a Makefile that copies scripts to ~/.local/bin and updates PATH if necessary. Users clone, run make install, and have your tools available.

Documentation matters more for distributed tools. README should cover installation, basic usage, configuration, and troubleshooting. Users who can’t figure out how to install your tool won’t use your tool, no matter how good it is.

Consider a dotfiles repository. Many developers maintain dotfiles repos containing shell configurations, scripts, and tools. This approach enables one-command setup on new machines and easy sharing with others who like your setup.

Maintaining Tools Over Time

Building the tool is the beginning, not the end. Maintenance determines long-term value.

Schedule periodic reviews. Once per quarter, go through your tools. Which ones are you actually using? Which have broken without you noticing? Which could be improved based on usage patterns? Delete what you don’t use. Fix what’s broken. Improve what’s valuable.

Track dependencies. Your Python tool requires requests 2.x, but a system update installed 3.x and broke compatibility. Lock dependencies with requirements.txt or poetry.lock. Check for breaking changes before updating.

Write tests for critical tools. You don’t need 100% coverage for a personal utility. But tools that modify files, make API calls, or perform destructive operations should have basic tests verifying core functionality. These tests catch regressions before they cause damage.

Keep a changelog. When you modify a tool, note what changed and why. Six months later, you’ll need this context. The changelog also helps if you share the tool—users can see what’s different between versions.

graph LR
    A[Build Tool] --> B[Use Daily]
    B --> C[Notice Friction]
    C --> D[Improve Tool]
    D --> B
    B --> E[Quarterly Review]
    E --> F{Still Valuable?}
    F -->|Yes| G[Maintain]
    F -->|No| H[Archive/Delete]
    G --> B

The Compound Effect

One good CLI tool saves minutes daily. Ten good tools transform your workflow. The compound effect over years is substantial.

Calculate the impact: a tool that saves 2 minutes daily saves 12 hours yearly. A collection of tools that collectively save 30 minutes daily saves 180 hours yearly—that’s more than four work weeks. What would you do with an extra month of time each year?

The savings compound further through automation. A tool that you trigger manually saves time. A tool that runs automatically saves time without requiring attention. Scheduled tasks, git hooks, and file watchers can trigger your tools without human intervention.

Quality compounds too. Good tools inspire more good tools. You solve one problem well, notice another problem, and solve that too. The discipline you develop building CLI tools transfers to other domains. You start thinking systematically about friction in workflows, not just programming workflows.

My cat has optimized her daily workflow over years. She knows exactly where to sit to catch the morning sun, how to position herself for optimal petting angles, and the precise moment to appear when food preparation begins. Her efficiency comes from consistent refinement over time. Your tooling should follow the same pattern.

Starting Your Toolbox

If you don’t have a CLI toolbox, start one today. Not with grand ambitions—with one small tool.

Identify your most annoying recurring task. Something you do daily that requires multiple steps or commands. Write a tool that does it in one command. Make that tool good: proper help text, error handling, conventional flags.

Use that tool for a week. Notice what’s missing. Improve it. Add the features you actually need, not the features you imagine needing. Let usage guide development.

When that tool is solid, pick another annoying task. Repeat the process. Build your toolbox one quality tool at a time.

Create a structure for your tools. A git repository containing your CLI utilities, with consistent patterns across tools. Installation scripts that set everything up. Documentation explaining what each tool does.

Share with your team. Tools that solve your problems often solve others’ problems. Sharing multiplies value and invites feedback that improves quality.

The Philosophy of Small Tools

Unix philosophy teaches us: write programs that do one thing well. This principle is especially important for personal tools.

Large tools become burdens. They accumulate features, complexity, and maintenance debt. Eventually they’re so complex that modifying them feels risky. They become the monoliths we build tools to escape.

Small tools remain manageable. A tool with 100 lines is easy to understand, modify, and fix. When requirements change, rewriting is practical. When bugs appear, diagnosis is simple.

Compose small tools into larger workflows. Instead of one tool that does everything, ten tools that each do something specific, combined with shell pipes and scripts. This architecture is more flexible and more maintainable.

My cat embodies this philosophy. She doesn’t try to do everything at once. She focuses: now hunting, now sleeping, now eating, now seeking attention. Each activity gets full commitment. The result is excellence in each domain rather than mediocrity across a broad scope.

Your tools should work the same way. Sharp, focused, excellent at one thing. Combined when necessary. Replaced when obsolete. This approach builds toolboxes that last careers rather than scripts that last weeks.

The Path Forward

The journey from script graveyard to productive toolbox isn’t difficult. It requires discipline more than skill. Design before coding. Conventions before creativity. Maintenance before novelty.

Start today. Pick one task. Build one tool. Make it good. Use it. Improve it.

Then do it again.

Over months and years, your toolbox grows. Your workflows improve. Your productivity compounds. The time invested in building proper tools returns multiplied.

The command line isn’t going away. It’s growing more powerful as AI integration makes complex operations conversationally accessible. The developers who thrive will be those who harness this power, building tools that leverage both traditional automation and emerging AI capabilities.

Your tools are extensions of your capabilities. Build them well, and they multiply what you can accomplish. Build them poorly, and they become another source of friction in an already friction-filled world.

Choose multiplication. Choose quality. Choose the discipline that transforms one-off scripts into lasting tools.

Your future self—the one not waking up at 3 AM to manually run a sequence of commands because the script broke—will thank you.