How bundleup's command line and library API should look and behave. Synthesized from round 5 research (compass, primary; gemini, agrees on almost everything). Adopted via ADR-0016 (Proposed). Where this guide and ADR-0011 differ, the user decides during the ADR-0011 review.
Verbs (option A). bundleup build [PATH] builds, where PATH is a project directory or a
script; later bundleup check, bundleup cache …. Bare bundleup prints
help. Chosen by the owner (ADR-0016) because new commands
can never collide with a project path, and it matches uv and cargo.
| Option | Example | For | Against |
|---|---|---|---|
| A. Verbs (Compass) | bundleup build, bundleup check, bundleup targets; bare bundleup prints help |
Matches uv and cargo, which users run alongside bundleup. Adding commands later (check, targets, cache clean) never collides with a project path. clig.dev warns against default commands |
Two extra words for the main action |
| B. Default action (Gemini, current code) | bundleup [path] builds; bundleup check, bundleup targets are extras |
The esbuild feel: bundleup alone does the job |
bundleup check is ambiguous if a folder is named check; every new subcommand is a potential breaking change |
- Zero config builds correctly for this machine. Python version from
requires-python/.python-versionas uv resolves it; outputdist/<name>.pyz. - Reuse uv's flag names and meanings where we overlap:
--python,--locked,--frozen,--project,--python-platform(uv's platform vocabulary, e.g.x86_64-manylinux_2_28). No synonyms. - Every flag has a long form. Short flags only for
-o/--output,-q/--quiet,-v/--verbose,-n/--dry-run,-h/--help,-V/--version. - No abbreviations:
ArgumentParser(allow_abbrev=False). - Booleans come in pairs (
--compile/--no-compile, argparseBooleanOptionalAction). - List options repeat or take commas (
--python-platform a --python-platform bor--python-platform a,b; ADR-0051). - No named targets (ADR-0039): a
destination's needs are ordinary flags (
--python-platform,--format,--max-size), and destinations are documented as recipes. (Until 2026-10-07: presets that printed their expansion.) --dry-run/-nresolves everything, prints the plan and where each setting came from, and writes nothing.- In CI (
CIset), behave as--locked: a stale lockfile is an error, not a silent bundle missing a new dependency. Only when a lockfile exists: a PEP 723 script often has none, anduv export --lockedrefuses to start without one (found 2026-10-05).
- stdout is for machine output only (
--json, listings). All human messages, warnings and errors go to stderr. - On success, print at most two short lines, e.g.
Bundled myapp 1.4.0 → dist/myapp.pyz (8.2 MiB) in 1.37s. - Progress only on a TTY and only for steps over ~200 ms: one rewriting line, never in CI.
- Colour off when the stream isn't a TTY (checked per stream),
NO_COLORis set,TERM=dumb, or--color never.FORCE_COLOR/--color alwaysturn it on. Colour only forerror(red),warning(yellow),hint(cyan), and bold paths. - ASCII fallback (
->, no spinner) when stderr isn't UTF-8. - Quiet and verbose levels:
-q= warnings and errors only,-qq= errors only;-v= step timings,-vv= the uv commands run.
--jsonwrites exactly one JSON document to stdout at exit, on success and failure, with the normal exit code. Human stderr output is suppressed.- It carries
schema_version. Within a version, changes are additive only; consumers ignore unknown keys. Breaking changes need a new version. Publish a JSON Schema. - Every diagnostic has a stable
codeslug (e.g.lock-outdated),level,message, and optionalhint,file/line,package. Scripts and agents branch on codes, not prose. - Never prompt.
- Deterministic output: sort lists; paths relative to the project; durations and absolute paths isolated in known fields.
- Format:
error: <what happened>, optional context lines, thenhint: <what to do next>last (uv's style). - Point at what to edit:
file:linewith a caret for config errors;package==versionfor dependency errors. - Expected failures never show a traceback. Every expected failure is a
BundleupErrorsubclass the CLI renders. Unexpected exceptions are bugs: exit 3, "this is a bug" line, issue link, full traceback with-v. - Suggest fixes for typos in subcommands, choices and config keys, but never auto-run them
(argparse
suggest_on_erroron 3.14+,difflibbefore). - When uv fails, quote its stderr verbatim, indented under bundleup's one-line explanation.
| Code | bundleup.ExitCode |
Meaning |
|---|---|---|
| 0 | OK |
Success (warnings allowed unless --strict) |
| 1 | BUILD_FAILED |
Expected failure: stale or missing lock, no compatible wheel, uv failure, check found problems, --strict warnings |
| 2 | USAGE_ERROR |
Invalid flags, unknown command or format, invalid [tool.bundleup] |
| 3 | INTERNAL_ERROR |
A bug in bundleup |
| 130 | INTERRUPTED |
Ctrl-C |
- Don't add codes per failure type; use the JSON
codefor detail. - Warnings never change the exit code unless
--strict.
| Rank | Source | Example |
|---|---|---|
| 1 | Flags | --format lambda --python 3.13 |
| 2 | Environment | BUNDLEUP_PYTHON=3.12, UV_LOCKED, NO_COLOR, CI |
| 3 | [tool.bundleup] in pyproject.toml |
format = "lambda" |
| 4 | Defaults | host platform, dist/<name>.pyz |
- Env vars are
BUNDLEUP_+ the long flag in SCREAMING_SNAKE. Show them in--help. - Per-run settings (
--json,-q,--dry-run,--color) are flags/env only, rejected in[tool.bundleup]. - Unknown keys in
[tool.bundleup]are errors with a suggestion. - No user-level config files until users ask.
--help: one-line purpose, 3–4 examples, common options first, docs and bug-report URLs. Under ~30 lines.- Per-flag help shows default, allowed values and
[env: …]. - Generate the CLI reference doc from the parser in CI, and fail if it drifts.
- Startup budget:
--versionand--helpmust not import heavy modules or start uv. Measure on CI withpython -X importtime. - Contracts: commands, flags, env vars,
[tool.bundleup]keys, exit codes, JSON schema and diagnostic codes. Human-readable text is not a contract. - Versioning: uv's scheme until 1.0 (minor = breaking). Deprecate in three steps: warn, then error with the replacement, then remove. Never change what an existing flag means.
From "Satisfying Terminal UX" research done for another project (filegoblin), keeping only what fits a build tool. Everything here applies to TTY output only; rules 10–15 still hold.
- Show the work, not a bare spinner. For steps over ~1 s, the progress line names the step
and the current item or count (
Fetching wheels 12/23 · numpy-2.1.3), so it's obviously alive. A spinner with no detail is reserved for short, unknown-length waits. - Steps read like cargo and uv: a short bold verb at the left margin (
Resolved,Fetched,Bundled), then the detail. Easy to scan. - End with one clear completion line (rule 11), visually distinct from the progress that preceded it, so the task feels finished.
- Colour has meaning; metadata is dimmed. Sizes, timings and paths in dim/grey, the important word bold, red/yellow/cyan only for error/warning/hint. No decorative colour.
- No emoji. At most
✓/→on UTF-8 terminals, with ASCII fallbacks. - The output path is a clickable link (OSC 8 hyperlink) when stderr is a TTY that supports it; plain text otherwise.
- Rejected from that research: artificial stutters or fake progress, animated "illusion" bars, sounds and haptics. They manipulate rather than inform, and they don't belong in CI-adjacent tools.
- The public API is exactly
bundleup.__all__. Everything else lives in_underscoremodules. Shippy.typed. - Entry points:
build(),check(),verify(), with keyword-only typed options (a frozenBuildOptionsdataclass mirroring the CLI). - Results are frozen dataclasses whose
to_json_dict()is the--jsonschema. - One exception root,
BundleupError, with.code,.hint,.exit_code; subclasses such asConfigError,LockfileOutdatedError,NoCompatibleWheelError,UvError. - The library never prints, exits, configures logging, or touches
sys.stdout/os.environ. Progress goes through an optional callback; warnings come back in the result. - The CLI is a thin layer:
argv → BuildOptions → build() → render → ExitCode, in onemain(argv: list[str] | None = None) -> int. - Guard the API with a test that snapshots
__all__and every public signature.
- argparse, not Click/Typer/Cyclopts: no extra dependencies, fastest startup. Add an
Examples:epilog and[env: …]suffixes with a customHelpFormatter. - No Rich. A ~80-line
_term.pyhandles TTY detection, the colour rules, four styles, ASCII fallback and a TTY-only spinner.
- Snapshot every output mode (success, warnings, each error class, crash) for stdout and stderr, with durations and paths redacted.
- Exit-code contract tests through a real subprocess (
python -m bundleup), including Ctrl-C → 130 and an injected crash → 3. - Output-mode matrix:
NO_COLOR,FORCE_COLOR,TERM=dumb, piped (non-TTY) and a real TTY. - JSON contract test: validate against the published schema; one document on stdout even with warnings.
Commands: build, check (ADR-0024), targets, verify
(ADR-0019) and cache list|clean
(ADR-0022). Implemented in
src/bundleup/_cli.py, _errors.py, _term.py and the public API
(ADR-0018), with contract tests in tests/test_cli.py
and the generated CLI reference:
- verbs (
bundleup build), uv's flag names, long forms,allow_abbrev=False(rules 1-4); - stdout/stderr split, two-line success, TTY-only status line, colour rules, ASCII fallback,
-q/-qq/-v/-vv(10-15), clickable output path (56); --jsonwithschema_version, stablecodeslugs, JSON Schema (16-20);error:/hint:messages, uv output quoted under the message, exit codes 0/1/2/3/130, "this is a bug" for crashes, a suggestion for mistyped commands (21-27);BUNDLEUP_OUTPUT,BUNDLEUP_PYTHON,BUNDLEUP_ENTRY(28);--lockedis the default when a lockfile exists (9);- help under ~30 lines with examples and
[env: ...](32-33), generated reference checked by a test (34), startup budget checked by a test (35); - no named targets;
--max-size(7; ADR-0039); [tool.bundleup]inpyproject.tomlor a PEP 723 script, below flags and environment (29-31, ADR-0032);--strict(27) onbuildandcheck; diagnostics carrypackage,fileandline(18);- library API:
__all__,py.typed,build(BuildOptions, progress=...), frozen results withto_json_dict(), one exception root, a library that never prints or exits, API snapshot test (38-44); snapshots of every output mode and exit-code tests (47-50).
Not yet: --dry-run (8); progress counts per item (51); Ctrl-C and crash exit codes
are tested in-process rather than through a subprocess (48).