clickwork -- LLM Reference¶
Reference for AI agents building CLI commands with clickwork. Read this before writing or migrating commands.
For deeper context: GUIDE.md (tutorial), ARCHITECTURE.md (design decisions).
What clickwork Is¶
A Python CLI framework built on Click. You write command files, drop them
in a commands/ directory, and the framework handles discovery, config,
subprocess management, global flags, and error handling.
Writing a Command¶
Every command file exports a Click command or group as cli:
# commands/deploy.py
import click
from clickwork import pass_cli_context, CliContext
@click.command()
@click.argument("target")
@pass_cli_context
def deploy(ctx: CliContext, target: str):
"""Deploy a component to the active environment."""
ctx.require("wrangler")
account_id = ctx.config.get("cloudflare.account_id")
ctx.run(["wrangler", "deploy", "--account-id", account_id])
cli = deploy
Subcommand Groups¶
Export a click.Group instead of a click.Command:
# commands/runner.py
import click
from clickwork import pass_cli_context
@click.group()
def runner():
"""Manage CI runners."""
pass
@runner.command()
@pass_cli_context
def setup(ctx):
"""Set up a new runner."""
ctx.require("docker")
ctx.run(["docker", "compose", "up", "-d"])
cli = runner
CliContext API¶
Commands receive CliContext via @pass_cli_context. Available attributes:
| Method/Attribute | Purpose |
|---|---|
ctx.run(cmd) |
Execute mutating command (respects --dry-run) |
ctx.capture(cmd) |
Execute and return stdout (always runs, even dry-run) |
ctx.run_with_confirm(cmd, msg) |
Confirm then execute |
ctx.require(binary) |
Assert binary on PATH |
ctx.require(binary, authenticated=True) |
Assert binary + auth |
ctx.confirm(msg) |
Yes/no prompt (respects --yes) |
ctx.confirm_destructive(msg) |
Requires typing "yes" |
ctx.config |
Merged config dict |
ctx.env |
Selected environment string |
ctx.dry_run |
True if --dry-run |
ctx.verbose |
Verbosity level (0/1/2) |
ctx.yes |
True if --yes |
ctx.logger |
Configured logger |
Rules¶
Never Do¶
- Never pass strings to run/capture. Always
list[str]:ctx.run(["echo", "hello"])notctx.run("echo hello"). - Never put secrets in argv. Use
ctx.run(cmd, env={"TOKEN": secret.get()}). - Never hardcode config values. Use
ctx.config.get("key"). - Never import from
clickwork._typesor other private modules in command code. Import fromclickworkdirectly.
Always Do¶
- Export
cliat module level. The framework discovers it by this name. - Call
ctx.require()at the top of commands that need external tools. - Use
ctx.run()for mutations,ctx.capture()for reads. This is how --dry-run works correctly. - Use
@pass_cli_contextnot@click.pass_obj(handles nested groups safely). - Add docstrings to commands. Click uses them for --help text.
Config¶
TOML files with layered resolution (highest priority wins):
- Environment variables (explicit mapping or auto-prefixed
PROJECT_NAME_KEY) [env.staging]section in repo config[default]section in repo config (.project-name.toml)- User config (
~/.config/project-name/config.toml)
Schema example:
CONFIG_SCHEMA = {
"cloudflare.account_id": {
"type": str,
"required": True,
"env": "CLOUDFLARE_ACCOUNT_ID",
},
"api_token": {
"secret": True,
"env": "MY_TOOL_API_TOKEN",
},
}
Keys with secret: True are auto-wrapped in Secret() -- use .get() to unwrap.
Entry Point¶
#!/usr/bin/env python3
from pathlib import Path
from clickwork import create_cli
commands_dir = Path(__file__).resolve().parent / "commands"
cli = create_cli(name="my-tool", commands_dir=commands_dir)
if __name__ == "__main__":
cli()
Testing Commands¶
Prefer clickwork.testing.run_cli / clickwork.testing.make_test_cli for new
test code -- they pin catch_exceptions=False and default name="test-cli"
so real tracebacks surface in pytest output. Note that result.output
contains stdout AND stderr interleaved; use result.stdout / result.stderr
when asserting on a specific stream. See
GUIDE.md "Testing commands with clickwork.testing".
from clickwork.testing import make_test_cli, run_cli
def test_deploy_dry_run(tmp_path):
(tmp_path / "deploy.py").write_text(...)
cli = make_test_cli(commands_dir=tmp_path)
result = run_cli(cli, ["--dry-run", "deploy"])
assert result.exit_code == 0
Common Patterns¶
Migrating a Bash Script¶
- Identify what the script does (prereqs, config values, subprocess calls)
- Create
commands/script_name.py - Move prereq checks to
ctx.require()calls at the top - Move config/env vars to the TOML config + schema
- Replace subprocess calls with
ctx.run()/ctx.capture() - Replace any confirmation prompts with
ctx.confirm()/ctx.confirm_destructive() - Export as
cli = command_function
Wrapping an Existing Tool¶
@click.command()
@click.argument("args", nargs=-1)
@pass_cli_context
def tool(ctx: CliContext, args: tuple):
"""Run some-tool with project config."""
ctx.require("some-tool")
base_cmd = ["some-tool", "--config", ctx.config.get("tool.config_path")]
ctx.run([*base_cmd, *args])
Common Footguns¶
Quick hits for "why is my thing broken?" Each entry is Pitfall / Instead / Why. Link out to helper docstrings or GUIDE.md for depth.
1. Patching ctx.require¶
Pitfall: patching a private clickwork.cli helper to fake out prereq checks.
Instead: patch("clickwork.prereqs.require").
Why: CliContext.require routes through clickwork.cli._require_via_prereqs, which dispatches to clickwork.prereqs.require at call time. Patch the public prereqs function so your mock intercepts the actual lookup, not a stale internal alias.
2. Signalling user errors¶
Pitfall: sys.exit(1) with manual click.echo.
Instead: raise click.ClickException("message").
Why: Wave 1 #5 made ClickException route correctly; ad-hoc exits bypass that.
3. CliRunner mixed output¶
Pitfall: asserting on result.output when you specifically want stdout-only or stderr-only content (result.output interleaves BOTH streams).
Instead: assert on result.stdout or result.stderr directly.
Why: clickwork declares click>=8.2, where result.output is stdout+stderr interleaved while result.stdout and result.stderr are populated independently. The older CliRunner(mix_stderr=False) kwarg referenced in some online snippets was removed in 8.2 -- don't copy those. See GUIDE.md "Testing commands with clickwork.testing".
4. URL-encoding query params¶
Pitfall: string-concatenating user values into URL query strings.
Instead: build params as a dict and use urllib.parse.urlencode.
Why: spaces, &, # in user values silently break URLs or enable injection.
5. Secrets in argv¶
Pitfall: ctx.run(["wrangler", "secret", "put", name, token.get()]).
Instead: ctx.run_with_secrets(...) (Wave 3 #11).
Why: argv is world-readable in ps; the helper enforces this and routes via env/stdin.
6. Shell-sourceable config files¶
Pitfall: hand-rolling a .env parser.
Instead: clickwork.config.load_env_file(path) (Wave 2 #9).
Why: parser gotchas are solved once; the helper also enforces owner-only permissions.
7. Platform dispatch¶
Pitfall: repeating if sys.platform == "linux": ... elif sys.platform == "win32": ....
Instead: @clickwork.platform_dispatch(linux=..., windows=..., macos=...) (Wave 2 #12).
Why: the helper handles "unsupported platform" errors consistently.
8. HTTP calls¶
Pitfall: building a urllib.request helper in each command, or adding requests.
Instead: clickwork.http.get/post/put/delete (Wave 3 #13).
Why: stdlib-only helper with URL allowlist, JSON auto-parse, structured HttpError. See clickwork.http docstring.
9. Missing import sys¶
Pitfall: calling sys.exit() or sys.stdin without importing.
Instead: explicit import sys.
Why: easy to forget; sys is not a builtin.
10. bash -c¶
Pitfall: ctx.run(["bash", "-c", "command $VAR"]).
Instead: use Python stdlib directly, or ctx.run(["command"], env={...}).
Why: bash -c opens a shell-injection vector if any part of the command string is user-influenced, and creates a cross-platform dependency on bash.
11. Secret.get() at module scope¶
Pitfall: TOKEN = Secret(...).get() at module import.
Instead: call .get() at the call site when you actually need the value.
Why: module-scope unwrap defeats the "value stays wrapped until used" invariant.
Lessons Learned¶
This section is updated as we migrate commands and discover patterns.