@mhd-ghaith-abtah/flow 0.7.2-beta.0 → 0.8.0-beta.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.
@@ -49,6 +49,7 @@ export default async function doctor(args, ctx) {
49
49
  adapters: [],
50
50
  clis: [],
51
51
  upstreams: [],
52
+ collisions: [],
52
53
  };
53
54
 
54
55
  // Adapter + CLI checks depend on a parsed config.
@@ -60,6 +61,12 @@ export default async function doctor(args, ctx) {
60
61
  // Upstream presence — cheap detection from install-state.
61
62
  report.upstreams = probeUpstreams(ctx, report.state.home);
62
63
 
64
+ // Cross-scope collision checks (E7-004). Detects e.g. ECC installed at
65
+ // both ~/.claude/rules/ecc and <cwd>/.claude/rules/ecc, which happens
66
+ // when a user changes --ecc-scope without uninstalling the previous
67
+ // scope's content. Cheap fs.existsSync checks, no parsing required.
68
+ report.collisions = probeCollisions(ctx, report.state.home);
69
+
63
70
  if (json) {
64
71
  // Strip the internal `raw` catalog payload — JSON consumers want doctor
65
72
  // results, not the entire catalog dumped.
@@ -220,16 +227,65 @@ function probeUpstreams(ctx, homeState) {
220
227
  if (!homeState.parsed?.upstreams) return [];
221
228
  const out = [];
222
229
  for (const [name, rec] of Object.entries(homeState.parsed.upstreams)) {
223
- out.push({
230
+ const entry = {
224
231
  name,
225
232
  status: rec.installed ? '✓' : 'ℹ',
226
233
  subset: rec.subset,
227
234
  version: rec.version || 'not pinned',
228
- });
235
+ };
236
+ // Surface the Caveman fork status so users know we're shipping from a
237
+ // pinned fork tag pending JuliusBrussee/caveman#407 merging upstream.
238
+ // Hint level (ℹ) on purpose — it's expected state, not a problem.
239
+ if (name === 'caveman' && rec.source === 'npx-from-fork') {
240
+ entry.note = `installed from fork (${rec.fork_tag || 'pinned'}); tracking upstream PR #${rec.upstream_pr || 407}`;
241
+ }
242
+ out.push(entry);
229
243
  }
230
244
  return out;
231
245
  }
232
246
 
247
+ /**
248
+ * E7-004: detect ECC installed at both user-scope (~/.claude/rules/ecc)
249
+ * AND project-scope (<cwd>/.claude/rules/ecc). Happens when a user
250
+ * switches --ecc-scope without uninstalling the previous scope's content.
251
+ * Returns an array of collision entries; empty array when no drift.
252
+ *
253
+ * Probe is intentionally cheap (existsSync only, no parsing) so it adds
254
+ * negligible cost to every doctor run.
255
+ *
256
+ * @param {Object} ctx - doctor invocation context
257
+ * @param {Object} homeState - parsed home install-state (for the recorded scope)
258
+ * @returns {Array<{kind:string,status:string,detail:string,fix:string}>}
259
+ */
260
+ function probeCollisions(ctx, homeState) {
261
+ const collisions = [];
262
+ const homeDir = ctx.home || process.env.HOME;
263
+ if (!homeDir) return collisions;
264
+ const cwd = ctx.cwd || process.cwd();
265
+
266
+ const userEccDir = join(homeDir, '.claude', 'rules', 'ecc');
267
+ const projectEccDir = join(cwd, '.claude', 'rules', 'ecc');
268
+ const userPresent = existsSync(userEccDir);
269
+ const projectPresent = existsSync(projectEccDir);
270
+
271
+ if (userPresent && projectPresent) {
272
+ const recordedScope = homeState?.parsed?.upstreams?.ecc?.install_scope || null;
273
+ const expected = recordedScope === 'project' ? projectEccDir : userEccDir;
274
+ const stale = recordedScope === 'project' ? userEccDir : projectEccDir;
275
+ const scopeLabel = recordedScope ?? 'unknown (no install_scope recorded)';
276
+ collisions.push({
277
+ kind: 'ecc-scope-collision',
278
+ status: '⚠',
279
+ detail: `ECC content found at BOTH ${userEccDir} and ${projectEccDir}. Recorded scope: ${scopeLabel}.`,
280
+ fix: recordedScope
281
+ ? `Active install is at ${expected}. Remove the stale one (likely safe): rm -rf ${stale}`
282
+ : `No install_scope recorded — re-run \`/flow-init --update\` or \`flow install --ecc-scope <user|project>\` so the state file knows which scope owns the install, then remove whichever path doesn't match.`,
283
+ });
284
+ }
285
+
286
+ return collisions;
287
+ }
288
+
233
289
  function countSeverities(report) {
234
290
  const counts = { ok: 0, info: 0, warn: 0, fail: 0 };
235
291
  const items = [
@@ -240,6 +296,7 @@ function countSeverities(report) {
240
296
  ...report.adapters,
241
297
  ...report.clis,
242
298
  ...report.upstreams,
299
+ ...(report.collisions || []),
243
300
  ];
244
301
  for (const item of items) {
245
302
  if (!item) continue;
@@ -288,6 +345,16 @@ function renderHuman(report, { verbose }) {
288
345
  lines.push('Upstreams:');
289
346
  for (const u of report.upstreams) {
290
347
  lines.push(` ${u.name}: ${tag(u.status)} subset=${u.subset || '—'} version=${u.version}`);
348
+ if (u.note) lines.push(` ${chalk.dim(u.note)}`);
349
+ }
350
+ }
351
+
352
+ if (report.collisions && report.collisions.length > 0) {
353
+ lines.push('');
354
+ lines.push('Collisions:');
355
+ for (const col of report.collisions) {
356
+ lines.push(` ${col.kind}: ${tag(col.status)} ${col.detail}`);
357
+ lines.push(` ${chalk.dim('Fix: ' + col.fix)}`);
291
358
  }
292
359
  }
293
360
 
@@ -371,19 +438,27 @@ async function runRepairUpstream(name, ctx) {
371
438
  console.log(chalk.dim(` - npx will install the exact pinned version.`));
372
439
  console.log(chalk.dim(` - If the pinned version was a commit hash (e.g. "unknown@<date>"), use git checkout in _bmad/ instead.`));
373
440
  } else if (name === 'ecc') {
374
- console.log(` ${chalk.cyan(`cd ~/.claude/rules && git fetch --all && git checkout ${record.version}`)}`);
441
+ const scope = record.install_scope || 'user';
442
+ const target = scope === 'project' ? 'claude-project' : 'claude';
443
+ const rulesDir = scope === 'project' ? '<projectRoot>/.claude/rules' : '~/.claude/rules';
444
+ console.log(` ${chalk.cyan(`cd ${rulesDir} && git fetch --all && git checkout ${record.version}`)}`);
375
445
  console.log();
376
446
  console.log(chalk.dim(' Notes:'));
377
- console.log(chalk.dim(` - ECC ships as a git checkout under ~/.claude/rules. The pinned value is a git ref.`));
378
- console.log(chalk.dim(` - If ~/.claude/rules/ isn't a git checkout, re-run the ECC installer:`));
379
- console.log(chalk.dim(` npx @everything-claude-code/ecc install --target claude --profile ${record.subset}`));
447
+ console.log(chalk.dim(` - ECC ships as a git checkout under ${rulesDir}. The pinned value is a git ref.`));
448
+ console.log(chalk.dim(` - Recorded install scope: ${scope} (target: --target ${target}).`));
449
+ console.log(chalk.dim(` - If ${rulesDir}/ isn't a git checkout, re-run the ECC installer:`));
450
+ console.log(chalk.dim(` npx -y -p "github:affaan-m/ECC#98bd5174" ecc-install --target ${target} --profile ${record.subset}`));
451
+ console.log(chalk.dim(` (Pinned to ECC main post-merge of #2006 until ecc-universal@2.x lands on npm.)`));
380
452
  } else if (name === 'caveman') {
381
- console.log(` ${chalk.cyan(`curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash`)}`);
453
+ console.log(` ${chalk.cyan(`npx -y "github:mhd-ghaith-abtah/caveman#flow-pin-v0.1"`)}`);
382
454
  console.log();
383
455
  console.log(chalk.dim(' Notes:'));
384
- console.log(chalk.dim(` - Caveman ships only via curl-pipe-bash. The pinned version (${record.version}) is informational; the installer always lands on main.`));
385
- console.log(chalk.dim(` - To inspect first: set FLOW_INSPECT_INSTALL_SCRIPTS=1 and re-run /flow-init.`));
386
- console.log(chalk.dim(` - Local hook patches (e.g., project-scope from PR #407) at ~/.claude/hooks/caveman-*.pre-scope-patch will be overwritten — reapply them after.`));
456
+ console.log(chalk.dim(` - Flow installs Caveman from a temporary fork (mhd-ghaith-abtah/caveman @ flow-pin-v0.1).`));
457
+ console.log(chalk.dim(` Fork = upstream main + JuliusBrussee/caveman#407 (project-scope gating) patches.`));
458
+ console.log(chalk.dim(` - Pinned version: ${record.version}. When #407 merges upstream Flow will swap back.`));
459
+ console.log(chalk.dim(` - To inspect first: gh repo view mhd-ghaith-abtah/caveman --branch flow-pin-v0.1`));
460
+ console.log(chalk.dim(` or set FLOW_INSPECT_INSTALL_SCRIPTS=1 and re-run /flow-init.`));
461
+ console.log(chalk.dim(` - Track upstream merge status: gh pr view 407 --repo JuliusBrussee/caveman`));
387
462
  }
388
463
 
389
464
  console.log();
@@ -1,19 +1,38 @@
1
- // lib/commands/init.js — `flow init` headless entry point.
1
+ // lib/commands/init.js — `flow init` entry point.
2
2
  //
3
- // v0.7 scope: the *interactive* installer lives in skills/flow-init/workflow.md
4
- // (Claude Code reads the workflow and runs it). The headless `flow init` CLI
5
- // path is intentionally thin — it prints the plan, then either:
6
- // • dispatches to `claude` CLI to run /flow-init (if `claude` is in $PATH), or
7
- // • prints clear instructions for running /flow-init manually.
3
+ // Two paths:
8
4
  //
9
- // Why thin: porting the full interactive workflow to Node duplicates ~800 LOC
10
- // of decision logic already in workflow.md. Better to have one source of truth.
5
+ // 1. **Interactive (default inside Claude Code):** the rich Q&A lives
6
+ // in skills/flow-init/workflow.md. The CLI just prints the
7
+ // slash-command nudge so the user runs /flow-init in-session
8
+ // where the LLM can drive the prompts, error recovery, and
9
+ // multi-step ceremony.
10
+ //
11
+ // 2. **Headless (--yes outside Claude Code, npx-first):** chain
12
+ // detect → questions (pre-populated) → upstream dispatch → MCP
13
+ // registration → optional BMad migration → scaffold the project
14
+ // via lib/init/orchestrate.js. This is the v0.8 npx-first install
15
+ // path. Tests + CI scripts hit this branch.
16
+ //
17
+ // --dry-run shows the plan + halts before any execution in either path.
11
18
 
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { resolve, dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
12
22
  import chalk from 'chalk';
13
23
  import { execa } from 'execa';
14
- import { loadCatalog, resolveProfile } from '../catalog.js';
24
+ import { loadCatalog } from '../catalog.js';
15
25
  import { resolveRepoRoot } from '../repo-root.js';
16
26
  import plan from './plan.js';
27
+ import { runInit, defaultAnswersForProfile } from '../init/orchestrate.js';
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const PKG_PATH = resolve(__dirname, '..', '..', 'package.json');
31
+
32
+ function readFlowVersion() {
33
+ try { return JSON.parse(readFileSync(PKG_PATH, 'utf8')).version; }
34
+ catch { return 'unknown'; }
35
+ }
17
36
 
18
37
  /**
19
38
  * @param {Object} args
@@ -21,11 +40,29 @@ import plan from './plan.js';
21
40
  */
22
41
  export default async function init(args, ctx) {
23
42
  const repoRoot = ctx.repoRoot ?? resolveRepoRoot(import.meta.url);
24
- loadCatalog(repoRoot); // validate parseable; throws on bad catalog
25
-
43
+ const catalog = loadCatalog(repoRoot);
26
44
  const profileName = args.profile ?? 'standard';
27
45
  const yes = Boolean(args.yes);
28
46
  const dryRun = Boolean(args['dry-run']);
47
+ const cwd = ctx.cwd || process.cwd();
48
+
49
+ // Repair mode: skip everything except scaffold. Useful when the user
50
+ // accidentally deleted docs/flow/sprint.yaml or .claude/flow.config.yaml
51
+ // and wants Flow to put them back without re-running upstream installers
52
+ // (which would re-fetch BMad/ECC/Caveman from network for no reason).
53
+ if (args.repair) {
54
+ return runRepair({ catalog, cwd, args });
55
+ }
56
+
57
+ // Update mode: re-run the chain against an existing install. Reads
58
+ // recorded answers from install-state.json, lets CLI flags override
59
+ // (e.g. swap profile, change --ecc-scope), then runs the chain. The
60
+ // upstream dispatchers + mcp.js are already idempotent (skip when
61
+ // already-installed), so --update is mostly about computing the
62
+ // delta + force-rewriting flow.config.yaml.
63
+ if (args.update) {
64
+ return runUpdate({ catalog, cwd, args });
65
+ }
29
66
 
30
67
  // Always show the plan first.
31
68
  console.log(chalk.dim(`Resolving plan for profile '${profileName}'…`));
@@ -33,54 +70,324 @@ export default async function init(args, ctx) {
33
70
  await plan({ ...args, profile: profileName }, ctx);
34
71
  console.log();
35
72
 
36
- if (dryRun) {
73
+ if (dryRun && !yes) {
74
+ // dry-run without --yes is plan-only.
37
75
  console.log(chalk.dim('(--dry-run: stopping before execution)'));
38
76
  return 0;
39
77
  }
40
78
 
41
- // Inside Claude Code: just tell the user to run /flow-init. Don't fork.
79
+ // Headless path: --yes (or --yes + --dry-run for the integration-test path).
80
+ if (yes) {
81
+ return runHeadless({ catalog, cwd, profileName, args, dryRun });
82
+ }
83
+
84
+ // Interactive path inside Claude Code: nudge to slash command.
42
85
  if (ctx.insideClaudeCode) {
43
86
  console.log(chalk.bold('Inside Claude Code — run the slash command:'));
44
87
  console.log(` ${chalk.cyan('/flow-init')}${profileName !== 'standard' ? ` --profile ${profileName}` : ''}`);
45
88
  return 0;
46
89
  }
47
90
 
48
- // Outside Claude Code: try to dispatch to `claude` CLI.
49
- if (!yes) {
50
- console.log(chalk.yellow('?'), `Run the interactive installer via the \`claude\` CLI? [y/N]`);
51
- console.log(chalk.dim(' (Re-run with --yes to skip this prompt, or use --dry-run to preview only.)'));
52
- return 0; // Non-interactive default: stop. CI scripts use --yes.
91
+ // Outside Claude Code, no --yes: show both paths.
92
+ console.log(chalk.bold('Choose an installation path:'));
93
+ console.log();
94
+ console.log(` ${chalk.cyan('flow init --profile ' + profileName + ' --yes')} # headless install (this CLI)`);
95
+ console.log(` ${chalk.cyan('claude')} ${chalk.cyan('/flow-init')} # interactive install (Claude Code)`);
96
+ console.log();
97
+ console.log(chalk.dim('Re-run with --dry-run for a preview, or --yes to install headlessly.'));
98
+ return 0;
99
+ }
100
+
101
+ /**
102
+ * Run the orchestrator end-to-end with pre-populated answers from the
103
+ * resolved profile + CLI overrides.
104
+ */
105
+ async function runHeadless({ catalog, cwd, profileName, args, dryRun }) {
106
+ // Pre-populate every Q&A slot from the profile defaults so askAll()
107
+ // never fires an interactive prompt. Honor --ecc-scope override.
108
+ const overrides = {};
109
+ if (args['ecc-scope']) overrides.eccScope = args['ecc-scope'];
110
+ if (args['bmad-subset']) overrides.bmadSubset = args['bmad-subset'];
111
+ if (args['ecc-subset']) overrides.eccSubset = args['ecc-subset'];
112
+ const cliAnswers = defaultAnswersForProfile(catalog, profileName, overrides);
113
+
114
+ console.log(chalk.bold('━━━ flow init (headless) ━━━'));
115
+ console.log(chalk.dim(` profile=${profileName} cwd=${cwd} dry-run=${dryRun}`));
116
+ console.log();
117
+
118
+ const result = await runInit({
119
+ cwd,
120
+ catalog,
121
+ flowVersion: readFlowVersion(),
122
+ cliAnswers,
123
+ dryRun,
124
+ force: Boolean(args.force),
125
+ continueOnUpstreamError: Boolean(args['continue-on-error']),
126
+ });
127
+
128
+ if (!result.ok) {
129
+ console.error(chalk.red(`✗ init halted: ${result.haltReason}`));
130
+ for (const [name, r] of Object.entries(result.upstreamResults)) {
131
+ if (r && !r.ok && r.error) console.error(chalk.dim(` ${name}: ${r.error}`));
132
+ }
133
+ return 1;
134
+ }
135
+
136
+ // Render the manifest.
137
+ console.log(chalk.bold('Upstream installers:'));
138
+ for (const [name, r] of Object.entries(result.upstreamResults)) {
139
+ const tag = r.stateRecord?.skipped ? chalk.dim('skipped') : (r.ok ? chalk.green('✓') : chalk.red('✗'));
140
+ console.log(` ${name}: ${tag} ${chalk.dim(r.command?.source || '')}`);
141
+ }
142
+ console.log();
143
+ if (result.mcpResults.length > 0) {
144
+ console.log(chalk.bold('MCPs:'));
145
+ for (const m of result.mcpResults) {
146
+ const tag = m.stateRecord?.skipped ? chalk.dim('already-registered') : (m.ok ? chalk.green('✓') : chalk.red('✗'));
147
+ console.log(` ${m.id}: ${tag}`);
148
+ }
149
+ console.log();
53
150
  }
151
+ if (result.secretsResult && result.secretsResult.store !== 'skipped') {
152
+ console.log(chalk.bold('Secrets:'));
153
+ if (result.secretsResult.ok) {
154
+ const tag = result.secretsResult.store === 'env-file' ? chalk.green('✓') : chalk.dim('printed');
155
+ const where = result.secretsResult.path
156
+ ? chalk.dim(` → ${result.secretsResult.path}`)
157
+ : chalk.dim(` (${result.secretsResult.store} — copy lines above)`);
158
+ console.log(` ${tag} ${result.secretsResult.envVarsWritten.length} var(s) for ${result.secretsResult.mcpsCovered.join(', ')}${where}`);
159
+ } else {
160
+ console.log(` ${chalk.red('✗')} ${result.secretsResult.error}`);
161
+ }
162
+ console.log();
163
+ }
164
+ if (result.migrationResult) {
165
+ console.log(chalk.bold('BMad migration:'));
166
+ console.log(` ${chalk.green('✓')} ${result.migrationResult.storiesImported} stories, ${result.migrationResult.epicsImported} epics`);
167
+ console.log();
168
+ }
169
+ console.log(chalk.bold('Files:'));
170
+ for (const p of result.scaffoldManifest.written) console.log(` ${chalk.green('+')} ${p}`);
171
+ for (const p of result.scaffoldManifest.skipped) console.log(` ${chalk.dim('=')} ${p} ${chalk.dim('(exists; use --force to overwrite)')}`);
172
+ console.log();
173
+ console.log(chalk.green('✓ flow init complete'));
174
+ if (dryRun) console.log(chalk.yellow(' (--dry-run: nothing was actually written)'));
175
+ return 0;
176
+ }
54
177
 
55
- const claudeAvailable = await hasClaudeCli();
56
- if (!claudeAvailable) {
57
- console.error(chalk.yellow('⚠'), 'The `claude` CLI is not on $PATH.');
58
- console.error(' Install Claude Code (https://claude.com/claude-code), then:');
59
- console.error(` ${chalk.cyan('claude')} # start a session`);
60
- console.error(` ${chalk.cyan('/flow-init')} # inside the session`);
61
- return 2;
178
+ /**
179
+ * Repair mode: recreate missing project-scope scaffold files
180
+ * (.claude/flow.config.yaml, docs/flow/sprint.yaml, deferred.md,
181
+ * .claude/flow/install-state.json) without re-running upstream
182
+ * installers or MCPs. Reads the profile + answers from the existing
183
+ * install-state.json so the regenerated config matches what the user
184
+ * picked at install time.
185
+ *
186
+ * Refuses to run if no install-state.json exists — there's nothing
187
+ * authoritative to repair from. User should run `flow init --yes`
188
+ * instead.
189
+ */
190
+ async function runRepair({ catalog, cwd, args }) {
191
+ const statePath = join(cwd, '.claude', 'flow', 'install-state.json');
192
+ if (!existsSync(statePath)) {
193
+ console.error(chalk.red('✗ flow init --repair: no .claude/flow/install-state.json found'));
194
+ console.error(chalk.dim(' Repair needs a prior install to read profile + answers from.'));
195
+ console.error(chalk.dim(' Run `flow init --profile <name> --yes` to do a fresh install instead.'));
196
+ return 1;
62
197
  }
63
198
 
64
- // Dispatch: open `claude` with /flow-init prefilled.
65
- console.log(chalk.dim('Dispatching to `claude` CLI…'));
199
+ let state;
66
200
  try {
67
- await execa('claude', ['/flow-init', ...(profileName !== 'standard' ? ['--profile', profileName] : [])], {
68
- stdio: 'inherit',
69
- env: { ...process.env, FLOW_REPO_ROOT: repoRoot }
70
- });
201
+ state = JSON.parse(readFileSync(statePath, 'utf8'));
202
+ } catch (err) {
203
+ console.error(chalk.red(`✗ flow init --repair: failed to parse install-state.json: ${err.message}`));
204
+ return 2;
205
+ }
206
+ if (!state.profile || !state.answers) {
207
+ console.error(chalk.red('✗ flow init --repair: install-state.json missing required profile/answers fields'));
208
+ return 2;
209
+ }
210
+
211
+ const profileName = state.profile;
212
+ const answers = state.answers;
213
+ const dryRun = Boolean(args['dry-run']);
214
+
215
+ console.log(chalk.bold('━━━ flow init (repair) ━━━'));
216
+ console.log(chalk.dim(` profile=${profileName} cwd=${cwd} dry-run=${dryRun}`));
217
+ console.log();
218
+
219
+ // Import scaffold lazily so the regular init path doesn't pay for it.
220
+ const { scaffold } = await import('../init/scaffold.js');
221
+ const { resolveProfile } = await import('../catalog.js');
222
+ const resolved = resolveProfile(catalog, profileName);
223
+
224
+ const manifest = scaffold({
225
+ cwd,
226
+ profile: profileName,
227
+ answers,
228
+ resolvedProfile: resolved,
229
+ catalog,
230
+ flowVersion: readFlowVersion(),
231
+ upstreamResults: state.upstreams || {},
232
+ migrationResult: state.migration || null,
233
+ }, { dryRun, force: false });
234
+
235
+ if (manifest.written.length === 0 && manifest.skipped.length > 0 && manifest.dirs.length === 0) {
236
+ console.log(chalk.green('✓ Nothing to repair — all scaffold files already present.'));
71
237
  return 0;
238
+ }
239
+
240
+ console.log(chalk.bold('Files:'));
241
+ for (const p of manifest.written) console.log(` ${chalk.green('+')} ${p} ${chalk.dim('(recreated)')}`);
242
+ for (const p of manifest.skipped) console.log(` ${chalk.dim('=')} ${p} ${chalk.dim('(present, untouched)')}`);
243
+ if (manifest.dirs.length > 0) {
244
+ for (const d of manifest.dirs) console.log(` ${chalk.green('+')} ${d}/ ${chalk.dim('(directory created)')}`);
245
+ }
246
+ console.log();
247
+ console.log(chalk.green('✓ flow init --repair complete'));
248
+ if (dryRun) console.log(chalk.yellow(' (--dry-run: nothing was actually written)'));
249
+ return 0;
250
+ }
251
+
252
+ /**
253
+ * Update mode: re-run the install chain against an existing install,
254
+ * with CLI flags taking precedence over recorded answers. The chain
255
+ * itself is idempotent (upstream dispatchers + mcp.js skip on already-
256
+ * installed state), so most of this function is about loading the prior
257
+ * state, computing the delta, and force-rewriting flow.config.yaml so
258
+ * the on-disk config reflects whatever changed.
259
+ *
260
+ * Behavior:
261
+ * - No install-state.json → exit 1 (nothing to update from; hint
262
+ * toward `flow init --yes` for a fresh install).
263
+ * - install_scope change (user → project or vice-versa) → exit 1
264
+ * with hint. Scope swaps need uninstall + reinstall because the
265
+ * filesystem layout is fundamentally different; mid-flight swap
266
+ * would leave stale content at the old scope's location.
267
+ * - Otherwise: run the chain with the new resolved answers + force=true
268
+ * on scaffold. Emit a delta summary at the end.
269
+ */
270
+ async function runUpdate({ catalog, cwd, args }) {
271
+ const statePath = join(cwd, '.claude', 'flow', 'install-state.json');
272
+ if (!existsSync(statePath)) {
273
+ console.error(chalk.red('✗ flow init --update: no .claude/flow/install-state.json found'));
274
+ console.error(chalk.dim(' Update needs a prior install to read from.'));
275
+ console.error(chalk.dim(' Run `flow init --profile <name> --yes` to do a fresh install.'));
276
+ return 1;
277
+ }
278
+
279
+ let state;
280
+ try {
281
+ state = JSON.parse(readFileSync(statePath, 'utf8'));
72
282
  } catch (err) {
73
- if (err.exitCode != null) return err.exitCode;
74
- console.error(chalk.red(`✗ Failed to launch claude: ${err.message}`));
283
+ console.error(chalk.red(`✗ flow init --update: failed to parse install-state.json: ${err.message}`));
284
+ return 2;
285
+ }
286
+ if (!state.profile || !state.answers) {
287
+ console.error(chalk.red('✗ flow init --update: install-state.json missing required profile/answers fields'));
288
+ return 2;
289
+ }
290
+
291
+ const prevProfile = state.profile;
292
+ const prevAnswers = state.answers;
293
+ const dryRun = Boolean(args['dry-run']);
294
+
295
+ // Apply CLI overrides on top of recorded answers. We DON'T re-derive
296
+ // from defaultAnswersForProfile because the user may have customized
297
+ // individual adapter / subset values via --with / --without at install
298
+ // time and we shouldn't blow those away.
299
+ const newProfileName = args.profile || prevProfile;
300
+ const newAnswers = { ...prevAnswers, profile: newProfileName };
301
+ if (args['ecc-scope']) newAnswers.eccScope = args['ecc-scope'];
302
+ if (args['bmad-subset']) newAnswers.bmadSubset = args['bmad-subset'];
303
+ if (args['ecc-subset']) newAnswers.eccSubset = args['ecc-subset'];
304
+
305
+ // If the profile changed, re-derive adapter/subset defaults from the
306
+ // new profile UNLESS the user explicitly overrode them. This is the
307
+ // "swap profile" use case (e.g. mini → team) where the user expects
308
+ // team's adapters + subsets to take effect.
309
+ if (newProfileName !== prevProfile) {
310
+ const newDefaults = defaultAnswersForProfile(catalog, newProfileName);
311
+ for (const key of ['issueTracker', 'pr', 'e2e', 'verify', 'bmadSubset', 'eccSubset', 'eccScope', 'cavemanSubset']) {
312
+ // Only update if the user didn't already pin it via CLI override above.
313
+ const cliKey = ({
314
+ eccScope: 'ecc-scope', bmadSubset: 'bmad-subset', eccSubset: 'ecc-subset',
315
+ })[key];
316
+ if (cliKey && args[cliKey]) continue;
317
+ newAnswers[key] = newDefaults[key];
318
+ }
319
+ }
320
+
321
+ // Hard halt on install_scope change — too destructive to do mid-flight.
322
+ if (prevAnswers.eccScope && newAnswers.eccScope !== prevAnswers.eccScope) {
323
+ console.error(chalk.red(`✗ flow init --update: install_scope change (${prevAnswers.eccScope} → ${newAnswers.eccScope}) is not supported mid-flight`));
324
+ console.error(chalk.dim(' Scope swaps need uninstall + reinstall to avoid stale content at the old location.'));
325
+ console.error(chalk.dim(' Suggested:'));
326
+ console.error(chalk.dim(` flow uninstall --execute --yes${prevAnswers.eccScope === 'project' ? ' --remove-project-ecc' : ''}`));
327
+ console.error(chalk.dim(` flow init --profile ${newProfileName} --ecc-scope ${newAnswers.eccScope} --yes`));
328
+ return 1;
329
+ }
330
+
331
+ // Compute + show the delta before running anything.
332
+ const deltas = computeDeltas(prevAnswers, newAnswers);
333
+
334
+ console.log(chalk.bold('━━━ flow init (update) ━━━'));
335
+ console.log(chalk.dim(` was: profile=${prevProfile} → now: profile=${newProfileName} cwd=${cwd} dry-run=${dryRun}`));
336
+ console.log();
337
+ if (deltas.length === 0) {
338
+ console.log(chalk.green('✓ No changes — install matches the requested state.'));
339
+ console.log(chalk.dim(' (Re-run with --force to refresh flow.config.yaml anyway.)'));
340
+ if (!args.force) return 0;
341
+ } else {
342
+ console.log(chalk.bold('Changes:'));
343
+ for (const d of deltas) console.log(` ${chalk.yellow('Δ')} ${d.key}: ${chalk.dim(`${formatVal(d.from)} → ${formatVal(d.to)}`)}`);
344
+ console.log();
345
+ }
346
+
347
+ const result = await runInit({
348
+ cwd,
349
+ catalog,
350
+ flowVersion: readFlowVersion(),
351
+ cliAnswers: newAnswers,
352
+ dryRun,
353
+ // Force scaffold rewrite so flow.config.yaml picks up the new answers.
354
+ // Without this, the existing flow.config.yaml would be preserved and
355
+ // the on-disk config would drift from install-state.json.
356
+ force: true,
357
+ continueOnUpstreamError: Boolean(args['continue-on-error']),
358
+ });
359
+
360
+ if (!result.ok) {
361
+ console.error(chalk.red(`✗ update halted: ${result.haltReason}`));
75
362
  return 1;
76
363
  }
364
+
365
+ console.log(chalk.green('✓ flow init --update complete'));
366
+ if (dryRun) console.log(chalk.yellow(' (--dry-run: nothing was actually written)'));
367
+ return 0;
77
368
  }
78
369
 
79
- async function hasClaudeCli() {
80
- try {
81
- await execa('which', ['claude']);
82
- return true;
83
- } catch {
84
- return false;
370
+ /**
371
+ * Compute a flat list of {key, from, to} deltas between two answer sets.
372
+ * Used by --update to surface what changed before running the chain.
373
+ *
374
+ * @param {Object} prev
375
+ * @param {Object} next
376
+ * @returns {Array<{key: string, from: any, to: any}>}
377
+ */
378
+ function computeDeltas(prev, next) {
379
+ const out = [];
380
+ const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
381
+ for (const key of keys) {
382
+ if (prev[key] !== next[key]) {
383
+ out.push({ key, from: prev[key], to: next[key] });
384
+ }
85
385
  }
386
+ return out;
387
+ }
388
+
389
+ function formatVal(v) {
390
+ if (v == null) return '∅';
391
+ if (typeof v === 'string') return v;
392
+ return JSON.stringify(v);
86
393
  }
@@ -26,6 +26,7 @@ import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, readd
26
26
  import { resolve, join, dirname } from 'node:path';
27
27
  import chalk from 'chalk';
28
28
  import { loadCatalog, resolveProfile, listProfiles } from '../catalog.js';
29
+ import { applyEccScopeOverride } from './plan.js';
29
30
  import { resolveRepoRoot } from '../repo-root.js';
30
31
 
31
32
  /**
@@ -44,6 +45,10 @@ export default async function install(args, ctx) {
44
45
  }
45
46
 
46
47
  const profile = resolveProfile(catalog, profileName);
48
+ // CLI --ecc-scope overrides the profile default (E7-002 follow-up).
49
+ // Validates against user|project; throws on typo to avoid silent
50
+ // fallback to profile default.
51
+ const eccScope = applyEccScopeOverride(profile.ecc_install_scope, args['ecc-scope']);
47
52
  const yes = Boolean(args.yes);
48
53
  const dryRun = Boolean(args['dry-run']);
49
54
  const scope = args.scope ?? 'both';
@@ -83,7 +88,7 @@ export default async function install(args, ctx) {
83
88
  console.log(`Adapters: ${profile.adapters.length} (${profile.adapters.join(', ')})`);
84
89
  console.log(`MCPs: ${profile.mcps.length} (${profile.mcps.join(', ') || chalk.dim('none')})`);
85
90
  console.log(`BMad subset: ${profile.bmad_subset} ${chalk.dim('(this CLI will NOT run BMad installer)')}`);
86
- console.log(`ECC subset: ${profile.ecc_subset} ${chalk.dim('(this CLI will NOT run ECC installer)')}`);
91
+ console.log(`ECC subset: ${profile.ecc_subset} ${chalk.dim(`(scope: ${eccScope} — this CLI will NOT run ECC installer)`)}`);
87
92
  console.log(`Caveman: ${profile.caveman_subset} ${chalk.dim('(this CLI will NOT run Caveman installer)')}`);
88
93
  console.log();
89
94
  console.log(`Operations: ${operations.length}`);