Guide¶
A step-by-step guide to building project automation CLIs with clickwork. Each section builds on the last, starting from a single command and progressing to a full-featured CLI with config, environments, and distributed plugins.
For the design decisions behind the framework, see ARCHITECTURE.md.
Who This Is For¶
You have a project with automation tasks -- deploying, packaging, setting up CI runners, generating release manifests, running benchmarks. Maybe these live in scattered bash scripts, maybe they are ad-hoc commands you run from memory. You want to unify them into a single CLI with consistent flags, configuration, and error handling.
clickwork gives you the scaffolding. You write the commands.
Prerequisites¶
- Python 3.11 or later
- uv (recommended) or pip
Installation¶
# From PyPI (preferred)
uv pip install "clickwork>=1.0,<2"
# or, pinning to a git tag if you need a ref PyPI doesn't expose
uv pip install "git+https://github.com/qubitrenegade/clickwork.git@v1.0.0"
For local development alongside your project:
git clone https://github.com/qubitrenegade/clickwork.git
cd your-project
uv pip install -e ../clickwork
Your First CLI¶
Step 1: Create the Entry Point¶
Create a Python script that will be your CLI. This is the only boilerplate -- everything else is commands.
#!/usr/bin/env python3
"""my-tool: Project automation CLI."""
from pathlib import Path
from clickwork import create_cli
# Resolve commands_dir relative to this script so it works
# regardless of the current working directory.
commands_dir = Path(__file__).resolve().parent / "commands"
cli = create_cli(name="my-tool", commands_dir=commands_dir)
if __name__ == "__main__":
cli()
Step 2: Write a Command¶
Create a commands/ directory next to your entry point. Drop a .py
file in it. The only requirement: export a Click command or group as
cli.
# commands/greet.py
import click
@click.command()
@click.argument("name", default="world")
def greet(name: str):
"""Say hello to someone."""
click.echo(f"Hello, {name}!")
# The framework discovers commands via this export.
cli = greet
Step 3: Run It¶
python my-tool.py greet
# Hello, world!
python my-tool.py greet Alice
# Hello, Alice!
python my-tool.py --help
# Shows all discovered commands
python my-tool.py greet --help
# Shows greet's help text
You get --verbose, --quiet, --dry-run, --env, and --yes for
free. Every command inherits these global flags. Pass version= or
package_name= to create_cli() to also install --version / -V
(see "Version flag" below).
Using the Context¶
Most commands need access to config, flags, or subprocess helpers. The
CliContext dataclass carries all of this. Use @pass_cli_context to
receive it:
# 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."""
ctx.require("wrangler")
account_id = ctx.config.get("cloudflare.account_id")
ctx.run(["wrangler", "deploy", "--account-id", account_id])
cli = deploy
The context gives you:
| Attribute/Method | What It Does |
|---|---|
ctx.config |
Merged config dict from all sources |
ctx.env |
Selected environment string (e.g., "staging") |
ctx.dry_run |
True if --dry-run was passed |
ctx.verbose |
Verbosity level (0, 1, or 2) |
ctx.yes |
True if --yes was passed |
ctx.logger |
Configured logger instance |
ctx.run(cmd) |
Execute a mutating command |
ctx.capture(cmd) |
Execute and return stdout |
ctx.require(binary) |
Check a binary is on PATH |
ctx.confirm(msg) |
Ask yes/no, respects --yes |
ctx.confirm_destructive(msg) |
Requires typing "yes" |
ctx.run_with_confirm(cmd, msg) |
Confirm then execute |
Subcommand Groups¶
When a command file exports a click.Group instead of a click.Command,
it becomes a subcommand group:
# 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"])
@runner.command()
@pass_cli_context
def teardown(ctx):
"""Remove a runner."""
ctx.run_with_confirm(
["docker", "compose", "down", "-v"],
"This will delete all runner data. Continue?"
)
cli = runner
Usage:
Configuration¶
Config Precedence¶
clickwork merges config from six ordered sources. The table below is
the authoritative precedence contract -- it is part of the public 1.0
surface (see API_POLICY.md),
so changing the order is a breaking change requiring a major version
bump. Highest priority wins; when the same key is set in multiple
sources, the higher row's value is what ctx.config[key] returns.
| # | Source | How it's set | Notes / gotchas |
|---|---|---|---|
| 1 (highest) | Explicit env-var mapping | CLOUDFLARE_ACCOUNT_ID=abc with {"env": "CLOUDFLARE_ACCOUNT_ID"} in schema |
Only applies to keys whose schema entry declares an env: name. Beats the auto-prefix form for the same key. |
| 2 | Auto-prefixed env var | MY_TOOL_R2_BUCKET=bucket-x |
Prefix is the project name uppercased with hyphens replaced by _; suffix is the dotted config key with . and - replaced by _, uppercased. my-tool + r2.bucket -> MY_TOOL_R2_BUCKET. |
| 3 | [env.<selected>] section |
[env.staging] in .my-tool.toml, selected via --env staging or MY_TOOL_ENV=staging |
Only the section matching the selected env applies; other [env.*] sections are ignored. Selecting an undefined env raises ConfigError when the repo config file exists but has no matching [env.<name>] section. If there is no repo config file at all, the env selection is silently a no-op. |
| 4 | [default] section |
[default] in .my-tool.toml |
Shared defaults for all envs. Keys absent from the selected [env.<x>] fall through to here. |
| 5 | User config | ~/.config/my-tool/config.toml |
Must be chmod 600 (or stricter) on POSIX -- any group/other bit raises ConfigError. On Windows the permission check is skipped. |
| 6 (lowest) | Schema default | {"port": {"default": 8080}} in the schema passed to create_cli() |
Applied only if the key is declared in the schema AND still absent after all higher layers merge. No schema => no defaults. |
The rationale for this order:
- Env vars win because they are the canonical escape hatch for one-off overrides and per-process CI injection. An operator should be able to override any checked-in value without editing a file.
- Repo config beats user config because clickwork targets project
automation: the
.my-tool.tomlcommitted to the repo defines the project's canonical behaviour, so teammates and CI get the same result. If you want a personal override, use an env var (layer 1 or 2), not your user config. - Schema defaults sit at the bottom so they only fire when no real source provided a value. This matches the "documented fallback" role defaults play -- a schema default should never shadow a value the operator explicitly set anywhere else.
Worked example¶
Given the schema, repo file, user file, and env below:
# in create_cli(config_schema=...)
CONFIG_SCHEMA = {
"r2.bucket": {"type": str, "default": "releases-fallback"},
"region": {"type": str, "default": "us-east-1"},
}
# .my-tool.toml (checked in)
[default]
r2.bucket = "releases-staging"
region = "us-east-1"
[env.production]
r2.bucket = "releases-prod"
Resolution:
| Key | Value | Chosen by |
|---|---|---|
r2.bucket |
"releases-prod" |
Layer 3 ([env.production]) beats user config (layer 5) and [default] (layer 4). |
region |
"ap-south-1" |
Layer 2 (auto-prefixed MY_TOOL_REGION) beats every TOML layer and the schema default. |
Drop the env var (unset MY_TOOL_REGION) and region falls to
"us-east-1" from [default] (layer 4), NOT "eu-west-1" from the
user file (layer 5) -- because repo config overrides user config. Drop
[env.production] and r2.bucket falls to "releases-staging" from
[default]. Drop every source for a key entirely and it resolves to
its schema default ("releases-fallback") or raises ConfigError if
the schema marks it required: True.
Repo Config¶
Create a .my-tool.toml file in your project root. The [default]
section provides baseline values:
Commands access these via ctx.config:
bucket = ctx.config.get("r2.bucket") # "releases-staging"
region = ctx.config.get("region") # "us-east-1"
missing = ctx.config.get("nonexistent") # None
Environment-Specific Config¶
Add [env.*] sections to override values per environment. Keys not
present in the env section fall through to [default]:
[default]
r2.bucket = "releases-staging"
region = "us-east-1"
[env.staging]
cloudflare.account_id = "staging-abc"
[env.production]
cloudflare.account_id = "prod-xyz"
r2.bucket = "releases-prod"
Select the environment with --env:
my-tool --env staging deploy site
# r2.bucket = "releases-staging" (from default, not overridden)
# cloudflare.account_id = "staging-abc" (from env.staging)
my-tool --env production deploy site
# r2.bucket = "releases-prod" (overridden in env.production)
# cloudflare.account_id = "prod-xyz" (from env.production)
CI pipelines can set MY_TOOL_ENV=staging instead of passing --env
on every command.
User Config¶
Personal settings (credentials, local overrides) go in
~/.config/my-tool/config.toml. This file sits below repo config
in the precedence order -- repo config overrides it. (Schema-declared
defaults are the only thing lower; see
Config Precedence for the full table.) To
override a repo value locally, use an environment variable instead.
User config may contain secrets, so the framework enforces owner-only
permissions on Unix (chmod 600). Files that are group- or
world-readable are refused.
Environment Variables¶
Environment variables have the highest priority. Two mechanisms:
Auto-prefix: Every config key is automatically checked against
{PROJECT_NAME}_{KEY}. Dots become underscores, everything uppercased:
Explicit mapping: For third-party env var names, declare the mapping in a config schema:
CONFIG_SCHEMA = {
"cloudflare.account_id": {
"env": "CLOUDFLARE_ACCOUNT_ID",
},
}
cli = create_cli(name="my-tool", commands_dir=..., config_schema=CONFIG_SCHEMA)
When both an explicit mapping and an auto-prefixed var could provide the same key, the explicit mapping wins.
Env-var values always arrive as strings from the OS. If your schema
declares type: int, type: float, or type: bool for a key that
might be set via env var, the loader coerces the string into the
declared type automatically. See
Environment Variable Types below for
the coercion rules -- bool parsing in particular has a fixed
allowlist that avoids the classic bool("false") == True
footgun.
Config Schema¶
Schemas are optional but recommended for production CLIs. They provide validation at startup so commands do not fail halfway through a deploy because of a missing key:
CONFIG_SCHEMA = {
"cloudflare.account_id": {
"type": str,
"required": True,
"env": "CLOUDFLARE_ACCOUNT_ID",
"description": "Cloudflare account ID for deployments",
},
"r2.bucket": {
"type": str,
"default": "releases-staging",
},
"api_token": {
"secret": True,
"env": "MY_TOOL_API_TOKEN",
},
}
Schema features:
required: True-- RaisesConfigErrorif the key is missing after all layers merge.type: str(orint,bool,float) -- Validates the resolved value matches the expected type. For any string-sourced value (env var or TOML string literal), also performs coercion to the declared type -- see Environment Variable Types below for the exact rules and the bool allowlist.default: "value"-- Fills missing keys after all layers merge but before validation.env: "VAR_NAME"-- Explicit env var mapping (overrides auto-prefix).secret: True-- Refuses the key if found in repo config (which is checked into git). The resolved value is automatically wrapped in aSecret()instance that redacts itself in logs and string formatting.description: "..."-- Documentation only, ignored by the framework.
Environment Variable Types¶
Environment variables at the OS level are always strings.
os.environ is dict[str, str], and the kernel-level environ
array is a list of NAME=value byte strings -- there is no such
thing as an "integer environment variable." That means when a
plugin author declares a schema key like {"port": {"type": int}}
and the value arrives via MY_TOOL_PORT=8080, something has to
convert the string "8080" into the integer 8080 before the
command code uses it.
clickwork pins that conversion at the schema layer. When the
loader finishes merging all layers and the schema declares a non-
str type, the loader coerces any string value in the merged
config dict to the declared type before returning it in
ctx.config. The rule is uniform across sources: the coercion
applies to env vars, TOML string literals (port = "8080"), and
TOML string literals alike -- whichever source produced the
string, the same coercion fires. The caller never has to write
int(os.environ["PORT"]) by hand, and a TOML author who quoted the
value by mistake still gets a usable int.
Pinning coercion at the schema layer (rather than the caller or the env-var reader) means:
- Env vars and TOML values behave the same at the call site.
ctx.config["port"]is an int whether it came fromport = 8080in TOML orMY_TOOL_PORT=8080in the shell. - String literals in TOML coerce too. A
.test-cli.tomlthat containsport = "8080"undertype: intproduces the int8080inctx.config["port"], matching what an env var would have delivered. - Conversion errors surface at CLI startup (during
load_config) rather than halfway through a deploy when a command does arithmetic on a string. You get aConfigErrornaming the key and the offending value (redacted to<redacted>if the schema marks the key assecret: True). - The coercion table is small, stdlib-only, and deliberately
explicit about bools so Python's classic
bool("false") == Truefootgun never bites you.
The supported type values and their string-source coercion rules:
Schema type |
String input | Result | Failure mode |
|---|---|---|---|
str |
"hello" |
"hello" (unchanged) |
Never fails -- strings are strings. |
int |
"8080" |
8080 (base 10) |
ConfigError on non-integer text ("3.14", "abc"). |
float |
"3.14" |
3.14 |
ConfigError on non-numeric text. |
bool |
"true", "1", "yes", "on" |
True |
ConfigError on anything outside the allowlist. |
bool |
"false", "0", "no", "off" |
False |
See above. |
Boolean parsing is case-insensitive ("TRUE", "True", and
"true" all produce True) but the allowlist is fixed. Tokens like
"maybe", "enabled", or "y" raise ConfigError rather than
silently defaulting either way. If you need looser parsing, do it
in your command code before feeding the value to clickwork.
Values that already carry the declared type pass through unchanged.
TOML's native typing means port = 8080 parses as int and skips
coercion entirely -- the schema type check still runs, but
there's nothing to convert. Only string values in the merged
config dict take the coercion path.
Worked TOML-string example: given the schema
{"port": {"type": int}} and a repo config containing
port = "8080" (quoted string literal), the loader coerces the
string and ctx.config["port"] is the int 8080 -- identical to
what port = 8080 (unquoted int) would have produced.
Without a schema, string values stay as strings in ctx.config.
The schema's type declaration is the explicit opt-in for
coercion; there is no heuristic "looks like a number, must be a
number" detection.
Example combining all of the above:
CONFIG_SCHEMA = {
"port": {
"type": int,
"default": 8080,
"description": "HTTP listener port; honours MY_TOOL_PORT.",
},
"debug": {
"type": bool,
"default": False,
},
"api_token": {
"secret": True,
"env": "MY_TOOL_API_TOKEN",
},
}
With MY_TOOL_PORT=9090 MY_TOOL_DEBUG=true my-tool deploy, the
command sees ctx.config["port"] == 9090 (int) and
ctx.config["debug"] is True.
Subprocess Helpers¶
run() -- Execute Mutating Commands¶
Streams output in real-time. Raises CliProcessError on non-zero exit.
Respects --dry-run:
# Normal execution
ctx.run(["wrangler", "deploy"])
# In --dry-run mode, prints the command without executing
# [dry-run] Would execute: wrangler deploy
capture() -- Execute Read-Only Commands¶
Returns stripped stdout. Always executes, even in --dry-run mode,
because commands need the data to proceed:
run_with_confirm() -- Destructive Commands¶
Prompts for confirmation before executing. Respects --yes (skips the
prompt) and --dry-run (skips execution):
Passing Secrets to Subprocesses¶
Never put secrets in the command's argv -- they are visible in ps
output. Pass them as environment variables instead:
token = ctx.config["api_token"] # This is a Secret instance
# WRONG: visible in ps output
ctx.run(["curl", "-H", f"Authorization: Bearer {token.get()}", url])
# RIGHT: only readable by the process owner
ctx.run(["curl", "-H", "Authorization: Bearer $TOKEN", url],
env={"TOKEN": token.get()})
Prerequisite Checking¶
Check that required tools exist before doing any work:
ctx.require("docker") # Is it on PATH?
ctx.require("gh", authenticated=True) # On PATH AND authenticated?
If the check fails, PrerequisiteError is raised with a clear message
and the CLI exits with code 1. This catches missing tools at the top of
a command, not halfway through a deploy.
Built-in auth checks exist for gh, gcloud, and aws. Add your own:
Confirmation Prompts¶
Two levels of confirmation:
# Standard: "Continue? [y/N]" -- accepts y or yes
if ctx.confirm("Deploy to staging?"):
ctx.run(["deploy", "--env", "staging"])
# Destructive: requires typing the full word "yes"
if ctx.confirm_destructive("Drop the production database?"):
ctx.run(["dropdb", "production"])
Both respect --yes (auto-confirm for CI) and auto-deny when stdin is
not a TTY (prevents hangs in piped/automated contexts).
Distributing as a Package¶
Once your CLI is stable, you can distribute it as an installable package.
Commands are registered via Python entry points so they are discoverable
without a commands/ directory on disk.
Package Structure¶
my-tool/
pyproject.toml
src/
my_tool/
__init__.py
commands/
deploy.py # exports cli = click.command()(deploy)
runner.py # exports cli = click.group()(runner)
pyproject.toml¶
[project]
name = "my-tool"
dependencies = ["clickwork"]
[project.scripts]
my-tool = "my_tool:cli"
[project.entry-points."clickwork.commands"]
deploy = "my_tool.commands.deploy:cli"
runner = "my_tool.commands.runner:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Entry Point in init.py¶
# src/my_tool/__init__.py
from clickwork import create_cli
cli = create_cli(name="my-tool", discovery_mode="installed")
After installation (pip install my-tool), users run my-tool deploy
directly. The framework discovers commands from entry points -- no
commands/ directory needed.
Testing Your Commands¶
Testing commands with clickwork.testing¶
The clickwork.testing module ships two thin helpers that collapse the
boilerplate of constructing a test CLI and invoking it through Click's
CliRunner:
from clickwork.testing import make_test_cli, run_cli
def test_greet_says_hello(tmp_path):
(tmp_path / "greet.py").write_text(
"import click\n"
"@click.command()\n"
"def greet():\n"
" click.echo('hello')\n"
"cli = greet\n"
)
cli = make_test_cli(commands_dir=tmp_path)
result = run_cli(cli, ["greet"])
assert result.exit_code == 0
assert "hello" in result.stdout
What the helpers do:
make_test_cli(*, commands_dir=None, **kwargs)wrapscreate_cli()with a defaultname="test-cli". Every other kwarg forwards unchanged, so you still getdescription=,config_schema=, etc. when you need them.run_cli(cli, args, **kwargs)wrapsCliRunner().invoke()withcatch_exceptions=Falsepinned by default. This means a bug in your command surfaces as a real traceback in pytest output instead of being quietly captured intoresult.exception. Passcatch_exceptions=Trueexplicitly when you want Click's default swallow-and-report behaviour.
run_cli returns Click's native click.testing.Result -- the helpers
deliberately do not invent a new result type, so any idiom you already
know from Click docs keeps working.
result.output vs result.stdout vs result.stderr¶
Click's Result exposes three stream attributes and they are not
interchangeable:
| Attribute | Contents |
|---|---|
result.output |
stdout and stderr interleaved in the order the command produced them |
result.stdout |
stdout only |
result.stderr |
stderr only |
The rule of thumb: if a test says "the error message was printed to
stderr", it should assert on result.stderr -- asserting on
result.output would pass even if the command wrote the error to
stdout by mistake, because output contains both streams.
@click.command()
def noisy():
click.echo("normal line")
click.echo("error line", err=True)
result = run_cli(noisy, [])
assert "normal line" in result.stdout # yes
assert "error line" in result.stderr # yes
assert "error line" in result.output # ALSO yes (interleaved)
assert "normal line" in result.stderr # NO -- would fail
Footgun: Click 8.2 removed the
mix_stderrkwarg onCliRunner.__init__that used to toggle whether stderr was folded intooutput. Post-removal,result.stdout/result.stderrare populated independently andresult.outputkeeps providing the interleaved form. clickwork declaresclick>=8.2so this guidance always applies: snippets in older tutorials that useCliRunner(mix_stderr=False)will raiseTypeError, and theresult.stderradvice above cannot fall back to Click 8.1 where, under the defaultCliRunner()configuration (streams mixed unlessmix_stderr=Falsewas passed), it would have raisedValueError: stderr not separately captured.
Unit Testing with CliRunner¶
If you need finer control than run_cli gives -- custom CliRunner
configuration, isolated filesystems via runner.isolated_filesystem(),
and so on -- reach for Click's CliRunner directly:
from click.testing import CliRunner
from clickwork import create_cli
def test_deploy_dry_run(tmp_path):
cmd_dir = tmp_path / "commands"
cmd_dir.mkdir()
(cmd_dir / "deploy.py").write_text(
"import click\n"
"@click.command()\n"
"@click.pass_obj\n"
"def deploy(ctx):\n"
" click.echo(f'dry_run={ctx.dry_run}')\n"
"cli = deploy\n"
)
cli = create_cli(name="test-cli", commands_dir=cmd_dir)
# Pass ``catch_exceptions=False`` here for the same reason
# ``run_cli`` pins it above: without it, a bug inside the command
# surfaces only as ``result.exception`` with a generic exit code,
# and the real traceback is swallowed.
result = CliRunner().invoke(cli, ["--dry-run", "deploy"], catch_exceptions=False)
assert result.exit_code == 0
assert "dry_run=True" in result.output
Testing with a Mock Context¶
For testing command logic without the CLI harness, construct a
CliContext directly:
from clickwork import CliContext
def test_deploy_logic():
commands_run = []
ctx = CliContext(
config={"cloudflare.account_id": "test-123"},
dry_run=False,
)
ctx.run = lambda cmd, env=None: commands_run.append(cmd)
ctx.require = lambda binary, **kw: None
# Call your command logic directly
deploy_impl(ctx, target="site")
assert ["wrangler", "deploy", "--account-id", "test-123"] in commands_run
Using the conftest Fixture¶
The test suite provides a make_cli_context fixture for constructing
contexts with sensible defaults:
def test_something(make_cli_context):
ctx = make_cli_context(dry_run=True, config={"key": "value"})
assert ctx.dry_run is True
Reference¶
Config Resolution Order¶
See Config Precedence above for the authoritative
ordered table (layers 1-6, from highest to lowest priority) and a
worked example showing which source wins each tiebreaker. The ordering
is part of the 1.0 stability contract -- see
API_POLICY.md.
Exit Codes¶
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | User/environment error (missing tool, bad config, command failure) |
| 2 | Framework internal error (unhandled exception -- report as a bug) |
Global Flags¶
| Flag | Description |
|---|---|
--verbose / -v |
Increase log verbosity (-v = INFO, -vv = DEBUG) |
--quiet / -q |
Suppress non-error output (mutually exclusive with -v) |
--dry-run |
Preview actions without executing |
--env NAME |
Select config environment |
--yes / -y |
Skip confirmation prompts |
--version / -V |
Print the CLI's version string and exit (only installed if create_cli() receives version= or package_name=) |
Overriding a global option in a subcommand¶
add_global_option(cli, "--flag", ...) installs the same option on the
root group AND every subcommand that exists at call time, so users can
pass the flag at any level. Occasionally a single subcommand needs to
reclaim a flag name for different semantics — for example, a plugin
subcommand that wants its own --env with a plugin-specific default
instead of the framework's --env that selects the config environment.
The rule: inside the overriding subcommand's scope, the subcommand's
option wins — the value flows into that subcommand's own kwarg, and the
global's merge callback does not run there. Outside that subcommand
(i.e. on other subcommands that did NOT redeclare the flag, or at the
root group level), the global remains active and continues to populate
ctx.find_root().meta.
The pattern: add_global_option takes a call-time snapshot of the
command tree — only commands attached BEFORE the call get the global
installed on them. Commands attached AFTER the call don't. So the
right ordering is:
- Attach every subcommand that SHOULD inherit the global (or that simply doesn't care about the flag).
- Call
add_global_option. - Attach the overriding subcommand(s) — these won't get the global
installed, so their own
@click.option("--region", ...)owns the flag inside their scope.
import click
from clickwork import create_cli, add_global_option
cli = create_cli(name="myapp", commands_dir=None)
# Step 1: attach subcommands that should inherit the global.
@cli.command("status")
@click.pass_context
def status(ctx: click.Context) -> None:
# --region is accessible via ctx.find_root().meta because the
# global was installed on this subcommand (step 2 ran after the
# attachment).
region = ctx.find_root().meta.get("region")
click.echo(f"status for {region!r}")
# Step 2: install the global AFTER every inheriting subcommand is
# attached. Pick a name that does NOT collide with create_cli's
# framework builtins (--verbose, --quiet, --dry-run, --env, --yes) --
# we use --region in this example.
add_global_option(cli, "--region", default=None, help="Target region.")
# Step 3: attach the overriding subcommand. Its own --region wins
# inside its scope; `status` above still sees the global via
# ctx.find_root().meta because it was attached before add_global_option ran.
@cli.command("deploy")
@click.option("--region", default="us-east-1", help="Deploy target.")
@click.pass_context
def deploy(ctx: click.Context, region: str) -> None:
# `region` here is the subcommand's own kwarg -- "us-east-1" by
# default, or whatever the user passed after "deploy" on the CLI.
# The global's ctx.find_root().meta["region"] reflects the
# ROOT-level parse only; it is NOT touched by the inner --region.
click.echo(f"deploying to {region}")
The reverse order — subcommand declares --region first, then
add_global_option(cli, "--region", ...) is called — is rejected at
install time with ValueError naming the colliding flag string (e.g.
--region). That failure mode is deliberate: silently picking a
winner would make override behaviour order-dependent. If you hit this
error, either rename one side or reorder your setup so
add_global_option runs before the overriding subcommand is attached.
Version flag¶
create_cli() accepts two kwargs that opt into a --version / -V
flag on the resulting group:
version="1.2.3"— the literal string to print.package_name="your-pypi-name"— resolve viaimportlib.metadata.version(...)atcreate_cli()call time; a missing distribution raisesValueErrorso typos fail loud.
When both are set, version= wins. When neither is set, --version
is NOT installed so existing clickwork consumers see no change on
upgrade.
Public API¶
Everything you need is re-exported from clickwork:
from clickwork import (
create_cli, # Build a CLI with global flags and discovery
load_config, # Load layered TOML config directly
CliContext, # Typed context passed to every command
pass_cli_context, # Decorator for receiving CliContext
Secret, # Redacted wrapper for sensitive values
CliProcessError, # Raised when a subprocess fails
PrerequisiteError, # Raised when a required tool is missing
ConfigError, # Raised when config validation fails
normalize_prefix, # Convert project name to env-var prefix
)
Release notes¶
This section is for clickwork maintainers cutting a release; framework users can skip it.
Release notes for clickwork itself are produced by GitHub's built-in
auto-generated release notes feature, configured via
.github/release.yml. The config is only
consulted when auto-generated notes are explicitly requested — clicking
"Generate release notes" in the GitHub UI's Release form, or passing
--generate-notes to gh release create. A bare gh release create
uses the body you provide directly.
To make sure a PR lands in the right section, apply one of these labels before merging:
| Label | Section |
|---|---|
enhancement |
Features |
bug |
Bug fixes |
documentation |
Documentation |
| (no label, or any other label) | Other changes |
PRs labeled duplicate, invalid, or wontfix are excluded from the notes
entirely.
Maintainers can still tweak the generated notes in the GitHub Release UI before publishing -- the auto-generated text is a starting point, not a finished artifact. This is the right place to call out breaking changes, highlight the most user-visible work, or add an upgrade blurb.
For the underlying mechanism and full config grammar, see GitHub's docs on automatically-generated release notes.