Quick start
lw is a single self-contained binary for Linux x86_64, macOS arm64 and Windows x86_64. It needs no Neovim and no Lua install. Every command has built-in help: lw help <command> or lw <command> --help. The full guides are lw help ci, lw help agent and lw help cache.
Install and verify
lw installs itself. Download the binary for your platform, verify it, and then let the verified binary place itself on PATH and fetch its first bundle. A binary can't verify itself, so you establish its integrity before you run it for the first time.
Recommended: build provenance
Every release binary is attested by GitHub Actions and recorded in Sigstore's public transparency log. gh attestation verify checks that the binary came out of the repository's release workflow, using no key and no hashes supplied by the project. This needs an authenticated gh, which is preinstalled on GitHub Actions.
$ gh release download --repo samienne/loomworks.nvim -p lw-linux-x86_64
$ gh attestation verify lw-linux-x86_64 --repo samienne/loomworks.nvim
$ chmod +x lw-linux-x86_64 && ./lw-linux-x86_64 install -y
Fallback: release-key signature (no gh needed)
Each release publishes a SHA256SUMS list signed with the loomworks release key. Verify the signature with the public key below, then check the binary against the list. These commands work unchanged for every release. This is trust-on-first-use: after the first install, lw self-update verifies updates against the key built into your installed binary.
base=https://github.com/samienne/loomworks.nvim/releases/latest/download
bin=lw-linux-x86_64 # or lw-macos-arm64
d="$(mktemp -d)" && cd "$d"
curl -fsSLO "$base/$bin" && curl -fsSLO "$base/SHA256SUMS" \
&& curl -fsSLO "$base/SHA256SUMS.sig"
cat > lw.pub.pem <<'PEM'
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8MhoZlT5ww82JmplPiRyta32R8HY
meq3+ZL1wAo7PHHBnHHzXIE+Kab49ClyLvDUGsOR3LG+kU1lH6nxunmO2A==
-----END PUBLIC KEY-----
PEM
# macOS ships `shasum`, not GNU `sha256sum`.
if command -v sha256sum >/dev/null; then check="sha256sum -c -"
else check="shasum -a 256 -c -"; fi
openssl dgst -sha256 -verify lw.pub.pem -signature SHA256SUMS.sig SHA256SUMS \
&& grep -E "[ *]$bin\$" SHA256SUMS | $check \
&& chmod +x "$bin" && "./$bin" install
$base = "https://github.com/samienne/loomworks.nvim/releases/latest/download"
$bin = "lw-windows-x86_64.exe"
$d = New-Item -ItemType Directory (Join-Path $env:TEMP (New-Guid))
"$bin","SHA256SUMS","SHA256SUMS.sig" | ForEach-Object {
Invoke-WebRequest "$base/$_" -OutFile (Join-Path $d $_)
}
$pub = @"
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8MhoZlT5ww82JmplPiRyta32R8HY
meq3+ZL1wAo7PHHBnHHzXIE+Kab49ClyLvDUGsOR3LG+kU1lH6nxunmO2A==
-----END PUBLIC KEY-----
"@
$ec = [System.Security.Cryptography.ECDsa]::Create()
$ec.ImportFromPem($pub)
$sums = [IO.File]::ReadAllBytes((Join-Path $d "SHA256SUMS"))
$sig = [IO.File]::ReadAllBytes((Join-Path $d "SHA256SUMS.sig"))
if (-not $ec.VerifyData($sums, $sig,
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence)) {
throw "SHA256SUMS signature does not verify"
}
$want = ((Get-Content (Join-Path $d "SHA256SUMS") |
Select-String " $bin$") -split '\s+')[0]
$got = (Get-FileHash (Join-Path $d $bin) -Algorithm SHA256).Hash
if ($got -ine $want) { throw "hash mismatch for $bin" }
& (Join-Path $d $bin) install
ImportFromPem needs PowerShell 7 (.NET 5+); Windows PowerShell 5.1 doesn't have it. On Windows, the provenance route is usually simpler.
What lw install does
It copies the binary to a per-user location (~/.local/bin/lw on Unix, %LOCALAPPDATA%\Microsoft\WindowsApps\lw.exe on Windows), makes sure that location is on PATH, and runs lw self-update to fetch the verified release bundle. It needs no admin rights.
-yappliesPATHchanges without prompting;--no-modify-pathskips them.--dry-runshows what it would do;--no-bundleskips the bundle fetch.- Shell completion:
lw completion bashorlw completion zsh.
To pin a release in CI, pass an explicit tag (gh release download v0.1.30 …, or a versioned URL instead of latest) and don't run lw self-update. For most repositories the repo-local launcher is simpler.
Your first workspace
From the root of a repository:
$ lw init
$ lw project add ./app # type auto-detected (cmake, meson, …)
$ lw configset create dev app=Debug # map a configuration set
$ lw profile create dev ninja-gcc # set + toolchain; `lw tools` lists them
$ lw build dev
$ lw publish # write the shared loomworks.json
New CMake and Meson projects already expose configurations such as Debug and Release (lw config list <project> shows them). You only add configurations for custom variants.
Run bare lw (or lw status) at any time for a one-screen overview: the active profile, its targets, diagnostics, profiles, configuration sets and projects. Interactively, lw build with no profile will even onboard one for you: it asks you to pick a configuration set and a toolchain, then builds.
Profiles are numbered in lw profile list, and a number, an exact key or a unique substring (lw build clang-18) all resolve. Numbers are an interactive convenience only; scripts and CI should use keys.
Concepts
| Term | Meaning |
|---|---|
| Project | A CMake, Meson, shell or TypeScript project in the workspace, declared by type. By default its key is its path relative to the workspace root. |
| Configuration | A build variant of one project. Module-emitted ones are named variant:Debug, preset:<name> or auto:<base>; your own can inherits: from them and add options, variables and environment. |
| Configuration set | A cross-project mapping, for example "Debug means Debug for MyApp and development for Frontend". This is the portable unit teams share. |
| Tool | A toolchain choice detected on this machine. For CMake it is a generator + compiler pair, such as ninja-gcc-14.2.0. |
| Profile | A configuration set + toolchain, fully resolved and buildable, for example Debug:ninja-gcc-14.2.0. Profiles are what you build, run, test and clean. Artifacts land in .nvim/build/<project>/<tool>/<config>/, a separate build directory per toolchain. |
The compiler is chosen only by the profile's tool. CMAKE_<LANG>_COMPILER, CC and CXX overrides in a configuration are rejected, so a profile name always tells you what compiled the build.
Local vs committed config
workspace-root/
├── loomworks.json Published snapshot. Optional: commit it, or don't.
└── .nvim/ Machine-local: always gitignored.
├── loomworks.user.json Live working copy; the runtime source of truth.
├── loomworks.cache.json Build state.
├── loomworks.health.json Advisory cache for `lw health`.
└── build/ Build trees.
- Fully local. All configuration stays in
.nvim/loomworks.user.jsonand nothing is added to the repository. This suits a tool you use on your own. - Committed.
lw publishregeneratesloomworks.jsonfrom the working copy. Commit it, and teammates and CI get the same projects and configuration sets. The published file is never read at runtime, so starting local and publishing later is an upgrade, not a migration.
Every item carries an intent. CLI-created items default to local+shared and reach loomworks.json on publish; --local keeps one private. Profiles default to local, because they pin toolchains resolved on your machine. Share the configuration set, and let each machine create its own profile.
A value that differs per machine, such as an SDK path, is declared once as a blank variable and filled per profile. The fill lives only in the working copy, and a build refuses to run while any blank is unfilled:
$ lw project set App sdk_root --type path # blank (no default)
$ lw config set App Debug options.CMAKE_PREFIX_PATH '${sdk_root}'
$ lw profile set App sdk_root /opt/sdk/3.2 # fill it on this machine
Using lw in CI
The model: commit the projects and configuration sets. Each matrix cell then picks a local toolchain and creates its own profile. Profiles are per-machine and don't need to be committed.
- Pin
lwitself with the repo-local launcher, so every cell runs the same verified release with no install step. - Run non-interactively. Pass
--no-input(alias--non-interactive), or setLW_NO_INPUT=1orCI. A missing value then fails instead of prompting, andlw buildignores the active profile and never guesses one, so name the profile explicitly. - Pick a toolchain coarsely. Selectors match at segment boundaries:
ninja-clang-18resolves to the highest installed 18.x andmsvc-17matches any Visual Studio 17 edition. Runlw toolson the runner to see the real keys. CMake keys are generator + compiler (ninja-gcc-12,msvc-17); Meson keys are compiler only (clang-18). - Build and test with real exit codes and JUnit output. Everything after
--is forwarded to the native tool. - Collect artifacts without parsing logs: the build directory is deterministic and known before you build.
$ ./lw.sh --no-input profile create Debug ninja-clang-18
$ ./lw.sh --no-input build Debug:ninja-clang-18
$ ./lw.sh --no-input test Debug:ninja-clang-18 --junit results.xml -- -j 4
$ BD=$(./lw.sh --no-input profile query Debug:ninja-clang-18 app build-dir)
$ cp "$BD/app" out/
lw profile query <profile> <project> <field> prints one fact: build-dir, config, state, tool, cache or variables[.<name>]. Two more commands are useful as CI gates: lw status --check exits non-zero if any diagnostic is present, and lw migrate --check lints the workspace files against current conventions.
lw build runs CMake's or Meson's own configure and build, so FetchContent and Meson wraps fetch their dependencies as they normally would. Cache .nvim/build/<project>/ between runs to reuse both fetched sources and compiled objects. Keep .nvim/ in .gitignore.Repo-local launcher
For a repository where every contributor and CI runner should use the same pinned lw with no prior install, commit a launcher from the repo root:
$ lw bootstrap # pins this host's release; or: lw bootstrap --version 0.1.30
$ git add lw.sh lw.cmd lw.pin # commit the three files
| File | What it is |
|---|---|
lw.pin | The pinned release version plus the SHA-256 of every host binary and of the release bundle, in plain key = value form. The hashes come from the release's signed SHA256SUMS, whose signature is verified before any hash is trusted. |
lw.sh | POSIX launcher for Linux, macOS, and Git Bash / MSYS on Windows. |
lw.cmd | Native Windows launcher for cmd and PowerShell. |
bootstrap also appends .nvim/cache/ to .gitignore. Then anyone with a checkout runs ./lw.sh build <profile> (or lw.cmd build <profile>). The launcher downloads the platform's host binary from the official release, always verifies its SHA-256 against the pin, caches it under .nvim/cache/ and runs it. That host then provisions the pinned bundle in the same place, leaving the machine-wide install untouched.
- Proxies and air-gapped runners. The launcher honours
HTTPS_PROXY/HTTP_PROXY.--insecure(orLOOMWORKS_INSECURE=1) relaxes TLS for an intercepting proxy; the SHA-256 check is independent and still enforced. PointLOOMWORKS_RELEASE_URLat a local mirror directory for offline runners. - Stronger check.
--verifyalso runsgh attestation verifywhenghis available. - Global installs honour the pin. Inside a pinned repo, a global
lwruns the pinned release forbuild,run,testandclean. Use--no-pinto bypass it, orLOOMWORKS_LW=<path>to test a specific binary. - Moving forward.
lw updaterepointslw.pinat the latest release (or--version x.y.z) and refreshes the launchers. If that release can't be fetched, the command fails cleanly.
Git worktrees
The working copy is machine-local and gitignored, so a fresh worktree starts empty. lw fills it for you:
$ lw worktree # list worktrees and whether each is inited
$ lw worktree add feature/x # new branch + worktree, seeded with main's config
$ lw worktree add hotfix v1.2.0 # branch off a tag
$ lw pull --dry-run # preview a pull from the main worktree
$ lw pull
lw pull is a source-wins, non-destructive merge of projects, configuration sets, profiles, SDK declarations and default targets. It never pulls the active profile, the workspace name or the device selection. It never touches build state, and it never publishes loomworks.json.
Compiler caching
For CMake and Meson builds, lw wires in a compiler cache according to the reserved cache variable:
auto(the default): for GCC and Clang it usesccache, orsccacheif that is the only one installed. For MSVC and clang-cl,automeans off, because shared.pdbdebug info (/Zi) breaks sccache.ccache/sccache: use that launcher. This is how you opt in on MSVC.lwthen switches CMake's debug info to embedded/Z7and scans the compile commands for leftover/Zitargets, warning per target.off: no cache.
$ lw config set App Debug variables.cache sccache # per configuration
$ lw profile set App cache off # per machine, active profile
$ lw status --cache-stats # fold in the tool's hit rates
With CMake, the launcher is injected on the Ninja and Makefile paths. The Visual Studio and Xcode generators, and preset-driven configurations, can't take a launcher, and lw reports that as not applied instead of failing silently. lw help cache covers the details.
Health and updates
lw health lists advisory items in full and always exits 0. They include actionable suggestions (no compiler cache found, /Zi findings, a newer lw on your channel) and informational status (Compiler cache: using sccache). The update check makes a network request, so it runs only when you call lw health, at most once a day (--force refreshes it now). It also works outside a workspace.
lw self-update fetches the signed manifest, verifies it against the key built into lw, checks the bundle's hash, and installs it alongside the running copy. It then replaces the lw binary itself from the same release. That replacement is atomic, it happens only after verification, and it never downgrades.
$ lw health
$ lw self-update # stable channel (default)
$ lw self-update --channel unstable # include pre-releases, this run only
$ lw settings set channel unstable # make it the default
$ lw version
Both channels go through the same verification; unstable describes release maturity, not reduced checking. A repo pinned with lw.pin ignores channels and never self-replaces.
Running targets
lw target lists a profile's runnable targets: your declared launch configurations plus the build system's executables. lw run builds, deploys and runs one, and returns the program's exit code.
$ lw run app -- --config release # args after -- go to the program
$ lw run --prefix 'valgrind --leak-check=full' # run under a wrapper
$ lw run --print=json # resolved argv, cwd and env; no run
--prefix runs the wrapper in the launch's resolved working directory and environment, which valgrind $(lw run --print) can't do. --no-build skips build and deploy.
Commands
lw with no command prints the workspace status. lw help <command> has the details for each.
| Command | Description |
|---|---|
lw init | Initialize the workspace working copy. |
lw status | One-screen overview. --check exits non-zero on diagnostics; --cache-stats adds compiler-cache statistics. |
lw project | add · remove · rename · list · show · set · unset (project variables). |
lw config | add · set · get · show · rename: per-project configurations, options, variables and environment. |
lw configset | create · map · show · rename: configuration sets. |
lw profile | list · show · select · create · remove · publish · query · set · unset. |
lw tools | List detected toolchains (--cached reads the cache instead of scanning). |
lw sdk | Declare toolchains that detection can't find: types · list · add · remove. |
lw build [profile] | Configure if needed, then build. --reconfigure forces a full reconfigure; --force overrides an output conflict; -- forwards args to the build tool. |
lw test [profile] | Build, then run tests with a real exit code. --junit <file> writes a JUnit report. |
lw run | Build, deploy and run a target. --prefix, --print[=json], --no-build. |
lw target | List a profile's launchable targets; set / clear the default. |
lw launch | Manage launch configurations: list · add · show · remove. |
lw clean [profile] | Run the build system's clean; keeps the configuration. |
lw reset [profile | --all] | Remove build directories and return configurations to unconfigured. Destructive; -y is required under --no-input. |
lw publish | Write loomworks.json from the working copy. |
lw pull [source] | Merge another checkout's working config into this one (default: the main worktree). |
lw worktree [add] | List git worktrees, or create one and seed its config. |
lw migrate [--check] | Bring workspace files up to current conventions (--check for CI lint). |
lw health | Advisory suggestions, including update availability. Never fails. |
lw module | install · update · remove · list verified add-on modules. |
lw settings | Get and set lw's own settings (channel, release-url, …). |
lw bootstrap / lw update | Create or move the repo-local launcher pin. |
lw install / lw self-update / lw version | Install, update and inspect lw itself. |
lw completion <bash|zsh> | Shell completion script. |
Editor integration
The same workspace drives loomworks.nvim, an optional Neovim plugin. It provides a status page (:LoomworksInfo), clangd wiring with a generated compilation database, overseer.nvim tasks, nvim-dap debugging and a lualine component. It requires Neovim 0.9+, snacks.nvim and overseer.nvim.
{
"samienne/loomworks.nvim",
event = "VeryLazy",
}
The plugin auto-loads when Neovim opens in a directory that contains loomworks.json; otherwise run :LoomworksInit. See the README for keymaps and options.