feat(opencode): add JSON-only integration and compact AGENTS.md generator (#570)

* feat: add OpenCode integration implementation plan for BMAD-METHOD

* installer(opencode): add OpenCode target metadata in install.config.yaml

* chore(deps): add comment-json for JSONC parsing in OpenCode integration

* feat(installer/opencode): implement setupOpenCode with minimal instructions merge and BMAD-managed agents/commands

* feat(installer): add OpenCode (SST) to IDE selector and CLI --ide help

* fix(opencode): align generated opencode.json(c) with schema (instructions as strings; agent.prompt; command.template; remove unsupported fields)

* feat(installer): enhance OpenCode setup with agent selection and prefix options

* fix: update configuration file references from `bmad-core/core-config.yaml` to `.bmad-core/core-config.yaml` across multiple agent and task files for consistency and clarity.

* refactor: streamline OpenCode configuration prompts and normalize instruction paths for agents and tasks

* feat: add tools property to agent definitions for enhanced functionality. Otherwise opencode consders the subagents as readonly

* feat: add extraction of 'whenToUse'  from agents markdown files for improved agent configuration in opencode

* feat: enhance task purpose extraction from markdown files with improved parsing and cleanup logic

* feat: add collision warnings for non-BMAD-managed agent and command keys during setup

* feat: generate and update AGENTS.md for OpenCode integration with agent and task details

* feat: add compact AGENTS.md generator and JSON-only integration for OpenCode

* chore(docs): remove completed OpenCode integration implementation plans

* feat: enable default prefixes for agent and command keys to avoid collisions

* fix: remove unnecessary line breaks in 'whenToUse' descriptions for QA agents to mathc the rest of the agents definitions and improve programatic parsing of whenToUse prop

* fix: update OpenCode references to remove 'SST' for consistency across documentation and configuration

* fix: update agent mode from 'subagent' to 'all' for consistency in agent definitions

* fix: consolidate 'whenToUse' description format for clarity and consistent parsing
This commit is contained in:
Javier Gomez
2025-09-12 00:44:41 +02:00
committed by GitHub
parent 2b247ea385
commit f09e282d72
38 changed files with 915 additions and 96 deletions

View File

@@ -3,6 +3,7 @@ const fs = require('fs-extra');
const yaml = require('js-yaml');
const chalk = require('chalk');
const inquirer = require('inquirer');
const cjson = require('comment-json');
const fileManager = require('./file-manager');
const configLoader = require('./config-loader');
const { extractYamlFromAgent } = require('../../lib/yaml-utils');
@@ -44,6 +45,9 @@ class IdeSetup extends BaseIdeSetup {
case 'cursor': {
return this.setupCursor(installDir, selectedAgent);
}
case 'opencode': {
return this.setupOpenCode(installDir, selectedAgent, spinner, preConfiguredSettings);
}
case 'claude-code': {
return this.setupClaudeCode(installDir, selectedAgent);
}
@@ -93,6 +97,643 @@ class IdeSetup extends BaseIdeSetup {
}
}
async setupOpenCode(installDir, selectedAgent, spinner = null, preConfiguredSettings = null) {
// Minimal JSON-only integration per plan:
// - If opencode.json or opencode.jsonc exists: only ensure instructions include .bmad-core/core-config.yaml
// - If none exists: create minimal opencode.jsonc with $schema and instructions array including that file
const jsonPath = path.join(installDir, 'opencode.json');
const jsoncPath = path.join(installDir, 'opencode.jsonc');
const hasJson = await fileManager.pathExists(jsonPath);
const hasJsonc = await fileManager.pathExists(jsoncPath);
// Determine key prefix preferences (with sensible defaults)
// Defaults: non-prefixed (agents = "dev", commands = "create-doc")
let useAgentPrefix = false;
let useCommandPrefix = false;
// Allow pre-configuration (if passed) to skip prompts
const pre = preConfiguredSettings && preConfiguredSettings.opencode;
if (pre && typeof pre.useAgentPrefix === 'boolean') useAgentPrefix = pre.useAgentPrefix;
if (pre && typeof pre.useCommandPrefix === 'boolean') useCommandPrefix = pre.useCommandPrefix;
// If no pre-config and in interactive mode, prompt the user
if (!pre) {
// Pause spinner during prompts if active
let spinnerWasActive = false;
if (spinner && spinner.isSpinning) {
spinner.stop();
spinnerWasActive = true;
}
try {
const resp = await inquirer.prompt([
{
type: 'confirm',
name: 'useAgentPrefix',
message:
"Prefix agent keys with 'bmad-'? (Recommended to avoid collisions, e.g., 'bmad-dev')",
default: true,
},
{
type: 'confirm',
name: 'useCommandPrefix',
message:
"Prefix command keys with 'bmad:tasks:'? (Recommended, e.g., 'bmad:tasks:create-doc')",
default: true,
},
]);
useAgentPrefix = resp.useAgentPrefix;
useCommandPrefix = resp.useCommandPrefix;
} catch {
// Keep defaults if prompt fails or is not interactive
} finally {
if (spinner && spinnerWasActive) spinner.start();
}
}
const ensureInstructionRef = (obj) => {
const preferred = '.bmad-core/core-config.yaml';
const alt = './.bmad-core/core-config.yaml';
if (!obj.instructions) obj.instructions = [];
if (!Array.isArray(obj.instructions)) obj.instructions = [obj.instructions];
// Normalize alternative form (with './') to preferred without './'
obj.instructions = obj.instructions.map((it) =>
typeof it === 'string' && it === alt ? preferred : it,
);
const hasPreferred = obj.instructions.some(
(it) => typeof it === 'string' && it === preferred,
);
if (!hasPreferred) obj.instructions.push(preferred);
return obj;
};
const mergeBmadAgentsAndCommands = async (configObj) => {
// Ensure objects exist
if (!configObj.agent || typeof configObj.agent !== 'object') configObj.agent = {};
if (!configObj.command || typeof configObj.command !== 'object') configObj.command = {};
if (!configObj.instructions) configObj.instructions = [];
if (!Array.isArray(configObj.instructions)) configObj.instructions = [configObj.instructions];
// Track a concise summary of changes
const summary = {
target: null,
created: false,
agentsAdded: 0,
agentsUpdated: 0,
agentsSkipped: 0,
commandsAdded: 0,
commandsUpdated: 0,
commandsSkipped: 0,
};
// Determine package scope: previously SELECTED packages in installer UI
const selectedPackages = preConfiguredSettings?.selectedPackages || {
includeCore: true,
packs: [],
};
// Helper: ensure an instruction path is present without './' prefix, de-duplicating './' variants
const ensureInstructionPath = (pathNoDot) => {
const withDot = `./${pathNoDot}`;
// Normalize any existing './' variant to non './'
configObj.instructions = configObj.instructions.map((it) =>
typeof it === 'string' && it === withDot ? pathNoDot : it,
);
const has = configObj.instructions.some((it) => typeof it === 'string' && it === pathNoDot);
if (!has) configObj.instructions.push(pathNoDot);
};
// Helper: detect orchestrator agents to set as primary mode
const isOrchestratorAgent = (agentId) => /(^|-)orchestrator$/i.test(agentId);
// Helper: extract whenToUse string from an agent markdown file
const extractWhenToUseFromFile = async (absPath) => {
try {
const raw = await fileManager.readFile(absPath);
const yamlMatch = raw.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlBlock = yamlMatch ? yamlMatch[1].trim() : null;
if (!yamlBlock) return null;
// Try quoted first, then unquoted
const quoted = yamlBlock.match(/whenToUse:\s*"([^"]+)"/i);
if (quoted && quoted[1]) return quoted[1].trim();
const unquoted = yamlBlock.match(/whenToUse:\s*([^\n\r]+)/i);
if (unquoted && unquoted[1]) return unquoted[1].trim();
} catch {
// ignore
}
return null;
};
// Helper: extract Purpose string from a task file (YAML fenced block, Markdown heading, or inline 'Purpose:')
const extractTaskPurposeFromFile = async (absPath) => {
const cleanupAndSummarize = (text) => {
if (!text) return null;
let t = String(text);
// Drop code fences and HTML comments
t = t.replaceAll(/```[\s\S]*?```/g, '');
t = t.replaceAll(/<!--([\s\S]*?)-->/g, '');
// Normalize line endings
t = t.replaceAll(/\r\n?/g, '\n');
// Take the first non-empty paragraph
const paragraphs = t.split(/\n\s*\n/g).map((p) => p.trim());
let first = paragraphs.find((p) => p.length > 0) || '';
// Remove leading list markers, quotes, and headings remnants
first = first.replaceAll(/^\s*[>*-]\s+/gm, '');
first = first.replaceAll(/^#{1,6}\s+/gm, '');
// Strip simple Markdown formatting
first = first.replaceAll(/\*\*([^*]+)\*\*/g, '$1').replaceAll(/\*([^*]+)\*/g, '$1');
first = first.replaceAll(/`([^`]+)`/g, '$1');
// Collapse whitespace
first = first.replaceAll(/\s+/g, ' ').trim();
if (!first) return null;
// Prefer ending at a sentence boundary if long
const maxLen = 320;
if (first.length > maxLen) {
const boundary = first.slice(0, maxLen + 40).match(/^[\s\S]*?[.!?](\s|$)/);
const cut = boundary ? boundary[0] : first.slice(0, maxLen);
return cut.trim();
}
return first;
};
try {
const raw = await fileManager.readFile(absPath);
// 1) YAML fenced block: look for Purpose fields
const yamlMatch = raw.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlBlock = yamlMatch ? yamlMatch[1].trim() : null;
if (yamlBlock) {
try {
const data = yaml.load(yamlBlock);
if (data) {
let val = data.Purpose ?? data.purpose;
if (!val && data.task && (data.task.Purpose || data.task.purpose)) {
val = data.task.Purpose ?? data.task.purpose;
}
if (typeof val === 'string') {
const cleaned = cleanupAndSummarize(val);
if (cleaned) return cleaned;
}
}
} catch {
// ignore YAML parse errors
}
// Fallback regex inside YAML block
const quoted = yamlBlock.match(/(?:^|\n)\s*(?:Purpose|purpose):\s*"([^"]+)"/);
if (quoted && quoted[1]) {
const cleaned = cleanupAndSummarize(quoted[1]);
if (cleaned) return cleaned;
}
const unquoted = yamlBlock.match(/(?:^|\n)\s*(?:Purpose|purpose):\s*([^\n\r]+)/);
if (unquoted && unquoted[1]) {
const cleaned = cleanupAndSummarize(unquoted[1]);
if (cleaned) return cleaned;
}
}
// 2) Markdown heading section: ## Purpose (any level >= 2)
const headingRe = /^(#{2,6})\s*Purpose\s*$/im;
const headingMatch = headingRe.exec(raw);
if (headingMatch) {
const headingLevel = headingMatch[1].length;
const sectionStart = headingMatch.index + headingMatch[0].length;
const rest = raw.slice(sectionStart);
// Next heading of same or higher level ends the section
const nextHeadingRe = new RegExp(`^#{1,${headingLevel}}\\s+[^\n]+`, 'im');
const nextMatch = nextHeadingRe.exec(rest);
const section = nextMatch ? rest.slice(0, nextMatch.index) : rest;
const cleaned = cleanupAndSummarize(section);
if (cleaned) return cleaned;
}
// 3) Inline single-line fallback: Purpose: ...
const inline = raw.match(/(?:^|\n)\s*Purpose\s*:\s*([^\n\r]+)/i);
if (inline && inline[1]) {
const cleaned = cleanupAndSummarize(inline[1]);
if (cleaned) return cleaned;
}
} catch {
// ignore
}
return null;
};
// Build core sets
const coreAgentIds = new Set();
const coreTaskIds = new Set();
if (selectedPackages.includeCore) {
for (const id of await this.getCoreAgentIds(installDir)) coreAgentIds.add(id);
for (const id of await this.getCoreTaskIds(installDir)) coreTaskIds.add(id);
}
// Build packs info: { packId, packPath, packKey, agents:Set, tasks:Set }
const packsInfo = [];
if (Array.isArray(selectedPackages.packs)) {
for (const packId of selectedPackages.packs) {
const dotPackPath = path.join(installDir, `.${packId}`);
const altPackPath = path.join(installDir, 'expansion-packs', packId);
const packPath = (await fileManager.pathExists(dotPackPath))
? dotPackPath
: (await fileManager.pathExists(altPackPath))
? altPackPath
: null;
if (!packPath) continue;
// Ensure pack config.yaml is added to instructions (relative path, no './')
const packConfigAbs = path.join(packPath, 'config.yaml');
if (await fileManager.pathExists(packConfigAbs)) {
const relCfg = path.relative(installDir, packConfigAbs).replaceAll('\\', '/');
ensureInstructionPath(relCfg);
}
const packKey = packId.replace(/^bmad-/, '').replaceAll('/', '-');
const info = { packId, packPath, packKey, agents: new Set(), tasks: new Set() };
const glob = require('glob');
const agentsDir = path.join(packPath, 'agents');
if (await fileManager.pathExists(agentsDir)) {
const files = glob.sync('*.md', { cwd: agentsDir });
for (const f of files) info.agents.add(path.basename(f, '.md'));
}
const tasksDir = path.join(packPath, 'tasks');
if (await fileManager.pathExists(tasksDir)) {
const files = glob.sync('*.md', { cwd: tasksDir });
for (const f of files) info.tasks.add(path.basename(f, '.md'));
}
packsInfo.push(info);
}
}
// Generate agents - core first (respect optional agent prefix)
for (const agentId of coreAgentIds) {
const p = await this.findAgentPath(agentId, installDir); // prefers core
if (!p) continue;
const rel = path.relative(installDir, p).replaceAll('\\', '/');
const fileRef = `{file:./${rel}}`;
const baseKey = agentId;
const key = useAgentPrefix
? baseKey.startsWith('bmad-')
? baseKey
: `bmad-${baseKey}`
: baseKey;
const existing = configObj.agent[key];
const whenToUse = await extractWhenToUseFromFile(p);
const agentDef = {
prompt: fileRef,
mode: isOrchestratorAgent(agentId) ? 'primary' : 'all',
tools: { write: true, edit: true, bash: true },
...(whenToUse ? { description: whenToUse } : {}),
};
if (!existing) {
configObj.agent[key] = agentDef;
summary.agentsAdded++;
} else if (
existing &&
typeof existing === 'object' &&
typeof existing.prompt === 'string' &&
existing.prompt.includes(rel)
) {
existing.prompt = agentDef.prompt;
existing.mode = agentDef.mode;
if (whenToUse) existing.description = whenToUse;
existing.tools = { write: true, edit: true, bash: true };
configObj.agent[key] = existing;
summary.agentsUpdated++;
} else {
summary.agentsSkipped++;
// Collision warning: key exists but does not appear BMAD-managed (different prompt path)
console.log(
chalk.yellow(
`⚠︎ Skipped agent key '${key}' (existing entry not BMAD-managed). Tip: enable agent prefixes to avoid collisions.`,
),
);
}
}
// Generate agents - expansion packs (forced pack-specific prefix)
for (const pack of packsInfo) {
for (const agentId of pack.agents) {
const p = path.join(pack.packPath, 'agents', `${agentId}.md`);
if (!(await fileManager.pathExists(p))) continue;
const rel = path.relative(installDir, p).replaceAll('\\', '/');
const fileRef = `{file:./${rel}}`;
const prefixedKey = `bmad-${pack.packKey}-${agentId}`;
const existing = configObj.agent[prefixedKey];
const whenToUse = await extractWhenToUseFromFile(p);
const agentDef = {
prompt: fileRef,
mode: isOrchestratorAgent(agentId) ? 'primary' : 'all',
tools: { write: true, edit: true, bash: true },
...(whenToUse ? { description: whenToUse } : {}),
};
if (!existing) {
configObj.agent[prefixedKey] = agentDef;
summary.agentsAdded++;
} else if (
existing &&
typeof existing === 'object' &&
typeof existing.prompt === 'string' &&
existing.prompt.includes(rel)
) {
existing.prompt = agentDef.prompt;
existing.mode = agentDef.mode;
if (whenToUse) existing.description = whenToUse;
existing.tools = { write: true, edit: true, bash: true };
configObj.agent[prefixedKey] = existing;
summary.agentsUpdated++;
} else {
summary.agentsSkipped++;
console.log(
chalk.yellow(
`⚠︎ Skipped agent key '${prefixedKey}' (existing entry not BMAD-managed). Tip: enable agent prefixes to avoid collisions.`,
),
);
}
}
}
// Generate commands - core first (respect optional command prefix)
for (const taskId of coreTaskIds) {
const p = await this.findTaskPath(taskId, installDir); // prefers core/common
if (!p) continue;
const rel = path.relative(installDir, p).replaceAll('\\', '/');
const fileRef = `{file:./${rel}}`;
const key = useCommandPrefix ? `bmad:tasks:${taskId}` : `${taskId}`;
const existing = configObj.command[key];
const purpose = await extractTaskPurposeFromFile(p);
const cmdDef = { template: fileRef, ...(purpose ? { description: purpose } : {}) };
if (!existing) {
configObj.command[key] = cmdDef;
summary.commandsAdded++;
} else if (
existing &&
typeof existing === 'object' &&
typeof existing.template === 'string' &&
existing.template.includes(rel)
) {
existing.template = cmdDef.template;
if (purpose) existing.description = purpose;
configObj.command[key] = existing;
summary.commandsUpdated++;
} else {
summary.commandsSkipped++;
console.log(
chalk.yellow(
`⚠︎ Skipped command key '${key}' (existing entry not BMAD-managed). Tip: enable command prefixes to avoid collisions.`,
),
);
}
}
// Generate commands - expansion packs (forced pack-specific prefix)
for (const pack of packsInfo) {
for (const taskId of pack.tasks) {
const p = path.join(pack.packPath, 'tasks', `${taskId}.md`);
if (!(await fileManager.pathExists(p))) continue;
const rel = path.relative(installDir, p).replaceAll('\\', '/');
const fileRef = `{file:./${rel}}`;
const prefixedKey = `bmad:${pack.packKey}:${taskId}`;
const existing = configObj.command[prefixedKey];
const purpose = await extractTaskPurposeFromFile(p);
const cmdDef = { template: fileRef, ...(purpose ? { description: purpose } : {}) };
if (!existing) {
configObj.command[prefixedKey] = cmdDef;
summary.commandsAdded++;
} else if (
existing &&
typeof existing === 'object' &&
typeof existing.template === 'string' &&
existing.template.includes(rel)
) {
existing.template = cmdDef.template;
if (purpose) existing.description = purpose;
configObj.command[prefixedKey] = existing;
summary.commandsUpdated++;
} else {
summary.commandsSkipped++;
console.log(
chalk.yellow(
`⚠︎ Skipped command key '${prefixedKey}' (existing entry not BMAD-managed). Tip: enable command prefixes to avoid collisions.`,
),
);
}
}
}
return { configObj, summary };
};
// Helper: generate AGENTS.md section for OpenCode (acts as system prompt memory)
const generateOpenCodeAgentsMd = async () => {
try {
const filePath = path.join(installDir, 'AGENTS.md');
const startMarker = '<!-- BEGIN: BMAD-AGENTS-OPENCODE -->';
const endMarker = '<!-- END: BMAD-AGENTS-OPENCODE -->';
const agents = selectedAgent ? [selectedAgent] : await this.getAllAgentIds(installDir);
const tasks = await this.getAllTaskIds(installDir);
let section = '';
section += `${startMarker}\n`;
section += `# BMAD-METHOD Agents and Tasks (OpenCode)\n\n`;
section += `OpenCode reads AGENTS.md during initialization and uses it as part of its system prompt for the session. This section is auto-generated by BMAD-METHOD for OpenCode.\n\n`;
section += `## How To Use With OpenCode\n\n`;
section += `- Run \`opencode\` in this project. OpenCode will read \`AGENTS.md\` and your OpenCode config (opencode.json[c]).\n`;
section += `- Reference a role naturally, e.g., "As dev, implement ..." or use commands defined in your BMAD tasks.\n`;
section += `- Commit \`.bmad-core\` and \`AGENTS.md\` if you want teammates to share the same configuration.\n`;
section += `- Refresh this section after BMAD updates: \`npx bmad-method install -f -i opencode\`.\n\n`;
section += `### Helpful Commands\n\n`;
section += `- List agents: \`npx bmad-method list:agents\`\n`;
section += `- Reinstall BMAD core and regenerate this section: \`npx bmad-method install -f -i opencode\`\n`;
section += `- Validate configuration: \`npx bmad-method validate\`\n\n`;
// Brief context note for modes and tools
section += `Note\n`;
section += `- Orchestrators run as mode: primary; other agents as all.\n`;
section += `- All agents have tools enabled: write, edit, bash.\n\n`;
section += `## Agents\n\n`;
section += `### Directory\n\n`;
section += `| Title | ID | When To Use |\n|---|---|---|\n`;
// Fallback descriptions for core agents (used if whenToUse is missing)
const fallbackDescriptions = {
'ux-expert':
'Use for UI/UX design, wireframes, prototypes, front-end specs, and user experience optimization',
sm: 'Use for story creation, epic management, retrospectives in party-mode, and agile process guidance',
qa: 'Ensure quality strategy, test design, risk profiling, and QA gates across features',
po: 'Backlog management, story refinement, acceptance criteria, sprint planning, prioritization decisions',
pm: 'PRDs, product strategy, feature prioritization, roadmap planning, and stakeholder communication',
dev: 'Code implementation, debugging, refactoring, and development best practices',
'bmad-orchestrator':
'Workflow coordination, multi-agent tasks, role switching guidance, and when unsure which specialist to consult',
'bmad-master':
'Comprehensive cross-domain execution for tasks that do not require a specific persona',
architect:
'System design, architecture docs, technology selection, API design, and infrastructure planning',
analyst:
'Discovery/research, competitive analysis, project briefs, initial discovery, and brownfield documentation',
};
const sanitizeDesc = (s) => {
if (!s) return '';
let t = String(s).trim();
// Drop surrounding single/double/backtick quotes
t = t.replaceAll(/^['"`]+|['"`]+$/g, '');
// Collapse whitespace
t = t.replaceAll(/\s+/g, ' ').trim();
return t;
};
const agentSummaries = [];
for (const agentId of agents) {
const agentPath = await this.findAgentPath(agentId, installDir);
if (!agentPath) continue;
let whenToUse = '';
try {
const raw = await fileManager.readFile(agentPath);
const yamlMatch = raw.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlBlock = yamlMatch ? yamlMatch[1].trim() : null;
if (yamlBlock) {
try {
const data = yaml.load(yamlBlock);
if (data && typeof data.whenToUse === 'string') {
whenToUse = data.whenToUse;
}
} catch {
// ignore YAML parse errors
}
if (!whenToUse) {
// Fallback regex supporting single or double quotes
const m1 = yamlBlock.match(/whenToUse:\s*"([^\n"]+)"/i);
const m2 = yamlBlock.match(/whenToUse:\s*'([^\n']+)'/i);
const m3 = yamlBlock.match(/whenToUse:\s*([^\n\r]+)/i);
whenToUse = (m1?.[1] || m2?.[1] || m3?.[1] || '').trim();
}
}
} catch {
// ignore read/parse errors for agent metadata extraction
}
const title = await this.getAgentTitle(agentId, installDir);
const finalDesc = sanitizeDesc(whenToUse) || fallbackDescriptions[agentId] || '—';
agentSummaries.push({ agentId, title, whenToUse: finalDesc, path: agentPath });
// Strict 3-column row
section += `| ${title} | ${agentId} | ${finalDesc} |\n`;
}
section += `\n`;
for (const { agentId, title, whenToUse, path: agentPath } of agentSummaries) {
const relativePath = path.relative(installDir, agentPath).replaceAll('\\', '/');
section += `### ${title} (id: ${agentId})\n`;
section += `Source: [${relativePath}](${relativePath})\n\n`;
if (whenToUse) section += `- When to use: ${whenToUse}\n`;
section += `- How to activate: Mention "As ${agentId}, ..." to get role-aligned behavior\n`;
section += `- Full definition: open the source file above (content not embedded)\n\n`;
}
if (tasks && tasks.length > 0) {
section += `## Tasks\n\n`;
section += `These are reusable task briefs; use the paths to open them as needed.\n\n`;
for (const taskId of tasks) {
const taskPath = await this.findTaskPath(taskId, installDir);
if (!taskPath) continue;
const relativePath = path.relative(installDir, taskPath).replaceAll('\\', '/');
section += `### Task: ${taskId}\n`;
section += `Source: [${relativePath}](${relativePath})\n`;
section += `- How to use: Reference the task in your prompt or execute via your configured commands.\n`;
section += `- Full brief: open the source file above (content not embedded)\n\n`;
}
}
section += `${endMarker}\n`;
let finalContent = '';
if (await fileManager.pathExists(filePath)) {
const existing = await fileManager.readFile(filePath);
if (existing.includes(startMarker) && existing.includes(endMarker)) {
const pattern = String.raw`${startMarker}[\s\S]*?${endMarker}`;
const replaced = existing.replace(new RegExp(pattern, 'm'), section);
finalContent = replaced;
} else {
finalContent = existing.trimEnd() + `\n\n` + section;
}
} else {
finalContent += '# Project Agents\n\n';
finalContent += 'This file provides guidance and memory for your coding CLI.\n\n';
finalContent += section;
}
await fileManager.writeFile(filePath, finalContent);
console.log(chalk.green('✓ Created/updated AGENTS.md for OpenCode CLI integration'));
console.log(
chalk.dim(
'OpenCode reads AGENTS.md automatically on init. Run `opencode` in this project to use BMAD agents.',
),
);
} catch {
console.log(chalk.yellow('⚠︎ Skipped creating AGENTS.md for OpenCode (write failed)'));
}
};
if (hasJson || hasJsonc) {
// Preserve existing top-level fields; only touch instructions
const targetPath = hasJsonc ? jsoncPath : jsonPath;
try {
const raw = await fs.readFile(targetPath, 'utf8');
// Use comment-json for both .json and .jsonc for resilience
const parsed = cjson.parse(raw, undefined, true);
ensureInstructionRef(parsed);
const { configObj, summary } = await mergeBmadAgentsAndCommands(parsed);
const output = cjson.stringify(parsed, null, 2);
await fs.writeFile(targetPath, output + (output.endsWith('\n') ? '' : '\n'));
console.log(
chalk.green(
'✓ Updated OpenCode config: ensured BMAD instructions and merged agents/commands',
),
);
// Summary output
console.log(
chalk.dim(
` File: ${path.basename(targetPath)} | Agents +${summary.agentsAdded} ~${summary.agentsUpdated} ${summary.agentsSkipped} | Commands +${summary.commandsAdded} ~${summary.commandsUpdated} ${summary.commandsSkipped}`,
),
);
// Ensure AGENTS.md is created/updated for OpenCode as well
await generateOpenCodeAgentsMd();
} catch (error) {
console.log(chalk.red('✗ Failed to update existing OpenCode config'), error.message);
return false;
}
return true;
}
// Create minimal opencode.jsonc
const minimal = {
$schema: 'https://opencode.ai/config.json',
instructions: ['.bmad-core/core-config.yaml'],
agent: {},
command: {},
};
try {
const { configObj, summary } = await mergeBmadAgentsAndCommands(minimal);
const output = cjson.stringify(minimal, null, 2);
await fs.writeFile(jsoncPath, output + (output.endsWith('\n') ? '' : '\n'));
console.log(
chalk.green('✓ Created opencode.jsonc with BMAD instructions, agents, and commands'),
);
console.log(
chalk.dim(
` File: opencode.jsonc | Agents +${summary.agentsAdded} | Commands +${summary.commandsAdded}`,
),
);
// Also create/update AGENTS.md for OpenCode on new-config path
await generateOpenCodeAgentsMd();
return true;
} catch (error) {
console.log(chalk.red('✗ Failed to create opencode.jsonc'), error.message);
return false;
}
}
async setupCodex(installDir, selectedAgent, options) {
options = options ?? { webEnabled: false };
// Codex reads AGENTS.md at the project root as project memory (CLI & Web).
@@ -230,7 +871,6 @@ class IdeSetup extends BaseIdeSetup {
if (options.webEnabled) {
if (exists) {
let gi = await fileManager.readFile(gitignorePath);
// Remove lines that ignore BMAD dot-folders
const updated = gi
.split(/\r?\n/)
.filter((l) => !/^\s*\.bmad-core\/?\s*$/.test(l) && !/^\s*\.bmad-\*\/?\s*$/.test(l))

View File

@@ -409,10 +409,23 @@ class Installer {
for (const ide of ides) {
spinner.text = `Setting up ${ide} integration...`;
let preConfiguredSettings = null;
if (ide === 'github-copilot') {
preConfiguredSettings = config.githubCopilotConfig;
} else if (ide === 'auggie-cli') {
preConfiguredSettings = config.augmentCodeConfig;
switch (ide) {
case 'github-copilot': {
preConfiguredSettings = config.githubCopilotConfig;
break;
}
case 'auggie-cli': {
preConfiguredSettings = config.augmentCodeConfig;
break;
}
case 'opencode': {
preConfiguredSettings = config.openCodeConfig;
break;
}
default: {
// no pre-configured settings
break;
}
}
await ideSetup.setup(ide, installDir, config.agent, spinner, preConfiguredSettings);
}