claude-nomad 0.27.0 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.1](https://github.com/funkadelic/claude-nomad/compare/v0.29.0...v0.29.1) (2026-05-29)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **doctor:** degrade gitleaks-absent probe to WARN, not FAIL ([#173](https://github.com/funkadelic/claude-nomad/issues/173)) ([320bb8a](https://github.com/funkadelic/claude-nomad/commit/320bb8a5d6f6c1f02207be8e186fe678f8e6f8bd))
9
+
10
+ ## [0.29.0](https://github.com/funkadelic/claude-nomad/compare/v0.28.0...v0.29.0) (2026-05-29)
11
+
12
+
13
+ ### Added
14
+
15
+ * sync hook scripts and tool support dirs across hosts ([#171](https://github.com/funkadelic/claude-nomad/issues/171)) ([e340fd2](https://github.com/funkadelic/claude-nomad/commit/e340fd221882f9107f4e10c3a64ccd7be4061a14))
16
+
17
+ ## [0.28.0](https://github.com/funkadelic/claude-nomad/compare/v0.27.0...v0.28.0) (2026-05-28)
18
+
19
+
20
+ ### Added
21
+
22
+ * **doctor:** settings schema drift tooling (auto-sync PR + --check-schema) ([#168](https://github.com/funkadelic/claude-nomad/issues/168)) ([ac4ac21](https://github.com/funkadelic/claude-nomad/commit/ac4ac21f90148d7261b0b907bfdad43b3758f9fd))
23
+
24
+
25
+ ### Fixed
26
+
27
+ * **doctor:** resync KNOWN_SETTINGS_KEYS with official settings schema ([#166](https://github.com/funkadelic/claude-nomad/issues/166)) ([2b453e1](https://github.com/funkadelic/claude-nomad/commit/2b453e18c18520dd0a4df035ace3825709097bc1))
28
+ * drop-session scrub hint and README rendering/layout fixes ([#165](https://github.com/funkadelic/claude-nomad/issues/165)) ([0840ab4](https://github.com/funkadelic/claude-nomad/commit/0840ab408b72174b23532a0ea32c27df522cfe39))
29
+
3
30
  ## [0.27.0](https://github.com/funkadelic/claude-nomad/compare/v0.26.2...v0.27.0) (2026-05-28)
4
31
 
5
32
 
package/README.md CHANGED
@@ -45,6 +45,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
45
45
  - [Repo layout](#repo-layout-what-claude-nomad-looks-like-on-a-configured-host)
46
46
  - [What gets synced vs. not](#what-gets-synced-vs-not)
47
47
  - [Path remapping](#path-remapping)
48
+ - [Shared support dirs (sharedDirs)](#shared-support-dirs-shareddirs)
48
49
  - [Per-host overrides](#per-host-overrides)
49
50
  - [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs)
50
51
  - **Getting started**
@@ -67,7 +68,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
67
68
  ## Quickstart
68
69
 
69
70
  If you already have a private **claude-nomad** mirror (see [Setup](#setup) for the one-time
70
- bootstrap), adding a new host is three steps:
71
+ bootstrap), adding a new host is two one-time steps, then the everyday loop:
71
72
 
72
73
  ```bash
73
74
  $ npm i -g claude-nomad
@@ -113,6 +114,7 @@ public funkadelic/claude-nomad your private <your-username>/claude-noma
113
114
  │ ├── skills/
114
115
  │ ├── commands/
115
116
  │ ├── rules/
117
+ │ ├── hooks/
116
118
  │ ├── settings.base.json
117
119
  │ └── projects/
118
120
  ├── hosts/<hostname>.json
@@ -143,6 +145,7 @@ so a clobbered dotfile variable does not break the CLI.
143
145
  │ ├── skills/
144
146
  │ ├── commands/
145
147
  │ ├── rules/
148
+ │ ├── hooks/ # hook scripts, symlinked into ~/.claude/hooks/
146
149
  │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
147
150
  │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
148
151
  │ ├── projects/ # session transcripts under logical names
@@ -157,16 +160,35 @@ so a clobbered dotfile variable does not break the CLI.
157
160
 
158
161
  ## What gets synced vs. not
159
162
 
160
- | Category | Items | Behavior |
161
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
162
- | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
163
- | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
164
- | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
165
- | **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. |
166
- | **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. |
167
- | **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. |
168
-
169
- > [!NOTE] Plugins that depend on host-specific state (external binaries, API keys in env, MCP server
163
+ | Category | Items | Behavior |
164
+ | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `hooks/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
166
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
167
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
168
+ | **Per-project extras** | Whitelisted dirs like `.planning/`, or a root file like `CLAUDE.md` | Opt-in via the `extras` field in `path-map.json`; mirrored to/from `shared/extras/<logical>/`. |
169
+ | **Shared support dirs** | Opt-in global `~/.claude/` dirs like a tool's `get-shit-done/` | Opt-in via the `sharedDirs` field in `path-map.json`; symlinked into `~/.claude/` from `shared/`. See [Shared support dirs](#shared-support-dirs-shareddirs). |
170
+ | **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
171
+ | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
172
+
173
+ Pointers and specifics:
174
+
175
+ - **Synced** link names live in `SHARED_LINKS` (and the optional `sharedDirs` field in
176
+ `path-map.json` -- see [Shared support dirs](#shared-support-dirs-shareddirs)), **whitelisted
177
+ extras** names in `SUPPORTED_EXTRAS`, and the full **never-synced** set in `NEVER_SYNC` (all in
178
+ `src/config.ts`).
179
+ - **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `.credentials.json` (OAuth
180
+ credential store), `history.jsonl`, `settings.local.json` (per-host overrides),
181
+ `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`,
182
+ `session-env/`, `statsig/`, `telemetry/`, `ide/`, plus host-local caches and runtime state
183
+ (`cache/`, `backups/`, `paste-cache/`, `daemon/`, `jobs/`, `tasks/`, `security/`, `sessions/`).
184
+ This set is also the deny-list the `sharedDirs` opt-in is checked against, so one of these names
185
+ cannot be symlinked into the shared repo by mistake.
186
+ - **Per-project extras** run a pre-pull divergence WARN that flags local edits before they get
187
+ overwritten.
188
+
189
+ <!-- prettier-ignore -->
190
+ > [!NOTE]
191
+ > Plugins that depend on host-specific state (external binaries, API keys in env, MCP server
170
192
  > URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
171
193
  > own per-host config.
172
194
 
@@ -197,7 +219,9 @@ block opts a project into syncing whitelisted directories (or a single root file
197
219
  }
198
220
  ```
199
221
 
200
- > [!IMPORTANT] The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
222
+ <!-- prettier-ignore -->
223
+ > [!IMPORTANT]
224
+ > The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
201
225
  > [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's
202
226
  > encoded dir.
203
227
 
@@ -225,6 +249,55 @@ copy and prints a per-file WARN naming anything that differs.
225
249
  Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
226
250
  the pull copy lands, so an unexpected overwrite is always recoverable.
227
251
 
252
+ ## Shared support dirs (sharedDirs)
253
+
254
+ Some tools install a `hooks` block into `settings.json` whose commands point at scripts under
255
+ `~/.claude/hooks/` (and sometimes a support directory such as `~/.claude/get-shit-done/`). Because
256
+ `settings.json` is regenerated on every pull, that hook configuration travels to every host, but the
257
+ scripts it points at did not, so hooks broke on a freshly configured host. `~/.claude/hooks/` is now
258
+ a built-in synced link (it rides the same symlink model as `skills/` and `agents/`), so hook scripts
259
+ travel automatically.
260
+
261
+ For any other global `~/.claude/` support directory a tool needs, the optional top-level
262
+ `sharedDirs` field in `path-map.json` opts it into the same symlink sync:
263
+
264
+ ```json
265
+ {
266
+ "projects": {
267
+ "my-example-repo": {
268
+ "<your-mac>": "/Users/you/code/my-example-repo"
269
+ }
270
+ },
271
+ "sharedDirs": ["get-shit-done"]
272
+ }
273
+ ```
274
+
275
+ What this means for you: each listed name is symlinked from `shared/<name>` into `~/.claude/<name>`
276
+ (the same model as the built-in synced links, not a copy), so editing it on any host updates the one
277
+ shared copy. The field is additive and back-compatible: a `path-map.json` without it behaves exactly
278
+ as before.
279
+
280
+ Entries are validated before anything is linked. A name is accepted only if it is a single path
281
+ segment (no `/`, no `..`), is not one of the never-synced names, and does not collide with a
282
+ reserved `shared/` name (`settings.base.json`, the built-in synced links, `hooks`, `hosts`,
283
+ `path-map.json`). An invalid entry is dropped with a warning rather than aborting the run. The
284
+ contents still go through the same gitleaks scan as everything else on push, so do not point
285
+ `sharedDirs` at a directory that holds credentials.
286
+
287
+ First-time setup on an already-configured repo: a symlink can only form once the directory exists
288
+ under `shared/`. On a fresh repo `nomad init --snapshot` handles this for you. To add `hooks/` (or a
289
+ new `sharedDirs` entry) to a repo that is already set up, move it into `shared/` once on the host
290
+ that has it, then let the normal flow take over:
291
+
292
+ ```bash
293
+ $ mv ~/.claude/hooks ~/claude-nomad/shared/hooks # one-time, on the source host
294
+ $ nomad pull # re-creates ~/.claude/hooks as a symlink
295
+ $ nomad push # shares it with your other hosts
296
+ ```
297
+
298
+ `nomad pull` never writes back to the remote, so it will not seed `shared/` for you; the one-time
299
+ move is deliberate.
300
+
228
301
  ## Per-host overrides
229
302
 
230
303
  `settings.base.json` holds portable defaults (model, permissions, plugins).
@@ -249,17 +322,25 @@ host-only model overrides).
249
322
 
250
323
  ```json
251
324
  {
252
- "model": "claude-opus-4-7",
325
+ "model": "claude-opus-4-8",
253
326
  "env": { "OLLAMA_HOST": "http://localhost:11434" }
254
327
  }
255
328
  ```
256
329
 
257
- Results on `your-other-host`: opus 4.7, the local Ollama env var, plus the shared permissions array.
330
+ Results on `your-other-host`: opus 4.8, the local Ollama env var, plus the shared permissions array.
258
331
 
259
- > [!CAUTION] Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
332
+ <!-- prettier-ignore -->
333
+ > [!CAUTION]
334
+ > Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
260
335
  > `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
261
336
  > repo instead.
262
337
 
338
+ `nomad doctor` warns when `settings.json` carries a top-level key it does not recognize (a cue that
339
+ Claude Code added a setting). The recognized set is kept current against Claude Code's published
340
+ settings schema by a weekly automated PR in the public repo, so a periodic `nomad update` is what
341
+ keeps that warning quiet on your hosts. To check your own `settings.json` against the live schema on
342
+ demand, run `nomad doctor --check-schema`.
343
+
263
344
  ## What does NOT sync (deliberate trade-offs)
264
345
 
265
346
  Read these before adopting so you opt in with eyes open.
@@ -300,9 +381,9 @@ Read these before adopting so you opt in with eyes open.
300
381
  - `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
301
382
  the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
302
383
  continues
303
- - [curl](https://curl.se/), used only by the version/update check (the `nomad doctor` latest-release
304
- line and the post-`nomad update` check); it degrades silently when curl is absent or offline, so
305
- the rest of the CLI works without it
384
+ - [curl](https://curl.se/), used by the version/update check (the `nomad doctor` latest-release line
385
+ and the post-`nomad update` check) and by `nomad doctor --check-schema`; it degrades silently when
386
+ curl is absent or offline, so the rest of the CLI works without it
306
387
 
307
388
  ## Setup
308
389
 
@@ -330,7 +411,9 @@ automatically:
330
411
  Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already
331
412
  enforces an Actions policy upstream).
332
413
 
333
- > [!WARNING] If you ever flip the mirror to public, both protections evaporate: CI starts firing on
414
+ <!-- prettier-ignore -->
415
+ > [!WARNING]
416
+ > If you ever flip the mirror to public, both protections evaporate: CI starts firing on
334
417
  > every `nomad push` against `main`, and your session transcripts (which include conversation
335
418
  > content) become world-readable. **Keep it private.**
336
419
 
@@ -516,27 +599,35 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
516
599
 
517
600
  ## Commands
518
601
 
519
- | Command | Description |
520
- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
521
- | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
522
- | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
523
- | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
524
- | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing. |
525
- | `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. |
526
- | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
527
- | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
528
- | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
529
- | `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). |
530
- | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
531
- | `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. |
532
- | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
533
- | `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). |
534
- | `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. |
602
+ | Command | Description |
603
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
604
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
605
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
606
+ | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
607
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing. |
608
+ | `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. |
609
+ | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
610
+ | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
611
+ | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
612
+ | `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). |
613
+ | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
614
+ | `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, a Hook targets check that fails (`✗`, exit 1) when `settings.json` references a hook command whose script under `~/.claude/` is missing on this host, plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
615
+ | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
616
+ | `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). |
617
+ | `nomad doctor --check-schema` | Read-only: fetches the live Claude Code settings schema and lists any `~/.claude/settings.json` key absent from it (candidates for the hand-maintained `APP_ONLY_KEYS` list). Non-fatal and offline-tolerant: skips with a `⚠︎` when curl is missing or the schema is unreachable. |
618
+ | `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. |
535
619
 
536
620
  The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
537
621
  install is behind the latest upstream release, and `✓ claude-nomad: <local> (latest)` when current.
538
622
  It silently skips on network failures.
539
623
 
624
+ The Hook targets check reads the live `~/.claude/settings.json` `hooks` block and fails (`✗`, exit
625
+
626
+ 1. when a hook command points at a script under `~/.claude/` that is missing on this host (the
627
+ freshly-configured-host symptom that motivated syncing `hooks/`). It deliberately skips any
628
+ command it cannot resolve to a `~/.claude/` path (bare binaries like `jq`, unresolved env vars),
629
+ so it never false-fails on a command that does not reference a local script.
630
+
540
631
  Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
541
632
  `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
542
633
  CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so
@@ -614,8 +705,9 @@ synced, or a `⚠︎` warning naming the counts when something was skipped:
614
705
  gitleaks missing when push checks for it, or a rebase conflict before anything is staged) suppresses
615
706
  the tree entirely, so you do not see "summary: clean" stacked under an error. A later leak-scan
616
707
  finding is different: by then the tree has already been built, so it still renders in full with a
617
- `✗` Leak scan row and the recovery block below it (see "Recovery flow: gitleaks FATAL on a session
618
- JSONL"). Projects with no entry in `path-map.json` for this host count as unmapped and fold into the
708
+ `✗` Leak scan row and the recovery block below it (see
709
+ [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
710
+ Projects with no entry in `path-map.json` for this host count as unmapped and fold into the
619
711
  collapsed `ℹ︎ ... not in path-map` count; the hint points at `nomad doctor`, which lists them by
620
712
  logical name.
621
713
 
@@ -662,6 +754,13 @@ REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.t
662
754
  local content into the staged tree on the next push, so a drop without a local scrub re-stages the
663
755
  same secret.
664
756
 
757
+ A successful drop prints this reminder inline, pointing at the live transcript that still needs
758
+ scrubbing (the exact path when `path-map.json` maps the project to the current host, a generic
759
+ `~/.claude/projects/<encoded>/<id>.jsonl` template otherwise). This is why a
760
+ `nomad doctor --check-shared` run still reports the session after a drop: that scan reads the live
761
+ `~/.claude/projects/` source, not the staged tree, so it keeps flagging the secret until the local
762
+ transcript is scrubbed.
763
+
665
764
  ### Recovery flow: gitleaks FATAL on a session JSONL
666
765
 
667
766
  `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.27.0",
3
+ "version": "0.29.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,72 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { dim, green, infoGlyph, okGlyph, warnGlyph, yellow } from './color.ts';
6
+ import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
7
+ import { CLAUDE_HOME, SETTINGS_SCHEMA_URL } from './config.ts';
8
+
9
+ /**
10
+ * Opt-in `nomad doctor --check-schema` reporter. Fetches the live Claude Code
11
+ * settings JSON schema and lists any top-level key in this host's
12
+ * `~/.claude/settings.json` that the published schema does not define, i.e.
13
+ * candidates for the hand-maintained `APP_ONLY_KEYS` list. Offline-tolerant by
14
+ * design (mirrors the release version check): curl missing, a network failure,
15
+ * or a malformed schema all degrade to a single `⚠︎` skip line. Never sets
16
+ * `process.exitCode`; this is informational discovery, not a gate.
17
+ */
18
+
19
+ /**
20
+ * Fetch the live settings schema via curl and return its top-level property
21
+ * names. curl is optional (matches the version check): a missing binary,
22
+ * non-2xx response, or malformed payload all surface as `null` so the caller
23
+ * skips cleanly. 3s timeout, fail-fast (`-f`), silent (`-s`), follow redirects.
24
+ */
25
+ function fetchSchemaKeys(): string[] | null {
26
+ try {
27
+ const raw = execFileSync('curl', ['-fsSL', '-m', '3', SETTINGS_SCHEMA_URL], {
28
+ stdio: ['ignore', 'pipe', 'pipe'],
29
+ }).toString();
30
+ const parsed = JSON.parse(raw) as { properties?: Record<string, unknown> };
31
+ if (typeof parsed.properties !== 'object' || parsed.properties === null) return null;
32
+ return Object.keys(parsed.properties);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Append the `--check-schema` result to the supplied section: an info line when
40
+ * there is no local settings.json, a `⚠︎` skip when the schema cannot be
41
+ * fetched, an OK line when every key is in the schema, or a `⚠︎` line naming the
42
+ * keys absent from it (APP_ONLY_KEYS candidates).
43
+ */
44
+ export function reportCheckSchema(section: DoctorSection): void {
45
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
46
+ if (!existsSync(settingsPath)) {
47
+ addItem(section, `${dim(infoGlyph)} no ~/.claude/settings.json to check`);
48
+ return;
49
+ }
50
+ const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
51
+ if (settings === null) return;
52
+
53
+ const liveKeys = fetchSchemaKeys();
54
+ if (liveKeys === null) {
55
+ addItem(
56
+ section,
57
+ `${yellow(warnGlyph)} schema check skipped (offline, curl missing, or schema unreachable)`,
58
+ );
59
+ return;
60
+ }
61
+
62
+ const liveSet = new Set(liveKeys);
63
+ const candidates = Object.keys(settings).filter((k) => !liveSet.has(k));
64
+ if (candidates.length === 0) {
65
+ addItem(section, `${green(okGlyph)} settings.json keys all present in the published schema`);
66
+ } else {
67
+ addItem(
68
+ section,
69
+ `${yellow(warnGlyph)} settings.json keys absent from published schema (APP_ONLY_KEYS candidates): ${candidates.join(', ')}`,
70
+ );
71
+ }
72
+ }
@@ -83,10 +83,10 @@ function reportRemediation(
83
83
  const logical = logicalBySession.get(sid);
84
84
  /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
85
85
  if (logical !== undefined) {
86
- addItem(
87
- section,
88
- ` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
86
+ const rotateLine = dim(
87
+ `- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
89
88
  );
89
+ addItem(section, ` ${rotateLine}`);
90
90
  }
91
91
  }
92
92
  addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
@@ -147,7 +147,8 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
147
147
  addItem(section, '');
148
148
  addItem(section, bold('Finding types'));
149
149
  for (const [rule, desc] of descByRule) {
150
- addItem(section, ` ${red(`- [${rule}]`)}: ${dim(desc)}`);
150
+ const ruleLabel = red(`- [${rule}]`);
151
+ addItem(section, ` ${ruleLabel}: ${dim(desc)}`);
151
152
  }
152
153
  }
153
154
 
@@ -0,0 +1,176 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { dim, failGlyph, green, infoGlyph, okGlyph, red } from './color.ts';
5
+ import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
6
+ import { CLAUDE_HOME, HOME } from './config.ts';
7
+
8
+ /**
9
+ * Always-on `nomad doctor` reporter. Reads `~/.claude/settings.json`, walks
10
+ * every `{ type: "command", command }` entry in the `hooks` block, and FAILs
11
+ * with `process.exitCode = 1` for each command token that confidently resolves
12
+ * to a path under `~/.claude` but is missing on disk. Commands with no
13
+ * resolvable `~/.claude` path (bare binaries, unresolved env vars) are silently
14
+ * skipped per D-09: the check only surfaces the issue-#170 case of synced hook
15
+ * config pointing at unsynced local scripts.
16
+ */
17
+
18
+ /**
19
+ * Expand a leading `~`, `$HOME`, or `${HOME}` to the resolved HOME directory so
20
+ * the resulting path can be passed to `existsSync` and compared against the
21
+ * absolute `~/.claude` location. A token with no home-relative prefix (a bare
22
+ * binary, an already-absolute path) is returned unchanged.
23
+ *
24
+ * @param token - A raw path token extracted from a hook command string.
25
+ * @returns The path with any leading home-relative syntax resolved to HOME.
26
+ */
27
+ function expandHome(token: string): string {
28
+ return token
29
+ .replace(/^\$\{HOME\}/, HOME)
30
+ .replace(/^\$HOME/, HOME)
31
+ .replace(/^~/, HOME);
32
+ }
33
+
34
+ /**
35
+ * Strip shell quoting and trailing control punctuation from a raw command
36
+ * token so a real path is not mistaken for a missing one. Without this, a
37
+ * quoted compound command like `bash -c 'a.sh; ~/.claude/hooks/run.sh'` yields
38
+ * the token `~/.claude/hooks/run.sh'` (trailing quote), and `existsSync` would
39
+ * FAIL on a script that is actually present (a D-09 false-FAIL). Removes
40
+ * leading quotes and any trailing run of `'"`;)|&>` characters. A genuine path
41
+ * never carries these on its boundary, so stripping them is safe.
42
+ *
43
+ * @param token - A raw whitespace-delimited token from a command segment.
44
+ * @returns The token with boundary shell punctuation removed.
45
+ */
46
+ function stripShellPunctuation(token: string): string {
47
+ return token.replace(/^['"]+/, '').replace(/['"`;)|&>]+$/, '');
48
+ }
49
+
50
+ /**
51
+ * Yield every command token that resolves to a path under `~/.claude`. Each
52
+ * `&&`-, `||`-, `;`-, or `|`-separated sub-command is scanned token by token
53
+ * (not just its leading word), so wrappers like `bash ~/.claude/hooks/run.sh`
54
+ * are caught and not just `~/.claude/hooks/run.sh` on its own. Every token is
55
+ * stripped of shell quoting and home-expanded before comparison, so the
56
+ * literal `~`, `$HOME`, and `${HOME}` forms collapse to one check. Tokens that
57
+ * do not resolve under `~/.claude` (bare binaries, flags, unresolved env vars)
58
+ * are skipped per D-09, so the check only ever FAILs on a real `~/.claude`
59
+ * target.
60
+ *
61
+ * @param command - The raw `command` string from a hook entry.
62
+ * @returns Iterable of absolute resolved paths under `~/.claude`.
63
+ */
64
+ function* claudePathsIn(command: string): Iterable<string> {
65
+ const claudePrefix = `${CLAUDE_HOME}/`;
66
+ for (const segment of command.split(/&&|\|\||;|\|/)) {
67
+ for (const raw of segment.trim().split(/\s+/).filter(Boolean)) {
68
+ const expanded = expandHome(stripShellPunctuation(raw));
69
+ if (expanded.startsWith(claudePrefix)) yield expanded;
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * A hook entry in flat format: `{ type: "command"; command: string }`.
76
+ * Used internally by `commandsFromFlat` to narrow the parsed JSON shape.
77
+ */
78
+ type FlatEntry = { type: unknown; command?: unknown };
79
+
80
+ /**
81
+ * Yield command strings from a flat-format entry list (each element is
82
+ * directly `{ type: "command", command: string }`). Skips non-object and
83
+ * non-command entries silently (T-25-07 defence).
84
+ *
85
+ * @param entries - Array of flat hook entries to walk.
86
+ */
87
+ function* commandsFromFlat(entries: unknown[]): Iterable<string> {
88
+ for (const entry of entries) {
89
+ if (typeof entry !== 'object' || entry === null) continue;
90
+ const e = entry as FlatEntry;
91
+ if (e.type === 'command' && typeof e.command === 'string') yield e.command;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Yield every `{ type: "command"; command: string }` entry from a single
97
+ * hook group, which may be a flat array entry or a grouped object with a
98
+ * nested `hooks` array. Non-object / non-command entries are silently skipped
99
+ * (D-09 / T-25-07 defence: malformed input degrades to skips, never throws).
100
+ *
101
+ * @param group - One element of a hooks event array.
102
+ * @returns Iterable of command strings from command-type entries.
103
+ */
104
+ function* commandsFromGroup(group: unknown): Iterable<string> {
105
+ if (typeof group !== 'object' || group === null) return;
106
+ const g = group as Record<string, unknown>;
107
+ // Grouped shape: { matcher?, hooks: HookEntry[] }
108
+ if (Array.isArray(g.hooks)) {
109
+ yield* commandsFromFlat(g.hooks);
110
+ return;
111
+ }
112
+ // Flat shape: the group itself is { type: "command", command: string }
113
+ if (g.type === 'command' && typeof g.command === 'string') yield g.command;
114
+ }
115
+
116
+ /**
117
+ * Walk all hook groups for a single event and emit FAIL items for every
118
+ * resolved-but-missing `~/.claude` target. Returns true when at least one
119
+ * FAIL was emitted (used by the caller to suppress the OK summary line).
120
+ *
121
+ * @param section - Doctor section to append items to.
122
+ * @param event - Hook event name (e.g. `PostToolUse`).
123
+ * @param groups - Array of hook groups for this event.
124
+ * @returns True when any missing target was found.
125
+ */
126
+ function checkEventGroups(section: DoctorSection, event: string, groups: unknown[]): boolean {
127
+ let anyFail = false;
128
+ for (const group of groups) {
129
+ for (const cmd of commandsFromGroup(group)) {
130
+ for (const resolved of claudePathsIn(cmd)) {
131
+ if (existsSync(resolved)) continue;
132
+ addItem(section, `${red(failGlyph)} hooks/${event}: command target missing: ${resolved}`);
133
+ process.exitCode = 1;
134
+ anyFail = true;
135
+ }
136
+ }
137
+ }
138
+ return anyFail;
139
+ }
140
+
141
+ /**
142
+ * Append the Hook-targets check result to the supplied section. Reads
143
+ * `~/.claude/settings.json`, walks every command entry in the `hooks` block,
144
+ * and emits a `✗` FAIL for each `~/.claude` target that is absent on disk.
145
+ * Commands with no resolvable local path are silently skipped (D-09).
146
+ * Emits a `✓` OK line when all resolvable targets exist (or none were found).
147
+ * Emits a `ℹ︎` info skip when `settings.json` is absent.
148
+ *
149
+ * @param section - The doctor section to append items to.
150
+ */
151
+ export function reportHooksTargetCheck(section: DoctorSection): void {
152
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
153
+ if (!existsSync(settingsPath)) {
154
+ addItem(section, `${dim(infoGlyph)} no ~/.claude/settings.json; skipping hook target check`);
155
+ return;
156
+ }
157
+
158
+ const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
159
+ if (settings === null) return;
160
+
161
+ const hooks = settings.hooks;
162
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
163
+ addItem(section, `${green(okGlyph)} hooks: all command targets present`);
164
+ return;
165
+ }
166
+
167
+ let anyFail = false;
168
+ for (const [event, groups] of Object.entries(hooks as Record<string, unknown>)) {
169
+ if (!Array.isArray(groups)) continue;
170
+ if (checkEventGroups(section, event, groups)) anyFail = true;
171
+ }
172
+
173
+ if (!anyFail) {
174
+ addItem(section, `${green(okGlyph)} hooks: all command targets present`);
175
+ }
176
+ }