- Go 99.4%
- Just 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .just | ||
| .woodpecker | ||
| docs | ||
| internal | ||
| .gitignore | ||
| bench_test.go | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
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:
- 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, - rebuilds
PATHas the domain's prepends over the currentPATHwith the previous domain's entries removed (tracked inMOOR_PATH), - exports the tools' env for the directory, the language-cache vars
(
GOPATH,NPM_CONFIG_PREFIX, …),MANPATHand zshfpathentries probed from the tool paths, and the domain's ownenvtable, - 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
- Write the domain's
[domains.<name>.tools]from the oldshell.nixbuildInputs. 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.- Delete
shell.nixand.envrc; drop the direnv wiring from~/.zshrconce no domain uses it.