devflow-kit 2.0.1 → 2.1.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 +22 -0
- package/dist/cli/agents-view/render.js +9 -42
- package/dist/cli/agents-view/terminal.js +29 -153
- package/dist/cli/commands/agents.js +11 -1
- package/dist/cli/commands/flags.js +501 -82
- package/dist/cli/commands/init-seed.js +60 -42
- package/dist/cli/commands/init.js +36 -79
- package/dist/cli/commands/proxy.js +42 -13
- package/dist/cli/commands/uninstall.js +2 -3
- package/dist/cli/flags-view/index.js +9 -0
- package/dist/cli/flags-view/render.js +271 -0
- package/dist/cli/flags-view/state.js +478 -0
- package/dist/cli/flags-view/terminal.js +63 -0
- package/dist/cli/tui/cells.js +47 -0
- package/dist/cli/tui/terminal.js +329 -0
- package/dist/cli.js +3 -2
- package/dist/core/ansi.js +84 -0
- package/dist/core/flags.js +938 -123
- package/dist/core/manifest.js +97 -13
- package/dist/core/teammate-mode-cleanup.js +2 -2
- package/dist/hud/colors.js +6 -70
- package/package.json +2 -2
package/dist/core/flags.js
CHANGED
|
@@ -1,139 +1,244 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code flag registry
|
|
2
|
+
* Claude Code flag registry — typed, extensible mechanism for managing
|
|
3
|
+
* Claude Code feature flags and settings.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* Pure functions: applyFlags, stripFlags, getDefaultFlagsRecord — no I/O.
|
|
6
|
+
*
|
|
7
|
+
* D14: Typed registry — flags carry kind (boolean|enum|number|string), target
|
|
8
|
+
* (env|setting), and per-kind defaultValue. Neutral values delete their target
|
|
9
|
+
* key; active values write the appropriate payload. Number 0 is ACTIVE. Sink
|
|
10
|
+
* validation via coerceFlagValue (applies PF-023: validate at the convergence
|
|
11
|
+
* point every caller reaches). applyFlags(settingsJson, FlagsRecord) is the
|
|
12
|
+
* sole API; init.ts works directly with FlagsRecord (no legacy string[] bridge).
|
|
13
|
+
*/
|
|
14
|
+
// ─── Registry ─────────────────────────────────────────────────────────────────
|
|
15
|
+
// Phase 0 probe findings: see docs/reference/claude-code-flags-probe.md
|
|
16
|
+
/**
|
|
17
|
+
* Ordered registry of all Claude Code flags managed by devflow.
|
|
18
|
+
*
|
|
19
|
+
* IDs are the stable manifest keys (`features.flags` in the devflow manifest).
|
|
20
|
+
* Array order drives the `--list` table and TUI row order — intentional changes
|
|
21
|
+
* to order are display changes and should be made deliberately.
|
|
22
|
+
*
|
|
23
|
+
* Not every Claude Code env var belongs here. One notable exclusion:
|
|
24
|
+
* `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` — deliberately
|
|
25
|
+
* proxy-owned. It is paired with `ANTHROPIC_BASE_URL` in proxy.ts and its
|
|
26
|
+
* lifecycle is coupled to relay enable/disable; strip is handled by
|
|
27
|
+
* `stripProxyEnv` (src/cli/commands/proxy.ts). Adding it here would create a
|
|
28
|
+
* second owner and double-strip it on uninstall. (mirrors agent-teams note)
|
|
6
29
|
*/
|
|
7
30
|
export const FLAG_REGISTRY = [
|
|
8
|
-
//
|
|
31
|
+
// ══ Recommended (default ON) ══════════════════════════════════════════════
|
|
9
32
|
{
|
|
10
33
|
id: 'tui',
|
|
11
34
|
label: 'Fullscreen terminal UI',
|
|
12
35
|
description: 'Flicker-free fullscreen rendering',
|
|
13
|
-
hint: '
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
hint: 'Enables fullscreen mode — flicker-free and cursor-stable',
|
|
37
|
+
blurb: 'fullscreen terminal UI',
|
|
38
|
+
kind: 'boolean',
|
|
39
|
+
target: { type: 'setting', key: 'tui' },
|
|
40
|
+
onPayload: 'fullscreen',
|
|
41
|
+
recommended: true,
|
|
42
|
+
defaultValue: true,
|
|
16
43
|
},
|
|
17
44
|
{
|
|
18
45
|
id: 'tool-search',
|
|
19
46
|
label: 'Deferred tool loading',
|
|
20
47
|
description: 'Load tool schemas on demand instead of all at startup',
|
|
21
|
-
hint: '
|
|
22
|
-
|
|
23
|
-
|
|
48
|
+
hint: 'Defers tool schema loading to first use — smaller initial context',
|
|
49
|
+
blurb: 'deferred tool schema loading',
|
|
50
|
+
kind: 'boolean',
|
|
51
|
+
target: { type: 'env', key: 'ENABLE_TOOL_SEARCH' },
|
|
52
|
+
onPayload: 'true',
|
|
53
|
+
recommended: true,
|
|
54
|
+
defaultValue: true,
|
|
24
55
|
},
|
|
25
56
|
{
|
|
26
57
|
id: 'lsp',
|
|
27
58
|
label: 'LSP support',
|
|
28
59
|
description: 'Enable Language Server Protocol integration',
|
|
29
|
-
hint: '
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
hint: 'Activates LSP tool so Claude can query your editor code intelligence',
|
|
61
|
+
blurb: 'editor code intelligence',
|
|
62
|
+
kind: 'boolean',
|
|
63
|
+
target: { type: 'env', key: 'ENABLE_LSP_TOOL' },
|
|
64
|
+
onPayload: 'true',
|
|
65
|
+
recommended: true,
|
|
66
|
+
defaultValue: true,
|
|
32
67
|
},
|
|
33
68
|
{
|
|
34
69
|
id: 'prompt-caching-1h',
|
|
35
70
|
label: 'Extended prompt cache',
|
|
36
71
|
description: 'Extend prompt cache TTL from 5min to 1h',
|
|
37
|
-
hint: '
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
hint: 'Extends cache TTL from 5 min to 1 hr — cheaper long sessions',
|
|
73
|
+
blurb: '1-hour prompt cache TTL',
|
|
74
|
+
kind: 'boolean',
|
|
75
|
+
target: { type: 'env', key: 'ENABLE_PROMPT_CACHING_1H' },
|
|
76
|
+
onPayload: 'true',
|
|
77
|
+
recommended: true,
|
|
78
|
+
defaultValue: true,
|
|
40
79
|
},
|
|
41
80
|
{
|
|
42
81
|
id: 'show-turn-duration',
|
|
43
82
|
label: 'Show turn duration',
|
|
44
83
|
description: 'Display timing info after each turn',
|
|
45
|
-
hint: '
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
hint: 'Shows wall-clock time for each turn — useful for spotting slow paths',
|
|
85
|
+
blurb: 'wall-clock time per turn',
|
|
86
|
+
kind: 'boolean',
|
|
87
|
+
target: { type: 'setting', key: 'showTurnDuration' },
|
|
88
|
+
onPayload: true,
|
|
89
|
+
recommended: true,
|
|
90
|
+
defaultValue: true,
|
|
48
91
|
},
|
|
49
92
|
{
|
|
50
93
|
id: 'clear-context-on-plan',
|
|
51
94
|
label: 'Clear context on plan accept',
|
|
52
95
|
description: 'Clear context window when accepting a plan',
|
|
53
|
-
hint: '
|
|
54
|
-
|
|
55
|
-
|
|
96
|
+
hint: 'Clears context on plan accept so implementation starts with full budget',
|
|
97
|
+
blurb: 'clear context on plan accept',
|
|
98
|
+
kind: 'boolean',
|
|
99
|
+
target: { type: 'setting', key: 'showClearContextOnPlanAccept' },
|
|
100
|
+
onPayload: true,
|
|
101
|
+
recommended: true,
|
|
102
|
+
defaultValue: true,
|
|
56
103
|
},
|
|
57
104
|
{
|
|
58
105
|
id: 'disable-bundled-skills',
|
|
59
106
|
label: 'Disable bundled skills',
|
|
60
107
|
description: "Remove Claude Code's built-in skills and workflows (devflow provides its own)",
|
|
61
|
-
hint: '
|
|
62
|
-
|
|
63
|
-
|
|
108
|
+
hint: "Removes Claude Code's built-in skills — devflow installs its own set",
|
|
109
|
+
blurb: 'remove built-in CC skills',
|
|
110
|
+
kind: 'boolean',
|
|
111
|
+
target: { type: 'setting', key: 'disableBundledSkills' },
|
|
112
|
+
onPayload: true,
|
|
113
|
+
recommended: true,
|
|
114
|
+
defaultValue: true,
|
|
64
115
|
},
|
|
65
116
|
{
|
|
66
117
|
id: 'pin-sonnet-4-6',
|
|
67
118
|
label: 'Pin Sonnet to 4.6',
|
|
68
119
|
description: 'Pin the default Sonnet model to claude-sonnet-4-6',
|
|
69
|
-
hint: '
|
|
70
|
-
|
|
71
|
-
|
|
120
|
+
hint: 'Pins Sonnet to 4.6 — stable, deterministic alias across model updates',
|
|
121
|
+
blurb: 'pin Sonnet to 4.6 model',
|
|
122
|
+
kind: 'boolean',
|
|
123
|
+
target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' },
|
|
124
|
+
onPayload: 'claude-sonnet-4-6',
|
|
125
|
+
recommended: true,
|
|
126
|
+
defaultValue: true,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
// Devflow fan-outs routinely exceed the upstream default of 20.
|
|
130
|
+
// Set to 40 by default so parallel Code/Review/Research waves don't
|
|
131
|
+
// silently queue. upstreamDefault recorded for display. (applies PF-023 bounds)
|
|
132
|
+
id: 'max-concurrent-subagents',
|
|
133
|
+
label: 'Max concurrent subagents',
|
|
134
|
+
description: 'Maximum number of subagents Claude Code will spawn concurrently',
|
|
135
|
+
hint: 'Sets concurrent subagent cap; upstream default is 20 — devflow uses 40',
|
|
136
|
+
blurb: 'parallel subagent cap',
|
|
137
|
+
kind: 'number',
|
|
138
|
+
target: { type: 'env', key: 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS' },
|
|
139
|
+
recommended: true,
|
|
140
|
+
defaultValue: 40,
|
|
141
|
+
min: 1,
|
|
142
|
+
max: 100, // devflow sanity bound (applies PF-023)
|
|
143
|
+
integer: true,
|
|
144
|
+
upstreamDefault: 20,
|
|
72
145
|
},
|
|
73
|
-
//
|
|
146
|
+
// ══ Optional (default OFF) — skip these if you're unsure ══════════════════
|
|
74
147
|
{
|
|
75
148
|
id: 'brief',
|
|
76
149
|
label: 'Brief output mode',
|
|
77
150
|
description: 'Reduce verbosity of Claude Code output',
|
|
78
|
-
hint: '
|
|
79
|
-
|
|
80
|
-
|
|
151
|
+
hint: 'Reduces output verbosity — shorter responses, less explanation',
|
|
152
|
+
blurb: 'shorter, less verbose output',
|
|
153
|
+
kind: 'boolean',
|
|
154
|
+
target: { type: 'env', key: 'CLAUDE_CODE_BRIEF' },
|
|
155
|
+
onPayload: 'true',
|
|
156
|
+
recommended: false,
|
|
157
|
+
defaultValue: false,
|
|
81
158
|
},
|
|
82
159
|
{
|
|
83
160
|
id: 'thinking-summaries',
|
|
84
161
|
label: 'Thinking summaries',
|
|
85
162
|
description: 'Show thinking summaries during reasoning',
|
|
86
|
-
hint: '
|
|
87
|
-
|
|
88
|
-
|
|
163
|
+
hint: 'Surfaces condensed reasoning previews during extended thinking',
|
|
164
|
+
blurb: 'condensed reasoning previews',
|
|
165
|
+
kind: 'boolean',
|
|
166
|
+
target: { type: 'setting', key: 'showThinkingSummaries' },
|
|
167
|
+
onPayload: true,
|
|
168
|
+
recommended: false,
|
|
169
|
+
defaultValue: false,
|
|
89
170
|
},
|
|
90
171
|
{
|
|
91
172
|
id: 'subprocess-env-scrub',
|
|
92
173
|
label: 'Subprocess env scrub',
|
|
93
174
|
description: 'Strip cloud credentials from subprocesses',
|
|
94
|
-
hint: '
|
|
95
|
-
|
|
96
|
-
|
|
175
|
+
hint: 'Strips cloud credentials (AWS, GCP, Azure) from subprocess env',
|
|
176
|
+
blurb: 'strip cloud credentials',
|
|
177
|
+
kind: 'boolean',
|
|
178
|
+
target: { type: 'env', key: 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB' },
|
|
179
|
+
onPayload: '1',
|
|
180
|
+
recommended: false,
|
|
181
|
+
defaultValue: false,
|
|
97
182
|
},
|
|
98
183
|
{
|
|
99
184
|
id: 'disable-nonessential-traffic',
|
|
100
185
|
label: 'Disable non-essential traffic',
|
|
101
186
|
description: 'Suppress usage metrics telemetry',
|
|
102
|
-
hint: '
|
|
103
|
-
|
|
104
|
-
|
|
187
|
+
hint: 'Suppresses usage telemetry sent back to Anthropic',
|
|
188
|
+
blurb: 'suppress usage telemetry',
|
|
189
|
+
kind: 'boolean',
|
|
190
|
+
target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' },
|
|
191
|
+
onPayload: 'true',
|
|
192
|
+
recommended: false,
|
|
193
|
+
defaultValue: false,
|
|
105
194
|
},
|
|
106
195
|
{
|
|
107
196
|
id: 'forked-subagents',
|
|
108
197
|
label: 'Forked subagents',
|
|
109
198
|
description: 'Better subagent perf on external builds',
|
|
110
|
-
hint: '
|
|
111
|
-
|
|
112
|
-
|
|
199
|
+
hint: 'Enables forked subagent model — faster parallel agents (experimental)',
|
|
200
|
+
blurb: 'faster parallel agents',
|
|
201
|
+
kind: 'boolean',
|
|
202
|
+
target: { type: 'env', key: 'CLAUDE_CODE_FORK_SUBAGENT' },
|
|
203
|
+
onPayload: '1',
|
|
204
|
+
recommended: false,
|
|
205
|
+
defaultValue: false,
|
|
113
206
|
},
|
|
114
207
|
{
|
|
115
208
|
id: 'disable-adaptive-thinking',
|
|
116
209
|
label: 'Disable adaptive thinking',
|
|
117
210
|
description: 'Disable adaptive reasoning on Opus/Sonnet 4.6',
|
|
118
|
-
hint: '
|
|
119
|
-
|
|
120
|
-
|
|
211
|
+
hint: 'Disables adaptive thinking budget — fixes compute per turn',
|
|
212
|
+
blurb: 'fixed compute per turn',
|
|
213
|
+
kind: 'boolean',
|
|
214
|
+
target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING' },
|
|
215
|
+
onPayload: 'true',
|
|
216
|
+
recommended: false,
|
|
217
|
+
defaultValue: false,
|
|
121
218
|
},
|
|
122
219
|
{
|
|
123
220
|
id: 'always-thinking',
|
|
124
221
|
label: 'Always enable thinking',
|
|
125
222
|
description: 'Enable extended thinking by default',
|
|
126
|
-
hint: '
|
|
127
|
-
|
|
128
|
-
|
|
223
|
+
hint: 'Forces extended thinking on every turn, including non-complex ones',
|
|
224
|
+
blurb: 'extended thinking always',
|
|
225
|
+
kind: 'boolean',
|
|
226
|
+
target: { type: 'setting', key: 'alwaysThinkingEnabled' },
|
|
227
|
+
onPayload: true,
|
|
228
|
+
recommended: false,
|
|
229
|
+
defaultValue: false,
|
|
129
230
|
},
|
|
130
231
|
{
|
|
131
232
|
id: 'disable-git-instructions',
|
|
132
233
|
label: 'Disable git instructions',
|
|
133
234
|
description: 'Remove git workflow instructions from system prompt',
|
|
134
|
-
hint: '
|
|
135
|
-
|
|
136
|
-
|
|
235
|
+
hint: 'Removes git workflow from system prompt — saves tokens in each turn',
|
|
236
|
+
blurb: 'remove git system prompt',
|
|
237
|
+
kind: 'boolean',
|
|
238
|
+
target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS' },
|
|
239
|
+
onPayload: 'true',
|
|
240
|
+
recommended: false,
|
|
241
|
+
defaultValue: false,
|
|
137
242
|
},
|
|
138
243
|
// NOTE: DISABLE_COMPACT and DISABLE_AUTOUPDATER intentionally omit the CLAUDE_CODE_ prefix —
|
|
139
244
|
// these names are defined by upstream Claude Code and must match exactly.
|
|
@@ -141,79 +246,707 @@ export const FLAG_REGISTRY = [
|
|
|
141
246
|
id: 'disable-compact',
|
|
142
247
|
label: 'Disable auto-compaction',
|
|
143
248
|
description: 'Disable automatic context compaction',
|
|
144
|
-
hint: '
|
|
145
|
-
|
|
146
|
-
|
|
249
|
+
hint: 'Disables auto-compaction — retains full context at the cost of more tokens',
|
|
250
|
+
blurb: 'retain full context always',
|
|
251
|
+
kind: 'boolean',
|
|
252
|
+
target: { type: 'env', key: 'DISABLE_COMPACT' },
|
|
253
|
+
onPayload: 'true',
|
|
254
|
+
recommended: false,
|
|
255
|
+
defaultValue: false,
|
|
147
256
|
},
|
|
148
257
|
{
|
|
258
|
+
// v2.1.223 semantics: disables the 1M-token context window experiment and
|
|
259
|
+
// falls back to the standard context budget for the model.
|
|
149
260
|
id: 'disable-1m-context',
|
|
150
261
|
label: 'Disable 1M context window',
|
|
151
|
-
description: '
|
|
152
|
-
hint: '
|
|
153
|
-
|
|
154
|
-
|
|
262
|
+
description: 'Disable the 1M-token context window experiment (v2.1.223+)',
|
|
263
|
+
hint: 'Opts out of the 1M context experiment — uses standard context budget',
|
|
264
|
+
blurb: 'use standard context budget',
|
|
265
|
+
kind: 'boolean',
|
|
266
|
+
target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT' },
|
|
267
|
+
onPayload: 'true',
|
|
268
|
+
recommended: false,
|
|
269
|
+
defaultValue: false,
|
|
155
270
|
},
|
|
156
271
|
{
|
|
157
272
|
id: 'disable-autoupdater',
|
|
158
273
|
label: 'Disable auto-updater',
|
|
159
274
|
description: 'Prevent automatic update checks',
|
|
160
|
-
hint: '
|
|
161
|
-
|
|
162
|
-
|
|
275
|
+
hint: 'Prevents automatic update checks — manage updates manually',
|
|
276
|
+
blurb: 'manual update management',
|
|
277
|
+
kind: 'boolean',
|
|
278
|
+
target: { type: 'env', key: 'DISABLE_AUTOUPDATER' },
|
|
279
|
+
onPayload: 'true',
|
|
280
|
+
recommended: false,
|
|
281
|
+
defaultValue: false,
|
|
163
282
|
},
|
|
164
283
|
{
|
|
165
284
|
id: 'agent-teams',
|
|
166
285
|
label: 'Agent Teams (experimental)',
|
|
167
286
|
description: 'Enable Claude Code experimental Agent Teams',
|
|
168
|
-
hint: '
|
|
169
|
-
|
|
170
|
-
|
|
287
|
+
hint: 'Enables peer-agent teammate mode — experimental, may change any release',
|
|
288
|
+
blurb: 'peer-agent teammate mode',
|
|
289
|
+
kind: 'boolean',
|
|
290
|
+
target: { type: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' },
|
|
291
|
+
onPayload: '1',
|
|
292
|
+
recommended: false,
|
|
293
|
+
defaultValue: false,
|
|
171
294
|
// Note: the legacy `teammateMode:"auto"` settings key is stripped by
|
|
172
295
|
// src/core/teammate-mode-cleanup.ts during uninstall (stripDevflowTeammateModeFromJson).
|
|
173
296
|
// The env var above is the only surface managed by FLAG_REGISTRY for this flag.
|
|
174
297
|
},
|
|
298
|
+
{
|
|
299
|
+
// Upstream: restores Todo/TaskCreate tools removed by default in Opus 4.8+,
|
|
300
|
+
// Sonnet 5+, and Fable 5+. Set to '1' to re-enable.
|
|
301
|
+
id: 'enable-todo-tools',
|
|
302
|
+
label: 'Enable todo/task tools',
|
|
303
|
+
description: 'Restore Todo and TaskCreate tools removed by default in newer models',
|
|
304
|
+
hint: 'Re-enables Todo/TaskCreate tools on Opus 4.8+ / Sonnet 5+ / Fable 5+',
|
|
305
|
+
blurb: 'restore Todo/Task tools',
|
|
306
|
+
kind: 'boolean',
|
|
307
|
+
target: { type: 'env', key: 'CLAUDE_CODE_ENABLE_TODO_TOOLS' },
|
|
308
|
+
onPayload: '1',
|
|
309
|
+
recommended: false,
|
|
310
|
+
defaultValue: false,
|
|
311
|
+
},
|
|
312
|
+
// ── Valued flags (number/enum/string) ────────────────────────────────────
|
|
313
|
+
{
|
|
314
|
+
// Domain: unset by default; set only when users want a non-default spawn depth.
|
|
315
|
+
// upstreamDefault: 3 (recorded for display). PF-023 bounds: max 10.
|
|
316
|
+
id: 'subagent-spawn-depth',
|
|
317
|
+
label: 'Max subagent spawn depth',
|
|
318
|
+
description: 'Maximum depth of nested subagent spawning',
|
|
319
|
+
hint: 'Caps nested spawn depth; upstream default is 3 — raise only when needed',
|
|
320
|
+
blurb: 'nested spawn depth limit',
|
|
321
|
+
kind: 'number',
|
|
322
|
+
target: { type: 'env', key: 'CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH' },
|
|
323
|
+
recommended: false,
|
|
324
|
+
defaultValue: undefined,
|
|
325
|
+
min: 1,
|
|
326
|
+
max: 10, // devflow sanity bound (applies PF-023)
|
|
327
|
+
integer: true,
|
|
328
|
+
upstreamDefault: 3,
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
// Phase 0: domain verified small|medium|large|unrestricted from binary
|
|
332
|
+
// (4-value cluster at adjacent string offsets, adjacent to Workflows feature text).
|
|
333
|
+
id: 'workflow-size-guideline',
|
|
334
|
+
label: 'Workflow size guideline',
|
|
335
|
+
description: 'Guide Claude on the expected size of workflow plans',
|
|
336
|
+
hint: 'Hints preferred plan scale: small/medium/large/unrestricted',
|
|
337
|
+
blurb: 'plan scale hint',
|
|
338
|
+
kind: 'enum',
|
|
339
|
+
target: { type: 'setting', key: 'workflowSizeGuideline' },
|
|
340
|
+
values: ['small', 'medium', 'large', 'unrestricted'],
|
|
341
|
+
recommended: false,
|
|
342
|
+
defaultValue: undefined,
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: 'default-model',
|
|
346
|
+
label: 'Default model',
|
|
347
|
+
description: 'Override the default model for Claude Code',
|
|
348
|
+
hint: 'Sets ANTHROPIC_DEFAULT_MODEL — overrides session-level model selection',
|
|
349
|
+
blurb: 'override default model',
|
|
350
|
+
kind: 'string',
|
|
351
|
+
target: { type: 'env', key: 'ANTHROPIC_DEFAULT_MODEL' },
|
|
352
|
+
recommended: false,
|
|
353
|
+
defaultValue: undefined,
|
|
354
|
+
maxLength: 64,
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
// Upstream default: 30 min. 0 = disabled (still ACTIVE — written to env).
|
|
358
|
+
// PF-023 bounds: max 1440 (24h). min 0 (0 = off, explicit value not neutral).
|
|
359
|
+
id: 'goal-checkin-minutes',
|
|
360
|
+
label: 'Goal check-in interval',
|
|
361
|
+
description: 'Interval in minutes for Claude to check in on task goals',
|
|
362
|
+
hint: 'Periodic goal check-ins every N min; 0 = off; upstream default is 30',
|
|
363
|
+
blurb: 'goal check-in interval',
|
|
364
|
+
kind: 'number',
|
|
365
|
+
target: { type: 'env', key: 'CLAUDE_CODE_GOAL_CHECKIN_MINUTES' },
|
|
366
|
+
recommended: false,
|
|
367
|
+
defaultValue: undefined,
|
|
368
|
+
min: 0, // 0 = off (ACTIVE, not neutral — written as "0")
|
|
369
|
+
max: 1440, // devflow sanity bound: 24 hours (applies PF-023)
|
|
370
|
+
integer: true,
|
|
371
|
+
upstreamDefault: 30,
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
// Writes as { command: value } per Claude Code spellcheck setting shape.
|
|
375
|
+
id: 'spellcheck',
|
|
376
|
+
label: 'Spellcheck command',
|
|
377
|
+
description: 'Custom spellcheck command for Claude Code',
|
|
378
|
+
hint: 'Sets the external spell-check command (written as {command: ...})',
|
|
379
|
+
blurb: 'external spell-check command',
|
|
380
|
+
kind: 'string',
|
|
381
|
+
target: { type: 'setting', key: 'spellcheck' },
|
|
382
|
+
recommended: false,
|
|
383
|
+
defaultValue: undefined,
|
|
384
|
+
wrapKey: 'command',
|
|
385
|
+
maxLength: 256, // devflow sanity bound (applies PF-023)
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
// view-mode folded into the registry; neutralValue 'default' deletes the viewMode key.
|
|
389
|
+
// VIEW_MODES, ViewMode, resolveExistingViewMode, and resolveFinalViewMode remain exported
|
|
390
|
+
// for init.ts and other callers that read/resolve view-mode in the settings pipeline.
|
|
391
|
+
id: 'view-mode',
|
|
392
|
+
label: 'View mode',
|
|
393
|
+
description: 'Interface view mode (default / verbose / focus)',
|
|
394
|
+
hint: "Controls view mode; 'default' removes the key (Claude Code native default)",
|
|
395
|
+
blurb: 'interface view mode',
|
|
396
|
+
kind: 'enum',
|
|
397
|
+
target: { type: 'setting', key: 'viewMode' },
|
|
398
|
+
values: ['default', 'verbose', 'focus'],
|
|
399
|
+
valueHints: {
|
|
400
|
+
default: 'Standard view (no override)',
|
|
401
|
+
verbose: 'Show all tool output and reasoning',
|
|
402
|
+
focus: 'Minimal UI — hides secondary panels',
|
|
403
|
+
},
|
|
404
|
+
neutralValue: 'default',
|
|
405
|
+
recommended: false,
|
|
406
|
+
defaultValue: 'default',
|
|
407
|
+
},
|
|
175
408
|
];
|
|
409
|
+
// Pre-built lookup for O(1) flag-by-id access.
|
|
410
|
+
const FLAG_REGISTRY_MAP = new Map(FLAG_REGISTRY.map(f => [f.id, f]));
|
|
176
411
|
/**
|
|
177
|
-
*
|
|
412
|
+
* O(1) flag lookup backed by FLAG_REGISTRY_MAP.
|
|
413
|
+
* Returns undefined when the id is not in the registry.
|
|
178
414
|
*/
|
|
179
|
-
export function
|
|
180
|
-
return
|
|
415
|
+
export function findFlag(id) {
|
|
416
|
+
return FLAG_REGISTRY_MAP.get(id);
|
|
181
417
|
}
|
|
418
|
+
// ─── Core value helpers ───────────────────────────────────────────────────────
|
|
182
419
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
420
|
+
* Returns the neutral value for a flag — the value that means "no preference"
|
|
421
|
+
* (applying neutral deletes the target key).
|
|
422
|
+
*
|
|
423
|
+
* - boolean: false (false = off = no key written)
|
|
424
|
+
* - enum: neutralValue if defined, else null
|
|
425
|
+
* - number: null (no number, including 0, is neutral — 0 is ACTIVE)
|
|
426
|
+
* - string: null
|
|
186
427
|
*/
|
|
187
|
-
export function
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
428
|
+
export function neutralValueOf(flag) {
|
|
429
|
+
switch (flag.kind) {
|
|
430
|
+
case 'boolean': return false;
|
|
431
|
+
case 'enum': return flag.neutralValue ?? null;
|
|
432
|
+
case 'number': return null;
|
|
433
|
+
case 'string': return null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Returns true when `value` is the neutral value for `flag`.
|
|
438
|
+
* null is always neutral. Number 0 is NOT neutral.
|
|
439
|
+
*/
|
|
440
|
+
export function isNeutral(flag, value) {
|
|
441
|
+
if (value === null)
|
|
442
|
+
return true;
|
|
443
|
+
return value === neutralValueOf(flag);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Map a record value to a TUI value.
|
|
447
|
+
*
|
|
448
|
+
* viewMode GLUE RULE (PF-017 one-shared-definition corollary): the mapping lives here,
|
|
449
|
+
* next to neutralValueOf — the definition it depends on — not across a module boundary.
|
|
450
|
+
* enum with neutralValue: neutralValue → null in TUI (null is the TUI representation
|
|
451
|
+
* of "use the default"; the key is deleted when persisted).
|
|
452
|
+
* All other values pass through unchanged.
|
|
453
|
+
*
|
|
454
|
+
* Consumers: flags-view/state.ts (buildFlagRows, buildDevflowDefault, collectFlagRecord).
|
|
455
|
+
*/
|
|
456
|
+
export function recordToTui(flag, v) {
|
|
457
|
+
if (v === null)
|
|
458
|
+
return null;
|
|
459
|
+
if (flag.kind === 'enum' && flag.neutralValue !== undefined) {
|
|
460
|
+
if (v === flag.neutralValue)
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
return v;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Map a TUI value back to a record value.
|
|
467
|
+
*
|
|
468
|
+
* viewMode GLUE RULE (PF-017 one-shared-definition corollary): inverse of recordToTui,
|
|
469
|
+
* co-located with that function so the round-trip contract is auditable in one place.
|
|
470
|
+
* enum with neutralValue: null → neutralValue (e.g. 'default').
|
|
471
|
+
* All other values pass through unchanged.
|
|
472
|
+
*
|
|
473
|
+
* Consumers: flags-view/state.ts (collectFlagRecord).
|
|
474
|
+
*/
|
|
475
|
+
export function tuiToRecord(flag, v) {
|
|
476
|
+
if (v === null && flag.kind === 'enum' && flag.neutralValue !== undefined) {
|
|
477
|
+
return flag.neutralValue;
|
|
478
|
+
}
|
|
479
|
+
return v;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Validate and coerce `raw` to a safe value for `flag` at the sink.
|
|
483
|
+
* Returns null when the value is invalid (hostile-value defence — applies PF-023).
|
|
484
|
+
*
|
|
485
|
+
* Number invariants: finite, within [min, max], integer when required.
|
|
486
|
+
* String invariants: within maxLength, no control characters.
|
|
487
|
+
* Enum invariants: value must be in the declared values array.
|
|
488
|
+
* Boolean invariants: must be a boolean.
|
|
489
|
+
*/
|
|
490
|
+
export function coerceFlagValue(flag, raw) {
|
|
491
|
+
if (raw === null || raw === undefined)
|
|
492
|
+
return null;
|
|
493
|
+
switch (flag.kind) {
|
|
494
|
+
case 'boolean': {
|
|
495
|
+
if (typeof raw !== 'boolean')
|
|
496
|
+
return null;
|
|
497
|
+
return raw;
|
|
498
|
+
}
|
|
499
|
+
case 'enum': {
|
|
500
|
+
if (typeof raw !== 'string')
|
|
501
|
+
return null;
|
|
502
|
+
if (!flag.values.includes(raw))
|
|
503
|
+
return null;
|
|
504
|
+
return raw;
|
|
505
|
+
}
|
|
506
|
+
case 'number': {
|
|
507
|
+
if (typeof raw !== 'number')
|
|
508
|
+
return null;
|
|
509
|
+
if (!Number.isFinite(raw))
|
|
510
|
+
return null; // rejects Infinity, NaN, 1e309
|
|
511
|
+
if (flag.min !== undefined && raw < flag.min)
|
|
512
|
+
return null;
|
|
513
|
+
if (flag.max !== undefined && raw > flag.max)
|
|
514
|
+
return null;
|
|
515
|
+
if (flag.integer === true && !Number.isInteger(raw))
|
|
516
|
+
return null;
|
|
517
|
+
return raw;
|
|
518
|
+
}
|
|
519
|
+
case 'string': {
|
|
520
|
+
if (typeof raw !== 'string')
|
|
521
|
+
return null;
|
|
522
|
+
// Empty string is UNSET, never an active value — caller should pass null for unset.
|
|
523
|
+
if (raw === '')
|
|
524
|
+
return null;
|
|
525
|
+
if (flag.maxLength !== undefined && raw.length > flag.maxLength)
|
|
526
|
+
return null;
|
|
527
|
+
// Reject ASCII control chars except \t (horizontal tab is benign in commands).
|
|
528
|
+
// LF (\x0a) MUST be rejected: `spellcheck` is executed as a shell command, where
|
|
529
|
+
// a newline is a statement separator, and the --status table is line-oriented.
|
|
530
|
+
// The range \x0a-\x1f covers LF through US, with \x09 (TAB) as the sole omission.
|
|
531
|
+
if (/[\x00-\x08\x0a-\x1f\x7f]/.test(raw))
|
|
532
|
+
return null;
|
|
533
|
+
return raw;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Parse a CLI text input to a FlagsRecordValue.
|
|
539
|
+
* 'unset' (literal) → null for any flag.
|
|
540
|
+
*
|
|
541
|
+
* Number branch uses strict decimal grammar (applies PF-023 — invariant at the sink
|
|
542
|
+
* every caller reaches, not per-caller): rejects empty, padded, hex, exponent,
|
|
543
|
+
* and leading-zero forms. Equivalent to the TUI's strict parsing so both entry
|
|
544
|
+
* points share one grammar.
|
|
545
|
+
*
|
|
546
|
+
* String branch: empty string → null (empty is UNSET, not an active value).
|
|
547
|
+
*/
|
|
548
|
+
export function parseFlagValueInput(flag, text) {
|
|
549
|
+
if (text === 'unset')
|
|
550
|
+
return null;
|
|
551
|
+
switch (flag.kind) {
|
|
552
|
+
case 'boolean': {
|
|
553
|
+
if (text === 'true')
|
|
554
|
+
return true;
|
|
555
|
+
if (text === 'false')
|
|
556
|
+
return false;
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
case 'enum':
|
|
560
|
+
return coerceFlagValue(flag, text);
|
|
561
|
+
case 'number': {
|
|
562
|
+
// Strict decimal grammar: reject empty, padded, hex, exponent, and leading zeros.
|
|
563
|
+
// Number('') === 0, Number(' 3 ') === 3, Number('0x5') === 5, Number('1e1') === 10 —
|
|
564
|
+
// all would pass bare Number() but violate the strict grammar contract.
|
|
565
|
+
if (text === '' || text !== text.trim())
|
|
566
|
+
return null;
|
|
567
|
+
if (!/^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text))
|
|
568
|
+
return null;
|
|
569
|
+
return coerceFlagValue(flag, Number(text));
|
|
570
|
+
}
|
|
571
|
+
case 'string':
|
|
572
|
+
// Empty string → null (empty is UNSET); coerceFlagValue handles the rest.
|
|
573
|
+
return coerceFlagValue(flag, text);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Returns a human-readable kind label for a flag — used by --list output.
|
|
578
|
+
*
|
|
579
|
+
* Exhaustive switch (no default): TypeScript narrows on `flag.kind` so
|
|
580
|
+
* each branch sees the narrowed subtype directly.
|
|
581
|
+
*
|
|
582
|
+
* Output examples:
|
|
583
|
+
* boolean → 'boolean'
|
|
584
|
+
* enum [small|medium|large|…] → 'enum [small|medium|large|…]'
|
|
585
|
+
* number min=1 max=100 integer → 'number min=1 max=100 integer'
|
|
586
|
+
* string maxLen=64 → 'string maxLen=64'
|
|
587
|
+
*/
|
|
588
|
+
export function describeFlagKind(flag) {
|
|
589
|
+
switch (flag.kind) {
|
|
590
|
+
case 'boolean':
|
|
591
|
+
return 'boolean';
|
|
592
|
+
case 'enum':
|
|
593
|
+
return `enum [${flag.values.join('|')}]`;
|
|
594
|
+
case 'number': {
|
|
595
|
+
const parts = [];
|
|
596
|
+
if (flag.min !== undefined)
|
|
597
|
+
parts.push(`min=${flag.min}`);
|
|
598
|
+
if (flag.max !== undefined)
|
|
599
|
+
parts.push(`max=${flag.max}`);
|
|
600
|
+
if (flag.integer)
|
|
601
|
+
parts.push('integer');
|
|
602
|
+
return `number${parts.length ? ' ' + parts.join(' ') : ''}`;
|
|
603
|
+
}
|
|
604
|
+
case 'string':
|
|
605
|
+
return `string${flag.maxLength !== undefined ? ` maxLen=${flag.maxLength}` : ''}`;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Returns the expected-input hint shown by --set when a value is invalid.
|
|
610
|
+
*
|
|
611
|
+
* Exhaustive switch: TypeScript narrows each arm directly.
|
|
612
|
+
*
|
|
613
|
+
* Output examples:
|
|
614
|
+
* boolean → 'true|false|unset'
|
|
615
|
+
* enum → 'small|medium|large|unrestricted|unset'
|
|
616
|
+
* number → 'a valid number value or unset'
|
|
617
|
+
* string → 'a valid string value or unset'
|
|
618
|
+
*/
|
|
619
|
+
export function expectedInputFor(flag) {
|
|
620
|
+
switch (flag.kind) {
|
|
621
|
+
case 'boolean':
|
|
622
|
+
return 'true|false|unset';
|
|
623
|
+
case 'enum':
|
|
624
|
+
return `${flag.values.join('|')}|unset`;
|
|
625
|
+
case 'number':
|
|
626
|
+
return 'a valid number value or unset';
|
|
627
|
+
case 'string':
|
|
628
|
+
return 'a valid string value or unset';
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Returns the effective display for a flag value — the single source of truth
|
|
633
|
+
* for every render site (TUI cell, --status rows, --list default label,
|
|
634
|
+
* --enable/--disable confirmation text).
|
|
635
|
+
*
|
|
636
|
+
* Never returns 'unset' — always shows what the flag effectively does:
|
|
637
|
+
* boolean true → { text: 'on', isDefault: false }
|
|
638
|
+
* boolean false/null → { text: 'off', isDefault: true }
|
|
639
|
+
* enum active value → { text: value, isDefault: false }
|
|
640
|
+
* enum neutral/null → { text: neutralValue ?? '—', isDefault: true }
|
|
641
|
+
* number active value → { text: String(value), isDefault: false }
|
|
642
|
+
* number null → { text: String(defaultValue ?? upstreamDefault), isDefault: true }
|
|
643
|
+
* or { text: '—', isDefault: true } when no default exists
|
|
644
|
+
* string active value → { text: value, isDefault: false }
|
|
645
|
+
* string null → { text: '—', isDefault: true }
|
|
646
|
+
*
|
|
647
|
+
* D-EFFDV: one-definition seam consumed by formatFlagValue, render.ts formatValue,
|
|
648
|
+
* formatStatusRows (Site D), and handleList defaultLabel (Site C).
|
|
649
|
+
* All display sites must route through here — never re-derive independently.
|
|
650
|
+
*/
|
|
651
|
+
export function effectiveDisplay(flag, value) {
|
|
652
|
+
// Boolean: true = 'on' (active), false/null = 'off' (neutral but meaningful)
|
|
653
|
+
if (flag.kind === 'boolean') {
|
|
654
|
+
const active = value === true;
|
|
655
|
+
return { text: active ? 'on' : 'off', isDefault: !active };
|
|
656
|
+
}
|
|
657
|
+
// Active non-null non-neutral value for enum/number/string
|
|
658
|
+
if (value !== null && !isNeutral(flag, value)) {
|
|
659
|
+
return { text: String(value), isDefault: false };
|
|
660
|
+
}
|
|
661
|
+
// Neutral or null: show what the flag effectively defaults to
|
|
662
|
+
switch (flag.kind) {
|
|
663
|
+
case 'enum': {
|
|
664
|
+
const neutral = flag.neutralValue ?? '—';
|
|
665
|
+
return { text: neutral, isDefault: true };
|
|
666
|
+
}
|
|
667
|
+
case 'number': {
|
|
668
|
+
const def = flag.defaultValue ?? flag.upstreamDefault;
|
|
669
|
+
return { text: def !== undefined ? String(def) : '—', isDefault: true };
|
|
670
|
+
}
|
|
671
|
+
case 'string':
|
|
672
|
+
return { text: '—', isDefault: true };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Format a flag value for display (CLI output, confirmation messages).
|
|
677
|
+
*
|
|
678
|
+
* Delegates to effectiveDisplay — see its JSDoc for the full vocabulary.
|
|
679
|
+
* D-EFFDV: one definition, all sites route through effectiveDisplay.
|
|
680
|
+
*
|
|
681
|
+
* NOTE: --set confirmation for an explicit 'unset' input should special-case
|
|
682
|
+
* null → literal 'unset' at the call site, since the user named it explicitly.
|
|
683
|
+
*/
|
|
684
|
+
export function formatFlagValue(flag, value) {
|
|
685
|
+
return effectiveDisplay(flag, value).text;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Count flags in `record` that have active (non-neutral) values.
|
|
689
|
+
* Unknown IDs are counted if their value is truthy.
|
|
690
|
+
*/
|
|
691
|
+
export function countActiveFlags(record) {
|
|
692
|
+
let count = 0;
|
|
693
|
+
for (const [id, value] of Object.entries(record)) {
|
|
694
|
+
if (value === null)
|
|
193
695
|
continue;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
696
|
+
const flag = FLAG_REGISTRY_MAP.get(id);
|
|
697
|
+
if (flag) {
|
|
698
|
+
if (!isNeutral(flag, value))
|
|
699
|
+
count++;
|
|
700
|
+
}
|
|
701
|
+
else if (value) {
|
|
702
|
+
// Unknown flag ID: count if truthy
|
|
703
|
+
count++;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return count;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Read the view-mode from a FlagsRecord.
|
|
710
|
+
* Returns 'default' when the entry is absent, null, or unrecognised.
|
|
711
|
+
*/
|
|
712
|
+
export function readViewMode(record) {
|
|
713
|
+
const v = record['view-mode'];
|
|
714
|
+
if (typeof v === 'string' && VIEW_MODES.includes(v)) {
|
|
715
|
+
return v;
|
|
716
|
+
}
|
|
717
|
+
return 'default';
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Sanitize a FlagsRecord by coercing each known flag's value through
|
|
721
|
+
* coerceFlagValue.
|
|
722
|
+
*
|
|
723
|
+
* Known flag IDs (applies ADR-014 key-presence semantics):
|
|
724
|
+
* - explicit null input → kept as null (deliberately unset)
|
|
725
|
+
* - valid non-null input → kept as coerced value
|
|
726
|
+
* - invalid non-null input → KEY DROPPED (absent = adopt default on next init,
|
|
727
|
+
* which is safer than writing null = "deliberately unset" for a corrupt value)
|
|
728
|
+
*
|
|
729
|
+
* Unknown flag IDs (forward-compat):
|
|
730
|
+
* - primitive values (boolean, number, string, null) → kept as-is
|
|
731
|
+
* - non-primitive values (objects, arrays) → DROPPED to avoid laundering
|
|
732
|
+
* untrusted shapes into FlagsRecordValue (applies PF-023)
|
|
733
|
+
*
|
|
734
|
+
* D39: `__proto__`, `constructor`, `prototype` are always skipped.
|
|
735
|
+
*/
|
|
736
|
+
export function sanitizeFlagsRecord(record) {
|
|
737
|
+
const result = {};
|
|
738
|
+
for (const [id, value] of Object.entries(record)) {
|
|
739
|
+
// D39: prototype pollution guard — skip dangerous own-property names that
|
|
740
|
+
// would invoke [[Set]] accessors on the result object and mutate its prototype.
|
|
741
|
+
if (id === '__proto__' || id === 'constructor' || id === 'prototype')
|
|
742
|
+
continue;
|
|
743
|
+
const flag = FLAG_REGISTRY_MAP.get(id);
|
|
744
|
+
if (flag) {
|
|
745
|
+
if (value === null) {
|
|
746
|
+
// Explicit null = deliberately unset: preserve key-presence semantics.
|
|
747
|
+
result[id] = null;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const coerced = coerceFlagValue(flag, value);
|
|
751
|
+
if (coerced !== null) {
|
|
752
|
+
result[id] = coerced;
|
|
753
|
+
}
|
|
754
|
+
// else: invalid non-null value → DROP the key so the flag is re-adopted
|
|
755
|
+
// on next init from registry defaults (safer than writing null = "unset").
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// Unknown id: forward-compat pass-through for primitive/null values only.
|
|
760
|
+
// Non-primitive values (objects, arrays) are dropped — laundering an
|
|
761
|
+
// arbitrary object into FlagsRecordValue violates the type contract.
|
|
762
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
763
|
+
result[id] = value;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
// ─── Record builders ──────────────────────────────────────────────────────────
|
|
770
|
+
/**
|
|
771
|
+
* Per-kind default-value rule (single authoritative source — CONS-M2).
|
|
772
|
+
*
|
|
773
|
+
* - boolean: flag.defaultValue (always a boolean — never collapses to null)
|
|
774
|
+
* - enum / number / string: flag.defaultValue ?? null
|
|
775
|
+
* (undefined defaultValue → null = adopt-on-next-init semantics)
|
|
776
|
+
*
|
|
777
|
+
* Call sites: getDefaultFlagsRecord, resolveSeedFlags (init-seed.ts),
|
|
778
|
+
* buildDevflowDefault (flags-view/state.ts). Adding a fifth kind or changing
|
|
779
|
+
* the null-collapse rule requires updating only this function.
|
|
780
|
+
*/
|
|
781
|
+
export function defaultValueOf(flag) {
|
|
782
|
+
return flag.kind === 'boolean' ? flag.defaultValue : (flag.defaultValue ?? null);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Return a FlagsRecord with every registered flag set to its defaultValue.
|
|
786
|
+
* Flags with undefined defaultValue get null.
|
|
787
|
+
* This record has an entry for EVERY flag — use it for initial seeding.
|
|
788
|
+
*/
|
|
789
|
+
export function getDefaultFlagsRecord() {
|
|
790
|
+
return Object.fromEntries(FLAG_REGISTRY.map(f => [f.id, defaultValueOf(f)]));
|
|
791
|
+
}
|
|
792
|
+
// ─── Migration helper ─────────────────────────────────────────────────────────
|
|
793
|
+
/**
|
|
794
|
+
* Migrate a legacy (string-array) enabled-flags manifest to a typed FlagsRecord.
|
|
795
|
+
* Called by manifest.ts self-healing when it encounters an old string-array manifest.
|
|
796
|
+
*
|
|
797
|
+
* Contract (applies ADR-014 transition semantics):
|
|
798
|
+
* - knownIds defined → knownSet = knownIds ∪ enabledIds
|
|
799
|
+
* - knownIds undefined → knownSet = full current registry ∪ enabledIds
|
|
800
|
+
* (pre-knownFlags manifests: all flags known, so adopt-nothing is expressed
|
|
801
|
+
* as value = enabledIds.includes(id) rather than absent entry)
|
|
802
|
+
* - Boolean registry flags in knownSet → value = enabledIds.includes(id)
|
|
803
|
+
* - Registry flags NOT in knownSet → NO entry (adopted on next seed)
|
|
804
|
+
* - Unknown enabled IDs (not in registry) → `true` preserved
|
|
805
|
+
* - viewMode fold: 'view-mode' = legacyViewMode ?? 'default'
|
|
806
|
+
*/
|
|
807
|
+
export function migrateLegacyFlagsToRecord(enabledIds, knownIds, legacyViewMode) {
|
|
808
|
+
const enabledSet = new Set(enabledIds);
|
|
809
|
+
const knownSet = knownIds !== undefined
|
|
810
|
+
? new Set([...knownIds, ...enabledIds])
|
|
811
|
+
: new Set([...FLAG_REGISTRY.map(f => f.id), ...enabledIds]);
|
|
812
|
+
const result = {};
|
|
813
|
+
for (const flag of FLAG_REGISTRY) {
|
|
814
|
+
// view-mode is handled separately at the end
|
|
815
|
+
if (flag.id === 'view-mode')
|
|
816
|
+
continue;
|
|
817
|
+
if (!knownSet.has(flag.id)) {
|
|
818
|
+
// Not known at last install → NO entry (will be adopted on next seed)
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (flag.kind === 'boolean') {
|
|
822
|
+
result[flag.id] = enabledSet.has(flag.id);
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// Valued flags: legacy string arrays never contain them; null = neutral
|
|
826
|
+
result[flag.id] = null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Unknown enabled IDs (not in any registry) preserved as true
|
|
830
|
+
for (const id of enabledIds) {
|
|
831
|
+
if (!FLAG_REGISTRY_MAP.has(id)) {
|
|
832
|
+
result[id] = true;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// viewMode fold: always written so the view-mode entry is explicit
|
|
836
|
+
result['view-mode'] = legacyViewMode ?? 'default';
|
|
837
|
+
return result;
|
|
838
|
+
}
|
|
839
|
+
// ─── Apply / Strip ────────────────────────────────────────────────────────────
|
|
840
|
+
/**
|
|
841
|
+
* Return `v` as a `Record<string, unknown>` only when it is a plain object.
|
|
842
|
+
* Returns undefined for arrays, null, or non-objects.
|
|
843
|
+
*
|
|
844
|
+
* Used as a guard at every `settings.env` access point so that a malformed
|
|
845
|
+
* `"env": []` in settings.json cannot cause `Object.keys([]).length === 0`
|
|
846
|
+
* to delete the entire env key, losing user-set env vars (applies TS-M3).
|
|
847
|
+
*/
|
|
848
|
+
function asPlainObject(v) {
|
|
849
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
850
|
+
? v
|
|
851
|
+
: undefined;
|
|
852
|
+
}
|
|
853
|
+
/** Compute the value to write to settings.json for an active flag. */
|
|
854
|
+
function buildPayload(flag, value) {
|
|
855
|
+
switch (flag.kind) {
|
|
856
|
+
case 'boolean':
|
|
857
|
+
return flag.onPayload;
|
|
858
|
+
case 'enum':
|
|
859
|
+
return value;
|
|
860
|
+
case 'number':
|
|
861
|
+
// Env targets receive string values; setting targets receive numbers.
|
|
862
|
+
return flag.target.type === 'env' ? String(value) : value;
|
|
863
|
+
case 'string': {
|
|
864
|
+
const s = value;
|
|
865
|
+
return flag.wrapKey ? { [flag.wrapKey]: s } : s;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Apply a FlagsRecord to a settings JSON string.
|
|
871
|
+
*
|
|
872
|
+
* - Unknown flag IDs are skipped (forward-compatible with future flags).
|
|
873
|
+
* - `coerceFlagValue` is called at the sink before applying (applies PF-023).
|
|
874
|
+
* - Neutral values delete their target key.
|
|
875
|
+
* - Env payloads for number flags are stringified ('40', never 40).
|
|
876
|
+
* - Setting payloads for string flags with wrapKey are shaped ({ command: v }).
|
|
877
|
+
* - env object is created on demand; deleted when it becomes empty.
|
|
878
|
+
* - `__proto__`, `constructor`, `prototype` keys are silently skipped.
|
|
879
|
+
*/
|
|
880
|
+
export function applyFlags(settingsJson, flags) {
|
|
881
|
+
// REL-M2 sink guard (applies PF-023): a non-plain-object root (null, array, scalar)
|
|
882
|
+
// would cause a silent no-op or a confusing TypeError deep inside the loop.
|
|
883
|
+
// Throw early with a clear message so every caller path is self-guarding.
|
|
884
|
+
const root = JSON.parse(settingsJson);
|
|
885
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) {
|
|
886
|
+
throw new Error('applyFlags: settings.json root must be a plain object');
|
|
887
|
+
}
|
|
888
|
+
const settings = root;
|
|
889
|
+
for (const [id, value] of Object.entries(flags)) {
|
|
890
|
+
// Prototype pollution guard
|
|
891
|
+
if (id === '__proto__' || id === 'constructor' || id === 'prototype')
|
|
892
|
+
continue;
|
|
893
|
+
const flag = FLAG_REGISTRY_MAP.get(id);
|
|
894
|
+
if (!flag)
|
|
895
|
+
continue; // unknown id — skip for forward compat
|
|
896
|
+
// Coerce at the sink (applies PF-023: validate at the convergence point)
|
|
897
|
+
const safe = coerceFlagValue(flag, value);
|
|
898
|
+
if (isNeutral(flag, safe)) {
|
|
899
|
+
// Neutral → delete the target key
|
|
900
|
+
if (flag.target.type === 'env') {
|
|
901
|
+
// asPlainObject guard: "env": [] must not delete a user's env var (applies TS-M3)
|
|
902
|
+
const env = asPlainObject(settings.env);
|
|
903
|
+
if (env)
|
|
904
|
+
delete env[flag.target.key];
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
delete settings[flag.target.key];
|
|
908
|
+
}
|
|
197
909
|
}
|
|
198
910
|
else {
|
|
199
|
-
|
|
911
|
+
const payload = buildPayload(flag, safe);
|
|
912
|
+
if (flag.target.type === 'env') {
|
|
913
|
+
if (!asPlainObject(settings.env)) {
|
|
914
|
+
settings.env = {};
|
|
915
|
+
}
|
|
916
|
+
settings.env[flag.target.key] = payload;
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
settings[flag.target.key] = payload;
|
|
920
|
+
}
|
|
200
921
|
}
|
|
201
922
|
}
|
|
923
|
+
// Clean up empty env object; asPlainObject guard avoids matching "env": []
|
|
924
|
+
const env = asPlainObject(settings.env);
|
|
925
|
+
if (env && Object.keys(env).length === 0) {
|
|
926
|
+
delete settings.env;
|
|
927
|
+
}
|
|
202
928
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
203
929
|
}
|
|
204
930
|
/**
|
|
205
931
|
* Strip all flag-managed keys from a settings JSON string.
|
|
206
|
-
*
|
|
207
|
-
*
|
|
932
|
+
* Registry-driven unconditional delete. Now covers viewMode (via view-mode
|
|
933
|
+
* registry entry) and object-valued settings (spellcheck → key deleted).
|
|
934
|
+
* Cleans up empty env object. Strip-then-apply idempotence preserved (INV-1).
|
|
208
935
|
*/
|
|
209
936
|
export function stripFlags(settingsJson) {
|
|
210
|
-
|
|
211
|
-
|
|
937
|
+
// REL-M2 sink guard (applies PF-023): mirror of applyFlags — throw early on a
|
|
938
|
+
// non-plain-object root so every caller path is self-guarding.
|
|
939
|
+
const root = JSON.parse(settingsJson);
|
|
940
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) {
|
|
941
|
+
throw new Error('stripFlags: settings.json root must be a plain object');
|
|
942
|
+
}
|
|
943
|
+
const settings = root;
|
|
944
|
+
// asPlainObject guard: "env": [] must not have its keys iterated as an object (applies TS-M3)
|
|
945
|
+
const env = asPlainObject(settings.env);
|
|
212
946
|
for (const flag of FLAG_REGISTRY) {
|
|
213
947
|
if (flag.target.type === 'env') {
|
|
214
|
-
if (env)
|
|
948
|
+
if (env)
|
|
215
949
|
delete env[flag.target.key];
|
|
216
|
-
}
|
|
217
950
|
}
|
|
218
951
|
else {
|
|
219
952
|
delete settings[flag.target.key];
|
|
@@ -224,33 +957,10 @@ export function stripFlags(settingsJson) {
|
|
|
224
957
|
}
|
|
225
958
|
return JSON.stringify(settings, null, 2) + '\n';
|
|
226
959
|
}
|
|
960
|
+
// ─── viewMode helpers ─────────────────────────────────────────────────────────
|
|
227
961
|
const VIEW_MODE_KEY = 'viewMode';
|
|
228
962
|
/** All valid view mode values. Used for validation at manifest read boundaries. */
|
|
229
963
|
export const VIEW_MODES = ['default', 'verbose', 'focus'];
|
|
230
|
-
/**
|
|
231
|
-
* Apply a view mode to a settings JSON string.
|
|
232
|
-
* 'default' removes the viewMode key (Claude Code default behaviour);
|
|
233
|
-
* 'verbose' and 'focus' set the key explicitly.
|
|
234
|
-
*/
|
|
235
|
-
export function applyViewMode(settingsJson, mode) {
|
|
236
|
-
const settings = JSON.parse(settingsJson);
|
|
237
|
-
if (mode === 'default') {
|
|
238
|
-
delete settings[VIEW_MODE_KEY];
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
settings[VIEW_MODE_KEY] = mode;
|
|
242
|
-
}
|
|
243
|
-
return JSON.stringify(settings, null, 2) + '\n';
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Strip the viewMode key from a settings JSON string.
|
|
247
|
-
* Used during uninstall / flag strip to restore Claude Code defaults.
|
|
248
|
-
*/
|
|
249
|
-
export function stripViewMode(settingsJson) {
|
|
250
|
-
const settings = JSON.parse(settingsJson);
|
|
251
|
-
delete settings[VIEW_MODE_KEY];
|
|
252
|
-
return JSON.stringify(settings, null, 2) + '\n';
|
|
253
|
-
}
|
|
254
964
|
/**
|
|
255
965
|
* Extract the non-default view mode from a settings JSON string.
|
|
256
966
|
*
|
|
@@ -283,20 +993,11 @@ export function resolveExistingViewMode(settingsJson) {
|
|
|
283
993
|
}
|
|
284
994
|
/**
|
|
285
995
|
* Resolve the final view mode to write, combining an existing settings value,
|
|
286
|
-
* an init-prompt-selected value, and whether the selection was explicit
|
|
287
|
-
* CLI flag) or implicit (prompt default / recommended path).
|
|
288
|
-
*
|
|
289
|
-
* @param current - Existing viewMode from settings.json (resolveExistingViewMode).
|
|
290
|
-
* undefined means no opinion in the current settings.
|
|
291
|
-
* @param selected - What the init prompt (or recommended path) would use.
|
|
292
|
-
* @param explicit - true when the user made an explicit interactive selection in
|
|
293
|
-
* the Advanced init prompt, or when --reset was passed (which
|
|
294
|
-
* forces viewMode back to 'default' and sets explicit=true so
|
|
295
|
-
* that 'default' wins over any externally-set value).
|
|
996
|
+
* an init-prompt-selected value, and whether the selection was explicit.
|
|
296
997
|
*
|
|
297
998
|
* Rules:
|
|
298
999
|
* 1. explicit ⇒ selected wins (user intent is unambiguous, even 'default')
|
|
299
|
-
* 2. non-default current ⇒ current wins (preserve externally-set mode
|
|
1000
|
+
* 2. non-default current ⇒ current wins (preserve externally-set mode)
|
|
300
1001
|
* 3. else ⇒ selected
|
|
301
1002
|
*/
|
|
302
1003
|
export function resolveFinalViewMode(current, selected, explicit) {
|
|
@@ -306,4 +1007,118 @@ export function resolveFinalViewMode(current, selected, explicit) {
|
|
|
306
1007
|
return current;
|
|
307
1008
|
return selected;
|
|
308
1009
|
}
|
|
1010
|
+
// ─── Fold-before-strip pipeline ───────────────────────────────────────────────
|
|
1011
|
+
/**
|
|
1012
|
+
* Fold-before-strip pipeline — the single authoritative entry point for all
|
|
1013
|
+
* settings.json mutation paths (applies PF-015, PF-017, ADR-014).
|
|
1014
|
+
*
|
|
1015
|
+
* Both `init.ts` and `persistFlagConfig` (flags.ts) MUST call this instead of
|
|
1016
|
+
* invoking `stripFlags` + `applyFlags` directly; the invariant lives in the
|
|
1017
|
+
* pipeline, not at call sites.
|
|
1018
|
+
*
|
|
1019
|
+
* Fold semantics (D15-adopt):
|
|
1020
|
+
*
|
|
1021
|
+
* view-mode (Step 1): resolved via `resolveFinalViewMode` so an externally-set
|
|
1022
|
+
* `/focus` survives unless `viewModeExplicit` is true.
|
|
1023
|
+
*
|
|
1024
|
+
* Valued flags — enum/number/string, excluding view-mode (Step 2):
|
|
1025
|
+
* The "claimed" set is determined by `opts.ownedRecord`:
|
|
1026
|
+
* - `undefined` → use `record` itself (persistFlagConfig path — the manifest
|
|
1027
|
+
* record IS what devflow claims)
|
|
1028
|
+
* - `null` → nothing previously owned (fresh install)
|
|
1029
|
+
* - `FlagsRecord`→ the original manifest flags BEFORE seeding (init path)
|
|
1030
|
+
*
|
|
1031
|
+
* A flag is "claimed" when it is present and non-null in the claimed set.
|
|
1032
|
+
* Claimed: record value wins (devflow previously set this value).
|
|
1033
|
+
* Unclaimed: fold from settings — if the user has a value in settings.json,
|
|
1034
|
+
* adopt it into the record (ADR-014 adoption, devflow takes ownership).
|
|
1035
|
+
*
|
|
1036
|
+
* Boolean flags: never folded — on/off is always record-driven.
|
|
1037
|
+
*
|
|
1038
|
+
* The fold MUST run on pre-strip content — `stripFlags` removes the target
|
|
1039
|
+
* keys, making any fold after strip vacuous.
|
|
1040
|
+
*
|
|
1041
|
+
* Uninstall note: `src/cli/commands/uninstall.ts` calls `stripFlags` directly
|
|
1042
|
+
* with no record argument, preserving its full-sweep semantics. Do not change.
|
|
1043
|
+
*
|
|
1044
|
+
* Pure function: no I/O.
|
|
1045
|
+
*
|
|
1046
|
+
* @param settingsJson Current settings.json content (pre-strip)
|
|
1047
|
+
* @param record FlagsRecord to fold into and apply
|
|
1048
|
+
* @param opts.viewModeExplicit true when the caller explicitly selected a view
|
|
1049
|
+
* mode (TUI row changed or `--set view-mode=...` passed)
|
|
1050
|
+
* @param opts.ownedRecord Prior ownership set; see semantics above.
|
|
1051
|
+
* Init path: `existingManifest?.features.flags ?? null`.
|
|
1052
|
+
* persistFlagConfig path: omit (undefined).
|
|
1053
|
+
* @returns `{ settings: updated JSON string, record: folded FlagsRecord }`
|
|
1054
|
+
*/
|
|
1055
|
+
export function convergeFlagsIntoSettings(settingsJson, record, opts) {
|
|
1056
|
+
// ── Step 1: fold view-mode (must read pre-strip) ──────────────────────────
|
|
1057
|
+
// PF-015: resolveExistingViewMode reads the viewMode key. stripFlags removes
|
|
1058
|
+
// it as part of the view-mode registry entry. Reading after strip silently
|
|
1059
|
+
// reverts an externally-set /focus.
|
|
1060
|
+
const folded = {
|
|
1061
|
+
...record,
|
|
1062
|
+
'view-mode': resolveFinalViewMode(resolveExistingViewMode(settingsJson), readViewMode(record), opts.viewModeExplicit),
|
|
1063
|
+
};
|
|
1064
|
+
// ── Step 2: fold existing values for valued flags (pre-strip) ────────────
|
|
1065
|
+
// D15-adopt: for unclaimed valued flags, read the current settings value and
|
|
1066
|
+
// adopt it into the record. Claimed flags (previously set by devflow) keep
|
|
1067
|
+
// their record value; boolean flags are never folded.
|
|
1068
|
+
//
|
|
1069
|
+
// "Claimed" is determined by opts.ownedRecord:
|
|
1070
|
+
// undefined → use `record` (persistFlagConfig: manifest record = owned set)
|
|
1071
|
+
// null → nothing claimed (fresh install)
|
|
1072
|
+
// FlagsRecord → original manifest flags before seeding (init path)
|
|
1073
|
+
const claimedIn = opts.ownedRecord !== undefined ? opts.ownedRecord : record;
|
|
1074
|
+
let parsed;
|
|
1075
|
+
try {
|
|
1076
|
+
parsed = JSON.parse(settingsJson);
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
parsed = {};
|
|
1080
|
+
}
|
|
1081
|
+
const env = asPlainObject(parsed.env);
|
|
1082
|
+
for (const flag of FLAG_REGISTRY) {
|
|
1083
|
+
if (flag.kind === 'boolean')
|
|
1084
|
+
continue; // boolean flags: record-driven only
|
|
1085
|
+
if (flag.id === 'view-mode')
|
|
1086
|
+
continue; // already handled above
|
|
1087
|
+
// Check whether devflow previously owned this flag's key.
|
|
1088
|
+
// Any presence in claimedIn — including null (explicitly unset) — means
|
|
1089
|
+
// devflow owns the slot; the record value (or its absence) wins over settings.
|
|
1090
|
+
// Absence from claimedIn means devflow never wrote it → fold from settings.
|
|
1091
|
+
const previouslyOwned = claimedIn !== null &&
|
|
1092
|
+
Object.prototype.hasOwnProperty.call(claimedIn, flag.id);
|
|
1093
|
+
if (previouslyOwned)
|
|
1094
|
+
continue;
|
|
1095
|
+
// Read the raw value from settings.json (before strip removes it)
|
|
1096
|
+
const rawVal = flag.target.type === 'env'
|
|
1097
|
+
? env?.[flag.target.key]
|
|
1098
|
+
: parsed[flag.target.key];
|
|
1099
|
+
if (rawVal === undefined)
|
|
1100
|
+
continue;
|
|
1101
|
+
// Unwrap wrapKey-shaped values (e.g., spellcheck: { command: 'hunspell' } → 'hunspell')
|
|
1102
|
+
let toCoerce = rawVal;
|
|
1103
|
+
if (flag.kind === 'string' && flag.wrapKey !== undefined) {
|
|
1104
|
+
const obj = asPlainObject(rawVal);
|
|
1105
|
+
toCoerce = obj !== undefined ? obj[flag.wrapKey] : undefined;
|
|
1106
|
+
}
|
|
1107
|
+
if (toCoerce === undefined)
|
|
1108
|
+
continue;
|
|
1109
|
+
// Env vars store numbers as strings ('8') — convert to number for coercion
|
|
1110
|
+
if (flag.kind === 'number' && typeof toCoerce === 'string') {
|
|
1111
|
+
const n = Number(toCoerce);
|
|
1112
|
+
toCoerce = Number.isFinite(n) ? n : toCoerce;
|
|
1113
|
+
}
|
|
1114
|
+
const coerced = coerceFlagValue(flag, toCoerce);
|
|
1115
|
+
if (coerced !== null) {
|
|
1116
|
+
folded[flag.id] = coerced;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// ── Step 3: strip all managed keys, then apply the folded record ──────────
|
|
1120
|
+
const stripped = stripFlags(settingsJson);
|
|
1121
|
+
const settings = applyFlags(stripped, folded);
|
|
1122
|
+
return { settings, record: folded };
|
|
1123
|
+
}
|
|
309
1124
|
//# sourceMappingURL=flags.js.map
|