CLI reference
Every vx command, flag and output. Each verb has a section below.
# The prebuilt standalone binary; inside a project# that installs @vzn/vx, it runs that one (§ Which vx runs):bun add -g @vzn/vx
# From source (Bun ≥ 1.4.4):bun src/bin.ts --versionOn a Bun older than 1.4.4, vx still runs, and vx info’s bun row
warns. What breaks is the output, not the start:
- a large
--format jsonwrite is cut off; - no task reports what it used;
- a config syntax error shows as an internal error.
The released binary carries its own Bun, so the row never warns there.
modules/util-bun-version.md has the
measurements.
Commands
Section titled “Commands”| Page | Covers |
|---|---|
| vx run | vx run, Planning mode, Run artifacts, Sandbox |
| vx watch | vx watch |
| vx show | vx show |
| vx why | vx why |
| vx last | vx last |
| vx info | vx info |
| vx lock | vx lock, Runs read the lock |
| vx cache clean | vx cache clean |
| vx init | vx init, bunx @vzn/vx-migrate |
| vx upgrade | vx upgrade |
| vx docs | vx docs |
| vx completions | vx completions |
| Plugin commands | Plugin commands |
| Environment variables vx reads | Environment variables vx reads |
| Output format | Output format |
Top-level shape
Section titled “Top-level shape”# Corevx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]vx watch [OPTIONS] [--poll] TASK [-- forwarded-args...]vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry] [--format pretty|json] [--cache-dir <path>]vx lock [--check] [--format pretty|json]vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <hook>]vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]vx info [--format pretty|json] [--cache-dir <path>]vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]vx last [RUNID] [--list[=N]] [--failed] [--log <task>] [--stats [<task>] [--branch <name>]] [--sql <query>] [--live [--follow]] [--format pretty|json] [--cache-dir <path>]vx upgrade [TAG] [-g|--global]vx docs <query> [--limit N] [--format pretty|json]vx completions bash|zsh|fish
# Metavx # in a workspace, vx --help; outside one, says so and exits 1vx help [VERB]vx --help, -hvx versionvx --versionEach verb’s lines are the usage lines vx <verb> --help prints, word
for word (tests/cli-help-synopsis.test.ts); the flags a verb accepts
are read from that line.
Several tasks run as one graph: vx run build lint test runs all
three across the selected projects.
pkg#tasknames one project’s task.- A bare name runs in the cwd project, or wherever
--all,--filteror--affectedpoints.
The cwd project is the deepest project holding the directory, one
reached through a link (packages/b -> ../ext/b) included.
Every requested name must resolve. When a name matches no project
in scope, the run and --dry both refuse, exit 1, and print to stderr:
vx run: no projects declare task(s): <name>.
The refusal adds what helps:
Did you mean <task>?when a declared task (or, forpkg#task, a runnable one) is within two edits. Apkgthat is a project’s directory, relative to the root, names that project:p/a#buildhintsalpha#buildforalphaatp/a.- Otherwise what exists:
Tasks: build, test.,No project is named zzz; projects: a, b., ora's tasks: build, test. - With no task declared anywhere (right after
vx init):No project declares a task yet: add one under `tasks` in a vx.config, then run `vx lock`. - A
pkgthat matches a project only after Unicode normalization (cafétyped in NFC, the project’s directory in NFD) is said to differ in Unicode form, with the fix: setnamein its vx.config. - Otherwise
A run reads the lock: after editing a vx.config, run `vx lock`., since a task added since the last lock is not in it. Only projects outside the selection declare <name> — pass --all, or a --filter that includes them.when the run was scoped and a project outside the scope declares it, then the lock line, since a task just added to a selected project’s config is not in the lock yet. A selected project whosebuildis the default one (a root with an empty config) is named instead:vx showcounts that build, but no bare name runs it.
A name declared by only some projects is fine; the check fires only when
a name matches nowhere. So vx run lint test typecheck fails on CI the
day typecheck is renamed, instead of running two of three.
A name no config can declare is refused before the lookup: an empty one
(vx run '') and one with surrounding whitespace (vx run 'build '),
which the refusal quotes so the space shows.
Under --affected or a [ref] filter, a bare name is checked against
the whole workspace, since what changed decides the projects:
- A change that reached no project declaring
testexits 0 withNo affected project declares task(s): test.A name no project declares is still refused. - A change that reaches no requested task exits 0 with
Nothing affected: the change reaches no test task. - A diff that touched no project exits 0 with
nothing affected since <ref>on stderr, once each bare name is found declared somewhere: a name no project declares is still refused.
There is no -V; use vx --version, as in Turbo.
vx <verb> --help (also -h, vx help <verb> and vx --help <verb>) prints this
reference for that verb: its usage lines and sections (of a section several
verbs share, only its own entries), then
Full reference: vx help. Every argument error points at it.
vx help <plugin verb> is vx <plugin verb> --help: the plugin answers.
vx help <name> for a name that is no verb is refused like
vx <name>: one line, a guess when one is close, exit 1. A second
word after the verb is refused too.
After --, a flag belongs to the task: vx run build -- --help passes
--help to it. A plugin verb handles its own arguments, --help included.
Releasing (maintainers)
Section titled “Releasing (maintainers)”Releases are on demand. Dispatching auto-release.yml releases a
commit on main whose CI went green: the sha input, or by default the
newest such commit; one CI did not pass on is refused. Claude dispatches
it at least once a day when main has changes, after merging that
release’s blog post (.claude/skills/release/SKILL.md), and shares the
post on Bluesky. The workflow tags the commit with the next version and
creates the GitHub release, both from the Conventional Commits since the
last tag (scripts/release-notes.ts, run by the release.auto task,
scripts/auto-release.ts). The release is created as a draft: this
repo’s releases are immutable and take assets only before they are
published, so release.yml publishes it after its last upload. The version: below 0.1.0 always a
patch (cutting 0.1.0 is the owner’s, by hand); then before 1.0 a feat
or a breaking change (type!:, a BREAKING CHANGE: footer) is a minor
(v0.4.2 → v0.5.0) and anything else a patch (v0.4.3); from 1.0 a
breaking change is a major. The notes: breaking changes first, then
feat, fix and perf, the rest counted. It then
dispatches release.yml (with tag) and
npm.yml (with version and ref). A release made with the workflow
token fires no release event in other workflows, which is why the two
are dispatched rather than triggered. A commit is released only when the
last release is its ancestor, so an older tree never gets a higher
version, and one that already carries a v* tag is a no-op.
A version can still be cut by hand: push its tag (say v1.0.0),
create a draft release for it, and dispatch release.yml (tag) and
npm.yml (version, ref); the next auto-release continues from it.
A GitHub release publishes everything: release.yml builds the four
binaries, proves the darwin ones on macOS (re-signed ad hoc only where
macOS refuses one) and attaches them; npm.yml
builds the fifteen npm packages (@vzn/vx, one per platform, and every
other public package: the plugins, the servers, @vzn/vx-devtools) and publishes them with npm trusted
publishing — the job’s OIDC token
is exchanged for a short-lived credential and provenance is attached,
so no long-lived npm token exists anywhere. Both publish loops skip a
package already on the registry, so a re-run (npm publish →
Run workflow with the version) resumes where it stopped. That dispatch
also takes a ref: it runs the workflow from the default branch against
the code that ref names, so a release whose publish died on a workflow
bug is completed by the FIXED workflow building the tag’s own source
(version: 0.0.20, ref: v0.0.20) — re-running the failed run itself
would replay the broken file, which is pinned to the tag.
Every step of the three workflows past the setup (checkout, Bun, Node,
bun install, the runner action) is one vx task: CI runs no command of
its own (2026-10-03), and tests/workflow-runner.unsafe.test.ts
holds it. The tasks are @vzn/vx’s release.*, all uncached and in no
ci graph:
| Task | Does |
|---|---|
release.stamp |
writes VX_RELEASE_VERSION into packages/vx/package.json, the manifest the binary inlines |
release.npm |
checks npm can publish with provenance (>= 11.5.1, sigstore loads); else installs npm into dist/npm-cli/ |
release.prove.<os> |
launches the host’s binary (re-signed only if macOS refuses it) and asserts vx <version>; on darwin, launches both (x64 under Rosetta) |
release.assemble.<os> |
emits the platform packages under dist/npm (linux: also @vzn/vx and the plugins) |
release.publish.<os> |
npm publish --provenance each in order, skipping one the registry holds |
release.served |
waits (up to 10 min) until npm’s CDN answers 200 for every published tarball: it 404ed v0.0.646’s for minutes, failing installs |
release.images |
pushes the three server images to ghcr.io/vznjs/<server> at the version (amd64 + arm64); latest follows npm’s |
release.upload.<os> |
attaches dist/vx-<os>-* the draft release lacks; darwin’s then publishes it |
release.auto |
decides the version, creates the draft release with its notes, dispatches the two workflows |
The version reaches them as VX_RELEASE_VERSION (the tag or the
dispatch input) and each refuses one that is not a version. Every one
is sandboxed: the publish is granted the registry, the OIDC token host
and Sigstore, the GitHub-Actions variables npm reads, and ~/.npm;
release.auto reads .git and reaches api.github.com with GH_TOKEN;
release.upload.<os> reaches it and uploads.github.com.
A dispatch with ref builds that ref’s code, so it must hold these tasks.
Every Linux job that runs a task runs vx setup github (@vzn/vx-ci)
before its first vx run: every task in this repo declares exec.sandbox, and a
declared sandbox whose runtime is missing is a hard error, not a
downgrade — the compile tasks fail in 0 ms saying which of bubblewrap,
socat and ripgrep is absent. v0.0.20 shipped its darwin packages and
then stopped exactly there.
One-time setup, per package, on npmjs.com → package → Settings →
Trusted Publisher → GitHub Actions: owner vznjs, repository vx,
workflow npm.yml, environment left blank. Do this for @vzn/vx,
@vzn/vx-darwin-x64, @vzn/vx-darwin-arm64, @vzn/vx-linux-x64,
@vzn/vx-linux-arm64 and the six plugins: @vzn/vx-ci,
@vzn/vx-lockfile, @vzn/vx-migrate, @vzn/vx-otel,
@vzn/vx-reapi and @vzn/vx-history.
Each platform package carries LICENSE and THIRD_PARTY_NOTICES.txt:
the binary embeds Bun and npm code whose licenses must travel with it.
The GitHub release carries the same file as an asset beside the binaries.
tests/dependency-licenses.unsafe.test.ts holds every published package’s
production dependency closure to permissive licenses (MIT, ISC, BSD,
Apache-2.0, 0BSD, or a dual license offering one).
After a dependency or Bun bump, bun packages/vx/scripts/third-party-notices.ts
regenerates the notices; tests/third-party-notices.unsafe.test.ts
fails until it does.
The plugins ship as the TypeScript source Bun runs, at the release’s
version, with @vzn/vx as a peer on the same minor (^<version>).
scripts/build-npm.ts --only=plugins emits every public workspace
package other than @vzn/vx, so a new plugin package is published
without a workflow edit, and tests/build-npm.unsafe.test.ts holds the
set. If npm will not add a trusted publisher to a name that has never
been published, publish that plugin once by hand from an owner’s
account (npm publish dist/npm-plugins/plugins/<dir> --access public
after a build-npm.ts <version> --only=plugins --out=dist/npm-plugins
build), then add the publisher. Then
delete the NPM_TOKEN repository secret:
the workflow no longer reads it, and npm restricts classic tokens for
direct publishing (the E401 token is invalid that stopped v0.0.17).
Every uses: in both workflows is pinned to a commit SHA with the
version in a comment; bump the SHA and the comment together.
The workspace root
Section titled “The workspace root”Every verb works on one workspace: the directory whose .vx holds the
nearest .vx/workspace.ts (or .mts, .js, .mjs, .cts, .cjs) from
the working directory up. vx never looks above the git top-level (a
worktree is its own, a submodule’s too; a submodule, its .git a file
or a directory, looks on into its superproject), and
outside git it looks up to /. Nothing else marks the root: not a
package.json, pnpm-workspace.yaml or a lockfile (2026-10-13).
Any entry by one of those names marks the root: one that is a broken
link or a directory, with no file beside it, is refused by name
(<path>: is a broken link — fix or remove it), not walked past, as
vx lock counts it a nested workspace. A workspace file a sparse checkout
left out (a cone checkout keeps the root’s files, not its .vx) is
refused as such, VX_E_NO_WORKSPACE with the fix git sparse-checkout add .vx, by every verb, vx init too: a new one would replace the tracked
one. Git is asked only once no file was found. Without one, every verb but vx init stops with VX_E_NO_WORKSPACE;
vx init writes one (@vzn/vx-migrate beside a turbo.json,
nx.json, exported Nx graph or Lerna on Nx). A project inside the
root is a directory holding a vx.config.*, which vx lock finds
(see vx lock); a discover plugin adds others.
Which vx runs
Section titled “Which vx runs”A global vx (installed with a package manager, or a compiled binary
installed by hand) hands every call to the project’s own @vzn/vx when
there is one, so a project runs the version its lockfile pins
(2026-10-13). There is no opt-out. The workspace root’s
node_modules/@vzn/vx runs, through its
node_modules/.bin/vx. One hop: the receiving vx sees VX_HANDED_OFF
and does not look again. A vx never hands off to itself (the same
package, a pnpm symlink included), nor to a .bin/vx another user owns
(one planted above your checkout would run as you). An npm install’s launcher does the
walk in the shell, a test per level and no fork, and execs the local
vx; the compiled binary checks one path per level before it loads
anything, then runs the local vx with its stdio, passes on a signal sent
to it alone, and exits with its code. From source (bun src/bin.ts) vx
never hands off.
vx --version says when it was handed the call (vx 0.0.640 (/repo/node_modules/@vzn/vx-linux-x64/vx, handed off from /usr/local/bin/vx)), and vx info shows the file that ran and who handed off
(vxPath, handedOffFrom).
Machine-readable output
Section titled “Machine-readable output”vx show, vx info, vx why, vx last and vx cache clean take
--format json. Each prints one JSON document on one line, whose shape
is a checked-in JSON Schema (draft 2020-12) shipped with the package:
schemas/show.json, schemas/info.json, schemas/why.json,
schemas/last.json, schemas/cache.json
(node_modules/@vzn/vx/schemas/ in an install). Every object closes its
key set, so a field vx adds is a schema change, never a surprise.
tests/cli-json-schemas.test.ts holds each verb’s output to its schema,
each declared field to some output, and each object’s keys to the
source type. tests/cli-json-doc.test.ts holds this page to the
schemas: each { … } a verb’s section shows is an object its schema
closes with those keys, each object the verb prints whole is shown, and
vx info’s field list is its schema’s top level.
A run’s result as JSON is vx last --format json, read after the run
from the run history: one row per task with its status, key and cache
result. vx run --dry=json prints the plan.
Error codes
Section titled “Error codes”A verb asked for JSON (--format json, --format=json, --dry=json,
before any --) answers a refusal on stdout too, as one line that
schemas/error.json describes: {"ok":false,"error":{"code","message","docs"}}.
docs links the code’s section below, which says what to do;
vx docs <code> prints that section. The exit code (1) and the
prose on stderr do not change. Branch on the code; the message may change.
VX_E_USAGE
Section titled “VX_E_USAGE”A flag, argument, selection or VX_* environment value the verb does
not take. The message names it, and a close spelling the verb does take when there is one;
vx <verb> --help lists every flag.
VX_E_UNKNOWN_COMMAND
Section titled “VX_E_UNKNOWN_COMMAND”No verb of that name here, core’s or a declared plugin’s. vx --help
lists core’s verbs. A plugin’s verb exists once its plugin is in
.vx/workspace.ts plugins; a task runs as vx run <task>.
VX_E_UNKNOWN_TASK
Section titled “VX_E_UNKNOWN_TASK”No project in scope declares a requested task. The message lists the
tasks it found; vx show --format json lists each project’s. Check the
spelling, and the scope: --filter, --all, or the project the working
directory is in. A run reads its configs from the lock, so a task
added to a vx.config since the last vx lock is not there yet: run
vx lock.
VX_E_UNKNOWN_PROJECT
Section titled “VX_E_UNKNOWN_PROJECT”vx show names a project the workspace does not have. A project is named
by its config’s name, else by a plugin (a lockfile plugin names it from
its package.json), else by its directory; vx show --format json lists them.
VX_E_NO_HISTORY
Section titled “VX_E_NO_HISTORY”vx why / vx last find no recorded run for what they were asked, or
vx last --live no run in progress. Run the task once (vx run <task>);
history is this checkout’s own. Where nothing has run yet and no project
declares the task, vx why says so and points at vx show instead.
VX_E_AMBIGUOUS
Section titled “VX_E_AMBIGUOUS”vx why <task> matches the task in several projects. Name one:
vx why <project>#<task>. A run id that is the start of several
recorded runs (vx last <id>, --run <id>) is refused the same way:
type more of it.
VX_E_CONFIG
Section titled “VX_E_CONFIG”A vx.config.* or .vx/workspace.* that does not load, throws or is
refused. The message names the file and the field, or what the config
(or a plugin it called, such as a factory refusing an option) threw and
the line it threw at, then each cause it wraps (; caused by: …);
fix that field (vx docs <field> prints its reference). vx lock
names every project config that does not load at once. A project
config’s refusal is vx lock’s, not a run’s: a run never evaluates one.
VX_E_CYCLE
Section titled “VX_E_CYCLE”The task graph has a cycle. The message names the loop; drop one of its
dependsOn edges.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
VX_E_LOCK
Section titled “VX_E_LOCK”A run reads its configs only from .vx/projects.lock.json, and that
lock is missing, not one this vx reads (not JSON, another version, a
hand-edited entry), holds no entry for a project the workspace has, has
not seen a vx.config written since, or locks one that is gone. Or a
plugin claims a lockfile (bun(), pnpm(), …) and
.vx/dependencies.lock.json is missing, not one this vx reads, does not
record that lockfile, or records bytes it no longer holds: it changed
since vx lock (an install).
Run vx lock, then the verb again. Commit the lock; vx lock --check
on CI fails one that no longer matches the configs.
VX_E_NO_WORKSPACE
Section titled “VX_E_NO_WORKSPACE”No .vx/workspace.* in the working directory or a parent, up to the git
top-level (a file above the checkout never counts). The file marks the
workspace root, so run vx init to create one. Outside git, one another
user owns does not count (security.md); the message names it. One a
sparse checkout left out names git sparse-checkout add .vx instead, and
vx init refuses rather than write a second one.
VX_E_CACHE_VERSION
Section titled “VX_E_CACHE_VERSION”A reading verb met a cache index from another vx version. It reads
nothing from it and leaves it be; the next vx run resets it. Or run the
vx version that wrote it.
VX_E_REFUSED
Section titled “VX_E_REFUSED”Any other refusal (a plugin’s, a check’s) with no code of its own. The
message is that plugin’s or check’s: do what it says.
vx info --format json lists the plugins.
VX_E_FS
Section titled “VX_E_FS”The file system refused a read or a write, or the cache index could not
be opened: permissions, a full disk, a path longer than it allows
(ENAMETOOLONG, an output past PATH_MAX), a file where the cache directory
goes, a directory where cache.db goes. The message names the path. Free
space (vx cache clean --max-size <size> trims the cache), grant the
access, shorten the path, or move it away, then re-run.
VX_E_FDS
Section titled “VX_E_FDS”Out of file descriptors. Raise the limit (ulimit -n 4096), or run fewer
tasks at once (--concurrency), then re-run.
VX_E_AFFECTED_BASE
Section titled “VX_E_AFFECTED_BASE”--affected (or --affected=<ref>) has no base to diff against: the
ref does not resolve, is a range (A..B) or an exclusion (^A), names
a path inside a commit, or the repository has no history to compare
with. Pass a branch or commit you have locally, alone:
--affected=origin/main. A shallow clone fetches its base from origin
itself; when origin cannot give it, fetch it (git fetch --depth=1 origin <base>),
and with no origin remote, from its URL (git fetch --depth=1 <url> <base>) or
add one (git remote add origin <url>).
VX_E_NO_GIT
Section titled “VX_E_NO_GIT”vx needs git and found none: git is not on PATH, or the workspace is
not inside a git work tree. The message says which. Install git, or run
git init in the workspace root, then re-run.
VX_E_GIT
Section titled “VX_E_GIT”A git command vx runs to read the diff or list a run’s inputs failed; the message carries git’s exit and its own words. Run that command in the workspace to see why (a broken index, a missing object in a partial clone).
VX_E_NETWORK
Section titled “VX_E_NETWORK”vx docs could not read its pages from GitHub: no network, a proxy that
refuses, or GitHub’s limit on anonymous listings. The message carries
what failed. Retry with a network, set GITHUB_TOKEN or GH_TOKEN to
lift the limit, or read the same pages on the site,
https://vznjs.github.io/vx/.
VX_E_DEPENDS_ON
Section titled “VX_E_DEPENDS_ON”A task’s dependsOn names a task or project that is not declared, or uses
a form vx does not take (a bare wildcard, a negation, a pattern in
pkg#task). The message names the task and the entry: declare the
target, fix its spelling, or drop the entry.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
A ^task with no edge source is refused too: <id>: ^build needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns `deps` . A lockfile plugin
whose lockfile is absent gives no edges. Core reads no edge from a manifest, so with no plugin
returning deps every ^ would resolve to nothing and fold no
upstream into the key. Declare the lockfile plugin in
.vx/workspace.ts (or a plugin of your own that returns deps); a run
refuses it as well when the plugin left the workspace after the lock.
VX_E_OUTPUT_OVERLAP
Section titled “VX_E_OUTPUT_OVERLAP”Two tasks declare overlapping outputs with no dependsOn edge between
them, so whichever runs second deletes the other’s files. Give each task
its own output path, or make one depend on the other.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
VX_E_INPUT_IS_OUTPUT
Section titled “VX_E_INPUT_IS_OUTPUT”A task’s inputs match outputs: its own (vx cleans outputs before
a run, so it would delete its sources; files and workspaceFiles are
compared across, read from the root) or another task’s (its key must
not read them; dependsOn already cascades). Exclude the outputs from
the inputs with a ! glob, as the message shows, or declare no outputs
for a task that rewrites files in place.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
A run refuses the one shape the lock cannot see: an output directory
that is a symbolic link into a directory the task’s own inputs can match
below (dist -> src under src/** or **/*.ts). Remove the link.
VX_E_NESTED_INPUT
Section titled “VX_E_NESTED_INPUT”A task’s inputs.files entry is rooted inside a project nested in its
own (dist/** where dist/ holds a config): a project’s globs never
reach another project’s files, so it would key nothing. Depend on a
task of the nested project, or name the files in
inputs.workspaceFiles, as the message shows. A glob rooted in a nested
workspace (dist/ holding its own .vx/workspace.*) is not refused: it
keys that workspace’s files.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
VX_E_NESTED_OUTPUT
Section titled “VX_E_NESTED_OUTPUT”A task’s outputs.files entry is rooted inside a project nested in its
own (sub/dist/** where sub/ holds a config): a project’s globs never
reach another project’s files, so it would save nothing. Let a task of
the nested project write the files, or name them in
outputs.workspaceFiles, as the message shows. A nested workspace
(sub/ holding its own .vx/workspace.*) is refused the same way, though
an input glob may reach into one.
vx lock refuses it, not a run: a run trusts the graph the lock holds.
VX_E_DEPENDENCY_RULE
Section titled “VX_E_DEPENDENCY_RULE”vx lock found a project depending on one a run.rules.dependencies
rule denies or does not allow. A header counts the broken edges and
each indented line under it names both projects and the rule. Drop the dependency, or change the project’s tags or the rule.
VX_E_INTERNAL
Section titled “VX_E_INTERNAL”A defect in vx: stderr carries the stack, then each cause under it as
a Caused by: line with its stack. (A config’s own throw is
VX_E_CONFIG; a plugin hook’s is VX_E_REFUSED or the plugin’s code.)
Report it with that stack at
https://github.com/vznjs/vx/issues.
A plugin can give its refusal a code: new UserError(message, 'MY_CODE').
The code holds whether a hook, setup or a verb of the plugin throws it.
Streams. A verb whose stdout is a product (a --format json
document, --dry / --dry=json, a completion
script) writes that product alone there; a notice or warning it meets
on the way (a cache index from another vx version, a plugin’s warning) goes
to stderr, as every refusal does. vx run‘s stdout is the run’s frame,
the tasks’ output it carries. tests/cli-streams.test.ts holds each
product verb to it under a notice.
Remote cache (plugin-driven)
Section titled “Remote cache (plugin-driven)”Core ships no remote-cache wire client — the remote cache is a
plugin concern. A cache plugin returns a wire client (a
RemoteCacheLayer) and core layers it over the local cache;
@vzn/vx-reapi provides one for any Bazel REAPI server
(ActionCache + CAS).
Reads try local first, then remote (hydrating local on remote hit), with a background prefetch pass overlapping remote GETs with execution. Writes go to local immediately; the remote upload is a fire-and-forget background task drained at end of run — failures are warned but never fail the build.
For any OTHER cache server (a Turbo-wire deployment, S3-direct, …),
implement core’s RemoteCacheLayer interface in a plugin’s cache
capability — the recipe lives in the plugins guide. Embedders
holding a wire client can inject it per-run via
RunOptions.remoteCache (explicit injection wins over the plugin
consult).
Run analytics
Section titled “Run analytics”vx info surfaces the aggregate cache stats (entry count, total
size, runs + hits in the last 24 h). For anything deeper, vx records
every task to a runs table in cache.db (UUIDv7 run_id, hrtime
wallclock spans, cpu_ms, peak RSS, status, cache_hit flag) plus one
invocations header row per run (command, git/CI context,
counts). The SQLite file IS the API:
sqlite3 .vx/cache/cache.db " SELECT project, task, status, duration_ms FROM runs WHERE run_id = (SELECT run_id FROM runs ORDER BY id DESC LIMIT 1) ORDER BY duration_ms DESC;"The schema is documented in
caching.md § SQLite tables.
What’s still missing vs Turbo
Section titled “What’s still missing vs Turbo”Tracked in comparison.md. Nothing visible from the
CLI is open: --output-logs hash-only, --continue=<mode>
and --cache-dir <path> all shipped and are documented above.
Remote-cache credentials are not core CLI flags at all: core carries no
HTTP cache client — a remote cache arrives through a plugin’s cache
capability, which owns its own configuration.
Programmatic API
Section titled “Programmatic API”import { run, planRun, defineProject, defineWorkspace } from '@vzn/vx'
const summary = await run({ cwd: process.cwd(), tasks: ['build', 'test'], concurrency: 4, // Optional 4-axis cache control; omit for everything-on. cache: { localRead: true, localWrite: true, remoteRead: true, remoteWrite: true },})// summary.ok: boolean; summary.outcomes: TaskOutcome[]
const plan = await planRun({ cwd: process.cwd(), tasks: ['build'],})// plan.tasks: PlannedTask[]Surface:
run(options)— execute. ReturnsPromise<RunSummary>.planRun(options)— predict, no execute. ReturnsPromise<RunPlan>. Used by--dry.prepareRun(options, log)— the shared setup (discovery → configs → graph → cache). What an embedder builds on.defineProject/defineWorkspace— identity helpers for type inference in user configs.- Every one of the three returns a type the façade exports, so an
embedder can hold it:
RunOptions/RunSummary/TaskOutcome,RunPlan/PlannedTask,PreparedRun— alongside the plugin (VxPlugin,TaskExecutor,CacheLayer) and telemetry (TelemetrySink,RunSummaryRecord) surfaces. Seesrc/index.ts.
A log: Logger option lets embedders swap the default framed-block
logger for a custom one (e.g. JSON-line emission). Custom loggers
always see plain text (colors are off when a non-default logger is
provided).
The CLI dispatcher (run(argv) in src/cli/index.ts) is not part of
the public package exports; bin.ts calls it directly.