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.

  • Languages
  • $python-cli-tools
  • 213 lines
  • 2 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- python-cli-tools

Installs to ~/.agents/skills/python-cli-tools/ for every project on this machine.

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

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.

~/.agents/skills/python-cli-tools/SKILL.md

--- name: python-cli-tools description: Builds Python command-line tools with Typer, Click or argparse - subcommands, typed options, config and env var precedence, stdout versus stderr,… license: MIT ---

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.md and pyproject.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

SituationUse
Typed project, several subcommands, wants nice helpTyper (built on Click)
Needs Click plugins, custom parameter types, lazy-loaded command groupsClick directly
Single-file script, zero dependencies, ships in a stdlib-only environmentargparse
Interactive full-screen terminal UITextual, 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

RuleWhy
Results to stdout, logs/progress/errors to stderracme status prod --json | jq must stay parseable
--json emits one JSON document (or JSON Lines for streams) and nothing else on stdoutScripts depend on it
Exit 0 success, 1 general failure, 2 usage error, other codes documentedShells and CI branch on them
Respect NO_COLOR and non-TTY stdout: no colors or spinners when pipedLogs stay clean
Never prompt when stdin is not a TTY; fail with a hint to pass --yesCI hangs otherwise
Destructive commands require --yes or an interactive confirm, and support --dry-runSafety

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.exception holds uncaught errors.
  • Test the installed entry point once in CI: uv run acme --help or uv 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 --json output. 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.Option for clarity.
  • Boolean flags in Typer get --flag/--no-flag automatically; name them positively.
  • Windows consoles and pipes may not be UTF-8; set encoding explicitly when writing files and avoid exotic characters in plain output.
  • sys.exit() deep inside core logic makes it untestable; raise exceptions there.

Verification

  • acme --help and every acme <cmd> --help render with descriptions; time under 300 ms.
  • acme status x --json | python -m json.tool succeeds; nothing but JSON on stdout.
  • Exit codes match the documented table in tests.
  • Fresh install via uv tool install . puts acme on PATH and acme --version matches pyproject.toml.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

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.

esc