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.
@@ -1,119 +1,538 @@
1
+ /**
2
+ * devflow flags — Manage Claude Code feature flags.
3
+ *
4
+ * D-P3-1: Typed flags CLI rewrite (Phase 3).
5
+ * - createFlagsCommand() factory — fresh Commander instance per call;
6
+ * used by tests; src/cli.ts consumes the flagsCommand singleton export.
7
+ * - Persist pipeline: convergeFlagsIntoSettings (fold-before-strip) — the
8
+ * single pipeline entry point shared with init.ts (ARCH-H1, PF-015/017).
9
+ * - PF-014 (process.exit swallows async work): all error paths set
10
+ * process.exitCode = 1 and return; never call process.exit().
11
+ * - PF-015 (multi-artifact fan-out): compute record first; settings write
12
+ * and manifest write handled independently with their own error paths.
13
+ * - PF-022 (applies-on-restart): bare non-TTY invocation prints status table
14
+ * with a note that changes apply on restart.
15
+ * - PF-023 (validate at the sink): parseFlagValueInput → coerceFlagValue
16
+ * runs inside the core helpers before any write.
17
+ */
1
18
  import { Command } from 'commander';
2
19
  import { promises as fs } from 'fs';
3
20
  import * as path from 'path';
4
21
  import * as p from '@clack/prompts';
5
22
  import color from 'picocolors';
6
- import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
7
- import { FLAG_REGISTRY, applyFlags, stripFlags, getDefaultFlags } from '../../core/flags.js';
23
+ import { getClaudeDirectory, getDevFlowDirectory, } from '../../targets/claude-code/claude-paths.js';
24
+ import { FLAG_REGISTRY, findFlag, convergeFlagsIntoSettings, parseFlagValueInput, formatFlagValue, effectiveDisplay, neutralValueOf, describeFlagKind, expectedInputFor, } from '../../core/flags.js';
8
25
  import { readManifest, writeManifest } from '../../core/manifest.js';
26
+ import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
27
+ import { sanitizeCell } from '../tui/cells.js';
28
+ // Static imports for pure view-state helpers — no TTY machinery (applies PF-017).
29
+ // runFlagsTui stays lazily imported in handleBare to keep TTY module out of
30
+ // --list/--status code paths; buildFlagRows and collectFlagRecord are pure.
31
+ import { buildFlagRows, collectFlagRecord } from '../flags-view/state.js';
32
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
9
33
  /**
10
- * Resolve current enabled flags from manifest (falls back to defaults if no manifest).
34
+ * Read and parse settings.json.
35
+ * ENOENT → returns `{ content: '{}', ok: true }`.
36
+ * Malformed JSON → returns `{ ok: false, reason: string }`.
37
+ *
38
+ * NEVER silently falls back to '{}' on malformed JSON — that would clobber the
39
+ * user's settings. The caller must abort with exit code 1 on !ok (avoids PF-023).
11
40
  */
12
- async function resolveEnabledFlags(devflowDir) {
13
- const manifest = await readManifest(devflowDir);
14
- if (manifest) {
15
- return manifest.features.flags;
41
+ async function readSettingsSafe(settingsPath) {
42
+ let raw;
43
+ try {
44
+ raw = await fs.readFile(settingsPath, 'utf-8');
16
45
  }
17
- return getDefaultFlags();
46
+ catch (err) {
47
+ if (err.code === 'ENOENT') {
48
+ return { ok: true, content: '{}' };
49
+ }
50
+ return { ok: false, reason: `Cannot read settings.json: ${err.message}` };
51
+ }
52
+ // REL-M2 + PERF-L4: single parse — validate root shape and return raw string.
53
+ // The plain-object guard catches null/array roots before they reach applyFlags/stripFlags
54
+ // (applies PF-023 — validate at the sink; early rejection gives actionable error messages).
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return { ok: false, reason: 'settings.json is malformed — fix it before changing flags' };
61
+ }
62
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
63
+ return { ok: false, reason: 'settings.json must be a JSON object — fix it before changing flags' };
64
+ }
65
+ return { ok: true, content: raw };
18
66
  }
19
67
  /**
20
- * Update settings.json with the given flag set.
68
+ * Persist a FlagsRecord to settings.json and manifest.
69
+ *
70
+ * Uses `convergeFlagsIntoSettings` (ARCH-H1: fold-before-strip pipeline) so the
71
+ * invariant lives in the pipeline, not at call sites. This ensures that:
72
+ * - An externally-set /focus survives unless viewModeExplicit is true (PF-015).
73
+ * - Valued flags not yet claimed by devflow (absent from the manifest record)
74
+ * have their existing settings values preserved rather than stripped (REG-H1,
75
+ * SEC-M3, ADR-014).
76
+ *
77
+ * PF-015: settings write and manifest write are evaluated independently.
78
+ * Each failure is reported with its own message and exit code 1.
79
+ * The second write is never skipped due to the first succeeding or failing.
80
+ *
81
+ * Returns a discriminated PersistResult (never a boolean — avoids the two-state
82
+ * lie that cannot express the third "manifest absent" outcome). Success is tracked
83
+ * in LOCALS, never read back off `process.exitCode` (avoids PF-014, PF-015).
21
84
  */
22
- async function updateSettingsFlags(claudeDir, flagIds) {
85
+ async function persistFlagConfig(claudeDir, devflowDir, settingsContent, newRecord,
86
+ // ARCH-M2 + PERF-L1: caller passes the already-read manifest so this function
87
+ // does not re-read it (two snapshots, one write; readManifest also self-heals
88
+ // = extra write). null → {ok:false,reason:'no-manifest'} (C2 discriminant intact).
89
+ manifest, opts = { viewModeExplicit: false }) {
90
+ // D15: convergeFlagsIntoSettings is the fold-before-strip pipeline entry point
91
+ // (applies PF-015, PF-017, REG-H1, ARCH-H1). ownedRecord is omitted so the
92
+ // `newRecord` (the manifest record) serves as the owned set — a key present in
93
+ // the manifest means devflow previously claimed it; absent = never written by
94
+ // devflow, so the existing settings value is adopted.
95
+ const { settings: updatedSettings, record: foldedRecord } = convergeFlagsIntoSettings(settingsContent, newRecord, opts);
96
+ // PF-015: accumulate each artifact's failure independently; combine at the end.
97
+ const failed = [];
98
+ // Settings write — independent error path (avoids PF-015 fan-out).
23
99
  const settingsPath = path.join(claudeDir, 'settings.json');
24
- let content;
25
100
  try {
26
- content = await fs.readFile(settingsPath, 'utf-8');
27
- // Validate that content is parseable JSON before passing to stripFlags/applyFlags
28
- JSON.parse(content);
101
+ await writeFileAtomicExclusive(settingsPath, updatedSettings);
29
102
  }
30
- catch {
31
- content = '{}';
103
+ catch (err) {
104
+ p.log.error(`Failed to write settings.json: ${err instanceof Error ? err.message : String(err)}`);
105
+ failed.push('settings');
106
+ process.exitCode = 1;
107
+ // PF-015: still attempt the manifest write — evaluate each artifact independently.
108
+ }
109
+ // Manifest write — independent error path (avoids PF-015 fan-out).
110
+ // Uses foldedRecord (not newRecord) so adopted values are persisted to the
111
+ // manifest, keeping manifest ↔ settings.json in sync.
112
+ //
113
+ // An absent manifest is a FAILURE, not a no-op (TS-H2 / ARCH-H2 / REL-H2):
114
+ // returning success here would tell the user "Flags saved." while the manifest
115
+ // was never updated — reverted on the next `devflow init`.
116
+ if (!manifest) {
117
+ p.log.error('No devflow manifest found — flag selections were not recorded. Run devflow init first.');
118
+ process.exitCode = 1;
119
+ // Return the dedicated discriminant so callers cannot accidentally suppress it.
120
+ return { ok: false, reason: 'no-manifest' };
121
+ }
122
+ manifest.features.flags = foldedRecord;
123
+ manifest.updatedAt = new Date().toISOString();
124
+ try {
125
+ await writeManifest(devflowDir, manifest);
126
+ }
127
+ catch (err) {
128
+ p.log.error(`Failed to write manifest.json: ${err instanceof Error ? err.message : String(err)}`);
129
+ failed.push('manifest');
130
+ process.exitCode = 1;
32
131
  }
33
- const stripped = stripFlags(content);
34
- const updated = applyFlags(stripped, flagIds);
35
- await fs.writeFile(settingsPath, updated, 'utf-8');
132
+ // PF-015: OR the locals afterwards — never compose required side effects with ||/&&.
133
+ return failed.length > 0 ? { ok: false, failed } : { ok: true };
36
134
  }
37
135
  /**
38
- * Update manifest with the given flag set.
136
+ * Load manifest and settings.json for the mutating CLI branches.
137
+ *
138
+ * Returns a discriminated result — never exits itself. The dispatcher or handler
139
+ * reports the reason and sets process.exitCode = 1 on failure (avoids PF-014).
140
+ * One shared load path means a fix lands once, not four times (applies PF-017 —
141
+ * the four copies of the same preamble are exactly the "fix on one site, miss the
142
+ * other three" shape).
39
143
  */
40
- async function updateManifestFlags(devflowDir, flagIds) {
144
+ async function loadFlagContext(claudeDir, devflowDir) {
41
145
  const manifest = await readManifest(devflowDir);
42
- if (!manifest)
43
- return;
44
- manifest.features.flags = flagIds;
45
- manifest.updatedAt = new Date().toISOString();
46
- await writeManifest(devflowDir, manifest);
146
+ if (!manifest) {
147
+ return { ok: false, reason: 'No devflow installation found — run devflow init first' };
148
+ }
149
+ const settingsResult = await readSettingsSafe(path.join(claudeDir, 'settings.json'));
150
+ if (!settingsResult.ok) {
151
+ return { ok: false, reason: settingsResult.reason };
152
+ }
153
+ return { ok: true, value: { manifest, settingsContent: settingsResult.content } };
47
154
  }
48
155
  /**
49
- * Parse and validate comma-separated flag IDs against the registry.
50
- * Exits with error if any IDs are unknown.
156
+ * Format the current FlagsRecord as a status table — one row per registry flag.
157
+ *
158
+ * Shared between --status (p.log.info sink) and bare non-TTY (process.stdout.write
159
+ * sink). Both call sites choose their own sink; this function produces the row
160
+ * strings only (CPLX-SF5, CONS-M3: the longer "not adopted — default X applies
161
+ * on next devflow init" wording is kept in both surfaces; the short form dropped
162
+ * the actionable second half).
163
+ *
164
+ * Returns plain strings — sanitizeCell strips control characters to prevent a
165
+ * persisted LF/TAB from reshaping the line-oriented table (applies SEC-M1).
51
166
  */
52
- function parseFlagIds(input) {
53
- const ids = input.split(',').map(s => s.trim()).filter(Boolean);
54
- const invalid = ids.filter(id => !FLAG_REGISTRY.some(f => f.id === id));
55
- if (invalid.length > 0) {
56
- p.log.error(`Unknown flag(s): ${invalid.join(', ')}`);
57
- p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`);
58
- process.exit(1);
59
- }
60
- return ids;
167
+ function formatStatusRows(record) {
168
+ return FLAG_REGISTRY.map(flag => {
169
+ const value = Object.prototype.hasOwnProperty.call(record, flag.id)
170
+ ? record[flag.id]
171
+ : undefined;
172
+ // sanitizeCell: defence in depth — a persisted LF/TAB must not inject extra
173
+ // rows into the line-oriented table (applies SEC-M1).
174
+ // D-EFFDV: effectiveDisplay supplies the default label so 'unset' never appears.
175
+ const rawDisplay = value !== undefined
176
+ ? formatFlagValue(flag, value)
177
+ : `not adopted — default: ${effectiveDisplay(flag, neutralValueOf(flag)).text} applies on next devflow init`;
178
+ const displayValue = sanitizeCell(rawDisplay);
179
+ return `${flag.id.padEnd(28)} ${displayValue}`;
180
+ });
61
181
  }
62
- export const flagsCommand = new Command('flags')
63
- .description('Manage Claude Code feature flags')
64
- .option('--enable <ids>', 'Enable flag(s), comma-separated')
65
- .option('--disable <ids>', 'Disable flag(s), comma-separated')
66
- .option('--status', 'Show current flag states')
67
- .option('--list', 'List all available flags')
68
- .action(async (options) => {
69
- const claudeDir = getClaudeDirectory();
70
- const devflowDir = getDevFlowDirectory();
71
- if (options.list) {
72
- p.intro(color.bgCyan(color.black(' Claude Code Flags ')));
73
- const defaults = new Set(getDefaultFlags());
74
- for (const flag of FLAG_REGISTRY) {
75
- const status = defaults.has(flag.id) ? color.green('default ON') : color.dim('default OFF');
76
- const targetInfo = flag.target.type === 'env'
77
- ? `env.${flag.target.key}`
78
- : `setting.${flag.target.key}`;
79
- p.log.info(`${color.bold(flag.id)} — ${flag.label} (${status})`);
80
- p.log.info(` ${color.dim(flag.description)} → ${color.dim(targetInfo)}`);
182
+ // ─── Branch handlers ──────────────────────────────────────────────────────────
183
+ //
184
+ // One named async handler per CLI branch — each is independently readable and
185
+ // carries one responsibility. The dispatcher (createFlagsCommand action) is ~15
186
+ // lines and routes without logic of its own (ARCH-M1, CPLX-H1).
187
+ /** Handle --list: read-only registry dump, no manifest required. */
188
+ async function handleList() {
189
+ p.intro(color.bgCyan(color.black(' Claude Code Flags ')));
190
+ for (const flag of FLAG_REGISTRY) {
191
+ const kindLabel = describeFlagKind(flag);
192
+ const targetInfo = flag.target.type === 'env'
193
+ ? `env ${flag.target.key}`
194
+ : `setting ${flag.target.key}`;
195
+ // D-EFFDV: number flags show the upstream default when present so the
196
+ // registry dump is meaningful even for flags with no devflow defaultValue.
197
+ const defaultLabel = flag.kind === 'number' && flag.upstreamDefault !== undefined
198
+ ? `upstream default: ${flag.upstreamDefault}`
199
+ : flag.defaultValue !== undefined && flag.defaultValue !== null
200
+ ? String(flag.defaultValue)
201
+ : 'none';
202
+ const recLabel = flag.recommended ? color.green('recommended') : color.dim('optional');
203
+ p.log.info(`${color.bold(flag.id.padEnd(28))} ${recLabel.padEnd(20)} ${color.dim(kindLabel.padEnd(36))} ${color.dim(targetInfo)}`);
204
+ p.log.info(` ${color.dim(flag.hint)} — default: ${color.cyan(defaultLabel)}`);
205
+ }
206
+ p.outro(color.dim('Use --enable / --disable / --set / --unset to manage flags'));
207
+ }
208
+ /** Handle --status: read-only status table, degrades gracefully without a manifest. */
209
+ async function handleStatus(devflowDir) {
210
+ p.intro(color.bgCyan(color.black(' Claude Code Flags — Status ')));
211
+ const manifest = await readManifest(devflowDir);
212
+ if (!manifest) {
213
+ p.log.warn('Devflow is not installed — run devflow init first');
214
+ p.log.info('Showing registry defaults only:');
215
+ }
216
+ const record = manifest?.features.flags ?? {};
217
+ for (const row of formatStatusRows(record)) {
218
+ p.log.info(row);
219
+ }
220
+ p.outro(color.dim('Use --enable / --disable / --set / --unset to change flags'));
221
+ }
222
+ /**
223
+ * Handle --enable/--disable: set boolean flags to the given value.
224
+ *
225
+ * Collapsed from two identical 50-line branches into one handler parameterized by
226
+ * `value: boolean` — the only deltas were the record assignment (true vs false)
227
+ * and one error-message string (--set vs --unset as the suggested alternative)
228
+ * (CPLX-H2 — applies PF-017: one fix lands once, not twice).
229
+ */
230
+ async function handleSetBooleans(claudeDir, devflowDir, ids, value) {
231
+ // Validate: must be known boolean flags only.
232
+ // Collect the validated flag definitions so the success loop can use them
233
+ // directly — avoids findFlag(id) re-lookups after the guard (TS-S1).
234
+ const flagDefs = [];
235
+ for (const id of ids) {
236
+ const flag = findFlag(id);
237
+ if (!flag) {
238
+ p.log.error(`Unknown flag: ${color.bold(id)}`);
239
+ p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`);
240
+ process.exitCode = 1;
241
+ return;
242
+ }
243
+ if (flag.kind !== 'boolean') {
244
+ const alt = value ? `--set ${id}=value` : `--unset ${id}`;
245
+ p.log.error(`${color.bold(id)} is a ${flag.kind} flag — use ${color.bold(alt)} to ${value ? 'set' : 'clear'} it`);
246
+ process.exitCode = 1;
247
+ return;
81
248
  }
249
+ flagDefs.push(flag);
250
+ }
251
+ // Manifest required for mutating ops (avoids settings/manifest desync)
252
+ const ctx = await loadFlagContext(claudeDir, devflowDir);
253
+ if (!ctx.ok) {
254
+ p.log.error(ctx.reason);
255
+ process.exitCode = 1;
82
256
  return;
83
257
  }
84
- if (options.status) {
85
- p.intro(color.bgCyan(color.black(' Claude Code Flags ')));
86
- const enabled = new Set(await resolveEnabledFlags(devflowDir));
87
- for (const flag of FLAG_REGISTRY) {
88
- const state = enabled.has(flag.id) ? color.green('enabled') : color.dim('disabled');
89
- p.log.info(`${flag.id.padEnd(25)} ${state}`);
258
+ // PF-015: compute new record before any write
259
+ const newRecord = { ...ctx.value.manifest.features.flags };
260
+ for (const flag of flagDefs) {
261
+ newRecord[flag.id] = value;
262
+ }
263
+ const result = await persistFlagConfig(claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest);
264
+ if (result.ok) {
265
+ for (const flag of flagDefs) {
266
+ // D-EFFDV: formatFlagValue routes through effectiveDisplay — one vocabulary
267
+ // shared with --status and TUI so the three surfaces cannot drift.
268
+ p.log.success(`${flag.id} ${formatFlagValue(flag, value)}`);
269
+ }
270
+ }
271
+ }
272
+ /** Handle --set id=value (repeatable): validate all assignments then persist. */
273
+ async function handleSet(claudeDir, devflowDir, setValues) {
274
+ // Phase: parse and validate ALL assignments before any mutation.
275
+ const assignments = [];
276
+ for (const assignment of setValues) {
277
+ // Split on first = only — rest is the value (e.g. spellcheck=a=b → id='spellcheck', value='a=b')
278
+ const eqIdx = assignment.indexOf('=');
279
+ if (eqIdx === -1) {
280
+ p.log.error(`Invalid --set format: ${color.bold(assignment)} — expected id=value`);
281
+ process.exitCode = 1;
282
+ return;
283
+ }
284
+ const id = assignment.slice(0, eqIdx);
285
+ const text = assignment.slice(eqIdx + 1);
286
+ // Prototype pollution guard (applies PF-023)
287
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') {
288
+ p.log.error(`Unknown flag: ${color.bold(id)}`);
289
+ process.exitCode = 1;
290
+ return;
291
+ }
292
+ const flag = findFlag(id);
293
+ if (!flag) {
294
+ p.log.error(`Unknown flag: ${color.bold(id)}`);
295
+ p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`);
296
+ process.exitCode = 1;
297
+ return;
90
298
  }
299
+ const value = parseFlagValueInput(flag, text);
300
+ if (value === null && text !== 'unset') {
301
+ // parseFlagValueInput returns null both for 'unset' and for invalid values.
302
+ // If the input isn't literally 'unset', the null means invalid.
303
+ p.log.error(`Invalid value for ${color.bold(id)}: ${color.bold(text)}`);
304
+ p.log.info(`Expected: ${expectedInputFor(flag)}`);
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ assignments.push({ id, flag, value });
309
+ }
310
+ // All assignments valid — load manifest + settings
311
+ const ctx = await loadFlagContext(claudeDir, devflowDir);
312
+ if (!ctx.ok) {
313
+ p.log.error(ctx.reason);
314
+ process.exitCode = 1;
91
315
  return;
92
316
  }
93
- if (options.enable) {
94
- const ids = parseFlagIds(options.enable);
95
- const current = await resolveEnabledFlags(devflowDir);
96
- const updated = [...new Set([...current, ...ids])];
97
- await updateSettingsFlags(claudeDir, updated);
98
- await updateManifestFlags(devflowDir, updated);
99
- for (const id of ids) {
100
- p.log.success(`${id} enabled`);
317
+ // PF-015: compute final record before any write
318
+ const newRecord = { ...ctx.value.manifest.features.flags };
319
+ for (const { id, flag, value } of assignments) {
320
+ // null from parseFlagValueInput for literal 'unset' → use neutral value
321
+ newRecord[id] = value ?? neutralValueOf(flag);
322
+ }
323
+ // viewModeExplicit: true when the user explicitly assigned view-mode in --set.
324
+ // This lets the chosen value override an externally-set /focus.
325
+ const viewModeExplicit = assignments.some(a => a.id === 'view-mode');
326
+ const result = await persistFlagConfig(claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest, { viewModeExplicit });
327
+ if (result.ok) {
328
+ for (const { id, flag, value } of assignments) {
329
+ // null means the user typed 'unset' explicitly — echo their word back.
330
+ // For active values, route through formatFlagValue (D-EFFDV vocabulary).
331
+ const displayText = value === null ? 'unset' : formatFlagValue(flag, value);
332
+ p.log.success(`${id} = ${displayText}`);
101
333
  }
334
+ }
335
+ }
336
+ /** Handle --unset ids: reset flags to their neutral values. */
337
+ async function handleUnset(claudeDir, devflowDir, ids) {
338
+ // Validate: must be known flags (any kind).
339
+ // Collect the validated flag definitions so the mutation loop can use them
340
+ // directly — avoids findFlag(id) re-lookups after the guard (TS-S1).
341
+ const flagDefs = [];
342
+ for (const id of ids) {
343
+ const flag = findFlag(id);
344
+ if (!flag) {
345
+ p.log.error(`Unknown flag: ${color.bold(id)}`);
346
+ p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`);
347
+ process.exitCode = 1;
348
+ return;
349
+ }
350
+ flagDefs.push(flag);
351
+ }
352
+ const ctx = await loadFlagContext(claudeDir, devflowDir);
353
+ if (!ctx.ok) {
354
+ p.log.error(ctx.reason);
355
+ process.exitCode = 1;
102
356
  return;
103
357
  }
104
- if (options.disable) {
105
- const ids = parseFlagIds(options.disable);
106
- const current = await resolveEnabledFlags(devflowDir);
107
- const toDisable = new Set(ids);
108
- const updated = current.filter(id => !toDisable.has(id));
109
- await updateSettingsFlags(claudeDir, updated);
110
- await updateManifestFlags(devflowDir, updated);
358
+ // PF-015: compute new record before any write
359
+ const newRecord = { ...ctx.value.manifest.features.flags };
360
+ for (const flag of flagDefs) {
361
+ newRecord[flag.id] = neutralValueOf(flag);
362
+ }
363
+ // viewModeExplicit: true when the user explicitly unset view-mode.
364
+ const viewModeExplicit = ids.includes('view-mode');
365
+ const result = await persistFlagConfig(claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest, { viewModeExplicit });
366
+ if (result.ok) {
111
367
  for (const id of ids) {
112
- p.log.success(`${id} disabled`);
368
+ p.log.success(`${id} unset`);
113
369
  }
114
- return;
115
370
  }
116
- // No option — show help
117
- p.log.info('Usage: devflow flags --status | --list | --enable <ids> | --disable <ids>');
118
- });
371
+ }
372
+ /**
373
+ * Apply a TUI result to disk — the save/persist seam extracted from handleBare.
374
+ *
375
+ * Enables seam testing of the TUI→persist wiring without a real TTY (closes
376
+ * the interactive-surface coverage gap per PF-017(c)). The test drives
377
+ * runFlagsTui with PassThrough streams, feeds its result here, and asserts
378
+ * the whole post-state of both artifacts (manifest + settings.json) per PF-015.
379
+ *
380
+ * @param result TUI result from runFlagsTui — action 'save', 'cancel', or 'abort'.
381
+ * @param freshSettingsContent Settings.json content re-read AFTER the TUI closed
382
+ * (see REL-M3 in handleBare — caller owns the re-read).
383
+ * @param manifest Loaded manifest threaded from loadFlagContext before TUI launch.
384
+ * @param claudeDir Path to ~/.claude directory (for settings.json write).
385
+ * @param devflowDir Path to ~/.devflow directory (for manifest write).
386
+ * @returns 'saved' on successful persist, 'unchanged' for cancel/abort,
387
+ * or the PersistResult error discriminant when persist fails
388
+ * (persistFlagConfig already logged + set exitCode in that case).
389
+ */
390
+ export async function applyTuiResult(result, freshSettingsContent, manifest, claudeDir, devflowDir) {
391
+ if (result.action !== 'save') {
392
+ return 'unchanged';
393
+ }
394
+ const existingRecord = manifest.features.flags;
395
+ const newRecord = collectFlagRecord(result.rows);
396
+ // viewModeExplicit: true if the user changed the view-mode row in the TUI
397
+ const viewModeExplicit = newRecord['view-mode'] !== existingRecord['view-mode'];
398
+ const persistResult = await persistFlagConfig(claudeDir, devflowDir, freshSettingsContent, newRecord, manifest, { viewModeExplicit });
399
+ if (persistResult.ok) {
400
+ return 'saved';
401
+ }
402
+ return persistResult;
403
+ }
404
+ /**
405
+ * Handle bare invocation (no subcommand flags).
406
+ *
407
+ * D-P5-1: TTY path launches the interactive flags TUI via lazy import;
408
+ * non-TTY path prints a status table + note to stderr + exitCode 1.
409
+ * CONS-M3: formatStatusRows() is the shared row formatter — non-TTY now uses
410
+ * the longer "not adopted — default X applies on next devflow init" wording,
411
+ * matching --status (convergence of the two divergent status surfaces).
412
+ *
413
+ * TS-H2 / ARCH-H2 / REL-H2: TTY path reuses loadFlagContext (the same guard
414
+ * that mutating handlers use) before importing or launching the TUI. An absent or
415
+ * unreadable manifest is a hard-refuse: the TUI must not launch, and settings.json
416
+ * must not be touched. This prevents the silent-factory-reset path (TUI seeded
417
+ * from {} writes settings.json; next devflow init re-adopts registry defaults and
418
+ * silently reverts everything the user confirmed). The non-TTY path degrades
419
+ * gracefully (status table only, no writes, no manifest required).
420
+ */
421
+ async function handleBare(claudeDir, devflowDir) {
422
+ // REL-H1: require both stdin AND stdout to be TTYs.
423
+ // Gating on process.stdout.isTTY alone lets `devflow flags < /dev/null` enter
424
+ // alt-screen while stdin ends immediately, leaving the terminal stranded with
425
+ // hidden cursor on exit. Precedent: agents.ts uses the same two-flag predicate.
426
+ if (process.stdin.isTTY && process.stdout.isTTY) {
427
+ // ── Manifest + settings required before the TUI may launch ──────────
428
+ // Reuses loadFlagContext — the same guard as --enable/--disable/--set/--unset.
429
+ // If the manifest is absent or unreadable, we refuse here and settings.json
430
+ // is never touched (avoids TS-H2 / ARCH-H2 / REL-H2 silent half-write).
431
+ const ctx = await loadFlagContext(claudeDir, devflowDir);
432
+ if (!ctx.ok) {
433
+ p.log.error(ctx.reason);
434
+ process.exitCode = 1;
435
+ return;
436
+ }
437
+ const record = ctx.value.manifest.features.flags;
438
+ // ── Build initial rows from registry + current record ──────────────
439
+ // buildFlagRows is a static import (pure — no TTY); only runFlagsTui is lazy.
440
+ const initialRows = buildFlagRows(record);
441
+ // ── Launch TUI ────────────────────────────────────────────────────
442
+ const { runFlagsTui } = await import('../flags-view/index.js');
443
+ // Wrap: runTui rejects on initial-render failure or handler throw.
444
+ // On rejection: log and bail — no settings write (avoids PF-014 process.exit).
445
+ let result;
446
+ try {
447
+ result = await runFlagsTui(initialRows);
448
+ }
449
+ catch (err) {
450
+ p.log.error(`Flags editor failed: ${err instanceof Error ? err.message : String(err)}`);
451
+ process.exitCode = 1;
452
+ return;
453
+ }
454
+ if (result.action === 'save') {
455
+ // REL-M3: re-read settings.json AFTER the human-paced TUI session closes.
456
+ // The read captured before runFlagsTui is a stale snapshot by the time the
457
+ // user saves — any concurrent writer (proxy enable, devflow agents, Claude
458
+ // Code /config) that ran during the session would be silently overwritten by
459
+ // the atomic rename in writeFileAtomicExclusive. Re-reading rebases the flag
460
+ // write onto current content and ensures convergeFlagsIntoSettings sees the
461
+ // fresh viewMode (applies PF-022 — file state, not config state, is reality).
462
+ const freshSettings = await readSettingsSafe(path.join(claudeDir, 'settings.json'));
463
+ if (!freshSettings.ok) {
464
+ p.log.error(freshSettings.reason);
465
+ process.exitCode = 1;
466
+ return;
467
+ }
468
+ const outcome = await applyTuiResult(result, freshSettings.content, ctx.value.manifest, claudeDir, devflowDir);
469
+ if (outcome === 'saved') {
470
+ p.outro(color.green('Flags saved.'));
471
+ }
472
+ // Error outcomes: persistFlagConfig already logged and set exitCode.
473
+ }
474
+ else {
475
+ p.outro(color.dim('No changes made.'));
476
+ }
477
+ }
478
+ else {
479
+ // non-TTY: status table — degrades gracefully without manifest (read-only).
480
+ const manifest = await readManifest(devflowDir);
481
+ const record = manifest?.features.flags ?? {};
482
+ for (const row of formatStatusRows(record)) {
483
+ process.stdout.write(`${row}\n`);
484
+ }
485
+ process.stderr.write('Note: interactive TUI requires a TTY. Use --enable/--disable/--set/--unset for mutations.\n');
486
+ process.exitCode = 1;
487
+ }
488
+ }
489
+ // ─── Command factory ──────────────────────────────────────────────────────────
490
+ /** Accumulator for repeatable --set options. */
491
+ function collectSet(val, prev) {
492
+ return prev.concat(val);
493
+ }
494
+ /**
495
+ * Create a fresh flags Command instance.
496
+ *
497
+ * Call this in tests to get a clean Commander instance per test case — avoids
498
+ * Commander's internal option-value state leaking across tests.
499
+ *
500
+ * Bare invocation (no subcommand):
501
+ * - TTY: launches the interactive flags TUI (lazy import keeps TTY machinery
502
+ * out of --list/--status code paths).
503
+ * - non-TTY: prints status table to stdout + note to stderr + exitCode 1.
504
+ */
505
+ export function createFlagsCommand() {
506
+ return new Command('flags')
507
+ .description('Manage Claude Code feature flags')
508
+ .option('--list', 'List all available flags with metadata')
509
+ .option('--status', 'Show current flag states')
510
+ .option('--enable <ids>', 'Enable boolean flag(s), comma-separated')
511
+ .option('--disable <ids>', 'Disable boolean flag(s), comma-separated')
512
+ .option('--set <assignment>', 'Set flag value (repeatable): id=value. Use "unset" as value to clear.', collectSet, [])
513
+ .option('--unset <ids>', 'Reset flag(s) to neutral (comma-separated)')
514
+ .action(async (options) => {
515
+ const claudeDir = getClaudeDirectory();
516
+ const devflowDir = getDevFlowDirectory();
517
+ const splitIds = (s) => s.split(',').map(t => t.trim()).filter(Boolean);
518
+ if (options.list)
519
+ return handleList();
520
+ if (options.status)
521
+ return handleStatus(devflowDir);
522
+ if (options.enable !== undefined)
523
+ return handleSetBooleans(claudeDir, devflowDir, splitIds(options.enable), true);
524
+ if (options.disable !== undefined)
525
+ return handleSetBooleans(claudeDir, devflowDir, splitIds(options.disable), false);
526
+ if (options.set && options.set.length > 0)
527
+ return handleSet(claudeDir, devflowDir, options.set);
528
+ if (options.unset !== undefined)
529
+ return handleUnset(claudeDir, devflowDir, splitIds(options.unset));
530
+ return handleBare(claudeDir, devflowDir);
531
+ });
532
+ }
533
+ // ─── Singleton export ─────────────────────────────────────────────────────────
534
+ //
535
+ // src/cli.ts imports this; end-to-end tests should use createFlagsCommand()
536
+ // instead to get a fresh instance per test.
537
+ export const flagsCommand = createFlagsCommand();
119
538
  //# sourceMappingURL=flags.js.map