diff --git a/.gitignore b/.gitignore index 0f130a3b3..e6de83eda 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ cursor CLAUDE.local.md .serena/ .claude/settings.local.json +.agents z*/ diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js new file mode 100644 index 000000000..203a790d0 --- /dev/null +++ b/test/test-codex-transform.js @@ -0,0 +1,145 @@ +/** + * Tests for CodexSetup.transformToSkillFormat + * + * Validates that descriptions round-trip correctly through parse/stringify, + * producing valid YAML regardless of input quoting style. + * + * Usage: node test/test-codex-transform.js + */ + +const path = require('node:path'); +const yaml = require('yaml'); + +// ANSI colors +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let passed = 0; +let failed = 0; + +function assert(condition, testName, detail) { + if (condition) { + console.log(` ${colors.green}PASS${colors.reset} ${testName}`); + passed++; + } else { + console.log(` ${colors.red}FAIL${colors.reset} ${testName}`); + if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`); + failed++; + } +} + +/** + * Parse the output frontmatter and return the description value. + * Validates the output is well-formed YAML that parses back correctly. + */ +function parseOutputDescription(output) { + const match = output.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + const parsed = yaml.parse(match[1]); + return parsed?.description; +} + +// Import the class under test +const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js')); + +const setup = new CodexSetup(); + +console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`); + +// --- Simple description, no quotes --- +{ + const input = `---\ndescription: A simple description\n---\n\nBody content here.`; + const result = setup.transformToSkillFormat(input, 'my-skill'); + const desc = parseOutputDescription(result); + assert(desc === 'A simple description', 'simple description round-trips', `got description: ${JSON.stringify(desc)}`); + assert(result.includes('\nBody content here.'), 'body preserved for simple description'); +} + +// --- Description with embedded single quotes (from double-quoted YAML input) --- +{ + const input = `---\ndescription: "can't stop won't stop"\n---\n\nBody content here.`; + const result = setup.transformToSkillFormat(input, 'my-skill'); + const desc = parseOutputDescription(result); + assert(desc === "can't stop won't stop", 'description with apostrophes round-trips', `got description: ${JSON.stringify(desc)}`); + assert(result.includes('\nBody content here.'), 'body preserved for quoted description'); +} + +// --- Description with embedded single quote --- +{ + const input = `---\ndescription: "it's a test"\n---\n\nBody.`; + const result = setup.transformToSkillFormat(input, 'test-skill'); + const desc = parseOutputDescription(result); + assert(desc === "it's a test", 'description with apostrophe round-trips', `got description: ${JSON.stringify(desc)}`); +} + +// --- Single-quoted input with pre-escaped apostrophe (YAML '' escape) --- +{ + const input = `---\ndescription: 'don''t panic'\n---\n\nBody.`; + const result = setup.transformToSkillFormat(input, 'test-skill'); + const desc = parseOutputDescription(result); + assert(desc === "don't panic", 'single-quoted escaped apostrophe round-trips', `got description: ${JSON.stringify(desc)}`); +} + +// --- Verify name is set correctly --- +{ + const input = `---\ndescription: test\n---\n\nBody.`; + const result = setup.transformToSkillFormat(input, 'my-custom-skill'); + const match = result.match(/^---\n([\s\S]*?)\n---/); + const parsed = yaml.parse(match[1]); + assert(parsed.name === 'my-custom-skill', 'name field matches skillName argument', `got name: ${JSON.stringify(parsed.name)}`); +} + +// --- Extra frontmatter keys are stripped --- +{ + const input = `---\ndescription: foo\ndisable-model-invocation: true\ncustom-field: bar\n---\n\nBody.`; + const result = setup.transformToSkillFormat(input, 'strip-extra'); + const desc = parseOutputDescription(result); + assert(desc === 'foo', 'description preserved when extra keys present', `got description: ${JSON.stringify(desc)}`); + const match = result.match(/^---\n([\s\S]*?)\n---/); + const parsed = yaml.parse(match[1]); + assert(parsed.name === 'strip-extra', 'name equals skillName after stripping extras', `got name: ${JSON.stringify(parsed.name)}`); + assert(!('disable-model-invocation' in parsed), 'disable-model-invocation stripped', `keys: ${Object.keys(parsed).join(', ')}`); + assert(!('custom-field' in parsed), 'custom-field stripped', `keys: ${Object.keys(parsed).join(', ')}`); + const keys = Object.keys(parsed).sort(); + assert( + keys.length === 2 && keys[0] === 'description' && keys[1] === 'name', + 'only name and description remain', + `keys: ${keys.join(', ')}`, + ); +} + +// --- No frontmatter wraps content --- +{ + const input = 'Just some content without frontmatter.'; + const result = setup.transformToSkillFormat(input, 'bare-skill'); + const desc = parseOutputDescription(result); + assert(desc === 'bare-skill', 'no-frontmatter fallback uses skillName as description', `got description: ${JSON.stringify(desc)}`); + assert(result.includes('Just some content without frontmatter.'), 'body preserved when no frontmatter'); +} + +// --- No frontmatter with single-quote in skillName --- +{ + const input = 'Body content for the skill.'; + const result = setup.transformToSkillFormat(input, "it's-a-task"); + const desc = parseOutputDescription(result); + assert(desc === "it's-a-task", 'no-frontmatter skillName with single quote round-trips', `got description: ${JSON.stringify(desc)}`); + assert(result.includes('Body content for the skill.'), 'body preserved for single-quote skillName'); +} + +// --- CRLF frontmatter is parsed correctly (Windows line endings) --- +{ + const input = '---\r\ndescription: windows line endings\r\n---\r\n\r\nBody.'; + const result = setup.transformToSkillFormat(input, 'crlf-skill'); + const desc = parseOutputDescription(result); + assert(desc === 'windows line endings', 'CRLF frontmatter parses correctly', `got description: ${JSON.stringify(desc)}`); + assert(result.includes('Body.'), 'body preserved for CRLF input'); +} + +// --- Summary --- +console.log(`\n${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-codex-write-skills.js b/test/test-codex-write-skills.js new file mode 100644 index 000000000..ac54dc96f --- /dev/null +++ b/test/test-codex-write-skills.js @@ -0,0 +1,216 @@ +/** + * Tests for CodexSetup.writeSkillArtifacts + * + * Validates directory creation, SKILL.md file writing, type filtering, + * and integration with transformToSkillFormat. + * + * Usage: node test/test-codex-write-skills.js + */ + +const path = require('node:path'); +const fs = require('fs-extra'); +const os = require('node:os'); +const yaml = require('yaml'); + +// ANSI colors +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let passed = 0; +let failed = 0; + +function assert(condition, testName, detail) { + if (condition) { + console.log(` ${colors.green}PASS${colors.reset} ${testName}`); + passed++; + } else { + console.log(` ${colors.red}FAIL${colors.reset} ${testName}`); + if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`); + failed++; + } +} + +// Import the class under test +const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js')); + +const setup = new CodexSetup(); + +// Create a temp directory for each test run +let tmpDir; + +async function createTmpDir() { + tmpDir = path.join(os.tmpdir(), `bmad-test-skills-${Date.now()}`); + await fs.ensureDir(tmpDir); + return tmpDir; +} + +async function cleanTmpDir() { + if (tmpDir) { + await fs.remove(tmpDir); + } +} + +async function runTests() { + console.log(`\n${colors.cyan}CodexSetup.writeSkillArtifacts tests${colors.reset}\n`); + + // --- Writes a single artifact as a skill directory with SKILL.md --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + type: 'task', + relativePath: 'bmm/tasks/create-story.md', + content: '---\ndescription: Create a user story\n---\n\nStory creation instructions.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task'); + assert(count === 1, 'single artifact returns count 1'); + + const skillDir = path.join(destDir, 'bmad-bmm-create-story'); + assert(await fs.pathExists(skillDir), 'skill directory created'); + + const skillFile = path.join(skillDir, 'SKILL.md'); + assert(await fs.pathExists(skillFile), 'SKILL.md file created'); + + const content = await fs.readFile(skillFile, 'utf8'); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + assert(fmMatch !== null, 'SKILL.md has frontmatter'); + + const parsed = yaml.parse(fmMatch[1]); + assert(parsed.name === 'bmad-bmm-create-story', 'name matches skill directory name', `got: ${parsed.name}`); + assert(parsed.description === 'Create a user story', 'description preserved', `got: ${parsed.description}`); + assert(content.includes('Story creation instructions.'), 'body content preserved'); + await cleanTmpDir(); + } + + // --- Filters artifacts by type --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + type: 'task', + relativePath: 'bmm/tasks/create-story.md', + content: '---\ndescription: A task\n---\n\nTask body.', + }, + { + type: 'workflow-command', + relativePath: 'bmm/workflows/plan-project.md', + content: '---\ndescription: A workflow\n---\n\nWorkflow body.', + }, + { + type: 'agent-launcher', + relativePath: 'bmm/agents/pm.md', + content: '---\ndescription: An agent\n---\n\nAgent body.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task'); + assert(count === 1, 'only matching type is written when filtering for task'); + + const entries = await fs.readdir(destDir); + assert(entries.length === 1, 'only one skill directory created', `got ${entries.length}: ${entries.join(', ')}`); + assert(entries[0] === 'bmad-bmm-create-story', 'correct artifact was written', `got: ${entries[0]}`); + await cleanTmpDir(); + } + + // --- Writes multiple artifacts of the same type --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + type: 'workflow-command', + relativePath: 'bmm/workflows/plan-project.md', + content: '---\ndescription: Plan\n---\n\nPlan body.', + }, + { + type: 'workflow-command', + relativePath: 'core/workflows/review.md', + content: '---\ndescription: Review\n---\n\nReview body.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'workflow-command'); + assert(count === 2, 'two artifacts written'); + + const entries = new Set((await fs.readdir(destDir)).sort()); + assert(entries.has('bmad-bmm-plan-project'), 'first skill directory exists'); + assert(entries.has('bmad-review'), 'second skill directory exists (core module)'); + await cleanTmpDir(); + } + + // --- Returns 0 when no artifacts match type --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + type: 'agent-launcher', + relativePath: 'bmm/agents/pm.md', + content: '---\ndescription: An agent\n---\n\nBody.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task'); + assert(count === 0, 'returns 0 when no types match'); + + const entries = await fs.readdir(destDir); + assert(entries.length === 0, 'no directories created when no types match'); + await cleanTmpDir(); + } + + // --- Handles empty artifacts array --- + { + const destDir = await createTmpDir(); + const count = await setup.writeSkillArtifacts(destDir, [], 'task'); + assert(count === 0, 'returns 0 for empty artifacts array'); + await cleanTmpDir(); + } + + // --- Artifacts without type field are always written --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + relativePath: 'bmm/tasks/no-type.md', + content: '---\ndescription: No type field\n---\n\nBody.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task'); + assert(count === 1, 'artifact without type field is written (no filtering)'); + await cleanTmpDir(); + } + + // --- Content without frontmatter gets minimal frontmatter added --- + { + const destDir = await createTmpDir(); + const artifacts = [ + { + type: 'task', + relativePath: 'bmm/tasks/bare.md', + content: 'Just plain content, no frontmatter.', + }, + ]; + const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task'); + assert(count === 1, 'bare content artifact written'); + + const skillFile = path.join(destDir, 'bmad-bmm-bare', 'SKILL.md'); + const content = await fs.readFile(skillFile, 'utf8'); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + assert(fmMatch !== null, 'frontmatter added to bare content'); + + const parsed = yaml.parse(fmMatch[1]); + assert(parsed.name === 'bmad-bmm-bare', 'name set for bare content', `got: ${parsed.name}`); + assert(content.includes('Just plain content, no frontmatter.'), 'original content preserved'); + await cleanTmpDir(); + } + + // --- Summary --- + console.log(`\n${passed} passed, ${failed} failed\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch((error) => { + console.error('Test runner error:', error); + process.exit(1); +}); diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 143402282..a261b0f6e 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -7,10 +7,13 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); const { getTasksFromBmad } = require('./shared/bmad-artifacts'); const { toDashPath, customAgentDashName } = require('./shared/path-utils'); +const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); /** * Codex setup handler (CLI mode) + * Writes BMAD artifacts as Agent Skills (agentskills.io format) + * into .agents/skills/ directories. */ class CodexSetup extends BaseIdeSetup { constructor() { @@ -23,35 +26,35 @@ class CodexSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { - // Non-interactive mode: use default (global) + // Non-interactive mode: use default (project) if (options.skipPrompts) { - return { installLocation: 'global' }; + return { installLocation: 'project' }; } let confirmed = false; - let installLocation = 'global'; + let installLocation = 'project'; while (!confirmed) { installLocation = await prompts.select({ - message: 'Where would you like to install Codex CLI prompts?', + message: 'Where would you like to install Codex CLI skills?', choices: [ { - name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', - value: 'global', - }, - { - name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, + name: 'Project-specific - Recommended (/.agents/skills)', value: 'project', }, + { + name: 'Global - ($HOME/.agents/skills)', + value: 'global', + }, ], - default: 'global', + default: 'project', }); // Show brief confirmation hint (detailed instructions available via verbose) if (installLocation === 'project') { - await prompts.log.info('Prompts installed to: /.codex/prompts (requires CODEX_HOME)'); + await prompts.log.info('Skills installed to: /.agents/skills'); } else { - await prompts.log.info('Prompts installed to: ~/.codex/prompts'); + await prompts.log.info('Skills installed to: $HOME/.agents/skills'); } // Confirm the choice @@ -80,20 +83,21 @@ class CodexSetup extends BaseIdeSetup { // Always use CLI mode const mode = 'cli'; - // Get installation location from pre-collected config or default to global - const installLocation = options.preCollectedConfig?.installLocation || 'global'; + // Get installation location from pre-collected config or default to project + const installLocation = options.preCollectedConfig?.installLocation || 'project'; const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); - const destDir = this.getCodexPromptDir(projectDir, installLocation); + const destDir = this.getCodexSkillsDir(projectDir, installLocation); await fs.ensureDir(destDir); - await this.clearOldBmadFiles(destDir, options); + await this.clearOldBmadSkills(destDir, options); - // Collect artifacts and write using underscore format + // Collect and write agent skills const agentGen = new AgentCommandGenerator(this.bmadFolderName); const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); - const agentCount = await agentGen.writeDashArtifacts(destDir, agentArtifacts); + 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) { @@ -117,19 +121,23 @@ class CodexSetup extends BaseIdeSetup { }); } + 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 workflowGenerator.writeDashArtifacts(destDir, workflowArtifacts); - - // Also write tasks using underscore format - const ttGen = new TaskToolCommandGenerator(this.bmadFolderName); - const tasksWritten = await ttGen.writeDashArtifacts(destDir, taskArtifacts); + 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} files → ${destDir}`, + `${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} skills → ${destDir}`, ); } @@ -145,13 +153,82 @@ class CodexSetup extends BaseIdeSetup { } /** - * Detect Codex installation by checking for BMAD prompt exports + * 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 dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md + const flatName = toDashPath(artifact.relativePath); + 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 + await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); + 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) { + // 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 }).trimEnd(); + return `---\n${newFrontmatter}\n---\n${body}`; + } + + /** + * Detect Codex installation by checking for BMAD skills */ async detect(projectDir) { // Check both global and project-specific locations - const globalDir = this.getCodexPromptDir(null, 'global'); + const globalDir = this.getCodexSkillsDir(null, 'global'); const projectDir_local = projectDir || process.cwd(); - const projectSpecificDir = this.getCodexPromptDir(projectDir_local, 'project'); + const projectSpecificDir = this.getCodexSkillsDir(projectDir_local, 'project'); // Check global location if (await fs.pathExists(globalDir)) { @@ -240,27 +317,20 @@ class CodexSetup extends BaseIdeSetup { }; } - getCodexPromptDir(projectDir = null, location = 'global') { + getCodexSkillsDir(projectDir = null, location = 'project') { if (location === 'project' && projectDir) { - return path.join(projectDir, '.codex', 'prompts'); + return path.join(projectDir, '.agents', 'skills'); } - return path.join(os.homedir(), '.codex', 'prompts'); + if (location === 'project' && !projectDir) { + throw new Error('projectDir is required for project-scoped skill installation'); + } + return path.join(os.homedir(), '.agents', 'skills'); } - async flattenAndWriteArtifacts(artifacts, destDir) { - let written = 0; - - for (const artifact of artifacts) { - const flattenedName = this.flattenFilename(artifact.relativePath); - const targetPath = path.join(destDir, flattenedName); - await fs.writeFile(targetPath, artifact.content); - written++; - } - - return written; - } - - async clearOldBmadFiles(destDir, options = {}) { + /** + * Remove existing BMAD skill directories from the skills directory. + */ + async clearOldBmadSkills(destDir, options = {}) { if (!(await fs.pathExists(destDir))) { return; } @@ -299,7 +369,8 @@ class CodexSetup extends BaseIdeSetup { } async readAndProcessWithProject(filePath, metadata, projectDir) { - const content = await fs.readFile(filePath, 'utf8'); + const rawContent = await fs.readFile(filePath, 'utf8'); + const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); return super.processContent(content, metadata, projectDir); } @@ -311,14 +382,14 @@ class CodexSetup extends BaseIdeSetup { const lines = [ 'IMPORTANT: Codex Configuration', '', - '/prompts installed globally to your HOME DIRECTORY.', + 'Skills installed globally to your HOME DIRECTORY ($HOME/.agents/skills).', '', - 'These prompts reference a specific _bmad path.', + 'These skills reference a specific _bmad path.', "To use with other projects, you'd need to copy the _bmad dir.", '', - 'You can now use /commands in Codex CLI', - ' Example: /bmad_bmm_pm', - ' Type / to see all available commands', + 'Skills are available in Codex CLI automatically.', + ' Use /skills to see available skills', + ' Skills can also be invoked implicitly based on task description', ]; return lines.join('\n'); } @@ -330,40 +401,15 @@ class CodexSetup extends BaseIdeSetup { * @returns {string} Instructions text */ getProjectSpecificInstructions(projectDir = null, destDir = null) { - const isWindows = os.platform() === 'win32'; - - const commonLines = [ + const lines = [ 'Project-Specific Codex Configuration', '', - `Prompts will be installed to: ${destDir || '/.codex/prompts'}`, - '', - 'REQUIRED: You must set CODEX_HOME to use these prompts', + `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.', ]; - const windowsLines = [ - 'Create a codex.cmd file in your project root:', - '', - ' @echo off', - ' set CODEX_HOME=%~dp0.codex', - ' codex %*', - '', - String.raw`Then run: .\codex instead of codex`, - '(The %~dp0 gets the directory of the .cmd file)', - ]; - - const unixLines = [ - 'Add this alias to your ~/.bashrc or ~/.zshrc:', - '', - ' alias codex=\'CODEX_HOME="$PWD/.codex" codex\'', - '', - 'After adding, run: source ~/.bashrc (or source ~/.zshrc)', - '(The $PWD uses your current working directory)', - ]; - const closingLines = ['', 'This tells Codex CLI to use prompts from this project instead of ~/.codex']; - - const lines = [...commonLines, ...(isWindows ? windowsLines : unixLines), ...closingLines]; - return lines.join('\n'); } @@ -372,31 +418,34 @@ class CodexSetup extends BaseIdeSetup { */ async cleanup(projectDir = null) { // Clean both global and project-specific locations - const globalDir = this.getCodexPromptDir(null, 'global'); - await this.clearOldBmadFiles(globalDir); + const globalDir = this.getCodexSkillsDir(null, 'global'); + await this.clearOldBmadSkills(globalDir); if (projectDir) { - const projectSpecificDir = this.getCodexPromptDir(projectDir, 'project'); - await this.clearOldBmadFiles(projectSpecificDir); + const projectSpecificDir = this.getCodexSkillsDir(projectDir, 'project'); + await this.clearOldBmadSkills(projectSpecificDir); } } /** - * Install a custom agent launcher for Codex - * @param {string} projectDir - Project directory (not used, Codex installs to home) + * 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 command + * @returns {Object|null} Info about created skill */ async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { - const destDir = this.getCodexPromptDir(projectDir, 'project'); - await fs.ensureDir(destDir); + const destDir = this.getCodexSkillsDir(projectDir, 'project'); - const launcherContent = `--- -name: '${agentName}' -description: '${agentName} agent' -disable-model-invocation: true + // 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 fm = yaml.stringify({ name: skillName, description: `${agentName} agent` }).trimEnd(); + const skillContent = `--- +${fm} --- You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. @@ -411,14 +460,12 @@ You must fully embody this agent's persona and follow all activation instruction `; - // Use underscore format: bmad_custom_fred-commit-poet.md - const fileName = customAgentDashName(agentName); - const launcherPath = path.join(destDir, fileName); - await fs.writeFile(launcherPath, launcherContent, 'utf8'); + const skillPath = path.join(skillDir, 'SKILL.md'); + await fs.writeFile(skillPath, skillContent, 'utf8'); return { - path: path.relative(projectDir, launcherPath), - command: `/${fileName.replace('.md', '')}`, + path: path.relative(projectDir, skillPath), + command: `$${skillName}`, }; } }