CLI reference¶
moonlit¶
Top-level flags¶
| Flag | Description |
|---|---|
-V, --version |
Print moonlit <semver> to stdout and exit 0. |
-h, --help |
Print top-level help to stdout and exit 0. |
Running moonlit with no subcommand and no flag prints the help to stderr and exits 2. Running moonlit <unknown> (with or without --help) prints error: no such subcommand: <name> to stderr and exits 2.
Subcommands: build (produce a .pyz/.exe from a local uv project), pack (produce one straight from PyPI packages — no project needed), info (inspect a built archive's env.json), and clean (reap stale cache entries).
moonlit build¶
PROJECT is an optional positional that defaults to the current working directory. It must resolve to an existing directory.
Flags¶
| Short | Long | Type | Required | Default | Description |
|---|---|---|---|---|---|
-e |
--entry-point |
module:callable |
one-of {-e, -c} |
— | Entry point string baked into env.json. |
-c |
--console-script |
string | one-of {-e, -c} |
— | Console-script name; resolved against staged *.dist-info/entry_points.txt. |
-o |
--output-file |
path | yes | — | Destination .pyz (or .exe with --windows-exe). With --bundle-python set, this is a directory path instead — it MUST NOT end in .exe or .pyz. |
-p |
--python |
string | no | /usr/bin/env python3 |
Shebang line baked into env.json and prefixed to the artifact. ASCII only, no \n/\r/\x00, ≤127 bytes. With --windows-exe, the default pivots to python.exe (or py -<X.Y> when --python-version is set). |
--package |
string | iff workspace | — | Workspace member to build; required iff [tool.uv.workspace] is present, forbidden otherwise. PEP 503 normalized on both sides. |
|
--no-dev |
flag | no | (default) | Exclude dev-group dependencies (default behavior). | |
--dev |
flag | no | off | Opt in to dev-group dependencies. Mutually exclusive with --no-dev. |
|
--windows-exe |
flag | no | off | Produce a native Windows .exe (small Rust launcher prepended to the same zip body) instead of a .pyz. Requires -o to end in .exe. The recipient still needs a Python interpreter on PATH or registered with py.exe. |
|
--python-version |
string <X.Y> |
no | build host's sys.version_info.major.minor |
Target Python major.minor for cross-interpreter builds (e.g. 3.12). Threads through every uv invocation as --python <X.Y> so wheels are tagged for that ABI; stamped into env.json.python_version. uv auto-fetches a managed standalone CPython if the requested version isn't locally installed. Format: ^\d+\.\d+$. |
|
--bundle-python |
flag | no | off | Produce a self-contained directory bundle instead of a single file. The bundle contains <basename>.exe (a thin Rust launcher), <basename>.pyz (the application zipapp), and _python\ (a managed CPython tree from uv python install). Recipients without Python on PATH run <basename>.exe, which probes for the sibling _python\python.exe and spawns it directly — nothing is extracted at runtime, which avoids the Trojan:Win32/Wacatac.B!ml false positives that hit moonlit 0.3.0's single-file bundle shape. With this flag, -o is a directory path; it MUST NOT end in .exe or .pyz. --windows-exe may be set alongside but is a no-op (the folder always contains a launcher .exe). |
|
--force |
flag | no | off | Overwrite an existing regular-file output. Does not override a directory target. | |
--gc / --no-gc |
flag | no | --gc (on) |
Bake runtime cache self-GC into the artifact. On the recipient machine, after a fresh extraction, the app trims its own older cache entries — keeping the most recent — so a recipient's cache root doesn't grow by one extracted site-packages per rebuild forever. Stamped into env.json.gc; recipients can override at runtime with MOONLIT_NO_GC etc. See Runtime → Automatic cache cleanup. |
|
--gc-keep-latest |
int | no | 2 |
How many of this app's newest cache entries to keep (≥ 1). 2 = current build + one predecessor (a rollback margin and a guard against reaping a still-running previous version). 1 = leave only the most recent. |
|
--gc-grace |
duration | no | 24h |
Only reap entries older than this (<int><s\|m\|h\|d>, e.g. 1h, 7d). Spares a build a concurrently-running older process may still be using. |
|
-q |
--quiet |
flag | no | off | Suppress progress output on stderr; the success line on stdout is preserved. |
-v |
--verbose |
flag | no | off | On error, append the full traceback to stderr. Mutually exclusive with --quiet. |
-h |
--help |
flag | no | — | Print this help to stdout, exit 0. Short-circuits all other validation. |
Flag interactions¶
- Exactly one of
-e/-cis required. Neither or both → exit 2. -qand-vare mutually exclusive → exit 2.--no-devand--devare mutually exclusive → exit 2.--packageis required iff the project is a uv workspace → exit 5 on mismatch.--windows-exe(without--bundle-python) requires--output-fileto end in.exe(case-insensitive) → exit 2.--python-versionmust match^\d+\.\d+$(major.minor only) → exit 2.--bundle-pythonrejects--output-filevalues ending in.exeor.pyz→ exit 2. With--bundle-pythonthe output is a directory; the directory's basename is reused for the launcher and inner zipapp filenames inside.- When
--windows-exeAND--python-versionare set AND-pis at its default, the default shebang pivots frompython.exetopy -<X.Y>so the recipient's PEP 397 launcher pins to the matching interpreter. - The
MOONLIT_*environment variables are runtime-only; they are ignored during a build.
Preflight order¶
The CLI performs these checks in order; the first failure short-circuits with the listed exit code. The order is part of the contract — tests pin which fault wins on multi-fault inputs.
- Click argument parsing (unknown flag, missing
-o, both/neither of-e/-c,-q+-v,--no-dev+--dev) → exit 2. PROJECTresolves to an existing directory → exit 2.uvonPATH(shutil.which("uv")) → exit 3.<PROJECT>/pyproject.tomlexists → exit 5.<PROJECT>/uv.lockexists → exit 4.- Workspace shape vs
--package→ exit 5. --entry-pointsyntactic validity → exit 6.- Output-path preflight → exit 7.
- Build pipeline → exits 6, 8, 9, 10.
Exit codes¶
| Code | Meaning | Error class |
|---|---|---|
| 0 | Success | — |
| 1 | Unhandled Python exception (a moonlit bug) | — |
| 2 | CLI usage error (parser-level) | — |
| 3 | uv binary not on PATH |
UvNotFoundError |
| 4 | uv.lock missing |
NoLockfileError |
| 5 | Workspace shape mismatch / pyproject malformed | NotAWorkspaceError, UnknownPackageError, MissingPackageError, MalformedPyprojectError |
| 6 | Entry-point resolution failed | BadEntryPointError, ConsoleScriptNotFoundError |
| 7 | Output path issue | OutputExistsError, OutputNotWritableError |
| 8 | uv export failure |
ExportError |
| 9 | uv pip install --target failure |
StagingError |
| 10 | uv build wheel failure or wheel artifact issue |
WheelArtifactError |
| 11 | Internal invariant violation (a moonlit bug) | InternalError |
| 12 | Input archive (for moonlit info) is not a valid moonlit .pyz |
BadArchiveError |
| 13 | --bundle-python: uv python install failed or the install dir's shape was unexpected |
PythonBundleError |
| 14 | moonlit clean: at least one entry was skipped because its lock was held and --force was not set |
CleanRefusedError |
| 15 | moonlit clean: I/O failure during deletion (rmtree raised, etc.) |
CleanIOError |
| 130 | SIGINT (Ctrl-C) | — |
Build-time and runtime exit codes are independent enumerations; the runtime codes (0–3) live in Runtime. The same numeric value can mean different things in the two namespaces.
stdout / stderr semantics¶
- Default: progress lines go to stderr — one line per pipeline step with a brief result and elapsed time (e.g.
writing archive · wrote myapp.pyz · 1.3s total). The final linewrote <output> (<size>, <N> entries)goes to stdout. --quiet: stderr is suppressed; the stdout success line is preserved.--verbose: on error, the full traceback follows the error line on stderr.- Errors: every error is a single line on stderr, formatted as
<ErrorClassName>: <message>. With--quiet, errors are still emitted. - Parser-level errors: formatted as
error: <message>(lowercaseerror:), distinct from the class-prefixed format used forMoonlitErrorsubclasses.
Examples¶
Build a single-package project, current directory:
Build a workspace member, with a custom shebang:
moonlit build /path/to/workspace --package shouter \
-e shouter.cli:main \
-p '/usr/bin/env python3.13' \
-o shouter.pyz
Resolve the entry point from a console script declared by the target's wheel:
Overwrite an existing output file:
Get a traceback when something goes wrong:
Cross-compile a .pyz for Python 3.12 from a Python 3.13 dev box (uv auto-fetches a managed CPython 3.12 if needed):
Produce a native Windows .exe pinned to Python 3.12 (shebang auto-pivots to py -3.12):
Produce a fully self-contained folder bundle that ships a managed CPython next to a thin launcher .exe so recipients don't need Python installed:
The bundle directory layout:
dist/myapp/
├── myapp.exe # the launcher (runs ./_python/python.exe ./myapp.pyz)
├── myapp.pyz # the application zipapp
└── _python/ # bundled CPython tree (~30 MiB)
Distribute the folder (typically by zipping it) and run myapp\myapp.exe. The folder-bundle shape replaces the v0.3.0 single-.exe --bundle-python output, which tripped Windows Defender's ML heuristics for self-extracting archives. The new shape extracts nothing at runtime and so isn't flagged.
moonlit pack¶
Build a .pyz/.exe/folder-bundle directly from PyPI requirement specs — with no local pyproject.toml or uv.lock. This is the moonlit analogue of uvx --with <extra> <tool> or shiv -e mod:fn <pkgs>: hand it package names, get back a self-contained artifact a recipient can run with neither uv nor PyPI access. moonlit build stays the command for your own uv-managed project; pack is for bundling things that already live on an index.
pack resolves the full dependency closure at build time with uv pip compile — that resolution is the lock for the produced artifact (there is no uv.lock to freeze). It then installs the closure with uv pip install --no-deps and runs the same back half as build (entry-point resolution → build_id → env.json → archive). There is no uv build wheel step — every package, including the primary one, is just a resolved dependency.
SPEC is an optional positional — the primary package in PEP 508 form (mooring, mooring==1.4, mooring[extra]>=1). It seeds both the default name and the default entry point.
Flags¶
| Short | Long | Type | Required | Default | Description |
|---|---|---|---|---|---|
--with |
SPEC (repeatable) |
no | — | Additional package to bundle (mirrors uvx --with). May be repeated. |
|
-r |
--with-requirements |
path (repeatable) | no | — | A requirements file whose pins are bundled. May be repeated. |
-e |
--entry-point |
module:callable |
one-of {-e, -c}* |
— | Entry point baked into env.json. |
-c |
--console-script |
string | one-of {-e, -c}* |
— | Console-script name; resolved against staged *.dist-info/entry_points.txt. |
--name |
string | conditional | derived from SPEC |
env.json name (drives the cache key). Required when no positional SPEC is given. Must be a valid PEP 508 name. |
|
-o |
--output-file |
path | yes | — | Destination — same shapes as build (.pyz, .exe with --windows-exe, directory with --bundle-python). |
-p |
--python |
string | no | /usr/bin/env python3 |
Shebang line; same semantics and --windows-exe/--python-version pivots as build. |
--python-version |
<X.Y> |
no | build host's | Target Python major.minor; threaded into uv pip compile --python-version and uv pip install --python. |
|
--windows-exe |
flag | no | off | Native Windows .exe shape. Requires -o to end in .exe (unless --bundle-python). |
|
--bundle-python |
flag | no | off | Self-contained directory bundle shipping a managed CPython. -o is a directory; MUST NOT end in .exe/.pyz. |
|
--force |
flag | no | off | Overwrite an existing regular-file output (or a recognized bundle dir). | |
--gc / --no-gc |
flag | no | --gc (on) |
Bake runtime cache self-GC. Same as build. |
|
--gc-keep-latest |
int | no | 2 |
Retention count (≥ 1). Same as build. |
|
--gc-grace |
duration | no | 24h |
Age grace. Same as build. |
|
-q |
--quiet |
flag | no | off | Suppress progress on stderr. |
-v |
--verbose |
flag | no | off | Echo uv invocations; tracebacks on error. |
-h |
--help |
flag | no | — | Print help, exit 0. |
* Exactly one of -e/-c is required — except when a positional SPEC is present and you pass neither, in which case pack defaults to -c <name-from-SPEC> (resolve the console script named after the primary package, exactly as uvx <tool> would run it).
pack does not accept --package, --dev, or --no-dev — there is no workspace and no dependency groups.
Flag interactions¶
- At least one of
SPEC,--with,--with-requirementsmust be present → else exit 2. --nameis required when there is no positionalSPEC→ else exit 2.- Both
-eand-c→ exit 2; neither (with noSPECto default from) → exit 2. -q+-v, the--windows-exe/--bundle-pythonsuffix rules, and--python-versionformat are validated exactly as forbuild.
Exit codes¶
Same enumeration as build, with one addition: a uv pip compile failure (e.g. an unsatisfiable resolution) is exit 8 (CompileError) — the same resolution-failure code build uses for ExportError. The install (9), entry-point (6), output (7), and bundled-Python (13) codes are shared with build unchanged.
Determinism note¶
Because there is no uv.lock, pack resolves fresh each time. Two packs of the same specs against a moved index may pick newer pins and therefore a different build_id (and a different cache key on recipients). If you need frozen, reproducible inputs, pin exact versions in your specs or pass a fully-pinned --with-requirements file.
Examples¶
Bundle a PyPI tool plus an extra dependency (the uvx --with polars mooring case), then run it offline:
moonlit pack mooring --with polars -o mooring.pyz
python mooring.pyz # runs mooring's console script, polars bundled in
Pick the entry point explicitly instead of the console-script default:
Bundle straight from an existing requirements file (shiv-style):
Pin a version and cross-compile for Python 3.12:
moonlit info¶
Print the env.json manifest of a moonlit-built archive. <pyz> must resolve to an existing regular file.
| Flag | Description |
|---|---|
--json |
Emit the raw env.json bytes to stdout with no header. Useful for piping to jq. |
Default output: a one-line header <path> (<size>, <N> entries) followed by a sorted listing of the manifest's required fields (build_id, built_at, entry_point, moonlit_version, name, python_shebang, schema_version). The optional python_version field is not included in the default listing today; use --json to see it.
If the input is not a zipfile, or env.json is missing/malformed, exit 12 with BadArchiveError: <reason> on stderr.
Examples:
moonlit clean¶
Reap stale cache entries from the runtime cache root. The cache root is resolved the same way the bootstrap resolves it: MOONLIT_ROOT if set, otherwise %LOCALAPPDATA%\moonlit\ on Windows or ~/.moonlit/ on POSIX.
Every .pyz extracts its bundled site-packages/ to <cache_root>/<normalized_name>_<build_id>/site-packages/ on first run. By default, artifacts also self-prune their own app's older entries automatically (keep newest N, default 2 — see Runtime → Automatic cache cleanup and --gc), so a single app's footprint is bounded without moonlit clean. moonlit clean remains the tool for cross-app reaping, whole-cache wipes, age- or name-filtered sweeps, and orphan cleanup — and for trimming caches left by artifacts that were built with --no-gc.
Flags¶
| Flag | Type | Required | Description |
|---|---|---|---|
--all |
flag | one-of {--all, --older-than, --keep-latest, --name} |
Match every well-formed cache entry. |
--older-than |
<int><s\|m\|h\|d> |
conditional | Match entries whose site-packages/ mtime is older than the given duration (e.g. 30m, 7d). Must be positive; compound forms like 1h30m are not supported. |
--keep-latest |
int >= 0 |
conditional | Group entries by normalized name; keep the N newest per group, mark the rest deletable. --keep-latest 0 deletes every matching entry. |
--name |
fnmatch glob | conditional | Match against the PEP-503 normalized name (the part before the trailing _<64hex>). Globs like my* or myapp are accepted. |
--force |
flag | no | Skip the try-lock liveness check. Useful when a stuck holder needs to be cleared. See the Liveness model note below. |
--dry-run |
flag | no | Print the action plan; do not modify the filesystem. |
--show-sizes |
flag | no | Compute per-entry sizes for keep/skip rows (off by default for speed). delete and orphan rows always show what was freed. |
-q, --quiet |
flag | no | Suppress the table; print only the trailer line on stdout. |
-v, --verbose |
flag | no | Show full 64-char build_id hex in the table. |
Bare moonlit clean (no --all, no --older-than, no --keep-latest, no --name) exits 2 with a usage message. There is no implicit "scan and report" default — that would tempt a "do nothing" mental model.
When more than one of --all, --older-than, --keep-latest, --name is set, the deletion set is the intersection. --keep-latest is applied last, after the other filters narrow the candidate set.
Output¶
The action plan is rendered as a table on stderr with columns ACTION, NAME, BUILD_ID, AGE, SIZE, PATH. The trailer is one line on stdout:
--dry-run swaps the trailer to would delete N entries, would free … and leaves the filesystem alone. --quiet suppresses the table but the trailer stays on stdout.
ACTION is one of:
delete— the entry will be removed (or was removed, in a real run).keep— within a--keep-latestgroup, this is one of the N newest.skip— a candidate that could not be deleted (its<cache_key>.lockis held and--forcewas not set). The reason appears in parentheses after the path.orphan— a.tmp.<pid>/.old.<pid>/.locksibling reaped because its owning cache_key is missing or being deleted.
Liveness model¶
moonlit clean is cooperative. For each cache entry slated for deletion it tries to acquire <cache_key>.lock non-blocking. On success it holds the lock through the rmtree, so a concurrent extractor serializes against the deletion. On failure the entry is marked skip (locked); the process exit code is 14 to signal partial completion.
The bootstrap's cache-hit fast path reads site-packages/ without holding the lock. moonlit clean therefore cannot detect a process that is mid-import. Do not run moonlit clean while a moonlit .pyz is actively in use. This is documented in specs/CROSS_CUTTING_DECISIONS.md D23.
--force bypasses the try-lock entirely. The cache directory is deleted regardless of lock state; the lock file is left in place (a live holder still owns the byte range on Windows and we do not pull the rug). The next clean run will reap the lock file once nothing holds it.
Exit codes¶
| Code | Meaning |
|---|---|
| 0 | All targeted entries deleted (or zero candidates matched). |
| 2 | Usage error — no filter, bad --older-than syntax, negative --keep-latest, etc. |
| 14 | CleanRefusedError — at least one entry was skipped because its lock was held and --force was not set. |
| 15 | CleanIOError — an I/O failure during deletion (rmtree raised). Partial progress is possible; the trailer reports what was actually freed. |
Examples¶
Preview what --all would delete without touching the filesystem:
Reap cache entries older than 30 days:
Keep the 3 newest builds per app, delete the rest:
Delete only myapp cache entries:
Force-delete a stuck cache (e.g. after a debugger killed an extracting process and left a stale lock):
python -m moonlit¶
python -m moonlit ... is equivalent to moonlit ...; both delegate to the same moonlit.cli.main(). Useful when the moonlit console script isn't on PATH but moonlit is importable.