Packaging an AI Script as a CLI
11 min read · updated August 4, 2026
The distance between a script that works on your machine and a command somebody else can install is smaller than it looks: a config precedence rule, a dry run, an entry point, and correct exit codes.
What makes a script a tool
Four properties, and the second is the one that separates a tool that gets used from one that gets forked.
- It is installable and on the PATH.
summarise notes.txt, notpython /home/me/scripts/summarise.py notes.txt. - Configuration has a documented precedence. The user can override anything at the point of use without editing a file, and can also set a durable default. Both, with a rule that is written down.
- It can tell you what it would do. A dry run is not a nicety for a program that spends money per invocation.
- It composes. Reads stdin, writes stdout, diagnostics to stderr, non-zero exit on failure.
Config resolution, in one function
The order is the standard one and it is worth stating explicitly in --help, because a user who cannot predict which value wins will stop trusting the tool.
command-line flag highest priority — this invocation only environment variable the shell session, or a .env project config file ./aitool.toml — checked into the repo user config file ~/.config/aitool.toml — personal defaults built-in default lowest priority
# config.py
import os
import tomllib # Python 3.11+; use tomli on 3.10
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULTS: dict[str, Any] = {
"model": "openai/gpt-4o-mini",
"max_tokens": 800,
"temperature": 0.2,
"concurrency": 4,
}
ENV_PREFIX = "AITOOL_"
def _read_toml(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
with path.open("rb") as fh:
return tomllib.load(fh)
def resolve(cli: dict[str, Any]) -> dict[str, Any]:
"""Merge every source in precedence order. Later updates win."""
settings: dict[str, Any] = dict(DEFAULTS)
settings.update(_read_toml(Path.home() / ".config" / "aitool.toml"))
settings.update(_read_toml(Path.cwd() / "aitool.toml"))
for key in list(settings) + ["api_key", "base_url"]:
env_value = os.environ.get(ENV_PREFIX + key.upper())
if env_value is not None:
settings[key] = _coerce(env_value, DEFAULTS.get(key))
settings.update({k: v for k, v in cli.items() if v is not None})
return settings
def _coerce(value: str, like: Any) -> Any:
"""Environment variables are strings; make them match the default's type."""
if isinstance(like, bool):
return value.strip().lower() in {"1", "true", "yes", "on"}
if isinstance(like, int):
return int(value)
if isinstance(like, float):
return float(value)
return value{k: v for k, v in cli.items() if v is not None} is the line that makes the whole thing work. argparse fills unsupplied options with None when no default is set, so filtering them out means “flag not given” falls through to the next source instead of overwriting it with None. Setting defaults in the parser instead of here is the classic bug: every flag then appears to have been supplied, and the config file is silently ignored.
The _coerce function exists because environment variables are always strings. Without it, AITOOL_CONCURRENCY=8 gives you the string "8", and range(concurrency) raises somewhere far from the cause.
The parser
argparse is in the standard library, which for a tool other people install is worth a great deal — one fewer dependency to resolve and to keep current. Typer and Click are more pleasant for a large command tree; nothing on this page needs them.
# cli.py
import argparse
import sys
from pathlib import Path
from . import __version__
from .config import resolve
EPILOG = """\
configuration precedence (highest first):
--flag, AITOOL_* environment variable, ./aitool.toml,
~/.config/aitool.toml, built-in default
exit codes:
0 ok 1 runtime failure 2 bad usage 3 budget exceeded
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="aitool",
description="Summarise text files with a language model.",
epilog=EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("paths", nargs="*", type=Path,
help="files to process; omit to read stdin")
parser.add_argument("--model", help="model id (default: from config)")
parser.add_argument("--max-tokens", type=int, dest="max_tokens")
parser.add_argument("--temperature", type=float)
parser.add_argument("--concurrency", type=int)
parser.add_argument("--budget", type=float, default=None,
help="abort if the estimated cost exceeds this")
parser.add_argument("--dry-run", action="store_true",
help="print what would be sent and the estimated cost")
parser.add_argument("--json", action="store_true", dest="as_json",
help="emit one JSON object per input on stdout")
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument("--version", action="version", version=f"aitool {__version__}")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
settings = resolve({
"model": args.model,
"max_tokens": args.max_tokens,
"temperature": args.temperature,
"concurrency": args.concurrency,
})
documents = read_inputs(args.paths)
if not documents:
parser.error("no input: give one or more paths, or pipe text on stdin")
plan = plan_work(documents, settings)
if args.dry_run:
print_plan(plan, settings, file=sys.stderr)
return 0
if args.budget is not None and plan.estimated_cost > args.budget:
print(f"estimated {plan.estimated_cost:.2f} exceeds budget {args.budget:.2f}",
file=sys.stderr)
return 3
return run(plan, settings, as_json=args.as_json)
if __name__ == "__main__":
raise SystemExit(main())main takes argv and returns an int rather than calling sys.exit. That one change makes the whole command testable: a test calls main(["--dry-run", "sample.txt"]) and asserts on the return code and captured output, with no subprocess.
A dry run that prints the cost
For a tool that spends money per invocation, --dry-run is the feature that makes it safe to try on a directory you have not counted.
# plan.py
from dataclasses import dataclass, field
CHARS_PER_TOKEN = 4 # rough for English prose; see the note below
@dataclass
class Plan:
items: list[tuple[str, int]] = field(default_factory=list) # (name, input tokens)
max_tokens: int = 0
price_in_per_mtok: float = 0.0
price_out_per_mtok: float = 0.0
@property
def input_tokens(self) -> int:
return sum(tokens for _, tokens in self.items)
@property
def estimated_cost(self) -> float:
output = self.max_tokens * len(self.items) # the worst case, on purpose
return (self.input_tokens / 1e6) * self.price_in_per_mtok + \
(output / 1e6) * self.price_out_per_mtok
def print_plan(plan: Plan, settings: dict, *, file) -> None:
print(f"model {settings['model']}", file=file)
print(f"documents {len(plan.items)}", file=file)
print(f"input tokens {plan.input_tokens:,} (estimated)", file=file)
print(f"max output {plan.max_tokens * len(plan.items):,} tokens", file=file)
print(f"cost ceiling {plan.estimated_cost:.4f}", file=file)
print("", file=file)
for name, tokens in plan.items[:10]:
print(f" {name:<40} {tokens:>8,} tokens", file=file)
if len(plan.items) > 10:
print(f" ... and {len(plan.items) - 10} more", file=file)Two deliberate choices. The estimate uses max_tokens for output rather than a guess, so the printed figure is a ceiling and not a hope — an estimate a user finds optimistic is worse than no estimate. And the plan goes to stderr, so --dry-run in a pipeline does not pollute the data stream.
Failing in a way a user can act on
A traceback is a correct description of a failure and a useless one for somebody who did not write the program. Every error a user can cause should print one line saying what went wrong and what to do, and only the errors nobody anticipated should print a stack.
# cli.py, continued
import logging
import sys
import httpx
class UserError(Exception):
"""A problem the user can fix. Printed as one line, no traceback."""
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
logging.basicConfig(
level={0: logging.WARNING, 1: logging.INFO}.get(args.verbose, logging.DEBUG),
format="%(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
try:
return _run(args) # the body from the previous listing, moved out
except UserError as exc:
print(f"aitool: {exc}", file=sys.stderr)
return 1
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
hint = {
401: "check AITOOL_API_KEY",
402: "the account is out of credit",
404: f"no such model: {exc.request.url}",
429: "rate limited; try --concurrency 1",
}.get(status, "")
print(f"aitool: provider returned {status}. {hint}".rstrip(), file=sys.stderr)
if args.verbose:
print(exc.response.text[:1000], file=sys.stderr)
return 1
except KeyboardInterrupt:
print("aitool: interrupted", file=sys.stderr)
return 130
except Exception:
logging.exception("unexpected failure") # traceback only for real bugs
return 1The status-code hints are worth the twelve lines. A 401 and a 402 are both “the provider said no” and have completely different remedies, and the user has no way to know which is which from the number. Mapping the four codes that are actually a user’s problem removes most of the support burden a small tool generates.
Three smaller rules complete it. Send diagnostics through logging at a level -v controls, not through bare print, so a user can turn detail up without you shipping a new version. Print progress only when sys.stderr.isatty(), or a scheduled run fills its log with spinner frames. And on a partial failure — nine files processed, one failed — write the nine, report the one, and return non-zero: silently succeeding with incomplete output is the worst of the available behaviours, because nothing downstream can detect it.
Making it installable
# pyproject.toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "aitool" version = "0.1.0" description = "Summarise text files with a language model." requires-python = ">=3.11" dependencies = ["httpx>=0.27", "python-dotenv>=1.0"] [project.scripts] aitool = "aitool.cli:main"
[project.scripts] is the whole mechanism. On install, the build backend generates an executable named aitool that calls main() in aitool/cli.py and exits with its return value — which is why main returns an int.
pip install -e . # editable install, for development
pipx install . # or: uv tool install .
# installs into its own environment and puts
# the command on PATH — the right way to ship a
# tool, because it cannot break the user's
# project dependenciespipx or uv tool rather than a plain pip install into the user’s active environment. A CLI that pins httpx>=0.27 and lands in somebody’s application environment is a dependency conflict waiting to be blamed on you.
Behaving like a Unix program
| Convention | Description |
|---|---|
| stdout is data | Only the result. Progress, warnings and the dry-run plan go to stderr, so the tool can be piped without a flag to silence it. |
| Read stdin when no path is given | cat notes.md | aitool should work. if not sys.stdin.isatty(): text = sys.stdin.read() is the check, so that an interactive user with no arguments gets the usage message instead of a hang. |
| Meaningful exit codes | 0 success, 1 runtime failure, 2 bad usage (argparse already uses 2), and a distinct code for a budget stop so a wrapper script can tell it apart from a crash. |
| Handle SIGINT quietly | Catch KeyboardInterrupt at the top of main, print one line, return 130. A traceback on Ctrl-C looks like a bug. |
| Respect NO_COLOR and non-TTY output | If you emit colour, disable it when NO_COLOR is set or when sys.stdout.isatty() is false, or your output arrives in files full of escape sequences. |
| --version, and mean it | Users report bugs against a version. Read it from the installed package metadata so it cannot disagree with what pip reports. |
One AI-specific addition: make the model, and ideally the prompt version, visible in --verbose output and in the JSON mode. A user reporting that the tool got worse is usually reporting that the default model changed, and that is a two-second diagnosis when the output says which one ran — logging every model call is the same argument at service scale.