Skip to content
nomadv0.69.0

How it works

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/core/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.

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
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/. On Windows nomad pull runs that mirror before it fetches (and nomad sync inherits it), so an unpublished edit is captured rather than overlaid, and a real (non-dry-run) nomad pull now prints a Symlinks section naming what it captured, so the copy is visible in the pull output instead of silent. A name the mirror cannot read at all, held open by another program or changed out from under it by a permissions edit, is left out with a warning naming the file and the reason, instead of vanishing from the capture list without comment. nomad push prints the same warning. The nomad diff and --dry-run previews print a read-only version of it, saying nothing was captured for the file and nothing was written, because they were never going to write to the repo. A shared name is skipped for a second reason too: when its shared/<name> counterpart is in the repo but leads nowhere, usually because the machine that shared it no longer has the original, or when that counterpart cannot be read at all. Those are left alone as well, with a warning naming the entry, on nomad push and on the pull-side mirror alike. Nomad does not repair either for you: remove a counterpart that leads nowhere from the sync repo, or restore what it points at, by hand; for one that could not be read, check its permissions in the sync repo instead, since nothing established where it leads. A later step in the same pull reads that file too, and it no longer stops there: it warns as well, naming the file, saying whether the file is gone or may be part-updated, and pointing at the backup copy when it made one, then skips that one name and finishes the rest of the pull. A file you delete from inside one of these directories is mirrored the same way: it is removed from shared/ by the next pull, the same as deleting inside a symlinked directory already removes it on macOS or Linux, and that pull names the removal too, in the same Symlinks section as the captured rows, right after them. The removal is left uncommitted (it publishes on your next push, through the same secret scan as everything else) and the repo copy is snapshotted to the backup dir first. nomad only removes a file it has a record of having given this machine, so a repo file this machine has never synced is never touched. That record is also why the first pull after a host upgrades to this version is an exception: there is nothing to compare against yet, so a deletion made before that pull is restored once and has to be repeated. 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 optional sharedDirs field in path-map.json); the skills filtered copy is driven by src/sync/skills-sync.ts (syncSkillsPull/syncSkillsPush); whitelisted extras names in SUPPORTED_EXTRAS (currently .planning, CLAUDE.md, and .claude); and the full never-synced set in NEVER_SYNC (in src/core/config.never-sync.ts; the other three are in src/core/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 the sharedDirs opt-in is checked against, so one of these names cannot be symlinked into the shared repo by mistake. That is the full list for one question only: is this NAME safe to share at all? A different, narrower question applies to the CONTENT already inside a directory you have chosen to share, covered next.
  • The win32 mirror is filtered against a narrower set once you are inside a name you already share. Copying your local edits into shared/ (the previous bullet) never writes a path carrying your Claude login or credential files, your per-host settings, or your local history and stats cache as a segment, and if one reaches the sync repo working tree anyway, such as a file edited directly in the repo rather than through ~/.claude/, the next nomad pull removes it if git is not already tracking it (snapshotting it to the backup dir first), and otherwise warns once, naming the file and the exact git command to run, changing nothing itself. An ordinary directory of your own inside one of your shared names (a sessions or tasks subfolder, for example) is mirrored, not stopped; only that narrower credential and host-config floor is still refused, matched on a whole path segment, directory or file name alike, so a file named tasks.md is unaffected either way. Nothing in this gate can fail a pull outright: a hit is a warning, never an error.
  • 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 from shared/skills/, nomad doctor lists it before a pull would overwrite the edit without warning. gsd-* skills are excluded (they are not copy-synced).
  • nomad doctor also checks two preflight conditions on the sync repo itself: whether a git committer identity (user.name and user.email) is configured (a WARN fires before a nomad push would 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 legitimate todos/ and plans/ 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 whole NEVER_SYNC set plus session transcripts under projects/ (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 .claude extra preserves any of them already on disk for the current host (most importantly a project’s own settings.local.json) while still mirroring the synced config over the top, at every level of the tree. So a pull updates your shared settings.json and the rest from the repo without ever overwriting or deleting the local-only settings that machine keeps to itself.

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/core/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:

  • .planning syncs 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-run and on a fresh clone where there is no pre-rebase HEAD to diff against, so only the overlay runs in those cases.
  • .claude mirrors the synced config on pull but preserves any host-local files already on disk (most importantly a project’s own settings.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.

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 both nomad pull (which mirrors before it fetches, naming each capture in its Symlinks section) and nomad push carry a local edit into the repo. Your own folders inside that directory, even ones spelled like cache, sessions, tasks, or plans, are carried along; only the credential and per-host-settings floor from the never-synced bullet above is still refused. On native Windows, as everywhere else, a directory the sync repo does not carry yet is published by an explicit nomad adopt <name>, and after that every push carries its edits. The field is additive and back-compatible: a path-map.json without it behaves exactly as before.

Entries are validated before anything is linked. This checks the entry’s own NAME, the question the full never-synced list above answers, not the content that ends up inside it once it is shared: 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, add the name to path-map.json first, then run nomad adopt on the host that has the directory:

Terminal window
$ nomad adopt my-tool # on the source host, after adding "my-tool" to sharedDirs
$ nomad push # shares it with your other hosts

nomad adopt only accepts a name you have already declared, so the path-map.json edit comes first: it is a mover, not a config editor, and it never writes path-map.json for you. It then backs your directory up, copies it into shared/my-tool, replaces ~/.claude/my-tool with a symlink pointing there (a real copy on native Windows), and stages the result. It stops at that point, printing the nomad push reminder rather than committing or pushing anything itself. Use nomad adopt my-tool --dry-run first to see the backup, move, and staging it would perform without touching anything.

Let nomad adopt do the move rather than moving the directory into shared/ by hand. Only the command applies the name validation, the credential and never-synced refusal, and the backup, and only it recreates the local symlink; a manual mv skips all of that and leaves the local name missing until the next pull.

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.

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.json so the file GSD manages stays pristine.
  • On push: if shared/settings.base.json still 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 through nomad capture-settings exactly like any other ahead key: run nomad capture-settings to promote it into the base, then it travels on every subsequent pull.
  • In nomad diff and --dry-run: the preview filters both sides before comparing, so GSD’s per-session self-heal never renders as a phantom hooks change. 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, by design.
  • Only sessions in path-map.json are remapped. Drive-by sessions on un-mapped paths are left alone.
  • Extras are opt-in and whitelisted. Projects without an extras entry in path-map.json are unaffected. Supported names are .planning, CLAUDE.md, and .claude; anything outside SUPPORTED_EXTRAS is skipped with a skip ... not in SUPPORTED_EXTRAS log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in logical keys, non-absolute or unnormalized localRoot values) abort the run before any file is touched, so a malformed entry fails loudly instead of corrupting state.
  • Cross-OS claude --resume cwd binding. Sessions embed the cwd where they were created, so Claude Code’s picker’s cd ... && claude --resume <id> line fails on a different host. Use nomad 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 doctor reports them as missing (benign). Drop a .gitkeep to force materialization.