claude-nomad 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/funkadelic/claude-nomad/compare/v0.27.0...v0.28.0) (2026-05-28)
4
+
5
+
6
+ ### Added
7
+
8
+ * **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))
9
+
10
+
11
+ ### Fixed
12
+
13
+ * **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))
14
+ * 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))
15
+
16
+ ## [0.27.0](https://github.com/funkadelic/claude-nomad/compare/v0.26.2...v0.27.0) (2026-05-28)
17
+
18
+
19
+ ### Added
20
+
21
+ * **output:** grouped tree output for push/pull and dry-run leak preview ([#163](https://github.com/funkadelic/claude-nomad/issues/163)) ([fff6f1e](https://github.com/funkadelic/claude-nomad/commit/fff6f1e28116b072ee4eceda36d87c13f4c1bc1c))
22
+
3
23
  ## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
4
24
 
5
25
 
package/README.md CHANGED
@@ -67,7 +67,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
67
67
  ## Quickstart
68
68
 
69
69
  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:
70
+ bootstrap), adding a new host is two one-time steps, then the everyday loop:
71
71
 
72
72
  ```bash
73
73
  $ npm i -g claude-nomad
@@ -157,16 +157,28 @@ so a clobbered dotfile variable does not break the CLI.
157
157
 
158
158
  ## What gets synced vs. not
159
159
 
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
160
+ | Category | Items | Behavior |
161
+ | ---------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
162
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
163
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
164
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
165
+ | **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>/`. |
166
+ | **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
167
+ | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
168
+
169
+ Pointers and specifics:
170
+
171
+ - **Synced** link names live in `SHARED_LINKS`, **whitelisted extras** names in `SUPPORTED_EXTRAS`,
172
+ and the full **never-synced** set in `NEVER_SYNC` (all in `src/config.ts`).
173
+ - **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `history.jsonl`,
174
+ `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`,
175
+ `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/`.
176
+ - **Per-project extras** run a pre-pull divergence WARN that flags local edits before they get
177
+ overwritten.
178
+
179
+ <!-- prettier-ignore -->
180
+ > [!NOTE]
181
+ > Plugins that depend on host-specific state (external binaries, API keys in env, MCP server
170
182
  > URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
171
183
  > own per-host config.
172
184
 
@@ -197,7 +209,9 @@ block opts a project into syncing whitelisted directories (or a single root file
197
209
  }
198
210
  ```
199
211
 
200
- > [!IMPORTANT] The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
212
+ <!-- prettier-ignore -->
213
+ > [!IMPORTANT]
214
+ > The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
201
215
  > [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's
202
216
  > encoded dir.
203
217
 
@@ -249,17 +263,25 @@ host-only model overrides).
249
263
 
250
264
  ```json
251
265
  {
252
- "model": "claude-opus-4-7",
266
+ "model": "claude-opus-4-8",
253
267
  "env": { "OLLAMA_HOST": "http://localhost:11434" }
254
268
  }
255
269
  ```
256
270
 
257
- Results on `your-other-host`: opus 4.7, the local Ollama env var, plus the shared permissions array.
271
+ Results on `your-other-host`: opus 4.8, the local Ollama env var, plus the shared permissions array.
258
272
 
259
- > [!CAUTION] Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
273
+ <!-- prettier-ignore -->
274
+ > [!CAUTION]
275
+ > Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
260
276
  > `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
261
277
  > repo instead.
262
278
 
279
+ `nomad doctor` warns when `settings.json` carries a top-level key it does not recognize (a cue that
280
+ Claude Code added a setting). The recognized set is kept current against Claude Code's published
281
+ settings schema by a weekly automated PR in the public repo, so a periodic `nomad update` is what
282
+ keeps that warning quiet on your hosts. To check your own `settings.json` against the live schema on
283
+ demand, run `nomad doctor --check-schema`.
284
+
263
285
  ## What does NOT sync (deliberate trade-offs)
264
286
 
265
287
  Read these before adopting so you opt in with eyes open.
@@ -300,9 +322,9 @@ Read these before adopting so you opt in with eyes open.
300
322
  - `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
301
323
  the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
302
324
  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
325
+ - [curl](https://curl.se/), used by the version/update check (the `nomad doctor` latest-release line
326
+ and the post-`nomad update` check) and by `nomad doctor --check-schema`; it degrades silently when
327
+ curl is absent or offline, so the rest of the CLI works without it
306
328
 
307
329
  ## Setup
308
330
 
@@ -330,7 +352,9 @@ automatically:
330
352
  Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already
331
353
  enforces an Actions policy upstream).
332
354
 
333
- > [!WARNING] If you ever flip the mirror to public, both protections evaporate: CI starts firing on
355
+ <!-- prettier-ignore -->
356
+ > [!WARNING]
357
+ > If you ever flip the mirror to public, both protections evaporate: CI starts firing on
334
358
  > every `nomad push` against `main`, and your session transcripts (which include conversation
335
359
  > content) become world-readable. **Keep it private.**
336
360
 
@@ -525,12 +549,13 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
525
549
  | `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
550
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
527
551
  | `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); skip stage, scan, commit, and push. |
552
+ | `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
553
  | `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
554
  | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
531
555
  | `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
556
  | `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
557
  | `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). |
558
+ | `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. |
534
559
  | `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
560
 
536
561
  The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
@@ -547,20 +572,83 @@ re-enabled, complementing the auto-disable that runs on `nomad init` (see
547
572
  [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub
548
573
  origin, `gh` unauthed, public repo, or Actions already off).
549
574
 
550
- Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The
551
- status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring
552
- `nomad doctor`'s left-gutter format:
575
+ ### Reading push and pull output
576
+
577
+ `nomad push` and `nomad pull` print a grouped tree, the same left-gutter layout you already see from
578
+ `nomad doctor`. There is a header line naming the command and host, then a few named sections
579
+ (`Sessions`, `Extras`, and so on), each with its items hanging off `├`/`└` connectors. A status
580
+ glyph leads every line: `✓` green for something that synced, `ℹ︎` dim for an informational count, `⚠︎`
581
+ yellow for a warning, and `✗` red for a failure. What this means for you: instead of one long flat
582
+ list with a line per project, related work is grouped and the noise is collapsed.
583
+
584
+ A clean `nomad push` looks like this (one `✓` row per project whose sessions were copied up, the
585
+ projects this host does not track folded into a single count, then the secret-scan result and a
586
+ one-line summary):
587
+
588
+ ```text
589
+ push on host=workstation
590
+ Sessions
591
+ ├ ✓ claude-nomad
592
+ ├ ✓ my-side-project
593
+ └ ℹ︎ 4 not in path-map (run nomad doctor to list)
594
+ Extras
595
+ └ ✓ claude-nomad/.planning
596
+ Leak scan
597
+ └ ✓ no leaks
598
+ Summary
599
+ └ ✓ summary: clean
600
+ ```
601
+
602
+ The `ℹ︎ 4 not in path-map` row is the collapse: rather than printing one line per project that this
603
+ host does not sync, push and pull now show a single count and point you at `nomad doctor`, which
604
+ lists those projects by name if you want the detail. The `Leak scan` section is the secret check
605
+ that runs before anything is published: `✓ no leaks` when the staged transcripts are clean. If a
606
+ secret IS found, that row turns into `✗ gitleaks detected secrets in N session transcript(s)` and
607
+ the full recovery block (which sessions, how to scrub them) still prints below the tree, exactly as
608
+ before (see
609
+ [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
610
+ The same `Leak scan` row shows up under `nomad push --dry-run`, which runs that secret scan as a
611
+ read-only preview (nothing is written to the sync repo) and exits non-zero if the preview finds
612
+ anything.
613
+
614
+ A `nomad pull` is the mirror image, leading with the settings file it regenerated and then the
615
+ sessions and extras it copied down for this host:
616
+
617
+ ```text
618
+ pull on host=workstation (backup=2026-05-27T14-02-09Z)
619
+ Settings
620
+ └ ✓ settings.json (base + workstation.json)
621
+ Sessions
622
+ ├ ✓ claude-nomad
623
+ └ ℹ︎ 2 not in path-map (run nomad doctor to list)
624
+ Extras
625
+ └ ✓ claude-nomad/.planning
626
+ Summary
627
+ └ ✓ summary: clean
628
+ ```
629
+
630
+ The `Summary` row is the final verdict for the run. It reads `✓ summary: clean` when everything
631
+ synced, or a `⚠︎` warning naming the counts when something was skipped:
553
632
 
554
633
  ```text
555
- ✓ summary: clean
556
634
  ⚠︎ summary: 3 unmapped on pull (run nomad doctor to list)
557
635
  ⚠︎ summary: 2 unmapped on push, 1 collisions (run nomad doctor to list)
558
636
  ```
559
637
 
560
- `✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. The summary is suppressed when a fatal (`✗`)
561
- fires mid-run so you do not see "summary: clean" stacked under an error. Drive-by projects that have
562
- no entry in `path-map.json` for this host count as unmapped; the hint points at `nomad doctor`,
563
- which lists them by logical name.
638
+ `✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. An early, pre-tree fatal abort (for example
639
+ gitleaks missing when push checks for it, or a rebase conflict before anything is staged) suppresses
640
+ the tree entirely, so you do not see "summary: clean" stacked under an error. A later leak-scan
641
+ finding is different: by then the tree has already been built, so it still renders in full with a
642
+ `✗` Leak scan row and the recovery block below it (see
643
+ [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
644
+ Projects with no entry in `path-map.json` for this host count as unmapped and fold into the
645
+ collapsed `ℹ︎ ... not in path-map` count; the hint points at `nomad doctor`, which lists them by
646
+ logical name.
647
+
648
+ `nomad pull --dry-run` keeps its own readable preview format (a unified diff of the `settings.json`
649
+ changes plus the transcripts a real pull would overwrite) rather than the grouped tree, so that
650
+ preview stays easy to scan; only a real `nomad pull` prints the tree above. `nomad diff` is
651
+ unchanged.
564
652
 
565
653
  ## Recovery flows
566
654
 
@@ -600,12 +688,25 @@ REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.t
600
688
  local content into the staged tree on the next push, so a drop without a local scrub re-stages the
601
689
  same secret.
602
690
 
691
+ A successful drop prints this reminder inline, pointing at the live transcript that still needs
692
+ scrubbing (the exact path when `path-map.json` maps the project to the current host, a generic
693
+ `~/.claude/projects/<encoded>/<id>.jsonl` template otherwise). This is why a
694
+ `nomad doctor --check-shared` run still reports the session after a drop: that scan reads the live
695
+ `~/.claude/projects/` source, not the staged tree, so it keeps flagging the secret until the local
696
+ transcript is scrubbed.
697
+
603
698
  ### Recovery flow: gitleaks FATAL on a session JSONL
604
699
 
605
700
  `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
606
- push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`,
607
- which stages and scans the exact transcripts a push would publish. When findings live in a session
608
- transcript, the push aborts and names every affected session id and the recovery command:
701
+ push (and without mutating anything), two read-only options are available:
702
+ `nomad doctor --check-shared` scans the session transcripts a push would publish;
703
+ `nomad push --dry-run` runs the same scan AND also covers opted-in extras (`.planning`,
704
+ `CLAUDE.md`), which `--check-shared` does not. Both stage content into a throwaway temp copy and
705
+ never write to the sync repo. A leak-scan finding is the contrast to an early, pre-tree fatal:
706
+ because the scan runs after the tree is built, the push aborts but the grouped tree still renders in
707
+ full, with a `✗ gitleaks detected secrets in N session transcript(s)` row in its `Leak scan`
708
+ section, and then the full recovery block prints below it, naming every affected session id and the
709
+ recovery command:
609
710
 
610
711
  ```text
611
712
  ✗ gitleaks detected secrets in 1 session transcript(s).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
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
+ }
@@ -1,43 +1,8 @@
1
1
  import { failGlyph, red } from './color.ts';
2
+ import { addItem, type DoctorSection } from './output-tree.ts';
2
3
  import { readJson } from './utils.json.ts';
3
4
 
4
- /**
5
- * Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
6
- * `failGlyph` constant may carry. Header rendering composes its own
7
- * spacing (`${red(failGlyph)} ${header}`), so the section-header path
8
- * must use the unpadded codepoint to avoid a double space on WSL.
9
- */
10
- const FAIL_GLYPH_BARE = '✗';
11
-
12
- /**
13
- * Tree-style output builder for `cmdDoctor`. Doctor builds an ordered list of
14
- * `DoctorSection`s, each reporter pushes plain-text items into the relevant
15
- * section, then the orchestrator calls `renderDoctor` to emit a Claude Code
16
- * `/doctor`-style tree (`Header` / ` ├ item` / ` └ last`) on stdout.
17
- *
18
- * Color and status glyphs (okGlyph/warnGlyph/failGlyph/infoGlyph) already
19
- * live inside the item text; this module never re-colors or re-tokenizes.
20
- * Sections with zero items are skipped at render time (no empty headers).
21
- *
22
- * Output goes directly through `console.log` rather than `utils.log` so the
23
- * dim `ℹ︎` info glyph used by `pull` / `push` / `init` does NOT appear in
24
- * doctor output (doctor has its own glyphs per row). Test assertions continue
25
- * to spy on `console.log`.
26
- */
27
- export type DoctorSection = {
28
- header: string;
29
- items: string[];
30
- };
31
-
32
- /** Construct an empty section with the given header. */
33
- export function section(header: string): DoctorSection {
34
- return { header, items: [] };
35
- }
36
-
37
- /** Append one rendered line to a section. */
38
- export function addItem(s: DoctorSection, text: string): void {
39
- s.items.push(text);
40
- }
5
+ export { section, addItem, renderTree, renderDoctor, type DoctorSection } from './output-tree.ts';
41
6
 
42
7
  /**
43
8
  * Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
@@ -55,47 +20,3 @@ export function readJsonSafe<T>(path: string, label: string, section: DoctorSect
55
20
  return null;
56
21
  }
57
22
  }
58
-
59
- /**
60
- * True when any item in the section contains the FAIL glyph.
61
- * Color-wrapped failGlyph (`✗`) still contains the
62
- * glyph as a substring, so this works for both color-on and color-off output.
63
- */
64
- function sectionFailed(s: DoctorSection): boolean {
65
- return s.items.some((line) => line.includes(failGlyph));
66
- }
67
-
68
- /**
69
- * Emit the full doctor report. Skips empty sections, prefixes failed-section
70
- * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
71
- * `grep -F '✗'` catches both row and header failures), and writes one blank
72
- * line between rendered sections (no leading or trailing blank).
73
- *
74
- * An empty-string item renders as a true blank line (no tree connector), which
75
- * lets a reporter set off a footer block (e.g. the `--check-shared` description
76
- * legend) with vertical whitespace. The `└` connector attaches to the last
77
- * non-empty item rather than the last array slot so a trailing blank does not
78
- * strand the elbow on an empty line.
79
- */
80
- /**
81
- * Render one section: a (possibly fail-glyph-prefixed) header followed by its
82
- * items as a tree. Empty-string items print as true blank lines; the `└` elbow
83
- * attaches to the last non-empty item so a trailing blank cannot strand it.
84
- */
85
- function renderSection(s: DoctorSection): void {
86
- const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
87
- console.log(header);
88
- const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
89
- for (let j = 0; j < s.items.length; j++) {
90
- if (s.items[j] === '') console.log('');
91
- else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
92
- }
93
- }
94
-
95
- export function renderDoctor(sections: DoctorSection[]): void {
96
- const visible = sections.filter((s) => s.items.length > 0);
97
- for (let i = 0; i < visible.length; i++) {
98
- if (i > 0) console.log('');
99
- renderSection(visible[i]);
100
- }
101
- }
@@ -15,6 +15,7 @@ import {
15
15
  reportRebaseClean,
16
16
  reportRemote,
17
17
  } from './commands.doctor.checks.repository.ts';
18
+ import { reportCheckSchema } from './commands.doctor.check-schema.ts';
18
19
  import { reportCheckShared } from './commands.doctor.check-shared.ts';
19
20
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
20
21
  import { renderDoctor, section } from './commands.doctor.format.ts';
@@ -35,8 +36,12 @@ import { reportVersionCheck } from './commands.doctor.version.ts';
35
36
  * section that runs the gitleaks preflight over the session transcripts a
36
37
  * `nomad push` would stage. It is OFF by default so plain `nomad doctor`
37
38
  * stays the fast read-only smoke test (no scan, no temp tree).
39
+ *
40
+ * `opts.checkSchema` (the `--check-schema` sub-flag) appends a "Schema scan"
41
+ * section that fetches the live settings schema and flags local settings.json
42
+ * keys absent from it. Also OFF by default (it needs the network).
38
43
  */
39
- export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
44
+ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean } = {}): void {
40
45
  const host = section('Host');
41
46
  reportHostAndPaths(host);
42
47
  reportRepoState(host);
@@ -74,5 +79,18 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
74
79
  // drift check above spawns `gitleaks version` separately, by design.)
75
80
  if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
76
81
 
77
- renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
82
+ const schemaScan = section('Schema scan');
83
+ if (opts.checkSchema === true) reportCheckSchema(schemaScan);
84
+
85
+ renderDoctor([
86
+ version,
87
+ host,
88
+ links,
89
+ settings,
90
+ pathMap,
91
+ neverSync,
92
+ repository,
93
+ sharedScan,
94
+ schemaScan,
95
+ ]);
78
96
  }
@@ -0,0 +1,72 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
+ import { log } from './utils.ts';
6
+ import { encodePath, readJson } from './utils.json.ts';
7
+
8
+ /**
9
+ * Repo-relative session-match shape `shared/projects/<logical>/...`; the single
10
+ * capture group is the `<logical>` segment fed to the path-map reverse lookup.
11
+ */
12
+ const SHARED_PROJECT_LOGICAL = /^shared\/projects\/([^/]+)\//;
13
+
14
+ /**
15
+ * After a successful drop, remind the operator that the unstage is per-push
16
+ * only: the leaked secret still lives in the local transcript, so the next
17
+ * `nomad push` re-copies it (via `remapPush`) and `nomad doctor --check-shared`
18
+ * keeps reporting it (it scans the live `~/.claude/projects/` source, not the
19
+ * repo index). Points at the exact live transcript when it resolves for this
20
+ * host, or a generic `~/.claude/projects/<encoded>/<id>.jsonl` template
21
+ * otherwise. Advisory output only; never mutates state.
22
+ *
23
+ * @param id Already-validated session id.
24
+ * @param matches Repo-relative paths collected by `collectMatches`.
25
+ */
26
+ export function reportScrubHint(id: string, matches: string[]): void {
27
+ const live = resolveLiveTranscript(id, matches);
28
+ const target = live ?? `~/.claude/projects/<encoded>/${id}.jsonl`;
29
+ log(
30
+ 'note: this only un-stages the session from the next push. The leaked secret\n' +
31
+ ' is still in your local transcript, so nomad push re-stages it and nomad\n' +
32
+ ' doctor --check-shared keeps reporting it. To remediate, rotate the\n' +
33
+ ` credential, then scrub ${target}`,
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Reverse-map a dropped session to its live transcript
39
+ * `~/.claude/projects/<encoded>/<id>.jsonl` on THIS host via `path-map.json`
40
+ * (`<logical>` -> `hosts[HOST]` -> `encodePath`). Best-effort: returns the path
41
+ * only when it resolves AND exists on disk; null when `path-map.json` is
42
+ * absent or malformed, no match maps to this host, or the live file is already
43
+ * gone. A `'TBD'` host placeholder also yields null (its bogus path never
44
+ * exists). The whole body is wrapped so a malformed map (parse error, `null`
45
+ * projects) degrades to the generic hint instead of crashing the drop.
46
+ *
47
+ * @param id Already-validated session id.
48
+ * @param matches Repo-relative paths collected by `collectMatches`.
49
+ * @returns Absolute live transcript path, or null when unresolvable.
50
+ */
51
+ function resolveLiveTranscript(id: string, matches: string[]): string | null {
52
+ try {
53
+ const mapPath = join(REPO_HOME, 'path-map.json');
54
+ if (!existsSync(mapPath)) return null;
55
+ const projects = readJson<PathMap>(mapPath).projects;
56
+ for (const rel of matches) {
57
+ const logical = SHARED_PROJECT_LOGICAL.exec(rel)?.[1];
58
+ /* c8 ignore next -- defensive: every collectMatches path is rooted at shared/projects/<logical>/ */
59
+ if (logical === undefined) continue;
60
+ const abs = projects[logical]?.[HOST];
61
+ // A 'TBD' host placeholder needs no special case: encodePath('TBD') yields
62
+ // a directory that cannot exist among the absolute-path-encoded dirs, so
63
+ // the existsSync guard below rejects it and falls through to the generic.
64
+ if (abs === undefined) continue;
65
+ const live = join(CLAUDE_HOME, 'projects', encodePath(abs), `${id}.jsonl`);
66
+ if (existsSync(live)) return live;
67
+ }
68
+ return null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
@@ -4,6 +4,7 @@ import { join, relative } from 'node:path';
4
4
 
5
5
  import { REPO_HOME } from './config.ts';
6
6
  import { expandStagedDir, isInIndex, isTrackedInHead } from './commands.drop-session.git.ts';
7
+ import { reportScrubHint } from './commands.drop-session.scrub-hint.ts';
7
8
  import { die, fail, log, NomadFatal } from './utils.ts';
8
9
  import { acquireLock, releaseLock } from './utils.lockfile.ts';
9
10
 
@@ -13,7 +14,9 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
13
14
  * lock, collects every staged path matching the flat `<id>.jsonl` and the
14
15
  * sibling subagent directory `<id>/` (via `collectMatches`), then unstages
15
16
  * each (via `unstageOne`). This closes the leak where a "dropped" session
16
- * still shipped its subagent transcripts.
17
+ * still shipped its subagent transcripts. A successful drop ends with
18
+ * `reportScrubHint`, which reminds the operator that the unstage is per-push
19
+ * only and points at the live transcript that still needs scrubbing.
17
20
  *
18
21
  * Idempotent: entries not in the index are skipped silently. Exits 0 on
19
22
  * any drop (including an idempotent re-run); exits 1 with `✗ no staged
@@ -53,6 +56,7 @@ export function cmdDropSession(id: string): void {
53
56
  throw new NomadFatal(`no staged session matches ${id}`);
54
57
  }
55
58
  for (const rel of matches) unstageOne(rel);
59
+ reportScrubHint(id, matches);
56
60
  } catch (err) {
57
61
  // Defensive escape hatch: only fires if a non-NomadFatal error escapes
58
62
  // the try block. All execFileSync mutation failures are wrapped in