Python CLI Tools skill for Codex
Command-line tools that pipe, script and install cleanly. Codex keeps argument parsing in a thin CLI layer over importable logic, sends results to stdout and diagnostics to stderr, adds a --json mode and documented exit codes, and guards destructive commands with --yes and --dry-run. It measures --help startup with -X importtime, moves heavy imports into commands, tests with CliRunner, and wires a console-script entry point you can install with uv tool.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- python-cli-tools Installs to ~/.agents/skills/python-cli-tools/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install python-cli-tools The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project python-cli-tools Installs to .agents/skills/python-cli-tools/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/python-cli-tools.zipunzip -o python-cli-tools.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) python-cli-tools PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $python-cli-tools, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Python command-line tools with Typer, Click or argparse - subcommands, typed options, config and env var precedence, stdout versus stderr, exit codes, JSON output for scripts, fast startup, CliRunner tests, and console-script entry points installable with uv tool or pipx. Use when a user asks to turn a script into a CLI, add subcommands or flags, make a tool installable, fix slow --help, or test command output and exit codes.
Use it when
- A Python script needs flags, subcommands and help text
- Other scripts or CI parse the tool's output
- The CLI must install as a command on PATH
- Command output and exit codes need tests
Use something else when
- You are writing a one-off automation script rather than a reusable tool: use python-automation-scripts
- The job is lockfiles and publishing the package itself: use uv-python-packaging
How to check the result
Help renders for every command in under 300 ms, the --json output parses with json.tool, and tests assert the exit code of each success and failure path.
The mistake it prevents
Printing progress or warnings to stdout, which breaks every script that pipes the tool's output into jq or another program.
Done well means: --help explains every command, data goes to stdout and diagnostics to
stderr, exit codes are meaningful, there is a machine-readable output mode, startup is
fast, and the tool installs as a real command through a console-script entry point.
Before changing anything
- Read
AGENTS.mdandpyproject.toml. If the project already uses Click, Typer or argparse, stay with it. - Check
[project.scripts]for existing entry points and how the tool is invoked today (python script.py,python -m pkg, a shell alias). - Adding Typer or Click is a new dependency; installing it needs network (ask in a sandbox with network off).
Framework choice
| Situation | Use |
|---|---|
| Typed project, several subcommands, wants nice help | Typer (built on Click) |
| Needs Click plugins, custom parameter types, lazy-loaded command groups | Click directly |
| Single-file script, zero dependencies, ships in a stdlib-only environment | argparse |
| Interactive full-screen terminal UI | Textual, not a CLI framework |
Layout
src/acme_tool/
├── __init__.py
├── __main__.py # from acme_tool.cli import app; app()
├── cli.py # argument parsing only, thin
└── core.py # logic, importable and tested without the CLI
[project.scripts]
acme = "acme_tool.cli:app" # Typer apps are callable; for Click use the group object
Keep business logic out of cli.py. The CLI layer parses, calls core, formats output and
maps exceptions to exit codes.
Typer pattern
# cli.py
import json
import sys
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
@app.callback()
def main(
ctx: typer.Context,
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Log debug output to stderr")] = False,
) -> None:
"""Acme deployment helper."""
ctx.obj = {"verbose": verbose}
@app.command()
def status(
env: Annotated[str, typer.Argument(help="Environment name, e.g. staging")],
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output")] = False,
config: Annotated[Path, typer.Option(envvar="ACME_CONFIG", exists=True, dir_okay=False)] = Path("acme.toml"),
) -> None:
"""Show deployment status for ENV."""
from acme_tool.core import get_status, NotFound # lazy import keeps --help fast
try:
result = get_status(env, config)
except NotFound as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=2)
if as_json:
json.dump(result, sys.stdout)
sys.stdout.write("\n")
else:
typer.echo(f"{env}: {result['state']} ({result['version']})")
@app.command()
def deploy(
env: str,
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
) -> None:
"""Deploy the current build to ENV."""
if not yes and not typer.confirm(f"Deploy to {env}?"):
raise typer.Abort()
...
With a callback present, Typer always requires a subcommand. Without one, a single-command app runs that command directly.
Click equivalent (key parts)
import click
@click.group()
@click.option("-v", "--verbose", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
ctx.obj = {"verbose": verbose}
@cli.command()
@click.argument("env")
@click.option("--json", "as_json", is_flag=True)
def status(env: str, as_json: bool) -> None:
...
Output and exit code contract
| Rule | Why |
|---|---|
| Results to stdout, logs/progress/errors to stderr | acme status prod --json | jq must stay parseable |
--json emits one JSON document (or JSON Lines for streams) and nothing else on stdout | Scripts depend on it |
| Exit 0 success, 1 general failure, 2 usage error, other codes documented | Shells and CI branch on them |
Respect NO_COLOR and non-TTY stdout: no colors or spinners when piped | Logs stay clean |
Never prompt when stdin is not a TTY; fail with a hint to pass --yes | CI hangs otherwise |
Destructive commands require --yes or an interactive confirm, and support --dry-run | Safety |
Click and Typer already return 2 for usage errors. Unhandled exceptions should not print a
50-line traceback to users: catch your own error types at the CLI layer, print one line to
stderr, and exit non-zero. Keep a --debug flag that re-raises.
When Codex itself runs a tool’s destructive command (deploy, delete, publish) while testing
it, get explicit user confirmation first and prefer --dry-run.
Configuration precedence
Command-line flag > environment variable > project config file > user config file > default.
Typer and Click support envvar= per option. For config files, read TOML with tomllib
and pass values as default_map (Click) or explicit defaults; document the order in --help.
Store user config under typer.get_app_dir("acme") / click.get_app_dir("acme"), not in
the home directory root.
Startup time
--help should return in well under 300 ms. Measure:
python -X importtime -m acme_tool --help 2> imports.log
sort -t'|' -k2 -n imports.log | tail -15 # slowest cumulative imports
Move heavy imports (pandas, boto3, requests, rich tables) inside the command functions that
need them. For large command trees, Click supports lazily loaded subcommands via a custom
Group.get_command.
Testing
from typer.testing import CliRunner
from acme_tool.cli import app
runner = CliRunner()
def test_status_json(monkeypatch):
monkeypatch.setattr("acme_tool.core.get_status", lambda env, cfg: {"state": "ok", "version": "1.2"})
result = runner.invoke(app, ["status", "staging", "--json"])
assert result.exit_code == 0, result.output
assert '"state": "ok"' in result.stdout
def test_unknown_env_exits_2():
result = runner.invoke(app, ["status", "nope"])
assert result.exit_code == 2
- Assert exit codes on every test;
result.exceptionholds uncaught errors. - Test the installed entry point once in CI:
uv run acme --helporuv tool install . && acme --version.
Distribution
uv tool install . # or: pipx install . (installs into an isolated env on PATH)
uv tool install --editable . # during development
uvx --from . acme status dev # run without installing
Add --version (Typer: an eager callback option reading importlib.metadata.version("acme-tool")).
Shell completion comes free with Typer (acme --install-completion).
Gotchas
print()inside library code called by the CLI corrupts--jsonoutput. Route everything through the CLI layer or stderr logging.- Typer infers options from defaults: a parameter without a default becomes a required
argument. Be explicit with
typer.Argument/typer.Optionfor clarity. - Boolean flags in Typer get
--flag/--no-flagautomatically; name them positively. - Windows consoles and pipes may not be UTF-8; set
encodingexplicitly when writing files and avoid exotic characters in plain output. sys.exit()deep inside core logic makes it untestable; raise exceptions there.
Verification
acme --helpand everyacme <cmd> --helprender with descriptions; time under 300 ms.acme status x --json | python -m json.toolsucceeds; nothing but JSON on stdout.- Exit codes match the documented table in tests.
- Fresh install via
uv tool install .putsacmeon PATH andacme --versionmatchespyproject.toml.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Python CLI Tools"
short_description: "Command-line tools that pipe, script and install cleanly"
default_prompt: "$python-cli-tools Turn this deploy.py script into a proper CLI with subcommands" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.