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:
parent
6ff74ba662
commit
9086546e2f
|
|
@ -285,6 +285,10 @@ async function runTests() {
|
|||
const opencodeInstaller = platformCodes.platforms.opencode?.installer;
|
||||
|
||||
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 installedBmadDir = await createTestBmadFixture();
|
||||
|
|
@ -301,6 +305,24 @@ async function runTests() {
|
|||
const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md');
|
||||
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(path.dirname(installedBmadDir));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,43 @@ const csv = require('csv-parse/sync');
|
|||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||
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
|
||||
*
|
||||
|
|
@ -128,11 +165,76 @@ class ConfigDrivenIdeSetup {
|
|||
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
||||
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);
|
||||
this.skillWriteTracker = null;
|
||||
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.
|
||||
* Copies the entire source directory as-is into the IDE skill directory.
|
||||
|
|
@ -256,6 +358,13 @@ class ConfigDrivenIdeSetup {
|
|||
if (this.installerConfig?.target_dir) {
|
||||
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.
|
||||
* When removalSet is provided, only removes entries in that set.
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ platforms:
|
|||
installer:
|
||||
target_dir: .agents/skills
|
||||
global_target_dir: ~/.agents/skills
|
||||
commands_target_dir: .opencode/commands
|
||||
|
||||
openhands:
|
||||
name: "OpenHands"
|
||||
|
|
|
|||
Loading…
Reference in New Issue