@mhd-ghaith-abtah/flow 0.8.0-beta.0 → 0.8.0-beta.2
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 +13 -0
- package/README.md +20 -16
- package/bin/flow.js +2 -1
- package/docs/migrate-from-bmad.md +7 -6
- package/docs/profiles.md +3 -1
- package/docs/quickstart.md +60 -23
- package/docs/usage.md +1133 -0
- package/lib/commands/install-skills.js +190 -0
- package/lib/init/orchestrate.js +20 -0
- package/package.json +2 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// lib/commands/install-skills.js — bootstrap Flow's slash commands.
|
|
2
|
+
//
|
|
3
|
+
// Symlinks the four flow-* skills (flow-init, flow-sprint, flow-story,
|
|
4
|
+
// flow-doctor) from the Flow package source into a Claude Code skill
|
|
5
|
+
// directory so /flow-init / /flow-sprint / etc. resolve.
|
|
6
|
+
//
|
|
7
|
+
// This closes the bootstrap gap: `npm install -g @mhd-ghaith-abtah/flow`
|
|
8
|
+
// puts `flow` on $PATH but does NOT make slash commands work in Claude
|
|
9
|
+
// Code — those need to live under ~/.claude/skills/ (home scope) or
|
|
10
|
+
// <project>/.claude/skills/ (project scope, team-commit pattern).
|
|
11
|
+
//
|
|
12
|
+
// Idempotent: re-running is safe. Symlinks pointing at the correct
|
|
13
|
+
// source are skipped. Real directories at the target path refuse
|
|
14
|
+
// overwrite without --force (defensive — could be hand-edited content).
|
|
15
|
+
//
|
|
16
|
+
// Why symlinks (not copies):
|
|
17
|
+
// - Updates to the package propagate automatically. Bug fix in the
|
|
18
|
+
// skill workflow? `npm install -g @latest` and the slash command
|
|
19
|
+
// immediately picks it up — no re-bootstrap.
|
|
20
|
+
// - Disk footprint: 4× <1 kB symlinks instead of 4× ~30 kB copies.
|
|
21
|
+
// The tradeoff: deleting the npm package breaks the slash commands.
|
|
22
|
+
// That's a correct failure mode — uninstalling Flow SHOULD break /flow-*.
|
|
23
|
+
|
|
24
|
+
import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
25
|
+
import { resolve, dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import chalk from 'chalk';
|
|
28
|
+
import { resolveRepoRoot } from '../repo-root.js';
|
|
29
|
+
|
|
30
|
+
const SKILLS = Object.freeze(['flow-init', 'flow-sprint', 'flow-story', 'flow-doctor']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} args - yargs-parser output
|
|
34
|
+
* @param {Object} ctx
|
|
35
|
+
*/
|
|
36
|
+
export default async function installSkills(args, ctx) {
|
|
37
|
+
const repoRoot = ctx.repoRoot ?? resolveRepoRoot(import.meta.url);
|
|
38
|
+
const sourceSkillsDir = join(repoRoot, 'skills');
|
|
39
|
+
const scope = args.scope ?? 'home';
|
|
40
|
+
const force = Boolean(args.force);
|
|
41
|
+
const dryRun = Boolean(args['dry-run']);
|
|
42
|
+
const cwd = ctx.cwd || process.cwd();
|
|
43
|
+
const homeDir = ctx.home || process.env.HOME;
|
|
44
|
+
|
|
45
|
+
if (!existsSync(sourceSkillsDir)) {
|
|
46
|
+
console.error(chalk.red(`✗ source skills not found at ${sourceSkillsDir}`));
|
|
47
|
+
console.error(chalk.dim(' Repo root or package install path is wrong.'));
|
|
48
|
+
return 2;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!['home', 'project', 'both'].includes(scope)) {
|
|
52
|
+
console.error(chalk.red(`✗ Unknown scope: ${scope}. Use home | project | both.`));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const targets = resolveTargets({ scope, homeDir, cwd });
|
|
57
|
+
if (targets.length === 0) {
|
|
58
|
+
console.error(chalk.red(`✗ no target directories resolved for scope=${scope}`));
|
|
59
|
+
if (scope !== 'project' && !homeDir) console.error(chalk.dim(' $HOME is not set; cannot resolve home scope.'));
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(chalk.bold('━━━ flow install-skills ━━━'));
|
|
64
|
+
console.log(chalk.dim(` source: ${sourceSkillsDir}`));
|
|
65
|
+
console.log(chalk.dim(` scope: ${scope} ${force ? '(force)' : ''}${dryRun ? ' (dry-run)' : ''}`));
|
|
66
|
+
console.log();
|
|
67
|
+
|
|
68
|
+
let linked = 0;
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
let failed = 0;
|
|
71
|
+
for (const targetDir of targets) {
|
|
72
|
+
console.log(chalk.bold(`Target: ${targetDir}`));
|
|
73
|
+
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
74
|
+
for (const skill of SKILLS) {
|
|
75
|
+
const src = join(sourceSkillsDir, skill);
|
|
76
|
+
const dst = join(targetDir, skill);
|
|
77
|
+
const result = linkOne({ src, dst, force, dryRun });
|
|
78
|
+
if (result.status === 'linked') {
|
|
79
|
+
console.log(` ${chalk.green('+')} ${skill} ${chalk.dim('→ ' + src)}`);
|
|
80
|
+
linked++;
|
|
81
|
+
} else if (result.status === 'already-linked') {
|
|
82
|
+
console.log(` ${chalk.dim('=')} ${skill} ${chalk.dim('(symlink already points to source)')}`);
|
|
83
|
+
skipped++;
|
|
84
|
+
} else if (result.status === 'refused') {
|
|
85
|
+
console.log(` ${chalk.yellow('!')} ${skill} ${chalk.dim('(real directory exists; pass --force to replace)')}`);
|
|
86
|
+
skipped++;
|
|
87
|
+
} else {
|
|
88
|
+
console.log(` ${chalk.red('✗')} ${skill} ${chalk.red(result.error)}`);
|
|
89
|
+
failed++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (failed > 0) {
|
|
96
|
+
console.error(chalk.red(`✗ ${failed} skill(s) failed to link.`));
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(chalk.bold(`Done: ${linked} linked, ${skipped} skipped${failed ? `, ${failed} failed` : ''}.`));
|
|
101
|
+
if (dryRun) {
|
|
102
|
+
console.log(chalk.yellow(' (--dry-run: nothing was actually linked)'));
|
|
103
|
+
} else if (linked > 0) {
|
|
104
|
+
console.log();
|
|
105
|
+
console.log(chalk.dim('Slash commands /flow-init, /flow-sprint, /flow-story, /flow-doctor are now'));
|
|
106
|
+
console.log(chalk.dim('available in any Claude Code session for the chosen scope.'));
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the absolute target dirs based on the scope flag.
|
|
113
|
+
*
|
|
114
|
+
* - home → ~/.claude/skills/ (default; user-wide)
|
|
115
|
+
* - project → <cwd>/.claude/skills/ (team-commit pattern; the skill
|
|
116
|
+
* dir gets committed to the repo so contributors don't each have
|
|
117
|
+
* to bootstrap)
|
|
118
|
+
* - both → both of the above
|
|
119
|
+
*/
|
|
120
|
+
function resolveTargets({ scope, homeDir, cwd }) {
|
|
121
|
+
const out = [];
|
|
122
|
+
if (scope === 'home' || scope === 'both') {
|
|
123
|
+
if (homeDir) out.push(join(homeDir, '.claude', 'skills'));
|
|
124
|
+
}
|
|
125
|
+
if (scope === 'project' || scope === 'both') {
|
|
126
|
+
if (cwd) out.push(join(cwd, '.claude', 'skills'));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Attempt to symlink one skill. Possible outcomes:
|
|
133
|
+
* - linked — fresh symlink created (or dry-run no-op)
|
|
134
|
+
* - already-linked — symlink already points to the right source
|
|
135
|
+
* - refused — target exists and isn't ours; need --force
|
|
136
|
+
* - replaced — target was a wrong-source link or (under
|
|
137
|
+
* --force) a real dir; removed + re-linked
|
|
138
|
+
* - error — fs op failed; result.error has the message
|
|
139
|
+
*
|
|
140
|
+
* @returns {{status: 'linked'|'already-linked'|'refused'|'replaced'|'error', error?: string}}
|
|
141
|
+
*/
|
|
142
|
+
function linkOne({ src, dst, force, dryRun }) {
|
|
143
|
+
if (existsSync(dst) || lstatSafe(dst)) {
|
|
144
|
+
const stat = lstatSafe(dst);
|
|
145
|
+
if (stat?.isSymbolicLink()) {
|
|
146
|
+
try {
|
|
147
|
+
const current = readlinkSync(dst);
|
|
148
|
+
if (resolve(dirname(dst), current) === src) {
|
|
149
|
+
return { status: 'already-linked' };
|
|
150
|
+
}
|
|
151
|
+
// Wrong-source symlink → safe to replace (it's already a link, not user content).
|
|
152
|
+
// Use unlinkSync rather than rmSync — rmSync on a symlink-to-dir follows
|
|
153
|
+
// the link on macOS and tries to delete the target dir, failing with
|
|
154
|
+
// "Path is a directory".
|
|
155
|
+
if (!dryRun) {
|
|
156
|
+
unlinkSync(dst);
|
|
157
|
+
symlinkSync(src, dst);
|
|
158
|
+
}
|
|
159
|
+
return { status: 'linked' };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { status: 'error', error: err.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Real directory or file at the target.
|
|
165
|
+
if (!force) {
|
|
166
|
+
return { status: 'refused' };
|
|
167
|
+
}
|
|
168
|
+
// --force: replace.
|
|
169
|
+
try {
|
|
170
|
+
if (!dryRun) {
|
|
171
|
+
rmSync(dst, { recursive: true, force: true });
|
|
172
|
+
symlinkSync(src, dst);
|
|
173
|
+
}
|
|
174
|
+
return { status: 'linked' };
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { status: 'error', error: err.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Nothing at target → fresh link.
|
|
180
|
+
try {
|
|
181
|
+
if (!dryRun) symlinkSync(src, dst);
|
|
182
|
+
return { status: 'linked' };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return { status: 'error', error: err.message };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function lstatSafe(path) {
|
|
189
|
+
try { return lstatSync(path); } catch { return null; }
|
|
190
|
+
}
|
package/lib/init/orchestrate.js
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
// files. Integration tests use --dry-run to exercise the full path
|
|
25
25
|
// without polluting the developer's MCP config / installed upstreams.
|
|
26
26
|
|
|
27
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
27
29
|
import { resolveProfile } from '../catalog.js';
|
|
28
30
|
import { detect } from './detect.js';
|
|
29
31
|
import { askAll } from './questions.js';
|
|
@@ -114,6 +116,24 @@ export async function runInit(opts) {
|
|
|
114
116
|
return halt('caveman upstream failed', { detection, state, upstreamResults });
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
// 4b. Drop the .caveman-enable marker in the project root.
|
|
120
|
+
// Matches what skills/flow-init/workflow.md does on the slash path: when
|
|
121
|
+
// Caveman is requested for this project (subset !== 'none'), put a
|
|
122
|
+
// zero-byte marker so Caveman activates here even when the user has
|
|
123
|
+
// ~/.config/caveman/config.json set to {"defaultMode": "off"} (allowlist
|
|
124
|
+
// mode). Idempotent — if the marker already exists, leave it alone.
|
|
125
|
+
// Headless parity with the slash path; closes the gap where docs claimed
|
|
126
|
+
// the marker landed automatically but only the slash workflow did it.
|
|
127
|
+
const cavemanRequested = (state.answers.cavemanSubset || profile.caveman_subset) !== 'none';
|
|
128
|
+
if (cavemanRequested && !opts.dryRun) {
|
|
129
|
+
const markerPath = join(cwd, '.caveman-enable');
|
|
130
|
+
if (!existsSync(markerPath)) {
|
|
131
|
+
try { writeFileSync(markerPath, ''); } catch {
|
|
132
|
+
// Non-fatal — the install still works; user can `touch .caveman-enable` manually.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
// 5. MCP registration. Resolved from profile.mcps; idempotent per mcp.js.
|
|
118
138
|
const mcpIds = Array.isArray(profile.mcps) ? profile.mcps : [];
|
|
119
139
|
const mcpDefs = mcpModule.resolveMcps(catalog, mcpIds);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhd-ghaith-abtah/flow",
|
|
3
|
-
"version": "0.8.0-beta.
|
|
3
|
+
"version": "0.8.0-beta.2",
|
|
4
4
|
"description": "Token-light per-story workflow for Claude Code. Delegates to BMad + ECC.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"tools/fix-caveman-shrink.sh",
|
|
20
20
|
"catalog.yaml",
|
|
21
21
|
"docs/quickstart.md",
|
|
22
|
+
"docs/usage.md",
|
|
22
23
|
"docs/adapters.md",
|
|
23
24
|
"docs/profiles.md",
|
|
24
25
|
"docs/migrate-from-bmad.md",
|