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:
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user