fix(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>
This commit is contained in:
jheyworth 2026-04-26 20:40:30 +01:00
parent 6ff74ba662
commit 9086546e2f
3 changed files with 177 additions and 0 deletions

View File

@ -285,6 +285,10 @@ async function runTests() {
const opencodeInstaller = platformCodes.platforms.opencode?.installer; const opencodeInstaller = platformCodes.platforms.opencode?.installer;
assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path'); assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path');
assert(
opencodeInstaller?.commands_target_dir === '.opencode/commands',
'OpenCode commands_target_dir is configured for /<skill> slash commands',
);
const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-')); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-'));
const installedBmadDir = await createTestBmadFixture(); const installedBmadDir = await createTestBmadFixture();
@ -301,6 +305,24 @@ async function runTests() {
const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md'); const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md');
assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output'); assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output');
// Command pointer assertions: a /<canonicalId> slash command should exist
// for each installed skill so users can invoke skills directly without
// going through the /skills menu.
const commandFile = path.join(tempProjectDir, '.opencode', 'commands', 'bmad-master.md');
assert(await fs.pathExists(commandFile), 'OpenCode install writes per-skill command pointer file');
const commandContent = await fs.readFile(commandFile, 'utf8');
assert(commandContent.includes('@skills/bmad-master'), 'Command pointer body references the skill via @skills/<canonicalId>');
assert(commandContent.includes('description:'), 'Command pointer carries a description in YAML frontmatter');
// Idempotency: re-running install must not duplicate or rewrite pointers.
const result2 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
});
assert(result2.success === true, 'Second OpenCode install succeeds (idempotent)');
assert(await fs.pathExists(commandFile), 'Command pointer survives a second install pass');
await fs.remove(tempProjectDir); await fs.remove(tempProjectDir);
await fs.remove(path.dirname(installedBmadDir)); await fs.remove(path.dirname(installedBmadDir));
} catch (error) { } catch (error) {

View File

@ -6,6 +6,43 @@ const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills'); const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
// Reserved OpenCode slash commands. A skill whose canonicalId collides with
// one of these is skipped during command-pointer generation so it doesn't
// shadow a built-in.
const RESERVED_OPENCODE_COMMANDS = new Set([
'review',
'commit',
'init',
'help',
'skills',
'fast',
'compact',
'clear',
'undo',
'redo',
'edit',
'editor',
'exit',
'quit',
'theme',
'config',
'model',
'session',
]);
// Wrap a description for safe insertion into single-line YAML frontmatter.
// Leaves plain values untouched; double-quotes (and escapes) anything that
// could break YAML parsing or span multiple lines.
function yamlSafeSingleLine(value) {
const collapsed = String(value)
.replaceAll(/[\r\n]+/g, ' ')
.trim();
const needsQuoting = /[:#'"\\]/.test(collapsed) || /^[!&*?|>%@`]/.test(collapsed);
if (!needsQuoting) return collapsed;
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`);
return `"${escaped}"`;
}
/** /**
* Config-driven IDE setup handler * Config-driven IDE setup handler
* *
@ -128,11 +165,76 @@ class ConfigDrivenIdeSetup {
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config); results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
results.skillDirectories = this.skillWriteTracker.size; results.skillDirectories = this.skillWriteTracker.size;
if (config.commands_target_dir) {
results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options);
}
await this.printSummary(results, target_dir, options); await this.printSummary(results, target_dir, options);
this.skillWriteTracker = null; this.skillWriteTracker = null;
return { success: true, results }; return { success: true, results };
} }
/**
* Generate per-skill command pointer files for IDEs that surface commands
* separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
*
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
* so invoking `/<canonicalId>` routes the user straight to the skill instead
* of forcing them through a `/skills` menu.
*
* Skips:
* - Names that collide with reserved built-in slash commands.
* - Existing files (treated as hand-tuned) unless options.forceCommands.
*
* @param {string} projectDir
* @param {string} bmadDir
* @param {Object} config - Installer config; reads commands_target_dir.
* @param {Object} options - Setup options. forceCommands overwrites existing files.
* @returns {Promise<Object>} { created, skippedExisting, skippedCollision, fallbackDescription }
*/
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
const result = { created: 0, skippedExisting: 0, skippedCollision: 0, fallbackDescription: 0 };
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return result;
const commandsPath = path.join(projectDir, config.commands_target_dir);
await fs.ensureDir(commandsPath);
const csvContent = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
for (const record of records) {
const canonicalId = record.canonicalId;
if (!canonicalId) continue;
if (RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
result.skippedCollision++;
continue;
}
const commandFile = path.join(commandsPath, `${canonicalId}.md`);
if ((await fs.pathExists(commandFile)) && !options.forceCommands) {
result.skippedExisting++;
continue;
}
let description = (record.description || '').trim();
if (!description) {
description = `Run the ${canonicalId} skill`;
result.fallbackDescription++;
}
const body = `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n@skills/${canonicalId}\n`;
await fs.writeFile(commandFile, body, 'utf8');
result.created++;
}
return result;
}
/** /**
* Install verbatim native SKILL.md directories from skill-manifest.csv. * Install verbatim native SKILL.md directories from skill-manifest.csv.
* Copies the entire source directory as-is into the IDE skill directory. * Copies the entire source directory as-is into the IDE skill directory.
@ -256,6 +358,13 @@ class ConfigDrivenIdeSetup {
if (this.installerConfig?.target_dir) { if (this.installerConfig?.target_dir) {
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet); await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet);
} }
// Clean generated command pointer files in commands_target_dir.
// Mirrors target_dir cleanup so uninstalls and skill removals don't
// leave dangling /<canonicalId> commands pointing at missing skills.
if (this.installerConfig?.commands_target_dir) {
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet);
}
} }
/** /**
@ -346,6 +455,51 @@ class ConfigDrivenIdeSetup {
} }
} }
/**
* Cleanup generated command pointer files for entries in removalSet.
* Symmetric counterpart to installCommandPointers removes <canonicalId>.md
* files whose canonicalId is in the set. Removes the commands directory
* entirely if it ends up empty.
* @param {string} projectDir
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
* @param {Object} options
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
*/
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set()) {
if (!removalSet || removalSet.size === 0) return;
const commandsPath = path.join(projectDir, commandsTargetDir);
if (!(await fs.pathExists(commandsPath))) return;
let entries;
try {
entries = await fs.readdir(commandsPath);
} catch {
return;
}
for (const entry of entries) {
if (typeof entry !== 'string' || !entry.endsWith('.md')) continue;
const canonicalId = entry.slice(0, -3);
if (!removalSet.has(canonicalId)) continue;
try {
await fs.remove(path.join(commandsPath, entry));
} catch {
// Skip files we can't remove.
}
}
// Remove the commands directory if we emptied it.
try {
const remaining = await fs.readdir(commandsPath);
if (remaining.length === 0) {
await fs.remove(commandsPath);
}
} catch {
// Directory may already be gone.
}
}
/** /**
* Cleanup a specific target directory. * Cleanup a specific target directory.
* When removalSet is provided, only removes entries in that set. * When removalSet is provided, only removes entries in that set.

View File

@ -222,6 +222,7 @@ platforms:
installer: installer:
target_dir: .agents/skills target_dir: .agents/skills
global_target_dir: ~/.agents/skills global_target_dir: ~/.agents/skills
commands_target_dir: .opencode/commands
openhands: openhands:
name: "OpenHands" name: "OpenHands"