@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 +2 -2
- package/dist/capability-versions.json +16 -16
- package/dist/commands/init.js +2 -2
- package/dist/commands/loop.js +1 -1
- package/dist/commands/status.js +3 -1
- package/dist/commands/update.js +11 -1
- package/dist/lib/db-status.d.ts +13 -1
- package/dist/lib/db-status.js +97 -3
- package/dist/lib/execute.js +1 -4
- package/dist/lib/manifest.d.ts +1 -1
- package/dist/lib/preflight.js +0 -8
- package/dist/lib/scaffold.d.ts +12 -5
- package/dist/lib/scaffold.js +65 -100
- package/dist/lib/update.d.ts +5 -1
- package/dist/lib/update.js +72 -6
- package/package.json +3 -3
- package/profiles/web-app/reference/message-queue-patterns.ref.md +16 -1
- package/src/templates/kernel/AGENTS.md.hbs +2 -3
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** —
|
|
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
|
|
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
|
-
"@boopkit/cap-background-jobs": "0.
|
|
4
|
-
"@boopkit/cap-cloud-storage": "0.
|
|
5
|
-
"@boopkit/cap-coding": "0.
|
|
6
|
-
"@boopkit/cap-database": "0.
|
|
7
|
-
"@boopkit/cap-email": "0.
|
|
8
|
-
"@boopkit/cap-hello": "0.
|
|
9
|
-
"@boopkit/cap-noticeboard": "0.
|
|
10
|
-
"@boopkit/cap-product-design": "0.
|
|
11
|
-
"@boopkit/cap-project-mgmt": "0.
|
|
12
|
-
"@boopkit/cap-services": "0.
|
|
13
|
-
"@boopkit/cap-skills": "0.
|
|
14
|
-
"@boopkit/cap-testing": "0.
|
|
15
|
-
"@boopkit/cap-users": "0.
|
|
16
|
-
"@boopkit/cap-web": "0.
|
|
17
|
-
"@boopkit/logger": "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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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 = [];
|
package/dist/commands/loop.js
CHANGED
|
@@ -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/
|
|
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}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/commands/update.js
CHANGED
|
@@ -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[
|
|
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)`);
|
package/dist/lib/db-status.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/lib/db-status.js
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/dist/lib/execute.js
CHANGED
|
@@ -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);
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -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' | '
|
|
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;
|
package/dist/lib/preflight.js
CHANGED
|
@@ -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',
|
package/dist/lib/scaffold.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/lib/scaffold.js
CHANGED
|
@@ -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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/dist/lib/update.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/lib/update.js
CHANGED
|
@@ -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).
|
|
103
|
-
|
|
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
|
|
320
|
-
const srcPath = path.join(pkgDir, boopkit.skillsDir,
|
|
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
|
+
"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"
|
|
24
|
-
"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
|
|
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
|
-
|
|
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 (
|
|
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)
|