CLI reference

The complete command and flag reference. Every entry here is grounded in the argument parser (cli_spec.ts, cli.ts) and the launcher CLI (@zuke/cli), not the help text alone — so what you read is what the parser actually accepts.

Install the global CLI (project scaffolding) once:

deno install -A -g -n zuke jsr:@zuke/cli

Scaffold zuke.ts and the ./zuke launcher:

zuke setup

Everything after setup runs through the scaffolded launcher (./zuke build is deno run -A zuke.ts build):

./zuke build

Two commands named zuke

There are two distinct programs, and the flags below belong to one or the other:

  • The global CLI (@zuke/cli, installed with deno install) is the pre-build tool: it scaffolds a project (setup, import), documents packages (doc), and reports its own --version. You run it as zuke before a build file exists.
  • The build launcher — the ./zuke script setup writes, which is just deno run -A zuke.ts — drives your build: it runs targets and hosts every build-aware command (graph, generate-ci, completions, runs, resume, cancel, mcp, register, and its own doc).

Below, zuke denotes the global CLI and ./zuke the build launcher. Both accept --help/-h.

The reserved command words (graph, generate-ci, completions, mcp, resume, runs, cancel, register, doc) are not runnable as target names — a target called graph can't be launched by name. The parser, --help, and shell completion all read this list from a single source of truth, so they never drift out of sync.

Project: setup, import, doc, version

zuke setup scaffolds a starter zuke.ts and a ./zuke launcher into a directory. It prompts for the build class name and whether to overwrite unless --yes is given or stdin isn't a terminal.

zuke setup                        # scaffold into the current directory
zuke setup --dir services/api     # scaffold into a subdirectory
zuke setup --name ApiBuild --yes  # name the Build class, skip prompts
zuke setup --launcher-name make   # write ./make when a zuke/ dir is in the way
zuke setup --mcp                  # also write .mcp.json so agents find the build
zuke setup --no-bootstrap-deno    # launchers that require Deno on PATH
zuke setupEffect
--dir <path>directory to scaffold into (default .)
--name <Class>the Build subclass name for zuke.ts (default MyBuild)
--launcher-name <name>base name for the launcher scripts — used when a zuke/ directory already occupies the name. Must be a single path segment
--force, -foverwrite existing files
--yes, -yaccept defaults without prompting
--mcpalso write a .mcp.json registering the build's own MCP server, so an agent client picks the build up from the first commit. An existing file is merged around its other servers, and an existing zuke entry is kept unless --force is set
--allow-runregister the MCP server with execution enabled; implies --mcp
--bootstrap-deno / --no-bootstrap-denowhich launchers to write. The default (and what --yes takes) installs a pinned, checksum-verified Deno when none is on PATH, so a checkout needs nothing up front; --no- writes launchers that require Deno on PATH and fail closed without it — for a project that must never download a tool from its build entry point

zuke import takes the same flags plus --from, and generates a zuke.ts from an existing project's build scripts — a package.json's scripts or a Makefile. Without --from it auto-detects the source.

zuke import                       # auto-detect package.json scripts or a Makefile
zuke import --from package.json   # force the source
zuke import --from makefile --yes
zuke importEffect
--from <source>package.json or makefile; auto-detected when omitted
--dir, --name, --force, --yesas for setup

zuke doc <package> prints a package's API by running deno doc from an isolated empty directory. A bare name resolves to a Zuke package (corejsr:@zuke/core); a scoped name (@scope/pkg) becomes jsr:@scope/pkg; and any explicit jsr:/npm:/https:/file:/path specifier passes through. The isolation matters inside a Node repo: run plainly, deno doc resolves the repo's node_modules/@types/* and buries the API under type-resolution warnings; the empty working directory has nothing to resolve. A relative path is pinned to your real directory first.

zuke doc core            # → deno doc jsr:@zuke/core
zuke doc @scope/pkg      # a scoped package
zuke doc jsr:@zuke/deno  # a jsr:/npm:/https:/file: specifier, passed through

# The build launcher has its own 'doc' command, but it only passes through
# explicit specifiers/paths (jsr:/npm:/https:/file:/relative) — no bare-name
# 'core' → jsr:@zuke/core mapping, so give the full specifier:
./zuke doc jsr:@zuke/deno

zuke --version (or -V) prints the CLI version.

./zuke completions

A build-launcher command: it emits a shell-completion script for bash, zsh, or fish that completes your build's target names, the reserved commands, the built-in flags, and any declared parameters as --flag candidates. Unlisted targets stay hidden, exactly as in --list. It takes an explicit sub-action first — print or install — then the shell.

# Print a script and source it in the current shell:
source <(./zuke completions print bash)
source <(./zuke completions print zsh)
./zuke completions print fish | source

# Or wire it into your shell's startup once:
./zuke completions install zsh
Sub-actionEffect
print <shell>write the script to stdout (source it yourself)
install <shell>write the script under your config directory (honouring $XDG_CONFIG_HOME) and wire it into the shell's startup. Idempotent — an already-sourced rc file is left untouched

The script is a static snapshot of the build it was generated from, so regenerate and re-source it after you add, rename, or remove targets — the same model as deno completions.

./zuke outdated

Compares the version your deno.lock resolves for every jsr: specifier against the latest each package publishes, and prints the ones that are behind.

./zuke outdated               # report jsr: pins the lock resolves behind latest
./zuke outdated --exit-code   # exit 1 if anything is behind or unverifiable
@zuke/git     1.5.0  →  1.11.0
@zuke/gcloud  1.1.0  →  1.3.0

2 packages are behind. Delete these entries from the lock (or the lock file) and re-run …

It covers the case nothing else does. A build whose specifiers are written inline — jsr:@zuke/git@^1 in zuke.ts rather than in a deno.json imports map — gets no signal from deno outdated, which reads manifests. The lock keeps resolving the versions recorded when the build was written and --frozen is content with that, so a build can sit several minors behind a wrapper for months, still hand-rolling a command the package has since typed. Reading the lock is the point: it records what a run actually resolves, which is the number a stale pin hides.

A package the registry can't answer for — a private scope, a rename, an offline runner — doesn't fail the report, but it is named in it. Otherwise a run behind a proxy that reached nothing at all would print "every package is at its latest release", the confident wrong answer this command exists to prevent. --exit-code exits 1 when anything is behind or could not be checked, for the same reason; without it the command is a report and always exits 0. A missing lock file is an outright error.

It needs the network, which is why it's a command you run rather than a line in --list or the run summary — those stay offline and instant. To refresh afterwards, remove the stale entries from deno.lock (or delete the lock) and re-run: neither --reload flavour re-resolves an inline specifier, and deno outdated --update reads manifests, so it can't see one either.

Running targets

./zuke <target> runs the target and all its transitive dependencies in order. With no positional argument, the launcher runs a target literally named default if one exists, otherwise it prints --list. Each target prints a start banner and a / line with its duration; a failure aborts the rest and exits 1.

./zuke build                     # run 'build' and all its dependencies
./zuke                           # run the 'default' target, else print --list
./zuke test --skip lint          # run 'test' but skip the 'lint' dependency
./zuke ci --parallel             # run independent targets concurrently
./zuke ci --parallel=4           # cap the number in flight
./zuke ci --affected=origin/main # only targets reachable from changed files
./zuke deploy --dry-run          # print the plan without executing any body
./zuke build --no-cache          # ignore the incremental cache; re-run all
FlagBehaviour
--skip <dep>run the target but skip the named dependency (repeatable)
--parallel[=N]run independent targets concurrently, still completing every dependency before its dependents. N caps the number in flight (default: the host CPU count). See execution model
--affected[=<base>]run only the targets a git diff since <base> can reach (default base HEAD); a target with no declared .inputs() can't be proven unaffected, so it always runs. See affected targets
--no-cacheignore the incremental cache and re-run every target
--no-remote-cacheuse the local cache only; skip the remote cache store (neither restore nor upload). --no-cache disables both
--dry-runresolve the plan and print every target that would run (honouring --skip and each onlyWhen) without executing any body or touching the cache
--statepersist a durable run record under .zuke/runs when no store is otherwise configured
--actor <name>attribute the run to <name> in its state record (else ZUKE_ACTOR, the CI actor, or "anonymous")
--list, -llist all targets with descriptions and dependencies
--jsonemit the whole build surface (commands, flags, targets, parameters) as JSON — for tools and agents
./zuke --list          # targets with descriptions and dependencies
./zuke --list --json   # the whole build surface (commands, flags, targets) as JSON

An unrecognised --flag is a hard error. Zuke names it, suggests the nearest known flag when one is within two edits (--dry-rn--dry-run), and exits 1 without running anything — a typo used to be silently ignored, which meant --dry-rn ran the build for real. The same applies to an unknown target name. --help still wins when it appears alongside a bad flag, a bare -- separator is skipped, and a built-in given an inline value it does not accept (--skip=lint) is told to pass the value as the next argument instead.

Graph & CI

./zuke graph shows the dependency graph. By default it prints the terminal target → deps adjacency listing; --output=html renders an interactive Cytoscape page to <repo root>/.zuke/graph.html and opens it (Cytoscape loads from a pinned CDN, so the first view needs internet access).

./zuke graph                  # print the 'target → deps' adjacency listing
./zuke graph --output=html    # render an interactive page to .zuke/graph.html and open it
./zuke graph --output=html --no-open
FlagBehaviour
--output=htmlrender the interactive HTML page instead of terminal text (--output text is the default)
--no-openwith --output=html, write the file without opening a browser

./zuke generate-ci writes the CI configuration files the build declares via cicd(). Running any target regenerates them too, so the command is mainly for CI verification.

./zuke generate-ci          # write the CI files declared via cicd()
./zuke generate-ci --check  # verify they are current instead (fail if stale) — use on CI
FlagBehaviour
--checkverify the declared files are current instead of writing them, failing if any has drifted (use on CI, so an ephemeral checkout is never left dirty)

State & orchestration

These commands read and steer persisted run records. They resolve the state store the same way a run does — ZUKE_STATE_URL / ZUKE_STATE_DIR, the build's stateStore() override, or the default .zuke/runs — and report a friendly error when no store is configured.

./zuke ci --state                       # persist a run record under .zuke/runs
./zuke runs list                        # one row per run, newest first
./zuke runs list --status failed --limit 20
./zuke runs list --target deploy --since 2026-07-01T00:00:00Z
./zuke runs list --counts --json        # aggregate { total, byStatus }
./zuke runs show <run-id> --json        # one run's full per-target status
./zuke runs prune --keep 90d --keep-last 50 --dry-run

./zuke runs takes a sub-action: list, show <id>, or prune.

./zuke runsBehaviour
listone row per run — id, status, root target, actor, creation time — newest first
list --status <s>keep only runs with this status: running, suspended, cancelling, succeeded, failed, or cancelled
list --target <t>keep only runs whose graph contains that target
list --since <iso>keep only runs created at/after an ISO-8601 timestamp
list --limit <n>return at most the newest N (must be a positive integer)
list --countsprint aggregate counts (a total and one line per status) instead of rows; with --json emits { total, byStatus }
show <id>reconstruct one run in full: header, resolved non-secret parameters, per-target status/duration/error, and any signals received
prune --keep <age>keep runs newer than an age (e.g. 90d); older terminal runs become eligible
prune --keep-last <n>always keep the newest N terminal runs
prune --dry-runreport what would be deleted without deleting; at least one --keep/--keep-last rule is required
--jsonon list/show, emit machine-readable output (the summary array or the whole record)

prune never touches non-terminal runs (suspended, running, cancelling) — a run is deleted only when it is terminal and matches neither keep rule. See retention for who owns pruning on each backend.

./zuke resume <id> continues a run parked at a .waitsFor() gate; ./zuke cancel <id> stops a run and unwinds its .onCancel(...) compensations in reverse order. Resumption is exactly-once — concurrent resumers race a compare-and-swap and all but one get AlreadyResumedError. See suspend & resume and cancellation.

./zuke resume <run-id>                       # continue a suspended run
./zuke resume <run-id> --signal approved --data '{"by":"alice"}'
./zuke resume --check                        # reap abandoned runs, then re-check waits & timeouts
./zuke resume <run-id> --resume-degraded     # continue despite a lost state write
./zuke cancel <run-id> --actor ci-bot        # cancel and run compensations
FlagBehaviour
resume --signal <name>deliver a named external signal to the run
resume --data <json>with --signal, the signal's JSON payload (default {}; capped at 64 KiB)
resume --check [<id>]the cron/webhook entry point. Makes three passes: reap abandoned runs (a running record whose lease can be acquired belongs to a dead process, so it returns to suspended and is resumed in the same sweep — or settles failed with its compensations if it is past the build's deadline()), then finish runs left cancelling by a dead settler, then re-check predicate waits and enforce timeouts across suspended runs
resume --force-graphcontinue even if the build graph changed since the run was suspended
resume --resume-degradedcontinue a resume whose record is degraded (a state write was permanently lost, so a target that succeeded may still be recorded running — a resume would run it a second time). Also accepted by --check, which otherwise counts a degraded run as failed on every sweep
resume --actor <name>, cancel --actor <name>attribute the resume/cancellation in the audit trail

Ctrl-C (or SIGTERM) during a run triggers the same graceful cancellation in-process; a second signal forces an immediate exit.

./zuke force

An operator sometimes has to take a step off a live run: one that cannot succeed, or one a person completed by hand. ./zuke force <id> <target> --outcome skipped|succeeded records an override on the record, and the executor settles that target without running its body — ahead of its onlyWhen conditions and its cache, because forcing outranks what the build would work out for itself. Dependents proceed either way.

# settle a step that cannot succeed, or one a person completed by hand
./zuke force <run-id> migrate --outcome succeeded --reason "ran by DBA"
./zuke force <run-id> smokeTest --outcome skipped --reason "env is down"

The two outcomes differ under a later cancellation. A forced succeeded asserts the target's effects exist, so it is compensated like any other succeeded target; a forced skipped never happened, so it isn't — exactly like a target a condition skipped.

It is refused, naming the rule, when the target has already settled (the record is the account of what happened, and rewriting a settled outcome would make it untrue), when the run is terminal, when the target is not in the run's graph, or when the build declared it off-limits via unforceable(). An override lands for any target the run has not started yet, which in practice means the next resume. ./zuke runs show prints every override, and over MCP the same operation is the force_target tool.

A resume refuses a run whose record is degraded and names the risk, because it re-runs every target the record does not show as succeeded — which for a deploy or a release would mean doing it twice. --resume-degraded accepts that risk; use it once you know those targets are safe to repeat. zuke cancel faces the same gap from the other side and widens its compensation walk to every target whose success the record cannot rule out.

MCP & registry

./zuke mcp runs a Model Context Protocol server over the build so an AI agent operates the pipeline through typed tool calls. It is read-only by default (inspect targets, parameters, graph, and runs); --allow-run adds one run:<target> tool per target. Every mutating or denied call is written to an audit trail (./zuke runs show mcp-audit).

./zuke mcp                                   # read-only MCP server on stdio
./zuke mcp --allow-run                        # also expose run:<target> tools
./zuke mcp --allow-run='build,test:*' --protect='deploy,release'
./zuke mcp --allow-run --confirm-destructive  # destructive runs return a plan first
./zuke mcp --http 127.0.0.1:8787              # streamable-HTTP transport
./zuke mcp --registry --max-concurrent-runs 8 # serve every registered build

./zuke register                              # record this build in the registry
FlagBehaviour
--allow-run[=<globs>]let agents execute targets, not just inspect them; an optional comma-separated glob allow-list exposes only matching targets as run tools (others stay invisible)
--protect <globs>require an operator token (ZUKE_OPERATOR_TOKEN) as a tool-call argument for any run whose plan touches a matching target — so a protected deploy reached as a dependency of an unprotected release still demands it. Fail-closed: an unresolvable plan is denied, not assumed harmless
--confirm-destructivemake a destructive run tool return its plan until called with confirm:true (a .readOnly() target is exempt)
--registryserve the build registry instead of this one build — expose every registered pipeline's targets, re-read live so a newly-registered build appears with no restart
--max-concurrent-runs <n>with --registry, cap concurrent run-tool spawns (default 4); a call past the cap gets an immediate busy error, read tools are never counted
--http <host:port>serve the streamable-HTTP transport instead of stdio. A bare <port> binds 127.0.0.1; a non-loopback host requires a bearer token (ZUKE_MCP_TOKEN)
--allowed-origin <origin>with --http, permit an extra browser Origin (repeatable). By default a loopback bind accepts only loopback origins — the drive-by / DNS-rebinding guard; a request with no Origin (a CLI client) is always allowed

There is no --token flag. The HTTP bearer token is read only from the ZUKE_MCP_TOKEN environment variable, and the operator token from ZUKE_OPERATOR_TOKEN — never from an argument that would land in a process listing or shell history.

./zuke register records this build in the build registry (its targets, parameters, and launch location, excluding secrets) so a registry-backed MCP server can discover it. It is idempotent, accepts --actor <name> and --json, and writes to .zuke/builds unless ZUKE_REGISTRY_URL/_DIR or the build's registry() configures a store. See the registry.

Environment variables

These configure state, the registry, the remote cache, MCP authorization, and tool resolution without a build-file change — the usual way to point the same build at production infrastructure from CI. For each pluggable backend, the _URL variable (with an optional _TOKEN) selects the HTTP backend; the _DIR variable selects the filesystem backend.

# Never a flag — the loopback-only guard reads it from the environment:
ZUKE_MCP_TOKEN= ./zuke mcp --http 0.0.0.0:8787

# Configure a remote cache with no build-file change:
ZUKE_REMOTE_CACHE_URL=https://cache.example.com ZUKE_REMOTE_CACHE_TOKEN= ./zuke ci
ZUKE_REMOTE_CACHE_DIR=/mnt/zuke-cache ./zuke ci

# Configure durable state / the registry the same way:
ZUKE_STATE_URL=https://state.example.com ZUKE_STATE_TOKEN= ./zuke ci --state
ZUKE_STATE_DIR=.zuke/runs ./zuke runs list
VariableEffect
ZUKE_MCP_TOKENbearer token required by a non-loopback mcp --http bind
ZUKE_OPERATOR_TOKENoperator token a protected (--protect) MCP run tool must carry
ZUKE_STATE_URL / ZUKE_STATE_TOKENselect an HttpStateStore for durable run state (see store precedence)
ZUKE_STATE_DIRselect a filesystem state store at this directory
ZUKE_REGISTRY_URL / ZUKE_REGISTRY_TOKENselect an HttpBuildRegistry for register and registry-backed MCP
ZUKE_REGISTRY_DIRselect a filesystem build registry at this directory
ZUKE_REMOTE_CACHE_URL / ZUKE_REMOTE_CACHE_TOKENselect an HttpCacheStore for the remote cache
ZUKE_REMOTE_CACHE_DIRselect a FileSystemCacheStore at this directory
ZUKE_ACTORdefault actor for a run when --actor is absent (before the CI actor and "anonymous")
ZUKE_LOG_LEVELseeds the console threshold — trace, debug, info (the fallback), warn, error, or silent. See Console output. Colour follows NO_COLOR and TTY detection; GitHub annotation mode follows GITHUB_ACTIONS
ZUKE_TOOL_RESOLUTIONambient override for how tool wrappers locate their binary: path (bare name on $PATH) or node_modules (npx-style node_modules/.bin walk). See installing tools

Store precedence is always: an explicit execute(...) option, then the build's typed override (stateStore(), registry(), remoteCache()), then these environment variables. An unreadable ZUKE_TOOL_RESOLUTION (no --allow-env) or an unrecognised value is treated as unset.