CLI reference
Every subcommand, every argument, every flag, and every exit code.
Sections appear in the order the commands appear in lattice --help. For the
reasoning behind a flag rather than its definition, see Selecting what
runs, Caching, Engines and
provisioning, and Upgrading.
Bare lattice
lattice with no subcommand prints the same splash as lattice version, then a
line pointing at --help, then exits 0. There is no “missing subcommand”
error.
lattice
The splash is the ASCII rosette, then a version line and the tagline. Without the rosette art, those two lines are:
❖ lattice 1.1.1 (aarch64)
A high-performance, local toolchain for managing monorepos.
Then a blank line and:
Run lattice --help to see available commands.
lattice run
lattice run [OPTIONS] <TASKS>...
Runs one or more tasks across your workspaces, in dependency order. Lattice
builds each task’s dependency graph from the tasks map in lattice.json, then
runs that graph in dependency order. Naming several tasks at once merges them
into one graph, so a dependency they share runs once.
Every name in <TASKS> must be a key of the tasks map. Lattice checks all of
them before it builds a graph, provisions a toolchain, or spawns a process.
Arguments
| Argument | Description |
|---|---|
<TASKS>... |
One or more task names, separated by spaces. Required; at least one. |
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--sequentially |
-s |
— | off | Run each task’s graph to completion in turn, instead of merging them into one combined graph |
--filter |
-f |
<PATTERN> |
none | Run in the workspaces whose name contains this pattern, plus what they depend on |
--concurrency |
— | <N> |
number of CPUs | Cap how many tasks run at once. The default is the number of CPUs |
--continue |
— | — | off | Keep running independent tasks after a failure instead of stopping |
--no-cache |
— | — | off | Neither read nor write the cache. Lattice re-runs every task and stores nothing |
--force |
— | — | off | Re-run every task and write fresh cache entries, replacing any already stored |
--dry-run |
— | — | off | List the tasks that would run, then exit without running them |
--concurrency has no short alias.
Plus the global flags below.
lattice run build
lattice run lint test build
lattice run lint test build --sequentially
lattice run test --filter api
lattice run lint --concurrency 4 --continue
--no-cache and --force both skip the lookup. They differ in what they leave
behind: --no-cache writes nothing, and --force writes a fresh entry over
whatever was stored at that key.
--dry-run prints one line per task in the graph, each with the command that
task would run:
❖ lattice dry run · build
→ core:build cargo build
→ api:build go build
→ web:build pnpm run build
A task that is in the graph only because a selected workspace depends on it
carries a (dependency) tag:
❖ lattice dry run · build
→ core:build (dependency) cargo build
→ web:build pnpm run build
With --sequentially, the banner names the task whose graph follows and ends in
(phase):
❖ lattice dry run · build (phase)
A --filter that matches no workspace prints a message and exits 0:
lattice: no workspaces matched filter 'zzz'.
So does a repo whose workspaces array is empty:
lattice: no workspaces declared. Add one to the `workspaces` array in lattice.json, then run `build`.
In that message, the backticks at the end hold the task names you passed, joined
by spaces. Neither message is a failure. See Selecting what
runs for how --filter and --dry-run shape the
graph, and Persistent tasks for why a
persistent task in the graph forces raw output whatever -v says.
lattice setup
lattice setup [OPTIONS] [WORKSPACES]...
Provisions pinned toolchains, then installs each workspace’s dependencies.
Lattice provisions the toolchains declared under engines first, into
.lattice/toolchains, so every dependency installer runs with the pinned
PATH. Each workspace’s package manager then installs that workspace’s
dependencies. A repo that declares no workspaces still gets its engines
provisioned.
Each workspace’s install command comes from its detected driver: pnpm install,
cargo fetch, poetry install, and so on.
Arguments
| Argument | Description |
|---|---|
[WORKSPACES]... |
Set up only the workspaces named here. Omit to set up all of them. |
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--force |
— | — | off | Reinstall dependencies even if the lockfile has not changed |
Plus the global flags below.
lattice setup
lattice setup api web
lattice setup --force
Setup rejects a name no workspace declares before it provisions anything:
Error: workspace 'wbe' is not declared in the `workspaces` array in lattice.json. Declared workspaces: api, web
Setup skips a workspace’s install step when no lockfile that governs it is
newer than the marker that records the last successful install. --force
reinstalls regardless.
The lockfiles that govern a workspace are the ones in its own directory and in
every directory above it, up to the repo root. A hoisted npm, pnpm, or yarn
workspace tree keeps its only lockfile at the root, so such a workspace
directory often holds no lockfile of its own. A root lockfile that a git pull
moved now reinstalls that workspace. Setup used to check only the workspace’s own
directory, so the everyday hoisted layout reported dependencies up to date
forever and the next build failed on a dependency nothing had installed.
--force was the only way out.
The marker is one file per workspace under .lattice/setup, named for the
workspace’s path relative to the repo root with / written %2F and a literal
% written %25:
| Workspace path | Marker |
|---|---|
apps/web |
.lattice/setup/apps%2Fweb.marker |
apps-web |
.lattice/setup/apps-web.marker |
., the repo root itself |
.lattice/setup/.marker |
Encoding the separator rather than replacing it keeps apps/web and apps-web
apart, so two workspaces can never share a marker and an install in one can
never mark the other up to date. The marker used to be a .lattice-setup-marker
in each workspace directory. A marker an older version of Lattice left there
still counts, so an upgrade mid-project reinstalls nothing, and setup deletes
that file once an install succeeds. lattice init writes both .lattice/setup/ and
.lattice-setup-marker to .gitignore, and nothing under .lattice reaches a
cache key, so the marker no longer moves the key of a task that declares no
inputs.
Lattice shows the install command’s output as it runs, indented four spaces, with no flag needed. The command gets no stdin, so an installer that stops to ask for a password or a confirmation fails with its own message instead of waiting on a prompt nothing displays. Supply the credential through the environment, or run that installer once yourself outside Lattice.
A workspace with no detected driver and no engines is skipped without a
message. One with engines but no package manager reports that under -v:
lattice: web: toolchains ready. This workspace has no package manager to install
A failed install is a per-workspace warning, and the remaining workspaces still
run. The command then fails as a whole. See lattice setup
failures.
lattice init
lattice init [OPTIONS]
Creates a lattice.json — the workspaces, the pinned tool versions, and the
pipeline the repo already declares — and a .lattice/schema.json, both in the
current directory.
Commit the schema file. init also adds five lines to .gitignore, which keep
Lattice’s per-machine artifacts out of version control:
.lattice/cache/
.lattice/toolchains/
.lattice/bin/
.lattice/setup/
.lattice-setup-marker
init lists .lattice/ a directory at a time because the committed
.lattice/schema.json stays tracked. The last line covers a marker an older
version of Lattice left inside a workspace directory.
init reads the repo before it writes anything. Every directory holding one of
these manifests becomes a proposed workspace:
package.json pyproject.toml pom.xml pubspec.yaml
Cargo.toml requirements.txt build.gradle Package.swift
go.mod setup.py build.gradle.kts stack.yaml
Gemfile composer.json mix.exs cabal.project
A directory holding a .sln, .csproj, .fsproj, or .vbproj file counts
too. Those are matched by extension, because their filenames vary. Every tool
version the repo already pins becomes a proposed
engine:
| File | Engine |
|---|---|
.tool-versions |
every well-known tool named in it |
.nvmrc |
node |
rust-toolchain.toml, rust-toolchain |
rust |
.python-version |
python |
.ruby-version |
ruby |
.java-version |
java |
package.json packageManager |
the tool named there |
package.json engines |
each tool named there |
go.mod, its toolchain line or its go directive |
go |
The walk skips hidden directories, anything .gitignore covers, and these
directory names: node_modules, target, dist, build, out, vendor,
venv, __pycache__, coverage, testdata, fixtures.
The tasks it writes
For each workspace it keeps, init reads the task list out of the file that
workspace’s driver reads, and writes every task it finds. Only the driver’s own
file is read: a workspace driven by turbo.json runs turbo run <task>, so its
package.json scripts are not tasks Lattice could drive there.
| Driver | File | What it reads |
|---|---|---|
turbo |
turbo.json, as JSONC |
tasks, or pipeline on a repo that has not migrated |
nx |
nx.json |
targetDefaults keys |
npm, pnpm, yarn, bun |
package.json |
scripts keys |
deno |
deno.json, deno.jsonc |
tasks keys |
composer |
composer.json |
scripts keys |
just |
justfile, .justfile |
recipe names |
task |
Taskfile.yml, Taskfile.yaml |
keys under the top-level tasks: |
pdm |
pyproject.toml |
[tool.pdm.scripts] keys |
poetry |
pyproject.toml |
[tool.poetry.scripts] keys |
uv |
pyproject.toml |
[project.scripts] keys |
pipenv |
Pipfile |
[scripts] keys |
A driver not in that table — cargo, go, gradle, maven, dotnet,
swift, mix, rake, dart, stack, cabal — takes the task name straight
on its command line and publishes no list of what it accepts. There is nothing
to read, so a workspace driven by one contributes the single task build, with
dependsOn: ["^build"]. That is also what you get when no kept workspace
declares anything. Add the rest of that workspace’s tasks by hand.
A turbo.json carries the most across: each task’s dependsOn, inputs,
outputs, env, persistent, and cache, plus the file’s
globalDependencies and globalEnv, which become the config’s. A !-negated
inputs glob becomes an ignore entry, because Lattice spells an exclusion as
its own list; a negated outputs glob is dropped.
Four rules decide the rest:
- A task a file only names, as a
package.jsonscript does, is written as{}.buildis the exception and keepsdependsOn: ["^build"]. - Where two kept workspaces name the same task, the one that describes it wins over the one that only names it.
- An entry Lattice has no word for is left out rather than guessed at: a
package-scoped
web#buildtask ordependsOnentry, a$LEGACY_ENVindependsOn, and$TURBO_DEFAULT$ininputs. - A
dependsOnentry naming a task that did not make it into the file is pruned. Lattice refuses to load a config that depends on a task it does not define, so whatinitwrites always loads.
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--force |
— | — | off | Overwrite an existing lattice.json |
--yes |
-y |
— | off | Write what the scan finds without prompting |
Plus the global flags below.
lattice init
lattice init --yes
lattice init --force
On a terminal, init shows the two lists — workspaces and engines — with
everything pre-checked and lets you uncheck what is wrong. Tasks are not a third
list; they follow the workspaces you keep, so unchecking a workspace also drops
the tasks only that workspace declared. A repo root that holds only a workspace
declaration,
such as a Cargo.toml with [workspace] or a package.json with
workspaces, is offered alongside its members but starts unchecked. So is a
directory whose driver stays ambiguous, because declaring it would halt the next
run on the ambiguity. init names
those directories on the way out:
· left out apps/legacy. No driver resolved there. Declare one in engines to add it.
With --yes, or with no terminal attached, init writes what the scan found and
prompts for nothing. A scan that finds nothing then writes the bare skeleton. On
a terminal, unchecking everything makes init ask for at least one workspace or
one engine rather than writing a config that does nothing.
Running init where a lattice.json already exists is an error unless you pass
--force.
lattice prune
lattice prune [OPTIONS]
Evicts cache artifacts, oldest first, until the local cache is under a size
limit. The limit comes from --max-size. Without that flag, Lattice uses
settings.maxCacheSize in lattice.json.
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--max-size |
— | <SIZE> |
settings.maxCacheSize |
Upper bound on the cache size, such as 10GB. Defaults to settings.maxCacheSize |
Plus the global flags below.
lattice prune
lattice prune --max-size 5GB
A size is a number and one of B, KB, MB, GB, or TB, case-insensitive
and base 1024, or a bare integer of bytes. With neither --max-size nor
settings.maxCacheSize, prune fails rather than guessing a limit.
❖ removed 0 artifacts, freed 0B
prune also reclaims what nothing can read: artifacts left without metadata by
an interrupted run, metadata that never recorded a digest, metadata that no
longer parses, and the staging files beside them. With settings.maxCacheSize
set, every run already holds the cache to it, so prune covers sweeping by hand
and enforcing a limit other than the one in the config. See Cache
internals for eviction order and the on-disk
layout.
prune reclaims a leftover only once it has sat untouched for at least an hour,
so a prune right after an interrupted run frees nothing and reports removed 0 artifacts. Until then the leftover’s bytes still count against the limit. The
wait is why two lattice processes can share one cache directory. A store in
progress and a store that died halfway through leave the same files behind, so
without the wait one process would finish its run and sweep away what the other
was still writing.
prune deletes *.tar.gz, *.meta.json, and *.tmp files in the cache
directory, and removes no directories. For that reason, Lattice requires
settings.cacheDir to name a directory inside the repo and never the repo
root.
lattice stats
lattice stats
Reports what this repo’s cache has done for it: task time saved, how many runs
and hits it took, and how much room the cache uses. stats has no flags of its
own, only the global flags below.
lattice stats
❖ lattice stats since 2026-05-02
saved 3h 41m of task time
runs 412 · 2,904 of 3,390 tasks cached (86%)
cache 1.4 GB · 892 entries
last 7d 27m 04s saved across 38 runs
| Line | What it counts |
|---|---|
saved |
Every recorded run’s saved figure, added up. Task time, not wall clock |
runs |
Recorded runs, then the cache hits and scheduled tasks across all of them, then the share as a percentage |
cache |
The cache directory as it stands now — bytes on disk and complete entries — rather than anything historical |
last 7d |
The same saved total and run count, over the last seven days |
The date in the header is the oldest run still on record. The saved totals are task time, not wall clock: each hit contributes the time the run that wrote its entry spent, whether or not those tasks would have run at the same moment. See Caching.
With nothing recorded, the header carries no date and one line follows it:
❖ lattice stats
No runs recorded yet. Run a task and this fills in — every run appends one line.
stats exits 0 either way.
The record is stats.jsonl in the cache directory, so
.lattice/cache/stats.jsonl unless settings.cacheDir moves it. One JSON
object is appended per run:
{ "at": "2026-05-02T14:03:11.482913Z", "total": 10, "cached": 8, "failed": 0, "savedMs": 171204, "elapsedMs": 4198 }
A run appends a line only when it could store to the cache and scheduled at
least one task, so a --no-cache run records nothing. The file is per-machine
and never committed, lattice prune leaves it alone, and clearing the cache
clears the history with it. A line that no longer parses is skipped on read,
which costs that run’s numbers and nothing else.
stats needs a lattice.json in the working directory or a parent, the same as
prune, and fails the same way without one.
lattice upgrade
lattice upgrade [OPTIONS] <VERSION>
Moves this repo to another version of Lattice and pins it. Lattice installs that
version into .lattice/bin and points .lattice/bin/lattice at the new binary.
It also writes the version to latticeVersion in lattice.json. Every later
invocation reads that pin.
Arguments
| Argument | Description |
|---|---|
<VERSION> |
Version to move to, such as 0.2.0, or latest for the newest release. Required. |
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--release-latest-url |
— | <URL> |
GitHub API | Endpoint that names the newest stable release, for upgrade latest |
--release-list-url |
— | <URL> |
GitHub API | Endpoint listing every release, used when no release is stable yet |
Only upgrade latest reads either URL. upgrade 0.2.0 reads neither. The
archive itself comes from the global --release-base-url.
Plus the global flags below.
lattice upgrade 0.2.0
lattice upgrade latest
lattice --release-base-url file:///srv/lattice-mirror upgrade 0.2.0
When the binary running upgrade is not the version it just pinned, upgrade
prints the command to run next instead of switching for you. See
Upgrading for the drift nag this pin suppresses and
how every other command honors the pin.
lattice completions
lattice completions [OPTIONS] <SHELL>
Prints a shell completion script to stdout.
Arguments
| Argument | Description |
|---|---|
<SHELL> |
Shell to generate completions for. One of bash, elvish, fish, powershell, zsh. Required. |
completions has no flags of its own. The global flags below
still parse.
lattice completions zsh > ~/.zsh/completions/_lattice
lattice completions bash
lattice version
lattice version [OPTIONS]
Prints version information. Without --json it prints the splash. With --json
it prints one line of JSON.
Flags
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--json |
— | — | off | Output version information as JSON |
Plus the global flags below.
lattice version
lattice version --json
{"version":"1.1.1","target":"aarch64-apple-darwin","arch":"aarch64"}
Global flags
These parse on lattice itself and on every subcommand. Put them before or
after the subcommand name.
| Flag | Short | Argument | Default | Description |
|---|---|---|---|---|
--verbose |
-v |
— | off | Print raw workspace:task: lines instead of the live display |
--loquacious |
-l |
— | off | Hidden alias for --verbose |
--no-version-check |
— | — | off | Run this binary even when the repo pins another version |
--theme |
— | light | dark |
detected | Shade the logo for a light or dark terminal |
--release-base-url |
— | <URL> |
GitHub releases | Base URL to download release archives from. A file:// base works offline |
--theme takes light or dark and nothing else. A third value is a parse
error rather than a fall-back to detection. Without the flag, Lattice reads
LATTICE_THEME, then the terminal’s own COLORFGBG, and treats a background of
ANSI 7 or 15 as light. With no signal at all it uses the dark shade.
--release-base-url is global because upgrade is not the only command that
downloads. An invocation in a repo pinning a version that is not installed
fetches it too, under whatever command you typed.
--loquacious and -l spell one hidden alias for --verbose. Neither appears
in any --help output.
-h and --help work on lattice and on every subcommand. -V and
--version print the compiled-in binary version and exist only on lattice
itself, so lattice run -V is a parse error. -v and -V differ only in case.
Lowercase prints raw output. Uppercase prints the version.
Option precedence
Where a setting can come from more than one place, Lattice resolves it in this order, highest first.
| Source | Examples |
|---|---|
| CLI flag | -v, --no-version-check, --theme, --release-base-url, --max-size |
| Environment variable | LATTICE_NO_VERSION_CHECK, LATTICE_THEME, LATTICE_RELEASE_BASE_URL |
settings in lattice.json |
settings.loquacious, settings.versionCheck, settings.maxCacheSize, settings.cacheDir |
| Built-in default | — |
Not every setting has all four sources. --verbose has a flag and
settings.loquacious, and no variable of its own. --theme has a flag and
LATTICE_THEME, and no settings entry.
Raw output has three triggers outside this order, each sufficient on its own:
CI set to any value, stdout that is not a terminal, and a persistent task in
the graph. See Environment
variables for every variable Lattice
reads.
Exit codes
| Exit code | Meaning |
|---|---|
0 |
Success. Also a run whose filter matched no workspace, and a run against an empty workspaces array |
1 |
Any error Lattice raises: a missing lattice.json, an unknown task name, a failed task, an unset cache limit on prune, or any other failure |
2 |
The command line was rejected before Lattice ran anything: an unknown subcommand, an unrecognized flag, a bad value for --theme or <SHELL>, or a missing required argument |
130 |
A lattice run was interrupted by Ctrl-C or SIGTERM |
A failing task exits 1 whether the run stopped at the first failure or kept
going under --continue.
Without --continue, the first failing task is the process’s error and no
further tasks start, though any already in flight run to completion:
Error: task 'app:build' failed, stopping the run
With --continue, independent branches keep running and anything downstream of
a failure is skipped rather than started. No separate error line prints in that
case. The run summary reports the counts and the process exits 1:
lattice: 2 tasks, 0 cached, 2 failed, 0.01s
--sequentially applies the same rule per phase. A failing phase stops the
remaining phases unless --continue is also set, in which case every phase runs
and the process exits 1 if any task failed.
130 is the shell’s convention for a process ended by SIGINT, 128 + 2. It is
distinct from 1 on purpose, so that a CI runner cancelling a job does not read
as a build that broke. An interrupted run prints its summary and exits 130
with no error line.
On unix, every running task’s process group is sent SIGTERM on the way out,
given five seconds, and then killed, so a task that shelled out to a compiler or
a server takes the whole tree with it. Each task is spawned into its own process
group, which is what makes that possible and is also why the terminal’s own
Ctrl-C never reaches it directly. On Windows tasks stay attached to the console,
which delivers the event to them.
For the full text of every message a non-zero exit prints, see Errors.