Compare commits
5 Commits
3c0d615b5d
...
030234c195
| Author | SHA1 | Date |
|---|---|---|
|
|
030234c195 | |
|
|
46a3d854f3 | |
|
|
81badb92cd | |
|
|
9086546e2f | |
|
|
6ff74ba662 |
|
|
@ -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,55 @@ 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');
|
||||
|
||||
// Description-update propagation: when the manifest description changes
|
||||
// and the on-disk pointer still matches the generator pattern, refresh
|
||||
// the file so users see the updated description.
|
||||
const csvPath = path.join(installedBmadDir, '_config', 'skill-manifest.csv');
|
||||
const updatedCsv =
|
||||
'canonicalId,name,description,module,path\n' +
|
||||
'"bmad-master","bmad-master","UPDATED description for the test agent","core","_bmad/core/bmad-master/SKILL.md"\n';
|
||||
await fs.writeFile(csvPath, updatedCsv);
|
||||
const result3 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
|
||||
silent: true,
|
||||
selectedModules: ['bmm'],
|
||||
});
|
||||
assert(result3.success === true, 'Third OpenCode install succeeds after description update');
|
||||
const refreshed = await fs.readFile(commandFile, 'utf8');
|
||||
assert(refreshed.includes('UPDATED description'), 'Generator-shaped pointer is refreshed when manifest description changes');
|
||||
|
||||
// Hand-edit preservation across the production install flow. The
|
||||
// installer passes previousSkillIds — without the cleanup-side spare,
|
||||
// hand edits would be wiped here.
|
||||
const SENTINEL = 'HAND_EDITED_BY_USER_SHOULD_SURVIVE';
|
||||
const handEditedBody = `---\ndescription: my custom description\n---\n\n${SENTINEL}\n`;
|
||||
await fs.writeFile(commandFile, handEditedBody);
|
||||
const result4 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
|
||||
silent: true,
|
||||
selectedModules: ['bmm'],
|
||||
previousSkillIds: new Set(['bmad-master']),
|
||||
});
|
||||
assert(result4.success === true, 'Fourth OpenCode install succeeds with hand-edited pointer present');
|
||||
const afterReinstall = await fs.readFile(commandFile, 'utf8');
|
||||
assert(afterReinstall.includes(SENTINEL), 'Hand-edited pointer survives a routine reinstall (cleanup spares active-manifest IDs)');
|
||||
|
||||
await fs.remove(tempProjectDir);
|
||||
await fs.remove(path.dirname(installedBmadDir));
|
||||
} catch (error) {
|
||||
|
|
@ -2731,6 +2784,113 @@ async function runTests() {
|
|||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 40c: OpenCode command pointers in multi-IDE batches
|
||||
// ============================================================
|
||||
// Regression: when OpenCode is the *peer* in a setupBatch sharing
|
||||
// .agents/skills (e.g. with openhands), the skill write is dedup-skipped
|
||||
// but the per-IDE .opencode/commands/ pointers must still be generated.
|
||||
// Symmetrically, partial uninstall while a peer remains must still clean
|
||||
// up OpenCode's own command pointers.
|
||||
console.log(`${colors.yellow}Test Suite 40c: OpenCode command pointers in shared-target batches${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
clearCache();
|
||||
const platformCodes40c = await loadPlatformCodes();
|
||||
const opencodeTarget40c = platformCodes40c.platforms.opencode?.installer?.target_dir;
|
||||
const openhandsTarget40c = platformCodes40c.platforms.openhands?.installer?.target_dir;
|
||||
assert(
|
||||
opencodeTarget40c === '.agents/skills' && openhandsTarget40c === '.agents/skills',
|
||||
'OpenCode and OpenHands share .agents/skills target_dir',
|
||||
);
|
||||
|
||||
// Order A: opencode first → opencode is the writer.
|
||||
const projA = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-a-'));
|
||||
const bmadA = await createTestBmadFixture();
|
||||
const mgrA = new IdeManager();
|
||||
await mgrA.ensureInitialized();
|
||||
const resultsA = await mgrA.setupBatch(['opencode', 'openhands'], projA, bmadA, {
|
||||
silent: true,
|
||||
selectedModules: ['core'],
|
||||
});
|
||||
const cmdA = path.join(projA, '.opencode', 'commands', 'bmad-master.md');
|
||||
assert(
|
||||
resultsA.every((r) => r.success === true),
|
||||
'opencode-first batch: all platforms succeed',
|
||||
);
|
||||
assert(await fs.pathExists(cmdA), 'opencode-first batch: command pointer is created');
|
||||
|
||||
// Order B: openhands first → opencode is the peer (skipTarget=true).
|
||||
// Without the fix, the early-return would bypass installCommandPointers.
|
||||
const projB = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-b-'));
|
||||
const bmadB = await createTestBmadFixture();
|
||||
const mgrB = new IdeManager();
|
||||
await mgrB.ensureInitialized();
|
||||
const resultsB = await mgrB.setupBatch(['openhands', 'opencode'], projB, bmadB, {
|
||||
silent: true,
|
||||
selectedModules: ['core'],
|
||||
});
|
||||
const cmdB = path.join(projB, '.opencode', 'commands', 'bmad-master.md');
|
||||
const opencodeResultB = resultsB.find((r) => r.ide === 'opencode');
|
||||
assert(
|
||||
resultsB.every((r) => r.success === true),
|
||||
'openhands-first batch: all platforms succeed',
|
||||
);
|
||||
assert(
|
||||
opencodeResultB?.handlerResult?.results?.sharedTargetHandledByPeer === true,
|
||||
'openhands-first batch: opencode is marked sharedTargetHandledByPeer (skill write deduped)',
|
||||
);
|
||||
assert(await fs.pathExists(cmdB), 'openhands-first batch: command pointer is generated even when skill write is deduped');
|
||||
|
||||
// Cleanup symmetry: uninstall opencode while openhands remains.
|
||||
// Uses an in-project bmadDir so the cleanup path can compute removalSet
|
||||
// from the manifest (the production layout). The cross-temp-dir fixture
|
||||
// above can't exercise this — same constraint Test Suite 40 documents.
|
||||
const projC = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-c-'));
|
||||
const bmadC = path.join(projC, '_bmad');
|
||||
await fs.ensureDir(path.join(bmadC, '_config'));
|
||||
await fs.writeFile(
|
||||
path.join(bmadC, '_config', 'skill-manifest.csv'),
|
||||
'canonicalId,name,description,module,path\n' +
|
||||
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"\n',
|
||||
);
|
||||
const skillC = path.join(bmadC, 'core', 'bmad-master');
|
||||
await fs.ensureDir(skillC);
|
||||
await fs.writeFile(
|
||||
path.join(skillC, 'SKILL.md'),
|
||||
['---', 'name: bmad-master', 'description: Minimal test agent fixture', '---', '', 'You are a test agent.'].join('\n'),
|
||||
);
|
||||
|
||||
const mgrC = new IdeManager();
|
||||
await mgrC.ensureInitialized();
|
||||
await mgrC.setupBatch(['openhands', 'opencode'], projC, bmadC, {
|
||||
silent: true,
|
||||
selectedModules: ['core'],
|
||||
});
|
||||
const cmdC = path.join(projC, '.opencode', 'commands', 'bmad-master.md');
|
||||
assert(await fs.pathExists(cmdC), 'in-project fixture: pointer is generated for opencode peer');
|
||||
|
||||
const cleanupResultsC = await mgrC.cleanupByList(projC, ['opencode'], {
|
||||
silent: true,
|
||||
remainingIdes: ['openhands'],
|
||||
});
|
||||
assert(cleanupResultsC[0].success !== false, 'opencode partial-uninstall reports success');
|
||||
const sharedSurvivesC = await fs.pathExists(path.join(projC, '.agents', 'skills', 'bmad-master', 'SKILL.md'));
|
||||
assert(sharedSurvivesC, 'shared .agents/skills/ survives partial uninstall (peer still uses it)');
|
||||
assert(!(await fs.pathExists(cmdC)), 'opencode command pointer is removed on partial uninstall even when peer remains');
|
||||
|
||||
await fs.remove(projA).catch(() => {});
|
||||
await fs.remove(path.dirname(bmadA)).catch(() => {});
|
||||
await fs.remove(projB).catch(() => {});
|
||||
await fs.remove(path.dirname(bmadB)).catch(() => {});
|
||||
await fs.remove(projC).catch(() => {});
|
||||
} catch (error) {
|
||||
console.log(`${colors.red}Test Suite 40c setup failed: ${error.message}${colors.reset}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 41: Custom-module skill ownership (non-bmad prefix)
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -6,6 +6,78 @@ 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}"`;
|
||||
}
|
||||
|
||||
// Validate that a canonicalId is a safe basename — no path separators, no
|
||||
// parent-dir traversal, no leading dots, only the character set we expect.
|
||||
// Defense-in-depth: the manifest is trusted today, but the value flows
|
||||
// directly into a file path and a malformed entry should not write outside
|
||||
// the commands directory.
|
||||
function isSafeCanonicalId(value) {
|
||||
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..');
|
||||
}
|
||||
|
||||
// The exact body the installer would generate for a given description and
|
||||
// canonicalId. Centralised so both the write and the freshness-check paths
|
||||
// agree on the canonical form.
|
||||
function buildCommandPointerBody(description, canonicalId) {
|
||||
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n@skills/${canonicalId}\n`;
|
||||
}
|
||||
|
||||
// Heuristic: does an existing pointer file look like our generator's output
|
||||
// (and therefore safe to refresh) versus a user-modified file (which we
|
||||
// preserve)? We check the body shape rather than full equality so that
|
||||
// description-only edits in the manifest can propagate without trampling
|
||||
// hand edits to the body.
|
||||
function looksLikeGeneratorOutput(content, canonicalId) {
|
||||
if (typeof content !== 'string') return false;
|
||||
const trimmed = content.trim();
|
||||
// Must end with the exact reference line our generator writes.
|
||||
if (!trimmed.endsWith(`@skills/${canonicalId}`)) return false;
|
||||
// Must start with frontmatter containing exactly one description: line.
|
||||
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
|
||||
if (!fmMatch) return false;
|
||||
const fmLines = fmMatch[1].split('\n').filter((l) => l.length > 0);
|
||||
if (fmLines.length !== 1) return false;
|
||||
if (!fmLines[0].startsWith('description:')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven IDE setup handler
|
||||
*
|
||||
|
|
@ -97,9 +169,15 @@ class ConfigDrivenIdeSetup {
|
|||
}
|
||||
|
||||
// When a peer platform in the same install batch owns this target_dir,
|
||||
// skip the skill write — the peer has already populated it.
|
||||
// skip the skill write — the peer has already populated it. Command
|
||||
// pointers, however, write to a separate per-IDE directory and must
|
||||
// still be generated for this IDE; they are not deduped across peers.
|
||||
if (options.skipTarget) {
|
||||
return { success: true, results: { skills: 0, sharedTargetHandledByPeer: true } };
|
||||
const results = { skills: 0, sharedTargetHandledByPeer: true };
|
||||
if (this.installerConfig.commands_target_dir) {
|
||||
results.commands = await this.installCommandPointers(projectDir, bmadDir, this.installerConfig, options);
|
||||
}
|
||||
return { success: true, results };
|
||||
}
|
||||
|
||||
if (this.installerConfig.target_dir) {
|
||||
|
|
@ -128,11 +206,141 @@ 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.
|
||||
* - canonicalIds that aren't safe basename-only identifiers (defense
|
||||
* against path traversal even though the manifest is currently trusted).
|
||||
* - Existing files whose body looks user-modified (preserves hand edits);
|
||||
* pointer files matching the generator pattern get overwritten so that
|
||||
* description changes in skill-manifest.csv propagate on re-install.
|
||||
*
|
||||
* Per-file write failures are recorded and reported but do not abort the
|
||||
* rest of the install — pointer files are a non-essential adjunct to the
|
||||
* skill copy that already succeeded.
|
||||
*
|
||||
* @param {string} projectDir
|
||||
* @param {string} bmadDir
|
||||
* @param {Object} config - Installer config; reads commands_target_dir.
|
||||
* @param {Object} options - Setup options. forceCommands overwrites existing
|
||||
* files unconditionally (including hand-modified ones).
|
||||
* @returns {Promise<Object>} { created, updated, skippedExisting, skippedCollision, skippedInvalidId, writeFailures, fallbackDescription }
|
||||
*/
|
||||
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
|
||||
const result = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skippedExisting: 0,
|
||||
skippedCollision: 0,
|
||||
skippedInvalidId: 0,
|
||||
writeFailures: 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;
|
||||
|
||||
// Defensive basename validation. canonicalId comes from a trusted
|
||||
// manifest today, but the value flows directly into a file path —
|
||||
// reject anything that could escape commands_target_dir.
|
||||
if (!isSafeCanonicalId(canonicalId)) {
|
||||
result.skippedInvalidId++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reserved-name guard is OpenCode-specific. Other adapters that opt
|
||||
// into commands_target_dir later should declare their own reserved
|
||||
// set rather than inheriting OpenCode's.
|
||||
if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
|
||||
result.skippedCollision++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let description = (record.description || '').trim();
|
||||
if (!description) {
|
||||
description = `Run the ${canonicalId} skill`;
|
||||
result.fallbackDescription++;
|
||||
}
|
||||
|
||||
const body = buildCommandPointerBody(description, canonicalId);
|
||||
const commandFile = path.join(commandsPath, `${canonicalId}.md`);
|
||||
|
||||
// If a pointer file already exists, decide whether to overwrite based
|
||||
// on whether it looks like generator output (description-only diff) or
|
||||
// a user-modified file. forceCommands overrides this protection.
|
||||
if (!options.forceCommands && (await fs.pathExists(commandFile))) {
|
||||
let existing;
|
||||
try {
|
||||
existing = await fs.readFile(commandFile, 'utf8');
|
||||
} catch {
|
||||
// Treat unreadable as user-owned and skip — safer than overwriting.
|
||||
result.skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing === body) {
|
||||
// No-op idempotent re-run.
|
||||
result.skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
if (looksLikeGeneratorOutput(existing, canonicalId)) {
|
||||
// Description (or other generated bit) has changed; refresh in place.
|
||||
try {
|
||||
await fs.writeFile(commandFile, body, 'utf8');
|
||||
result.updated++;
|
||||
} catch (error) {
|
||||
result.writeFailures++;
|
||||
if (!options.silent) {
|
||||
await prompts.log.warn(`Failed to update command pointer ${canonicalId}.md: ${error.message}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Hand-modified pointer — preserve it.
|
||||
result.skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(commandFile, body, 'utf8');
|
||||
result.created++;
|
||||
} catch (error) {
|
||||
result.writeFailures++;
|
||||
if (!options.silent) {
|
||||
await prompts.log.warn(`Failed to write command pointer ${canonicalId}.md: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install verbatim native SKILL.md directories from skill-manifest.csv.
|
||||
* Copies the entire source directory as-is into the IDE skill directory.
|
||||
|
|
@ -207,6 +415,18 @@ class ConfigDrivenIdeSetup {
|
|||
if (count > 0) {
|
||||
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
|
||||
}
|
||||
const cmd = results.commands;
|
||||
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
|
||||
const total = cmd.created + cmd.updated;
|
||||
const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`;
|
||||
await prompts.log.success(`${this.name} commands: ${detail} → ${this.installerConfig.commands_target_dir}`);
|
||||
if (cmd.skippedCollision > 0) {
|
||||
await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`);
|
||||
}
|
||||
if (cmd.writeFailures > 0) {
|
||||
await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -247,6 +467,28 @@ class ConfigDrivenIdeSetup {
|
|||
await this.cleanupRovoDevPrompts(projectDir, options);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Runs regardless of skipTarget — command pointers live in a per-IDE
|
||||
// directory and are not deduped across peers, so a peer-owned shared
|
||||
// skills directory does not protect this IDE's command pointers from
|
||||
// cleanup. The "currently active" set is passed so install-flow cleanup
|
||||
// (where removalSet contains skills that will be re-added moments later)
|
||||
// doesn't trample hand-edited pointers; install-flow cleanup will only
|
||||
// delete pointers for skills that are not in the new manifest.
|
||||
if (this.installerConfig?.commands_target_dir) {
|
||||
// In the install/update flow (signal: previousSkillIds was passed),
|
||||
// spare pointers whose canonicalId is still in the manifest so hand
|
||||
// edits survive a routine reinstall. In the uninstall flow (no
|
||||
// previousSkillIds — full uninstall or per-IDE removal via
|
||||
// cleanupByList), don't spare anything; the IDE itself is going away,
|
||||
// so its pointers should go with it.
|
||||
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
|
||||
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
|
||||
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet, activeSkillIds);
|
||||
}
|
||||
|
||||
// Skip target_dir cleanup when a peer platform owns this directory
|
||||
// (set during dedup'd install or when uninstalling one of several
|
||||
// platforms that share the same target_dir).
|
||||
|
|
@ -346,6 +588,86 @@ 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
|
||||
* @param {Set<string>} [activeSkillIds] - canonicalIds present in the
|
||||
* current manifest. Pointers for IDs in this set are spared so an
|
||||
* install-flow cleanup (where removalSet === previousSkillIds and the
|
||||
* same skills are about to be re-installed) doesn't wipe hand-edited
|
||||
* pointer files. Pass an empty set or omit to delete every match in
|
||||
* removalSet (uninstall flow).
|
||||
*/
|
||||
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set(), activeSkillIds = 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 (!entry.endsWith('.md')) continue;
|
||||
const canonicalId = entry.slice(0, -3);
|
||||
if (!removalSet.has(canonicalId)) continue;
|
||||
// Spare pointers for skills that are still in the manifest; the
|
||||
// install pass will refresh them in place if their content has gone
|
||||
// stale, while preserving hand edits.
|
||||
if (activeSkillIds.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.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the canonicalIds currently present in the skill-manifest.csv.
|
||||
* Used by cleanup to distinguish "re-install of an existing skill"
|
||||
* (preserve pointer) from "skill truly being removed" (delete pointer).
|
||||
* @param {string|null} bmadDir
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
async _readActiveSkillIds(bmadDir) {
|
||||
const ids = new Set();
|
||||
if (!bmadDir) return ids;
|
||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||
if (!(await fs.pathExists(csvPath))) return ids;
|
||||
try {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
|
||||
for (const record of records) {
|
||||
if (record.canonicalId) ids.add(record.canonicalId);
|
||||
}
|
||||
} catch {
|
||||
// Manifest unreadable — return an empty set so cleanup falls back to
|
||||
// the conservative "delete what removalSet says" behavior.
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ class CommunityModuleManager {
|
|||
// Shared across all instances; the manifest writer often uses a fresh instance.
|
||||
static _resolutions = new Map();
|
||||
|
||||
// moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
|
||||
// a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
|
||||
// skill-level install pipeline as custom-source installs (installFromResolution).
|
||||
static _pluginResolutions = new Map();
|
||||
|
||||
constructor() {
|
||||
this._client = new RegistryClient();
|
||||
this._cachedIndex = null;
|
||||
|
|
@ -40,6 +45,11 @@ class CommunityModuleManager {
|
|||
return CommunityModuleManager._resolutions.get(moduleCode) || null;
|
||||
}
|
||||
|
||||
/** Get the marketplace.json-derived plugin resolution for a community module, if any. */
|
||||
getPluginResolution(moduleCode) {
|
||||
return CommunityModuleManager._pluginResolutions.get(moduleCode) || null;
|
||||
}
|
||||
|
||||
// ─── Data Loading ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -371,6 +381,18 @@ class CommunityModuleManager {
|
|||
planSource: planEntry.source,
|
||||
});
|
||||
|
||||
// If the repo ships a marketplace.json, route through PluginResolver so the
|
||||
// skill-level install pipeline (installFromResolution) handles the copy.
|
||||
// Repos without marketplace.json fall through to the legacy findModuleSource
|
||||
// path unchanged.
|
||||
await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
|
||||
channel: planEntry.channel,
|
||||
version: recordedVersion,
|
||||
sha: installedSha,
|
||||
approvedTag,
|
||||
approvedSha,
|
||||
});
|
||||
|
||||
// Install dependencies if needed
|
||||
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
|
||||
|
|
@ -392,6 +414,204 @@ class CommunityModuleManager {
|
|||
return moduleCacheDir;
|
||||
}
|
||||
|
||||
// ─── Marketplace.json Resolution ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect `.claude-plugin/marketplace.json` in a cloned community repo and
|
||||
* route through PluginResolver. When successful, caches the resolution so
|
||||
* OfficialModulesManager.install() can route the copy through
|
||||
* installFromResolution() — the same path used by custom-source installs.
|
||||
*
|
||||
* Silent no-op when marketplace.json is absent or the resolver returns no
|
||||
* matches; the legacy findModuleSource path then handles the install.
|
||||
*
|
||||
* @param {string} repoPath - Absolute path to the cloned repo
|
||||
* @param {Object} moduleInfo - Normalized community module info
|
||||
* @param {Object} resolution - Resolution metadata from cloneModule
|
||||
* @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
|
||||
* @param {string} resolution.version - Recorded version string
|
||||
* @param {string} resolution.sha - Resolved git SHA
|
||||
* @param {string|null} resolution.approvedTag - Registry approved tag
|
||||
* @param {string|null} resolution.approvedSha - Registry approved SHA
|
||||
*/
|
||||
async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
|
||||
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||
if (!(await fs.pathExists(marketplacePath))) return;
|
||||
|
||||
let marketplaceData;
|
||||
try {
|
||||
marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||
} catch {
|
||||
// Malformed marketplace.json — fall through to legacy path.
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
|
||||
if (plugins.length === 0) return;
|
||||
|
||||
const selection = this._selectPluginForModule(plugins, moduleInfo);
|
||||
if (!selection) {
|
||||
await this._safeWarn(
|
||||
`Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
|
||||
`Falling back to legacy install path.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.source === 'single-fallback') {
|
||||
// Single-entry marketplace.json whose plugin name doesn't match the registry
|
||||
// code or the module_definition hint. Most likely correct, but worth surfacing
|
||||
// in case marketplace.json is misconfigured and we'd install the wrong plugin.
|
||||
await this._safeWarn(
|
||||
`Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` +
|
||||
`because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { PluginResolver } = require('./plugin-resolver');
|
||||
const resolver = new PluginResolver();
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resolver.resolve(repoPath, selection.plugin);
|
||||
} catch (error) {
|
||||
// PluginResolver threw (malformed plugin entry, missing files, etc.).
|
||||
// Honor the silent-fallthrough contract — warn and let the legacy
|
||||
// findModuleSource path handle the install.
|
||||
await this._safeWarn(
|
||||
`PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!resolved || resolved.length === 0) return;
|
||||
|
||||
// The registry registers a single code per module. If the resolver returns
|
||||
// multiple modules (Strategy 4: multiple standalone skills), accept only
|
||||
// the entry whose code matches the registry. Other entries are ignored —
|
||||
// they belong to plugins not registered in the community catalog.
|
||||
const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
|
||||
if (!matched) return;
|
||||
|
||||
// Shallow-clone before stamping provenance — the resolver may cache or reuse
|
||||
// its return objects, and we don't want install-specific fields leaking back.
|
||||
const stamped = {
|
||||
...matched,
|
||||
code: moduleInfo.code,
|
||||
repoUrl: moduleInfo.url,
|
||||
cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null,
|
||||
cloneSha: resolution.sha,
|
||||
communitySource: true,
|
||||
communityChannel: resolution.channel,
|
||||
communityVersion: resolution.version,
|
||||
registryApprovedTag: resolution.approvedTag,
|
||||
registryApprovedSha: resolution.approvedSha,
|
||||
};
|
||||
|
||||
CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy fallback: resolve marketplace.json straight from the on-disk cache
|
||||
* when `_pluginResolutions` is empty (e.g. callers that reach `install()`
|
||||
* without `cloneModule` having populated the cache earlier in this process).
|
||||
*
|
||||
* Reuses an existing channel resolution if present; otherwise synthesizes a
|
||||
* minimal stable-channel stub from the registry entry + the cached repo's
|
||||
* current HEAD. Returns the cached plugin resolution if one is produced,
|
||||
* otherwise null (caller falls back to the legacy path).
|
||||
*
|
||||
* @param {string} moduleCode
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async resolveFromCache(moduleCode) {
|
||||
const existing = this.getPluginResolution(moduleCode);
|
||||
if (existing) return existing;
|
||||
|
||||
const cacheRepoDir = path.join(this.getCacheDir(), moduleCode);
|
||||
const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json');
|
||||
if (!(await fs.pathExists(marketplacePath))) return null;
|
||||
|
||||
let moduleInfo;
|
||||
try {
|
||||
moduleInfo = await this.getModuleByCode(moduleCode);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!moduleInfo) return null;
|
||||
|
||||
let channelResolution = this.getResolution(moduleCode);
|
||||
if (!channelResolution) {
|
||||
let sha = '';
|
||||
try {
|
||||
sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim();
|
||||
} catch {
|
||||
// Not a git repo or unreadable — give up and let the legacy path run.
|
||||
return null;
|
||||
}
|
||||
channelResolution = {
|
||||
channel: 'stable',
|
||||
version: moduleInfo.approvedTag || sha.slice(0, 7),
|
||||
sha,
|
||||
registryApprovedTag: moduleInfo.approvedTag || null,
|
||||
registryApprovedSha: moduleInfo.approvedSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, {
|
||||
channel: channelResolution.channel,
|
||||
version: channelResolution.version,
|
||||
sha: channelResolution.sha,
|
||||
approvedTag: channelResolution.registryApprovedTag,
|
||||
approvedSha: channelResolution.registryApprovedSha,
|
||||
});
|
||||
|
||||
return this.getPluginResolution(moduleCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort warning emitter. `prompts.log.warn` may be undefined in some
|
||||
* harnesses and may return a rejected promise — swallow both cases so a
|
||||
* fallthrough warning can never crash the install.
|
||||
*/
|
||||
async _safeWarn(message) {
|
||||
try {
|
||||
const result = prompts.log?.warn?.(message);
|
||||
if (result && typeof result.then === 'function') await result;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which plugin entry from marketplace.json represents this community module.
|
||||
* Precedence:
|
||||
* 1. Exact match on `plugin.name === moduleInfo.code`
|
||||
* 2. Trailing directory of `module_definition` matches `plugin.name`
|
||||
* 3. Single plugin in marketplace.json — accepted with a warning so a
|
||||
* mismatched-but-uniquely-named plugin doesn't install silently.
|
||||
* Otherwise null (caller falls back to legacy path).
|
||||
*
|
||||
* @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null}
|
||||
*/
|
||||
_selectPluginForModule(plugins, moduleInfo) {
|
||||
const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
|
||||
if (byCode) return { plugin: byCode, source: 'name' };
|
||||
|
||||
if (moduleInfo.moduleDefinition) {
|
||||
// module_definition like "src/skills/suno-setup/assets/module.yaml" →
|
||||
// hint segment "suno-setup". Match that against plugin names.
|
||||
const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
|
||||
const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
|
||||
if (setupIdx !== -1) {
|
||||
const hint = segments[setupIdx];
|
||||
const byHint = plugins.find((p) => p && p.name === hint);
|
||||
if (byHint) return { plugin: byHint, source: 'hint' };
|
||||
}
|
||||
}
|
||||
|
||||
if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -269,6 +269,21 @@ class OfficialModules {
|
|||
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
|
||||
}
|
||||
|
||||
// Community modules whose cloned repo ships marketplace.json get the same
|
||||
// skill-level install treatment as custom-source installs. If the in-process
|
||||
// cache wasn't populated (e.g. caller skipped the pre-clone phase), fall
|
||||
// back to resolving directly from `~/.bmad/cache/community-modules/<name>/`
|
||||
// so we don't silently regress to the legacy half-install path.
|
||||
const { CommunityModuleManager } = require('./community-manager');
|
||||
const communityMgr = new CommunityModuleManager();
|
||||
let communityResolved = communityMgr.getPluginResolution(moduleName);
|
||||
if (!communityResolved) {
|
||||
communityResolved = await communityMgr.resolveFromCache(moduleName);
|
||||
}
|
||||
if (communityResolved) {
|
||||
return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
|
||||
}
|
||||
|
||||
const sourcePath = await this.findModuleSource(moduleName, {
|
||||
silent: options.silent,
|
||||
channelOptions: options.channelOptions,
|
||||
|
|
@ -360,21 +375,27 @@ class OfficialModules {
|
|||
await this.createModuleDirectories(resolved.code, bmadDir, options);
|
||||
}
|
||||
|
||||
// Update manifest. For custom modules, derive channel from the git ref:
|
||||
// cloneRef present → pinned at that ref
|
||||
// cloneRef absent → next (main HEAD)
|
||||
// local path → no channel concept
|
||||
// Update manifest. For community installs we honor the channel resolved by
|
||||
// CommunityModuleManager (stable/next/pinned) and propagate the registry's
|
||||
// approved tag/sha. For custom-source installs we derive channel from the
|
||||
// cloneRef (present → pinned, absent → next; local paths have no channel).
|
||||
const { Manifest } = require('../core/manifest');
|
||||
const manifestObj = new Manifest();
|
||||
|
||||
const hasGitClone = !!resolved.repoUrl;
|
||||
const isCommunity = resolved.communitySource === true;
|
||||
const manifestEntry = {
|
||||
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
|
||||
source: 'custom',
|
||||
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
|
||||
source: isCommunity ? 'community' : 'custom',
|
||||
npmPackage: null,
|
||||
repoUrl: resolved.repoUrl || null,
|
||||
};
|
||||
if (hasGitClone) {
|
||||
if (isCommunity) {
|
||||
if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
|
||||
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
||||
if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
|
||||
if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
|
||||
} else if (hasGitClone) {
|
||||
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
|
||||
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
||||
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
|
||||
|
|
@ -386,10 +407,13 @@ class OfficialModules {
|
|||
success: true,
|
||||
module: resolved.code,
|
||||
path: targetPath,
|
||||
// Match the manifestEntry.version expression above so downstream summary
|
||||
// lines show the cloned ref (tag or 'main') instead of the on-disk
|
||||
// package.json version for git-backed custom installs.
|
||||
versionInfo: { version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || '') },
|
||||
// Mirror the manifestEntry.version precedence above so downstream summary
|
||||
// lines show the same string we just wrote to disk (community installs
|
||||
// use the registry-approved tag via `communityVersion`; custom git-backed
|
||||
// installs show the cloned ref or 'main').
|
||||
versionInfo: {
|
||||
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,13 +123,19 @@ async function resolveInstalledModuleYaml(moduleName) {
|
|||
}
|
||||
}
|
||||
|
||||
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
|
||||
const rootEntries = await fs.readdir(root, { withFileTypes: true });
|
||||
for (const entry of rootEntries) {
|
||||
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory).
|
||||
// Check at the repo root, and also under src/skills/ and skills/ since
|
||||
// marketplace plugins commonly nest skills under src/skills/<name>/.
|
||||
const setupSearchRoots = [root, path.join(root, 'src', 'skills'), path.join(root, 'skills')];
|
||||
for (const setupRoot of setupSearchRoots) {
|
||||
if (!(await fs.pathExists(setupRoot))) continue;
|
||||
const entries = await fs.readdir(setupRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
|
||||
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
|
||||
const setupAssets = path.join(setupRoot, entry.name, 'assets', 'module.yaml');
|
||||
if (await fs.pathExists(setupAssets)) results.push(setupAssets);
|
||||
}
|
||||
}
|
||||
|
||||
const atRoot = path.join(root, 'module.yaml');
|
||||
if (await fs.pathExists(atRoot)) results.push(atRoot);
|
||||
|
|
@ -149,6 +155,16 @@ async function resolveInstalledModuleYaml(moduleName) {
|
|||
if (found) return found;
|
||||
}
|
||||
|
||||
// Community modules are cloned to ~/.bmad/cache/community-modules/<name>/
|
||||
// (parallel to the external-modules cache used above). Search there too so
|
||||
// collectAgentsFromModuleYaml and writeCentralConfig can locate community
|
||||
// module.yaml files regardless of how nested the layout is.
|
||||
const communityCacheRoot = path.join(os.homedir(), '.bmad', 'cache', 'community-modules', moduleName);
|
||||
if (await fs.pathExists(communityCacheRoot)) {
|
||||
const found = await searchRoot(communityCacheRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// Fallback: local custom-source modules store their source path in the
|
||||
// CustomModuleManager resolution cache populated during the same install run.
|
||||
// Match by code OR name since callers may use either form.
|
||||
|
|
|
|||
Loading…
Reference in New Issue