Building Real CLI Tools

Expert · 18 min read · ▶ live playground · ✦ checkpoint

A script becomes a tool when someone can run it with clear choices instead of editing variables in the file. A command-line interface, usually shortened to CLI, is the text interface where a program receives arguments like raw --output cleaned --apply and turns them into predictable behavior.

In the automation lesson, the script already knew its input folder, output folder, and dry-run mode. This lesson moves those decisions to the command line while keeping the same safety rule: plan first, apply only when asked.

The command line is just text pieces

An argument is one text piece passed to a command. A positional argument gets its meaning from where it appears, like raw meaning "the input folder." An option starts with a name such as --output cleaned. A flag is an option that flips behavior on or off, like --apply.

In a real terminal, the shell hands those pieces to Python. In this browser lesson, every live example supplies an explicit argv list so the code never depends on real shell arguments.

import argparse
 
parser = argparse.ArgumentParser(prog="tidy-reports")
parser.add_argument("input_folder")
parser.add_argument("--output", default="cleaned")
parser.add_argument("--apply", action="store_true")
 
args = parser.parse_args(["incoming", "--output", "reviewed", "--apply"])
 
print("input:", args.input_folder)
print("output:", args.output)
print("apply:", args.apply)

input: incoming
output: reviewed
apply: True

argparse.ArgumentParser describes the command. parse_args(argv) reads the supplied list and returns a Namespace, an object whose attributes hold the parsed values. action="store_true" means the flag starts as False and becomes True only when the user includes it.

Keep work separate from argument parsing

A CLI should have two layers. The worker function does the useful work with normal Python values. The main(argv) wrapper parses command-line text, calls the worker, prints user-facing messages, and returns an exit code.

That split makes the tool testable. You can test the worker with paths and lists. You can test main(["raw", "--apply"]) without pretending to type in a real terminal.

import argparse
from pathlib import Path
import sys
 
 
def plan_report_copies(input_folder, output_folder):
    actions = []
    for source in sorted(input_folder.glob("*.txt")):
        clean_name = source.stem.lower().replace(" ", "-") + source.suffix
        actions.append((source, output_folder / clean_name))
    return actions
 
 
def apply_report_copies(actions):
    for source, target in actions:
        target.parent.mkdir(parents=True, exist_ok=True)
        text = source.read_text(encoding="utf-8").strip().lower()
        target.write_text(text + "\n", encoding="utf-8")
 
 
def build_parser():
    parser = argparse.ArgumentParser(prog="tidy-reports")
    parser.add_argument("input_folder")
    parser.add_argument("--output", default="cleaned")
    parser.add_argument("--apply", action="store_true")
    return parser
 
 
def main(argv):
    args = build_parser().parse_args(argv)
    input_folder = Path(args.input_folder)
    output_folder = Path(args.output)
 
    actions = plan_report_copies(input_folder, output_folder)
    if not actions:
        print("No .txt reports found", file=sys.stderr)
        return 1
 
    if args.apply:
        apply_report_copies(actions)
        verb = "wrote"
    else:
        verb = "would write"
 
    for source, target in actions:
        print(f"{verb}: {source.name} -> {target.name}")
    return 0
 
 
sandbox = Path("cli_tool_sandbox")
raw = sandbox / "raw"
out = sandbox / "out"
raw.mkdir(parents=True, exist_ok=True)
(raw / "Mina Report.txt").write_text(" DONE: 3 invoices \n", encoding="utf-8")
(raw / "Ravi Report.txt").write_text(" DONE: 5 invoices \n", encoding="utf-8")
 
if __name__ == "__main__":
    code = main([raw.as_posix(), "--output", out.as_posix()])
    print("exit:", code)
    code = main([raw.as_posix(), "--output", out.as_posix(), "--apply"])
    print("exit:", code)

would write: Mina Report.txt -> mina-report.txt
would write: Ravi Report.txt -> ravi-report.txt
exit: 0
wrote: Mina Report.txt -> mina-report.txt
wrote: Ravi Report.txt -> ravi-report.txt
exit: 0

The sample setup exists only so the browser can run the example from a clean page. In a saved tool, the sandbox setup would not be part of the tool; the user would point the command at real folders.

Exit codes and output streams

An exit code is the small number a command returns to say how it ended. By convention, 0 means success. A nonzero number means something prevented normal work. argparse itself uses exit code 2 for command-line syntax problems, such as a missing required positional argument.

stdout is the normal output stream: the place for results the user may pipe, save, or read. stderr is the error output stream: the place for warnings, explanations, and problems. The main() above prints its planned or written file list to stdout. If no matching reports exist, it prints the problem to stderr and returns 1.

Static terminal transcript only - not run in this browser lesson:
 
$ python tidy_reports.py raw --output out
would write: Mina Report.txt -> mina-report.txt
would write: Ravi Report.txt -> ravi-report.txt
exit code: 0
 
$ python tidy_reports.py empty --output out
stderr: No .txt reports found
exit code: 1

Returning numbers from main(argv) keeps examples easy to test. A real command can pass that number back to the operating system from the if __name__ == "__main__" block; here, the lesson prints it so the playground stays clean.

Try it - route subcommands

A subcommand is a command word inside a larger CLI, like git status and git commit. Use subcommands when one tool has several related jobs. For a report tool, clean and summary can share one program but have different arguments.

python — playgroundlive
⌘/Ctrl + Enter to run

Subcommands are useful, but they are not the first thing every script needs. Start with one clear command. Add subcommands only when the user would naturally say, "This tool has several modes."

Sharing the tool

argparse is in the standard library, so it is the right first tool. Click and Typer are third-party libraries for larger CLIs with richer defaults, decorators, shell completion, and typed command definitions. They are useful later, but argparse teaches the shape underneath all of them: command, arguments, options, validation, return code.

Packaging turns a Python file into a command people can install. Lesson 18.3 showed the packaging pieces; a package can expose a script name that points at a tiny zero-argument wrapper, such as cli(), which delegates to your main(...) function.

Static packaging shape only - not run in this browser lesson:
 
[project.scripts]
tidy-reports = "tidy_reports:cli"

Before sharing, keep the tool boring in the best way: documented options, a dry-run default for risky work, clear stdout for normal results, stderr for problems, and tests around both the worker function and main(argv).

The next lesson keeps the same CLI discipline while the tool reaches outside your local sandbox. When automation sends email, calls APIs, or drives another system, safe arguments and dry-run thinking become the guardrails.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion