bmad-method 6.6.0 → 6.6.1-next.1
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/package.json
CHANGED
|
@@ -6,6 +6,125 @@ const csv = require('csv-parse/sync');
|
|
|
6
6
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
|
7
7
|
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
|
|
8
8
|
|
|
9
|
+
// Reserved OpenCode slash commands. A skill whose canonicalId collides with
|
|
10
|
+
// one of these is skipped during command-pointer generation so it doesn't
|
|
11
|
+
// shadow a built-in.
|
|
12
|
+
const RESERVED_OPENCODE_COMMANDS = new Set([
|
|
13
|
+
'review',
|
|
14
|
+
'commit',
|
|
15
|
+
'init',
|
|
16
|
+
'help',
|
|
17
|
+
'skills',
|
|
18
|
+
'fast',
|
|
19
|
+
'compact',
|
|
20
|
+
'clear',
|
|
21
|
+
'undo',
|
|
22
|
+
'redo',
|
|
23
|
+
'edit',
|
|
24
|
+
'editor',
|
|
25
|
+
'exit',
|
|
26
|
+
'quit',
|
|
27
|
+
'theme',
|
|
28
|
+
'config',
|
|
29
|
+
'model',
|
|
30
|
+
'session',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Wrap a description for safe insertion into single-line YAML frontmatter.
|
|
34
|
+
// Leaves plain values untouched; double-quotes (and escapes) anything that
|
|
35
|
+
// could break YAML parsing or span multiple lines.
|
|
36
|
+
function yamlSafeSingleLine(value) {
|
|
37
|
+
const collapsed = String(value)
|
|
38
|
+
.replaceAll(/[\r\n]+/g, ' ')
|
|
39
|
+
.trim();
|
|
40
|
+
const needsQuoting = /[:#'"\\]/.test(collapsed) || /^[!&*?|>%@`[{]/.test(collapsed);
|
|
41
|
+
if (!needsQuoting) return collapsed;
|
|
42
|
+
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`);
|
|
43
|
+
return `"${escaped}"`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate that a canonicalId is a safe basename — no path separators, no
|
|
47
|
+
// parent-dir traversal, no leading dots, only the character set we expect.
|
|
48
|
+
// Defense-in-depth: the manifest is trusted today, but the value flows
|
|
49
|
+
// directly into a file path and a malformed entry should not write outside
|
|
50
|
+
// the commands directory.
|
|
51
|
+
function isSafeCanonicalId(value) {
|
|
52
|
+
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Default body template for command pointer files. Used when a platform's
|
|
56
|
+
// installer config doesn't override `commands_body_template`. Matches
|
|
57
|
+
// OpenCode's native `@skills/<id>` skill-reference syntax.
|
|
58
|
+
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
|
|
59
|
+
|
|
60
|
+
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)?
|
|
61
|
+
// Used by platforms that surface only persona agents (e.g. Copilot's Custom
|
|
62
|
+
// Agents picker). Signal: the skill's source `customize.toml` has an
|
|
63
|
+
// `[agent]` section. This is the actual configuration source of truth —
|
|
64
|
+
// every BMAD persona is configured via [agent] in its customize.toml,
|
|
65
|
+
// every workflow uses [workflow], every standalone skill has no
|
|
66
|
+
// customize.toml at all. Verified against the full installed manifest:
|
|
67
|
+
// catches exactly the 20 description-confirmed personas across BMM, CIS,
|
|
68
|
+
// GDS, WDS, TEA, and correctly excludes meta-skills like
|
|
69
|
+
// `bmad-agent-builder` (a skill-builder workflow whose canonical id
|
|
70
|
+
// contains `-agent-` but which has no [agent] section because it isn't a
|
|
71
|
+
// persona itself).
|
|
72
|
+
//
|
|
73
|
+
// Reading the source toml — at install time the source skill directory
|
|
74
|
+
// (resolved from manifest record.path) still exists; cleanup runs later
|
|
75
|
+
// in the install flow.
|
|
76
|
+
async function isAgentSkill(record, bmadDir) {
|
|
77
|
+
if (!record?.path || !bmadDir) return false;
|
|
78
|
+
const bmadFolderName = path.basename(bmadDir);
|
|
79
|
+
const bmadPrefix = bmadFolderName + '/';
|
|
80
|
+
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
|
81
|
+
const tomlPath = path.join(bmadDir, path.dirname(relativePath), 'customize.toml');
|
|
82
|
+
if (!(await fs.pathExists(tomlPath))) return false;
|
|
83
|
+
try {
|
|
84
|
+
const content = await fs.readFile(tomlPath, 'utf8');
|
|
85
|
+
return /^\[agent\]/m.test(content);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve placeholders in a body template. Supported placeholders:
|
|
92
|
+
// {canonicalId} — the skill's canonical id
|
|
93
|
+
// {target_dir} — the platform's skill install directory (e.g. .agents/skills)
|
|
94
|
+
// {project-root} — left as a literal placeholder for the model/tool to expand
|
|
95
|
+
// at runtime; consistent with PR #1769's templates.
|
|
96
|
+
function expandBodyTemplate(template, { canonicalId, targetDir }) {
|
|
97
|
+
return template.replaceAll('{canonicalId}', canonicalId).replaceAll('{target_dir}', targetDir);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The exact body the installer would generate for a given description and
|
|
101
|
+
// canonicalId, given the platform's body template. Centralised so both the
|
|
102
|
+
// write and the freshness-check paths agree on the canonical form.
|
|
103
|
+
function buildCommandPointerBody(description, canonicalId, { template, targetDir }) {
|
|
104
|
+
const bodyText = expandBodyTemplate(template, { canonicalId, targetDir });
|
|
105
|
+
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n${bodyText}\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Heuristic: does an existing pointer file look like our generator's output
|
|
109
|
+
// (and therefore safe to refresh) versus a user-modified file (which we
|
|
110
|
+
// preserve)? We check the body shape rather than full equality so that
|
|
111
|
+
// description-only edits in the manifest can propagate without trampling
|
|
112
|
+
// hand edits to the body.
|
|
113
|
+
function looksLikeGeneratorOutput(content, canonicalId, { template, targetDir }) {
|
|
114
|
+
if (typeof content !== 'string') return false;
|
|
115
|
+
const trimmed = content.trim();
|
|
116
|
+
const expectedTail = expandBodyTemplate(template, { canonicalId, targetDir }).trim();
|
|
117
|
+
// Must end with the exact body our generator writes (post-expansion).
|
|
118
|
+
if (!trimmed.endsWith(expectedTail)) return false;
|
|
119
|
+
// Must start with frontmatter containing exactly one description: line.
|
|
120
|
+
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
|
|
121
|
+
if (!fmMatch) return false;
|
|
122
|
+
const fmLines = fmMatch[1].split('\n').filter((l) => l.length > 0);
|
|
123
|
+
if (fmLines.length !== 1) return false;
|
|
124
|
+
if (!fmLines[0].startsWith('description:')) return false;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
9
128
|
/**
|
|
10
129
|
* Config-driven IDE setup handler
|
|
11
130
|
*
|
|
@@ -97,9 +216,15 @@ class ConfigDrivenIdeSetup {
|
|
|
97
216
|
}
|
|
98
217
|
|
|
99
218
|
// When a peer platform in the same install batch owns this target_dir,
|
|
100
|
-
// skip the skill write — the peer has already populated it.
|
|
219
|
+
// skip the skill write — the peer has already populated it. Command
|
|
220
|
+
// pointers, however, write to a separate per-IDE directory and must
|
|
221
|
+
// still be generated for this IDE; they are not deduped across peers.
|
|
101
222
|
if (options.skipTarget) {
|
|
102
|
-
|
|
223
|
+
const results = { skills: 0, sharedTargetHandledByPeer: true };
|
|
224
|
+
if (this.installerConfig.commands_target_dir) {
|
|
225
|
+
results.commands = await this.installCommandPointers(projectDir, bmadDir, this.installerConfig, options);
|
|
226
|
+
}
|
|
227
|
+
return { success: true, results };
|
|
103
228
|
}
|
|
104
229
|
|
|
105
230
|
if (this.installerConfig.target_dir) {
|
|
@@ -128,11 +253,157 @@ class ConfigDrivenIdeSetup {
|
|
|
128
253
|
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
|
129
254
|
results.skillDirectories = this.skillWriteTracker.size;
|
|
130
255
|
|
|
256
|
+
if (config.commands_target_dir) {
|
|
257
|
+
results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options);
|
|
258
|
+
}
|
|
259
|
+
|
|
131
260
|
await this.printSummary(results, target_dir, options);
|
|
132
261
|
this.skillWriteTracker = null;
|
|
133
262
|
return { success: true, results };
|
|
134
263
|
}
|
|
135
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Generate per-skill command pointer files for IDEs that surface commands
|
|
267
|
+
* separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
|
|
268
|
+
*
|
|
269
|
+
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
|
|
270
|
+
* so invoking `/<canonicalId>` routes the user straight to the skill instead
|
|
271
|
+
* of forcing them through a `/skills` menu.
|
|
272
|
+
*
|
|
273
|
+
* Skips:
|
|
274
|
+
* - Names that collide with reserved built-in slash commands.
|
|
275
|
+
* - canonicalIds that aren't safe basename-only identifiers (defense
|
|
276
|
+
* against path traversal even though the manifest is currently trusted).
|
|
277
|
+
* - Existing files whose body looks user-modified (preserves hand edits);
|
|
278
|
+
* pointer files matching the generator pattern get overwritten so that
|
|
279
|
+
* description changes in skill-manifest.csv propagate on re-install.
|
|
280
|
+
*
|
|
281
|
+
* Per-file write failures are recorded and reported but do not abort the
|
|
282
|
+
* rest of the install — pointer files are a non-essential adjunct to the
|
|
283
|
+
* skill copy that already succeeded.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} projectDir
|
|
286
|
+
* @param {string} bmadDir
|
|
287
|
+
* @param {Object} config - Installer config; reads commands_target_dir.
|
|
288
|
+
* @param {Object} options - Setup options. forceCommands overwrites existing
|
|
289
|
+
* files unconditionally (including hand-modified ones).
|
|
290
|
+
* @returns {Promise<Object>} { created, updated, skippedExisting, skippedCollision, skippedInvalidId, writeFailures, fallbackDescription }
|
|
291
|
+
*/
|
|
292
|
+
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
|
|
293
|
+
const result = {
|
|
294
|
+
created: 0,
|
|
295
|
+
updated: 0,
|
|
296
|
+
skippedExisting: 0,
|
|
297
|
+
skippedCollision: 0,
|
|
298
|
+
skippedInvalidId: 0,
|
|
299
|
+
skippedFiltered: 0,
|
|
300
|
+
writeFailures: 0,
|
|
301
|
+
fallbackDescription: 0,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
|
305
|
+
if (!(await fs.pathExists(csvPath))) return result;
|
|
306
|
+
|
|
307
|
+
const commandsPath = path.join(projectDir, config.commands_target_dir);
|
|
308
|
+
await fs.ensureDir(commandsPath);
|
|
309
|
+
|
|
310
|
+
// Per-platform pointer-file shape, all overrideable in platform-codes.yaml.
|
|
311
|
+
const extension = config.commands_extension || '.md';
|
|
312
|
+
const template = config.commands_body_template || DEFAULT_COMMANDS_BODY_TEMPLATE;
|
|
313
|
+
const targetDir = config.target_dir;
|
|
314
|
+
const filter = config.commands_filter || null;
|
|
315
|
+
|
|
316
|
+
const csvContent = await fs.readFile(csvPath, 'utf8');
|
|
317
|
+
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
|
|
318
|
+
|
|
319
|
+
for (const record of records) {
|
|
320
|
+
const canonicalId = record.canonicalId;
|
|
321
|
+
if (!canonicalId) continue;
|
|
322
|
+
|
|
323
|
+
// Defensive basename validation. canonicalId comes from a trusted
|
|
324
|
+
// manifest today, but the value flows directly into a file path —
|
|
325
|
+
// reject anything that could escape commands_target_dir.
|
|
326
|
+
if (!isSafeCanonicalId(canonicalId)) {
|
|
327
|
+
result.skippedInvalidId++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Optional per-platform filter: surfaces that should only show
|
|
332
|
+
// persona agents (e.g. Copilot's Custom Agents picker) skip
|
|
333
|
+
// workflow/tool skills here so the picker isn't cluttered with
|
|
334
|
+
// 90+ unrelated entries.
|
|
335
|
+
if (filter === 'agents-only' && !(await isAgentSkill(record, bmadDir))) {
|
|
336
|
+
result.skippedFiltered++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Reserved-name guard is OpenCode-specific. Other adapters that opt
|
|
341
|
+
// into commands_target_dir later should declare their own reserved
|
|
342
|
+
// set rather than inheriting OpenCode's.
|
|
343
|
+
if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
|
|
344
|
+
result.skippedCollision++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let description = (record.description || '').trim();
|
|
349
|
+
if (!description) {
|
|
350
|
+
description = `Run the ${canonicalId} skill`;
|
|
351
|
+
result.fallbackDescription++;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const body = buildCommandPointerBody(description, canonicalId, { template, targetDir });
|
|
355
|
+
const commandFile = path.join(commandsPath, `${canonicalId}${extension}`);
|
|
356
|
+
|
|
357
|
+
// If a pointer file already exists, decide whether to overwrite based
|
|
358
|
+
// on whether it looks like generator output (description-only diff) or
|
|
359
|
+
// a user-modified file. forceCommands overrides this protection.
|
|
360
|
+
if (!options.forceCommands && (await fs.pathExists(commandFile))) {
|
|
361
|
+
let existing;
|
|
362
|
+
try {
|
|
363
|
+
existing = await fs.readFile(commandFile, 'utf8');
|
|
364
|
+
} catch {
|
|
365
|
+
// Treat unreadable as user-owned and skip — safer than overwriting.
|
|
366
|
+
result.skippedExisting++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (existing === body) {
|
|
371
|
+
// No-op idempotent re-run.
|
|
372
|
+
result.skippedExisting++;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (looksLikeGeneratorOutput(existing, canonicalId, { template, targetDir })) {
|
|
376
|
+
// Description (or other generated bit) has changed; refresh in place.
|
|
377
|
+
try {
|
|
378
|
+
await fs.writeFile(commandFile, body, 'utf8');
|
|
379
|
+
result.updated++;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
result.writeFailures++;
|
|
382
|
+
if (!options.silent) {
|
|
383
|
+
await prompts.log.warn(`Failed to update command pointer ${canonicalId}${extension}: ${error.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// Hand-modified pointer — preserve it.
|
|
389
|
+
result.skippedExisting++;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
await fs.writeFile(commandFile, body, 'utf8');
|
|
395
|
+
result.created++;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
result.writeFailures++;
|
|
398
|
+
if (!options.silent) {
|
|
399
|
+
await prompts.log.warn(`Failed to write command pointer ${canonicalId}${extension}: ${error.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
|
|
136
407
|
/**
|
|
137
408
|
* Install verbatim native SKILL.md directories from skill-manifest.csv.
|
|
138
409
|
* Copies the entire source directory as-is into the IDE skill directory.
|
|
@@ -207,6 +478,18 @@ class ConfigDrivenIdeSetup {
|
|
|
207
478
|
if (count > 0) {
|
|
208
479
|
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
|
209
480
|
}
|
|
481
|
+
const cmd = results.commands;
|
|
482
|
+
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
|
|
483
|
+
const total = cmd.created + cmd.updated;
|
|
484
|
+
const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`;
|
|
485
|
+
await prompts.log.success(`${this.name} commands: ${detail} → ${this.installerConfig.commands_target_dir}`);
|
|
486
|
+
if (cmd.skippedCollision > 0) {
|
|
487
|
+
await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`);
|
|
488
|
+
}
|
|
489
|
+
if (cmd.writeFailures > 0) {
|
|
490
|
+
await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
210
493
|
}
|
|
211
494
|
|
|
212
495
|
/**
|
|
@@ -247,6 +530,36 @@ class ConfigDrivenIdeSetup {
|
|
|
247
530
|
await this.cleanupRovoDevPrompts(projectDir, options);
|
|
248
531
|
}
|
|
249
532
|
|
|
533
|
+
// Clean generated command pointer files in commands_target_dir.
|
|
534
|
+
// Mirrors target_dir cleanup so uninstalls and skill removals don't
|
|
535
|
+
// leave dangling /<canonicalId> commands pointing at missing skills.
|
|
536
|
+
// Runs regardless of skipTarget — command pointers live in a per-IDE
|
|
537
|
+
// directory and are not deduped across peers, so a peer-owned shared
|
|
538
|
+
// skills directory does not protect this IDE's command pointers from
|
|
539
|
+
// cleanup. The "currently active" set is passed so install-flow cleanup
|
|
540
|
+
// (where removalSet contains skills that will be re-added moments later)
|
|
541
|
+
// doesn't trample hand-edited pointers; install-flow cleanup will only
|
|
542
|
+
// delete pointers for skills that are not in the new manifest.
|
|
543
|
+
if (this.installerConfig?.commands_target_dir) {
|
|
544
|
+
// In the install/update flow (signal: previousSkillIds was passed),
|
|
545
|
+
// spare pointers whose canonicalId is still in the manifest so hand
|
|
546
|
+
// edits survive a routine reinstall. In the uninstall flow (no
|
|
547
|
+
// previousSkillIds — full uninstall or per-IDE removal via
|
|
548
|
+
// cleanupByList), don't spare anything; the IDE itself is going away,
|
|
549
|
+
// so its pointers should go with it.
|
|
550
|
+
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
|
|
551
|
+
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
|
|
552
|
+
const extension = this.installerConfig.commands_extension || '.md';
|
|
553
|
+
await this.cleanupCommandPointers(
|
|
554
|
+
projectDir,
|
|
555
|
+
this.installerConfig.commands_target_dir,
|
|
556
|
+
options,
|
|
557
|
+
removalSet,
|
|
558
|
+
activeSkillIds,
|
|
559
|
+
extension,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
250
563
|
// Skip target_dir cleanup when a peer platform owns this directory
|
|
251
564
|
// (set during dedup'd install or when uninstalling one of several
|
|
252
565
|
// platforms that share the same target_dir).
|
|
@@ -346,6 +659,97 @@ class ConfigDrivenIdeSetup {
|
|
|
346
659
|
}
|
|
347
660
|
}
|
|
348
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Cleanup generated command pointer files for entries in removalSet.
|
|
664
|
+
* Symmetric counterpart to installCommandPointers — removes
|
|
665
|
+
* `<canonicalId><extension>` files whose canonicalId is in the set. Removes
|
|
666
|
+
* the commands directory entirely if it ends up empty.
|
|
667
|
+
* @param {string} projectDir
|
|
668
|
+
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
|
|
669
|
+
* @param {Object} options
|
|
670
|
+
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
|
|
671
|
+
* @param {Set<string>} [activeSkillIds] - canonicalIds present in the
|
|
672
|
+
* current manifest. Pointers for IDs in this set are spared so an
|
|
673
|
+
* install-flow cleanup (where removalSet === previousSkillIds and the
|
|
674
|
+
* same skills are about to be re-installed) doesn't wipe hand-edited
|
|
675
|
+
* pointer files. Pass an empty set or omit to delete every match in
|
|
676
|
+
* removalSet (uninstall flow).
|
|
677
|
+
* @param {string} [extension] - Pointer file extension (default '.md');
|
|
678
|
+
* matches the platform's commands_extension config value so cleanup
|
|
679
|
+
* correctly identifies pointer files for IDEs whose convention isn't .md
|
|
680
|
+
* (e.g. Copilot's `.agent.md`).
|
|
681
|
+
*/
|
|
682
|
+
async cleanupCommandPointers(
|
|
683
|
+
projectDir,
|
|
684
|
+
commandsTargetDir,
|
|
685
|
+
options = {},
|
|
686
|
+
removalSet = new Set(),
|
|
687
|
+
activeSkillIds = new Set(),
|
|
688
|
+
extension = '.md',
|
|
689
|
+
) {
|
|
690
|
+
if (!removalSet || removalSet.size === 0) return;
|
|
691
|
+
|
|
692
|
+
const commandsPath = path.join(projectDir, commandsTargetDir);
|
|
693
|
+
if (!(await fs.pathExists(commandsPath))) return;
|
|
694
|
+
|
|
695
|
+
let entries;
|
|
696
|
+
try {
|
|
697
|
+
entries = await fs.readdir(commandsPath);
|
|
698
|
+
} catch {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const entry of entries) {
|
|
703
|
+
if (!entry.endsWith(extension)) continue;
|
|
704
|
+
const canonicalId = entry.slice(0, -extension.length);
|
|
705
|
+
if (!removalSet.has(canonicalId)) continue;
|
|
706
|
+
// Spare pointers for skills that are still in the manifest; the
|
|
707
|
+
// install pass will refresh them in place if their content has gone
|
|
708
|
+
// stale, while preserving hand edits.
|
|
709
|
+
if (activeSkillIds.has(canonicalId)) continue;
|
|
710
|
+
try {
|
|
711
|
+
await fs.remove(path.join(commandsPath, entry));
|
|
712
|
+
} catch {
|
|
713
|
+
// Skip files we can't remove.
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Remove the commands directory if we emptied it.
|
|
718
|
+
try {
|
|
719
|
+
const remaining = await fs.readdir(commandsPath);
|
|
720
|
+
if (remaining.length === 0) {
|
|
721
|
+
await fs.remove(commandsPath);
|
|
722
|
+
}
|
|
723
|
+
} catch {
|
|
724
|
+
// Directory may already be gone.
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Read the canonicalIds currently present in the skill-manifest.csv.
|
|
730
|
+
* Used by cleanup to distinguish "re-install of an existing skill"
|
|
731
|
+
* (preserve pointer) from "skill truly being removed" (delete pointer).
|
|
732
|
+
* @param {string|null} bmadDir
|
|
733
|
+
* @returns {Promise<Set<string>>}
|
|
734
|
+
*/
|
|
735
|
+
async _readActiveSkillIds(bmadDir) {
|
|
736
|
+
const ids = new Set();
|
|
737
|
+
if (!bmadDir) return ids;
|
|
738
|
+
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
|
739
|
+
if (!(await fs.pathExists(csvPath))) return ids;
|
|
740
|
+
try {
|
|
741
|
+
const content = await fs.readFile(csvPath, 'utf8');
|
|
742
|
+
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
|
|
743
|
+
for (const record of records) {
|
|
744
|
+
if (record.canonicalId) ids.add(record.canonicalId);
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// Manifest unreadable — return an empty set so cleanup falls back to
|
|
748
|
+
// the conservative "delete what removalSet says" behavior.
|
|
749
|
+
}
|
|
750
|
+
return ids;
|
|
751
|
+
}
|
|
752
|
+
|
|
349
753
|
/**
|
|
350
754
|
* Cleanup a specific target directory.
|
|
351
755
|
* When removalSet is provided, only removes entries in that set.
|
|
@@ -132,6 +132,21 @@ platforms:
|
|
|
132
132
|
installer:
|
|
133
133
|
target_dir: .agents/skills
|
|
134
134
|
global_target_dir: ~/.agents/skills
|
|
135
|
+
commands_target_dir: .github/agents
|
|
136
|
+
commands_extension: .agent.md
|
|
137
|
+
commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!"
|
|
138
|
+
# The Custom Agents picker should only show persona agents (not
|
|
139
|
+
# workflows/tools). Detected by reading each skill's source
|
|
140
|
+
# `customize.toml` and checking for an `[agent]` section — that's
|
|
141
|
+
# the actual configuration source of truth: every BMAD persona is
|
|
142
|
+
# configured under `[agent]`, every workflow under `[workflow]`,
|
|
143
|
+
# every standalone skill has no customize.toml. This signal is
|
|
144
|
+
# naming-independent, so personas like `bmad-tea` (which doesn't
|
|
145
|
+
# follow the `-agent-` convention) are still included, and
|
|
146
|
+
# meta-skills like `bmad-agent-builder` (which contains `-agent-`
|
|
147
|
+
# but is a skill-builder workflow, not a persona) are correctly
|
|
148
|
+
# excluded.
|
|
149
|
+
commands_filter: agents-only
|
|
135
150
|
|
|
136
151
|
goose:
|
|
137
152
|
name: "Block Goose"
|
|
@@ -222,6 +237,7 @@ platforms:
|
|
|
222
237
|
installer:
|
|
223
238
|
target_dir: .agents/skills
|
|
224
239
|
global_target_dir: ~/.agents/skills
|
|
240
|
+
commands_target_dir: .opencode/commands
|
|
225
241
|
|
|
226
242
|
openhands:
|
|
227
243
|
name: "OpenHands"
|