@mhd-ghaith-abtah/flow 0.7.2-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 +87 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/adapters/e2e/_interface.md +42 -0
- package/adapters/e2e/none.md +15 -0
- package/adapters/e2e/playwright-mcp.md +86 -0
- package/adapters/issue-tracker/_interface.md +46 -0
- package/adapters/issue-tracker/github-issues.md +103 -0
- package/adapters/issue-tracker/linear.md +65 -0
- package/adapters/issue-tracker/none.md +26 -0
- package/adapters/pr/_interface.md +29 -0
- package/adapters/pr/github.md +61 -0
- package/adapters/pr/none.md +32 -0
- package/adapters/verify/_interface.md +26 -0
- package/adapters/verify/custom.md +27 -0
- package/adapters/verify/make.md +30 -0
- package/adapters/verify/pnpm.md +27 -0
- package/bin/flow.js +129 -0
- package/catalog.yaml +364 -0
- package/docs/adapters.md +99 -0
- package/docs/migrate-from-bmad.md +95 -0
- package/docs/profiles.md +81 -0
- package/docs/quickstart.md +82 -0
- package/lib/catalog.js +164 -0
- package/lib/commands/add.js +147 -0
- package/lib/commands/doctor.js +392 -0
- package/lib/commands/init.js +86 -0
- package/lib/commands/install.js +181 -0
- package/lib/commands/plan.js +108 -0
- package/lib/commands/remove.js +87 -0
- package/lib/commands/uninstall.js +157 -0
- package/lib/repo-root.js +53 -0
- package/package.json +62 -0
- package/schemas/catalog.schema.json +155 -0
- package/schemas/flow-config.schema.json +85 -0
- package/schemas/install-state.schema.json +79 -0
- package/skills/flow-doctor/SKILL.md +15 -0
- package/skills/flow-doctor/workflow.md +157 -0
- package/skills/flow-init/SKILL.md +10 -0
- package/skills/flow-init/workflow.md +420 -0
- package/skills/flow-sprint/SKILL.md +10 -0
- package/skills/flow-sprint/workflow.md +394 -0
- package/skills/flow-story/SKILL.md +12 -0
- package/skills/flow-story/workflow.md +531 -0
- package/templates/claude-md-section.md.tmpl +55 -0
- package/templates/flow-readme.md.tmpl +34 -0
- package/templates/flow.config.yaml.tmpl +94 -0
- package/templates/pr.md.tmpl +40 -0
- package/templates/retro.md.tmpl +58 -0
- package/templates/sprint.yaml.tmpl +18 -0
- package/templates/story.md.tmpl +35 -0
- package/tools/fix-caveman-shrink.sh +68 -0
- package/tools/lint-changelog.js +98 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// lib/commands/doctor.js — `flow doctor` health check CLI.
|
|
2
|
+
//
|
|
3
|
+
// Headless equivalent of the interactive /flow-doctor skill. Runs the subset
|
|
4
|
+
// of checks that don't require an LLM in the loop:
|
|
5
|
+
// - catalog.yaml parses + validates against schema
|
|
6
|
+
// - install-state.json exists + parses at each scope
|
|
7
|
+
// - flow.config.yaml exists + parses + has required keys
|
|
8
|
+
// - adapter files present at their expected paths
|
|
9
|
+
// - required CLIs in $PATH (per active adapters)
|
|
10
|
+
//
|
|
11
|
+
// LLM-dependent checks (probing MCP responsiveness via tool calls, parsing
|
|
12
|
+
// recent review notes for severity labels) stay in the skill. CLI just gives
|
|
13
|
+
// scriptable yes/no for CI gates.
|
|
14
|
+
//
|
|
15
|
+
// Exit codes:
|
|
16
|
+
// 0 — all ✓ or only ℹ
|
|
17
|
+
// 1 — at least one ⚠
|
|
18
|
+
// 2 — at least one ✗
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, statSync, lstatSync } from 'node:fs';
|
|
21
|
+
import { resolve, join } from 'node:path';
|
|
22
|
+
import chalk from 'chalk';
|
|
23
|
+
import { execaSync } from 'execa';
|
|
24
|
+
import { parse as parseYaml } from 'yaml';
|
|
25
|
+
import { loadCatalog } from '../catalog.js';
|
|
26
|
+
import { resolveRepoRoot } from '../repo-root.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} args - yargs-parser args
|
|
30
|
+
* @param {Object} ctx
|
|
31
|
+
*/
|
|
32
|
+
export default async function doctor(args, ctx) {
|
|
33
|
+
const repoRoot = ctx.repoRoot ?? resolveRepoRoot(import.meta.url);
|
|
34
|
+
const json = Boolean(args.json);
|
|
35
|
+
const verbose = Boolean(args.verbose);
|
|
36
|
+
const repairUpstream = args['repair-upstream'];
|
|
37
|
+
|
|
38
|
+
// --repair-upstream <name>: side-quest before the normal probe. Reads the
|
|
39
|
+
// pinned version from install-state and prints the commands to reinstall it
|
|
40
|
+
// (does NOT auto-run; upstream installs touch user-scope state).
|
|
41
|
+
if (repairUpstream) {
|
|
42
|
+
return runRepairUpstream(repairUpstream, ctx);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const report = {
|
|
46
|
+
catalog: probeCatalog(repoRoot),
|
|
47
|
+
state: probeState(ctx),
|
|
48
|
+
config: probeConfig(ctx),
|
|
49
|
+
adapters: [],
|
|
50
|
+
clis: [],
|
|
51
|
+
upstreams: [],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Adapter + CLI checks depend on a parsed config.
|
|
55
|
+
if (report.config.parsed) {
|
|
56
|
+
report.adapters = probeAdapters(repoRoot, report.config.parsed);
|
|
57
|
+
report.clis = probeClis(report.config.parsed, report.catalog.raw);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Upstream presence — cheap detection from install-state.
|
|
61
|
+
report.upstreams = probeUpstreams(ctx, report.state.home);
|
|
62
|
+
|
|
63
|
+
if (json) {
|
|
64
|
+
// Strip the internal `raw` catalog payload — JSON consumers want doctor
|
|
65
|
+
// results, not the entire catalog dumped.
|
|
66
|
+
const sanitized = {
|
|
67
|
+
...report,
|
|
68
|
+
catalog: { ...report.catalog, raw: undefined },
|
|
69
|
+
};
|
|
70
|
+
delete sanitized.catalog.raw;
|
|
71
|
+
console.log(JSON.stringify(sanitized, null, 2));
|
|
72
|
+
} else {
|
|
73
|
+
renderHuman(report, { verbose });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Compute exit code.
|
|
77
|
+
const counts = countSeverities(report);
|
|
78
|
+
if (counts.fail > 0) return 2;
|
|
79
|
+
if (counts.warn > 0) return 1;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function probeCatalog(repoRoot) {
|
|
84
|
+
const r = { path: join(repoRoot, 'catalog.yaml'), status: '✗', error: null, raw: null };
|
|
85
|
+
try {
|
|
86
|
+
r.raw = loadCatalog(repoRoot, { validate: true });
|
|
87
|
+
r.status = '✓';
|
|
88
|
+
} catch (err) {
|
|
89
|
+
r.error = err.message;
|
|
90
|
+
}
|
|
91
|
+
return r;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function probeState(ctx) {
|
|
95
|
+
const home = ctx.home || process.env.HOME;
|
|
96
|
+
const homePath = join(home, '.claude', 'flow', 'install-state.json');
|
|
97
|
+
const projectPath = join(ctx.cwd, '.claude', 'flow', 'install-state.json');
|
|
98
|
+
return {
|
|
99
|
+
home: probeJson(homePath, 'home'),
|
|
100
|
+
project: probeJson(projectPath, 'project'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function probeJson(path, scope) {
|
|
105
|
+
const r = { path, scope, status: 'ℹ', error: null, parsed: null };
|
|
106
|
+
if (!existsSync(path)) {
|
|
107
|
+
r.error = 'not present (run /flow-init)';
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const content = readFileSync(path, 'utf-8').trim();
|
|
112
|
+
if (!content) {
|
|
113
|
+
r.status = '⚠';
|
|
114
|
+
r.error = 'empty file';
|
|
115
|
+
return r;
|
|
116
|
+
}
|
|
117
|
+
r.parsed = JSON.parse(content);
|
|
118
|
+
r.status = '✓';
|
|
119
|
+
} catch (err) {
|
|
120
|
+
r.status = '✗';
|
|
121
|
+
r.error = err.message;
|
|
122
|
+
}
|
|
123
|
+
return r;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function probeConfig(ctx) {
|
|
127
|
+
const path = join(ctx.cwd, 'flow.config.yaml');
|
|
128
|
+
const r = { path, status: '✗', error: null, parsed: null };
|
|
129
|
+
if (!existsSync(path)) {
|
|
130
|
+
r.status = 'ℹ';
|
|
131
|
+
r.error = 'not present (run /flow-init)';
|
|
132
|
+
return r;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
r.parsed = parseYaml(readFileSync(path, 'utf-8'));
|
|
136
|
+
if (!r.parsed || typeof r.parsed !== 'object') {
|
|
137
|
+
r.status = '✗';
|
|
138
|
+
r.error = 'parsed to non-object';
|
|
139
|
+
return r;
|
|
140
|
+
}
|
|
141
|
+
if (!r.parsed.adapters) {
|
|
142
|
+
r.status = '⚠';
|
|
143
|
+
r.error = "missing required 'adapters' section";
|
|
144
|
+
return r;
|
|
145
|
+
}
|
|
146
|
+
r.status = '✓';
|
|
147
|
+
} catch (err) {
|
|
148
|
+
r.error = err.message;
|
|
149
|
+
}
|
|
150
|
+
return r;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function probeAdapters(repoRoot, config) {
|
|
154
|
+
const families = ['issue_tracker', 'pr', 'e2e', 'verify'];
|
|
155
|
+
const results = [];
|
|
156
|
+
for (const family of families) {
|
|
157
|
+
const id = config.adapters?.[family];
|
|
158
|
+
if (!id) {
|
|
159
|
+
results.push({ family, id: null, status: 'ℹ', detail: 'not configured' });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const adapterPath = join(repoRoot, 'adapters', family.replace('_', '-'), `${id}.md`);
|
|
163
|
+
if (!existsSync(adapterPath)) {
|
|
164
|
+
results.push({
|
|
165
|
+
family,
|
|
166
|
+
id,
|
|
167
|
+
status: '⚠',
|
|
168
|
+
detail: `adapter file missing: ${adapterPath}`,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Detect mixed-state (issue #28): project-side symlink vs regular file.
|
|
173
|
+
const projectAdapter = join(process.cwd(), '.claude', 'flow', 'adapters', `${family.replace('_', '-')}.md`);
|
|
174
|
+
let kind = 'absent';
|
|
175
|
+
if (existsSync(projectAdapter)) {
|
|
176
|
+
try {
|
|
177
|
+
kind = lstatSync(projectAdapter).isSymbolicLink() ? 'symlink' : 'regular_file';
|
|
178
|
+
} catch (e) {
|
|
179
|
+
kind = 'unknown';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
results.push({
|
|
183
|
+
family,
|
|
184
|
+
id,
|
|
185
|
+
status: kind === 'regular_file' ? '⚠' : '✓',
|
|
186
|
+
kind,
|
|
187
|
+
detail: kind === 'regular_file'
|
|
188
|
+
? 'project-side adapter is a regular file (not a Flow symlink); upstream updates will not propagate'
|
|
189
|
+
: `adapter file present at ${adapterPath}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function probeClis(config, catalog) {
|
|
196
|
+
if (!catalog) return [];
|
|
197
|
+
const required = new Set();
|
|
198
|
+
for (const family of ['issue_tracker', 'pr', 'e2e', 'verify']) {
|
|
199
|
+
const id = config.adapters?.[family];
|
|
200
|
+
if (!id) continue;
|
|
201
|
+
const adapter = catalog.adapters?.find((a) => a.id === id);
|
|
202
|
+
if (adapter?.needs_cli) {
|
|
203
|
+
for (const cli of adapter.needs_cli) required.add(cli);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const results = [];
|
|
207
|
+
for (const cli of required) {
|
|
208
|
+
let status = '✗';
|
|
209
|
+
let path = null;
|
|
210
|
+
try {
|
|
211
|
+
path = execaSync('which', [cli]).stdout.trim();
|
|
212
|
+
if (path) status = '✓';
|
|
213
|
+
} catch (e) { /* not in PATH */ }
|
|
214
|
+
results.push({ cli, status, path, detail: status === '✓' ? path : 'not in $PATH' });
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function probeUpstreams(ctx, homeState) {
|
|
220
|
+
if (!homeState.parsed?.upstreams) return [];
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const [name, rec] of Object.entries(homeState.parsed.upstreams)) {
|
|
223
|
+
out.push({
|
|
224
|
+
name,
|
|
225
|
+
status: rec.installed ? '✓' : 'ℹ',
|
|
226
|
+
subset: rec.subset,
|
|
227
|
+
version: rec.version || 'not pinned',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function countSeverities(report) {
|
|
234
|
+
const counts = { ok: 0, info: 0, warn: 0, fail: 0 };
|
|
235
|
+
const items = [
|
|
236
|
+
report.catalog,
|
|
237
|
+
report.state.home,
|
|
238
|
+
report.state.project,
|
|
239
|
+
report.config,
|
|
240
|
+
...report.adapters,
|
|
241
|
+
...report.clis,
|
|
242
|
+
...report.upstreams,
|
|
243
|
+
];
|
|
244
|
+
for (const item of items) {
|
|
245
|
+
if (!item) continue;
|
|
246
|
+
if (item.status === '✓') counts.ok++;
|
|
247
|
+
else if (item.status === 'ℹ') counts.info++;
|
|
248
|
+
else if (item.status === '⚠') counts.warn++;
|
|
249
|
+
else if (item.status === '✗') counts.fail++;
|
|
250
|
+
}
|
|
251
|
+
return counts;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderHuman(report, { verbose }) {
|
|
255
|
+
const lines = [];
|
|
256
|
+
lines.push(chalk.bold('━━━ flow doctor ━━━'));
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push(`Catalog: ${tag(report.catalog.status)} ${report.catalog.path}`);
|
|
259
|
+
if (report.catalog.error) lines.push(` ${chalk.dim(report.catalog.error)}`);
|
|
260
|
+
|
|
261
|
+
lines.push('');
|
|
262
|
+
lines.push('State:');
|
|
263
|
+
lines.push(` Home: ${tag(report.state.home.status)} ${report.state.home.path}`);
|
|
264
|
+
if (report.state.home.error) lines.push(` ${chalk.dim(report.state.home.error)}`);
|
|
265
|
+
lines.push(` Project: ${tag(report.state.project.status)} ${report.state.project.path}`);
|
|
266
|
+
if (report.state.project.error) lines.push(` ${chalk.dim(report.state.project.error)}`);
|
|
267
|
+
lines.push(` Config: ${tag(report.config.status)} ${report.config.path}`);
|
|
268
|
+
if (report.config.error) lines.push(` ${chalk.dim(report.config.error)}`);
|
|
269
|
+
|
|
270
|
+
if (report.adapters.length > 0) {
|
|
271
|
+
lines.push('');
|
|
272
|
+
lines.push('Adapters:');
|
|
273
|
+
for (const a of report.adapters) {
|
|
274
|
+
lines.push(` ${a.family}: ${tag(a.status)} ${a.id || chalk.dim('—')} ${chalk.dim(a.detail || '')}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (report.clis.length > 0) {
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push('CLIs:');
|
|
281
|
+
for (const c of report.clis) {
|
|
282
|
+
lines.push(` ${c.cli}: ${tag(c.status)} ${chalk.dim(c.detail)}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (report.upstreams.length > 0) {
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push('Upstreams:');
|
|
289
|
+
for (const u of report.upstreams) {
|
|
290
|
+
lines.push(` ${u.name}: ${tag(u.status)} subset=${u.subset || '—'} version=${u.version}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const c = countSeverities(report);
|
|
295
|
+
lines.push('');
|
|
296
|
+
lines.push(chalk.bold(`Summary: ${c.ok} ✓ · ${c.info} ℹ · ${c.warn} ⚠ · ${c.fail} ✗`));
|
|
297
|
+
|
|
298
|
+
if (verbose) {
|
|
299
|
+
lines.push('');
|
|
300
|
+
lines.push(chalk.dim('For LLM-dependent checks (MCP responsiveness, severity-label preservation,'));
|
|
301
|
+
lines.push(chalk.dim('Caveman global-scope detection), run /flow-doctor inside Claude Code.'));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(lines.join('\n'));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function tag(s) {
|
|
308
|
+
if (s === '✓') return chalk.green('✓');
|
|
309
|
+
if (s === '⚠') return chalk.yellow('⚠');
|
|
310
|
+
if (s === '✗') return chalk.red('✗');
|
|
311
|
+
if (s === 'ℹ') return chalk.cyan('ℹ');
|
|
312
|
+
return s;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* --repair-upstream <name>: print the exact commands to reinstall the pinned
|
|
317
|
+
* version of an upstream after drift. Does NOT auto-run — upstream installs
|
|
318
|
+
* touch user-scope state and curl-pipe-bash should never be triggered
|
|
319
|
+
* implicitly by a CLI subcommand.
|
|
320
|
+
*
|
|
321
|
+
* Reads pinned version from $HOME/.claude/flow/install-state.json.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} name - bmad | ecc | caveman
|
|
324
|
+
* @param {Object} ctx
|
|
325
|
+
* @returns {Promise<number>} exit code
|
|
326
|
+
*/
|
|
327
|
+
async function runRepairUpstream(name, ctx) {
|
|
328
|
+
const validNames = ['bmad', 'ecc', 'caveman'];
|
|
329
|
+
if (!validNames.includes(name)) {
|
|
330
|
+
console.error(chalk.red(`✗ Unknown upstream: ${name}`));
|
|
331
|
+
console.error(` Available: ${validNames.join(', ')}`);
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const homePath = join(ctx.home || process.env.HOME, '.claude', 'flow', 'install-state.json');
|
|
336
|
+
if (!existsSync(homePath)) {
|
|
337
|
+
console.error(chalk.red(`✗ No install-state.json at ${homePath}`));
|
|
338
|
+
console.error(' Run /flow-init first.');
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let state;
|
|
343
|
+
try {
|
|
344
|
+
state = JSON.parse(readFileSync(homePath, 'utf-8'));
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.error(chalk.red(`✗ Could not parse install-state.json: ${err.message}`));
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const record = state.upstreams?.[name];
|
|
351
|
+
if (!record || !record.version) {
|
|
352
|
+
console.error(chalk.yellow(`⚠ No pinned version recorded for ${name}.`));
|
|
353
|
+
console.error(' Either /flow-init pre-dates pinning, or this upstream was never installed via Flow.');
|
|
354
|
+
console.error(` Re-run /flow-init --update --pin-upstream ${name} to capture the current version.`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
console.log(chalk.bold(`━━━ flow doctor --repair-upstream ${name} ━━━`));
|
|
359
|
+
console.log();
|
|
360
|
+
console.log(`Pinned version (from install-state): ${chalk.cyan(record.version)}`);
|
|
361
|
+
console.log(`Subset: ${record.subset || '—'}`);
|
|
362
|
+
console.log();
|
|
363
|
+
console.log(chalk.bold('Run these commands yourself to repair (Flow refuses to auto-run upstream installers):'));
|
|
364
|
+
console.log();
|
|
365
|
+
|
|
366
|
+
if (name === 'bmad') {
|
|
367
|
+
const v = record.version.replace(/^v/, '');
|
|
368
|
+
console.log(` ${chalk.cyan(`npx bmad-method@${v} install --tools claude-code --yes`)}`);
|
|
369
|
+
console.log();
|
|
370
|
+
console.log(chalk.dim(' Notes:'));
|
|
371
|
+
console.log(chalk.dim(` - npx will install the exact pinned version.`));
|
|
372
|
+
console.log(chalk.dim(` - If the pinned version was a commit hash (e.g. "unknown@<date>"), use git checkout in _bmad/ instead.`));
|
|
373
|
+
} else if (name === 'ecc') {
|
|
374
|
+
console.log(` ${chalk.cyan(`cd ~/.claude/rules && git fetch --all && git checkout ${record.version}`)}`);
|
|
375
|
+
console.log();
|
|
376
|
+
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}`));
|
|
380
|
+
} else if (name === 'caveman') {
|
|
381
|
+
console.log(` ${chalk.cyan(`curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash`)}`);
|
|
382
|
+
console.log();
|
|
383
|
+
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.`));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.log();
|
|
390
|
+
console.log(chalk.dim('After repair, re-run `flow doctor` to verify the drift warning clears.'));
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// lib/commands/init.js — `flow init` headless entry point.
|
|
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.
|
|
8
|
+
//
|
|
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.
|
|
11
|
+
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { execa } from 'execa';
|
|
14
|
+
import { loadCatalog, resolveProfile } from '../catalog.js';
|
|
15
|
+
import { resolveRepoRoot } from '../repo-root.js';
|
|
16
|
+
import plan from './plan.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} args
|
|
20
|
+
* @param {Object} ctx
|
|
21
|
+
*/
|
|
22
|
+
export default async function init(args, ctx) {
|
|
23
|
+
const repoRoot = ctx.repoRoot ?? resolveRepoRoot(import.meta.url);
|
|
24
|
+
loadCatalog(repoRoot); // validate parseable; throws on bad catalog
|
|
25
|
+
|
|
26
|
+
const profileName = args.profile ?? 'standard';
|
|
27
|
+
const yes = Boolean(args.yes);
|
|
28
|
+
const dryRun = Boolean(args['dry-run']);
|
|
29
|
+
|
|
30
|
+
// Always show the plan first.
|
|
31
|
+
console.log(chalk.dim(`Resolving plan for profile '${profileName}'…`));
|
|
32
|
+
console.log();
|
|
33
|
+
await plan({ ...args, profile: profileName }, ctx);
|
|
34
|
+
console.log();
|
|
35
|
+
|
|
36
|
+
if (dryRun) {
|
|
37
|
+
console.log(chalk.dim('(--dry-run: stopping before execution)'));
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inside Claude Code: just tell the user to run /flow-init. Don't fork.
|
|
42
|
+
if (ctx.insideClaudeCode) {
|
|
43
|
+
console.log(chalk.bold('Inside Claude Code — run the slash command:'));
|
|
44
|
+
console.log(` ${chalk.cyan('/flow-init')}${profileName !== 'standard' ? ` --profile ${profileName}` : ''}`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
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.
|
|
53
|
+
}
|
|
54
|
+
|
|
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;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Dispatch: open `claude` with /flow-init prefilled.
|
|
65
|
+
console.log(chalk.dim('Dispatching to `claude` CLI…'));
|
|
66
|
+
try {
|
|
67
|
+
await execa('claude', ['/flow-init', ...(profileName !== 'standard' ? ['--profile', profileName] : [])], {
|
|
68
|
+
stdio: 'inherit',
|
|
69
|
+
env: { ...process.env, FLOW_REPO_ROOT: repoRoot }
|
|
70
|
+
});
|
|
71
|
+
return 0;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.exitCode != null) return err.exitCode;
|
|
74
|
+
console.error(chalk.red(`✗ Failed to launch claude: ${err.message}`));
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function hasClaudeCli() {
|
|
80
|
+
try {
|
|
81
|
+
await execa('which', ['claude']);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// lib/commands/install.js — headless install path.
|
|
2
|
+
//
|
|
3
|
+
// `flow install` is the scriptable / CI version of /flow-init. Where /flow-init
|
|
4
|
+
// asks ~8 questions, `flow install` takes everything via flags and either
|
|
5
|
+
// executes (with --yes) or prints the plan (without).
|
|
6
|
+
//
|
|
7
|
+
// This covers the "core" operations from catalog.yaml:
|
|
8
|
+
// - copy flow_components into the target scope's skills/ dir
|
|
9
|
+
// - copy templates into flow-init's templates/ subdir
|
|
10
|
+
// - ensure $HOME/.claude/flow/install-state.json exists
|
|
11
|
+
// - write a starter flow.config.yaml in the project root
|
|
12
|
+
// - record an install-state.json entry per scope
|
|
13
|
+
//
|
|
14
|
+
// What this does NOT do (intentionally — too risky/interactive for a CLI):
|
|
15
|
+
// - install Caveman via curl-pipe-bash (run /flow-init or the upstream installer)
|
|
16
|
+
// - install BMad (run /flow-init or `npx bmad-method install`)
|
|
17
|
+
// - install ECC (run /flow-init or its install.sh)
|
|
18
|
+
// - register MCP servers (run `claude mcp add` yourself; the slash-command path
|
|
19
|
+
// surfaces auth prompts properly)
|
|
20
|
+
// - migrate BMad state (--migrate-bmad needs interactive confirmations)
|
|
21
|
+
//
|
|
22
|
+
// `flow install` prints a summary at the end pointing at /flow-init for the
|
|
23
|
+
// interactive remainder.
|
|
24
|
+
|
|
25
|
+
import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
+
import { resolve, join, dirname } from 'node:path';
|
|
27
|
+
import chalk from 'chalk';
|
|
28
|
+
import { loadCatalog, resolveProfile, listProfiles } from '../catalog.js';
|
|
29
|
+
import { resolveRepoRoot } from '../repo-root.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} args
|
|
33
|
+
* @param {Object} ctx
|
|
34
|
+
*/
|
|
35
|
+
export default async function install(args, ctx) {
|
|
36
|
+
const repoRoot = ctx.repoRoot ?? resolveRepoRoot(import.meta.url);
|
|
37
|
+
const catalog = loadCatalog(repoRoot);
|
|
38
|
+
|
|
39
|
+
const profileName = args.profile ?? 'standard';
|
|
40
|
+
if (!catalog.profiles[profileName]) {
|
|
41
|
+
console.error(chalk.red(`✗ Unknown profile: ${profileName}`));
|
|
42
|
+
console.error(` Available: ${listProfiles(catalog).join(', ')}`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const profile = resolveProfile(catalog, profileName);
|
|
47
|
+
const yes = Boolean(args.yes);
|
|
48
|
+
const dryRun = Boolean(args['dry-run']);
|
|
49
|
+
const scope = args.scope ?? 'both';
|
|
50
|
+
|
|
51
|
+
const homeRoot = ctx.home || process.env.HOME;
|
|
52
|
+
const home = (p) => resolve(p.replace(/\$HOME/g, homeRoot));
|
|
53
|
+
|
|
54
|
+
// Build plan: list of file operations.
|
|
55
|
+
const operations = [];
|
|
56
|
+
for (const componentId of profile.flow_components) {
|
|
57
|
+
const component = catalog.flow_components.find((c) => c.id === componentId);
|
|
58
|
+
if (!component) {
|
|
59
|
+
console.error(chalk.yellow(`⚠ Component '${componentId}' referenced by profile but not in catalog`));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const op of component.operations || []) {
|
|
63
|
+
if (op.copy) {
|
|
64
|
+
operations.push({
|
|
65
|
+
kind: 'copy',
|
|
66
|
+
component: componentId,
|
|
67
|
+
from: join(repoRoot, op.copy.from),
|
|
68
|
+
to: home(op.copy.to),
|
|
69
|
+
});
|
|
70
|
+
} else if (op.ensure_dir) {
|
|
71
|
+
operations.push({ kind: 'ensure_dir', component: componentId, path: home(op.ensure_dir) });
|
|
72
|
+
} else if (op.touch) {
|
|
73
|
+
operations.push({ kind: 'touch', component: componentId, path: home(op.touch) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Plan summary.
|
|
79
|
+
console.log(chalk.bold(`━━━ flow install (profile: ${profileName}) ━━━`));
|
|
80
|
+
console.log();
|
|
81
|
+
console.log(`Scope: ${scope}`);
|
|
82
|
+
console.log(`Components: ${profile.flow_components.length}`);
|
|
83
|
+
console.log(`Adapters: ${profile.adapters.length} (${profile.adapters.join(', ')})`);
|
|
84
|
+
console.log(`MCPs: ${profile.mcps.length} (${profile.mcps.join(', ') || chalk.dim('none')})`);
|
|
85
|
+
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)')}`);
|
|
87
|
+
console.log(`Caveman: ${profile.caveman_subset} ${chalk.dim('(this CLI will NOT run Caveman installer)')}`);
|
|
88
|
+
console.log();
|
|
89
|
+
console.log(`Operations: ${operations.length}`);
|
|
90
|
+
if (args.verbose) {
|
|
91
|
+
for (const op of operations) {
|
|
92
|
+
console.log(` ${chalk.green('+')} ${op.kind} ${chalk.dim(op.path || `${op.from} → ${op.to}`)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
console.log();
|
|
98
|
+
console.log(chalk.dim('--dry-run: stopping before execution. Re-run with --yes to execute.'));
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!yes) {
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(chalk.yellow('?'), 'Execute? Re-run with --yes to confirm, or --dry-run to preview.');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Execute.
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(chalk.bold('Executing:'));
|
|
111
|
+
let executed = 0;
|
|
112
|
+
let skipped = 0;
|
|
113
|
+
let failed = 0;
|
|
114
|
+
|
|
115
|
+
for (const op of operations) {
|
|
116
|
+
try {
|
|
117
|
+
if (op.kind === 'copy') {
|
|
118
|
+
if (!existsSync(op.from)) {
|
|
119
|
+
throw new Error(`source missing: ${op.from}`);
|
|
120
|
+
}
|
|
121
|
+
const isDir = statSync(op.from).isDirectory();
|
|
122
|
+
if (isDir) {
|
|
123
|
+
copyDirRecursive(op.from, op.to);
|
|
124
|
+
} else {
|
|
125
|
+
mkdirSync(dirname(op.to), { recursive: true });
|
|
126
|
+
copyFileSync(op.from, op.to);
|
|
127
|
+
}
|
|
128
|
+
console.log(` ${chalk.green('✓')} copy ${op.from} → ${op.to}`);
|
|
129
|
+
} else if (op.kind === 'ensure_dir') {
|
|
130
|
+
mkdirSync(op.path, { recursive: true });
|
|
131
|
+
console.log(` ${chalk.green('✓')} ensure_dir ${op.path}`);
|
|
132
|
+
} else if (op.kind === 'touch') {
|
|
133
|
+
mkdirSync(dirname(op.path), { recursive: true });
|
|
134
|
+
if (!existsSync(op.path)) writeFileSync(op.path, '{}');
|
|
135
|
+
console.log(` ${chalk.green('✓')} touch ${op.path}`);
|
|
136
|
+
}
|
|
137
|
+
executed++;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.log(` ${chalk.red('✗')} ${op.kind} failed: ${err.message}`);
|
|
140
|
+
failed++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Record state.
|
|
145
|
+
const statePath = home('$HOME/.claude/flow/install-state.json');
|
|
146
|
+
let state = { schema_version: 'flow.install.v1', upstreams: {} };
|
|
147
|
+
if (existsSync(statePath)) {
|
|
148
|
+
try { state = JSON.parse(readFileSync(statePath, 'utf-8')); } catch (e) { /* keep fresh */ }
|
|
149
|
+
}
|
|
150
|
+
state.schema_version = state.schema_version || 'flow.install.v1';
|
|
151
|
+
state.flow_version = ctx.pkg?.version || state.flow_version;
|
|
152
|
+
state.profile = profileName;
|
|
153
|
+
state.last_updated = new Date().toISOString();
|
|
154
|
+
state.scope = scope;
|
|
155
|
+
state.installed_via = 'flow install (headless)';
|
|
156
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
157
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
158
|
+
|
|
159
|
+
console.log();
|
|
160
|
+
console.log(chalk.bold(`Done: ${executed} executed, ${failed} failed, ${skipped} skipped.`));
|
|
161
|
+
console.log();
|
|
162
|
+
console.log(chalk.dim('Next steps (this CLI does NOT do these — too interactive for headless):'));
|
|
163
|
+
console.log(chalk.dim(' 1. Install BMad / ECC / Caveman manually or run /flow-init for the interactive path'));
|
|
164
|
+
console.log(chalk.dim(' 2. Register MCPs: claude mcp add context7 npx @upstash/context7-mcp@latest'));
|
|
165
|
+
console.log(chalk.dim(' 3. Write flow.config.yaml in your project root (see templates/flow.config.yaml.tmpl)'));
|
|
166
|
+
|
|
167
|
+
return failed > 0 ? 2 : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function copyDirRecursive(src, dest) {
|
|
171
|
+
mkdirSync(dest, { recursive: true });
|
|
172
|
+
for (const entry of readdirSync(src)) {
|
|
173
|
+
const srcPath = join(src, entry);
|
|
174
|
+
const destPath = join(dest, entry);
|
|
175
|
+
if (statSync(srcPath).isDirectory()) {
|
|
176
|
+
copyDirRecursive(srcPath, destPath);
|
|
177
|
+
} else {
|
|
178
|
+
copyFileSync(srcPath, destPath);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|