@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.
- package/CHANGELOG.md +47 -0
- package/README.md +36 -8
- package/adapters/e2e/playwright-mcp.md +1 -1
- package/adapters/issue-tracker/_interface.md +1 -1
- package/adapters/verify/make.md +1 -1
- package/bin/flow.js +5 -4
- package/catalog.yaml +57 -8
- package/docs/adapters.md +7 -4
- package/docs/profiles.md +35 -6
- package/lib/catalog.js +9 -0
- package/lib/commands/doctor.js +85 -10
- package/lib/commands/init.js +346 -39
- package/lib/commands/install.js +6 -1
- package/lib/commands/plan.js +27 -2
- package/lib/commands/sprint.js +220 -0
- package/lib/commands/uninstall.js +81 -4
- package/lib/init/detect.js +307 -0
- package/lib/init/mcp.js +196 -0
- package/lib/init/migrate-bmad.js +491 -0
- package/lib/init/orchestrate.js +223 -0
- package/lib/init/questions.js +328 -0
- package/lib/init/recommendation.js +113 -0
- package/lib/init/scaffold.js +196 -0
- package/lib/init/secrets.js +234 -0
- package/lib/init/upstreams/bmad.js +96 -0
- package/lib/init/upstreams/caveman.js +110 -0
- package/lib/init/upstreams/common.js +181 -0
- package/lib/init/upstreams/ecc.js +120 -0
- package/lib/sprint/operations.js +201 -0
- package/lib/sprint/store.js +110 -0
- package/package.json +1 -1
- package/schemas/catalog.schema.json +1 -0
- package/skills/flow-doctor/workflow.md +1 -1
- package/skills/flow-init/workflow.md +28 -17
- package/skills/flow-sprint/workflow.md +1 -1
package/lib/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
378
|
-
console.log(chalk.dim(` -
|
|
379
|
-
console.log(chalk.dim(`
|
|
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(`
|
|
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
|
|
385
|
-
console.log(chalk.dim(`
|
|
386
|
-
console.log(chalk.dim(` -
|
|
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();
|
package/lib/commands/init.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
// lib/commands/init.js — `flow init`
|
|
1
|
+
// lib/commands/init.js — `flow init` entry point.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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);
|
|
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
|
-
//
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
console.log(chalk.dim('Dispatching to `claude` CLI…'));
|
|
199
|
+
let state;
|
|
66
200
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
}
|
package/lib/commands/install.js
CHANGED
|
@@ -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(
|
|
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}`);
|