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.json script does, is written as {}. build is the exception and keeps dependsOn: ["^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#build task or dependsOn entry, a $LEGACY_ENV in dependsOn, and $TURBO_DEFAULT$ in inputs.
  • A dependsOn entry 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 what init writes 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.