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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared leak-scan verdict vocabulary for `cmdPush`. Both the dry-run preview
3
+ * (`previewPushLeaks` in `./push-preview.ts`) and the real-push scan
4
+ * (`scanPushVerdict` here) produce the same structured `LeakVerdict` so the
5
+ * one-line Leak scan row rendered inside the grouped tree cannot drift between
6
+ * the two paths. The multi-line `recovery` block (the `buildSessionAwareFatal`
7
+ * body) is printed by `cmdPush` BELOW the rendered tree on a leak.
8
+ *
9
+ * On a real push the scan still aborts the run: `cmdPush` renders the tree with
10
+ * the ✗ verdict row, then throws a `NomadFatal` carrying `recovery` so the
11
+ * existing catch prints the recovery block and sets a non-zero exit. The
12
+ * dry-run path never throws; it only sets `process.exitCode = 1`.
13
+ */
14
+
15
+ import { failGlyph, green, okGlyph, red } from './color.ts';
16
+ import { REPO_HOME } from './config.ts';
17
+ import { gitleaksInstallHint } from './push-checks.ts';
18
+ import {
19
+ type Finding,
20
+ buildSessionAwareFatal,
21
+ partitionFindings,
22
+ scanStagedTree,
23
+ } from './push-gitleaks.ts';
24
+
25
+ /**
26
+ * Structured leak-scan verdict.
27
+ *
28
+ * - `leak`: `true` only when findings were present (a scan crash is surfaced
29
+ * via a ✗ `verdictRow` but is NOT a leak, so the dry-run path does not throw).
30
+ * - `verdictRow`: the rendered one-line Leak scan row (glyph embedded).
31
+ * - `recovery`: the `buildSessionAwareFatal` body on a leak, else `null`.
32
+ */
33
+ export type LeakVerdict = {
34
+ leak: boolean;
35
+ verdictRow: string;
36
+ recovery: string | null;
37
+ };
38
+
39
+ /** Rendered clean Leak scan row (no findings). */
40
+ export const noLeaksRow = (): string => `${green(okGlyph)} no leaks`;
41
+
42
+ /** Rendered ✗ Leak scan row (caller supplies the message text). */
43
+ export const failRow = (text: string): string => `${red(failGlyph)} ${text}`;
44
+
45
+ /**
46
+ * Build the one-line ✗ Leak scan verdict row for a non-empty findings set,
47
+ * naming the affected session count. Falls back to the raw finding count when
48
+ * no finding matches the session-path pattern. Pure.
49
+ *
50
+ * @param findings - The non-empty findings array.
51
+ * @returns The rendered ✗ verdict row.
52
+ */
53
+ export function leakVerdictRow(findings: Finding[]): string {
54
+ const { bySession } = partitionFindings(findings);
55
+ const n = bySession.size > 0 ? bySession.size : findings.length;
56
+ return failRow(`gitleaks detected secrets in ${n} session transcript(s)`);
57
+ }
58
+
59
+ /**
60
+ * Build the leak verdict for a non-empty findings set: the ✗ verdict row plus
61
+ * the `buildSessionAwareFatal` recovery body. Pure (no `process.exitCode`
62
+ * side effect; callers own that). Shared by the dry-run and real-push paths so
63
+ * the verdict row and recovery body cannot diverge.
64
+ *
65
+ * @param findings - The non-empty findings array.
66
+ * @returns A `leak=true` verdict carrying the ✗ row and recovery body.
67
+ */
68
+ function leakFound(findings: Finding[]): LeakVerdict {
69
+ const { bySession, other } = partitionFindings(findings);
70
+ return {
71
+ leak: true,
72
+ verdictRow: leakVerdictRow(findings),
73
+ recovery: buildSessionAwareFatal(bySession, other),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Map a `scanStagedTree` result to a structured `LeakVerdict`, applying the
79
+ * shared side effect (`process.exitCode = 1` on findings or a scan crash). A
80
+ * `null` report (scan crash) yields a ✗ scan-failed row with `recovery=null`
81
+ * and is NOT classified as a `leak` (so the dry-run path neither throws nor
82
+ * offers a phantom drop-session hint). An empty array yields the clean
83
+ * `✓ no leaks` row. Non-empty findings yield the ✗ verdict row plus the
84
+ * `buildSessionAwareFatal` recovery body.
85
+ *
86
+ * @param findings - Output of `scanStagedTree`, or `null` on scan crash.
87
+ * @returns The structured verdict for the Leak scan section.
88
+ */
89
+ export function verdictFromFindings(findings: Finding[] | null): LeakVerdict {
90
+ if (findings === null) {
91
+ process.exitCode = 1;
92
+ return {
93
+ leak: false,
94
+ verdictRow: failRow('scan failed, no parseable report'),
95
+ recovery: null,
96
+ };
97
+ }
98
+ if (findings.length === 0) return { leak: false, verdictRow: noLeaksRow(), recovery: null };
99
+ process.exitCode = 1;
100
+ return leakFound(findings);
101
+ }
102
+
103
+ /**
104
+ * Verdict for a scan that threw before producing a report (e.g. gitleaks/git
105
+ * absent on the dry-run path). Sets `process.exitCode = 1` and yields a ✗ row
106
+ * with `recovery=null`. Does not mark `leak` so the caller never throws.
107
+ *
108
+ * @param text - The ✗ row message text.
109
+ * @returns The structured scan-error verdict.
110
+ */
111
+ export function verdictScanError(text: string): LeakVerdict {
112
+ process.exitCode = 1;
113
+ return { leak: false, verdictRow: failRow(text), recovery: null };
114
+ }
115
+
116
+ /**
117
+ * Run the real-push staged gitleaks scan (the same `scanStagedTree(REPO_HOME,
118
+ * true)` the push gate uses) and RETURN a structured `LeakVerdict` instead of
119
+ * throwing. This lets `cmdPush` render the grouped tree with the ✗ Leak scan
120
+ * row BEFORE re-raising the FATAL so the recovery block prints below the tree.
121
+ *
122
+ * On findings: `leak=true`, `verdictRow` is the ✗ row, `recovery` is the
123
+ * `buildSessionAwareFatal` body. On a clean scan: `✓ no leaks`. On a null
124
+ * report (scanner crash, malformed JSON): a ✗ scan-failed verdict with
125
+ * `recovery` set to the same scan-failed FATAL string `runGitleaksScan` would
126
+ * have thrown, so `cmdPush` still aborts. ENOENT (gitleaks/git absent) maps to
127
+ * the platform-aware install-hint FATAL as `recovery` with a ✗ row.
128
+ *
129
+ * @returns The structured verdict for the real-push Leak scan section.
130
+ */
131
+ export function scanPushVerdict(): LeakVerdict {
132
+ let findings: Finding[] | null;
133
+ try {
134
+ findings = scanStagedTree(REPO_HOME, true);
135
+ } catch (err) {
136
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
137
+ return {
138
+ leak: true,
139
+ verdictRow: failRow('gitleaks not found'),
140
+ recovery: gitleaksInstallHint(),
141
+ };
142
+ }
143
+ throw err;
144
+ }
145
+ if (findings === null) {
146
+ return {
147
+ leak: true,
148
+ verdictRow: failRow('scan failed, no parseable report'),
149
+ recovery: 'gitleaks scan failed: no parseable JSON report. Review the gitleaks output above.',
150
+ };
151
+ }
152
+ if (findings.length === 0) return { leak: false, verdictRow: noLeaksRow(), recovery: null };
153
+ return leakFound(findings);
154
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Push dry-run gitleaks leak preview.
3
+ *
4
+ * Stages a read-only copy of the session transcripts and extras that a real
5
+ * `nomad push` would send for this host, then runs `scanStagedTree` against
6
+ * that temp tree. The verdict is RETURNED as a structured
7
+ * `{ leak, verdictRow, recovery }` (rather than logged) so `cmdPush` can place
8
+ * `verdictRow` in the grouped tree's Leak scan section and print `recovery`
9
+ * (the `buildSessionAwareFatal` body) below the tree. On findings it still sets
10
+ * `process.exitCode = 1`.
11
+ *
12
+ * This module is the push-dry-run-only path. The `nomad doctor --check-shared`
13
+ * preflight (session-only scan, no extras) is unchanged and lives in
14
+ * `./commands.doctor.check-shared.ts`. Extras-in-doctor is a deferred
15
+ * follow-up (out of scope here).
16
+ */
17
+
18
+ import { randomBytes } from 'node:crypto';
19
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { dim, infoGlyph } from './color.ts';
24
+ import { CLAUDE_HOME, HOST, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
25
+ import { assertSafeLogical } from './extras-sync.guards.ts';
26
+ import { copyExtras } from './extras-sync.ts';
27
+ import { copyDirJsonlOnly } from './remap.ts';
28
+ import { type LeakVerdict, verdictFromFindings, verdictScanError } from './push-leak-verdict.ts';
29
+ import { scanStagedTree } from './push-gitleaks.ts';
30
+ import { nowTimestamp } from './utils.fs.ts';
31
+ import { encodePath } from './utils.json.ts';
32
+
33
+ /** Rendered neutral Leak scan row when there was nothing to scan. */
34
+ const NOTHING_TO_SCAN_ROW = `${dim(infoGlyph)} nothing to scan, no leaks`;
35
+
36
+ /**
37
+ * Stage local session transcripts for HOST into `<tmpRoot>/shared/projects/<logical>/`
38
+ * using the same depth-0 `*.jsonl` filter as a real push. Builds the
39
+ * encoded-dir-to-logical reverse map from `map.projects` (skipping TBD or
40
+ * missing entries), then copies each matching `~/.claude/projects/<dir>/`.
41
+ *
42
+ * @param tmpRoot - Root of the throwaway staging tree.
43
+ * @param map - Parsed `path-map.json`.
44
+ * @returns Number of session directories staged.
45
+ */
46
+ function stageSessions(tmpRoot: string, map: PathMap): number {
47
+ if (typeof map.projects !== 'object' || map.projects === null) return 0;
48
+
49
+ const reverse = new Map<string, string>();
50
+ for (const [logical, hosts] of Object.entries(map.projects)) {
51
+ assertSafeLogical(logical);
52
+ const p = hosts[HOST];
53
+ if (!p || p === 'TBD') continue;
54
+ reverse.set(encodePath(p), logical);
55
+ }
56
+
57
+ const localProjects = join(CLAUDE_HOME, 'projects');
58
+ if (!existsSync(localProjects)) return 0;
59
+
60
+ let staged = 0;
61
+ for (const dir of readdirSync(localProjects)) {
62
+ const logical = reverse.get(dir);
63
+ if (!logical) continue;
64
+ copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
65
+ staged++;
66
+ }
67
+ return staged;
68
+ }
69
+
70
+ /**
71
+ * Stage whitelisted extras for HOST into
72
+ * `<tmpRoot>/shared/extras/<logical>/<dirname>/`. Mirrors the skip semantics of
73
+ * `remapExtrasPush`: skips logicals with no host path or `'TBD'`, skips
74
+ * dirnames not in `SUPPORTED_EXTRAS`, and skips when the source path does not
75
+ * exist locally.
76
+ *
77
+ * Guards a non-object or missing `map.projects` defensively (mirroring
78
+ * `stageSessions`): a malformed map with an `extras` block but no usable
79
+ * `projects` stages nothing rather than throwing on the `map.projects[logical]`
80
+ * read.
81
+ *
82
+ * @param tmpRoot - Root of the throwaway staging tree.
83
+ * @param map - Parsed `path-map.json`.
84
+ * @returns Number of extras entries staged.
85
+ */
86
+ function stageExtras(tmpRoot: string, map: PathMap): number {
87
+ if (typeof map.projects !== 'object' || map.projects === null) return 0;
88
+ const extrasMap = map.extras ?? {};
89
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
90
+ let staged = 0;
91
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
92
+ assertSafeLogical(logical);
93
+ const localRoot = map.projects[logical]?.[HOST];
94
+ if (!localRoot || localRoot === 'TBD') continue;
95
+ for (const dirname of dirnames) {
96
+ if (!whitelist.includes(dirname)) continue;
97
+ const src = join(localRoot, dirname);
98
+ if (!existsSync(src)) continue;
99
+ const dst = join(tmpRoot, 'shared', 'extras', logical, dirname);
100
+ copyExtras(src, dst);
101
+ staged++;
102
+ }
103
+ }
104
+ return staged;
105
+ }
106
+
107
+ /**
108
+ * Run a read-only gitleaks leak preview of what `nomad push` would stage for
109
+ * this host: both mapped session transcripts
110
+ * (`shared/projects/<logical>/*.jsonl`) and opted-in extras
111
+ * (`shared/extras/<logical>/<dirname>`).
112
+ *
113
+ * Stages the content into a throwaway tree under
114
+ * `~/.cache/claude-nomad/push-preview-tree-<stamp>` and runs `scanStagedTree`
115
+ * with `forwardStreams=false` (read-only: no gitleaks stderr/stdout leak to the
116
+ * terminal). The temp tree is always removed in a `finally`, regardless of
117
+ * whether the scan found leaks, crashed, or returned clean. `REPO_HOME/shared`
118
+ * is never written.
119
+ *
120
+ * Returns a structured `LeakVerdict` rather than logging the verdict line so
121
+ * `cmdPush` can render `verdictRow` in the Leak scan section and print
122
+ * `recovery` below the tree. Side effects preserved: `process.exitCode = 1` on
123
+ * findings AND on a scan crash. A scan that throws maps to a ✗ scan-error row
124
+ * with `exitCode = 1`: ENOENT (gitleaks/git absent) keeps the "not on PATH"
125
+ * wording, any other error (e.g. EACCES) surfaces its real message so the
126
+ * cause is not mislabeled. Nothing-to-scan maps to a neutral ℹ︎ row.
127
+ *
128
+ * Fails closed before any copy: an unsafe `logical` key (path separator or
129
+ * `..`) raised by `assertSafeLogical` in the staging step propagates out as a
130
+ * `NomadFatal` to `cmdPush`, and the `finally` still removes the temp tree.
131
+ *
132
+ * @param map - Parsed `path-map.json` (already in scope from `cmdPush`).
133
+ * @returns The structured verdict for the Leak scan section.
134
+ */
135
+ export function previewPushLeaks(map: PathMap): LeakVerdict {
136
+ const cacheDir = join(homedir(), '.cache', 'claude-nomad');
137
+ mkdirSync(cacheDir, { recursive: true });
138
+ const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
139
+ const tmpRoot = join(cacheDir, `push-preview-tree-${stamp}`);
140
+
141
+ try {
142
+ const sessionCount = stageSessions(tmpRoot, map);
143
+ const extrasCount = stageExtras(tmpRoot, map);
144
+ if (sessionCount + extrasCount === 0) {
145
+ return { leak: false, verdictRow: NOTHING_TO_SCAN_ROW, recovery: null };
146
+ }
147
+ let findings: ReturnType<typeof scanStagedTree>;
148
+ try {
149
+ findings = scanStagedTree(tmpRoot);
150
+ } catch (err) {
151
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
152
+ return verdictScanError('scan error (git or gitleaks not on PATH)');
153
+ }
154
+ return verdictScanError(`scan error: ${(err as Error).message}`);
155
+ }
156
+ return verdictFromFindings(findings);
157
+ } finally {
158
+ rmSync(tmpRoot, { recursive: true, force: true });
159
+ }
160
+ }
package/src/remap.ts CHANGED
@@ -49,22 +49,31 @@ export function copyDirJsonlOnly(src: string, dst: string): void {
49
49
  /**
50
50
  * Pull: copy from repo's logical project names into local path-encoded dirs.
51
51
  *
52
- * Returns `{ unmapped: N }` where `N` counts path-map entries skipped for
53
- * this host (either `'TBD'` placeholder or no entry for `HOST`). The count
54
- * is consumed by `computePreview` and the future summary line.
52
+ * Returns `{ unmapped, pulled, wouldPull }`. `unmapped` counts path-map entries
53
+ * skipped for this host (`'TBD'` placeholder or no entry for `HOST`); `pulled`
54
+ * holds logical names copied (wet), `wouldPull` those that would copy under
55
+ * `dryRun`. The arrays let cmdPull render a grouped tree; the wet path no longer
56
+ * logs per-project `pulled X -> Y` / `skip ...` inline. The dry-run path KEEPS
57
+ * its `would overwrite:` line because `computePreview` renders those as the
58
+ * Projects section of `nomad diff` and the dry-run pull preview. The degenerate
59
+ * early-return `log(...)` (not a per-project skip) is preserved.
55
60
  *
56
- * `opts.dryRun` (default `false`): when `true`, log `would overwrite:` lines
57
- * instead of calling `backupBeforeWrite` + `copyDir`. The unmapped count is
58
- * computed identically in both modes.
61
+ * @param ts - backup timestamp namespace.
62
+ * @param opts.dryRun - when `true`, collect `wouldPull` and log would-overwrite.
59
63
  */
60
- export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapped: number } {
64
+ export function remapPull(
65
+ ts: string,
66
+ opts: { dryRun?: boolean } = {},
67
+ ): { unmapped: number; pulled: string[]; wouldPull: string[] } {
61
68
  const dryRun = opts.dryRun === true;
62
69
  let unmapped = 0;
70
+ const pulled: string[] = [];
71
+ const wouldPull: string[] = [];
63
72
  const mapPath = join(REPO_HOME, 'path-map.json');
64
73
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
65
74
  if (!existsSync(mapPath) || !existsSync(repoProjects)) {
66
75
  log('no path-map or repo projects dir; skipping session remap');
67
- return { unmapped: 0 };
76
+ return { unmapped: 0, pulled, wouldPull };
68
77
  }
69
78
 
70
79
  const map = readJson<PathMap>(mapPath);
@@ -73,29 +82,28 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
73
82
 
74
83
  for (const [logical, hosts] of Object.entries(map.projects)) {
75
84
  const localPath = hosts[HOST];
76
- if (localPath === 'TBD') {
85
+ if (!localPath || localPath === 'TBD') {
77
86
  unmapped++;
78
- log(`skip ${logical}: placeholder path for ${HOST}`);
79
- continue;
80
- }
81
- if (!localPath) {
82
- unmapped++;
83
- log(`skip ${logical}: no path for ${HOST}`);
84
87
  continue;
85
88
  }
86
89
  const src = join(repoProjects, logical);
87
90
  if (!existsSync(src)) continue;
88
91
  const dst = join(localProjects, encodePath(localPath));
89
92
  if (dryRun) {
93
+ // KEEP this would-overwrite log: computePreview (backing both `nomad
94
+ // diff` and the dry-run pull preview) renders these lines as its
95
+ // Projects section, so removing them regresses that output. The grouped
96
+ // tree is built only on the WET path, which consumes `pulled` instead.
97
+ wouldPull.push(logical);
90
98
  log(`would overwrite: ${dst} (from ${src})`);
91
99
  continue;
92
100
  }
93
101
  // Snapshot prior encoded-path-dir state BEFORE copyDir overwrites it.
94
102
  backupBeforeWrite(dst, ts);
95
103
  copyDir(src, dst);
96
- log(`pulled ${logical} -> ${encodePath(localPath)}`);
104
+ pulled.push(logical);
97
105
  }
98
- return { unmapped };
106
+ return { unmapped, pulled, wouldPull };
99
107
  }
100
108
 
101
109
  /**
@@ -156,20 +164,32 @@ function buildReverseMap(map: PathMap): Map<string, string> {
156
164
  * refuse the push before any `shared/projects/` content is written. Detection
157
165
  * runs during the reverse-map build, so it fires under `dryRun` too.
158
166
  *
159
- * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
160
- * instead of calling `backupRepoWrite` + `copyDir`. Collision detection
161
- * runs identically in both modes.
167
+ * `opts.dryRun` (default `false`): when `true`, collect `wouldPush` without
168
+ * calling `backupRepoWrite` + `copyDir`. Collision detection runs identically
169
+ * in both modes.
170
+ *
171
+ * Returns `pushed` (logical names actually copied, wet mode) and `wouldPush`
172
+ * (logical names under `dryRun`) alongside the counts so cmdPush can render a
173
+ * grouped tree. This function no longer logs per-project `pushed X -> Y` /
174
+ * `skip ... not in path-map` / `would push:` lines inline; the
175
+ * `copyDirJsonlOnly` extension-skip log is a separate file-filter concern and
176
+ * is preserved, as are the degenerate early-return `log(...)` lines.
177
+ *
178
+ * @param ts - backup timestamp namespace.
179
+ * @param opts.dryRun - when `true`, collect `wouldPush` without mutating.
162
180
  */
163
181
  export function remapPush(
164
182
  ts: string,
165
183
  opts: { dryRun?: boolean } = {},
166
- ): { unmapped: number; collisions: number } {
184
+ ): { unmapped: number; collisions: number; pushed: string[]; wouldPush: string[] } {
167
185
  const dryRun = opts.dryRun === true;
168
186
  let unmapped = 0;
187
+ const pushed: string[] = [];
188
+ const wouldPush: string[] = [];
169
189
  const mapPath = join(REPO_HOME, 'path-map.json');
170
190
  if (!existsSync(mapPath)) {
171
191
  log('no path-map.json; skipping session export');
172
- return { unmapped: 0, collisions: 0 };
192
+ return { unmapped: 0, collisions: 0, pushed, wouldPush };
173
193
  }
174
194
 
175
195
  const map = readJson<PathMap>(mapPath);
@@ -177,7 +197,7 @@ export function remapPush(
177
197
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
178
198
 
179
199
  const reverse = buildReverseMap(map);
180
- if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
200
+ if (!existsSync(localProjects)) return { unmapped, collisions: 0, pushed, wouldPush };
181
201
  // Create the repo destination only after collision detection passes and we
182
202
  // know there is something to push, so a failing or no-op push is fully
183
203
  // side-effect-free (no empty shared/projects/ left behind).
@@ -187,12 +207,11 @@ export function remapPush(
187
207
  const logical = reverse.get(dir);
188
208
  if (!logical) {
189
209
  unmapped++;
190
- log(`skip ${dir}: not in path-map for ${HOST}`);
191
210
  continue;
192
211
  }
193
212
  const repoDst = join(repoProjects, logical);
194
213
  if (dryRun) {
195
- log(`would push: ${dir} -> ${logical}`);
214
+ wouldPush.push(logical);
196
215
  continue;
197
216
  }
198
217
  // Snapshot repo-side destination before copyDir clobbers it. Git
@@ -201,7 +220,7 @@ export function remapPush(
201
220
  // path. Symmetric with remapPull's backupBeforeWrite on the local dst.
202
221
  backupRepoWrite(repoDst, ts, REPO_HOME);
203
222
  copyDirJsonlOnly(join(localProjects, dir), repoDst);
204
- log(`pushed ${dir} -> ${logical}`);
223
+ pushed.push(logical);
205
224
  }
206
- return { unmapped, collisions: 0 };
225
+ return { unmapped, collisions: 0, pushed, wouldPush };
207
226
  }
@@ -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]);