No description
  • Go 99.4%
  • Just 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Viktor Varland 19c2536ecb
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v0.19.0
2026-10-21 16:35:22 +02:00
.just feat: install to the XDG bin dir 2026-08-01 23:56:49 +02:00
.woodpecker chore: sync the files wrangle manages 2026-10-20 21:23:18 +02:00
docs feat!: load a domain's token stash with locker 2026-10-15 09:38:03 +02:00
internal feat: add sync --relink to link the release over local builds 2026-10-21 16:34:45 +02:00
.gitignore chore: ignore local agent settings 2026-08-01 23:56:49 +02:00
bench_test.go feat!: leave drawing the prompt anchor to cairn 2026-10-20 12:20:47 +02:00
CHANGELOG.md docs: changelog for v0.19.0 2026-10-21 16:35:22 +02:00
config.example.toml feat: add an [outside] layer for the shell outside every domain 2026-10-15 12:34:36 +02:00
go.mod feat: declare the registry as the hook's input, so a shell may cache it 2026-10-20 20:23:29 +02:00
go.sum feat: declare the registry as the hook's input, so a shell may cache it 2026-10-20 20:23:29 +02:00
justfile chore: add bench and bench-compare recipes 2026-10-14 13:48:08 +02:00
main.go feat: add sync --relink to link the release over local builds 2026-10-21 16:34:45 +02:00
main_test.go feat: declare the registry as the hook's input, so a shell may cache it 2026-10-20 20:23:29 +02:00
README.md feat: add sync --relink to link the release over local builds 2026-10-21 16:34:45 +02:00

moor

Switch the shell between development domains as it moves between them. A domain is a context under the workspace — client work, company work, private tools — and cd amedia/foo lands you in a checkout with everything that context implies active: the domain's tools, per-domain language caches, profile env vars, and the sage ssh identity. cd out of every domain and the shell is exactly as it was before moor — plus, if the registry declares one, the [outside] layer: the identity and secrets a shell with no context still wants. There is no command to enter a domain: cd is that command, ^G picks where to cd to, and the context is always a function of where the shell stands. No nix, no direnv.

When moor is not working — not installed, its registry broken, a switch that fails — the shell is a plain zsh: no error, no half-applied context, nothing left behind.

moor deliberately does not manage what can be dispatched statically: git identity belongs in ~/.gitconfig via includeIf "gitdir:…", ssh keys in ~/.ssh/config via per-host IdentityAgent pointing at sage's sockets. Which tools are in force where is moor's own tier ladder, declared in the registry, and moor installs every tool itself, into its own store, each version checked against the checksum its publisher states. See docs/native-toolchain.md for how that replaced mise.

Install

Requires Go 1.26+ and just.

just install    # builds and copies to ~/.local/bin ($XDG_BIN_HOME)

Then in ~/.zshrc, after compinit:

(( $+commands[moor] )) && eval "$(moor hook zsh)"

The guard is what makes a machine without moor a plain zsh rather than one that prints command not found at every start. Two stock zsh options suit it, and are left to you: setopt auto_cd lets a bare amedia/foo mean cd amedia/foo, and setopt cd_silent stops zsh echoing the directory when cdpath found it.

Usage

cd amedia/foo        # from anywhere: ~/dev/amedia/foo, amedia active
cd vardevs           # ... a domain root, vardevs active
cd ~/dev             # the workspace root, a domain of its own
cd /tmp/scratch      # outside every domain: the shell as it was, plus [outside]
cd .                 # re-check where you stand (after a moor sync, say)
MOOR_HOLD=1          # stop following: keep this context wherever you cd
unset MOOR_HOLD      # ... and resume

moor exec -- mvn test        # run it with this domain active, from any shell
moor exec -d amedia -- ./ci  # ... in a named domain, from anywhere

moor status          # active domain, the tier ladder, the registry (bare `moor` too)
moor sync            # provision what applies here — this domain, or the user tier
moor sync --all      # install + cache dirs for every domain, and the user tier
moor sync -u --all   # ... moving each tool to the newest version its pin allows
moor sync -u -n      # what that would do, without doing it
moor sync -u -t user # only the user tier; -t domain.amedia: only amedia's own
moor tiers           # every tier the registry declares; moor tiers user: its tools
moor switch amedia   # the raw snippet the shell evals on a switch (also: eject, hook)
moor resolve foo/bar # the domain and directory a target names

cd amedia/foo works from anywhere because the hook puts the workspace on zsh's cdpath, and a domain cdpath cannot reach — one whose path puts it outside the workspace, or under another name — gets a named directory instead: cd ~amedia/foo. zsh completes both natively. A local ./amedia still wins over the workspace's, as cdpath always does, and cdpath stays a shell variable, so scripts doing their own cd are unaffected.

What the shell does at each prompt is cheap: it walks up from $PWD noting the deepest domain root and every repo pin file on the way (.tool-versions, mise.toml, .nvmrc, …, with their mtimes), plus the registry's own mtime — a few dozen stats, about a tenth of a millisecond, no process. Only when that changes does it ask moor for a switch, which costs tens of milliseconds. So entering a domain switches, and so does entering a repo that pins its own tools, editing any of those files, or editing the registry — where every tier you own is declared; moving around inside one does not. It checks after every cd as well as before every prompt, so cd foo && make already runs make in foo's context.

MOOR_HOLD is the escape hatch for the one case where following is wrong: reading another domain's code without leaving your own context. While it is set the context stays put. A shell standing outside the domain it holds is now the only way the two can disagree, and moor's exported MOOR_DOMAIN and MOOR_ROOT are how a prompt tells: cairn draws an anchor while a domain is active, and turns it red there. moor draws no prompt itself.

^G fuzzy-finds a target and cds to it, when fzf is installed. It is a thin widget over moor targets, which lists every domain, what is under each domain root, and the workspace's own directories; the pick goes into the line as a cd and is run, so it lands in history like anything typed:

cd "$(moor targets --paths | fzf -d '\t' --with-nth 1 | cut -f2)"   # what ^G does
bindkey '^Y' moor-jump       # if ^G is spoken for
MOOR_FZF_OPTS=(--height=12 --reverse --prompt='cd ')   # how the picker looks

How much the picker flickers as it closes is mostly how much of the screen fzf drew and has to clear again, so $MOOR_FZF_OPTS is where to tune it: a smaller --height, --height=~40% to size to the matches (at the cost of resizing as you type), or no --height at all for full screen, which the terminal restores in one go from its alternate buffer instead of clearing line by line.

Fuzzy matching lives in the picker, deliberately: a picker is a view over the targets, free to guess and rank, while what a name means stays a function of the registry rather than of what happens to be on disk. A checkout is a leaf — once a directory has a .git, moor targets stops descending — and --depth bounds the rest.

Config

The registry is explicit TOML at ~/.config/moor/config.toml — see config.example.toml:

[workspace]
path = "~/dev"                              # or just: workspace = "~/dev"
[workspace.tools]
uv = "latest"

[tools]                                     # the user tier
"github:junegunn/fzf" = "latest"

[domains.amedia]
path    = "~/work/amedia"                   # root; defaults to <workspace>/<name>
sage    = "amedia"                          # sage identity to load on switch
preload = true                              # warm its keys on each switch (needs sage)
claudia = "amedia"                          # claudia profile to load on switch
locker  = "amedia"                          # locker stash to decrypt on switch
langs   = ["go", "node", "python", "maven"] # per-domain caches under <root>/.moor
hooks   = ["cat .envrc.local"]              # exports captured from each command
paths   = ["bin"]                           # extra PATH entries (root-relative)
skip    = ["tmp", "scratch"]                # left out of the walk for repo pins
[domains.amedia.env]
K8S_OBJECTS_DIR = "/home/varl/dev/amedia/tools/k8s-objects"
[domains.amedia.tools]
go      = "1.25"
kubectl = "latest"

[[domains.amedia.projects]]                 # a directory below the root
path     = "reports"
env_file = ".env"

[outside]                                   # where no domain is: no root, no tools
sage    = "vlv"
preload = true
locker  = "global"

A domain's name is a registry key, not a directory name. path says where its root actually is, and <workspace>/<name> is only the default when it is omitted — so moving a checkout is a one-line config change, and a domain may live outside the workspace entirely. The workspace stays the namespace domains are named in, and what cdpath resolves bare names against.

Roots may nest when they are named explicitly, in which case the deepest one containing a directory is the domain that directory is in. A domain outside the workspace gets no workspace tier.

A hook is any command that prints export K=V statements (sage-style); moor captures them into the switch — PATH additions diffed against the base, other vars tracked and reverted like everything else.

sage, claudia and locker are that same seam one level up: a named tool moor loads on switch rather than an inline command — an ssh identity, a profile, a decrypted token stash. locker = "<name>" runs locker env <name>, folding the stash into the domain shell and reverting it on eject; a machine not enrolled for that stash gets nothing, so the switch never breaks on it.

sage names the identity moor runs sage load for on switch, capturing its agent socket into the context. That starts the agent and points the shell at it, but loads no keys and presets no passphrases — so with sage alone the first push or signed commit prompts whenever the agent has gone cold (the ssh key lifetime, gpg-agent's cache TTL), and a release that connects four times prompts four times. Set preload = true and every switch into the domain also runs sage preload, warming the identity's ssh keys and gpg passphrases up front. sage skips whatever is already warm, so a switch into a live domain costs a few milliseconds and asks nothing; and since a switch happens on every crossing into the domain, a key that went cold mid-session is re-warmed at the next one rather than prompted for in the middle of a push.

preload is the one part of a switch that may ask for anything, so it talks to the terminal directly — the hook discards a switch's stderr, which would otherwise swallow the prompt and leave the cd silently waiting. Ctrl-C at the prompt skips the warm-up and keeps the switch. Without a terminal (cron, a systemd unit) it is skipped, and it never runs from moor status or moor exec. preload requires sage.

The warm-up runs before the switch captures the domain's environment. When it unlocks sage's keyring, an integration that reads from the keyring finds it unlocked: with locker's identity kept in sage's keyring, the domain's tokens arrive with the same switch that asked for the keyring passphrase, rather than with the next one.

Outside every domain

[outside] is what a shell standing in no domain gets: sage, preload, locker and env, meaning what they do on a domain, and nothing else. It is not a domain. A domain rooted at ~ would contain every directory, so the shell would never be outside it: the user tier would always sit ahead of ~/.local/bin, the prompt would always carry an anchor, and that domain's stash would follow into every other. [outside] has no root, tools, paths, hooks or prompt anchor — moor refuses any of those keys there — and it never touches PATH.

Leaving every domain switches to it like to any other domain, and entering one reverts it like any other: a shell that starts inside amedia decrypts amedia's stash and warms amedia's keys, and never the outside layer's. moor eject still drops everything, back to the shell from before moor, and so does a moor that is missing or broken.

Without [outside], the layer a shell keeps outside every domain is whatever ~/.zshrc loaded before moor's hook — which then also loads, and may prompt for, in a shell that starts inside a domain.

Tools and their tiers

Every tier you own is declared in the registry. One file rather than a mise.toml per directory: it is all yours, so it needs no trust check and no discovery, and the shell already re-switches when it changes. From lowest precedence to highest:

Tier Declared in Applies
user [tools] everywhere, in and out of every domain
workspace [workspace.tools] in every domain under the workspace
domain [domains.<name>.tools] in that domain
repo a repo's own .tool-versions, a committed mise.toml's [tools], or .nvmrc, .node-version, .python-version at and below its directory
project [[domains.<name>.projects]] at and below its path

A tool is named by where it comes from — github:owner/repo, forgejo:host/owner/repo, aqua:owner/repo, go:module/path, npm:package, pipx:package, or a vendor's own build: golang:go, nodejs:node, pbs:python, temurin:java, rustlang:rust, gcloud:gcloud, gcloud:<component> — or by a bare name moor knows as shorthand for one: go, node, python, java, rust, gcloud, or a short name for an aqua package (kubectl, helm, just, …). It is pinned to an exact version, a prefix such as "1.25", or "latest". An inline table limits it to some platforms: pbcopy = { version = "latest", os = ["linux"] }. For a github: or forgejo: tool whose release provides a command not named after the repository, bin names it: "forgejo:git.meatbag.se/varl/subsyt" = { version = "latest", bin = "subsytctl" }.

github:<owner>/<repo> and forgejo:<host>/<owner>/<repo> are installed by moor itself, into ~/.local/share/moor/tools/<tool>/<version>/: the newest stable release the pin allows (drafts, prereleases and tags that are not versions are skipped), the asset for this OS and architecture, unpacked whole so whatever ships beside the binary stays with it. The download is checked against the sha256 the release states — the host's per-asset digest, or a checksum file in the release (SHA256SUMS, *checksums.txt, <asset>.sha256) — and refused on a mismatch; a release that states none has the hash it arrived with recorded in the install's .moor-install.json. Release lists are cached for an hour; --upgrade always asks again. Public repositories need no token; GITHUB_TOKEN raises GitHub's rate limit, and MOOR_TOKEN_<HOST> (MOOR_TOKEN_GIT_MEATBAG_SE) is sent to a Forgejo host.

aqua:<package> (aqua:kubernetes/kubernetes/kubectl, aqua:astral-sh/uv) is installed by moor too, from what aqua's registry says about the package: where each version's download is — a release asset or any URL — how it is packed, which commands are in it and where, and where the release states its checksum. The registry is read at a tag moor pins, fetched once and cached; aqua's maintainers keep the data, moor implements the part of its schema it needs and names anything outside it (a package built from source, say) rather than guessing. Only the commands a package declares go on PATH, from a .bin directory in its install, so the libraries and data an archive carries stay off it. A macOS .pkg is unpacked with pkgutil, without installing it. The short names in use (kubectl, helm, kustomize, just, yq, uv, …) stand for their aqua packages, so a repo's .tool-versions pinning kubectl shadows the domain's aqua:kubernetes/kubernetes/kubectl and shares its install.

go:, npm: and pipx: packages are installed through their own package manager — go install, npm install, uv tool install — into the store, with the manager from the tools in force, so they install after everything else. A latest or prefix pin takes final versions only, and npm's min-release-age is honoured. Only an npm package's own commands go on PATH, not its dependencies'.

A vendor's own build is named by its source, one prefix per source: golang:go from go.dev (GOROOT set), nodejs:node from nodejs.org, pbs:python from python-build-standalone (python linked to python3), temurin:java from Eclipse Temurin (JAVA_HOME set), rustlang:rust from the manifests the Rust project publishes per release — its components merged into one toolchain as rustup's default profile would have them, docs and LLVM tools left out — and gcloud:gcloud from Google's release bucket, checked against the md5 Google keeps. gcloud's components install inside it and are lost with each new version, so a component that is a program of its own is a tool instead: "gcloud:gke-gcloud-auth-plugin" = "latest".

A bare name moor knows none of is not guessed at: it is missing on every switch, and moor sync says to name its backend.

The nearest tier wins, one tool per command, and a shadowed version is absent rather than merely later on PATH — so nothing that exists only in it leaks through. kubectl and aqua:kubernetes/kubernetes/kubectl compete for the same command.

The repo tier is the one read from the filesystem: pins a repository carries for itself, which colleagues on asdf, mise, nvm or uv share. Within one directory a file pinning many tools wins over one pinning a single tool, as mise ranks them: .python-version, .nvmrc, .node-version, then .tool-versions, .mise.toml, mise.toml. moor reads them for their pins only (asdf's names mapped, so nodejs is node, and short names to their packages); nothing else in such a file — a mise.toml's [env], its tasks — is read, and pins moor cannot honour (system, ref:, path:) are named in moor status. Pin files at a domain root are not a repo tier: the root is the registry's to declare.

A project entry is where a directory below a root gets something of its own: tools that override the domain's and the repo's pins, and an env_file — a dotenv file loaded into the context there. Naming the file in the registry is the opt-in, which is what keeps a cloned repo from loading one by itself. Values are taken as written (single quotes literal, double quotes with \n-style escapes); nothing is expanded.

A pin to a version that is not installed leaves that tool off PATH: it replaces the version below it rather than falling back to it, since running node 24 where a repo asks for 16 breaks builds in ways that are hard to trace. The switch says so as the shell arrives — one line per place that declared such a pin, naming it and saying to run moor sync — and moor status names it too; moor sync there installs it. Knowing costs the switch nothing: what is installed is read from the store it reads anyway. A switch never installs anything and never touches the network: a pin resolves to the newest installed version that satisfies it.

langs and the tools are separate declarations — a lang redirects a runtime's caches, a tier provides the runtime — and nothing ties them together. Declaring langs = ["node"] with no node in any tier is otherwise silent: the switch exports NPM_CONFIG_PREFIX and prepends an empty .moor/npm/bin for an npm that does not exist, which looks like a working context until something needs node. sync and status therefore warn when a declared lang has no runtime on the PATH they produce. The test is that PATH, not the tool list, so a runtime from any source counts — a lower tier, the system package manager, a paths entry — and only a genuinely inert cache warns.

The workspace root is itself a domain unless the registry says otherwise: moor declares one there, named after the directory (dev for ~/dev), so the workspace tier activates where it is declared — cd ~/dev gets you its tools — and in any directory under the workspace that no declared domain claims. Declared domains still win inside their own roots, the deepest root being the one that counts. Declaring a domain at the workspace path, under any name, replaces the implicit one, which is how to give it a sage identity or env of its own; a declared domain that merely shares the name keeps it, and then the root goes undeclared.

moor status prints the ladder in force where you stand, lowest tier first:

tiers
TIER           DECLARED IN                             TOOLS  REACHES
user           config.toml [tools]                     5      every shell, whatever the directory
workspace      config.toml [workspace.tools]           4      every domain under ~/dev
domain.amedia  config.toml [domains.amedia.tools]      24     ~/dev/amedia
repo           ~/dev/amedia/apps/tweak/.tool-versions  1      ~/dev/amedia/apps/tweak and below

moor sync installs what is missing among the tools in force where it runs — where it runs, not where the domain root is, so a repo pinning its own versions is provisioned by syncing from inside it, exactly as the switch there activates it. Naming domains, or --all, provisions their roots instead. Standing outside every domain, sync installs the user tier.

Every sync then links the user tier's commands into ~/.local/bin, straight to the installed versions. That is the user tier's one way onto PATH: a switch prepends only the tiers above it, so a domain or repo pinning the same tool wins inside it, and a local build left in ~/.local/bin wins everywhere else. A sync that moves a version relinks it. A name there is taken over only when it is free or already a link into moor's store, and only links moor recorded making (in ~/.local/state/moor/links) are ever removed — anything else in ~/.local/bin is left alone, with a warning when it is in the way. moor sync --relink moor,sage gives up those local builds for the release, replacing each in place — moor's own included, which removed by hand would leave no moor to link it.

Keeping a tier current

A pin says which version is wanted; it does not install itself again when the answer changes. latest is the case where that gap is the whole point — the tools pinned latest are pinned that way because you want whatever is newest, and a plain sync gives you whatever was newest the day it last ran. moor sync --upgrade re-resolves each pin first:

moor sync -u              # this domain, and every tier above it
moor sync -u --all        # every domain in the registry
moor sync -u -n --all     # what that would do, without doing it

A pin's bound still holds. latest goes to today's latest; go = "1.26" finds the newest 1.26.x and never 1.27. Crossing that boundary means changing what the tier declares, and moor does not write a tier. Partly because sweeping a major version across every domain unattended is not what "keep things current" should mean, and partly because the registry is generated (chezmoi's), so a version moor wrote in place would silently revert at the next chezmoi apply. Bumping a pin is an edit to the registry.

Every domain's sync includes the user and workspace tiers, so --all re-resolves the shared tiers once per domain. Idempotent, just repetitive in the output.

The lock

What each registry tier's latest and prefix pins resolved to is recorded in moor.lock, beside the registry: per tier, the pin and the version ("golang:go" = { pin = "1.25", version = "1.25.14" } under ["domain.amedia"]). A switch, moor status and a plain sync hold a locked pin at that version exactly — installing it where it is missing, and calling it missing rather than running something newer — so every machine sharing the registry runs the same versions. A plain sync locks what it finds unlocked; moor sync -u resolves past the lock and moves it. Share it the way the registry is shared: with chezmoi, manage ~/.config/moor/moor.lock as a symlink into the source tree, and moor writes through it, so a sync's changes show up there to commit. A sync keeps entries for tools another platform declares and drops those no tier declares any more; an edited pin voids its entry. Repo pins are not locked — they are the repo's, and exact already — and neither are exact pins. moor tiers <tier> shows each pin's locked version.

--dry-run (-n) reports what the same sync would do and does none of it, in one table across every domain in scope — a tool a shared tier declares is listed once, and rows read lowest tier first. Without -u it lists what is missing and is local; with -u it adds what an upgrade would move, which asks the network:

$ moor sync -u -n
▸ would upgrade 4 tools
TOOL         INSTALLED  TARGET   TIER
codex        0.159.3    0.162.1  user
uv           0.12.21    0.13.0   workspace
go           1.27.1     1.27.2   vlv
node         26.10.0    26.11.1  vlv
  run `moor sync -u` to do it

A tool is named by the command it provides, and its tier by user, workspace, the domain, or the directory of the repo or project that pins it.

--tier (-t) limits a sync, dry or not, to some tiers, comma-separated: user, workspace, domain (every domain's own), domain.<name> (that one domain's — the registry's [domains.<name>.tools]), repo, project. These are the labels a dry run uses. A domain named there is the scope too, so moor sync -u -t domain.amedia upgrades amedia's tier from anywhere; what the other tiers declare stays as it is.

Standing in a repo, the repo tier is that repo's pins. Anywhere else — a domain's root, or a domain named from elsewhere — it is every repo's under the domain, found as moor prune finds them — less the domain's skip — and only -t repo installs it: a plain sync there leaves the repos alone.

moor sync -u -t user                 # the tools that follow you everywhere
moor sync -u -t workspace,domain.vlv # the workspace tier and vlv's own
moor sync -u -n --all -t domain      # what every domain's own tier would move
moor sync --all -t repo              # what every repo under every domain pins

moor status shows the tiers in force where you stand; moor tiers lists every one the registry declares — each domain's, empty or not, and every project entry — with the name --tier takes for it. moor tiers domain.amedia lists one tier's tools and pins:

$ moor tiers
TIER           DECLARED IN                                    TOOLS  REACHES
user           config.toml [tools]                            15     every shell, whatever the directory
workspace      config.toml [workspace.tools]                  4      every domain under ~/dev
domain.amedia  config.toml [domains.amedia.tools]             24     ~/dev/amedia
domain.vlv     config.toml [domains.vlv.tools]                3      ~/dev/vlv
project        config.toml [domains.amedia.projects] reports  -      ~/dev/amedia/reports and below, loads .env

A missing tool shows - installed and its pin as the target. moor status never touches the network; this is where to look for what is behind.

Pruning the store

An upgrade installs beside what it replaces, so the store keeps every version it was ever asked for. moor prune lists the ones nothing on this machine would run any more, with what they take; moor prune -y removes them. A version is kept while anything resolves to it: a registry tier's pin (the newest installed version it allows), the version the lock holds a pin to, or a repo pin anywhere under a domain. Repo pins are found by walking the domains for pin files — hidden directories and node_modules aside — so a repo not visited in months keeps what it pins; a pin file moor cannot read keeps nothing, and says so. A domain's skip — directories below its root, relative to it — leaves copies and scratch clones out of that walk, so their pins are neither kept nor installed by moor sync -t repo; a shell standing in one still gets them.

A domain without a shell

The hook makes a domain somewhere you are. moor exec makes it something you can hand to a process:

moor exec -- mvn test           # in the domain the cwd implies
moor exec -d amedia -- ./deploy # in a named one, from anywhere
moor exec -d amedia             # or just $SHELL there

The context is applied by starting the process with it rather than by printing a snippet for a shell to eval, so it holds for things that are not shells at all — a build, a systemd unit, a cron job — and it involves no shell dialect, which is the one part of moor that is not zsh-specific.

An interactive zsh needs none of this, tmux panes included: each one works out its own context from where it opens, at its first prompt, whatever environment the tmux server was started with.

How a switch works

The hook (moor hook zsh) is all zsh, and asks moor one question: what does switching here mean. Before every prompt and after every cd it computes where the shell stands — the deepest domain root above $PWD, every repo pin file on the way up with its mtime, the registry's mtime — and compares that with the last answer. Unchanged, it is done. Outside every domain it ejects by itself, unless the registry declares [outside], which only moor can build. Otherwise it evals moor switch -q, with stderr discarded: a switch is silent, and moor status is where a context that came out short is explained.

moor switch <domain> prints a zsh snippet that:

  1. reverts every var the previous switch exported (tracked in MOOR_VARS) — back to the value it had before moor overrode it (MOOR_PREV), or unset where it had none,
  2. rebuilds PATH as the domain's prepends over the current PATH with the previous domain's entries removed (tracked in MOOR_PATH),
  3. exports the tools' env for the directory, the language-cache vars (GOPATH, NPM_CONFIG_PREFIX, …), MANPATH and zsh fpath entries probed from the tool paths, and the domain's own env table,
  4. passes sage's own export SSH_AUTH_SOCK… line through verbatim.

Each integration it runs — the toolchain, sage, claudia, locker, a hook — gets two seconds and no terminal; one that misses, or fails, is left out and the switch goes on without it. Nothing a cd triggers can hang, and the one thing that can prompt is a domain's preload, on the terminal, skippable with Ctrl-C.

What moor records is what it did — the names it set, the values it replaced, the PATH and fpath entries it added — never a copy of the surrounding environment. That is what makes the reversal exact. A snapshot cannot tell moor's changes from anyone else's, so restoring one discards every unrelated change made since; it also freezes, which is what a tmux server started inside a domain hands to every pane it opens for the rest of its life. Reverting a delta instead means a PATH entry added while a domain was active survives the eject, and SSH_AUTH_SOCK goes back to your own agent rather than to nothing.

State lives in the shell's environment (MOOR_DOMAIN, MOOR_ROOT, MOOR_VARS, MOOR_PREV, MOOR_PATH), plus one shell-local array for the fpath dirs, since fpath is not environment and a child shell starts with its own. MOOR_PREV is quoted shell words rather than anything opaque, so zsh reads it without moor — which is how the fallback works: when moor is missing, or a switch fails, the hook reverts the shell to its pre-moor state by itself. The key it compares against is shell-local too, so a child shell never trusts the context it inherited; it works out its own at its first prompt.

What it costs

A prompt that changes nothing costs tens of microseconds of zsh; a switch costs a moor process plus one process per integration the domain declares. The benchmarks measure both against a synthetic registry and store (internal/fixture), never your own:

just bench                       # every benchmark, six rounds
just bench-compare origin/main   # a ref against the working tree, via benchstat

go test also holds a switch to the processes its domain declares (TestBuildStartsOnlyTheDeclaredProcesses) — a regression the benchmarks' noise could hide is one that fails outright.

Moving tiers out of mise.toml

Before the registry carried the tiers, each was a mise.toml: mise's global config for the user tier, one at the workspace root, one at each domain root. moor reads none of them now, and moor status names any still on disk with the table its tools belong in. Move each [tools] table into the registry, turn a subproject's [env] _.file = ".env" into a project entry's env_file, delete the old files, and run moor sync --all. A [settings] table has no counterpart.

Migrating a domain off nix

  1. Write the domain's [domains.<name>.tools] from the old shell.nix buildInputs.
  2. moor sync <domain>, then keep the caches: mv .nix-go .moor/go, mv .nix-npm .moor/npm, mv .nix-node .moor/node, and so on.
  3. Delete shell.nix and .envrc; drop the direnv wiring from ~/.zshrc once no domain uses it.