No description
  • Go 99%
  • Just 1%
Find a file
Viktor Varland eb060f6c88
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v0.5.0
2026-08-28 17:43:34 +02:00
.just feat: install to the XDG bin dir 2026-08-01 23:56:49 +02:00
.woodpecker ci: update to wrangle owned 2026-08-17 22:42:42 +02:00
docs docs: hand off restoring the darwin release build 2026-08-09 11:07:49 +02:00
internal fix: ignore a relative XDG_CONFIG_HOME 2026-08-28 16:36:46 +02:00
.gitignore chore: ignore local agent settings 2026-08-01 23:56:49 +02:00
CHANGELOG.md docs: changelog for v0.5.0 2026-08-28 17:43:34 +02:00
config.example.toml feat: first-class claudia profile per domain 2026-07-23 20:14:13 +02:00
go.mod refactor: take shell quoting from gout 2026-08-18 08:23:02 +02:00
go.sum refactor: take shell quoting from gout 2026-08-18 08:23:02 +02:00
justfile feat: install to the XDG bin dir 2026-08-01 23:56:49 +02:00
main.go feat: keep tiers current with sync --upgrade 2026-08-28 07:18:35 +02:00
main_test.go feat: keep tiers current with sync --upgrade 2026-08-28 07:18:35 +02:00
README.md feat: keep tiers current with sync --upgrade 2026-08-28 07:18:35 +02:00

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:

  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 mise env for the domain root, 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.

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

  1. Write the root's mise.toml 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.