Skip to content

Commit 6065a92

Browse files
jheyworthclaude
andcommitted
feat(installer): generate OpenCode /<skill> slash commands
Adds .opencode/commands/<canonicalId>.md pointer files for each installed skill so users can invoke skills directly (e.g. /bmad-quick-dev) instead of going through the /skills menu. - platform-codes.yaml: add commands_target_dir field for opencode - _config-driven.js: installCommandPointers() with skip-if-exists default, reserved-name collision guard, YAML-safe description quoting - _config-driven.js: cleanupCommandPointers() for symmetric uninstall - test-installation-components.js: extend OpenCode suite with assertions covering pointer creation, content, and idempotency OpenCode-only and opt-in via the new yaml field; other adapters unchanged. Refs #2267 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be85e5b commit 6065a92

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

test/test-installation-components.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@ async function runTests() {
285285
const opencodeInstaller = platformCodes.platforms.opencode?.installer;
286286

287287
assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path');
288+
assert(
289+
opencodeInstaller?.commands_target_dir === '.opencode/commands',
290+
'OpenCode commands_target_dir is configured for /<skill> slash commands',
291+
);
288292

289293
const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-'));
290294
const installedBmadDir = await createTestBmadFixture();
@@ -301,6 +305,24 @@ async function runTests() {
301305
const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md');
302306
assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output');
303307

308+
// Command pointer assertions: a /<canonicalId> slash command should exist
309+
// for each installed skill so users can invoke skills directly without
310+
// going through the /skills menu.
311+
const commandFile = path.join(tempProjectDir, '.opencode', 'commands', 'bmad-master.md');
312+
assert(await fs.pathExists(commandFile), 'OpenCode install writes per-skill command pointer file');
313+
314+
const commandContent = await fs.readFile(commandFile, 'utf8');
315+
assert(commandContent.includes('@skills/bmad-master'), 'Command pointer body references the skill via @skills/<canonicalId>');
316+
assert(commandContent.includes('description:'), 'Command pointer carries a description in YAML frontmatter');
317+
318+
// Idempotency: re-running install must not duplicate or rewrite pointers.
319+
const result2 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
320+
silent: true,
321+
selectedModules: ['bmm'],
322+
});
323+
assert(result2.success === true, 'Second OpenCode install succeeds (idempotent)');
324+
assert(await fs.pathExists(commandFile), 'Command pointer survives a second install pass');
325+
304326
await fs.remove(tempProjectDir);
305327
await fs.remove(path.dirname(installedBmadDir));
306328
} catch (error) {

tools/installer/ide/_config-driven.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,43 @@ const csv = require('csv-parse/sync');
66
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
77
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
88

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+
946
/**
1047
* Config-driven IDE setup handler
1148
*
@@ -128,11 +165,76 @@ class ConfigDrivenIdeSetup {
128165
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
129166
results.skillDirectories = this.skillWriteTracker.size;
130167

168+
if (config.commands_target_dir) {
169+
results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options);
170+
}
171+
131172
await this.printSummary(results, target_dir, options);
132173
this.skillWriteTracker = null;
133174
return { success: true, results };
134175
}
135176

177+
/**
178+
* Generate per-skill command pointer files for IDEs that surface commands
179+
* separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
180+
*
181+
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
182+
* so invoking `/<canonicalId>` routes the user straight to the skill instead
183+
* of forcing them through a `/skills` menu.
184+
*
185+
* Skips:
186+
* - Names that collide with reserved built-in slash commands.
187+
* - Existing files (treated as hand-tuned) unless options.forceCommands.
188+
*
189+
* @param {string} projectDir
190+
* @param {string} bmadDir
191+
* @param {Object} config - Installer config; reads commands_target_dir.
192+
* @param {Object} options - Setup options. forceCommands overwrites existing files.
193+
* @returns {Promise<Object>} { created, skippedExisting, skippedCollision, fallbackDescription }
194+
*/
195+
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
196+
const result = { created: 0, skippedExisting: 0, skippedCollision: 0, fallbackDescription: 0 };
197+
198+
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
199+
if (!(await fs.pathExists(csvPath))) return result;
200+
201+
const commandsPath = path.join(projectDir, config.commands_target_dir);
202+
await fs.ensureDir(commandsPath);
203+
204+
const csvContent = await fs.readFile(csvPath, 'utf8');
205+
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
206+
207+
for (const record of records) {
208+
const canonicalId = record.canonicalId;
209+
if (!canonicalId) continue;
210+
211+
if (RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
212+
result.skippedCollision++;
213+
continue;
214+
}
215+
216+
const commandFile = path.join(commandsPath, `${canonicalId}.md`);
217+
218+
if ((await fs.pathExists(commandFile)) && !options.forceCommands) {
219+
result.skippedExisting++;
220+
continue;
221+
}
222+
223+
let description = (record.description || '').trim();
224+
if (!description) {
225+
description = `Run the ${canonicalId} skill`;
226+
result.fallbackDescription++;
227+
}
228+
229+
const body = `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n@skills/${canonicalId}\n`;
230+
231+
await fs.writeFile(commandFile, body, 'utf8');
232+
result.created++;
233+
}
234+
235+
return result;
236+
}
237+
136238
/**
137239
* Install verbatim native SKILL.md directories from skill-manifest.csv.
138240
* Copies the entire source directory as-is into the IDE skill directory.
@@ -256,6 +358,13 @@ class ConfigDrivenIdeSetup {
256358
if (this.installerConfig?.target_dir) {
257359
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet);
258360
}
361+
362+
// Clean generated command pointer files in commands_target_dir.
363+
// Mirrors target_dir cleanup so uninstalls and skill removals don't
364+
// leave dangling /<canonicalId> commands pointing at missing skills.
365+
if (this.installerConfig?.commands_target_dir) {
366+
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet);
367+
}
259368
}
260369

261370
/**
@@ -346,6 +455,51 @@ class ConfigDrivenIdeSetup {
346455
}
347456
}
348457

458+
/**
459+
* Cleanup generated command pointer files for entries in removalSet.
460+
* Symmetric counterpart to installCommandPointers — removes <canonicalId>.md
461+
* files whose canonicalId is in the set. Removes the commands directory
462+
* entirely if it ends up empty.
463+
* @param {string} projectDir
464+
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
465+
* @param {Object} options
466+
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
467+
*/
468+
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set()) {
469+
if (!removalSet || removalSet.size === 0) return;
470+
471+
const commandsPath = path.join(projectDir, commandsTargetDir);
472+
if (!(await fs.pathExists(commandsPath))) return;
473+
474+
let entries;
475+
try {
476+
entries = await fs.readdir(commandsPath);
477+
} catch {
478+
return;
479+
}
480+
481+
for (const entry of entries) {
482+
if (typeof entry !== 'string' || !entry.endsWith('.md')) continue;
483+
const canonicalId = entry.slice(0, -3);
484+
if (!removalSet.has(canonicalId)) continue;
485+
try {
486+
await fs.remove(path.join(commandsPath, entry));
487+
} catch {
488+
// Skip files we can't remove.
489+
}
490+
}
491+
492+
// Remove the commands directory if we emptied it.
493+
try {
494+
const remaining = await fs.readdir(commandsPath);
495+
if (remaining.length === 0) {
496+
await fs.remove(commandsPath);
497+
}
498+
} catch {
499+
// Directory may already be gone.
500+
}
501+
}
502+
349503
/**
350504
* Cleanup a specific target directory.
351505
* When removalSet is provided, only removes entries in that set.

tools/installer/ide/platform-codes.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ platforms:
222222
installer:
223223
target_dir: .agents/skills
224224
global_target_dir: ~/.agents/skills
225+
commands_target_dir: .opencode/commands
225226

226227
openhands:
227228
name: "OpenHands"

0 commit comments

Comments
 (0)