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
|
@@ -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,
|
|
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
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
41
|
+
async function readSettingsSafe(settingsPath) {
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await fs.readFile(settingsPath, 'utf-8');
|
|
16
45
|
}
|
|
17
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
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
|
|
144
|
+
async function loadFlagContext(claudeDir, devflowDir) {
|
|
41
145
|
const manifest = await readManifest(devflowDir);
|
|
42
|
-
if (!manifest)
|
|
43
|
-
return;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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}
|
|
368
|
+
p.log.success(`${id} unset`);
|
|
113
369
|
}
|
|
114
|
-
return;
|
|
115
370
|
}
|
|
116
|
-
|
|
117
|
-
|
|
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
|