@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.
@@ -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
+ }
@@ -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.0",
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",