claude-nomad 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,464 @@
1
+ # claude-nomad
2
+
3
+ [![tests](https://img.shields.io/github/actions/workflow/status/funkadelic/claude-nomad/tests.yml?branch=main&label=tests)](https://github.com/funkadelic/claude-nomad/actions/workflows/tests.yml)
4
+ [![release](https://img.shields.io/github/v/release/funkadelic/claude-nomad?label=release&sort=semver)](https://github.com/funkadelic/claude-nomad/releases)
5
+ [![coverage](https://img.shields.io/codecov/c/github/funkadelic/claude-nomad/main?label=coverage)](https://codecov.io/gh/funkadelic/claude-nomad)
6
+
7
+ ![claude-nomad - Sync your Claude Code setup. Same environment. Any machine.](docs/hero.svg)
8
+
9
+ Claude Code's state is per-machine. Your `CLAUDE.md`, custom agents, skills, slash commands, settings, and session history live in `~/.claude/` and don't follow you to your laptop, your work machine, or your homelab box.
10
+
11
+ claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on another, and your full setup is there, including past sessions you can resume.
12
+
13
+ **Who this is for:** anyone running Claude Code on more than one machine. A laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine, or any combination. If you've ever felt the friction of starting fresh on a second machine or copying files around by hand, this is for you.
14
+
15
+ Two things it does that ad-hoc dotfiles syncing can't:
16
+
17
+ - **Session history survives path differences.** The same project at `/Users/norm/code/foo` on your Mac and `/home/norm/foo` on Linux gets remapped automatically, so `claude --resume` finds your past conversations on whichever machine you're on.
18
+ - **Per-host settings via deep merge.** Shared defaults live in one file; machine-specific overrides (model choice, MCP server URLs, env vars, hooks) live in a per-host file. They're merged on every pull instead of overwriting each other.
19
+
20
+ ## Table of contents
21
+
22
+ - [Quickstart](#quickstart)
23
+ - **Concepts**
24
+ - [How it works (two-repo model)](#how-it-works-two-repo-model)
25
+ - [Repo layout](#repo-layout-what-claude-nomad-looks-like-on-a-configured-host)
26
+ - [What gets synced vs. not](#what-gets-synced-vs-not)
27
+ - [Path remapping](#path-remapping)
28
+ - [Per-host overrides](#per-host-overrides)
29
+ - [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs)
30
+ - **Getting started**
31
+ - [Requirements](#requirements)
32
+ - [Setup](#setup)
33
+ - [Migrating an existing ~/.claude/](#migrating-an-existing-claude)
34
+ - [Upgrading the tool](#upgrading-the-tool)
35
+ - **Reference**
36
+ - [Commands](#commands)
37
+ - [Recovery flows](#recovery-flows)
38
+ - [`nomad drop-session <id>`](#nomad-drop-session-id)
39
+ - [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)
40
+ - [`.gitleaks.toml` allowlist policy](#gitleakstoml-allowlist-policy)
41
+ - [Cross-OS resume](#cross-os-resume)
42
+ - [Run tests](#run-tests)
43
+
44
+ ## Quickstart
45
+
46
+ If you already have a private claude-nomad mirror (see [Setup](#setup) for the one-time bootstrap), adding a new host is three steps:
47
+
48
+ ```bash
49
+ npm i -g claude-nomad
50
+ ```
51
+
52
+ ```bash
53
+ # Clone your private mirror so nomad has a repo to sync into.
54
+ git clone git@github.com:you/claude-nomad.git ~/claude-nomad
55
+
56
+ # Add to ~/.zshrc or ~/.bashrc:
57
+ export NOMAD_HOST=<your-host-label>
58
+
59
+ # Optional: developers running against an alternate checkout can point
60
+ # nomad at it via NOMAD_REPO. Default is ~/claude-nomad/.
61
+ # export NOMAD_REPO=/path/to/repo
62
+ ```
63
+
64
+ Then the everyday loop:
65
+
66
+ ```bash
67
+ nomad doctor # confirm setup
68
+ nomad pull # apply config to ~/.claude/
69
+ nomad push # publish local changes (sessions, settings)
70
+ ```
71
+
72
+ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/` are in [Setup](#setup) and [Migrating an existing ~/.claude/](#migrating-an-existing-claude).
73
+
74
+ ## How it works (two-repo model)
75
+
76
+ claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The tool's source and your config end up coexisting in one working tree on each host.
77
+
78
+ ```
79
+ public funkadelic/claude-nomad your private you/claude-nomad
80
+ ├── src/ (the CLI) ├── src/ (copy of the CLI)
81
+ ├── package.json ├── package.json
82
+ └── ... ├── ...
83
+ ├── shared/ (your config, synced)
84
+ │ ├── CLAUDE.md
85
+ │ ├── agents/
86
+ │ ├── skills/
87
+ │ ├── commands/
88
+ │ ├── rules/
89
+ │ ├── settings.base.json
90
+ │ └── projects/
91
+ ├── hosts/<hostname>.json
92
+ └── path-map.json
93
+ ```
94
+
95
+ You bootstrap once by mirror-pushing this public tool repo into a fresh private repo of your own (see [Setup](#setup)), then layer your config on top. Every host afterward installs the CLI (`npm i -g claude-nomad`), clones your private repo to `~/claude-nomad/`, and runs `nomad pull` to sync.
96
+
97
+ 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.
98
+
99
+ ## Repo layout (what `~/claude-nomad/` looks like on a configured host)
100
+
101
+ ```
102
+ ~/claude-nomad/
103
+ ├── src/ # the CLI (came from the public tool repo)
104
+ ├── scripts/ # tool helpers (update.sh; plus any one-shot scripts you add)
105
+ ├── shared/ # synced to every machine
106
+ │ ├── CLAUDE.md
107
+ │ ├── settings.base.json # baseline settings
108
+ │ ├── agents/
109
+ │ ├── skills/
110
+ │ ├── commands/
111
+ │ ├── rules/
112
+ │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
113
+ │ ├── .gitignore # defense-in-depth: blocks .claude.json, *.token, *.key, .env
114
+ │ └── projects/ # session transcripts under logical names
115
+ ├── hosts/
116
+ │ ├── <your-mac>.json # patches merged over settings.base.json
117
+ │ ├── <your-wsl-host>.json
118
+ │ └── <your-nuc>.json
119
+ ├── path-map.json # logical project -> per-host absolute path
120
+ └── package.json, ... (tool metadata)
121
+ ```
122
+
123
+ ## What gets synced vs. not
124
+
125
+ | Category | Items | Behavior |
126
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
127
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
128
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
129
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
130
+ | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
131
+ | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
132
+
133
+ > [!NOTE]
134
+ > Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
135
+
136
+ For the rationale behind these choices, see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs).
137
+
138
+ ## Path remapping
139
+
140
+ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in different directories on each host.
141
+
142
+ `path-map.json` defines logical names and where the repo lives on each host:
143
+
144
+ ```json
145
+ {
146
+ "projects": {
147
+ "ha-acwd": {
148
+ "<your-mac>": "/Users/you/code/ha-acwd",
149
+ "<your-wsl-host>": "/home/you/code/ha-acwd",
150
+ "<your-nuc>": "TBD"
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ > [!IMPORTANT]
157
+ > The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's encoded dir.
158
+
159
+ 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.
160
+
161
+ On `push`, sessions in `~/.claude/projects/-Users-you-code-ha-acwd/` get copied to `shared/projects/ha-acwd/`. On `pull` on another machine, they get copied to that host's encoded path. `claude --resume` then finds them (see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the cross-OS cwd-binding gotcha).
162
+
163
+ ## Per-host overrides
164
+
165
+ `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). Keys that used to be force-marked per-host because they embedded absolute paths (`statusLine.command`, `hooks`) can live in base 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).
166
+
167
+ `shared/settings.base.json`:
168
+
169
+ ```json
170
+ {
171
+ "model": "claude-sonnet-4-6",
172
+ "permissions": { "allow": ["Bash(npm run *)", "Bash(git status)"] }
173
+ }
174
+ ```
175
+
176
+ `hosts/<your-wsl-host>.json`:
177
+
178
+ ```json
179
+ {
180
+ "model": "claude-opus-4-7",
181
+ "env": { "OLLAMA_HOST": "http://localhost:11434" }
182
+ }
183
+ ```
184
+
185
+ Result on that host: opus model, the local Ollama env var, plus the shared permissions array.
186
+
187
+ > [!CAUTION]
188
+ > Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the repo instead.
189
+
190
+ ## What does NOT sync (deliberate trade-offs)
191
+
192
+ Read these before adopting so you opt in with eyes open.
193
+
194
+ - **Last-write-wins on conflicts.** Git surfaces them on merge; no field-level JSON merging.
195
+ - **Manual push/pull.** No file watcher. Shell hooks recommended.
196
+ - **OAuth doesn't sync.** You'll log in once per host. Intentional.
197
+ - **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
198
+ - **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the picker's `cd ... && claude --resume <id>` line fails on a different host. Use `nomad doctor --resume-cmd <id>` for a host-local equivalent (see [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
199
+ - **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.
200
+
201
+ ## Requirements
202
+
203
+ - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes — npm only blocks the install when `engine-strict=true` is configured)
204
+ - `tsx` (ships as a runtime dependency of the published package; no separate global install required)
205
+ - Git
206
+ - A **private** GitHub repo (or any Git remote you control)
207
+
208
+ ## Setup
209
+
210
+ **Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into a fresh private repo, not a fork.
211
+
212
+ > [!WARNING]
213
+ > Keep the mirror private. CI workflows under `.github/workflows/` are gated on `${{ !github.event.repository.private }}`, so they skip on any private repo and run only on public ones. Flipping your mirror to public will start firing CI on every `nomad push` against `main`, and your session transcripts (which include conversation content) become world-readable.
214
+
215
+ Bootstrap (steps 1-2 are once-ever across all hosts; step 3 repeats per host):
216
+
217
+ ```bash
218
+ # 1. Create the private repo (or use the GitHub UI). Once, ever.
219
+ gh repo create you/claude-nomad --private
220
+
221
+ # 2. Mirror the public tool into it. This severs the fork relationship,
222
+ # so your repo is independent of upstream. Once, ever.
223
+ git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/cn.git
224
+ cd /tmp/cn.git
225
+ git push --mirror git@github.com:you/claude-nomad.git
226
+ cd .. && rm -rf /tmp/cn.git
227
+
228
+ # 3. Install the CLI globally and clone your private copy. Repeat on every host.
229
+ npm i -g claude-nomad
230
+ git clone git@github.com:you/claude-nomad.git ~/claude-nomad
231
+ ```
232
+
233
+ `npm i -g claude-nomad` puts a `nomad` binary on your PATH. The bin shim is the existing `src/nomad.ts` entrypoint resolved through tsx (a runtime dependency); no compile step. The npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes; npm only blocks the install when `engine-strict=true` is configured.
234
+
235
+ On every additional host you only repeat step 3 (the global install is per-host; your private repo already exists on the remote from step 2).
236
+
237
+ Add to `~/.zshrc` or `~/.bashrc`:
238
+
239
+ ```bash
240
+ export NOMAD_HOST=<your-host-label> # any short, stable label; nomad reads this instead of os.hostname()
241
+ ```
242
+
243
+ `NOMAD_HOST` overrides `os.hostname()`, which returns noisy values like `WINDOWS-I5NT6OH` on WSL or `<name>.local` on macOS. Pick a clean label per machine (e.g., `wsl-laptop`, `macbook`, `homelab-nuc`). `nomad doctor` reports the resolved host so you can confirm.
244
+
245
+ Initialize the repo layout (first host only; subsequent hosts just clone and `nomad pull`). Pick one:
246
+
247
+ ```bash
248
+ # Fresh start: scaffold an empty shared/, hosts/, path-map.json skeleton.
249
+ nomad init
250
+
251
+ # Already have ~/.claude/ populated on this host? Capture it as the
252
+ # starting point. Stages shared/ and writes hosts/<NOMAD_HOST>.json from
253
+ # your current ~/.claude/settings.json. Does NOT touch the originals.
254
+ nomad init --snapshot
255
+ ```
256
+
257
+ `nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a safe no-op (it errors out naming the offender). `nomad pull` against an unscaffolded repo fails fast with `FATAL: repo not initialized; run 'nomad init' to scaffold` instead of silently leaving a half-state.
258
+
259
+ Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
260
+
261
+ ```bash
262
+ nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
263
+ nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
264
+ nomad push # send current state to the private remote
265
+ nomad pull # apply on another host (or this one after a remote update)
266
+ ```
267
+
268
+ `nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
269
+
270
+ If the destination host already has populated `~/.claude/{CLAUDE.md, agents/, ...}`, the first `nomad pull` will refuse to overwrite real files. See [Migrating an existing ~/.claude/](#migrating-an-existing-claude) for the safe migration flow.
271
+
272
+ ## Migrating an existing ~/.claude/
273
+
274
+ If a host already has real files at `~/.claude/{CLAUDE.md, agents/, skills/, ...}` and you want to bring them into the sync, the required sequence is `nomad init --snapshot` → `nomad push` → `nomad pull`:
275
+
276
+ ```bash
277
+ # From the host that has the canonical config (the originals are not modified):
278
+ nomad init --snapshot # stages shared/ and writes hosts/<NOMAD_HOST>.json from ~/.claude/
279
+ nomad push # publish the captured state to the private remote
280
+
281
+ # Then, on this host or any other host that has the private remote checked out:
282
+ nomad pull # materializes the symlinks
283
+ ```
284
+
285
+ `nomad pull` is what actually migrates the host. `applySharedLinks` runs a two-pass scan: any pre-existing non-symlink at a `SHARED_LINKS` path whose counterpart exists under `shared/` is renamed into `~/.cache/claude-nomad/backup/<ts>/` first, then the symlink is created. Your originals are preserved under that timestamped backup directory, not deleted. Paths whose `shared/<name>` is absent from the remote are left untouched, so a partial publish does not delete data on the destination host.
286
+
287
+ If the remote has not been populated yet (you skipped `nomad init --snapshot` and `nomad push`), `nomad pull` is a no-op for SHARED_LINKS: there is nothing on the remote to symlink against, so your local `~/.claude/` files stay in place. The auto-move only triggers once the canonical state is published.
288
+
289
+ Prefer an explicit tarball rollback and a confirmation prompt before any deletion? Write the equivalent under `scripts/`: tar the `SHARED_LINKS` entries under `~/.claude/` first, copy into `shared/`, prompt, then `nomad pull`. The auto-move path above is the recommended default.
290
+
291
+ ## Upgrading the tool
292
+
293
+ Two upgrade paths, depending on how you installed:
294
+
295
+ - **Global install (`npm i -g claude-nomad`):** `npm update -g claude-nomad`. This refreshes only the `nomad` CLI binary on PATH; your private `~/claude-nomad/` repo is untouched.
296
+ - **Source-checkout developer workflow:** `nomad update` (run from `~/claude-nomad/`). Topology-aware: detects vanilla vs fork remotes, pulls or merges upstream, and re-runs `npm install` when `package-lock.json` shifted.
297
+
298
+ Your private repo is not a fork, so GitHub's "Sync fork" UI doesn't apply. The shortcut on a source-checkout host is:
299
+
300
+ ```bash
301
+ cd ~/claude-nomad
302
+ nomad update
303
+ ```
304
+
305
+ `nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
306
+
307
+ - **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
308
+ - **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt.
309
+
310
+ Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
311
+
312
+ Common cases:
313
+
314
+ ```bash
315
+ nomad update # the usual path
316
+ nomad update --dry-run # detect topology + pre-flight, print would-be git commands only
317
+ nomad update --push-origin # fork topology: push merge to origin/main without prompting
318
+ nomad update --force # proceed past a dirty working tree
319
+ ```
320
+
321
+ One-time setup if you're running a fork layout and don't have the `upstream` remote yet:
322
+
323
+ ```bash
324
+ git remote add upstream git@github.com:funkadelic/claude-nomad.git
325
+ ```
326
+
327
+ `npm run update` still exists as a legacy shim that shells out to `scripts/update.sh`; prefer `nomad update` for new invocations.
328
+
329
+ To pin to a specific release (`vX.Y.Z`, tagged by release-please) instead of tracking `main`, fetch tags from the public repo and check out the tag (detached HEAD). On vanilla topology that's `origin`; on fork topology that's `upstream` (the private mirror at `origin` does not accumulate upstream release tags). Example: `git fetch upstream --tags && git switch --detach vX.Y.Z` (substitute `origin` for vanilla; use `git checkout vX.Y.Z` on older Git).
330
+
331
+ If you installed an earlier version via `./install.sh` and a shell alias (the pre-npm path), your existing alias keeps working unchanged. Run `npm i -g claude-nomad` whenever you're ready to switch to the global binary, confirm `nomad --version` resolves to the npm install (`which nomad` should point under your npm prefix's `bin/`), then delete the alias line from your shell rc.
332
+
333
+ ## Commands
334
+
335
+ | Command | Description |
336
+ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
337
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. |
338
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. |
339
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths. FATAL if scaffold missing. |
340
+ | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
341
+ | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
342
+ | `nomad push` | Export local sessions to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
343
+ | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
344
+ | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
345
+ | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
346
+ | `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check. |
347
+ | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
348
+ | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
349
+
350
+ The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
351
+
352
+ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring `nomad doctor`'s left-gutter format:
353
+
354
+ ```text
355
+ ✓ summary: clean
356
+ ⚠︎ summary: 3 unmapped on pull (run nomad doctor to list)
357
+ ⚠︎ summary: 2 unmapped on push, 1 collisions (run nomad doctor to list)
358
+ ```
359
+
360
+ `✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. The summary is suppressed when a fatal (`✗`) fires mid-run so you do not see "summary: clean" stacked under an error. Drive-by projects that have no entry in `path-map.json` for this host count as unmapped; the hint points at `nomad doctor`, which lists them by logical name.
361
+
362
+ ## Recovery flows
363
+
364
+ ### `nomad drop-session <id>`
365
+
366
+ Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` is never touched.
367
+
368
+ ```bash
369
+ nomad drop-session <id>
370
+ ```
371
+
372
+ Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
373
+
374
+ For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. Idempotent: a second run on the same id sees no matching staged file and exits 0.
375
+
376
+ Exit codes:
377
+
378
+ - `0` on any drop, including an idempotent re-run.
379
+ - `1` with `✗ no staged session matches <id>` on stderr when no `shared/projects/*/<id>.jsonl` matches.
380
+
381
+ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file. The local copy is preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local file separately.
382
+
383
+ ### Recovery flow: gitleaks FATAL on a session JSONL
384
+
385
+ `nomad push` runs `gitleaks protect --staged` before commit. When findings live in a session transcript, the FATAL names every affected session id and the recovery command:
386
+
387
+ ```text
388
+ ✗ gitleaks detected secrets in 1 session transcript(s).
389
+
390
+ Session <sid-aaaa>:
391
+ generic-api-key (14), aws-access-token (1)
392
+ Recover with: nomad drop-session <sid-aaaa>
393
+
394
+ After recovery, re-run nomad push.
395
+ ```
396
+
397
+ Two branches from here:
398
+
399
+ 1. **Real secret.** Rotate the credential at its provider (revoke in dashboard, issue replacement), then run `nomad drop-session <sid-aaaa>` to remove the contaminated staged copy, then re-run `nomad push`. To clear the secret from the local transcript as well, edit `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` to scrub the offending lines; the next `remapPush` copies the cleaned version forward. If the local file is not important to you, leave it alone, the staged-tree drop is enough to publish the push.
400
+
401
+ 2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist propagates to deploy hosts via `nomad update`.
402
+
403
+ `nomad drop-session` only acts on the staged tree of `~/claude-nomad/`. Active Claude Code sessions writing to the local file are not disturbed.
404
+
405
+ ### `.gitleaks.toml` allowlist policy
406
+
407
+ `gitleaks protect` runs against the staged tree on every `nomad push` and can flag structurally-distinguishable tool-output noise as `generic-api-key`. The repo-root `.gitleaks.toml` pre-allows four such patterns so routine pushes are not blocked:
408
+
409
+ - Sonar issue keys (`AY` prefix + 20+ url-safe chars).
410
+ - gitleaks fingerprint format (`<context>:<rule>:<line>` emitted by gitleaks's own reports).
411
+ - npm audit advisory hashes (anchored on the JSON shape `"id":"<40..64 hex>"`).
412
+ - Coverage-report line-keys (`key=<hex> <path>:<line>`).
413
+
414
+ The file extends the default gitleaks ruleset, so real high-entropy secrets like `ghp_*`, `sk_live_*`, `xoxb-*`, and `AKIA*` still fire. The allowlist patterns are structurally distinguishable from real-secret formats: a malformed credential cannot match an allowlist regex by accident.
415
+
416
+ ```toml
417
+ [extend]
418
+ useDefault = true
419
+
420
+ [allowlist]
421
+ description = "claude-nomad: structurally-distinguishable tool-output noise"
422
+ regexes = [
423
+ '''AY[A-Za-z0-9_-]{20,}''',
424
+ '''[\w-]+:[\w-]+:\d+''',
425
+ # ...see .gitleaks.toml at the repo root for the full list
426
+ ]
427
+ ```
428
+
429
+ File location: `.gitleaks.toml` at the public repo root (alongside `package.json`). At runtime both `probeGitleaks` (in `src/push-checks.ts`) and `runGitleaksScan` (in `src/push-gitleaks.ts`) conditionally pass `--config <REPO_HOME>/.gitleaks.toml` when the file exists. Hosts that have not yet run `nomad update` (or fresh clones predating the allowlist) fall back silently to the default gitleaks ruleset; there is no warning. Run `nomad update` to receive the latest allowlist.
430
+
431
+ Editing: amend `.gitleaks.toml` in this repo, open a PR, and merge to `main`. Use TOML literal strings (triple single quotes, `'''regex'''`) for new regex entries so backslashes do not need escaping. Verify the new pattern does not match real-secret formats (`ghp_<36>`, `sk_live_*`, `xoxb-*`, `AKIA[A-Z0-9]{16}`, etc.) before merging. The propagation path is the same as any other repo update: `nomad update` on each host pulls the new file in.
432
+
433
+ ## Cross-OS resume
434
+
435
+ Claude Code embeds the original `cwd` in each session transcript. When you resume on a different host where that path doesn't exist, the picker prints a `cd <orig-cwd> && claude --resume <id>` line that fails (the source-host path isn't there).
436
+
437
+ Run this instead:
438
+
439
+ ```bash
440
+ eval "$(nomad doctor --resume-cmd <session-id>)"
441
+ ```
442
+
443
+ Or pipe through bash:
444
+
445
+ ```bash
446
+ nomad doctor --resume-cmd <session-id> | bash
447
+ ```
448
+
449
+ `nomad doctor --resume-cmd <id>` reads the `.jsonl`'s recorded `cwd`, reverse-looks up the logical project in `path-map.json`, finds your current host's abspath for that logical, and prints `cd <local-abspath> && claude --resume <id>` to stdout. The command is read-only: it never modifies any transcript byte (Phase 1's sha256 byte-equality invariant is preserved).
450
+
451
+ If the session isn't mapped on this host, you'll see:
452
+
453
+ ```text
454
+ ✗ session <id> not mapped on this host; add the logical to path-map.json
455
+ ```
456
+
457
+ Other fatal surfaces: missing `~/.claude/projects/`, session id absent from every encoded dir, no `cwd` field anywhere in the transcript, missing `path-map.json`, recorded cwd not present in any logical's host map. All errors go to stderr prefixed with the red `✗` fail glyph; the success line goes to stdout as a bare shell command (no glyph) so `eval` works.
458
+
459
+ ## Run tests
460
+
461
+ ```bash
462
+ npm install
463
+ npx vitest run
464
+ ```
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "claude-nomad",
3
+ "version": "0.17.0",
4
+ "type": "module",
5
+ "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
+ "keywords": [
7
+ "claude",
8
+ "claude-code",
9
+ "sync",
10
+ "dotfiles"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/funkadelic/claude-nomad.git"
15
+ },
16
+ "homepage": "https://github.com/funkadelic/claude-nomad#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/funkadelic/claude-nomad/issues"
19
+ },
20
+ "license": "MIT",
21
+ "bin": {
22
+ "nomad": "./src/nomad.ts"
23
+ },
24
+ "files": [
25
+ "src/",
26
+ "shared/.gitignore",
27
+ ".gitleaks.toml",
28
+ "README.md",
29
+ "CHANGELOG.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22.22.1"
34
+ },
35
+ "scripts": {
36
+ "pull": "tsx src/nomad.ts pull",
37
+ "push": "tsx src/nomad.ts push",
38
+ "doctor": "tsx src/nomad.ts doctor",
39
+ "update": "bash scripts/update.sh",
40
+ "test": "vitest run",
41
+ "coverage": "vitest run --coverage",
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "eslint .",
44
+ "lint:fix": "eslint . --fix",
45
+ "format": "prettier --write .",
46
+ "format:check": "prettier --check .",
47
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test && node scripts/verify-tarball.cjs",
48
+ "prepare": "husky"
49
+ },
50
+ "lint-staged": {
51
+ "*.ts": [
52
+ "eslint --fix",
53
+ "prettier --write"
54
+ ],
55
+ "*.{js,mjs,cjs,json,md}": [
56
+ "prettier --write"
57
+ ]
58
+ },
59
+ "devDependencies": {
60
+ "@commitlint/cli": "^21.0.1",
61
+ "@commitlint/config-conventional": "^21.0.1",
62
+ "@eslint/js": "^10.0.1",
63
+ "@types/node": "^22.0.0",
64
+ "@vitest/coverage-v8": "^4.1.6",
65
+ "eslint": "^10.4.0",
66
+ "eslint-config-prettier": "^10.1.8",
67
+ "globals": "^17.6.0",
68
+ "husky": "^9.1.7",
69
+ "lint-staged": "^17.0.5",
70
+ "prettier": "^3.8.3",
71
+ "typescript": "^6.0.3",
72
+ "typescript-eslint": "^8.59.4",
73
+ "vitest": "^4.1.6"
74
+ },
75
+ "dependencies": {
76
+ "picocolors": "^1.1.1",
77
+ "tsx": "^4.22.2"
78
+ }
79
+ }
@@ -0,0 +1,8 @@
1
+ *.token
2
+ *.key
3
+ .env
4
+ .env.*
5
+ .claude.json
6
+ *.pem
7
+ id_rsa
8
+ id_ed25519
package/src/color.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Identity-fallback ANSI color helpers used exclusively by `cmdDoctor`.
3
+ *
4
+ * The seven exports wrap their picocolors equivalents when color is enabled
5
+ * (per the picocolors `isColorSupported` flag) and return their input unchanged
6
+ * when disabled. Picocolors already handles `NO_COLOR`, `FORCE_COLOR`,
7
+ * `--no-color`, `--color`, `win32`, `TTY`, `TERM=dumb`, and `CI` natively, so
8
+ * we delegate detection rather than rolling a hand-built TTY probe.
9
+ *
10
+ * Win32 caveat: picocolors forces color ON for `process.platform === 'win32'`
11
+ * even on piped output. The supported user surface is WSL / Linux / macOS
12
+ * where `process.platform` is `linux` or `darwin`; native Windows users can
13
+ * opt out via `NO_COLOR=1`.
14
+ *
15
+ * The `enabled` flag is read once at module load and constant for the rest of
16
+ * the CLI invocation; tests must `vi.resetModules()` between env-var toggles.
17
+ */
18
+ import pc from 'picocolors';
19
+
20
+ const enabled = pc.isColorSupported;
21
+
22
+ /** Wraps the FAIL glyph (failGlyph) and gitlink path warnings. */
23
+ export const red = (s: string): string => (enabled ? pc.red(s) : s);
24
+
25
+ /** Wraps the WARN glyph (warnGlyph). */
26
+ export const yellow = (s: string): string => (enabled ? pc.yellow(s) : s);
27
+
28
+ /** Wraps the PASS glyph (okGlyph) and short positive tags. */
29
+ export const green = (s: string): string => (enabled ? pc.green(s) : s);
30
+
31
+ /** Hostnames and URLs. */
32
+ export const cyan = (s: string): string => (enabled ? pc.cyan(s) : s);
33
+
34
+ /** Absolute paths. */
35
+ export const blue = (s: string): string => (enabled ? pc.blue(s) : s);
36
+
37
+ /** Version strings and counts. */
38
+ export const dim = (s: string): string => (enabled ? pc.dim(s) : s);
39
+
40
+ /** Combined-bold variant (e.g., `red(bold(...))` for emphasized error headers). */
41
+ export const bold = (s: string): string => (enabled ? pc.bold(s) : s);
42
+
43
+ /**
44
+ * WSL / Windows-Terminal width hack. On WSL the VS15-suffixed glyphs below
45
+ * (`⚠︎`, `ℹ︎`) render at 2 terminal columns even though VS15 nominally forces
46
+ * 1-column text presentation, while `✓`/`✗` (East-Asian-Width=Narrow) stay at
47
+ * 1 column. The call-site format `${glyph} ${msg}` then puts `msg` one column
48
+ * to the right after a warn/info glyph than after an ok/fail glyph, breaking
49
+ * the gutter alignment (`ℹ︎ host:` shifts one cell right of `✓ repo:`). The
50
+ * fix is to append an extra space to the NARROW `okGlyph`/`failGlyph` so all
51
+ * four glyphs occupy a 2-column rendered footprint on WSL. Native Linux and
52
+ * macOS terminals render every glyph at 1 column and need no compensation.
53
+ *
54
+ * Detection uses the `WSL_DISTRO_NAME` env var (always set by WSL2's init,
55
+ * present in interactive shells and propagated to subprocesses). The check
56
+ * runs at module load and is constant for the rest of the invocation.
57
+ */
58
+ const wslNarrowPad = process.env.WSL_DISTRO_NAME ? ' ' : '';
59
+
60
+ /** PASS indicator glyph (U+2713 CHECK MARK). Wrap in `green()` at call sites. */
61
+ export const okGlyph = `✓${wslNarrowPad}`;
62
+
63
+ /** FAIL indicator glyph (U+2717 BALLOT X). Wrap in `red()` at call sites. */
64
+ export const failGlyph = `✗${wslNarrowPad}`;
65
+
66
+ /**
67
+ * WARN indicator glyph (U+26A0 WARNING SIGN + U+FE0E VARIATION SELECTOR-15
68
+ * for text-presentation; the VS15 forces monochrome rendering so the symbol
69
+ * does not flash as a colored emoji on terminals with emoji-presentation
70
+ * defaults). Wrap in `yellow()` at call sites.
71
+ */
72
+ export const warnGlyph = '⚠︎';
73
+
74
+ /**
75
+ * Informational marker (U+2139 INFORMATION SOURCE + U+FE0E VARIATION
76
+ * SELECTOR-15 for text-presentation; the VS15 forces monochrome rendering
77
+ * so the symbol does not flash as a colored emoji on terminals with
78
+ * emoji-presentation defaults). Wrap in `dim()` at call sites so info rows
79
+ * do not compete visually with PASS/FAIL/WARN status glyphs.
80
+ */
81
+ export const infoGlyph = 'ℹ︎';