claude-nomad 0.24.0 → 0.25.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +31 -29
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +101 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.gitleaks-version.ts +132 -0
  12. package/src/commands.doctor.mirror-actions.ts +83 -0
  13. package/src/commands.doctor.ts +18 -10
  14. package/src/commands.drop-session.git.ts +81 -0
  15. package/src/commands.drop-session.ts +89 -107
  16. package/src/commands.pull.ts +3 -2
  17. package/src/commands.push.allowlist.ts +119 -0
  18. package/src/commands.push.ts +13 -116
  19. package/src/commands.update.git.ts +90 -0
  20. package/src/commands.update.resolve.ts +138 -0
  21. package/src/commands.update.test-helpers.git.ts +107 -0
  22. package/src/commands.update.ts +31 -223
  23. package/src/config.ts +23 -10
  24. package/src/diff.ts +2 -1
  25. package/src/extras-sync.diff.ts +40 -0
  26. package/src/extras-sync.guards.ts +52 -0
  27. package/src/extras-sync.ts +166 -227
  28. package/src/init.classify.ts +1 -1
  29. package/src/init.snapshot.ts +3 -1
  30. package/src/init.ts +2 -1
  31. package/src/links.ts +3 -10
  32. package/src/nomad.dispatch.ts +25 -0
  33. package/src/nomad.help.ts +43 -0
  34. package/src/nomad.ts +6 -68
  35. package/src/preview.ts +2 -1
  36. package/src/push-gitleaks.scan.ts +115 -0
  37. package/src/push-gitleaks.ts +66 -120
  38. package/src/remap.ts +3 -1
  39. package/src/resume.ts +2 -1
  40. package/src/update.fork-extras.ts +102 -0
  41. package/src/utils.fs.ts +152 -0
  42. package/src/utils.json.ts +55 -0
  43. package/src/utils.lockfile.ts +131 -0
  44. package/src/utils.ts +23 -330
  45. package/src/commands.doctor.checks.ts +0 -350
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.1](https://github.com/funkadelic/claude-nomad/compare/v0.25.0...v0.25.1) (2026-05-26)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **doctor:** scan --check-shared like push to fix false negatives ([#134](https://github.com/funkadelic/claude-nomad/issues/134)) ([5063028](https://github.com/funkadelic/claude-nomad/commit/5063028c6a695709f7f2d2dfe3cceabedecc17f9))
9
+
10
+
11
+ ### Changed
12
+
13
+ * split over-cap source and test files under the ~200-line cap ([#136](https://github.com/funkadelic/claude-nomad/issues/136)) ([0cc7eed](https://github.com/funkadelic/claude-nomad/commit/0cc7eed13abbc85084b04cfc8b686e53b26133c6))
14
+
15
+ ## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
16
+
17
+
18
+ ### Added
19
+
20
+ * **doctor:** add gitleaks version and mirror-Actions drift checks ([#125](https://github.com/funkadelic/claude-nomad/issues/125)) ([8a16e0c](https://github.com/funkadelic/claude-nomad/commit/8a16e0c274bb7b961c6fd2df9912874c572023ee))
21
+ * **extras:** support a single root file as an extras entry ([#132](https://github.com/funkadelic/claude-nomad/issues/132)) ([6303b62](https://github.com/funkadelic/claude-nomad/commit/6303b62de84406da6a7fa2b7eb7af28a0473d0aa))
22
+
23
+
24
+ ### Fixed
25
+
26
+ * **drop-session:** cascade unstage into subagent transcript directory ([#120](https://github.com/funkadelic/claude-nomad/issues/120)) ([7f10d51](https://github.com/funkadelic/claude-nomad/commit/7f10d5135a7eaa9b6e74fb1aa863506a7b914a09))
27
+ * **push,update:** handle untracked extras in allow-list and fork merge ([#122](https://github.com/funkadelic/claude-nomad/issues/122)) ([e710a48](https://github.com/funkadelic/claude-nomad/commit/e710a48973a7c4e7479a9671d5891458a182dac7))
28
+ * **update:** skip push prompt when fork merge is a no-op ([#123](https://github.com/funkadelic/claude-nomad/issues/123)) ([77fdb20](https://github.com/funkadelic/claude-nomad/commit/77fdb20244a096e44a1fecd0a283e3401a78972d))
29
+
30
+
31
+ ### Changed
32
+
33
+ * pin workflow actions to SHAs and drop persisted CI credentials ([#130](https://github.com/funkadelic/claude-nomad/issues/130)) ([b60109c](https://github.com/funkadelic/claude-nomad/commit/b60109c0cbb5dff1b13c4155cc12c3d98ce1b141))
34
+
35
+
36
+ ### Documentation
37
+
38
+ * add CONTRIBUTING, PR template, and SECURITY policy ([#131](https://github.com/funkadelic/claude-nomad/issues/131)) ([774eb58](https://github.com/funkadelic/claude-nomad/commit/774eb58a36af032f7d1cd109d59f313c4ff6affa))
39
+ * lead README with a benefit-first pitch ([#133](https://github.com/funkadelic/claude-nomad/issues/133)) ([289e299](https://github.com/funkadelic/claude-nomad/commit/289e2994fd7dffb10818fe02aad64b193ad800b6))
40
+ * **readme:** foreground secret-safety in the intro ([#126](https://github.com/funkadelic/claude-nomad/issues/126)) ([e661e9f](https://github.com/funkadelic/claude-nomad/commit/e661e9f02247fc30630fd90d5facc65aa7b4f2a0))
41
+
42
+
43
+ ### Dependencies
44
+
45
+ * bump SonarSource/sonarqube-scan-action from 8.0.0 to 8.1.0 ([#127](https://github.com/funkadelic/claude-nomad/issues/127)) ([603f20d](https://github.com/funkadelic/claude-nomad/commit/603f20d9a22528c8df19d328c91a02012690836c))
46
+ * bump the dev-dependencies group across 1 directory with 2 updates ([#128](https://github.com/funkadelic/claude-nomad/issues/128)) ([50b1b87](https://github.com/funkadelic/claude-nomad/commit/50b1b87b7288eed60280843504e6ccc2598f10b0))
47
+ * bump tsx from 4.22.2 to 4.22.3 in the prod-dependencies group ([#129](https://github.com/funkadelic/claude-nomad/issues/129)) ([a7ada00](https://github.com/funkadelic/claude-nomad/commit/a7ada000ec2d3dbee132c7b61dc207012e53c1c3))
48
+
3
49
  ## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
4
50
 
5
51
 
package/README.md CHANGED
@@ -6,17 +6,17 @@
6
6
 
7
7
  ![claude-nomad - Sync your Claude Code setup. Same environment. Any machine.](docs/hero.svg)
8
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.
9
+ **Your entire Claude Code setup, on every machine. History included, secrets excluded.**
10
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.
11
+ Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
12
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.
13
+ - **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
14
+ - **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions logs.
15
+ - **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
14
16
 
15
- Three things it does that ad-hoc dotfiles syncing can't:
17
+ Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
16
18
 
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
- - **Per-project content rides along, opt-in.** Whitelisted directories at a project's root (declared via `path-map.json`'s `extras` field) sync alongside session transcripts, so project-attached state like `.planning/` follows you across hosts. Off by default; projects without an `extras` entry behave exactly as before.
19
+ 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. [Get started in three steps.](#quickstart)
20
20
 
21
21
  ## Table of contents
22
22
 
@@ -114,7 +114,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
114
114
  │ ├── commands/
115
115
  │ ├── rules/
116
116
  │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
117
- │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
117
+ │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
118
118
  │ ├── projects/ # session transcripts under logical names
119
119
  │ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
120
120
  ├── hosts/
@@ -127,14 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
127
127
 
128
128
  ## What gets synced vs. not
129
129
 
130
- | Category | Items | Behavior |
131
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
132
- | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
133
- | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
134
- | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
135
- | **Per-project extras** | `<localRoot>/.planning/` and other directories whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<dirname>/`. Pre-pull divergence WARN flags local edits before they get overwritten. |
136
- | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
137
- | **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. |
130
+ | Category | Items | Behavior |
131
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
133
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
134
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
135
+ | **Per-project extras** | `<localRoot>/.planning/` and other directories, or a single root file like `CLAUDE.md`, whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<name>` (directory subtree or single file). Pre-pull divergence WARN flags local edits before they get overwritten. |
136
+ | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
137
+ | **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. |
138
138
 
139
139
  > [!NOTE]
140
140
  > 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.
@@ -145,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
145
145
 
146
146
  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.
147
147
 
148
- `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 at its root:
148
+ `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:
149
149
 
150
150
  ```json
151
151
  {
@@ -157,7 +157,7 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
157
157
  }
158
158
  },
159
159
  "extras": {
160
- "ha-acwd": [".planning"]
160
+ "ha-acwd": [".planning", "CLAUDE.md"]
161
161
  }
162
162
  }
163
163
  ```
@@ -169,7 +169,7 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
169
169
 
170
170
  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).
171
171
 
172
- The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory names validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in directories at `<localRoot>/<dirname>/` are copied to `shared/extras/<logical>/<dirname>/` and inherit the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
172
+ The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory or root-file names (e.g. `.planning`, `CLAUDE.md`) validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and inherits the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
173
173
 
174
174
  ## Per-host overrides
175
175
 
@@ -206,7 +206,7 @@ Read these before adopting so you opt in with eyes open.
206
206
  - **Manual push/pull.** No file watcher. Shell hooks recommended.
207
207
  - **OAuth doesn't sync.** You'll log in once per host. Intentional.
208
208
  - **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
209
- - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Dirnames outside `SUPPORTED_EXTRAS` are 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) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
209
+ - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Names (a directory or a single root file) outside `SUPPORTED_EXTRAS` are 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) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
210
210
  - **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.
211
211
  - **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.
212
212
 
@@ -334,9 +334,9 @@ nomad update
334
334
  `nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
335
335
 
336
336
  - **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
337
- - **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.
337
+ - **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that is still untracked locally so an overlap with upstream becomes a normal file merge instead of an untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged, nothing new to push) the prompt is skipped entirely and `nomad update` logs `already in sync with origin/main`.
338
338
 
339
- 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.
339
+ 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, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it, 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.
340
340
 
341
341
  Common cases:
342
342
 
@@ -369,15 +369,17 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
369
369
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
370
370
  | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
371
371
  | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
372
- | `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). |
372
+ | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
373
373
  | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
374
- | `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. |
374
+ | `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 plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
375
375
  | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
376
376
  | `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
377
377
  | `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. |
378
378
 
379
379
  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.
380
380
 
381
+ Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions re-enabled, complementing the auto-disable that runs on `nomad init` (see [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub origin, `gh` unauthed, public repo, or Actions already off).
382
+
381
383
  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:
382
384
 
383
385
  ```text
@@ -392,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
392
394
 
393
395
  ### `nomad drop-session <id>`
394
396
 
395
- Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` is never touched.
397
+ Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/` subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree are never touched.
396
398
 
397
399
  ```bash
398
400
  nomad drop-session <id>
@@ -400,14 +402,14 @@ nomad drop-session <id>
400
402
 
401
403
  Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
402
404
 
403
- 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.
405
+ 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. The `<id>/` subagent directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows through the same per-entry classification; a session that has only a subagent directory (no flat `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged entries and exits 0.
404
406
 
405
407
  Exit codes:
406
408
 
407
409
  - `0` on any drop, including an idempotent re-run.
408
- - `1` with `✗ no staged session matches <id>` on stderr when no `shared/projects/*/<id>.jsonl` matches.
410
+ - `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
409
411
 
410
- 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.
412
+ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrubbing or removing the local files is REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.ts`) re-mirrors the local content into the staged tree on the next push, so a drop without a local scrub re-stages the same secret.
411
413
 
412
414
  ### Recovery flow: gitleaks FATAL on a session JSONL
413
415
 
@@ -425,7 +427,7 @@ After recovery, re-run nomad push.
425
427
 
426
428
  Two branches from here:
427
429
 
428
- 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.
430
+ 1. **Real secret.** Rotate the credential at its provider first (revoke in dashboard, issue replacement) before touching anything else. Running `nomad drop-session <sid-aaaa>` clears the contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in `src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/` on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and re-stages the same secret. The durable fix is to rotate AND scrub or remove the local transcript at `~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent directory under that encoded dir, if present) so the next `remapPush` carries clean content forward. Do not leave the local file un-scrubbed and expect the staged-tree drop to hold.
429
431
 
430
432
  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`.
431
433
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Scan-result classification and row emission for the `nomad doctor
3
+ * --check-shared` preflight. Split out of `commands.doctor.check-shared.ts` to
4
+ * keep both files under the line cap; `reportCheckShared` (the public reporter)
5
+ * stays in the sibling and calls `scanAndReport` after staging the temp tree.
6
+ *
7
+ * Owns the post-stage block: run the shared `scanStagedTree` (the same git
8
+ * init + add + `gitleaks protect --staged` mechanism push uses), classify the
9
+ * findings via `partitionFindings`, and emit the doctor glyph rows (clean,
10
+ * per-session leak with rotate-and-scrub guidance, and the nested "other"
11
+ * bucket). All external work flows through `scanStagedTree`; this module spawns
12
+ * nothing itself.
13
+ */
14
+
15
+ import { join } from 'node:path';
16
+
17
+ import { green, red, okGlyph, failGlyph } from './color.ts';
18
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
+ import { CLAUDE_HOME } from './config.ts';
20
+ import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
21
+
22
+ /**
23
+ * Recover the absolute live transcript path
24
+ * `~/.claude/projects/<encoded>/<sid>.jsonl` by mapping the finding's
25
+ * `<logical>` through the staging association, falling back to the logical name
26
+ * when the association is missing (defensive; the temp-tree build guarantees a hit).
27
+ */
28
+ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
29
+ /* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
30
+ const encoded = logicalToEncoded.get(logical) ?? logical;
31
+ return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
32
+ }
33
+
34
+ /**
35
+ * Emit one fail row per affected session plus rotate-and-scrub + allowlist
36
+ * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
37
+ * `<logical>` captured from the same match that keyed `bySession`, so the
38
+ * scrub-path hint reuses the authoritative parse. The hint guard omits a row
39
+ * rather than print a wrong path if the invariant ever breaks; the leak row is
40
+ * always emitted.
41
+ */
42
+ function reportSessionFindings(
43
+ section: DoctorSection,
44
+ bySession: Map<string, Map<string, number>>,
45
+ logicalBySession: Map<string, string>,
46
+ logicalToEncoded: Map<string, string>,
47
+ ): void {
48
+ for (const [sid, counts] of bySession) {
49
+ const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
+ addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
51
+ const logical = logicalBySession.get(sid);
52
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
53
+ if (logical !== undefined) {
54
+ addItem(
55
+ section,
56
+ ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
57
+ );
58
+ }
59
+ addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
60
+ }
61
+ process.exitCode = 1;
62
+ }
63
+
64
+ /**
65
+ * Emit one fail row per non-session ("other"-bucket) finding and set
66
+ * `process.exitCode = 1`. These are findings whose `File` did not match the
67
+ * flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
68
+ * which `nomad push` would still stage). Names the repo-relative path and
69
+ * RuleID only, never the matched secret.
70
+ */
71
+ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
72
+ for (const f of other) {
73
+ addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
74
+ }
75
+ process.exitCode = 1;
76
+ }
77
+
78
+ /**
79
+ * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
80
+ * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
81
+ * the exported `SESSION_PATH` shape; the `<logical>` group lets the scrub-path
82
+ * hint reuse this single authoritative parse.
83
+ */
84
+ const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
85
+
86
+ /**
87
+ * Emit the single canonical clean row reporting the scanned-project count
88
+ * (`staged` is the number of mapped project dirs staged, not a transcript
89
+ * total). Centralizing the literal keeps every clean path (zero-staged,
90
+ * scanned-clean, findings-but-no-`other`) phrased consistently.
91
+ */
92
+ export function emitClean(section: DoctorSection, staged: number): void {
93
+ addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
94
+ }
95
+
96
+ /**
97
+ * Build the `sid -> <logical>` association from the findings, capturing both
98
+ * groups from the same `SESSION_PATH_LOGICAL` match so the scrub-path hint
99
+ * never re-derives the logical name. First match per sid wins (the scrub path
100
+ * is per session, not per finding).
101
+ */
102
+ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
103
+ const logicalBySession = new Map<string, string>();
104
+ for (const f of findings) {
105
+ const m = SESSION_PATH_LOGICAL.exec(f.File);
106
+ if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
107
+ /* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
108
+ logicalBySession.set(m[2], m[1] ?? '');
109
+ }
110
+ }
111
+ return logicalBySession;
112
+ }
113
+
114
+ /**
115
+ * Scan the staged temp tree through the shared `scanStagedTree` and emit the
116
+ * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
117
+ * try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
118
+ * stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
119
+ * and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
120
+ * finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
121
+ * nor any session) is still a stageable secret push would catch, so a
122
+ * `bySession`-only gate would make the preflight weaker than the push scan.
123
+ */
124
+ export function scanAndReport(
125
+ section: DoctorSection,
126
+ tmpRoot: string,
127
+ staged: number,
128
+ logicalToEncoded: Map<string, string>,
129
+ ): void {
130
+ let findings: Finding[] | null;
131
+ try {
132
+ findings = scanStagedTree(tmpRoot);
133
+ } catch (err) {
134
+ // ENOENT (binary vanished mid-flow) or a git failure. The top-of-flow probe
135
+ // WARN-skips a truly missing gitleaks; this catch reports a scan-failed FAIL
136
+ // row with err.message only (never stderr/stdout, which can echo
137
+ // redacted-but-sensitive scan output).
138
+ addItem(section, `${red(failGlyph)} scan failed: ${(err as Error).message}`);
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ if (findings === null) {
143
+ // Non-zero gitleaks exit with no parseable report. Carry no stream output,
144
+ // matching runGitleaksScan on the push side.
145
+ addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ const { bySession, other } = partitionFindings(findings);
150
+ if (bySession.size === 0 && other.length === 0) {
151
+ emitClean(section, staged);
152
+ return;
153
+ }
154
+ if (other.length > 0) reportOtherFindings(section, other);
155
+ if (bySession.size > 0) {
156
+ reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
157
+ }
158
+ }