@boopkit/cli 0.3.1 → 0.4.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/README.md CHANGED
@@ -61,7 +61,7 @@ A **capability** is a composable feature bundle. Each capability package ships:
61
61
  - **Middleware hooks** — lines wired into `src/server.ts` at the right phase (imports, pre-routes, routes, error-handling)
62
62
  - **Scripts** — added to `package.json` (e.g. `db:migrate`, `tailwind:build`)
63
63
  - **Post-install steps** — human/agent-readable instructions printed by `boop status`
64
- - **Agent skills** — `.skill.md` files that teach AI agents the conventions this capability uses
64
+ - **Agent skills** — skill folders (each containing a `SKILL.md`) that teach AI agents the conventions this capability uses
65
65
 
66
66
  Capabilities can declare `requires` relationships (e.g. `users-and-accounts` requires `database`, `services`, and `web`). `boop add` resolves the dependency graph and installs everything needed.
67
67
 
@@ -138,7 +138,7 @@ Flags:
138
138
 
139
139
  Boop is built for agent-assisted work — the skills and conventions ship with the code so your agent has real guidance, not just a blank repo. Every scaffolded project includes:
140
140
 
141
- - **Skills** — every capability installs `.skill.md` files into `skills/`. These are focused, task-specific instructions (e.g. `design.rest-api-design.skill.md`, `test.tdd-light.skill.md`) that tell an agent the conventions this project uses.
141
+ - **Skills** — every capability installs skill folders into `skills/`. These are focused, task-specific instructions (each folder contains a `SKILL.md`, e.g. `skills/rest-api-design/SKILL.md`, `skills/tdd-light/SKILL.md`) that tell an agent the conventions this project uses.
142
142
  - **`boop status`** — after `boop init` or `boop add`, run `boop status`. It prints every capability's post-install steps. Hand this to your agent and say "finish setup."
143
143
  - **`boop loop`** — Uses Claude for 'unattended work', building working code and UI from your specifications.
144
144
  - `prefix` mode: Plan work as a tree of issues, then `boop loop --prefix <id>` processes them one at a time, each in its own Claude Code session. Dry-run by default; budget-capped; resumes unfinished work. See [docs/loop.md](docs/loop.md).
@@ -1,18 +1,18 @@
1
1
  {
2
- "@boopkit/cap-api": "0.3.0",
3
- "@boopkit/cap-background-jobs": "0.3.0",
4
- "@boopkit/cap-cloud-storage": "0.3.0",
5
- "@boopkit/cap-coding": "0.3.0",
6
- "@boopkit/cap-database": "0.3.0",
7
- "@boopkit/cap-email": "0.3.0",
8
- "@boopkit/cap-hello": "0.3.0",
9
- "@boopkit/cap-noticeboard": "0.3.0",
10
- "@boopkit/cap-product-design": "0.3.0",
11
- "@boopkit/cap-project-mgmt": "0.3.0",
12
- "@boopkit/cap-services": "0.3.0",
13
- "@boopkit/cap-skills": "0.3.0",
14
- "@boopkit/cap-testing": "0.3.0",
15
- "@boopkit/cap-users": "0.3.0",
16
- "@boopkit/cap-web": "0.3.0",
17
- "@boopkit/logger": "0.3.0"
2
+ "@boopkit/cap-api": "0.4.0",
3
+ "@boopkit/cap-background-jobs": "0.4.0",
4
+ "@boopkit/cap-cloud-storage": "0.4.0",
5
+ "@boopkit/cap-coding": "0.4.0",
6
+ "@boopkit/cap-database": "0.4.0",
7
+ "@boopkit/cap-email": "0.4.0",
8
+ "@boopkit/cap-hello": "0.4.0",
9
+ "@boopkit/cap-noticeboard": "0.4.0",
10
+ "@boopkit/cap-product-design": "0.4.0",
11
+ "@boopkit/cap-project-mgmt": "0.4.0",
12
+ "@boopkit/cap-services": "0.4.0",
13
+ "@boopkit/cap-skills": "0.4.0",
14
+ "@boopkit/cap-testing": "0.4.0",
15
+ "@boopkit/cap-users": "0.4.0",
16
+ "@boopkit/cap-web": "0.4.0",
17
+ "@boopkit/logger": "0.4.0"
18
18
  }
@@ -245,6 +245,8 @@ function runInit(name, options) {
245
245
  }
246
246
  // --- Inject CSS imports for capability partials ---
247
247
  (0, scaffold_1.injectCssImports)(targetDir, cssCapMap, { skipBackup: true });
248
+ // --- Symlink skills/* into .claude/skills/ for Claude Code auto-discovery ---
249
+ (0, scaffold_1.linkSkillsToClaude)(targetDir);
248
250
  // --- Merge capability scripts into package.json ---
249
251
  if (Object.keys(allScripts).length > 0) {
250
252
  const pkgPath = path.join(targetDir, 'package.json');
@@ -263,8 +265,6 @@ function runInit(name, options) {
263
265
  if (fs.existsSync(profileRefDir)) {
264
266
  (0, safe_io_1.safeCopyDir)(profileRefDir, path.join(targetDir, 'reference'), { onConflict: 'skip' });
265
267
  }
266
- // --- Generate skills index from installed skill files ---
267
- (0, scaffold_1.generateSkillsIndex)(targetDir);
268
268
  // --- Build capabilityConfig for capabilities with env vars ---
269
269
  const capabilityConfig = {};
270
270
  const envEntries = [];
@@ -332,7 +332,7 @@ ${issue.description}
332
332
  ## Instructions
333
333
  1. Read AGENTS.md for project orientation
334
334
  2. Claim the issue: bd update ${issue.id} --claim
335
- 3. Read relevant skills from skills/index.md before making changes
335
+ 3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes
336
336
  4. Implement the changes following TDD-Lite: observe, implement, verify
337
337
  5. Run tests if applicable: npm test
338
338
  6. Commit your changes with a message referencing ${issue.id}
@@ -181,7 +181,9 @@ async function enrichWithSetupChecks(data, envValues, projectDir, projectName) {
181
181
  label: (0, interpolate_1.interpolate)(c.label, vars),
182
182
  hint: c.hint ? (0, interpolate_1.interpolate)(c.hint, vars) : undefined,
183
183
  }));
184
- const steps = await (0, db_status_1.runSetupChecks)(interpolatedChecks, envValues, projectDir, projectName);
184
+ // Extract this capability's .prisma files for scoped prisma-migrate checks
185
+ const prismaFiles = capDef?.files?.filter(f => f.endsWith('.prisma'));
186
+ const steps = await (0, db_status_1.runSetupChecks)(interpolatedChecks, envValues, projectDir, projectName, prismaFiles);
185
187
  const completedSteps = steps.filter(s => s.done).map(s => s.label);
186
188
  const nextSteps = steps.filter(s => !s.done).map(s => s.hint ? `${s.label}: ${s.hint}` : s.label);
187
189
  if (nextSteps.length === 0) {
@@ -40,6 +40,7 @@ const child_process_1 = require("child_process");
40
40
  const project_1 = require("../lib/project");
41
41
  const capability_1 = require("../lib/capability");
42
42
  const update_1 = require("../lib/update");
43
+ const scaffold_1 = require("../lib/scaffold");
43
44
  function runUpdate(capabilityName, options) {
44
45
  const targetDir = process.cwd();
45
46
  // Must be in a boop project
@@ -108,7 +109,8 @@ function runUpdate(capabilityName, options) {
108
109
  const icon = action.action === 'overwrite' ? '\x1b[32m↑\x1b[0m'
109
110
  : action.action === 'skip' ? '\x1b[33m◐\x1b[0m'
110
111
  : action.action === 'add' ? '\x1b[32m+\x1b[0m'
111
- : '\x1b[90m⚠\x1b[0m';
112
+ : action.action === 'migrate' ? '\x1b[36m↗\x1b[0m'
113
+ : '\x1b[90m⚠\x1b[0m';
112
114
  console.log(` ${icon} ${action.relativePath} — ${action.reason}`);
113
115
  }
114
116
  }
@@ -127,6 +129,7 @@ function runUpdate(capabilityName, options) {
127
129
  forcedOverwrite: [],
128
130
  skippedDeleted: [],
129
131
  pruned: [],
132
+ migrated: [],
130
133
  unchangedCount: plan.unchangedCount,
131
134
  });
132
135
  continue;
@@ -134,6 +137,10 @@ function runUpdate(capabilityName, options) {
134
137
  const result = (0, update_1.executeUpdatePlan)(plan, { force: options.force, restore: options.restore, prune: options.prune });
135
138
  results.push(result);
136
139
  }
140
+ // Refresh .claude/skills/ symlinks for any new skill folders added by this update
141
+ if (!options.dryRun) {
142
+ (0, scaffold_1.linkSkillsToClaude)(targetDir);
143
+ }
137
144
  // Output
138
145
  if (options.json) {
139
146
  console.log(JSON.stringify({ capabilities: results }, null, 2));
@@ -144,6 +151,9 @@ function runUpdate(capabilityName, options) {
144
151
  const summary = (0, update_1.formatUpdateSummary)(result);
145
152
  console.log(`${result.capability}: ${summary}`);
146
153
  // Show details for non-trivial updates
154
+ for (const f of result.migrated || []) {
155
+ console.log(` \x1b[36m↗\x1b[0m ${f} (migrated to folder)`);
156
+ }
147
157
  for (const f of result.overwritten) {
148
158
  if (result.forcedOverwrite.includes(f)) {
149
159
  console.log(` \x1b[33m⚠\x1b[0m ${f} (force-overwritten, backup saved)`);
@@ -26,6 +26,16 @@ export declare function checkMigrationsTable(dbUrl: string): Promise<boolean | n
26
26
  * Returns true if prisma/migrations/ contains at least one migration subdirectory.
27
27
  */
28
28
  export declare function hasPrismaMigrations(projectDir: string): boolean;
29
+ /**
30
+ * Parse a .prisma file and extract table names from model declarations.
31
+ * Returns the @@map() value if present, otherwise the model name.
32
+ */
33
+ export declare function parsePrismaTableNames(filePath: string): string[];
34
+ /**
35
+ * Check if specific tables exist in the database.
36
+ * Returns true if ALL tables exist, false if any are missing, null if psql unavailable.
37
+ */
38
+ export declare function checkTablesExist(dbUrl: string, tableNames: string[]): Promise<boolean | null>;
29
39
  /**
30
40
  * Check if any .prisma files in the project define models.
31
41
  * Returns true if at least one file contains a `model` declaration.
@@ -52,6 +62,8 @@ export interface CheckContext {
52
62
  projectName: string;
53
63
  /** Accumulated results from earlier checks — keyed by check type. */
54
64
  results: Map<string, boolean>;
65
+ /** Capability's .prisma file paths relative to projectDir — enables scoped prisma-migrate checks. */
66
+ prismaFiles?: string[];
55
67
  }
56
68
  /**
57
69
  * Run all setup checks declared in a capability's setupChecks list.
@@ -61,4 +73,4 @@ export declare function runSetupChecks(checks: {
61
73
  type: string;
62
74
  label: string;
63
75
  hint?: string;
64
- }[], envValues: Map<string, string>, projectDir: string, projectName: string): Promise<SetupStep[]>;
76
+ }[], envValues: Map<string, string>, projectDir: string, projectName: string, prismaFiles?: string[]): Promise<SetupStep[]>;
@@ -38,6 +38,8 @@ exports.checkTcpReachable = checkTcpReachable;
38
38
  exports.checkDatabaseConnectable = checkDatabaseConnectable;
39
39
  exports.checkMigrationsTable = checkMigrationsTable;
40
40
  exports.hasPrismaMigrations = hasPrismaMigrations;
41
+ exports.parsePrismaTableNames = parsePrismaTableNames;
42
+ exports.checkTablesExist = checkTablesExist;
41
43
  exports.hasPrismaModels = hasPrismaModels;
42
44
  exports.isPrismaClientGenerated = isPrismaClientGenerated;
43
45
  exports.checkPrismaMigrateStatus = checkPrismaMigrateStatus;
@@ -140,6 +142,77 @@ function hasPrismaMigrations(projectDir) {
140
142
  const entries = fs.readdirSync(migrationsDir, { withFileTypes: true });
141
143
  return entries.some(e => e.isDirectory());
142
144
  }
145
+ /**
146
+ * Parse a .prisma file and extract table names from model declarations.
147
+ * Returns the @@map() value if present, otherwise the model name.
148
+ */
149
+ function parsePrismaTableNames(filePath) {
150
+ if (!fs.existsSync(filePath))
151
+ return [];
152
+ const content = fs.readFileSync(filePath, 'utf-8');
153
+ const tables = [];
154
+ const lines = content.split('\n');
155
+ let currentModel = null;
156
+ let braceDepth = 0;
157
+ let mapName = null;
158
+ for (const line of lines) {
159
+ const modelMatch = line.match(/^\s*model\s+(\w+)\s*\{/);
160
+ if (modelMatch && !currentModel) {
161
+ currentModel = modelMatch[1];
162
+ braceDepth = 1;
163
+ mapName = null;
164
+ continue;
165
+ }
166
+ if (currentModel) {
167
+ for (const ch of line) {
168
+ if (ch === '{')
169
+ braceDepth++;
170
+ if (ch === '}')
171
+ braceDepth--;
172
+ }
173
+ const mapMatch = line.match(/@@map\s*\(\s*"([^"]+)"\s*\)/);
174
+ if (mapMatch) {
175
+ mapName = mapMatch[1];
176
+ }
177
+ if (braceDepth === 0) {
178
+ tables.push(mapName || currentModel);
179
+ currentModel = null;
180
+ mapName = null;
181
+ }
182
+ }
183
+ }
184
+ return tables;
185
+ }
186
+ /**
187
+ * Check if specific tables exist in the database.
188
+ * Returns true if ALL tables exist, false if any are missing, null if psql unavailable.
189
+ */
190
+ function checkTablesExist(dbUrl, tableNames) {
191
+ if (tableNames.length === 0)
192
+ return Promise.resolve(true);
193
+ // Validate table names — only allow alphanumeric and underscores
194
+ if (!tableNames.every(t => /^[a-zA-Z_]\w*$/.test(t))) {
195
+ return Promise.resolve(false);
196
+ }
197
+ const quotedNames = tableNames.map(t => `'${t}'`).join(',');
198
+ const query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN (${quotedNames})`;
199
+ return new Promise((resolve) => {
200
+ (0, child_process_1.execFile)('psql', [dbUrl, '-tAc', query], { timeout: 3000 }, (error, stdout) => {
201
+ if (error) {
202
+ if ('code' in error && error.code === 'ENOENT') {
203
+ resolve(null);
204
+ }
205
+ else {
206
+ resolve(false);
207
+ }
208
+ }
209
+ else {
210
+ const count = parseInt(stdout.trim(), 10);
211
+ resolve(count === tableNames.length);
212
+ }
213
+ });
214
+ });
215
+ }
143
216
  /**
144
217
  * Check if any .prisma files in the project define models.
145
218
  * Returns true if at least one file contains a `model` declaration.
@@ -216,7 +289,28 @@ async function runCheck(type, ctx) {
216
289
  // Skip only if a database check was explicitly run and failed
217
290
  if (ctx.results.has('database') && !ctx.results.get('database'))
218
291
  return false;
219
- // If schema has models but no migrations exist, migration hasn't been run
292
+ // Scoped check: when prismaFiles are provided, check this capability's tables instead of global status
293
+ if (ctx.prismaFiles?.length) {
294
+ const tableNames = [];
295
+ for (const file of ctx.prismaFiles) {
296
+ const filePath = path.join(ctx.projectDir, file);
297
+ tableNames.push(...parsePrismaTableNames(filePath));
298
+ }
299
+ if (tableNames.length > 0) {
300
+ // Capability defines models — check if its tables exist in the DB
301
+ const dbUrl = ctx.envValues.get('DATABASE_URL');
302
+ if (!dbUrl)
303
+ return false;
304
+ return (await checkTablesExist(dbUrl, tableNames)) === true;
305
+ }
306
+ // Capability has prisma files but no models (e.g., base schema) —
307
+ // check if _prisma_migrations table exists (initial migration was run)
308
+ const dbUrl = ctx.envValues.get('DATABASE_URL');
309
+ if (!dbUrl)
310
+ return false;
311
+ return (await checkMigrationsTable(dbUrl)) === true;
312
+ }
313
+ // Fallback: no capability context — use global check
220
314
  if (hasPrismaModels(ctx.projectDir) && !hasPrismaMigrations(ctx.projectDir))
221
315
  return false;
222
316
  const result = await checkPrismaMigrateStatus(ctx.projectDir);
@@ -233,8 +327,8 @@ async function runCheck(type, ctx) {
233
327
  * Run all setup checks declared in a capability's setupChecks list.
234
328
  * Returns an ordered list of steps with live done/pending status.
235
329
  */
236
- async function runSetupChecks(checks, envValues, projectDir, projectName) {
237
- const ctx = { envValues, projectDir, projectName, results: new Map() };
330
+ async function runSetupChecks(checks, envValues, projectDir, projectName, prismaFiles) {
331
+ const ctx = { envValues, projectDir, projectName, results: new Map(), prismaFiles };
238
332
  const steps = [];
239
333
  for (const check of checks) {
240
334
  const done = await runCheck(check.type, ctx);
@@ -100,6 +100,7 @@ function executeTask(task, manifest) {
100
100
  switch (task.op) {
101
101
  case 'copy-files': {
102
102
  (0, scaffold_1.copyCapabilityFiles)(targetDir, capabilityName);
103
+ (0, scaffold_1.linkSkillsToClaude)(targetDir);
103
104
  break;
104
105
  }
105
106
  case 'merge-deps': {
@@ -154,10 +155,6 @@ function executeTask(task, manifest) {
154
155
  (0, scaffold_1.injectCssImports)(targetDir, data.cssCapMap, { skipBackup: true });
155
156
  break;
156
157
  }
157
- case 'generate-skills-index': {
158
- (0, scaffold_1.generateSkillsIndex)(targetDir);
159
- break;
160
- }
161
158
  case 'update-manifest': {
162
159
  const data = task.data;
163
160
  const projectManifest = (0, project_1.readProjectManifest)(targetDir);
@@ -7,7 +7,7 @@
7
7
  * Phase 2 (execute) walks the manifest, performing each operation and
8
8
  * updating task status.
9
9
  */
10
- export type ManifestOp = 'copy-files' | 'merge-deps' | 'merge-scripts' | 'link-package' | 'inject-hook' | 'inject-css' | 'set-env' | 'generate-skills-index' | 'update-manifest' | 'run-command';
10
+ export type ManifestOp = 'copy-files' | 'merge-deps' | 'merge-scripts' | 'link-package' | 'inject-hook' | 'inject-css' | 'set-env' | 'update-manifest' | 'run-command';
11
11
  export type TaskStatus = 'pending' | 'done' | 'failed' | 'skipped';
12
12
  export interface ManifestTask {
13
13
  op: ManifestOp;
@@ -329,14 +329,6 @@ function planAdd(targetDir, capabilityName) {
329
329
  data: { cssCapMap },
330
330
  });
331
331
  }
332
- // Task: regenerate skills index
333
- tasks.push({
334
- op: 'generate-skills-index',
335
- source: capabilityName,
336
- target: path.join(targetDir, 'skills', 'index.md'),
337
- detail: 'Regenerate skills/index.md',
338
- status: 'pending',
339
- });
340
332
  // Task: update manifest
341
333
  tasks.push({
342
334
  op: 'update-manifest',
@@ -36,6 +36,18 @@ export declare function hashFile(filePath: string): string;
36
36
  * Returns list of copied file paths relative to targetDir, and file hashes.
37
37
  */
38
38
  export declare function copyCapabilityFiles(targetDir: string, capabilityName: string, searchPaths?: string[]): string[];
39
+ /**
40
+ * Symlink each skill folder under `<targetDir>/skills/` into
41
+ * `<targetDir>/.claude/skills/` so Claude Code's built-in Skill tool
42
+ * auto-discovers them alongside any plugin-installed skills.
43
+ *
44
+ * Per-folder symlinks (rather than a single dir-level symlink) so existing
45
+ * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
46
+ * are preserved.
47
+ *
48
+ * Idempotent. Returns the relative paths of symlinks created or already present.
49
+ */
50
+ export declare function linkSkillsToClaude(targetDir: string): string[];
39
51
  /**
40
52
  * Merge dependencies into an existing package.json.
41
53
  * Does not overwrite existing dependency versions.
@@ -83,9 +95,4 @@ interface CapabilityHookEntry {
83
95
  * prevent arbitrary code injection into scaffolded projects.
84
96
  */
85
97
  export declare function collectHooks(capabilities: CapabilityHookEntry[]): HookVariables;
86
- /**
87
- * Generate skills/index.md from the .skill.md files present in the project.
88
- * Overwrites any existing index.md.
89
- */
90
- export declare function generateSkillsIndex(targetDir: string): void;
91
98
  export {};
@@ -40,11 +40,11 @@ exports.renderTemplate = renderTemplate;
40
40
  exports.scaffoldKernel = scaffoldKernel;
41
41
  exports.hashFile = hashFile;
42
42
  exports.copyCapabilityFiles = copyCapabilityFiles;
43
+ exports.linkSkillsToClaude = linkSkillsToClaude;
43
44
  exports.getBoopkitVersion = getBoopkitVersion;
44
45
  exports.mergePackageJson = mergePackageJson;
45
46
  exports.injectCssImports = injectCssImports;
46
47
  exports.collectHooks = collectHooks;
47
- exports.generateSkillsIndex = generateSkillsIndex;
48
48
  const fs = __importStar(require("fs"));
49
49
  const path = __importStar(require("path"));
50
50
  const crypto = __importStar(require("crypto"));
@@ -139,6 +139,19 @@ function copyCapabilityFiles(targetDir, capabilityName, searchPaths) {
139
139
  // Fallback to source tree
140
140
  return copyFromSourceTree(targetDir, capabilityName);
141
141
  }
142
+ function walkDir(dir) {
143
+ const results = [];
144
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
145
+ const fullPath = path.join(dir, entry.name);
146
+ if (entry.isDirectory()) {
147
+ results.push(...walkDir(fullPath));
148
+ }
149
+ else {
150
+ results.push(fullPath);
151
+ }
152
+ }
153
+ return results;
154
+ }
142
155
  /**
143
156
  * Copy capability files from a package directory.
144
157
  */
@@ -153,10 +166,14 @@ function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
153
166
  (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
154
167
  const entries = fs.readdirSync(skillsDir);
155
168
  for (const entry of entries) {
156
- const destPath = path.join(destSkillsDir, entry);
157
- copied.push(path.join('skills', entry));
158
- if (fs.existsSync(destPath)) {
159
- fileHashes[path.join('skills', entry)] = hashFile(destPath);
169
+ const srcEntryPath = path.join(skillsDir, entry);
170
+ if (fs.statSync(srcEntryPath).isDirectory()) {
171
+ const files = walkDir(path.join(destSkillsDir, entry));
172
+ for (const filePath of files) {
173
+ const rel = path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath));
174
+ copied.push(rel);
175
+ fileHashes[rel] = hashFile(filePath);
176
+ }
160
177
  }
161
178
  }
162
179
  }
@@ -182,6 +199,42 @@ function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
182
199
  }
183
200
  return copied;
184
201
  }
202
+ /**
203
+ * Symlink each skill folder under `<targetDir>/skills/` into
204
+ * `<targetDir>/.claude/skills/` so Claude Code's built-in Skill tool
205
+ * auto-discovers them alongside any plugin-installed skills.
206
+ *
207
+ * Per-folder symlinks (rather than a single dir-level symlink) so existing
208
+ * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
209
+ * are preserved.
210
+ *
211
+ * Idempotent. Returns the relative paths of symlinks created or already present.
212
+ */
213
+ function linkSkillsToClaude(targetDir) {
214
+ const skillsDir = path.join(targetDir, 'skills');
215
+ if (!fs.existsSync(skillsDir))
216
+ return [];
217
+ const claudeSkillsDir = path.join(targetDir, '.claude', 'skills');
218
+ fs.mkdirSync(claudeSkillsDir, { recursive: true });
219
+ const linked = [];
220
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
221
+ if (!entry.isDirectory())
222
+ continue;
223
+ const linkPath = path.join(claudeSkillsDir, entry.name);
224
+ const target = path.join('..', '..', 'skills', entry.name);
225
+ const rel = path.join('.claude', 'skills', entry.name);
226
+ const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false });
227
+ if (existing) {
228
+ if (existing.isSymbolicLink() && fs.readlinkSync(linkPath) === target) {
229
+ linked.push(rel);
230
+ }
231
+ continue;
232
+ }
233
+ fs.symlinkSync(target, linkPath);
234
+ linked.push(rel);
235
+ }
236
+ return linked;
237
+ }
185
238
  /**
186
239
  * Copy capability files from the capabilities/ source tree (test fixtures only).
187
240
  */
@@ -198,7 +251,13 @@ function copyFromSourceTree(targetDir, capabilityName) {
198
251
  (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
199
252
  const entries = fs.readdirSync(skillsDir);
200
253
  for (const entry of entries) {
201
- copied.push(path.join('skills', entry));
254
+ const srcEntryPath = path.join(skillsDir, entry);
255
+ if (fs.statSync(srcEntryPath).isDirectory()) {
256
+ const files = walkDir(path.join(destSkillsDir, entry));
257
+ for (const filePath of files) {
258
+ copied.push(path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath)));
259
+ }
260
+ }
202
261
  }
203
262
  }
204
263
  // Scaffold files declared in the capability manifest
@@ -481,97 +540,3 @@ function collectHooks(capabilities) {
481
540
  hookPostRoutes: renderPhase('postRoutes'),
482
541
  };
483
542
  }
484
- /**
485
- * Preamble for generated skills/index.md.
486
- * Edit here to change the header text in every generated project.
487
- */
488
- const SKILLS_INDEX_PREAMBLE = [
489
- '# About Skills',
490
- '## Writing new skills',
491
- 'When writing a new skill, create a markdown file in this directory using the pattern `<topic>.skill.md`.',
492
- 'Each skill is a concise set of concrete steps that an AI agent can take to achieve a specific goal or perform a specific task.',
493
- 'Preface the skill with "When you ... " to help the agent understand when to use it.',
494
- '',
495
- '## Current skills',
496
- ];
497
- /**
498
- * Category display names for skill file prefixes.
499
- * Unknown prefixes get title-cased automatically.
500
- */
501
- const SKILL_CATEGORIES = {
502
- code: 'Code',
503
- design: 'Design',
504
- product: 'Product',
505
- proj: 'Project',
506
- reqs: 'Requirements and Docs',
507
- skills: 'Skills',
508
- test: 'Testing',
509
- web: 'Web',
510
- world: 'World',
511
- };
512
- /**
513
- * Extract the H1 title and first content line from a skill markdown file.
514
- */
515
- function parseSkillFile(content) {
516
- const lines = content.split('\n');
517
- let title = '';
518
- let description = '';
519
- for (const line of lines) {
520
- const trimmed = line.trim();
521
- if (!title && trimmed.startsWith('# ')) {
522
- title = trimmed.slice(2).trim();
523
- continue;
524
- }
525
- if (title && !description && trimmed.length > 0 && !trimmed.startsWith('#')) {
526
- description = trimmed;
527
- break;
528
- }
529
- }
530
- return { title: title || 'Untitled', description };
531
- }
532
- /**
533
- * Generate skills/index.md from the .skill.md files present in the project.
534
- * Overwrites any existing index.md.
535
- */
536
- function generateSkillsIndex(targetDir) {
537
- const skillsDir = path.join(targetDir, 'skills');
538
- if (!fs.existsSync(skillsDir))
539
- return;
540
- const files = fs.readdirSync(skillsDir)
541
- .filter(f => f.endsWith('.skill.md'))
542
- .sort();
543
- if (files.length === 0)
544
- return;
545
- const entries = [];
546
- for (const filename of files) {
547
- const content = fs.readFileSync(path.join(skillsDir, filename), 'utf-8');
548
- const { title, description } = parseSkillFile(content);
549
- const category = filename.split('.')[0];
550
- entries.push({ filename, title, description, category });
551
- }
552
- // Group by category
553
- const grouped = new Map();
554
- for (const entry of entries) {
555
- const list = grouped.get(entry.category) || [];
556
- list.push(entry);
557
- grouped.set(entry.category, list);
558
- }
559
- // Sort categories by display name
560
- const sortedCategories = [...grouped.keys()].sort((a, b) => {
561
- const nameA = SKILL_CATEGORIES[a] || a;
562
- const nameB = SKILL_CATEGORIES[b] || b;
563
- return nameA.localeCompare(nameB);
564
- });
565
- const lines = [...SKILLS_INDEX_PREAMBLE];
566
- for (const category of sortedCategories) {
567
- const displayName = SKILL_CATEGORIES[category]
568
- || category.charAt(0).toUpperCase() + category.slice(1);
569
- lines.push(`### ${displayName}`);
570
- for (const skill of grouped.get(category)) {
571
- const desc = skill.description ? ` - ${skill.description}` : '';
572
- lines.push(`- [**${skill.title}**](${skill.filename})${desc}`);
573
- }
574
- lines.push('');
575
- }
576
- fs.writeFileSync(path.join(skillsDir, 'index.md'), lines.join('\n'), 'utf-8');
577
- }
@@ -10,7 +10,7 @@ export interface FileUpdateAction {
10
10
  /** Relative path from project root */
11
11
  relativePath: string;
12
12
  /** What will happen to this file */
13
- action: 'overwrite' | 'skip' | 'add' | 'deprecate';
13
+ action: 'overwrite' | 'skip' | 'add' | 'deprecate' | 'migrate';
14
14
  /** Why this action was chosen */
15
15
  reason: string;
16
16
  /** Stored hash from config */
@@ -19,6 +19,8 @@ export interface FileUpdateAction {
19
19
  currentHash?: string;
20
20
  /** New hash from package */
21
21
  newHash?: string;
22
+ /** For migrate actions: the old file path being replaced */
23
+ oldPath?: string;
22
24
  }
23
25
  export interface UpdatePlan {
24
26
  capability: string;
@@ -46,6 +48,8 @@ export interface UpdateResult {
46
48
  skippedDeleted: string[];
47
49
  /** Files removed from disk via --prune */
48
50
  pruned: string[];
51
+ /** Files migrated from old flat format to new folder format */
52
+ migrated: string[];
49
53
  /** Count of files unchanged in both package and locally */
50
54
  unchangedCount: number;
51
55
  }
@@ -92,15 +92,19 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
92
92
  collectFiles(scaffoldDir, scaffoldDir, packageFiles);
93
93
  }
94
94
  }
95
- // Collect files from package skills dir
95
+ // Collect files from package skills dir (both flat files and folder-based skills)
96
96
  if (boopkit.skillsDir) {
97
97
  const skillsDir = path.join(pkgDir, boopkit.skillsDir);
98
98
  if (fs.existsSync(skillsDir)) {
99
99
  const entries = fs.readdirSync(skillsDir);
100
100
  for (const entry of entries) {
101
101
  const srcPath = path.join(skillsDir, entry);
102
- if (fs.statSync(srcPath).isFile()) {
103
- packageFiles.set(path.join('skills', entry), srcPath);
102
+ if (fs.statSync(srcPath).isDirectory()) {
103
+ const subFiles = new Map();
104
+ collectFiles(srcPath, srcPath, subFiles);
105
+ for (const [subRel, subAbs] of subFiles) {
106
+ packageFiles.set(path.join('skills', entry, subRel), subAbs);
107
+ }
104
108
  }
105
109
  }
106
110
  }
@@ -184,6 +188,53 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
184
188
  });
185
189
  }
186
190
  }
191
+ // Apply skill migration logic: upgrade deprecate+add pairs to migrate actions
192
+ const capJsonPath = path.join(pkgDir, 'capability.json');
193
+ if (fs.existsSync(capJsonPath)) {
194
+ const capJson = JSON.parse(fs.readFileSync(capJsonPath, 'utf-8'));
195
+ const migrations = capJson.skill_migrations;
196
+ if (migrations) {
197
+ for (const [newSkillName, oldFlatFilename] of Object.entries(migrations)) {
198
+ const oldRelPath = path.join('skills', oldFlatFilename);
199
+ const newPrefix = path.join('skills', newSkillName) + path.sep;
200
+ const oldStoredHash = storedHashes[oldRelPath];
201
+ if (!oldStoredHash)
202
+ continue;
203
+ const oldFilePath = path.join(targetDir, oldRelPath);
204
+ const oldState = compareFileHash(oldFilePath, oldStoredHash);
205
+ if (oldState === 'modified') {
206
+ // User modified the old file — skip new folder files, keep old file
207
+ for (const action of actions) {
208
+ if (action.relativePath.startsWith(newPrefix) || action.relativePath === path.join('skills', newSkillName)) {
209
+ action.action = 'skip';
210
+ action.reason = `Skipped: old skill file '${oldRelPath}' was locally modified`;
211
+ }
212
+ }
213
+ // Change deprecate to skip for the old file
214
+ const oldAction = actions.find(a => a.relativePath === oldRelPath);
215
+ if (oldAction) {
216
+ oldAction.action = 'skip';
217
+ oldAction.reason = 'Locally modified (migration skipped)';
218
+ }
219
+ }
220
+ else if (oldState === 'match') {
221
+ // Old file unmodified — convert deprecate to migrate
222
+ const oldAction = actions.find(a => a.relativePath === oldRelPath);
223
+ if (oldAction) {
224
+ oldAction.action = 'migrate';
225
+ oldAction.reason = `Migrated to folder: skills/${newSkillName}/`;
226
+ }
227
+ // Tag new folder files with the old path they're replacing
228
+ for (const action of actions) {
229
+ if (action.relativePath.startsWith(newPrefix) || action.relativePath === path.join('skills', newSkillName)) {
230
+ action.oldPath = oldRelPath;
231
+ }
232
+ }
233
+ }
234
+ // oldState === 'missing': old file already gone, add new files normally
235
+ }
236
+ }
237
+ }
187
238
  const upToDate = actions.length === 0;
188
239
  return {
189
240
  capability: capabilityName,
@@ -210,6 +261,7 @@ function executeUpdatePlan(plan, options = {}) {
210
261
  forcedOverwrite: [],
211
262
  skippedDeleted: [],
212
263
  pruned: [],
264
+ migrated: [],
213
265
  unchangedCount: plan.unchangedCount,
214
266
  };
215
267
  const pkgJson = JSON.parse(fs.readFileSync(path.join(plan.packageDir, 'package.json'), 'utf-8'));
@@ -294,6 +346,17 @@ function executeUpdatePlan(plan, options = {}) {
294
346
  }
295
347
  break;
296
348
  }
349
+ case 'migrate': {
350
+ const oldFilePath = path.join(plan.targetDir, action.relativePath);
351
+ if (fs.existsSync(oldFilePath)) {
352
+ (0, safe_io_1.createBackup)(oldFilePath);
353
+ fs.unlinkSync(oldFilePath);
354
+ }
355
+ delete newHashes[action.relativePath];
356
+ result.migrated.push(action.relativePath);
357
+ console.warn(`\x1b[33mWARN\x1b[0m Migrated skill: ${action.relativePath} → ${action.reason}`);
358
+ break;
359
+ }
297
360
  }
298
361
  }
299
362
  // Update .boop/config.json
@@ -314,10 +377,10 @@ function executeUpdatePlan(plan, options = {}) {
314
377
  * Resolve the source path for a relative file path from the package.
315
378
  */
316
379
  function resolveSourcePath(pkgDir, boopkit, relativePath) {
317
- // Skills are stored as skills/<filename> in relPath
380
+ // Skills are stored as skills/<filename> or skills/<folder>/<file> in relPath
318
381
  if (relativePath.startsWith('skills/') && boopkit.skillsDir) {
319
- const filename = relativePath.slice('skills/'.length);
320
- const srcPath = path.join(pkgDir, boopkit.skillsDir, filename);
382
+ const subPath = relativePath.slice('skills/'.length);
383
+ const srcPath = path.join(pkgDir, boopkit.skillsDir, subPath);
321
384
  return fs.existsSync(srcPath) ? srcPath : null;
322
385
  }
323
386
  // Scaffold files
@@ -348,6 +411,9 @@ function collectFiles(baseDir, dir, result) {
348
411
  */
349
412
  function formatUpdateSummary(result) {
350
413
  const parts = [];
414
+ if (result.migrated && result.migrated.length > 0) {
415
+ parts.push(`migrated ${result.migrated.length} skill${result.migrated.length === 1 ? '' : 's'}`);
416
+ }
351
417
  const normalOverwrites = result.overwritten.length - result.forcedOverwrite.length;
352
418
  if (normalOverwrites > 0) {
353
419
  parts.push(`Updated ${normalOverwrites} file${normalOverwrites === 1 ? '' : 's'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boopkit/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "boop: the opinionated bootstrap for founder friendly, AI-first web apps - code plus agent skills, composable from day one.",
5
5
  "main": "dist/cli.js",
6
6
  "workspaces": [
@@ -20,8 +20,8 @@
20
20
  "scripts": {
21
21
  "build": "tsc && node tools/generate-version-manifest.js",
22
22
  "build:caps": "./tools/build-caps.sh",
23
- "publish" : "./tools/publish-caps.sh",
24
- "release" : "./tools/publish-caps.sh --release",
23
+ "publish": "./tools/publish-caps.sh",
24
+ "release": "./tools/publish-caps.sh --release",
25
25
  "dev": "tsx src/cli.ts",
26
26
  "start": "node dist/cli.js",
27
27
  "test": "jest"
@@ -24,7 +24,7 @@ await MessageQueueService.publish('batch-ready', [message1, message2]);
24
24
  - **Reuse**: The same connection and channel are reused for all subsequent publishes
25
25
  - **Auto-reconnect**: If the connection drops (`error` or `close` event), the cached reference is cleared and the next `publish()` call opens a fresh connection
26
26
  - **Error recovery**: If `publish()` catches an error (e.g., channel closed), it resets connection state and returns `false` — the next call will reconnect
27
- - **Shutdown**: The publish connection is tracked in `listenerConnections` and closed by the coordinated SIGINT handler alongside listener connections
27
+ - **Shutdown**: The publish connection is tracked and closed by `close()` alongside the listener connection
28
28
 
29
29
  ### Why persistent (not per-call)
30
30
 
@@ -36,6 +36,21 @@ The original implementation opened a new AMQP connection for every `publish()` c
36
36
  |--------|-------|-----------|
37
37
  | Scheduler jobs | various | 1-2 per 10-min cycle |
38
38
 
39
+ ## Listening
40
+
41
+ `MessageQueueService.listen()` uses a **shared AMQP connection** across all queue consumers. Each `listen()` call creates a separate **channel** on the shared connection. This keeps connection count at 2 (1 publish + 1 listen) regardless of how many queues are consumed.
42
+
43
+ ### Connection lifecycle
44
+
45
+ - **Lazy init**: No connection is opened until the first `listen()` call
46
+ - **Shared**: All listeners share the same connection; each gets its own channel with independent prefetch and consumer
47
+ - **Auto-reconnect**: If the connection drops (`error` or `close` event), the cached reference is cleared and the next `listen()` call opens a fresh connection
48
+ - **Shutdown**: The listener connection is closed by `close()` alongside the publish connection
49
+
50
+ ### Why shared (not per-queue)
51
+
52
+ CloudAMQP's shared plan has a 20-connection limit. The previous implementation opened a new AMQP connection per `listen()` call. AMQP supports multiple channels on a single connection — each channel can have its own prefetch and consumer — so sharing one connection scales to any number of queue consumers without hitting connection limits.
53
+
39
54
  ## Consumer Modes
40
55
 
41
56
  The `MessageQueueService.listen()` function supports two modes controlled by `opts.manualAck`:
@@ -9,8 +9,7 @@ Don't confirm a hypothesis (or take action) without observation - logs, tests, a
9
9
 
10
10
  ## How to work in this project
11
11
 
12
- 0. **Read the skills index now.** Open [skills/index.md](skills/index.md) before doing anything else in this session. You need to know what skills exist before you can apply rule 1.
13
- 1. **Check skills first.** Before coding, reviewing, testing, designing, debugging, filing issues, or proposing any non-trivial change, check [skills/index.md](skills/index.md) for a matching skill and follow its process. Do not rely on general knowledge when a project skill exists.
12
+ 1. **Prefer project skills over general knowledge.** When a skill matches your task — coding, reviewing, testing, designing, debugging, filing issues — follow its `SKILL.md` instead of working from memory. Skills live in `skills/` (each is a folder with a `SKILL.md`) and are also exposed at `.claude/skills/` for tooling that auto-discovers there. If your tooling does not auto-surface skills, list `skills/` and read each `SKILL.md`'s frontmatter (`name`, `description`) to find a match.
14
13
  2. **Use boop for capabilities.** Run `boop caps` to see what's available. To add features like database, auth, or other services and infrastructure, run `boop add <capability>` — don't install or wire things manually.
15
14
  3. **Check project status.** If the system isn't working or new capabilities need to be added run `boop status` to see what's configured and what needs attention.
16
15
  4. **Run `boop --help`** for the full command list.
@@ -21,7 +20,7 @@ Read [reference/boop-guide.ref.md](reference/boop-guide.ref.md) to understand ho
21
20
 
22
21
  ## Project layout
23
22
  - `src/` — source code
24
- - `skills/` — process skills for agents (read `skills/index.md`)
23
+ - `skills/` — process skills for agents (each skill is a folder with a `SKILL.md`); also linked from `.claude/skills/` for Claude Code's built-in skill discovery
25
24
  - `reference/` — architecture and coding standards ("what exists now")
26
25
  - `specs/` — design docs and specifications ("future state")
27
26
  - `tests/` — `npm test` (Jest)