CLI reference
The vx binary is the user-facing entry point. The implementation is
intentionally simple: a hand-rolled argv parser (no commander / yargs)
in src/cli.ts dispatches to per-subcommand handlers under
src/cli/<name>.ts. The flag surface is aligned with
Turborepo’s turbo run
so existing Turbo users can swap in with minimal muscle-memory churn.
# Standalone binary (no Bun required on target):curl -fsSL https://raw.githubusercontent.com/vznjs/vx/main/install.sh | sh
# From source (Bun ≥ 1.3):bun src/bin.ts --versionTop-level shape
Section titled “Top-level shape”vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]vx watch [OPTIONS] TASK [-- forwarded-args...]vx cache prune [--older-than <duration>] [--max-size <bytes>]vx lock [--check]vx migrate [--dry] [--force]vx show [PROJECT[#TASK]] [--format pretty|json]vx infovx stats # deprecated alias of vx infovx helpvx --help, -hvx versionvx --versionMultiple positional tasks run in one orchestrator invocation with a
shared task graph: vx run build lint test fans out all three across
the resolved project scope. Anchored entries (pkg#task) target a
specific project; bare entries follow the usual scope rules
(default = the cwd project; broaden with --all / --filter /
--affected).
(No -V for version; vx --version only — matches Turbo.)
vx run
Section titled “vx run”vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]Run the named task(s). By default only the project containing the
current working directory is selected — dependsOn still expands so
the project’s upstream workspace deps run too. Override with --all,
--filter, --affected, or an explicit pkg#task.
If no task name is given:
- In a TTY — an interactive picker lists every
pkg#taskentry across the workspace, printsdescriptionnext to each, prompts for a number, runs the chosen one. - Not a TTY — exits
1withmissing task name (stdin is not a TTY).
Exit codes:
| Code | When |
|---|---|
0 | Every task finished success or cache-hit (local or remote). |
1 | At least one task ended failed or skipped; or parse/setup error. |
Selection
Section titled “Selection”| Form | Effect |
|---|---|
| (default) | The project that contains cwd. Errors if cwd is not inside a project. |
pkg#task | Just that project. |
--all | Every project that declares the task. |
--filter <pat> (repeatable) | pnpm-style filter DSL (see below). |
--affected[=<base>] | Sugar for --filter '[<base>]' — git-changed projects only. |
Combining: --filter and --affected stack (the affected base is
appended as another filter pattern); --all overrides scope to the
full workspace.
Filter DSL (--filter)
Section titled “Filter DSL (--filter)”The full DSL lives in src/workspace/filter.ts; this is the user-
facing summary.
| Form | Meaning |
|---|---|
<pattern> | Match by package name. * is a wildcard (no /). |
./<dir> | Match packages whose dir is at or under <dir> (relative to workspace root). |
{<dir>} | Same as ./<dir>. |
<pattern>... | Match + all transitive workspace dependencies. |
...<pattern> | Match + all transitive workspace dependents. |
<pattern>^... | Only the transitive dependencies, excluding the matched package itself. |
...^<pattern> | Only the transitive dependents, excluding the matched package itself. |
!<pattern> | Exclude packages matching <pattern>. |
[<git-ref>] | Projects whose files changed since <git-ref> (main, HEAD~5, …). |
Examples:
vx run build --filter @scope/* # all packages under @scopevx run build --filter app... # app and its transitive depsvx run build --filter ...util # util and everything depending on itvx run build --filter app^... # only app's deps (not app)vx run build --filter '*' --filter '!docs' # everything except docsvx run build --filter '[origin/main]' # projects with files changed since main--affected[=<base>]
Section titled “--affected[=<base>]”Run the task only in projects whose files changed since <base>.
--affected(no value) usesorigin/HEAD, falling back toHEAD~1iforigin/HEADisn’t resolvable.--affected=<ref>uses the given git ref.
It’s a pure sugar for --filter '[<base>]'; both are resolved by
src/workspace/affected.ts shelling out to git diff --name-only.
Argument forwarding (--)
Section titled “Argument forwarding (--)”Anything after -- is forwarded (shell-quoted via JSON.stringify)
to the task’s exec.command:
vx run test -- --watch # underlying test runner sees "--watch"vx run build -- --sourcemap # build command gets "--sourcemap"Forwarded args are folded into the cache key — different args produce different cache entries. They scope to user-requested tasks only; dependsOn-pulled deps don’t see them (so upstream cache identity stays clean).
| Flag | Type | Default | Description |
|---|---|---|---|
--filter <pattern> | repeatable | (none) | pnpm-style filter DSL (see above). |
--all | boolean | off | Select every project that declares the task. |
--affected[=<base>] | optional value | off | Filter to projects changed since <base> (default origin/HEAD). |
--excludeDependencies[=<names>] | optional value | off | Drop dependsOn edges. No value = all (just the requested task runs); comma-list = drop only those names. |
--concurrency <n> | positive int | navigator.hardwareConcurrency | Maximum parallel tasks. 1 serializes. |
--no-cache, --force | boolean | off | Skip cache reads AND writes; output globs are NOT cleaned. |
--verbosity <n> | int (0+) | 0 | 1 prints a per-task summary table after the framed blocks; 2+ reserved. |
--dry[=text|json] | optional value | off | Print the task graph + predicted cache hit/miss; skip execution. |
--graph[=<path>] | optional value | off | Emit Graphviz DOT (stdout if no path); skip execution. |
--summarize[=<path>] | optional value | off | Write per-run JSON to <cacheDir>/runs/<run_id>.json (or the explicit path). |
--profile[=<path>] | optional value | off (profile.json when set) | Write Chrome-trace JSON of the run’s wallclock spans. |
Mutual exclusion:
--dryand--graph— both skip execution; pick one.--dryor--graphwith--summarizeor--profile— the latter two need a real run.
Unknown flags are a parse error (unknown flag: --foo).
Output
Section titled “Output”What a run prints is derived from the run’s intent (its “flow”), unless explicitly overridden:
- FOCUSED — no selection flag was passed. The user is running “their” task; cwd and task count are irrelevant to the classification.
- BROAD — the invocation used
--all,--filter, or--affected. The user asked about a swath of the workspace and wants news, not output. - CI — the
CIenv var is truthy (CI=0/CI=falsedon’t count). Wins over the flow.
Per-task visibility by outcome:
| Outcome | focused (requested task) | focused (dependency) | broad | CI / full |
|---|---|---|---|---|
| executed | raw output, streamed live | silent | ● id ── executed • t | frame |
| restored-local/-remote | replayed stdout, streamed | silent | silent | frame, or one-liner if quiet |
| up-to-date | one-liner (nothing to show) | silent | silent | one-liner |
| failed | raw output, streamed live | full frame | full frame | frame |
| skipped | frame | silent | silent | frame |
The header and end-of-run summary always print; cache-hit counts that
broad mode silences per-task surface there. A focused vx run test
is meant to feel like running the test command directly — same
output, just faster.
Live streaming applies only when there is exactly one requested
task. Live open/close framing assumes a single task owns the terminal
between its open (┌─) and close (└─) lines — with two requested
tasks running concurrently their frames would interleave into garbage.
So when more than one task is requested (vx run build test), each
requested task instead buffers its output and renders as a single
atomic block at completion (success/failure/cache-hit-with-replay get
a full frame, up-to-date/skipped get a one-liner). The blocks are
blank-line separated and never interleave. A single vx run test
keeps the live-stream experience unchanged.
On an interactive terminal (TTY stdout, not CI) a status region tracks the run live. Top to bottom:
- Pinned failures —
✗ <id> ── failed (exit N), one per failed task, accumulating as failures happen and staying until run end so they can never scroll out of sight. Capped at 5 lines plus a dim… +K more failed. - Pinned persistent tasks —
▸ <id> ── runningfor every persistent task that became ready. The pin lives until run end (vx SIGTERMs persistent children when the graph finishes), so it is the visible evidence the dev server is still alive. - Worker rows — one per worker (sized from concurrency, capped
at 10 — the header states the pool as
(N tasks, C workers)), each showing a spinner, the running task’s identity-colored id, and its elapsed time. A task stays in its row for its whole life and idle rows hold their place dimmed, so nothing ever jumps. - Stats line — every bucket in fixed order:
▶ 1 failed · 78 success · 759 left · 1090 total │ 79 miss · 252 up-to-date · 0 local · 0 remote │ 00:16(red failed/miss, green success/up-to-date, yellow left/local, cyan
total/remote; +k more appears when more tasks run than rows). The
region is redrawn in place (cursor-up + clear; not a TUI — no
alternate screen) and erased before the summary prints. In the
focused flow it only lives while dependencies run; it disappears for
good the moment the requested task starts streaming.
Redraw cost is bounded: task events force a redraw, but forced redraws within 30 ms of the last draw coalesce into a single trailing draw when the floor expires (the final state always lands). On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.
Identity coloring: every project#task renders its project half in
a stable hue hashed from the project name (same project = same color
in every run and every surface) and its task half in a fixed pink —
both deliberately outside the status palette, so an id can never
read as an outcome.
On GitHub Actions (GITHUB_ACTIONS truthy, full output mode), each
task’s block is wrapped in ::group::<id> (<outcome> <duration>) /
::endgroup:: so it collapses in the log viewer. Failed tasks stay
pre-expanded and emit an ::error title=<id>::failed (exit N)
annotation instead.
--output-logs <mode>
Section titled “--output-logs <mode>”Explicit override; always beats the flow and CI defaults. full
(frames for executed work, one-liners for quiet cache hits),
errors-only (only failed tasks print; the CI noise budget), none
(no per-task output). The header and end-of-run summary always print.
Planning mode (--dry, --graph)
Section titled “Planning mode (--dry, --graph)”Both flags short-circuit execution. They build the full task graph, compute every task’s cache key, and probe the cache to predict the hit/miss outcome.
$ vx run ci --drywould run: ◉ @vzn/vx#format-check cache hit (local) 02bfe8a9 ◉ @vzn/vx#lint cache hit (local) d66cfed2 ▶ @vzn/vx#test cache miss — would exec 68595e49
3 task(s) planned, 2 cache hits (2 local), 1 would run.Status legend:
| Symbol | Meaning |
|---|---|
◉ | cache hit (local) — entry already in <cacheDir>/ |
↓ | cache hit (remote) — entry would be fetched from the layer |
▶ | cache miss — task would execute |
· | no-cache — task opts out (no cache block, or --no-cache) |
○ | group task (suppressed in human view; in DOT + JSON) |
--dry=json emits the same data as a structured object:
{ "tasks": [ { "id": "@vzn/vx#lint", "project": "@vzn/vx", "task": "lint", "description": "oxlint with tsgolint-backed type-aware checks", "hash": "d66cfed2...", "cacheStatus": "hit-local", "deps": [] } ]}--graph prints Graphviz DOT (stdout by default; --graph=path
writes a file):
vx run ci --graph | dot -Tsvg > graph.svgvx run ci --graph=graph.dotNode fillcolor varies by predicted status (green = local hit,
sky-blue = remote hit, orange = miss, gray = no-cache, fuchsia =
group). Edges are unstyled.
Run artifacts (--summarize, --profile)
Section titled “Run artifacts (--summarize, --profile)”Both flags add a side-effect after a real run completes. Errors
writing the artifact are surfaced via vx: failed to write … but
don’t change the run’s exit code — the run already happened.
--summarize[=<path>]
Section titled “--summarize[=<path>]”Writes a per-run JSON file:
{ "runId": "01HKQ...", "startedAt": "2026-05-13T22:00:00.123Z", "endedAt": "2026-05-13T22:00:05.567Z", "totalMs": 5443.7, "tasks": [ { "id": "@vzn/vx#lint", "project": "@vzn/vx", "task": "lint", "status": "cache-hit", "exitCode": 0, "durationMs": 4, "hash": "...", "cpuMs": 123, "peakRssBytes": 45678, "wallclockStartNs": "12345678", "wallclockEndNs": "12356789" } ], "summary": { "successful": 3, "failed": 0, "skipped": 0, "cachedLocal": 2, "cachedRemote": 0, "total": 3 }}Default path: <cacheDir>/runs/<run_id>.json. hrtime fields are
strings (bigints serialized as strings) to preserve ns precision
through JSON.
--profile[=<path>]
Section titled “--profile[=<path>]”Writes a Chrome-trace JSON of every task’s wallclock span. Open in
chrome://tracing or https://ui.perfetto.dev.
{ "traceEvents": [ { "name": "@vzn/vx#lint", "cat": "cache-hit", "ph": "X", "ts": 12345, "dur": 4321, "pid": 1, "tid": 1, "args": { "exitCode": 0, "hash": "...", "cpuMs": 123, "peakRssBytes": 45678 } } ]}Each project gets a distinct tid so concurrent tasks across packages
render on separate lanes. ts and dur are microseconds derived
from the per-task hrtime.bigint() spans the runner captures.
cat carries the task’s final status (success, cache-hit,
cache-hit-remote, failed).
Default path: profile.json (cwd-relative).
Sandbox
Section titled “Sandbox”Sandbox isolation is opt-in per task via a sandbox: {} block in
the task’s config — there is no --sandbox CLI flag. See
modules/sandbox-runtime.md for the
full reference.
export default { tasks: { build: { exec: { command: 'tsc' }, cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }, sandbox: { allowRead: ['../../node_modules'], // workspace-root node_modules allowWrite: ['/tmp'], }, }, },}Policy: fail on violation. The sandbox enforces declared inputs
at the kernel level; any task that tries to read a path it didn’t
declare either fails naturally (Linux: ENOENT from bwrap’s
mount-namespace hide) or is flagged via the macOS violation store
and forced to exit non-zero. No cache is written for a failed task.
vx run lazily initialises the sandbox runtime only when at least
one task in the graph declares sandbox: {}. If runtime deps are
missing (bwrap on Linux, sandbox-exec on macOS) or the platform is
unsupported, the orchestrator errors out with a clear message before
any task runs.
vx watch
Section titled “vx watch”vx watch [OPTIONS] TASK [-- forwarded-args...]Run the named task, then re-run it on every filesystem change in the
projects in scope. Press Ctrl+C to stop.
vx watch test # cwd project; re-test on changesvx watch test --all # every project that declares `test`vx watch lint --filter '@scope/*' # filtered scopevx watch build -- --sourcemap # forwarded args carry through every cycleLifecycle
Section titled “Lifecycle”- Initial run. Same code path as
vx run— same scope resolution, same task graph, same cache behaviour. The linevx watch: initial run...precedes it. - Watch loop. After the initial run finishes, every project’s
directory in scope is watched recursively. The workspace root is
watched (non-recursively) for lockfile /
pnpm-workspace.yamlchanges. - On change. The triggering path is logged
(
vx watch: <project> <relpath>; re-running...) and the orchestrator is invoked again with the same options. Events arriving while a run is in flight queue and drain after the current cycle. Re-runs are debounced ~150ms after the last event. - Exit.
SIGINT(Ctrl+C) printsvx watch: stoppedand exits 0.
Path filtering
Section titled “Path filtering”Always ignored (no re-trigger):
node_modules/,.git/,.vx/anywhere in the path.- Files ending in
.tsbuildinfoor~(editor swap files).
Everything else triggers a cycle. We deliberately don’t filter events
against per-task cache.inputs.files — the cache hash is the source
of truth. A change to an irrelevant file produces a cache-hit run
(typically tens of ms); the cost is much smaller than the engineering
cost of a per-event glob match.
Workspace fingerprint changes
Section titled “Workspace fingerprint changes”Edits to a lockfile (pnpm-lock.yaml, bun.lock, …) or
pnpm-workspace.yaml at the root invalidate every task’s cache key
via the workspace fingerprint.
Watch mode hears those because it watches the workspace root
(non-recursively).
Constraints
Section titled “Constraints”The following flags are rejected (parser exits 1 before the initial run):
--dry/--graph— those skip execution; nothing to watch.--summarize/--profile— would overwrite their target per cycle.
Persistent tasks (exec.persistent) re-spawn each cycle: the previous
SIGTERM happens between cycles, then the next cycle launches a fresh
child. For dev-server workflows where you want the server to stay up
across changes, use the dev tool’s own watch (vite, tsc -b -w,
bun --watch) rather than vx watch.
Exit codes
Section titled “Exit codes”0— clean Ctrl+C / SIGTERM exit.1— parser error or missing scope.
Re-run cycles whose orchestrator returns { ok: false } do NOT exit
the watch loop — a failed cycle just prints the framed FAILED block
and waits for the next change. This matches turbo watch / nx watch.
vx cache prune
Section titled “vx cache prune”Evict old or oversized cache entries. Operates on
<cacheDir>/cache.db plus the on-disk <hash>/ directories (each one
carrying its own outputs/, stdout, and stderr).
vx cache prune --older-than <duration> # Drop entries last accessed before now - duration.vx cache prune --max-size <size> # After age-based pruning, evict LRU until under <size>.At least one of --older-than / --max-size is required. Both may
be combined: age-based eviction runs first, then LRU eviction if the
total is still over the size cap.
Duration units: s, m, h, d. Examples: 30d, 24h, 60m, 30s.
Size units: K, M, G, T (powers of 1024). Optional B
suffix is accepted. Examples: 500M, 1G, 100K, 2T, 500MB.
$ vx cache prune --older-than 30dPruned 42 entries (1.3 GB freed)
$ vx cache prune --older-than 7d --max-size 500MPruned 18 entries (320.1 MB freed)Exit codes:
0— pruning completed (zero or more entries evicted).1— parse error, missing policy, or workspace-discovery error.
vx cache prune resolves the cache directory via
src/workspace/workspace.ts:findWorkspaceRoot from cwd. Note: the
prune command currently always uses the default .vx/cache/; a
workspace-config cacheDir override is not yet honored by this path
(tracked).
--frozen (run flag)
Section titled “--frozen (run flag)”vx run ... --frozen loads configs from the committed vx-lock.json
instead of evaluating them — CI reproducibility mode. Plain vx run
always evaluates live (a byte hash can’t see a config’s import
closure, so silently consuming the lock locally would risk stale
freezes). --frozen errors only when no lock exists or a project
is missing from it — it performs NO staleness checks of its own:
run vx lock --check first in the pipeline; that audit re-evaluates
everything, making any per-run re-check redundant.
vx lock
Section titled “vx lock”Freeze every project’s resolved config into vx-lock.json at the
workspace root. Configs are programs; vx lock evaluates them in the
current environment and stores the post-evaluation objects plus a
content hash of each config file.
vx lock # Evaluate all vx.config.* now; write vx-lock.json.vx lock --check # Audit: hash checks + full re-evaluation vs the lock. Exit 1 on drift.Plain runs ALWAYS evaluate live — the lock’s existence changes
nothing. Only vx run --frozen consumes it: configs come from the
lock with no evaluation and no staleness checks of its own (frozen-env
semantics: env reads in a config keep their lock-time values; a
project absent from the lock or a missing lock is a hard error).
--check is the audit: it reports changed config files via the
stored hashes AND re-evaluates every config in the current
environment, Bun.deepEquals-comparing against the frozen objects —
catching eval-time env and import-closure drift that byte hashes
cannot see. The CI recipe is vx lock --check && vx run … --frozen.
Full design: docs/design/config-lock-2026-06.md.
Exit codes:
0— lock written / lock is up to date.1— parse error, workspace-discovery error, missing lock (--checkwithout one), or any drift (every mismatched project is listed on stderr).
vx upgrade
Section titled “vx upgrade”Self-update the compiled binary in place: downloads the release asset
for this platform and atomically replaces the running executable
(vx upgrade <tag> pins a specific release; default latest). Named
upgrade per CLI convention (bun upgrade, deno upgrade). Refuses
when running from source — use git pull. Re-running install.sh
remains equivalent.
vx migrate
Section titled “vx migrate”Generate one vx.config.ts per workspace package from an existing
Turbo or Nx setup. The source is auto-detected at the workspace root:
turbo.json→ Turbo path. Reads the root pipeline (tasksin turbo 2,pipelinein turbo 1), per-packageturbo.jsonextendsoverlays (per-key merge over the root task), and each package’spackage.jsonscripts. A task is emitted for a package only when the package declares the matching script (turbo semantics); the script body is inlined asexec.command..nx/workspace-data/project-graph.json→ Nx path. Migrates from the resolved graph snapshot ONLY — plugin-inferred targets are frozen as static config (noted in the report header). Whennx.jsonexists but the graph file is missing, the error tells you to run any nx command once (ornx graph --file=.nx/workspace-data/project-graph.json).- Both present → pass
--from turboor--from nxto disambiguate from.
vx migrate # write vx.config.ts files (and vx-preset.ts when needed)vx migrate --dry # print the generated file contents instead of writingvx migrate --force # overwrite existing vx.config.* / vx-preset.tsExisting vx.config.* files are never overwritten without
--force — conflicts abort the whole run before anything is written.
Mapping highlights:
- Turbo:
dependsOncopies verbatim (same micro-syntax);inputs→cache.inputs.files($TURBO_DEFAULT$expands to'**/*'in place,!negation passes through);outputs→cache.outputs.files(vx outputs have no negation — negated entries become TODOs);env→cache.inputs.envANDexec.env.passThrough(vx child envs are isolated, so a hashed env var must also be forwarded);passThroughEnv→ passThrough only;cache: falseomits the cache block;persistent: true→exec.persistent: {}plus a TODO suggestingreadyWhen.globalEnv/globalPassThroughEnv/globalDependenciesbecome exported arrays in a generated rootvx-preset.tsthat each config imports and spreads — TypeScript composition replaces turbo’s global fields (globalInputsspreads intocache.inputs.workspaceFiles: globalDependencies are root-relative by definition).$TURBO_ROOT$/<path>inputs map tocache.inputs.workspaceFiles(negation keeps!), outputs tocache.outputs.workspaceFiles;$TURBO_ROOT$independsOn(and non-prefix forms) stays a TODO — vx has no workspace-root tasks. - Nx:
nx:run-commandsjoinscommandswith' && '(acwddiffering from the project root is a TODO);nx:run-scriptinlines the package.json script body; any other executor emits a valid placeholder command (echo 'TODO(vx-migrate): fill in' && exit 1) with a TODO carrying the executor + its options JSON. Inputs strip{projectRoot}/, map{workspaceRoot}/<path>tocache.inputs.workspaceFiles(negation keeps!), expand named inputs fromnx.json, route{env: X}tocache.inputs.env+ passThrough, and TODO the rest (^deps-inputs,externalDependencies,dependentTasksOutputFiles— vx folds upstream viadependsOnalready). Outputs strip{projectRoot}/, map{workspaceRoot}/<path>tocache.outputs.workspaceFiles, resolve literal{options.x}tokens, and append/**to bare directory paths.dependsOnobjects mapprojects: 'dependencies'→'^target','self'/absent →'target', project lists →'proj#target'. The graph’s dependency edges are ignored (vx derives package edges from manifests); edges with no manifest counterpart produce one report line (“N implicit Nx deps not representable”).
Everything unmappable becomes a // TODO(vx-migrate): … comment in
the generated file — TODOs are always comments, never values, so
every generated config loads and validates as-is. The run ends with a
report: tasks migrated clean, TODO count with project#task: reason
lines, and the files written.
Exit codes: 0 success (TODOs don’t fail the run); 1 parse error,
detection error, or overwrite conflict without --force.
vx show
Section titled “vx show”Introspect the workspace’s live resolved configs — what a run
would see right now. Configs are evaluated with the same loader the
run path uses; vx show never reads vx-lock.json (the lock is
already the frozen JSON — open it directly if you want the frozen
view).
vx show # list every projectvx show <project> # one project's resolved configvx show <pkg>#<task> # a single taskvx show ... --format json # machine-readable (default: pretty)No target: one line per project — name, root-relative dir, declared
task count, and a (no vx config) marker for config-less packages.
With --format json it’s an array of { name, dir, tasks: string[] }.
$ vx showapp packages/app 3 tasksbare packages/bare (no vx config)vx show <project> prints a block per task: description, command
((group) for group tasks), dependsOn, cache.inputs.files /
.env / .tasks, cache.outputs.files, and persistent fields.
--format json emits { name, dir, config } with the config exactly
as resolved. vx show <pkg>#<task> narrows to one task
({ name, dir, task, config } in JSON).
$ vx show app#buildapp — packages/app
build description: compile the app command: tsc -b dependsOn: ^build inputs.files: src/** inputs.env: NODE_ENV outputs.files: dist/**Unknown project / task names exit 1 with includes-match suggestions
(unknown project: "ap" — did you mean app?).
Exit codes: 0 success; 1 parse error or unknown target.
vx info
Section titled “vx info”Workspace doctor — one screen of facts for bug reports and sanity checks (pretty only):
$ vx infovx: 0.0.0bun: 1.3.14git: 2.53.0workspace root: /work/repoprojects: 12 (34 tasks)cache dir: /work/repo/.vx/cachecache entries: 42 (1.3 GB)runs (24h): 7 (5 cache hits)vx-lock.json: yesremote cache: nogitshows(not found)when the binary is missing; a broken project config contributes zero tasks instead of failing the printout.remote cacheisyeswhen bothVX_REMOTE_CACHE_URLandVX_REMOTE_CACHE_TOKENare set.vx statsis a deprecated alias ofvx info(info absorbed it); it prints byte-identical output.
Output format
Section titled “Output format”vx run emits framed blocks. Stdout/stderr from each task is
buffered until completion, then dumped inside the block — so
concurrent tasks never interleave their lines.
Frame anatomy:
┌─ <id> > <outcome header> restored-local • abc12345 / failed (exit N) / …├─ command only for executed tasks (success or failed)<the command, raw>├─ stdout only when non-empty<stdout lines, raw>├─ stderr only when non-empty<stderr lines, raw>├─ sandbox violations (N) when the sandbox recorded violations<violation lines, raw>└─ <id> ── (<duration>) <outcome word>Section headers (├─ …) and frame corners render dim; the id keeps
its identity coloring. Content lines are raw — no left border, no
indent — so long lines wrap without colliding with frame glyphs and
copy/paste yields the verbatim output. Every block (and every live
frame close in focused flow) is followed by a blank line so frames
never collide with the next one-liner.
• vx 0.0.0
• Running ci in 1 package (3 tasks) • Remote caching disabled
◌ @vzn/vx#format-check ── restored-local • 02bfe8a9
┌─ @vzn/vx#lint > restored-local • d66cfed2├─ stdoutFound 0 warnings and 0 errors.└─ @vzn/vx#lint ── (4ms) restored-local
┌─ @vzn/vx#test > success├─ commandbun test├─ stdout... test output ...└─ @vzn/vx#test ── (5.20s) success
tasks <stacked 50-cell meter> 3 success cache <stacked 50-cell meter> 1 miss · 2 local time 5.34sGroup tasks emit no framed block by design (they aren’t real tasks).
Colors
Section titled “Colors”ANSI truecolor (ansi-16m) sequences, gated by env:
| Var | Effect |
|---|---|
NO_COLOR=… | Force off. Overrides FORCE_COLOR. |
FORCE_COLOR=… | Force on. |
| (neither) | On iff stdout.isTTY. |
Programmatic callers passing a custom log to the run options always
see plain text.
Remote cache (env-driven)
Section titled “Remote cache (env-driven)”If VX_REMOTE_CACHE_URL and VX_REMOTE_CACHE_TOKEN are set in the
environment, vx run layers a remote cache on top of the local one.
Reads try local first, then remote (hydrating local on remote hit).
Writes go to local immediately, then upload to remote in the
background — failures are logged via onRemoteError but do not
fail the user’s build.
| Env var | Required? | Notes |
|---|---|---|
VX_REMOTE_CACHE_URL | yes | Base URL, e.g. https://cache.example.com. |
VX_REMOTE_CACHE_TOKEN | yes | Bearer token sent on every request. |
VX_REMOTE_CACHE_TEAM_ID | no | Sent as ?teamId= (Turbo tenancy). |
VX_REMOTE_CACHE_SLUG | no | Sent as ?slug=. |
VX_REMOTE_CACHE_TIMEOUT_MS | no | Per-request timeout. Default 60000. |
VX_REMOTE_CACHE_SIGNATURE_KEY | no | HMAC artifact signing (Turbo-compatible x-artifact-tag). When set, uploads are signed and downloads are verified — a missing or mismatched tag degrades to a cache miss. |
Wire spec is Turborepo /v8/artifacts/. Compatible servers include
ducktors/turborepo-remote-cache, Fox32/openturbo-remote-cache, and
Vercel’s hosted Turbo cache. See
design/remote-cache.md for the full
protocol.
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 — ULID
run_id, hrtime wallclock spans, cpu_ms, peak RSS, status, cache_hit
flag, bytes_uploaded / _downloaded for the remote layer. 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 detail in comparison.md. Recap of the
gaps visible from the CLI:
--continue=<mode>(current behavior: independent siblings continue; dependents are skipped).--output-logs full|errors-only|hash-only|none.--cache-dir <path>CLI flag (workspace-config field works; CLI flag doesn’t).--remote-cache-timeout,--token,--teamon the CLI (env vars work).vx prune(workspace subset for Docker builds).
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, noCache: false,})// 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/--graph.defineProject/defineWorkspace— identity helpers for type inference in user configs.RunOptions/RunSummary/RunPlan/TaskOutcometypes are re-exported from@vzn/vx.
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.ts) is not part of the
public package exports yet; bin.ts calls it directly.