fix(opencode): use native agents/ and commands/ layout instead of skills/
OpenCode does not have a native 'skills' concept. It uses .opencode/agents/ for agent persona definitions and .opencode/commands/ for slash commands. The installer was incorrectly placing everything into .opencode/skills/ as SKILL.md directories, which duplicated content from _bmad/ and prevented workflows from appearing as native opencode commands. Switch opencode to a multi-target layout: - .opencode/agents/ — flat agent launcher files (opencode-agent template) - .opencode/commands/ — flat workflow/task/tool command files This matches the output that older installations (e.g. v6.0.4) produced and aligns with how opencode actually discovers and surfaces agents and commands. Also update detect() and findAncestorConflict() in the config-driven handler to support multi-target platforms, so detection and ancestor conflict checks work correctly for all target directories.
This commit is contained in:
parent
be555aad8b
commit
cd88291fa7
|
|
@ -410,34 +410,47 @@ async function runTests() {
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Test 8: OpenCode Native Skills Install
|
// Test 8: OpenCode Multi-Target Install (agents + commands)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 8: OpenCode Native Skills${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 8: OpenCode Multi-Target Install${colors.reset}\n`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
clearCache();
|
clearCache();
|
||||||
const platformCodes = await loadPlatformCodes();
|
const platformCodes = await loadPlatformCodes();
|
||||||
const opencodeInstaller = platformCodes.platforms.opencode?.installer;
|
const opencodeInstaller = platformCodes.platforms.opencode?.installer;
|
||||||
|
|
||||||
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
|
assert(
|
||||||
|
Array.isArray(opencodeInstaller?.targets) && opencodeInstaller.targets.length === 2,
|
||||||
|
'OpenCode installer uses multi-target layout with 2 targets',
|
||||||
|
);
|
||||||
|
|
||||||
assert(opencodeInstaller?.skill_format === true, 'OpenCode installer enables native skill output');
|
assert(
|
||||||
|
opencodeInstaller?.targets?.[0]?.target_dir === '.opencode/agents' &&
|
||||||
|
opencodeInstaller?.targets?.[0]?.artifact_types?.includes('agents'),
|
||||||
|
'OpenCode agents target writes to .opencode/agents',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
opencodeInstaller?.targets?.[1]?.target_dir === '.opencode/commands' &&
|
||||||
|
['workflows', 'tasks', 'tools'].every((t) => opencodeInstaller?.targets?.[1]?.artifact_types?.includes(t)),
|
||||||
|
'OpenCode commands target writes workflows, tasks, and tools to .opencode/commands',
|
||||||
|
);
|
||||||
|
|
||||||
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(opencodeInstaller?.legacy_targets) &&
|
Array.isArray(opencodeInstaller?.legacy_targets) &&
|
||||||
['.opencode/agents', '.opencode/commands', '.opencode/agent', '.opencode/command'].every((legacyTarget) =>
|
['.opencode/skills', '.opencode/agent', '.opencode/command'].every((legacyTarget) =>
|
||||||
opencodeInstaller.legacy_targets.includes(legacyTarget),
|
opencodeInstaller.legacy_targets.includes(legacyTarget),
|
||||||
),
|
),
|
||||||
'OpenCode installer cleans split legacy agent and command output',
|
'OpenCode installer cleans legacy skills and singular agent/command output',
|
||||||
);
|
);
|
||||||
|
|
||||||
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();
|
||||||
|
// Create legacy dirs that should be cleaned up
|
||||||
const legacyDirs = [
|
const legacyDirs = [
|
||||||
path.join(tempProjectDir, '.opencode', 'agents', 'bmad-legacy-agent'),
|
path.join(tempProjectDir, '.opencode', 'skills', 'bmad-legacy-skill'),
|
||||||
path.join(tempProjectDir, '.opencode', 'commands', 'bmad-legacy-command'),
|
|
||||||
path.join(tempProjectDir, '.opencode', 'agent', 'bmad-legacy-agent-singular'),
|
path.join(tempProjectDir, '.opencode', 'agent', 'bmad-legacy-agent-singular'),
|
||||||
path.join(tempProjectDir, '.opencode', 'command', 'bmad-legacy-command-singular'),
|
path.join(tempProjectDir, '.opencode', 'command', 'bmad-legacy-command-singular'),
|
||||||
];
|
];
|
||||||
|
|
@ -457,10 +470,19 @@ async function runTests() {
|
||||||
|
|
||||||
assert(result.success === true, 'OpenCode setup succeeds against temp project');
|
assert(result.success === true, 'OpenCode setup succeeds against temp project');
|
||||||
|
|
||||||
const skillFile = path.join(tempProjectDir, '.opencode', 'skills', 'bmad-master', 'SKILL.md');
|
// Agents should be flat files in .opencode/agents/ (canonicalId = bmad-master from fixture)
|
||||||
assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output');
|
const agentFile = path.join(tempProjectDir, '.opencode', 'agents', 'bmad-master.md');
|
||||||
|
assert(await fs.pathExists(agentFile), 'OpenCode install writes flat agent files to .opencode/agents');
|
||||||
|
|
||||||
for (const legacyDir of ['agents', 'commands', 'agent', 'command']) {
|
// Agent file should have opencode-specific frontmatter (mode: all)
|
||||||
|
const agentContent = await fs.readFile(agentFile, 'utf8');
|
||||||
|
assert(agentContent.includes('mode: all'), 'OpenCode agent file uses opencode-specific frontmatter');
|
||||||
|
|
||||||
|
// Commands directory should exist (for workflows/tasks)
|
||||||
|
assert(await fs.pathExists(path.join(tempProjectDir, '.opencode', 'commands')), 'OpenCode creates .opencode/commands directory');
|
||||||
|
|
||||||
|
// Legacy dirs should be cleaned up
|
||||||
|
for (const legacyDir of ['skills', 'agent', 'command']) {
|
||||||
assert(
|
assert(
|
||||||
!(await fs.pathExists(path.join(tempProjectDir, '.opencode', legacyDir))),
|
!(await fs.pathExists(path.join(tempProjectDir, '.opencode', legacyDir))),
|
||||||
`OpenCode setup removes legacy .opencode/${legacyDir} dir`,
|
`OpenCode setup removes legacy .opencode/${legacyDir} dir`,
|
||||||
|
|
@ -470,7 +492,7 @@ async function runTests() {
|
||||||
await fs.remove(tempProjectDir);
|
await fs.remove(tempProjectDir);
|
||||||
await fs.remove(installedBmadDir);
|
await fs.remove(installedBmadDir);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assert(false, 'OpenCode native skills migration test succeeds', error.message);
|
assert(false, 'OpenCode multi-target migration test succeeds', error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
@ -788,10 +810,11 @@ async function runTests() {
|
||||||
const childProjectDir = path.join(parentProjectDir, 'child');
|
const childProjectDir = path.join(parentProjectDir, 'child');
|
||||||
const installedBmadDir = await createTestBmadFixture();
|
const installedBmadDir = await createTestBmadFixture();
|
||||||
|
|
||||||
|
// Place existing BMAD agent files in parent's .opencode/agents (multi-target layout)
|
||||||
await fs.ensureDir(path.join(parentProjectDir, '.git'));
|
await fs.ensureDir(path.join(parentProjectDir, '.git'));
|
||||||
await fs.ensureDir(path.join(parentProjectDir, '.opencode', 'skills', 'bmad-existing'));
|
await fs.ensureDir(path.join(parentProjectDir, '.opencode', 'agents'));
|
||||||
await fs.ensureDir(childProjectDir);
|
await fs.ensureDir(childProjectDir);
|
||||||
await fs.writeFile(path.join(parentProjectDir, '.opencode', 'skills', 'bmad-existing', 'SKILL.md'), 'legacy\n');
|
await fs.writeFile(path.join(parentProjectDir, '.opencode', 'agents', 'bmad-agent-bmm-pm.md'), 'existing\n');
|
||||||
|
|
||||||
const ideManager = new IdeManager();
|
const ideManager = new IdeManager();
|
||||||
await ideManager.ensureInitialized();
|
await ideManager.ensureInitialized();
|
||||||
|
|
@ -799,13 +822,13 @@ async function runTests() {
|
||||||
silent: true,
|
silent: true,
|
||||||
selectedModules: ['bmm'],
|
selectedModules: ['bmm'],
|
||||||
});
|
});
|
||||||
const expectedConflictDir = await fs.realpath(path.join(parentProjectDir, '.opencode', 'skills'));
|
const expectedConflictDir = await fs.realpath(path.join(parentProjectDir, '.opencode', 'agents'));
|
||||||
|
|
||||||
assert(result.success === false, 'OpenCode setup refuses install when ancestor skills already exist');
|
assert(result.success === false, 'OpenCode setup refuses install when ancestor agents already exist');
|
||||||
assert(result.handlerResult?.reason === 'ancestor-conflict', 'OpenCode ancestor rejection reports ancestor-conflict reason');
|
assert(result.handlerResult?.reason === 'ancestor-conflict', 'OpenCode ancestor rejection reports ancestor-conflict reason');
|
||||||
assert(
|
assert(
|
||||||
result.handlerResult?.conflictDir === expectedConflictDir,
|
result.handlerResult?.conflictDir === expectedConflictDir,
|
||||||
'OpenCode ancestor rejection points at ancestor .opencode/skills dir',
|
'OpenCode ancestor rejection points at ancestor .opencode/agents dir',
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.remove(tempRoot);
|
await fs.remove(tempRoot);
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether this IDE already has configuration in the project.
|
* Detect whether this IDE already has configuration in the project.
|
||||||
* For skill_format platforms, checks for bmad-prefixed entries in target_dir
|
* For skill_format platforms, checks for bmad-prefixed entries in target_dir.
|
||||||
* (matching old codex.js behavior) instead of just checking directory existence.
|
* For multi-target platforms, checks all target directories for bmad-prefixed entries.
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,6 +54,25 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For multi-target platforms, check all target directories for bmad-prefixed entries
|
||||||
|
if (this.installerConfig?.targets) {
|
||||||
|
for (const target of this.installerConfig.targets) {
|
||||||
|
const dir = path.join(projectDir || process.cwd(), target.target_dir);
|
||||||
|
if (await fs.pathExists(dir)) {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dir);
|
||||||
|
if (entries.some((e) => typeof e === 'string' && e.startsWith('bmad'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable directories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return super.detect(projectDir);
|
return super.detect(projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -982,21 +1001,34 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check ancestor directories for existing BMAD files in the same target_dir.
|
* Check ancestor directories for existing BMAD files in target directories.
|
||||||
* IDEs like Claude Code inherit commands from parent directories, so an existing
|
* IDEs like Claude Code and OpenCode inherit commands from parent directories,
|
||||||
* installation in an ancestor would cause duplicate commands.
|
* so an existing installation in an ancestor would cause duplicate commands.
|
||||||
|
* Supports both single target_dir and multi-target configurations.
|
||||||
* @param {string} projectDir - Project directory being installed to
|
* @param {string} projectDir - Project directory being installed to
|
||||||
* @returns {Promise<string|null>} Path to conflicting directory, or null if clean
|
* @returns {Promise<string|null>} Path to conflicting directory, or null if clean
|
||||||
*/
|
*/
|
||||||
async findAncestorConflict(projectDir) {
|
async findAncestorConflict(projectDir) {
|
||||||
const targetDir = this.installerConfig?.target_dir;
|
// Collect all target directories to check
|
||||||
if (!targetDir) return null;
|
const targetDirs = [];
|
||||||
|
if (this.installerConfig?.target_dir) {
|
||||||
|
targetDirs.push(this.installerConfig.target_dir);
|
||||||
|
}
|
||||||
|
if (this.installerConfig?.targets) {
|
||||||
|
for (const target of this.installerConfig.targets) {
|
||||||
|
if (target.target_dir && !targetDirs.includes(target.target_dir)) {
|
||||||
|
targetDirs.push(target.target_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetDirs.length === 0) return null;
|
||||||
|
|
||||||
const resolvedProject = await fs.realpath(path.resolve(projectDir));
|
const resolvedProject = await fs.realpath(path.resolve(projectDir));
|
||||||
let current = path.dirname(resolvedProject);
|
let current = path.dirname(resolvedProject);
|
||||||
const root = path.parse(current).root;
|
const root = path.parse(current).root;
|
||||||
|
|
||||||
while (current !== root && current.length > root.length) {
|
while (current !== root && current.length > root.length) {
|
||||||
|
for (const targetDir of targetDirs) {
|
||||||
const candidatePath = path.join(current, targetDir);
|
const candidatePath = path.join(current, targetDir);
|
||||||
try {
|
try {
|
||||||
if (await fs.pathExists(candidatePath)) {
|
if (await fs.pathExists(candidatePath)) {
|
||||||
|
|
@ -1011,6 +1043,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
} catch {
|
} catch {
|
||||||
// Can't read directory — skip
|
// Can't read directory — skip
|
||||||
}
|
}
|
||||||
|
}
|
||||||
current = path.dirname(current);
|
current = path.dirname(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,13 +193,17 @@ platforms:
|
||||||
description: "OpenCode terminal coding assistant"
|
description: "OpenCode terminal coding assistant"
|
||||||
installer:
|
installer:
|
||||||
legacy_targets:
|
legacy_targets:
|
||||||
- .opencode/agents
|
- .opencode/skills
|
||||||
- .opencode/commands
|
|
||||||
- .opencode/agent
|
- .opencode/agent
|
||||||
- .opencode/command
|
- .opencode/command
|
||||||
target_dir: .opencode/skills
|
target_dir: .opencode/agents # For detection; actual output uses targets below
|
||||||
|
targets:
|
||||||
|
- target_dir: .opencode/agents
|
||||||
template_type: opencode
|
template_type: opencode
|
||||||
skill_format: true
|
artifact_types: [agents]
|
||||||
|
- target_dir: .opencode/commands
|
||||||
|
template_type: opencode
|
||||||
|
artifact_types: [workflows, tasks, tools]
|
||||||
ancestor_conflict_check: true
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
pi:
|
pi:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue