diff --git a/test/test-installation-components.js b/test/test-installation-components.js index dda834079..8e5294a59 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -410,34 +410,47 @@ async function runTests() { 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 { clearCache(); const platformCodes = await loadPlatformCodes(); 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( 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), ), - '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 installedBmadDir = await createTestBmadFixture(); + // Create legacy dirs that should be cleaned up const legacyDirs = [ - path.join(tempProjectDir, '.opencode', 'agents', 'bmad-legacy-agent'), - path.join(tempProjectDir, '.opencode', 'commands', 'bmad-legacy-command'), + path.join(tempProjectDir, '.opencode', 'skills', 'bmad-legacy-skill'), path.join(tempProjectDir, '.opencode', 'agent', 'bmad-legacy-agent-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'); - const skillFile = path.join(tempProjectDir, '.opencode', 'skills', 'bmad-master', 'SKILL.md'); - assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output'); + // Agents should be flat files in .opencode/agents/ (canonicalId = bmad-master from fixture) + 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( !(await fs.pathExists(path.join(tempProjectDir, '.opencode', legacyDir))), `OpenCode setup removes legacy .opencode/${legacyDir} dir`, @@ -470,7 +492,7 @@ async function runTests() { await fs.remove(tempProjectDir); await fs.remove(installedBmadDir); } catch (error) { - assert(false, 'OpenCode native skills migration test succeeds', error.message); + assert(false, 'OpenCode multi-target migration test succeeds', error.message); } console.log(''); @@ -788,10 +810,11 @@ async function runTests() { const childProjectDir = path.join(parentProjectDir, 'child'); 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, '.opencode', 'skills', 'bmad-existing')); + await fs.ensureDir(path.join(parentProjectDir, '.opencode', 'agents')); 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(); await ideManager.ensureInitialized(); @@ -799,13 +822,13 @@ async function runTests() { silent: true, 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?.conflictDir === expectedConflictDir, - 'OpenCode ancestor rejection points at ancestor .opencode/skills dir', + 'OpenCode ancestor rejection points at ancestor .opencode/agents dir', ); await fs.remove(tempRoot); diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index a93fe0c87..30c77e8f8 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -36,8 +36,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { /** * Detect whether this IDE already has configuration in the project. - * For skill_format platforms, checks for bmad-prefixed entries in target_dir - * (matching old codex.js behavior) instead of just checking directory existence. + * For skill_format platforms, checks for bmad-prefixed entries in target_dir. + * For multi-target platforms, checks all target directories for bmad-prefixed entries. * @param {string} projectDir - Project directory * @returns {Promise} */ @@ -54,6 +54,25 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { } 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); } @@ -982,34 +1001,48 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} } /** - * Check ancestor directories for existing BMAD files in the same target_dir. - * IDEs like Claude Code inherit commands from parent directories, so an existing - * installation in an ancestor would cause duplicate commands. + * Check ancestor directories for existing BMAD files in target directories. + * IDEs like Claude Code and OpenCode inherit commands from parent directories, + * 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 * @returns {Promise} Path to conflicting directory, or null if clean */ async findAncestorConflict(projectDir) { - const targetDir = this.installerConfig?.target_dir; - if (!targetDir) return null; + // Collect all target directories to check + 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)); let current = path.dirname(resolvedProject); const root = path.parse(current).root; while (current !== root && current.length > root.length) { - const candidatePath = path.join(current, targetDir); - try { - if (await fs.pathExists(candidatePath)) { - const entries = await fs.readdir(candidatePath); - const hasBmad = entries.some( - (e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'), - ); - if (hasBmad) { - return candidatePath; + for (const targetDir of targetDirs) { + const candidatePath = path.join(current, targetDir); + try { + if (await fs.pathExists(candidatePath)) { + const entries = await fs.readdir(candidatePath); + const hasBmad = entries.some( + (e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'), + ); + if (hasBmad) { + return candidatePath; + } } + } catch { + // Can't read directory — skip } - } catch { - // Can't read directory — skip } current = path.dirname(current); } diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 2c4d2e920..647a6fb8b 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -193,13 +193,17 @@ platforms: description: "OpenCode terminal coding assistant" installer: legacy_targets: - - .opencode/agents - - .opencode/commands + - .opencode/skills - .opencode/agent - .opencode/command - target_dir: .opencode/skills - template_type: opencode - skill_format: true + target_dir: .opencode/agents # For detection; actual output uses targets below + targets: + - target_dir: .opencode/agents + template_type: opencode + artifact_types: [agents] + - target_dir: .opencode/commands + template_type: opencode + artifact_types: [workflows, tasks, tools] ancestor_conflict_check: true pi: