claude-nomad 0.27.0 → 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,18 @@
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
+
3
16
  ## [0.27.0](https://github.com/funkadelic/claude-nomad/compare/v0.26.2...v0.27.0) (2026-05-28)
4
17
 
5
18
 
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
 
@@ -531,6 +555,7 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
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
@@ -614,8 +639,9 @@ synced, or a `⚠︎` warning naming the counts when something was skipped:
614
639
  gitleaks missing when push checks for it, or a rebase conflict before anything is staged) suppresses
615
640
  the tree entirely, so you do not see "summary: clean" stacked under an error. A later leak-scan
616
641
  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
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
619
645
  collapsed `ℹ︎ ... not in path-map` count; the hint points at `nomad doctor`, which lists them by
620
646
  logical name.
621
647
 
@@ -662,6 +688,13 @@ REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.t
662
688
  local content into the staged tree on the next push, so a drop without a local scrub re-stages the
663
689
  same secret.
664
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
+
665
698
  ### Recovery flow: gitleaks FATAL on a session JSONL
666
699
 
667
700
  `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.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
+ }
@@ -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
package/src/config.ts CHANGED
@@ -36,6 +36,14 @@ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad')
36
36
  */
37
37
  export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
38
38
 
39
+ /**
40
+ * The official Claude Code settings JSON schema. Source of truth for
41
+ * `SCHEMA_KEYS` (kept current by `scripts/sync-settings-keys.ts`) and the
42
+ * on-demand `nomad doctor --check-schema` reporter, which fetches it live to
43
+ * flag local `settings.json` keys absent from the published schema.
44
+ */
45
+ export const SETTINGS_SCHEMA_URL = 'https://json.schemastore.org/claude-code-settings.json';
46
+
39
47
  /**
40
48
  * Pinned gitleaks version. Single source of truth for the gitleaks pin used by
41
49
  * `nomad doctor`'s version-drift check (`reportGitleaksVersionCheck`), which
@@ -103,48 +111,11 @@ export const NEVER_SYNC = new Set([
103
111
  'ide',
104
112
  ]);
105
113
 
106
- /**
107
- * Schema-drift baseline for `~/.claude/settings.json`. Top-level keys not in
108
- * this set trigger a `nomad doctor` WARN line so we notice when Anthropic
109
- * adds new settings before they silently round-trip through pull. Update on
110
- * Anthropic settings.json schema changes.
111
- */
112
- export const KNOWN_SETTINGS_KEYS = new Set<string>([
113
- '$schema',
114
- 'agent',
115
- 'agents',
116
- 'agentPushNotifEnabled',
117
- 'allowedHttpHookUrls',
118
- 'apiKeyHelper',
119
- 'apiKeyHelperTimeoutMs',
120
- 'awsAuthRefresh',
121
- 'awsCredentialExport',
122
- 'awsLoginRefresh',
123
- 'awsRegion',
124
- 'awsRetryMode',
125
- 'cleanupPeriodDays',
126
- 'disableNonEssentialModelCalls',
127
- 'enabledExperimentalFeatures',
128
- 'enabledPlugins',
129
- 'env',
130
- 'forceLoginMethod',
131
- 'forceLoginOrgUUID',
132
- 'hooks',
133
- 'includeCoAuthoredBy',
134
- 'installMethod',
135
- 'model',
136
- 'outputStyle',
137
- 'permissions',
138
- 'pluginGroups',
139
- 'pluginRepositoryEnabled',
140
- 'pluginsLocalConfig',
141
- 'proxy',
142
- 'skipAutoPermissionPrompt',
143
- 'statsig',
144
- 'statusLine',
145
- 'subagents',
146
- 'theme',
147
- ]);
114
+ // Schema-drift baseline for `~/.claude/settings.json`; top-level keys not in
115
+ // this set trigger a `nomad doctor` WARN. Defined in ./settings-keys.ts so the
116
+ // schema-derived half can be re-synced mechanically; re-exported here so
117
+ // existing `from './config.ts'` imports keep resolving.
118
+ export { KNOWN_SETTINGS_KEYS } from './settings-keys.ts';
148
119
 
149
120
  /**
150
121
  * Static half of the push allow-list. Entries with trailing `/` are prefix
package/src/nomad.help.ts CHANGED
@@ -49,6 +49,8 @@ export const DEFAULT_HELP = [
49
49
  cont('gitleaks, gitlinks).'),
50
50
  row(' --check-shared', 'Preflight gitleaks scan of the session transcripts a'),
51
51
  cont('`nomad push` would stage (a temp copy, never the live dir).'),
52
+ row(' --check-schema', 'Flag settings.json keys absent from the live published'),
53
+ cont('Claude Code settings schema (needs network; degrades offline).'),
52
54
  row(' --resume-cmd <id>', 'Print `cd <abspath> && claude --resume <id>` for a session id'),
53
55
  cont('from ~/.claude/projects/.'),
54
56
  '',
package/src/nomad.ts CHANGED
@@ -131,13 +131,16 @@ try {
131
131
  // Sub-flags: `doctor --resume-cmd <session-id>` dispatches to the
132
132
  // read-only sidecar that prints `cd <abspath> && claude --resume <id>`;
133
133
  // `doctor --check-shared` (no positional) appends the gitleaks preflight
134
- // scan of the transcripts a push would stage. Bare `doctor` runs the
135
- // plain read-only health check. Any other shape (unknown flag, extra
136
- // positional, `--check-shared` with trailing args) is a usage error.
134
+ // scan of the transcripts a push would stage; `doctor --check-schema`
135
+ // (no positional) appends the live settings-schema check. Bare `doctor`
136
+ // runs the plain read-only health check. Any other shape (unknown flag,
137
+ // extra positional, a scan flag with trailing args) is a usage error.
137
138
  if (process.argv[3] === undefined) {
138
139
  cmdDoctor();
139
140
  } else if (process.argv[3] === '--check-shared' && process.argv.length === 4) {
140
141
  cmdDoctor({ checkShared: true });
142
+ } else if (process.argv[3] === '--check-schema' && process.argv.length === 4) {
143
+ cmdDoctor({ checkSchema: true });
141
144
  } else if (process.argv[3] === '--resume-cmd') {
142
145
  const id = process.argv[4];
143
146
  if (process.argv.length !== 5 || typeof id !== 'string' || id.length === 0) {
@@ -146,7 +149,9 @@ try {
146
149
  }
147
150
  resumeCmd(id);
148
151
  } else {
149
- console.error('usage: nomad doctor [--check-shared | --resume-cmd <session-id>]');
152
+ console.error(
153
+ 'usage: nomad doctor [--check-shared | --check-schema | --resume-cmd <session-id>]',
154
+ );
150
155
  process.exit(1);
151
156
  }
152
157
  break;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Top-level keys recognized in `~/.claude/settings.json`, used by the
3
+ * `nomad doctor` schema-drift check. Split by provenance so the schema half
4
+ * can be re-synced mechanically.
5
+ *
6
+ * `SCHEMA_KEYS`: the documented properties from the official Claude Code
7
+ * settings JSON schema (https://json.schemastore.org/claude-code-settings.json).
8
+ * Regenerated by scripts/sync-settings-keys.mjs (run weekly by the
9
+ * settings-schema-drift workflow); do not hand-edit this array.
10
+ *
11
+ * `APP_ONLY_KEYS`: keys Claude Code writes to settings.json that the published
12
+ * schema has not caught up to yet (the running app runs ahead of the schema).
13
+ * These cannot be derived from the schema and are hand-maintained.
14
+ */
15
+ export const SCHEMA_KEYS = [
16
+ '$schema',
17
+ 'agent',
18
+ 'allowedChannelPlugins',
19
+ 'allowedHttpHookUrls',
20
+ 'allowedMcpServers',
21
+ 'allowManagedHooksOnly',
22
+ 'allowManagedMcpServersOnly',
23
+ 'allowManagedPermissionRulesOnly',
24
+ 'alwaysThinkingEnabled',
25
+ 'apiKeyHelper',
26
+ 'attribution',
27
+ 'autoMemoryDirectory',
28
+ 'autoMemoryEnabled',
29
+ 'autoMode',
30
+ 'autoUpdatesChannel',
31
+ 'availableModels',
32
+ 'awsAuthRefresh',
33
+ 'awsCredentialExport',
34
+ 'blockedMarketplaces',
35
+ 'channelsEnabled',
36
+ 'claudeMdExcludes',
37
+ 'cleanupPeriodDays',
38
+ 'companyAnnouncements',
39
+ 'defaultShell',
40
+ 'deniedMcpServers',
41
+ 'disableAllHooks',
42
+ 'disableDeepLinkRegistration',
43
+ 'disabledMcpjsonServers',
44
+ 'disableSkillShellExecution',
45
+ 'effortLevel',
46
+ 'enableAllProjectMcpServers',
47
+ 'enabledMcpjsonServers',
48
+ 'enabledPlugins',
49
+ 'env',
50
+ 'extraKnownMarketplaces',
51
+ 'fastMode',
52
+ 'fastModePerSessionOptIn',
53
+ 'feedbackSurveyRate',
54
+ 'fileSuggestion',
55
+ 'forceLoginMethod',
56
+ 'forceLoginOrgUUID',
57
+ 'forceRemoteSettingsRefresh',
58
+ 'hooks',
59
+ 'httpHookAllowedEnvVars',
60
+ 'includeCoAuthoredBy',
61
+ 'includeGitInstructions',
62
+ 'language',
63
+ 'minimumVersion',
64
+ 'model',
65
+ 'modelOverrides',
66
+ 'otelHeadersHelper',
67
+ 'outputStyle',
68
+ 'parentSettingsBehavior',
69
+ 'permissions',
70
+ 'plansDirectory',
71
+ 'pluginConfigs',
72
+ 'pluginTrustMessage',
73
+ 'prefersReducedMotion',
74
+ 'prUrlTemplate',
75
+ 'respectGitignore',
76
+ 'sandbox',
77
+ 'showClearContextOnPlanAccept',
78
+ 'showThinkingSummaries',
79
+ 'showTurnDuration',
80
+ 'skillOverrides',
81
+ 'skipDangerousModePermissionPrompt',
82
+ 'skippedMarketplaces',
83
+ 'skippedPlugins',
84
+ 'skipWebFetchPreflight',
85
+ 'spinnerTipsEnabled',
86
+ 'spinnerTipsOverride',
87
+ 'spinnerVerbs',
88
+ 'statusLine',
89
+ 'strictKnownMarketplaces',
90
+ 'strictPluginOnlyCustomization',
91
+ 'subagentStatusLine',
92
+ 'teammateMode',
93
+ 'terminalProgressBarEnabled',
94
+ 'tui',
95
+ 'useAutoModeDuringPlan',
96
+ 'viewMode',
97
+ 'voiceEnabled',
98
+ 'worktree',
99
+ 'wslInheritsWindowsSettings',
100
+ ];
101
+
102
+ export const APP_ONLY_KEYS = [
103
+ 'agentPushNotifEnabled',
104
+ 'agents',
105
+ 'apiKeyHelperTimeoutMs',
106
+ 'awsLoginRefresh',
107
+ 'awsRegion',
108
+ 'awsRetryMode',
109
+ 'disableNonEssentialModelCalls',
110
+ 'enabledExperimentalFeatures',
111
+ 'inputNeededNotifEnabled',
112
+ 'installMethod',
113
+ 'pluginGroups',
114
+ 'pluginRepositoryEnabled',
115
+ 'pluginsLocalConfig',
116
+ 'proxy',
117
+ 'skipAutoPermissionPrompt',
118
+ 'statsig',
119
+ 'subagents',
120
+ 'theme',
121
+ ];
122
+
123
+ /** Union of schema-documented and app-only settings keys; unknown top-level keys trigger a doctor WARN. */
124
+ export const KNOWN_SETTINGS_KEYS = new Set<string>([...SCHEMA_KEYS, ...APP_ONLY_KEYS]);