Building CLI Tools
Building CLI Tools — The Complete Guide
Learn to design, structure, package, and distribute professional command-line tools in Python — from first script to installable command.
Introduction
A CLI tool (Command-Line Interface tool) is a program you run and control entirely from the terminal. Great CLI tools are fast, scriptable, and composable — they do one job well, accept arguments and flags, produce clean output, and return proper exit codes so they can be chained together. This guide walks you through building a real CLI tool in Python: structuring the code, parsing arguments, handling errors, and packaging it so others can install and run it as a first-class command.
Real-World Analogy
A Well-Organized Power Tool
A good power drill has a clear handle, labelled speed settings, and interchangeable bits — anyone can pick it up and use it safely. A CLI tool is the same: a clean entry point (handle), options and flags (settings), and subcommands (bits). Build it well once, and it becomes a reliable tool people reach for again and again.
Prerequisites
Before You Start
- Python 3.7+ installed
- Understanding of functions, modules, and the
if __name__ == "__main__"idiom - Familiarity with
argparseand command-line arguments - Basic knowledge of
pipand virtual environments - Comfort running commands in a terminal
Anatomy of a Good CLI Tool
Clear Entry Point
A single main() function.
All execution flows through one place, making the tool easy to test, import, and package.
Argument Parsing
Robust input handling.
Use argparse (or Click/Typer) for options, flags, help text, and validation.
Proper Exit Codes
Success = 0, failure = non-zero.
Exit codes let your tool be chained safely in scripts and pipelines.
Step 1: A Minimal CLI Tool
Start with a clean structure — logic in functions, a main(), and a guarded entry point.
# greet.py
import argparse
import sys
def greet(name, times):
for _ in range(times):
print(f"Hello, {name}!")
def main():
parser = argparse.ArgumentParser(description="Greet someone")
parser.add_argument("name", help="who to greet")
parser.add_argument("-n", "--times", type=int, default=1, help="how many times")
args = parser.parse_args()
greet(args.name, args.times)
return 0
if __name__ == "__main__":
sys.exit(main())
$ python greet.py Rumman -n 2
Hello, Rumman!
Hello, Rumman!
greet() (not in main()) makes it testable and reusable as an imported module.
Step 2: Add Subcommands
Real tools often have multiple actions (git-style). Use subparsers to organize them.
# todo.py
import argparse
import sys
def add(args):
print(f"Added task: {args.task}")
def remove(args):
print(f"Removed task: {args.task}")
def main():
parser = argparse.ArgumentParser(prog="todo")
sub = parser.add_subparsers(dest="command", required=True)
add_p = sub.add_parser("add", help="add a task")
add_p.add_argument("task")
add_p.set_defaults(func=add)
rm_p = sub.add_parser("remove", help="remove a task")
rm_p.add_argument("task")
rm_p.set_defaults(func=remove)
args = parser.parse_args()
args.func(args) # dispatch to the chosen subcommand
return 0
if __name__ == "__main__":
sys.exit(main())
$ python todo.py add "Buy milk"
Added task: Buy milk
set_defaults(func=...) lets each subcommand point to its own handler — clean dispatch with no big if/elif chain.
Step 3: Handle Errors and Exit Codes
Fail gracefully with a clear message and a non-zero exit code.
import sys
def read_file(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print(f"Error: file not found: {path}", file=sys.stderr)
sys.exit(1) # non-zero = failure
def main():
content = read_file("data.txt")
print(content)
return 0
| Exit Code | Meaning |
|---|---|
0 |
Success |
1 |
General error |
2 |
Misuse (argparse uses this for bad arguments) |
>2 |
Custom, tool-specific error codes |
Step 4: Play Nicely with stdin/stdout/stderr
Composable tools read from stdin, write results to stdout, and send errors to stderr.
import sys
def main():
# Read piped input if no file given
data = sys.stdin.read()
# Normal output -> stdout
print(data.upper())
# Diagnostics -> stderr (won't pollute piped output)
print("Processed input", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
$ echo "hello" | python upper.py
HELLO
Step 5: Package It as an Installable Command
Use a pyproject.toml with an entry point so users can run your tool by name.
# pyproject.toml
[project]
name = "mytool"
version = "1.0.0"
[project.scripts]
mytool = "mytool.cli:main" # command -> module:function
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
$ pip install .
$ mytool Rumman -n 2 # now it's a real command!
Hello, Rumman!
Hello, Rumman!
[project.scripts] entry point maps the command name mytool to your main() function.
CLI Framework Options
| Library | Best For | Note |
|---|---|---|
argparse |
Standard, no dependencies | Built in; great for most tools |
click |
Decorator-based, rich features | Popular third-party framework |
typer |
Type-hint driven CLIs | Built on Click; very modern |
rich / rich-click |
Beautiful colored output | Tables, progress bars, styling |
The Unix Philosophy, Quantified
Small composable tools multiply your power. If you have \(t\) single-purpose tools that can be piped together, the number of possible two-stage pipelines is:
\[ N_{\text{pipelines}} = t \times (t - 1) \]
This combinatorial reuse is why "do one thing well" beats one giant monolithic program — each new small tool multiplies what's possible.
Best Practices
Do This
- Keep business logic separate from argument parsing for testability
- Return proper exit codes (0 for success, non-zero for errors)
- Send errors and diagnostics to
stderr, results tostdout - Provide
--helpand a clear--versionflag - Follow the Unix philosophy: do one thing well and be composable
- Package with an entry point so it installs as a real command
Common Mistakes
stdout — they get mixed into piped output and break downstream tools.
stderr: print(msg, file=sys.stderr).
&& chaining and CI pipelines behave correctly.
Interview Questions
| Question | Short Answer |
|---|---|
| What makes a good CLI tool? | Clear args, proper exit codes, stdout/stderr discipline, and composability. |
| Why separate logic from main()? | So it can be unit-tested and reused as an importable module. |
| How do you make a tool installable? | Define an entry point in pyproject.toml under [project.scripts]. |
| Where should errors go? | To stderr, keeping stdout clean for real output. |
| argparse vs click vs typer? | argparse is built in; click/typer add ergonomics and richer features. |
Quick Revision
| Step | What to Do |
|---|---|
| 1. Structure | Logic in functions, one main() |
| 2. Parse | Use argparse / subparsers |
| 3. Errors | stderr + non-zero exit codes |
| 4. I/O | Respect stdin/stdout/stderr |
| 5. Package | Entry point in pyproject.toml |
Key Takeaways
Great CLI tools separate logic from parsing, use proper exit codes, respect stdout/stderr, and ship as installable commands via an entry point. Follow the Unix philosophy — do one thing well and stay composable — and reach for argparse, Click, or Typer to power it.