Shell and terminal config, synced between my Mac (Ghostty) and remote dev VMs. One Dark theme everywhere, zsh with antidote instead of oh-my-zsh, starship for the prompt, NvChad for editing.
| Path | Links to | What it is |
|---|---|---|
zshrc |
~/.zshrc |
zsh config: completion, antidote plugin load, history, aliases, tool init hooks |
tool-versions |
~/.tool-versions |
mise runtime pins (node, python, go, bun, pnpm) — mise walks up from the current directory to find this, so the symlink is the global default under $HOME; see mise below |
zsh_plugins.txt |
~/.zsh_plugins.txt |
antidote's plugin list (zsh-autosuggestions, zsh-syntax-highlighting, fzf-tab, zsh-vi-mode) |
tmux.conf |
~/.tmux.conf |
tmux config, plugins managed by TPM |
config/starship.toml |
~/.config/starship.toml |
prompt — One Dark Pro preset, hostname shown only over SSH |
config/ghzinga/config.toml |
~/.config/ghzinga/config.toml |
ghzinga — GitHub issue/PR viewer TUI that the herdr plugin shells out to |
config/herdr/config.toml |
~/.config/herdr/config.toml |
Herdr (agent terminal workspace manager), one-dark theme + accent/border overrides |
config/herdr/palette |
~/.config/herdr/palette |
the prefix+p command palette — an fzf script run by a type = "popup" keybinding, plus the MIT notice of the plugin it's derived from |
herdr_plugins.txt |
(not linked — read by install.sh) |
Herdr plugin list, one owner/repo[@ref] per line; install.sh installs/updates each one |
config/herdr/plugins/config |
~/.config/herdr/plugins/config |
per-plugin Herdr config, one directory per plugin id — the whole tree is linked, so new plugins land here on install |
config/ghostty/config |
~/.config/ghostty/config |
Ghostty terminal: One Dark Two theme, shell integration — same path on macOS and Linux |
config/atuin/config.toml |
~/.config/atuin/config.toml |
Atuin (shell history): overrides only — daemon, fuzzy search, full-style UI, vi keymap, tmux popup, atuin ai. Also the answers atuin setup would otherwise re-ask on every install |
config/atuin/themes/one-dark.toml |
~/.config/atuin/themes/one-dark.toml |
One Dark for Atuin; foreground colors only, background comes from Ghostty |
config/nvim |
~/.config/nvim |
NvChad starter — vendored once, .git stripped, fully mine to edit from here |
config/zed/settings.json |
~/.config/zed/settings.json |
Zed editor settings — disable_ai: true since agents run from the terminal via omp, not inside the editor, so the agent/agent_servers keys go undefined rather than tracked as dead config. ssh_connections is also deliberately dropped: it's per-machine session state Zed rewrites on every connect |
gitconfig |
~/.gitconfig |
tracked git identity, LFS/xet filter wiring, and defaults meant to hold on every machine; anything that varies per machine layers in through gitconfig.local.example below |
gitconfig.local.example |
(copy, not linked) | template for ~/.gitconfig.local — work identity via includeIf "gitdir:…", private-registry credentials. gitconfig's trailing [include] applies last, so anything set here wins over every default in the tracked file |
config/git/ignore |
~/.config/git/ignore |
global gitignore — git's own default core.excludesFile location when that setting is unset, so machine-tool droppings (.DS_Store, .idea/) never have to live in a project's own .gitignore |
config/gh/config.yml |
~/.config/gh/config.yml |
gh CLI defaults and aliases; git_protocol: https is deliberate — ssh/config maps github.com to the work SSH key, so an ssh remote here would silently authenticate as the wrong account |
ssh/config |
~/.ssh/config |
portable ssh identity config — per-key Host blocks for github.com (IdentitiesOnly yes so the agent can't offer the wrong key first), github.com-personal, hf.co, runpod.io; machine-specific hosts live in ~/.ssh/config.local instead |
ssh/config.local.example |
(copy, not linked) | template for ~/.ssh/config.local — dstack's generated Include, throwaway test hosts. ssh/config's first real line is Include ~/.ssh/config.local, because ssh takes the first value it finds for any option and this is the only way the local file can override rather than be shadowed |
omp/agent/config.yml |
~/.omp/agent/config.yml |
omp coding agent settings — besides this file and rules/output-style.md below, the rest of ~/.omp/agent is databases, sessions, and a secrets key |
omp/agent/extensions/atuin.ts |
~/.omp/agent/extensions/atuin.ts |
records omp's bash commands into Atuin history as --author pi (a KNOWN_AGENTS name, so $all-user hides them), with omp's intent string as --intent. Hand-maintained: atuin hook install has no omp target |
omp/agent/rules/output-style.md |
~/.omp/agent/rules/output-style.md |
alwaysApply: true rule that shapes every omp response for an ADHD reader — answer first, numbered steps, one next action, no preamble or recap |
config/paseo/config.json |
(not linked — merged into ~/.paseo/config.json) |
Paseo daemon settings: which agent providers are enabled, MCP/browser-tool flags, relay off. Merged rather than symlinked because Paseo rewrites this file atomically and would replace the link, and because the live file also holds secrets; see Paseo below |
config/paseo/orchestration-preferences.json |
~/.paseo/orchestration-preferences.json |
which provider/model each delegated role gets (impl, ui, research, planning, audit). Read by Paseo's five orchestration skills, never by Paseo itself — which is what makes this one safe to link when its neighbour isn't |
config/paseo/paseo.service |
(copied to ~/.config/systemd/user/) |
systemd --user unit that supervises the Paseo daemon on Linux. Paseo ships no unit of its own. Unused on macOS, where the desktop app supervises its own daemon |
config/paseo/daemon.env.example |
(copy, not linked) | template for ~/.config/paseo/daemon.env, the unit's EnvironmentFile — this box's PASEO_LISTEN, PASEO_HOSTNAMES, and the plaintext PASEO_PASSWORD. The per-machine half of Paseo's config, kept out of the tracked file above |
Brewfile |
(not linked — read by install.sh) |
macOS formulae + casks for every tool this config drives, applied with brew bundle — replaced the old hand-maintained brew_ensure/brew_ensure_cask loop |
bin/tailscale |
~/.local/bin/tailscale |
PATH shim for the Mac App Store build of Tailscale — execs the bundled CLI directly, since a plain symlink to it fails at runtime (see the file itself for why). Linked only on macOS, and only when the App Store app is actually installed |
agent_skills.txt |
(not linked — read by install.sh) |
cross-agent skill manifest, one <owner>/<repo> --skill <name> per line; install.sh runs npx skills add … -g -y for each |
zshrc.local.example |
(copy, not linked) | template for machine-local secrets — never committed |
install.sh |
— | installs/updates every tool below, then symlinks all the config above into place — locally, or on another machine with --host |
git clone https://github.com/andyhite/dotfiles.git ~/dotfiles
~/dotfiles/install.shinstall.sh runs nine steps, and is safe to re-run any time (installs what's missing,
updates what's already there). With a terminal attached each step asks first and Enter
accepts; without one, or with --yes, it runs everything unattended:
./install.sh --yes # unattended, everything
./install.sh --only configs # just re-link the dotfiles
./install.sh --skip tools,nvim # skip the slow parts
./install.sh --verbose # show each installer's own output
./install.sh --host vm # install on another machine over ssh
./install.sh --help # list steps and flagsOutput is one line per item: ✓ already correct, + created, ↑ updated, · skipped,
! needs your attention, ✗ failed — then a tally. Installer output is suppressed unless
something fails, in which case the last 12 lines are replayed under the failure; --verbose
shows it all. A failed step reports and the run continues, so one broken formula doesn't
cost you the rest; the script exits non-zero if anything failed. Colour follows NO_COLOR
and TERM, and the glyphs fall back to ASCII outside a UTF-8 locale.
The steps, in order — the order is load-bearing, which is why --only exists but
reordering doesn't:
-
Installs/updates the tools this config drives: starship, zoxide, atuin, fzf, eza, bat, direnv, tmux, antidote, TPM, the JetBrains Mono Nerd Font, neovim, ripgrep, tree-sitter-cli, mise, omp, and NvChad. macOS applies
Brewfilewithbrew bundle(formulae + casks, including Ghostty and Paseo themselves); Linux goes throughaptwhere a package exists, and falls back to each tool's official installer otherwise:brew bundle check --file Brewfileruns first and is the common path on a repeat install: it exits clean only when every formula/cask is already installed and current, so the slowerbrew bundle installruns only when there's real work outstanding.brew bundleitself only ever adds — dropping a line fromBrewfilenever uninstalls anything already on the machine; that takes a manualbrew bundle cleanup.- starship/atuin ship curl-able install scripts.
- eza predates its Ubuntu packaging (24.04+), so on older releases it's built from
source via
cargo— bootstrappingrustupfirst if needed, and only rebuilding when crates.io actually has a newer version than what's installed. - neovim: Ubuntu's apt package (0.9.5 on 24.04) is below NvChad's 0.11 floor, so this
pulls the official release tarball instead and merges it into
~/.local, which is already onPATH. - tree-sitter-cli: not
cargo install tree-sitter-cli— that pulls inrquickjs-sys, which needs bindgen/clang to resolve its resource-dir correctly and fails to build on stock Ubuntu (fatal error: 'stdbool.h' file not found). Uses tree-sitter's own prebuilt release binary instead. - mise: macOS gets it from
Brewfilelike everything above it; Linux has no equally universal package for it, so this runs the upstreammise.runinstaller, which drops a single binary into~/.local/bin— already onPATHviazshrc. - The Nerd Font is fetched straight from its GitHub release and installed under
~/.local/share/fonts. - NvChad: clones
NvChad/starterstraight into this repo the first time (config/nvim), strips its.gitimmediately per NvChad's own docs, then symlinks it like everything else — so all my NvChad customization lives here too, not in some separate untracked directory. - omp: install-only, via the installer at omp.sh — the binary is
~120MB and ships its own
omp update, so re-running this script skips it rather than re-downloading.
Ghostty itself is only installed on macOS — it's a local GUI app, so there's nothing to install on a headless remote box, though its config still gets symlinked in case that box ever runs Ghostty directly.
-
Generates zsh completions into
~/.local/share/zsh/site-functions, whichzshrcprepends tofpath. Homebrew already drops completions for many of these tools into its ownsite-functions, but that covers nothing on a Linux box, and tools installed outside a package manager (omp,herdr,tree-sitter) are uncovered on both. Each file is generated by the binary itself, so it can never drift from the installed version. See Completions below. -
Symlinks every config file in the table above into place. Backs up (
.bak.<timestamp>) anything real that's already sitting where a symlink needs to go. Also copieszshrc.local.example,gitconfig.local.example, andssh/config.local.exampleto their~/.*.localtargets at mode 600 the first time only — a re-run never overwrites an already filled-in file. See The*.localtemplates below. -
Installs the language runtimes pinned in
~/.tool-versions, viamise install. Runs right after the symlinks step and not before: mise reads its pins by walking up from wherever it's invoked, and~/.tool-versionsis the symlink the configs step above just created — swap the order and this step runs against nothing on a fresh machine. See mise below. -
Sets up Paseo: merges
config/paseo/config.jsoninto~/.paseo/config.json, makes sure thepaseoCLI is onPATH— a symlink to the cask's bundled binary on macOS,npm install -g --prefix ~/.local @getpaseo/clion Linux — and on Linux installs and enables thesystemd --userunit that keeps the daemon up. After the configs step because the unit reads~/.config/paseo/daemon.env, which that step creates from its template; after runtimes because the Linux CLI is an npm package. See Paseo below. -
Offers to log in to Atuin sync, but only when not already logged in and only when a terminal is actually attached. Everything else about Atuin lives in
config/atuin/config.toml; sync is account state, so it can't be committed. See Atuin below. -
Installs/updates every Herdr plugin listed in
herdr_plugins.txt. Skipped with a note ifherdrisn't onPATH— this repo configures Herdr but doesn't install it. -
Installs cross-agent skills, from two sources.
agent_skills.txtfirst — onenpx skills add <owner>/<repo> --skill <name> -g -yper line — which needs theruntimesstep above to have already put node onPATH, hence the ordering. Then any skill an installed Herdr plugin ships in its ownskills/directory, symlinked into~/.omp/agent/skills— unchanged from before, and run after Herdr because a link made before a plugin's first install would point at a path that doesn't exist yet. -
Headlessly syncs NvChad's plugins (
nvim --headless "+Lazy! sync" +qa) once neovim and the config are both in place.
--host <ssh-host> redirects the whole run to another machine instead of adding to it:
this box installs nothing. For each host it ssh's in, clones the repo at --remote-path
(default ~/.dotfiles) or fast-forwards it if it's already there, and then runs that
copy's install.sh with every other flag passed through.
./install.sh --host vm # prompts per step, as if you were sitting there
./install.sh --host vm --yes --only configs # unattended, one step
./install.sh --host vm,box --yes # two hosts in turn
./install.sh --host vm --remote-path ~/src/dotfilesFive things worth knowing:
- The remote pulls from origin, not from this working copy. Commit and push first —
the driver prints a warning when the local tree is dirty or ahead of
origin, because a run that silently installs the previous commit is the one failure mode here that looks like success. - The remote's own
install.shis what runs. Nothing is piped over the wire, so the box always ends up in a state some git ref actually describes. - A dirty remote worktree is installed as-is, with a warning and no update — that's how you try a change on the remote before committing it, and resetting someone's edits to match origin isn't this script's call to make.
- The first clone uses the public https URL. The tracked
originisgit@github.com-personal:…, aHostalias from this repo's ownssh/configthat a machine being bootstrapped doesn't have yet (nor the key it names), so the alias is resolved throughssh -Gand thehttps://github.com/…form is handed over instead.DOTFILES_REPO=<url>overrides it. An existing clone keeps whatever origin it has. - A tty is forwarded only when you have one. With a terminal on this end,
ssh -tmakes the remote's per-step prompts work normally; without one it runs unattended rather than sitting out a read timeout per step. One host failing doesn't cancel the rest, and the exit status still reflects it.
ssh runs a non-login, non-interactive shell, so the bootstrap re-adds what zshrc
would have: ~/.local/bin, ~/.cargo/bin, mise's shims (node), and Homebrew on a macOS
host. Without that every command -v guard in install.sh would decide its tool is
missing and reinstall it — the right result, reached the slowest possible way.
zshrc puts ~/.local/share/zsh/site-functions first on fpath so a completion
generated from the installed binary beats a distro's stale copy. install.sh writes one
per tool that has a generator:
| Tool | Generator |
|---|---|
| starship | starship completions zsh |
| atuin | atuin gen-completions --shell zsh |
| bat | bat --completion zsh |
| ripgrep | rg --generate complete-zsh |
| omp | omp completions zsh |
| herdr | herdr completion zsh |
| tree-sitter | tree-sitter complete --shell zsh |
| mise | mise completion zsh |
Deliberately absent, because generating a file would be worse than what already works:
eza and zoxide have no generator (Homebrew ships _eza/_zoxide); direnv and
nvim publish no zsh completion at all; fzf comes from the fzf --zsh eval in
zshrc; and zsh itself ships _tmux, _jq and _vim.
Two details worth knowing. Generation goes through a temp file and only replaces the
target when the output is non-empty, so a tool that starts erroring can't blank a working
completion. And because zshrc runs compinit -C — which trusts a cached dump rather
than rescanning fpath on every shell start — install.sh deletes ~/.zcompdump*
whenever it writes something new, so the next shell rebuilds once.
Three tracked templates, one convention: zshrc.local.example, gitconfig.local.example,
and ssh/config.local.example are copied — never linked — to their ~/.*.local targets
at mode 600 the first time install.sh runs, and left alone on every run after that, so
a filled-in file is never clobbered. Each one holds real secrets or per-machine values
that have no business in a public repo.
The two config templates exist because of how their tracked file reads the copy back, not just as a place to dump overrides:
gitconfigends with[include]/path = ~/.gitconfig.local. Git applies repeated keys in file order, so an include at the very end wins over every default set above it —~/.gitconfig.localdoesn't need to know what it's overriding, it just wins by coming last.ssh/configstarts withInclude ~/.ssh/config.local, before anyHostblock. ssh takes the first value it finds for a given option, so the include has to come first or a laterHost github.comblock would shadow it instead of losing to it. A missing include target isn't an error inssh_config, so this line is safe on a machine that hasn't runinstall.shyet.
zshrc.local.example needs no such trick — zshrc just sources ~/.zshrc.local near
the top, before the tool blocks that read values like AWS_PROFILE.
Replaces the old per-language version manager: one binary instead of a plugin per
language (no plugin add node/plugin add golang to run on every machine), and it
activates by rewriting PATH on every prompt instead of installing shims — a version
change in ~/.tool-versions is live in the shell you're already sitting in, with no
reshim step.
tool-versions pins exact versions on purpose — node 26.7.0, not "latest" — so a
machine doesn't silently drift to whatever happened to be current the day someone ran
install.sh. mise resolves .tool-versions/mise.toml by walking up from the current
directory to $HOME and beyond, which is exactly what makes the ~/.tool-versions
symlink the global default everywhere that doesn't have its own: mise takes the nearest
file, so a project with its own pins still wins inside that project.
A trailing t on a python version — 3.14.7t versus the 3.14.7 pinned here — selects
the free-threaded (no-GIL) build. That's a distinct, opt-in variant, not a typo, so don't
"fix" it if it turns up somewhere else.
Apple ships GNU make 3.81, from 2006, as both make and gnumake — so the obvious
alias make=gnumake looks like an upgrade but is a silent no-op, still 3.81. Homebrew's
make formula installs GNU make 4.x as gmake instead, specifically to dodge that name
clash, so zshrc's macOS block aliases make to gmake once it finds Homebrew's copy —
gnumake was never the one worth reaching for.
Paseo is a daemon that supervises coding agents — it launches Claude,
Codex and omp as child processes, keeps their sessions, and exposes them over an HTTP/WS
API that a desktop, mobile, web or CLI client drives. This repo's two machines use it from
opposite ends: the Mac runs the GUI app as a client, the Linux VM runs the daemon the
Mac connects to. Everything under config/paseo/ is written to do the right thing on
either, and install.sh's paseo step branches on the OS rather than on which role the
box happens to play.
How the binary arrives differs by platform, because upstream ships two very different
artifacts. macOS gets cask "paseo" — the Electron desktop app, which bundles the daemon
and a CLI at Paseo.app/Contents/Resources/bin/paseo. Linux gets
npm install -g --prefix ~/.local @getpaseo/cli, which is what upstream's own docs point
headless machines at; despite the name it isn't a thin client, since @getpaseo/cli
depends on @getpaseo/server and so carries the whole daemon. The DEB/RPM/AppImage
downloads are the Electron app again, which is the wrong artifact for a box with no
display.
Two details in that Linux install are deliberate. --prefix ~/.local rather than a bare
npm install -g: a plain global install lands inside whichever node mise currently has
active and vanishes the next time tool-versions bumps node, whereas ~/.local/bin is
where every other manually-installed tool here already lives and is already on PATH.
And on macOS install.sh makes the ~/.local/bin/paseo symlink itself rather than
relying on the app's first-run hook to do it — that hook only fires once the GUI has been
opened, and brew bundle never opens anything, so on a fresh machine paseo would be
missing from PATH until someone double-clicked the icon.
This is the one place Paseo breaks the pattern the rest of this repo follows, and it's worth spelling out because the symlink looks like it works right up until it doesn't.
Paseo saves its config atomically. savePersistedConfig calls
writePrivateFileAtomicSync, which writes a sibling tempfile and renameSync()s it over
the target — and rename replaces a symlink with a real file. So a symlinked
~/.paseo/config.json survives exactly until the first settings change made in the app,
at which point the link is silently gone: the repo still shows a tracked config, git still
shows it clean, and it no longer has any effect on anything. Nothing warns you.
There's a second, independent reason. The live file legitimately holds things that must
never be committed — paseo daemon set-password writes a bcrypt hash into daemon.auth,
and custom providers keep API keys under agents.providers.*.env.
A merge answers both. install.sh runs jq -s '.[0] * .[1]' <live> <tracked>, a
recursive object merge with the right side winning, so the tracked file is authoritative
for the keys it names and every other key in the live file — secrets and per-machine
settings alike — is left exactly as Paseo wrote it. It's idempotent, and the comparison
before writing is semantic rather than textual (jq -e '. == $want[0]') because the daemon
rewrites the file with its own key order, and a byte diff would otherwise report drift on
every single run.
The step never restarts the daemon to apply what it merged, it just prints the command.
Config is read at startup, but a restart kills every agent running under the daemon —
including, when an agent is the thing running install.sh, itself.
config/paseo/orchestration-preferences.json is symlinked, and the difference is the
point: grepping getpaseo/paseo for that filename turns up only the five shipped
SKILL.md files. Paseo itself never reads or writes it, so nothing can replace the link
behind your back. It's the dial that decides which provider/model each delegated role gets
— impl, ui, research, planning, audit — and the values are provider/model
pairs split on the first slash, where the provider half comes from paseo provider ls
and the model half from paseo provider models <provider>. A bare provider id is rejected:
create_agent's schema refuses any value without a slash. (That first-slash-only split is
what lets omp's own slash-containing model ids, like
omp/amazon-bedrock/anthropic.claude-opus-5, work at all.)
One tracked config.json shared by two machines can't hold anything that has to differ
between them, and Paseo's config precedence — defaults < config.json < env < CLI flags —
is the escape hatch. So config/paseo/config.json carries only portable policy (which
providers are enabled, MCP and browser-tool flags, relay off, CORS), and the rest goes in
environment variables:
| Where | Holds | Read by |
|---|---|---|
~/.config/paseo/daemon.env (mode 600, from config/paseo/daemon.env.example) |
PASEO_LISTEN, PASEO_HOSTNAMES, PASEO_PASSWORD |
the Linux daemon, via the unit's EnvironmentFile |
~/.zshrc.local |
PASEO_HOST, PASEO_PASSWORD |
the Mac's paseo CLI, to drive the Linux daemon |
The Mac's app isn't in that table on purpose: it keeps its list of remote hosts in app
storage under the @paseo:daemon-registry key, not in any config file, so adding the Linux
box is a one-time Settings → Add host → Direct connection and there's nothing here to
track.
The daemon binds 127.0.0.1:6767 by default, which no other machine can reach. Set
PASEO_LISTEN to this box's Tailscale address (tailscale ip -4) rather than
0.0.0.0, which would also publish it on every LAN and coffee-shop Wi-Fi the box ever
joins. Bare IP literals are accepted unconditionally, so PASEO_HOSTNAMES is only needed
when a client connects by MagicDNS name; a leading dot there is a suffix match — the daemon
tests host === base || host.endsWith('.' + base) — so .your-tailnet.ts.net covers every
host in the tailnet.
Then set PASEO_PASSWORD. Tailscale encrypts the transport and its ACLs gate who can route
to the box, but Paseo does no authentication of its own: anything that can reach the listen
address can drive every agent on that machine. Supply it as plaintext in daemon.env
rather than running paseo daemon set-password, which writes a bcrypt hash into the very
file install.sh merges from this repo.
No relay needed for any of this — daemon.relay.enabled is false in the tracked config,
because a direct Tailscale connection is already end-to-end. Relay is for reaching the box
from outside the tailnet. Note that setting PASEO_RELAY_ENABLED pins the value as a
launch override, and Paseo then refuses to let the app change relay at runtime until the
override is gone.
Paseo ships no unit: there's no systemd artifact anywhere in getpaseo/paseo, and
paseo daemon exposes only start/pair/status/stop/restart/set-password with no
service installer. So config/paseo/paseo.service is maintained here, copied (not
symlinked — systemd resolves unit paths itself and daemon-reload is what publishes a
change) to ~/.config/systemd/user/.
Three things in it are load-bearing:
--foreground.paseo daemon startdaemonizes by default; the launcher spawns the supervisordetached: trueandunref()s it. Without the flag systemd would watch the launcher exit immediately and declare the service dead while the real daemon kept running unsupervised.- No
PIDFile=.$PASEO_HOME/paseo.pidis JSON, not the bare integer systemd expects. - An explicit
Environment=PATH=, andmise exec. systemd never sources~/.zshrc, so neither the pinned node the npm launcher'senv -S nodeshebang needs, nor the agent CLIs the daemon launches, would otherwise be findable.
A --user unit rather than a system one, because the daemon runs agent CLIs that read
per-user credentials (~/.claude, ~/.codex, ~/.omp). The catch is that user units
normally start at login, which never happens on a box only reached over ssh — so the step
offers to run loginctl enable-linger, which is what brings the user manager up at boot.
It asks rather than assumes, since that needs root and a box where the daemon only has to
run while you're logged in doesn't need it. The unit is enabled but not started: starting
it would be a surprise on a box already running a daemon under different launch overrides,
and restart on an active unit kills its agents.
agent_skills.txt lists Paseo's five orchestration skills (paseo, paseo-advisor,
paseo-committee, paseo-handoff, paseo-loop) from getpaseo/paseo. The macOS app also
copies them into ~/.agents/skills on first GUI launch, but that hook is useless here
twice over: it never fires on the Linux box, which has no desktop app at all, and on the
Mac it only fires once someone opens the app, which brew bundle never does. Going through
the manifest means both machines get them from install.sh alone, and they update on the
same schedule as everything else rather than whenever the app is next launched.
One dial deliberately left off: daemon.mcp.injectIntoAgents stays false. Turning it on
gives every agent Paseo launches the full create_agent/create_workspace tool surface,
which is what those skills drive — but it's a behaviour change to every agent on the
machine, so it's a decision to make on purpose rather than something a dotfiles install
flips for you.
config/atuin/config.toml holds overrides only; run atuin default-config to see the
full annotated template. It exists mainly so atuin setup never runs: that wizard is
what asks about Atuin AI and the daemon, and the upstream installer re-ran it on every
single install.sh. With the answers committed, install.sh passes --non-interactive
and the wizard has nothing left to decide.
Sync login is the exception — it's account state, not config — so install.sh prompts
for it, but only while genuinely logged out (atuin status exits non-zero) and only when
/dev/tty can actually be opened. The read is bounded at 30s, so a backgrounded or
piped run can't hang on it.
omp/agent/extensions/atuin.ts records commands omp runs through its bash tool into
the same history, tagged --author pi — omp is a distribution of pi, and "pi" is one of
the five names in Atuin's KNOWN_AGENTS, which is what makes the agent pseudo-filters
work:
atuin search --author pi # just the agent
atuin search --author '$all-agent' # any known agent
atuin search --author '$all-user' # just me$all-user is applied to every interactive search, so agent rows stay out of Ctrl+R
and out of the up-arrow list without any configuration. The one gap is a typed Ctrl+R
query: it's answered by the daemon's index, which has no author column, so agent rows
reappear there. The up-arrow search is exempt because it's pinned to the sqlite engine
(search_mode_shell_up_key_binding).
Rows recorded before this switch keep --author omp and behave like hand-typed commands.
Retagging them isn't worth it: history.db is a projection of the record store, so a
sqlite UPDATE would be reverted by the next atuin store rebuild history.
Two things had to be fixed for Atuin to behave under zsh-vi-mode, and both fail
silently, so they're worth knowing about if either ever regresses.
Ctrl+R. Being last in zshrc isn't enough to own it. zsh-vi-mode rebuilds the
viins/vicmd keymaps during its own init, which runs on the first precmd — after
every line of zshrc — and its insert mode re-binds ^R to zsh's builtin
history-incremental-search-backward. The binding Atuin installed survived only in the
emacs keymap, which vi mode never uses, so Ctrl+R was the builtin search. zshrc
now defers atuin init zsh into zvm_after_init_commands (still binding inline when the
plugin isn't loaded) and adds the vicmd ^R that Atuin itself leaves to fzf. Check
it with:
for m in emacs viins vicmd; do bindkey -M $m "^R"; done # all three -> atuin-search*Keymap mode. The widgets Atuin installs pass --keymap-mode=vim-insert/vim-normal,
but config beats that flag for every value except auto, and the default is emacs —
so the search opened in emacs keymap and threw the shell's actual mode away. Hence
keymap_mode = "auto" in the config.
The tmux popup is latched into the environment. [tmux] enabled is read by
atuin init, which exports ATUIN_TMUX_POPUP_WIDTH/_HEIGHT when it's on and
ATUIN_TMUX_POPUP=false when it's off — and the popup check honors that variable over
the config. So flipping it needs a new shell, and any shell started before the flip
keeps exporting the old answer into everything it spawns. If Ctrl+R draws inline
inside tmux when it shouldn't, that stale export is why:
printenv ATUIN_TMUX_POPUP # prints nothing when the popup is liveHerdr keeps its installed-plugin state in ~/.config/herdr/plugins.json, which it
rewrites on every install: absolute paths, resolved commit SHAs, install timestamps.
That's generated state, not config, so it stays out of this repo — the same call
antidote's generated zsh_plugins.zsh gets. herdr_plugins.txt is the tracked source
of truth instead:
persiyanov/herdr-reviewr # default branch at install time
someone/their-plugin@v1.2.0 # pinned to a tag, branch, or commit
To add a plugin, add the line and re-run install.sh — or run
herdr plugin install <owner>/<repo> --yes now and add the line so the other machine
picks it up. (Flags go after the repo argument; herdr plugin install --yes <repo>
is a usage error.) To remove one, delete the line and run
herdr plugin uninstall <plugin-id> — nothing prunes plugins automatically, since
removing a plugin also throws away whatever config it had.
A spec line can also end with # local-only — skipped whenever install.sh runs
inside an ssh session ($SSH_CONNECTION set), for a plugin that only makes sense on
the machine you physically sit at. nikok6/herdr-mirror is the one entry using it: it
mirrors a remote herdr into this one's sidebar over ssh, which inverts the moment
install.sh itself is run over ssh — installed on a box you've ssh'd into, it would
mirror some other machine's herdr back into the session you're already viewing
remotely.
Two plugins used to be listed here and aren't any more. ribbons-digital/pi-herd
hardcoded --name and --session-id into every harness launch, and omp — the agent
this setup drives — hard-errors on unknown flags: --name, --session-id; it also
shipped a Pi-only extension. It couldn't drive omp without patching, so it was dropped
from herdr_plugins.txt rather than carried as permanently broken.
AltanS/collie — a mobile web UI for the herd, served over Tailscale — was removed
because it wasn't wanted, not because it was broken. Worth recording is that taking it
out was three steps, not one: a plugin that installs a service owns state herdr knows
nothing about. herdr plugin action invoke uninstall --plugin herdr.collie came first,
to pull the herdr.collie LaunchAgent and the tailscale serve mapping the bridge had
published to the tailnet; then herdr plugin uninstall herdr.collie; then its config
directory, which herdr plugin uninstall leaves behind and which held a .env of VAPID
push keys. Uninstalling the plugin alone would have left a service running against a
plugin that no longer existed.
With ten plugins and thirty-three registered actions between them, keybindings stopped
being a per-plugin question and became one decision: config/herdr/palette/palette.sh,
bound to prefix+p — free because this config moved herdr's own previous_tab off it
and onto prefix+shift+tab — builds its fzf list at run time from herdr plugin action list, so every action of every installed plugin is one fuzzy search away whether or not
it has a key. Only the ones reached for constantly earn a [[keys.command]] entry;
ghzinga's click-driven open, workspace-manager's apply/validate/remove-gone, and
worktree-setup's total absence of actions all stay reachable through the palette instead
of crowding the keymap.
That palette started as the JanTvrdik/herdr-command-palette plugin and is now a script
in this repo, for two reasons that are really one. fzf needs a TTY; a herdr plugin action
runs on the server without one, so the plugin had to host the picker in an overlay
plugin pane, and an overlay covers the whole canvas — a command palette that takes the
screen is a tab, not a palette. A type = "popup" keybinding gets a TTY directly and is
session-modal, so the plugin's only job disappeared along with the full-screen overlay.
The fork also rewrote the rows: upstream led each one with a plugin.action id up to 42
characters wide, which buried the words you actually read behind an id and left the
titles in a ragged column. Titles come first now, in a fixed column, with the plugin id
trailing and dimmed. It stays visible rather than hidden in the invoke-only field
because fzf matches against what it displays, so a hidden field can't be searched.
Herdr's packed sidebar renders only the tokens named in a [ui.sidebar.agents]/
[ui.sidebar.spaces] row — a plugin can write a custom token correctly and still be
invisible if nothing names it. Two plugins depend on this: herdr-agent-inbox
contributes $title, $flag, $age, $since, and the workspace-level $agents/
$busy; gh-pr contributes $pr, the branch's PR state as #123 ✓. Both rows are
named in config/herdr/config.toml — miss one and its plugin looks broken when it's
actually just unrendered.
Three plugins now cooperate on a worktree's life, in a fixed order. worktree.created
fires; worktree-setup runs first and makes the checkout usable — copies .env* from
the main checkout, mise trust, direnv allow, installs deps — then workspace-manager
applies its YAML layout for that repo/branch, arranging tabs and panes and starting the
agent pane itself via herdr agent start, which blocks until herdr detects it's ready.
Later, once a branch's PR merges and its upstream is gone, workspace-manager's
remove-gone action previews and clears the worktree. reviewr used to also open on
worktree.created; two plugins independently arranging the same fresh workspace race
each other with no clear winner, so reviewr's auto_open is now off and
workspace-manager owns that moment alone — its issue layout carries a review tab
where reviewr's auto-open used to land. prefix+alt+r still opens it by hand.
herdr's [keys] table takes only its own built-in action names — there's no field in
it that names a plugin action. The only way to bind one to a key is [[keys.command]],
which shells back out to the herdr CLI instead of naming the action in config:
[[keys.command]]
key = "prefix+i"
type = "shell"
command = "herdr plugin action invoke settle --plugin herdr-agent-inbox"type controls how the command surfaces: shell runs it detached in the background,
pane opens a temporary pane that closes when the command exits, popup opens a
session-modal terminal. Plugin actions invoked this way are short control commands with
no output worth watching, so shell is the right call almost every time.
Several plugin READMEs (reviewr, vim-herdr-navigation, mirror, token-dashboard) still
document type = "plugin_action" with a combined <plugin>.<action> command string
instead of this. That form is stale: herdr --default-config on 0.8.0 documents only
shell/pane/popup, and plugin_action isn't one of them. Use the shell-plus-CLI
form above regardless of what a given plugin's own docs say.
Same shape as Herdr plugins above: agent_skills.txt is the tracked source of truth, one
<owner>/<repo> --skill <name> per line, and the state the CLI generates alongside it —
~/.agents/.skill-lock.json, content hashes and install/update timestamps — stays
untracked.
install.sh runs npx skills add <owner>/<repo> --skill <name> -g -y for each line.
-g writes one canonical copy of the skill into ~/.agents/skills and symlinks every
agent the CLI detects at that tree, instead of installing into a single project; -y
accepts its prompts so this can run with no tty attached. omp needs no install target of
its own here — it picks skills up through its agents skill provider, reading
~/.agents/skills directly rather than needing anything copied into
~/.omp/agent/skills.
(~/.omp/agent/skills is a separate, narrower thing: the same skills step also
symlinks any skill an installed Herdr plugin ships in its own skills/ directory there —
unrelated to agent_skills.txt, and the one part of this step that isn't new.)
Paseo's five orchestration skills come through this manifest rather than through the desktop app's own copy hook, which can't reach either machine reliably — see The five skills come from the manifest, not the app.
bin/fleet creates a git worktree per task, starts an omp in it, and lets one
orchestrator agent dispatch to the rest. The reason it exists as a shell script rather
than an omp extension is that the two halves of the problem live in different processes.
omp's own coordination surface stops at the process boundary. hub messaging runs over
a process-global mailbox bus, and history:// / agent:// resolve through the same
process-global registry, so none of them can see an omp running in another pane. Two
worktrees are also two directories, which puts them in different session-storage buckets
and different launch-broker scopes — there is no omp-level channel between them at all.
herdr has one. Every agent it recognises is addressable by name, and herdr agent prompt <name> writes into that agent's live input. That makes the channel symmetric: a worker
answers its orchestrator with the same command the orchestrator used to reach it. So the
transport is herdr's, and fleet is the part that makes it usable — worktree create
returns before the workspace-manager plugin has started an agent, the agent has to be
discovered rather than predicted, renamed to something addressable, and only then
prompted. Six commands and a race, per dispatch.
Two details worth knowing before reading the script:
A worker's report travels through a file under ~/.local/state/fleet/<handle>/, not
through its terminal. omp runs on the alternate screen, and rows that leave the alternate
screen never enter herdr's host scrollback — so herdr agent read cannot recover a
response that has scrolled, no matter how large --lines is. The file is the only
reliable transport for a report of any length.
A worker's fleet reply preempts the orchestrator's current tool call. herdr delivers
it as ordinary agent input, which omp treats as a steering interjection and which
backgrounds whatever the orchestrator was blocked on. That is the right behaviour for a
worker asking a question — it shouldn't queue behind another worker's build — but it's
why the skill tells workers to use fleet report for results and fleet reply only for
decisions.
omp/agent/rules/herdr-worktrees.md used to say, with alwaysApply: true, that inside
herdr you always use herdr worktree and never git worktree. Writing fleet on top of
it exposed the flaw: an agent cannot move itself into the worktree it just created. An
omp process keeps the directory it launched in, and herdr pane move relocates a pane's
display rather than its shell's cwd. So for an agent's own use — building another ref,
diffing two versions — herdr worktree create buys a sidebar entry it did not want and,
on a repo covered by a workspace-manager layout, a second idle omp burning tokens.
The rule is now organised around who will occupy the worktree. Someone else will sit in
it, human or agent: herdr worktree create, which is the only path that runs
tdi.worktree-setup (the .env* copy, mise trust, direnv allow) and starts an agent.
Nobody will, and you only need the files: plain git worktree add in a temp directory,
plain git worktree remove after. Removal has to match creation — git worktree remove
on a herdr-created worktree orphans the workspace, leaving a sidebar entry pointing at
nothing.
One measured trap, on 0.8.0: herdr worktree open is not a way to promote a plain
git worktree add into a real workspace. tdi.worktree-setup hooks worktree.created
only, so an opened worktree never gets its .env*; and workspace.created fires before
the new pane reaches a shell prompt, so the plugin's herdr agent start fails with
agent target pane <id> is not an available shell and — having already marked the layout
applied — never retries. The result is a workspace with a tab labelled agent and no
agent in it.
Dropping alwaysApply was also what kept fleet from becoming ambient. An always-on rule
about worktrees is one short step from every session deciding to dispatch a fleet at its
own discretion. The rule is now a rulebook entry — it keeps its description, and the
model reads it through rule://herdr-worktrees when it is actually about to touch a
worktree. Orchestration is a separate, deliberate opt-in: /fleet <objective>, a slash
command in omp/agent/commands/, which adopts the role and points at
skill://herdr-fleet for the procedure. Nothing loads that skill on its own; a session
that never asks for a fleet never hears about one.
Worth being clear about what is not doing the gating here, because all three look like
they should. Rules have no per-agent scoping — there is no frontmatter field binding one
to an agent, and scope: scopes TTSR stream surfaces, not agents. Skills are session-wide
for the same reason: every documented filter (ignoredSkills, disabledExtensions,
the per-source toggles) applies to the whole session. And omp has no --agent flag, so a
task-agent definition can't back a top-level session either. The launch is identical for
orchestrator and worker; the only difference is that one of them was told to be one.
NvChad's own quickstart docs say to run :MasonInstallAll and :TSInstallAll after the
first sync. Neither does what the docs imply on the current starter: MasonInstallAll
isn't a real command in mason.nvim, and TSInstallAll silently no-ops — the actual
command is :TSInstall <lang>, and :TSInstall all grabs every language
nvim-treesitter supports, which is not a sane default for anyone. Neither Mason nor
Treesitter auto-installs on first file-open in this config either. install.sh
deliberately does not paper over this with a guessed default set — install what you
actually use:
:MasonInstall pyright lua-language-server " example, not a real default
:TSInstall python lua bash
or declare an ensure_installed list in lua/configs/mason.lua /
lua/configs/treesitter.lua once you know what those are.
Ghostty's TERM is xterm-ghostty, and most remote hosts don't have that terminfo
entry — without a fix, anything that opens a real terminal (nvim included) fails with
Error opening terminal: xterm-ghostty the moment you SSH in. config/ghostty/config
sets:
shell-integration-features = cursor,sudo,title,ssh-env,ssh-terminfo
ssh-terminfo makes Ghostty's shell integration wrap interactive ssh calls, install
Ghostty's terminfo entry on the remote via tic the first time you connect (cached
after that, keyed by user@host), and fall back to TERM=xterm-256color automatically
if the install fails (no tic on the remote, etc.). ssh-env separately forwards
COLORTERM/TERM_PROGRAM so the remote shell can detect it's inside Ghostty — it does
not touch TERM on its own, so ssh-terminfo is the one actually preventing the
crash.
The wrapper is a shell function, so it only covers plain interactive ssh typed at a
prompt — not scripts run non-interactively, and not wrapper tools that spawn ssh
themselves (mosh, gcloud compute ssh, git/rsync over ssh, etc.). For those, or as
a one-off manual fix on a host you don't want Ghostty auto-installing terminfo onto:
infocmp -x xterm-ghostty | ssh host -- tic -x -After that:
- Fill in secrets. The installer copies
zshrc.local.example,gitconfig.local.example, andssh/config.local.exampleto their~/.*.localtargets (mode 600) if they don't already exist. Edit those files with real values —zshrc/gitconfig/ssh/configeach source or include the copy automatically, and all three are git-ignored, so secrets never end up in this repo or its history. See The*.localtemplates below. - Restart your shell (or
exec zsh). Antidote clones its plugins on first run. - Open tmux and press
prefix+Ito have TPM install its plugins on first run.
Edit the files in this repo directly — they're the real config, not copies, since
everything under $HOME is a symlink back here. Commit and push like normal, then run
install.sh (or just git pull) on the other machine to pick it up.
main is protected — push a branch and open a PR rather than pushing directly.