Documentation

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.

Early development. loomworks is pre-1.0. Commands and file formats may still change; check the release notes when you upgrade.

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.

Linux x86_64 (use lw-macos-arm64 on macOS)
$ 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.

Linux / macOS
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
Windows (PowerShell 7)
$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.

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:

first run
$ 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

TermMeaning
ProjectA CMake, Meson, shell or TypeScript project in the workspace, declared by type. By default its key is its path relative to the workspace root.
ConfigurationA 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 setA cross-project mapping, for example "Debug means Debug for MyApp and development for Frontend". This is the portable unit teams share.
ToolA toolchain choice detected on this machine. For CMake it is a generator + compiler pair, such as ninja-gcc-14.2.0.
ProfileA 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 layout
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.

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:

machine-specific values
$ 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.

  1. Pin lw itself with the repo-local launcher, so every cell runs the same verified release with no install step.
  2. Run non-interactively. Pass --no-input (alias --non-interactive), or set LW_NO_INPUT=1 or CI. A missing value then fails instead of prompting, and lw build ignores the active profile and never guesses one, so name the profile explicitly.
  3. Pick a toolchain coarsely. Selectors match at segment boundaries: ninja-clang-18 resolves to the highest installed 18.x and msvc-17 matches any Visual Studio 17 edition. Run lw tools on the runner to see the real keys. CMake keys are generator + compiler (ninja-gcc-12, msvc-17); Meson keys are compiler only (clang-18).
  4. Build and test with real exit codes and JUnit output. Everything after -- is forwarded to the native tool.
  5. Collect artifacts without parsing logs: the build directory is deterministic and known before you build.
a CI job, step by step
$ ./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.

Dependencies stay with your build system. 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:

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
FileWhat it is
lw.pinThe 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.shPOSIX launcher for Linux, macOS, and Git Bash / MSYS on Windows.
lw.cmdNative 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.

Git worktrees

The working copy is machine-local and gitignored, so a fresh worktree starts empty. lw fills it for you:

worktrees
$ 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:

cache policy
$ 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.

updates
$ 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.

run
$ 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.

CommandDescription
lw initInitialize the workspace working copy.
lw statusOne-screen overview. --check exits non-zero on diagnostics; --cache-stats adds compiler-cache statistics.
lw projectadd · remove · rename · list · show · set · unset (project variables).
lw configadd · set · get · show · rename: per-project configurations, options, variables and environment.
lw configsetcreate · map · show · rename: configuration sets.
lw profilelist · show · select · create · remove · publish · query · set · unset.
lw toolsList detected toolchains (--cached reads the cache instead of scanning).
lw sdkDeclare 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 runBuild, deploy and run a target. --prefix, --print[=json], --no-build.
lw targetList a profile's launchable targets; set / clear the default.
lw launchManage 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 publishWrite 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 healthAdvisory suggestions, including update availability. Never fails.
lw moduleinstall · update · remove · list verified add-on modules.
lw settingsGet and set lw's own settings (channel, release-url, …).
lw bootstrap / lw updateCreate or move the repo-local launcher pin.
lw install / lw self-update / lw versionInstall, 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.

lazy.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.