How it works
Overview
Section titled “Overview”claude-nomad is a tool, not a config store. You install the CLI globally
(npm i -g claude-nomad) and keep a separate private Git repo that holds only your config:
CLAUDE.md, skills, settings, session transcripts. No tool source code lives in that repo.
nomad init creates this repo for you (via gh) and scaffolds the directory structure in one
step. By default the private GitHub repo is named claude-nomad-config and is cloned locally to
~/claude-nomad/, so those two names refer to the same repo (the remote and its local checkout).
Every host after the first installs the CLI, clones your private data repo to ~/claude-nomad/,
and runs nomad pull to sync.
By default the CLI operates on ~/claude-nomad/ (see REPO_HOME in src/config.ts). Developers
working from an alternate checkout can export NOMAD_REPO=/path/to/repo to point the CLI at their
working tree without symlink gymnastics; nomad doctor surfaces an active override via a trailing
(NOMAD_REPO) annotation on the repo-state line. Empty NOMAD_REPO falls through to the default,
so a clobbered dotfile variable does not break the CLI.
Repo layout
Section titled “Repo layout”What ~/claude-nomad/ looks like on a configured host:
Directoryclaude-nomad/
Directoryshared/ synced to every machine
- CLAUDE.md
- settings.base.json baseline settings
Directoryskills/ your own skills (gsd-* excluded from sync)
- …
Directorycommands/
- …
Directoryrules/
- …
- my-statusline.cjs any script you want symlinked into ~/.claude/
- .gitignore blocks
.claude.json,settings.local.json,*.token,*.key,*.pem,id_rsa,id_ed25519,.env,.env.* Directoryprojects/ session transcripts under logical names
- …
Directoryextras/ opt-in per-project content
- …
Directoryhosts/
- <your-mac>.json patches merged over settings.base.json
- <your-wsl-host>.json
- <your-nuc>.json
- path-map.json logical project -> per-host absolute path
What gets synced vs. not
Section titled “What gets synced vs. not”| Category | Items | Behavior |
|---|---|---|
| Synced (symlinked) | CLAUDE.md, commands/, rules/, my-statusline.cjs |
Symlinked into ~/.claude/ from shared/. |
| Synced (filtered copy) | skills/ |
Copied to/from shared/skills/ on every pull/push; your own skills travel, gsd-* skills are excluded (they are installed per-host by the @opengsd/gsd-core tool via npm). |
| Generated | settings.json |
Deep-merge of settings.base.json with hosts/<hostname>.json; rewritten every pull. |
| Remapped | projects/ session transcripts |
Copied with path translation per path-map.json. |
| Per-project extras | Whitelisted dirs like .planning/ or a project’s own <repo>/.claude/, or a root file like CLAUDE.md |
Opt-in via the extras field in path-map.json; copied to/from shared/extras/<logical>/. .planning syncs as an additive overlay (repo-only and local-only files survive); .claude keeps host-local files on pull; other extras mirror. |
| Shared support dirs | Opt-in global ~/.claude/ dirs that another tool installs (e.g. my-tool/) |
Opt-in via the sharedDirs field in path-map.json; symlinked into ~/.claude/ from shared/. |
| gsd-owned (not synced) | hooks/, agents/ |
Installed per-host by @opengsd/gsd-core via npm. Not synced; syncing them caused cross-host churn when two machines ran different gsd versions. |
| Never synced | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
| Auto-rehydrated | ~/.claude/plugins/cache/<plugin>/... |
Re-downloaded by Claude Code from the enabledPlugins list; no per-host install. |
On native Windows the two symlinked rows are copy-synced instead: creating a symlink there needs
Developer Mode or admin rights, so nomad pull materializes those names as real copies and
nomad push mirrors your local edits back into shared/. After editing one of these files on
Windows, run nomad push before your next nomad pull or nomad sync so the edit is captured
rather than overlaid. Everything else in the table behaves identically. See
Windows in the Quickstart.
If you run GSD (@opengsd/gsd-core), the .planning overlay, the gsd-* skill exclusion, and the
unsynced hooks/ and agents/ are all part of nomad’s GSD-aware handling. See
GSD-aware sync for what nomad does for a GSD machine out of the box.
Pointers and specifics:
- Synced (symlinked) link names live in
SHARED_LINKS(and the optionalsharedDirsfield inpath-map.json); the skills filtered copy is driven bysrc/skills-sync.ts(syncSkillsPull/syncSkillsPush); whitelisted extras names inSUPPORTED_EXTRAS(currently.planning,CLAUDE.md, and.claude); and the full never-synced set inNEVER_SYNC(all insrc/config.ts). - Never synced, in full:
~/.claude.json(OAuth, MCP state),.credentials.json(OAuth credential store),history.jsonl,settings.local.json(per-host overrides),stats-cache.json,todos/,shell-snapshots/,debug/,file-history/,plans/,session-env/,statsig/,telemetry/,ide/, plus host-local caches and runtime state (cache/,backups/,paste-cache/,daemon/,jobs/,tasks/,security/,sessions/). This set is also the deny-list thesharedDirsopt-in is checked against, so one of these names cannot be symlinked into the shared repo by mistake. - Per-project extras run a pre-pull divergence WARN that flags local edits before they get overwritten.
- Synced skills get the same divergence WARN as extras: if a file under
~/.claude/skills/differs fromshared/skills/,nomad doctorlists it before a pull would overwrite the edit without warning.gsd-*skills are excluded (they are not copy-synced). nomad doctoralso checks two preflight conditions on the sync repo itself: whether a git committer identity (user.nameanduser.email) is configured (a WARN fires before anomad pushwould fail at commit time without one) and whether path-map entries for the current host point at project folders that still exist on disk (a WARN per missing path; other hosts’ entries are not checked).- Credentials stay blocked even inside synced extras. A strict subset of the never-synced set
(
.claude.json,.credentials.json,settings.local.json,history.jsonl,stats-cache.json) is hard-blocked even when nested inside an opted-in.planning/extras tree, so a secret file dropped under a synced.planning/directory can never ride through the extras gate. (.planning/uses only this subset so its legitimatetodos/andplans/content still syncs.) - The
.claude/extra is filtered against the full never-synced set, not just that subset. Because a.claude/directory mirrors the layout of~/.claude/, opting it in strips every ephemeral or host-local name on push: the wholeNEVER_SYNCset plus session transcripts underprojects/(settings.local.json,projects/,shell-snapshots/,sessions/,statsig/,telemetry/,todos/, …), leaving your project config (settings.json,skills/,commands/,rules/,hooks/,agents/). So syncing.claude/carries your project’s Claude setup across machines without ever leaking session transcripts or per-host secrets. The same boundary is enforced a second time at the push gate as a backstop. - A pull keeps each machine’s own per-host files. Because those never-synced names are stripped
on push, they never travel between machines. On pull, the
.claudeextra preserves any of them already on disk for the current host (most importantly a project’s ownsettings.local.json) while still mirroring the synced config over the top, at every level of the tree. So a pull updates your sharedsettings.jsonand the rest from the repo without ever overwriting or deleting the local-only settings that machine keeps to itself.
Path remapping
Section titled “Path remapping”The hard problem: Claude Code stores sessions in ~/.claude/projects/<encoded-path>/, where the
encoded path is the absolute project path with every character that isn’t an ASCII letter or digit
(A-Z, a-z, 0-9) replaced by -, and any result longer than 200 characters shortened with a
short hash on the end. So the same logical project ends up in different directories on each host.
The same rule is what makes the directory name come out right on Windows-style paths too:
C:\Users\you\code becomes C--Users-you-code, with the drive-letter colon and backslashes
collapsing to - exactly as Claude Code does it.
path-map.json defines logical names and where the repo lives on each host. The optional extras
block opts a project into syncing whitelisted directories (or a single root file) at its root:
{ "projects": { "my-example-repo": { "<your-mac>": "/Users/you/code/my-example-repo", "<your-wsl-host>": "/home/you/code/my-example-repo", "<your-nuc>": "TBD" } }, "extras": { "my-example-repo": [".planning", "CLAUDE.md", ".claude"] }}Use the literal string "TBD" for hosts you haven’t onboarded yet; remapPull skips TBD entries
cleanly instead of creating an orphan ~/.claude/projects/TBD/. Replace each "TBD" with the
real path when you bring up that host.
On push, sessions in ~/.claude/projects/-Users-you-code-my-example-repo/ get copied to
shared/projects/my-example-repo/. On nomad pull on another machine, they get copied to that
host’s encoded path. claude --resume then finds them.
The extras block is additive and back-compatible: legacy path-map.json files without it keep
working unchanged. Each value is an array of directory or root-file names (e.g. .planning,
CLAUDE.md) checked against SUPPORTED_EXTRAS in src/config.ts; anything outside that
whitelist is skipped with a log line, so an unrecognized name cannot widen the sync surface.
On nomad push, opted-in content at <localRoot>/<name> (a directory subtree or a single file)
is copied to shared/extras/<logical>/<name> and goes through the same staged-tree gitleaks scan
as everything else. On nomad pull, the reverse copy runs after git pull --rebase, and just
before it writes your working tree a divergence check compares the incoming content against your
local copy and prints a per-file WARN naming anything that differs.
How the copy treats files that exist on only one side depends on the extra:
.planningsyncs as an additive overlay in both directions: the copy adds and updates files but never blind-deletes, so a.planning/file that lives only in the repo (one another host pushed) survives a push from a host that lacks it, and local-only work in progress survives a pull. Genuine upstream deletions still propagate on pull: nomad diffs the repo’s pre- and post-rebase HEADs and removes locally only the files the rebase actually deleted. That delete pass is skipped on--dry-runand on a fresh clone where there is no pre-rebase HEAD to diff against, so only the overlay runs in those cases..claudemirrors the synced config on pull but preserves any host-local files already on disk (most importantly a project’s ownsettings.local.json), as described above.- Other extras (such as a root
CLAUDE.md) are an exact mirror: the destination is replaced with the source.
Your existing local content is backed up under ~/.cache/claude-nomad/backup/<ts>/extras/ before
the pull copy lands, so an unexpected overwrite is always recoverable.
Shared support dirs (sharedDirs)
Section titled “Shared support dirs (sharedDirs)”Some tools install a hooks block into settings.json whose commands point at scripts under
~/.claude/hooks/. The hooks/ and agents/ directories are managed directly by
@opengsd/gsd-core per host via npm install, so nomad no longer syncs them. If you use a
different tool that stores its hook scripts in a custom directory under ~/.claude/, you can
opt that directory in through sharedDirs.
For any global ~/.claude/ support directory a tool needs, the optional top-level sharedDirs
field in path-map.json opts it into symlink sync:
{ "projects": { "my-example-repo": { "<your-mac>": "/Users/you/code/my-example-repo" } }, "sharedDirs": ["my-tool"]}What this means for you: each listed name is symlinked from shared/<name> into
~/.claude/<name> (the same model as the built-in synced links, not a copy), so editing it on
any host updates the one shared copy. On native Windows these names are real copies, like every
synced link there, so nomad push is what captures a local edit. The field is additive and back-compatible: a path-map.json
without it behaves exactly as before.
Entries are validated before anything is linked. A name is accepted only if it is a single path
segment (no /, no ..), is not one of the never-synced names, and does not collide with a
reserved shared/ name (settings.base.json, the built-in synced links, hooks, agents,
skills, hosts, path-map.json). Note that hooks, agents, and skills are reserved and
cannot be re-added through sharedDirs: hooks and agents are gsd-owned per-host, and
skills is handled by the filtered copy-sync. An invalid entry is dropped with a warning rather
than aborting the run. The contents still go through the same gitleaks scan as everything else on
push, so do not point sharedDirs at a directory that holds credentials.
First-time setup on an already-configured repo: a symlink can only form once the directory exists
under shared/. On a fresh repo nomad init --snapshot handles this for you. To add a new
sharedDirs entry to a repo that is already set up, move it into shared/ once on the host that
has it, then let the normal flow take over:
$ mv ~/.claude/my-tool ~/claude-nomad/shared/my-tool # one-time, on the source host$ nomad pull # re-creates ~/.claude/my-tool as a symlink$ nomad push # shares it with your other hostsnomad pull never writes back to the remote, so it will not seed shared/ for you; the
one-time move is deliberate.
Per-host overrides
Section titled “Per-host overrides”settings.base.json holds portable defaults (model, permissions, plugins).
hosts/<NOMAD_HOST>.json holds machine-specific patches. They’re deep-merged on every pull
(scalars override, objects merge recursively, arrays replace, and an explicit null overrides).
Keys that used to be
force-marked per-host because they embedded absolute paths (statusLine.command, hooks) can
live in settings.base.json if you write the commands with $HOME (e.g.
"command": "node \"$HOME/.claude/my-statusline.cjs\""); Claude Code runs them through a shell
so shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP
URLs, host-only model overrides).
shared/settings.base.json:
{ "model": "claude-sonnet-4-6", "permissions": { "allow": ["Bash(npm run *)", "Bash(git status)"] }}hosts/<your-other-host>.json:
{ "model": "claude-opus-4-8", "env": { "OLLAMA_HOST": "http://localhost:11434" }}Results on your-other-host: opus 4.8, the local Ollama env var, plus the shared permissions
array.
Hook entries in settings
Section titled “Hook entries in settings”GSD (@opengsd/gsd-core) installs its hook commands into ~/.claude/settings.json per host via
npm and manages them on every session start. These entries (scripts whose basename starts with
gsd-) are not meant to sync across hosts: each host needs a version-current set installed
by GSD itself, and syncing them would cause permanent drift warnings as GSD self-heals a newer
set on each session.
What nomad does for you:
- On pull: gsd-owned hook entries are filtered out of the generated
~/.claude/settings.jsonso the file GSD manages stays pristine. - On push: if
shared/settings.base.jsonstill holds leftover gsd hook entries from an earlier era, nomad rewrites it to drop them before staging (a one-time self-clean; idempotent on a clean base). - Your own hooks still sync. A hook entry you write yourself (whose script basename does not
start with
gsd-) travels throughnomad capture-settingsexactly like any other ahead key: runnomad capture-settingsto promote it into the base, then it travels on every subsequent pull. - In
nomad diffand--dry-run: the preview filters both sides before comparing, so GSD’s per-session self-heal never renders as a phantomhookschange. The preview matches what a real pull writes.
nomad doctor warns when settings.json no longer matches the base+host merge nomad would
write, naming the missing or changed keys: the symptom of another tool rewriting the file out
from under you (the same clobber the warning above describes, except done by software). The fix
is nomad pull. It also warns when settings.json carries a top-level key it does not
recognize (a cue that Claude Code added a setting). The recognized set is kept current against
Claude Code’s published settings schema by a weekly automated PR in the public repo, so a
periodic nomad update (to get the latest CLI) is what keeps that warning quiet on your hosts.
To check your own settings.json against the live schema on demand, run
nomad doctor --check-schema.
What does NOT sync (deliberate trade-offs)
Section titled “What does NOT sync (deliberate trade-offs)”Read these before adopting so you opt in with eyes open.
- Last-write-wins on conflicts. Git surfaces them on merge; no field-level JSON merging.
- Manual push/pull. No file watcher. Shell hooks recommended.
- OAuth doesn’t sync. You’ll log in once per host. Intentional.
- Only sessions in
path-map.jsonare remapped. Drive-by sessions on un-mapped paths are left alone. - Extras are opt-in and whitelisted. Projects without an
extrasentry inpath-map.jsonare unaffected. Supported names are.planning,CLAUDE.md, and.claude; anything outsideSUPPORTED_EXTRASis skipped with askip ... not in SUPPORTED_EXTRASlog line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal inlogicalkeys, non-absolute or unnormalizedlocalRootvalues) abort the run before any file is touched, so a malformed entry fails loudly instead of corrupting state. - Cross-OS
claude --resumecwd binding. Sessions embed the cwd where they were created, so Claude Code’s picker’scd ... && claude --resume <id>line fails on a different host. Usenomad doctor --resume-cmd <id>for a host-local equivalent (see Usage). The sidecar approach preserves transcript byte-equality. - Empty directories don’t survive sync. Git doesn’t track empty dirs;
nomad doctorreports them asmissing(benign). Drop a.gitkeepto force materialization.