From ae21c1b7c3086920b2e8a1b3e3dead5a05674dec Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Fri, 6 Mar 2026 02:30:50 -0700 Subject: [PATCH] refactor(installer): migrate codex to config-driven pipeline Delete the custom codex.js installer (441 lines) and route Codex through the config-driven pipeline via platform-codes.yaml. This fixes 7 task/tool descriptions that were generic due to bypassing manifests, and eliminates duplicate transformToSkillFormat code. Key changes: - Add codex entry to platform-codes.yaml with skill_format + legacy_targets - Remove codex from custom installer list in manager.js - Add installCustomAgentLauncher() to config-driven for custom agent support - Add detect() override for skill_format platforms (bmad-prefix check) - Set configDir from target_dir for base-class detect() compatibility Co-Authored-By: Claude Opus 4.6 --- .../cli/installers/lib/ide/_config-driven.js | 107 ++++- tools/cli/installers/lib/ide/codex.js | 441 ------------------ tools/cli/installers/lib/ide/manager.js | 15 +- .../installers/lib/ide/platform-codes.yaml | 8 +- 4 files changed, 108 insertions(+), 463 deletions(-) delete mode 100644 tools/cli/installers/lib/ide/codex.js diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 7e4eff5c5..ceb0ae50c 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -25,6 +25,34 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { super(platformCode, platformConfig.name, platformConfig.preferred); this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; + + // Set configDir from target_dir so base-class detect() works + if (this.installerConfig?.target_dir) { + this.configDir = this.installerConfig.target_dir; + } + } + + /** + * 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. + * @param {string} projectDir - Project directory + * @returns {Promise} + */ + async detect(projectDir) { + if (this.installerConfig?.skill_format && this.configDir) { + const dir = path.join(projectDir || process.cwd(), this.configDir); + if (await fs.pathExists(dir)) { + try { + const entries = await fs.readdir(dir); + return entries.some((e) => typeof e === 'string' && e.startsWith('bmad')); + } catch { + return false; + } + } + return false; + } + return super.detect(projectDir); } /** @@ -425,25 +453,26 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} // No default } - let rendered = template + // Replace _bmad placeholder with actual folder name BEFORE inserting paths, + // so that paths containing '_bmad' are not corrupted by the blanket replacement. + let rendered = template.replaceAll('_bmad', this.bmadFolderName); + + // Replace {{bmadFolderName}} placeholder if present + rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName); + + rendered = rendered .replaceAll('{{name}}', artifact.name || '') .replaceAll('{{module}}', artifact.module || 'core') .replaceAll('{{path}}', pathToUse) .replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`) .replaceAll('{{workflow_path}}', pathToUse); - // Replace _bmad placeholder with actual folder name - rendered = rendered.replaceAll('_bmad', this.bmadFolderName); - - // Replace {{bmadFolderName}} placeholder if present - rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName); - return rendered; } /** * Write artifact as a skill directory with SKILL.md inside. - * Mirrors codex.js writeSkillArtifacts() approach. + * Writes artifact as a skill directory with SKILL.md inside. * @param {string} targetPath - Base skills directory * @param {Object} artifact - Artifact data * @param {string} content - Rendered template content @@ -461,12 +490,12 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} // Create skill directory const skillDir = path.join(targetPath, skillName); - await fs.ensureDir(skillDir); + await this.ensureDir(skillDir); // Transform content: rewrite frontmatter for skills format const skillContent = this.transformToSkillFormat(content, skillName); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent, 'utf8'); + await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); } /** @@ -506,6 +535,64 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} return `---\n${newFrontmatter}\n---\n${body}`; } + /** + * Install a custom agent launcher. + * For skill_format platforms, produces /SKILL.md. + * For flat platforms, produces a single file in target_dir. + * @param {string} projectDir - Project directory + * @param {string} agentName - Agent name (e.g., "fred-commit-poet") + * @param {string} agentPath - Path to compiled agent (relative to project root) + * @param {Object} metadata - Agent metadata + * @returns {Object|null} Info about created file/skill + */ + async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { + if (!this.installerConfig?.target_dir) return null; + + const { customAgentDashName } = require('./shared/path-utils'); + const targetPath = path.join(projectDir, this.installerConfig.target_dir); + await this.ensureDir(targetPath); + + // Build artifact to reuse existing template rendering. + // The default-agent template already includes the _bmad/ prefix before {{path}}, + // but agentPath is relative to project root (e.g. "_bmad/custom/agents/fred.md"). + // Strip the bmadFolderName prefix so the template doesn't produce a double path. + const bmadPrefix = this.bmadFolderName + '/'; + const normalizedPath = agentPath.startsWith(bmadPrefix) ? agentPath.slice(bmadPrefix.length) : agentPath; + + const artifact = { + type: 'agent-launcher', + name: agentName, + description: metadata?.description || `${agentName} agent`, + agentPath: normalizedPath, + relativePath: normalizedPath, + module: 'custom', + }; + + const { content: template } = await this.loadTemplate( + this.installerConfig.template_type || 'default', + 'agent', + this.installerConfig, + 'default-agent', + ); + const content = this.renderTemplate(template, artifact); + + if (this.installerConfig.skill_format) { + const skillName = customAgentDashName(agentName).replace(/\.md$/, ''); + const skillDir = path.join(targetPath, skillName); + await this.ensureDir(skillDir); + const skillContent = this.transformToSkillFormat(content, skillName); + const skillPath = path.join(skillDir, 'SKILL.md'); + await this.writeFile(skillPath, skillContent); + return { path: path.relative(projectDir, skillPath), command: `$${skillName}` }; + } + + // Flat file output + const filename = customAgentDashName(agentName); + const filePath = path.join(targetPath, filename); + await this.writeFile(filePath, content); + return { path: path.relative(projectDir, filePath), command: agentName }; + } + /** * Generate filename for artifact * @param {Object} artifact - Artifact data diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js deleted file mode 100644 index 73e93baf2..000000000 --- a/tools/cli/installers/lib/ide/codex.js +++ /dev/null @@ -1,441 +0,0 @@ -const path = require('node:path'); -const os = require('node:os'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const { BaseIdeSetup } = require('./_base-ide'); -const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator'); -const { AgentCommandGenerator } = require('./shared/agent-command-generator'); -const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); -const { getTasksFromBmad } = require('./shared/bmad-artifacts'); -const { toDashPath, resolveSkillName, customAgentDashName } = require('./shared/path-utils'); -const prompts = require('../../../lib/prompts'); - -/** - * Codex setup handler (CLI mode) - */ -class CodexSetup extends BaseIdeSetup { - constructor() { - super('codex', 'Codex', false); - } - - /** - * Setup Codex configuration - * @param {string} projectDir - Project directory - * @param {string} bmadDir - BMAD installation directory - * @param {Object} options - Setup options - */ - async setup(projectDir, bmadDir, options = {}) { - if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); - - // Always use CLI mode - const mode = 'cli'; - - const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); - - // Clean up old .codex/prompts locations (both global and project) - const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); - await this.clearOldBmadFiles(oldGlobalDir, options); - const oldProjectDir = this.getOldCodexPromptDir(projectDir, 'project'); - await this.clearOldBmadFiles(oldProjectDir, options); - - // Install to .agents/skills - const destDir = this.getCodexSkillsDir(projectDir); - await fs.ensureDir(destDir); - await this.clearOldBmadSkills(destDir, options); - - // Collect and write agent skills - const agentGen = new AgentCommandGenerator(this.bmadFolderName); - const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); - const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher'); - - // Collect and write task skills - const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []); - const taskArtifacts = []; - for (const task of tasks) { - const content = await this.readAndProcessWithProject( - task.path, - { - module: task.module, - name: task.name, - }, - projectDir, - ); - taskArtifacts.push({ - type: 'task', - name: task.name, - displayName: task.name, - module: task.module, - canonicalId: task.canonicalId || '', - path: task.path, - sourcePath: task.path, - relativePath: path.join(task.module, 'tasks', `${task.name}.md`), - content, - }); - } - - const ttGen = new TaskToolCommandGenerator(this.bmadFolderName); - const taskSkillArtifacts = taskArtifacts.map((artifact) => ({ - ...artifact, - content: ttGen.generateCommandContent(artifact, artifact.type), - })); - const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task'); - - // Collect and write workflow skills - const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName); - const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir); - const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command'); - - const written = agentCount + workflowCount + tasksWritten; - - if (!options.silent) { - await prompts.log.success( - `${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} skills → ${destDir}`, - ); - } - - return { - success: true, - mode, - artifacts, - counts, - destination: destDir, - written, - }; - } - - /** - * Detect Codex installation by checking for BMAD skills - */ - async detect(projectDir) { - const dir = this.getCodexSkillsDir(projectDir || process.cwd()); - - if (await fs.pathExists(dir)) { - try { - const entries = await fs.readdir(dir); - if (entries && entries.some((entry) => entry && typeof entry === 'string' && entry.startsWith('bmad'))) { - return true; - } - } catch { - // Ignore errors - } - } - - return false; - } - - /** - * Collect Claude-style artifacts for Codex export. - * Returns the normalized artifact list for further processing. - */ - async collectClaudeArtifacts(projectDir, bmadDir, options = {}) { - const selectedModules = options.selectedModules || []; - const artifacts = []; - - // Generate agent launchers - const agentGen = new AgentCommandGenerator(this.bmadFolderName); - const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules); - - for (const artifact of agentArtifacts) { - artifacts.push({ - type: 'agent', - module: artifact.module, - sourcePath: artifact.sourcePath, - relativePath: artifact.relativePath, - content: artifact.content, - }); - } - - const tasks = await getTasksFromBmad(bmadDir, selectedModules); - for (const task of tasks) { - const content = await this.readAndProcessWithProject( - task.path, - { - module: task.module, - name: task.name, - }, - projectDir, - ); - - artifacts.push({ - type: 'task', - name: task.name, - displayName: task.name, - module: task.module, - path: task.path, - sourcePath: task.path, - relativePath: path.join(task.module, 'tasks', `${task.name}.md`), - content, - }); - } - - const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName); - const { artifacts: workflowArtifacts, counts: workflowCounts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir); - artifacts.push(...workflowArtifacts); - - return { - artifacts, - counts: { - agents: agentArtifacts.length, - tasks: tasks.length, - workflows: workflowCounts.commands, - workflowLaunchers: workflowCounts.launchers, - }, - }; - } - - getCodexSkillsDir(projectDir) { - if (!projectDir) { - throw new Error('projectDir is required for project-scoped skill installation'); - } - return path.join(projectDir, '.agents', 'skills'); - } - - /** - * Get the old .codex/prompts directory for cleanup purposes - */ - getOldCodexPromptDir(projectDir = null, location = 'global') { - if (location === 'project' && projectDir) { - return path.join(projectDir, '.codex', 'prompts'); - } - return path.join(os.homedir(), '.codex', 'prompts'); - } - - /** - * Write artifacts as Agent Skills (agentskills.io format). - * Each artifact becomes a directory containing SKILL.md. - * @param {string} destDir - Base skills directory - * @param {Array} artifacts - Artifacts to write - * @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task') - * @returns {number} Number of skills written - */ - async writeSkillArtifacts(destDir, artifacts, artifactType) { - let writtenCount = 0; - - for (const artifact of artifacts) { - // Filter by type if the artifact has a type field - if (artifact.type && artifact.type !== artifactType) { - continue; - } - - // Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md - const flatName = resolveSkillName(artifact); - const skillName = flatName.replace(/\.md$/, ''); - - // Create skill directory - const skillDir = path.join(destDir, skillName); - await fs.ensureDir(skillDir); - - // Transform content: rewrite frontmatter for skills format - const skillContent = this.transformToSkillFormat(artifact.content, skillName); - - // Write SKILL.md with platform-native line endings - const platformContent = skillContent.replaceAll('\n', os.EOL); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), platformContent, 'utf8'); - writtenCount++; - } - - return writtenCount; - } - - /** - * Transform artifact content from Codex prompt format to Agent Skills format. - * Removes disable-model-invocation, ensures name matches directory. - * @param {string} content - Original content with YAML frontmatter - * @param {string} skillName - Skill name (must match directory name) - * @returns {string} Transformed content - */ - transformToSkillFormat(content, skillName) { - // Normalize line endings so body matches rebuilt frontmatter (both LF) - content = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); - - // Parse frontmatter - const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); - if (!fmMatch) { - // No frontmatter -- wrap with minimal frontmatter - const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd(); - return `---\n${fm}\n---\n\n${content}`; - } - - const frontmatter = fmMatch[1]; - const body = fmMatch[2]; - - // Parse frontmatter with yaml library to handle all quoting variants - let description; - try { - const parsed = yaml.parse(frontmatter); - description = parsed?.description || `${skillName} skill`; - } catch { - description = `${skillName} skill`; - } - - // Build new frontmatter with only skills-spec fields, let yaml handle quoting - const newFrontmatter = yaml.stringify({ name: skillName, description }, { lineWidth: 0 }).trimEnd(); - return `---\n${newFrontmatter}\n---\n${body}`; - } - - /** - * Remove existing BMAD skill directories from the skills directory. - */ - async clearOldBmadSkills(destDir, options = {}) { - if (!(await fs.pathExists(destDir))) { - return; - } - - let entries; - try { - entries = await fs.readdir(destDir); - } catch (error) { - if (!options.silent) await prompts.log.warn(`Warning: Could not read directory ${destDir}: ${error.message}`); - return; - } - - if (!entries || !Array.isArray(entries)) { - return; - } - - for (const entry of entries) { - if (!entry || typeof entry !== 'string') { - continue; - } - if (!entry.startsWith('bmad')) { - continue; - } - - const entryPath = path.join(destDir, entry); - try { - await fs.remove(entryPath); - } catch (error) { - if (!options.silent) { - await prompts.log.message(` Skipping ${entry}: ${error.message}`); - } - } - } - } - - /** - * Clean old BMAD files from legacy .codex/prompts directories. - */ - async clearOldBmadFiles(destDir, options = {}) { - if (!(await fs.pathExists(destDir))) { - return; - } - - let entries; - try { - entries = await fs.readdir(destDir); - } catch (error) { - // Directory exists but can't be read - skip cleanup - if (!options.silent) await prompts.log.warn(`Warning: Could not read directory ${destDir}: ${error.message}`); - return; - } - - if (!entries || !Array.isArray(entries)) { - return; - } - - for (const entry of entries) { - // Skip non-strings or undefined entries - if (!entry || typeof entry !== 'string') { - continue; - } - if (!entry.startsWith('bmad')) { - continue; - } - - const entryPath = path.join(destDir, entry); - try { - await fs.remove(entryPath); - } catch (error) { - if (!options.silent) { - await prompts.log.message(` Skipping ${entry}: ${error.message}`); - } - } - } - } - - async readAndProcessWithProject(filePath, metadata, projectDir) { - const rawContent = await fs.readFile(filePath, 'utf8'); - const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); - return super.processContent(content, metadata, projectDir); - } - - /** - * Get instructions for project-specific installation - * @param {string} projectDir - Optional project directory - * @param {string} destDir - Optional destination directory - * @returns {string} Instructions text - */ - getProjectSpecificInstructions(projectDir = null, destDir = null) { - const lines = [ - 'Project-Specific Codex Configuration', - '', - `Skills installed to: ${destDir || '/.agents/skills'}`, - '', - 'Codex automatically discovers skills in .agents/skills/ at and above the current directory and in your home directory.', - 'No additional configuration is needed.', - ]; - - return lines.join('\n'); - } - - /** - * Cleanup Codex configuration - cleans both new .agents/skills and old .codex/prompts - */ - async cleanup(projectDir = null) { - // Clean old .codex/prompts locations - const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); - await this.clearOldBmadFiles(oldGlobalDir); - - if (projectDir) { - const oldProjectDir = this.getOldCodexPromptDir(projectDir, 'project'); - await this.clearOldBmadFiles(oldProjectDir); - - // Clean new .agents/skills location - const destDir = this.getCodexSkillsDir(projectDir); - await this.clearOldBmadSkills(destDir); - } - } - - /** - * Install a custom agent launcher for Codex as an Agent Skill - * @param {string} projectDir - Project directory - * @param {string} agentName - Agent name (e.g., "fred-commit-poet") - * @param {string} agentPath - Path to compiled agent (relative to project root) - * @param {Object} metadata - Agent metadata - * @returns {Object|null} Info about created skill - */ - async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { - const destDir = this.getCodexSkillsDir(projectDir); - - // Skill name from the dash name (without .md) - const skillName = customAgentDashName(agentName).replace(/\.md$/, ''); - const skillDir = path.join(destDir, skillName); - await fs.ensureDir(skillDir); - - const description = metadata?.description || `${agentName} agent`; - const fm = yaml.stringify({ name: skillName, description }).trimEnd(); - const skillContent = - `---\n${fm}\n---\n` + - "\nYou must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.\n" + - '\n\n' + - `1. LOAD the FULL agent file from @${agentPath}\n` + - '2. READ its entire contents - this contains the complete agent persona, menu, and instructions\n' + - '3. FOLLOW every step in the section precisely\n' + - '4. DISPLAY the welcome/greeting as instructed\n' + - '5. PRESENT the numbered menu\n' + - '6. WAIT for user input before proceeding\n' + - '\n'; - - // Write with platform-native line endings - const platformContent = skillContent.replaceAll('\n', os.EOL); - const skillPath = path.join(skillDir, 'SKILL.md'); - await fs.writeFile(skillPath, platformContent, 'utf8'); - - return { - path: path.relative(projectDir, skillPath), - command: `$${skillName}`, - }; - } -} - -module.exports = { CodexSetup }; diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index 9e286fdd3..654574a25 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -8,7 +8,7 @@ const prompts = require('../../../lib/prompts'); * Dynamically discovers and loads IDE handlers * * Loading strategy: - * 1. Custom installer files (codex.js, github-copilot.js, kilo.js, rovodev.js) - for platforms with unique installation logic + * 1. Custom installer files (github-copilot.js, kilo.js, rovodev.js) - for platforms with unique installation logic * 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns */ class IdeManager { @@ -44,7 +44,7 @@ class IdeManager { /** * Dynamically load all IDE handlers - * 1. Load custom installer files first (codex.js, github-copilot.js, kilo.js, rovodev.js) + * 1. Load custom installer files first (github-copilot.js, kilo.js, rovodev.js) * 2. Load config-driven handlers from platform-codes.yaml */ async loadHandlers() { @@ -58,10 +58,11 @@ class IdeManager { /** * Load custom installer files (unique installation logic) * These files have special installation patterns that don't fit the config-driven model + * Note: codex was migrated to config-driven (platform-codes.yaml) and no longer needs a custom installer */ async loadCustomInstallerFiles() { const ideDir = __dirname; - const customFiles = ['codex.js', 'github-copilot.js', 'kilo.js', 'rovodev.js']; + const customFiles = ['github-copilot.js', 'kilo.js', 'rovodev.js']; for (const file of customFiles) { const filePath = path.join(ideDir, file); @@ -189,14 +190,6 @@ class IdeManager { if (r.tasks > 0) parts.push(`${r.tasks} tasks`); if (r.tools > 0) parts.push(`${r.tools} tools`); detail = parts.join(', '); - } else if (handlerResult && handlerResult.counts) { - // Codex handler returns { success, counts: { agents, workflows, tasks }, written } - const c = handlerResult.counts; - const parts = []; - if (c.agents > 0) parts.push(`${c.agents} agents`); - if (c.workflows > 0) parts.push(`${c.workflows} workflows`); - if (c.tasks > 0) parts.push(`${c.tasks} tasks`); - detail = parts.join(', '); } else if (handlerResult && handlerResult.modes !== undefined) { // Kilo handler returns { success, modes, workflows, tasks, tools } const parts = []; diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 1c2ebf18d..bb7cd2254 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -59,7 +59,13 @@ platforms: preferred: false category: cli description: "OpenAI Codex integration" - # No installer config - uses custom codex.js + installer: + legacy_targets: + - .codex/prompts + target_dir: .agents/skills + template_type: default + skill_format: true + artifact_types: [agents, workflows, tasks] codebuddy: name: "CodeBuddy"