- Go 99%
- Just 1%
|
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
|
||
|---|---|---|
| .just | ||
| .woodpecker | ||
| docs | ||
| internal | ||
| .gitignore | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
moor
Switch the shell between development domains. A domain is a context under the
workspace — client work, company work, private tools — and m amedia/foo
drops you into a checkout with everything that context implies activated: the
mise toolchain declared in the domain root, per-domain language caches, profile
env vars, and the sage ssh identity. A domain is a shell: m enters a
subshell, exit leaves it, and what you return to is the un-moored shell you
started from, unchanged. Inside, activation is an eval'd snippet like sage load and skiff switch, so m from within a domain replaces the subshell
rather than nesting — one exit is always the way out. No per-cd hook, no nix,
no direnv.
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.
Tool provisioning is delegated to mise — each domain
root carries a plain mise.toml, and moor uses mise purely as a provisioner
and env printer (no mise shell activation is installed).
Install
Requires Go 1.26+ and just.
just install # builds and copies to ~/.local/bin ($XDG_BIN_HOME)
Then in ~/.zshrc, after compinit:
eval "$(moor hook zsh)"
Usage
m amedia/foo # subshell in ~/dev/amedia/foo, amedia activated
m vardevs # ... at the domain root, vardevs activated
m . # re-enter, syncing context to the current directory
exit # leave the domain — back where you were, un-moored
m # same thing, spelled as a jump to the workspace root
m forks/thing # plain cd — no domain either side, no shell to enter
moor exec -- mvn test # run it with this domain activated, no shell entered
moor exec -d amedia -- ./ci # ... in a named domain, from anywhere
moor status # active domain, the tier ladder, the registry (bare `moor` too)
moor status -o # ... plus what is behind the newest its pin allows
moor sync # provision what applies here — this domain, or the user tier
moor sync --all # mise install + cache dirs for every domain
moor sync -u --all # ... moving each tool to the newest version its pin allows
moor switch amedia # the raw snippet a moor shell evals (also: eject, hook)
moor jump foo/bar # the raw snippet m() evals — what jumping there means
moor resolve foo/bar # the domain and directory a target names
^G fuzzy-finds a target and jumps to it, when fzf
is installed. It is a thin widget over moor targets, which prints the
directories m takes — every domain, what is under each domain root, and the
workspace's own directories:
m "$(moor targets | fzf)" # what ^G does, spelled out
bindkey '^Y' moor-jump # if ^G is spoken for
MOOR_FZF_OPTS=(--height=12 --reverse --prompt='m ') # 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 rather than in m itself, deliberately: a
picker is a view over the targets, free to guess and rank, while what m foo
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.
Targets are workspace-relative, and the workspace's own name is its root — so
with workspace = "~/dev", m dev is m. Absolute paths are taken as given;
the domain comes from where you land either way.
Config
The registry is explicit TOML at ~/.config/moor/config.toml — see
config.example.toml:
workspace = "~/dev"
[domains.amedia]
path = "~/work/amedia" # root; defaults to <workspace>/<name>
sage = "amedia" # sage identity to load on switch
claudia = "amedia" # claudia profile to load 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)
[domains.amedia.env]
K8S_OBJECTS_DIR = "/home/varl/dev/amedia/tools/k8s-objects"
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 m
resolves bare targets in, and where bare m takes you.
Roots may nest when they are named explicitly, in which case the deepest one containing a directory is the domain that directory is in. Note that a domain outside the workspace gets no workspace-level mise tier — mise merges configs from parent directories, and the workspace is not one of its parents.
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.
Tools live in the domain root's mise.toml, mise-native:
[tools]
go = "1.26"
kubectl = "latest"
"ubi:gini/dexter" = "0.5.2"
langs and [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. switch, 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.
Subprojects go one tier below the domain: a mise.toml inside a checkout
(e.g. [env] _.file = ".env" to load that dir's dotenv) joins the context
when you m into it — moor captures mise env at the directory you land in,
and mise merges every config on the way up. New config files need a one-time
mise trust; an untrusted config anywhere in the chain makes mise refuse the
whole capture (moor degrades to a warning).
A mise.toml at the workspace root is the tier above: mise merges parent-dir
configs, so its tools reach every domain under the workspace (moor applies it
to config-less domains too) without touching the bare system. Above that is
mise's own global config, ~/.config/mise/config.toml — the user tier, read
whatever the cwd, for tools that follow you everywhere. Machine-level packages
stay with the system package manager.
moor status prints that ladder, lowest tier first, with the file each one
lives in and which are in play where you stand:
tiers
TIER CONFIG REACHES
user ~/.config/mise/config.toml every shell, whatever the directory
workspace ~/dev/mise.toml every domain under ~/dev
domain ~/dev/vlv/mise.toml vlv
moor sync provisions the tiers that apply where it runs — where it runs,
not where the domain root is: a bare sync installs at the cwd, so a subproject
pinning its own versions (a mise.toml or a .tool-versions under the domain)
is provisioned by syncing from inside it, exactly as the switch there activates
it. Language caches remain the domain's. Standing outside every domain, sync
has only the tiers mise resolves at the cwd, which in ~ is the user tier
alone. Naming domains — or --all, which walks the registry — provisions their
roots instead, that being all a sync run elsewhere can honestly reach. Note that moor only installs; publishing a user-tier tool as a
command on PATH is a separate step (here, a chezmoi script that links mise's
shims into ~/.local/bin).
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 status -o # what that would move, without moving 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 — mise will
(mise upgrade --bump), and moor deliberately does not use it. Partly because
sweeping a major version across every domain unattended is not what "keep
things current" should mean, and partly because these files are generated: all
but one are chezmoi's, so a version moor wrote in place would silently revert
at the next chezmoi apply. Bumping a pin is an edit, made where the tier is
authored.
Because mise merges parent configs, an upgrade at any domain root also reaches
the workspace and user tiers — so --all re-resolves the shared tiers once per
domain. Idempotent, just repetitive in the output.
moor status --outdated adds a column to the tier ladder and lists what is
behind. It asks mise, which asks the network, so it is opt-in rather than part
of a bare moor:
tiers
TIER CONFIG REACHES OUTDATED
user ~/.config/mise/config.toml every shell, whatever the directory 4
workspace ~/dev/mise.toml every domain under ~/dev 1
domain ~/dev/vlv/mise.toml vlv 2
aqua:openai/codex 0.146.0 → 0.150.1 (~/.config/mise/config.toml)
go 1.26.5 → 1.26.7 (~/dev/vlv/mise.toml)
uv 0.11.31 → 0.12.6 (~/dev/mise.toml)
A tool that is not installed at all is missing, not outdated: it stays out of
this list, because moor sync is its fix and an upgrade would be the wrong
thing to reach for.
A domain without a shell
m 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, an editor's integrated terminal — and it involves no shell dialect, which is the one part of moor that is not zsh-specific.
Pointing tmux at it gives every new pane a context computed from where it opens, rather than inherited from whatever the server happened to be started inside:
set -g default-command "moor exec"
zsh completions are the one thing an exec cannot carry: fpath is a shell
array, not an environment variable. An interactive shell applies those itself
at startup, so moor exec deliberately leaves MOOR_FPATH unset for it to
fill.
How a switch works
m <target> asks moor jump what the jump means and evals the answer; that is
the whole of m, which decides nothing itself. Arriving in a domain execs a
zsh there — a fresh subshell, or, when already in one, in place of it, so a
single exit is always the way out however far you roam. That shell's own
startup line (installed by moor hook) runs moor switch -q, which activates
the domain the cwd implies and ejects when it implies none, so the context is
always a function of where the shell stands. A destination outside every domain
has nothing to activate and so earns no shell: it is a cd, ejecting in place
whatever context the shell carries.
m needs the shell for exactly two things no child process can do — mutating
the shell that called it, and reporting that shell's $$. The PID is passed as
--pid and checked against $MOOR_SHELL, which holds the PID of the shell moor
spawned rather than a bare flag: a flag would be inherited by every descendant,
and a tmux server started inside a moor shell hands its whole environment to
every pane it opens thereafter. Each pane would then claim to be a shell it is
not, and m alone would kill it instead of returning to a shell it never came
from. An inherited PID belongs to another process and simply fails the check.
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 mise env for the domain root, 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.
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 moor shell 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 only in the shell's environment (MOOR_DOMAIN, MOOR_VARS,
MOOR_PREV, MOOR_PATH, MOOR_FPATH, plus MOOR_SHELL marking a moor
subshell), so a switch is a pure function of the registry plus the previous
switch — nothing to get stale, nothing layered. A subshell inherits that state
and reverts it on entry, which is why replacing one leaves no residue of the
domain it replaced.
Migrating a domain off nix
- Write the root's
mise.tomlfrom 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.