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 +27 -0
- package/README.md +136 -37
- package/package.json +1 -1
- package/src/commands.doctor.check-schema.ts +72 -0
- package/src/commands.doctor.check-shared.scan.ts +5 -4
- package/src/commands.doctor.checks.hooks.ts +176 -0
- package/src/commands.doctor.checks.repo.ts +6 -5
- package/src/commands.doctor.checks.repository.ts +12 -6
- package/src/commands.doctor.ts +37 -4
- package/src/commands.drop-session.scrub-hint.ts +72 -0
- package/src/commands.drop-session.ts +5 -1
- package/src/commands.pull.ts +11 -5
- package/src/commands.push.allowlist.ts +2 -0
- package/src/config.sharedDirs.guard.ts +55 -0
- package/src/config.ts +65 -42
- package/src/diff-lines.ts +5 -6
- package/src/diff.ts +7 -2
- package/src/extras-sync.ts +5 -9
- package/src/init.snapshot.ts +15 -14
- package/src/init.ts +5 -2
- package/src/links.ts +16 -11
- package/src/nomad.help.ts +2 -0
- package/src/nomad.ts +9 -4
- package/src/preview.ts +6 -3
- package/src/settings-keys.ts +124 -0
- package/src/summary.ts +6 -3
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
|
|
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
|
|
161
|
-
|
|
|
162
|
-
| **Synced**
|
|
163
|
-
| **Generated**
|
|
164
|
-
| **Remapped**
|
|
165
|
-
| **Per-project extras**
|
|
166
|
-
| **
|
|
167
|
-
| **
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
330
|
+
Results on `your-other-host`: opus 4.8, the local Ollama env var, plus the shared permissions array.
|
|
258
331
|
|
|
259
|
-
|
|
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
|
|
304
|
-
|
|
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
|
-
|
|
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 --
|
|
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
|
|
618
|
-
|
|
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
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
}
|