From 18a4a489c8b8ae99f64a0514b3ee9ed172b90b1a Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 15:06:12 -0500 Subject: [PATCH 01/16] feat(codex): convert installer from deprecated prompts to agentskills.io skills format Codex CLI has deprecated custom prompts in favor of skills following the agentskills.io specification. Updates the Codex installer to write {name}/SKILL.md directories into .agents/skills/ instead of flat files into .codex/prompts/. Removes CODEX_HOME requirement for project-scoped installs since Codex auto-discovers .agents/skills/ directories. Co-Authored-By: Claude Opus 4.6 --- tools/cli/installers/lib/ide/codex.js | 211 ++++++++++-------- .../installers/lib/ide/platform-codes.yaml | 2 +- 2 files changed, 125 insertions(+), 88 deletions(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 143402282..c7f479067 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -11,6 +11,8 @@ 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() { @@ -33,14 +35,14 @@ class CodexSetup extends BaseIdeSetup { 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)', + name: 'Global - Simple for single project ' + '(~/.agents/skills, but references THIS project only)', value: 'global', }, { - name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, + name: 'Project-specific - Recommended for real work (/.agents/skills)', value: 'project', }, ], @@ -49,9 +51,9 @@ class CodexSetup extends BaseIdeSetup { // 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: ~/.agents/skills'); } // Confirm the choice @@ -85,15 +87,16 @@ class CodexSetup extends BaseIdeSetup { 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 +120,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 +152,75 @@ 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(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!fmMatch) { + // No frontmatter -- wrap with minimal frontmatter + return `---\nname: ${skillName}\ndescription: '${skillName}'\n---\n\n${content}`; + } + + const frontmatter = fmMatch[1]; + const body = fmMatch[2]; + + // Extract description from existing frontmatter + const descMatch = frontmatter.match(/^description:\s*['"]?(.*?)['"]?\s*$/m); + const description = descMatch ? descMatch[1] : `${skillName} skill`; + + // Build new frontmatter with only skills-spec fields + return `---\nname: ${skillName}\ndescription: '${description}'\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 +309,18 @@ class CodexSetup extends BaseIdeSetup { }; } - getCodexPromptDir(projectDir = null, location = 'global') { + getCodexSkillsDir(projectDir = null, location = 'global') { if (location === 'project' && projectDir) { - return path.join(projectDir, '.codex', 'prompts'); + return path.join(projectDir, '.agents', 'skills'); } - return path.join(os.homedir(), '.codex', 'prompts'); + 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. + * Handles both old flat files and new skill directories. + */ + async clearOldBmadSkills(destDir, options = {}) { if (!(await fs.pathExists(destDir))) { return; } @@ -289,6 +349,7 @@ class CodexSetup extends BaseIdeSetup { const entryPath = path.join(destDir, entry); try { + // fs.remove handles both files and directories await fs.remove(entryPath); } catch (error) { if (!options.silent) { @@ -311,14 +372,14 @@ class CodexSetup extends BaseIdeSetup { const lines = [ 'IMPORTANT: Codex Configuration', '', - '/prompts installed globally to your HOME DIRECTORY.', + 'Skills installed globally to your HOME DIRECTORY (~/.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 +391,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.', + '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 +408,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}' + // 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 skillContent = `--- +name: ${skillName} description: '${agentName} agent' -disable-model-invocation: true --- 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 +450,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}`, }; } } diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 7c2dde2cb..36a2b6a66 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -54,7 +54,7 @@ platforms: name: "Codex" preferred: false category: cli - description: "OpenAI Codex integration" + description: "OpenAI Codex skills (agentskills.io format)" # No installer config - uses custom codex.js crush: From 62e0b0ba5229644c8eb57762f5929e602d019a91 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 18:31:14 -0500 Subject: [PATCH 02/16] switch default to per-project skills to align with other tools --- tools/cli/installers/lib/ide/codex.js | 22 +++++++++---------- .../installers/lib/ide/platform-codes.yaml | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index c7f479067..576c66a6c 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -25,28 +25,28 @@ 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 skills?', choices: [ { - name: 'Global - Simple for single project ' + '(~/.agents/skills, but references THIS project only)', - value: 'global', - }, - { - name: 'Project-specific - Recommended for real work (/.agents/skills)', + name: 'Project-specific - Recommended (/.agents/skills)', value: 'project', }, + { + name: 'Global - (~/.agents/skills)', + value: 'global', + }, ], - default: 'global', + default: 'project', }); // Show brief confirmation hint (detailed instructions available via verbose) @@ -82,8 +82,8 @@ 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); diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 36a2b6a66..7c2dde2cb 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -54,7 +54,7 @@ platforms: name: "Codex" preferred: false category: cli - description: "OpenAI Codex skills (agentskills.io format)" + description: "OpenAI Codex integration" # No installer config - uses custom codex.js crush: From 79855b2c4c7ab4273795efbb96b16c73c41fce3f Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 18:51:53 -0500 Subject: [PATCH 03/16] default to per-project instead of global --- tools/cli/installers/lib/ide/codex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 576c66a6c..01f87c9b0 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -309,7 +309,7 @@ class CodexSetup extends BaseIdeSetup { }; } - getCodexSkillsDir(projectDir = null, location = 'global') { + getCodexSkillsDir(projectDir = null, location = 'project') { if (location === 'project' && projectDir) { return path.join(projectDir, '.agents', 'skills'); } From 44f07bf34180e54bc0239c8c300972f5a96a85c8 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 19:05:09 -0500 Subject: [PATCH 04/16] Clarify where Codex searches for skills --- tools/cli/installers/lib/ide/codex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 01f87c9b0..78198ad86 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -396,7 +396,7 @@ class CodexSetup extends BaseIdeSetup { '', `Skills installed to: ${destDir || '/.agents/skills'}`, '', - 'Codex automatically discovers skills in .agents/skills/ at and above the current directory.', + 'Codex automatically discovers skills in .agents/skills/ at and above the current directory and in your home directory.', 'No additional configuration is needed.', ]; From fcf781fa39865b5dc4778e3494b782ad94eb5c59 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 19:07:49 -0500 Subject: [PATCH 05/16] clean up comments --- tools/cli/installers/lib/ide/codex.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 78198ad86..a81af0e0d 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -318,7 +318,6 @@ class CodexSetup extends BaseIdeSetup { /** * Remove existing BMAD skill directories from the skills directory. - * Handles both old flat files and new skill directories. */ async clearOldBmadSkills(destDir, options = {}) { if (!(await fs.pathExists(destDir))) { @@ -349,7 +348,6 @@ class CodexSetup extends BaseIdeSetup { const entryPath = path.join(destDir, entry); try { - // fs.remove handles both files and directories await fs.remove(entryPath); } catch (error) { if (!options.silent) { From 746bd50d19c05310852824b9b635645ba5b0a162 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 19:19:02 -0500 Subject: [PATCH 06/16] Add .agents directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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*/ From 26dd80e021117abce32124403419e31c009e267d Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 19:31:04 -0500 Subject: [PATCH 07/16] Fix description regex to handle quotes in transformToSkillFormat The regex that extracts descriptions from YAML frontmatter did not properly parse quoted values. Descriptions containing apostrophes (e.g. "can't") produced invalid YAML when re-wrapped in single quotes. Replace the naive regex with proper handling of single-quoted, double-quoted, and unquoted YAML values, and escape inner single quotes using YAML '' syntax before re-wrapping. Add tests for transformToSkillFormat covering plain, double-quoted, and single-quoted descriptions with embedded apostrophes. --- test/test-codex-transform.js | 90 +++++++++++++++++++++++++++ tools/cli/installers/lib/ide/codex.js | 24 +++++-- 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 test/test-codex-transform.js diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js new file mode 100644 index 000000000..ff8c5c936 --- /dev/null +++ b/test/test-codex-transform.js @@ -0,0 +1,90 @@ +/** + * Tests for CodexSetup.transformToSkillFormat + * + * Demonstrates that the description regex mangles descriptions containing quotes. + * + * Usage: node test/test-codex-transform.js + */ + +const path = require('node:path'); + +// 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(); + +console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`); + +// --- Passing case: simple description, no quotes --- +{ + const input = `---\ndescription: A simple description\n---\n\nBody content here.`; + const result = setup.transformToSkillFormat(input, 'my-skill'); + const expected = `---\nname: my-skill\ndescription: 'A simple description'\n---\n\nBody content here.`; + assert(result === expected, 'simple description without quotes', `got: ${JSON.stringify(result)}`); +} + +// --- 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'); + + // Output should have properly escaped YAML single-quoted scalar: '' for each ' + const expected = `---\nname: my-skill\ndescription: 'can''t stop won''t stop'\n---\n\nBody content here.`; + assert(result === expected, 'description with embedded single quotes produces valid escaped YAML', `got: ${JSON.stringify(result)}`); +} + +// --- Description with embedded single quote produces valid YAML --- +{ + const input = `---\ndescription: "it's a test"\n---\n\nBody.`; + const result = setup.transformToSkillFormat(input, 'test-skill'); + + // Verify the inner quote is escaped: description: 'it''s a test' + const descLine = result.split('\n').find((l) => l.startsWith('description:')); + const value = descLine.replace('description: ', ''); + + // Check that single quotes inside the value are properly escaped + // In YAML single-quoted scalars, a literal ' must be written as '' + const innerContent = value.slice(1, -1); // strip outer quotes + const hasUnescapedQuote = innerContent.match(/[^']'[^']/); + assert( + !hasUnescapedQuote, + 'description with apostrophe produces valid YAML (no unescaped inner quotes)', + `description line: ${descLine}`, + ); +} + +// --- 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'); + // Input has don''t (YAML-escaped). Should round-trip to don''t in output. + const expected = `---\nname: test-skill\ndescription: 'don''t panic'\n---\n\nBody.`; + assert(result === expected, 'single-quoted description with escaped apostrophe round-trips correctly', `got: ${JSON.stringify(result)}`); +} + +// --- Summary --- +console.log(`\n${passed} passed, ${failed} failed\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index a81af0e0d..01988758e 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -205,12 +205,28 @@ class CodexSetup extends BaseIdeSetup { const frontmatter = fmMatch[1]; const body = fmMatch[2]; - // Extract description from existing frontmatter - const descMatch = frontmatter.match(/^description:\s*['"]?(.*?)['"]?\s*$/m); - const description = descMatch ? descMatch[1] : `${skillName} skill`; + // Extract description from existing frontmatter, handling quoted and unquoted values + const descMatch = frontmatter.match(/^description:\s*(?:'((?:[^']|'')*)'|"((?:[^"\\]|\\.)*)"|(.*))\s*$/m); + let description; + if (descMatch) { + if (descMatch[1] != null) { + // Single-quoted YAML: unescape '' to ' + description = descMatch[1].replaceAll("''", "'"); + } else if (descMatch[2] == null) { + description = descMatch[3]; + } else { + // Double-quoted YAML: unescape \" to " + description = descMatch[2].replaceAll(String.raw`\"`, '"'); + } + } else { + description = `${skillName} skill`; + } + + // Escape single quotes for YAML single-quoted scalar (a literal ' becomes '') + const safeDescription = description.replaceAll("'", "''"); // Build new frontmatter with only skills-spec fields - return `---\nname: ${skillName}\ndescription: '${description}'\n---\n${body}`; + return `---\nname: ${skillName}\ndescription: '${safeDescription}'\n---\n${body}`; } /** From 43672d33c11c1ecd29d409794c824beb27baab61 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 19:39:24 -0500 Subject: [PATCH 08/16] simplify test --- test/test-codex-transform.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js index ff8c5c936..897ff2196 100644 --- a/test/test-codex-transform.js +++ b/test/test-codex-transform.js @@ -60,20 +60,8 @@ console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.res { const input = `---\ndescription: "it's a test"\n---\n\nBody.`; const result = setup.transformToSkillFormat(input, 'test-skill'); - - // Verify the inner quote is escaped: description: 'it''s a test' - const descLine = result.split('\n').find((l) => l.startsWith('description:')); - const value = descLine.replace('description: ', ''); - - // Check that single quotes inside the value are properly escaped - // In YAML single-quoted scalars, a literal ' must be written as '' - const innerContent = value.slice(1, -1); // strip outer quotes - const hasUnescapedQuote = innerContent.match(/[^']'[^']/); - assert( - !hasUnescapedQuote, - 'description with apostrophe produces valid YAML (no unescaped inner quotes)', - `description line: ${descLine}`, - ); + const expected = `---\nname: test-skill\ndescription: 'it''s a test'\n---\n\nBody.`; + assert(result === expected, 'description with apostrophe produces valid YAML', `got: ${JSON.stringify(result)}`); } // --- Single-quoted input with pre-escaped apostrophe (YAML '' escape) --- From 7f815188967de12fad159e475f16af4691544594 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 07:13:30 -0500 Subject: [PATCH 09/16] Replace description regex with yaml.parse/stringify in codex.js Use the yaml library (already a project dependency) instead of a hand-rolled regex to parse and re-serialize frontmatter descriptions, matching the pattern used in manifest-generator.js. Update tests to validate round-trip correctness rather than exact quoting style. Co-Authored-By: Claude Opus 4.6 --- test/test-codex-transform.js | 58 ++++++++++++++++++++------- tools/cli/installers/lib/ide/codex.js | 27 +++++-------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js index 897ff2196..63db245bf 100644 --- a/test/test-codex-transform.js +++ b/test/test-codex-transform.js @@ -1,12 +1,14 @@ /** * Tests for CodexSetup.transformToSkillFormat * - * Demonstrates that the description regex mangles descriptions containing quotes. + * 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 = { @@ -31,6 +33,17 @@ function assert(condition, testName, detail) { } } +/** + * 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(/^---\n([\s\S]*?)\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')); @@ -38,39 +51,56 @@ const setup = new CodexSetup(); console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`); -// --- Passing case: simple description, no quotes --- +// --- Simple description, no quotes --- { const input = `---\ndescription: A simple description\n---\n\nBody content here.`; const result = setup.transformToSkillFormat(input, 'my-skill'); - const expected = `---\nname: my-skill\ndescription: 'A simple description'\n---\n\nBody content here.`; - assert(result === expected, 'simple description without quotes', `got: ${JSON.stringify(result)}`); + 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'); - - // Output should have properly escaped YAML single-quoted scalar: '' for each ' - const expected = `---\nname: my-skill\ndescription: 'can''t stop won''t stop'\n---\n\nBody content here.`; - assert(result === expected, 'description with embedded single quotes produces valid escaped YAML', `got: ${JSON.stringify(result)}`); + 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 produces valid YAML --- +// --- Description with embedded single quote --- { const input = `---\ndescription: "it's a test"\n---\n\nBody.`; const result = setup.transformToSkillFormat(input, 'test-skill'); - const expected = `---\nname: test-skill\ndescription: 'it''s a test'\n---\n\nBody.`; - assert(result === expected, 'description with apostrophe produces valid YAML', `got: ${JSON.stringify(result)}`); + 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'); - // Input has don''t (YAML-escaped). Should round-trip to don''t in output. - const expected = `---\nname: test-skill\ndescription: 'don''t panic'\n---\n\nBody.`; - assert(result === expected, 'single-quoted description with escaped apostrophe round-trips correctly', `got: ${JSON.stringify(result)}`); + 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)}`); +} + +// --- 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'); } // --- Summary --- diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 01988758e..07be97741 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -7,6 +7,7 @@ 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'); /** @@ -205,28 +206,18 @@ class CodexSetup extends BaseIdeSetup { const frontmatter = fmMatch[1]; const body = fmMatch[2]; - // Extract description from existing frontmatter, handling quoted and unquoted values - const descMatch = frontmatter.match(/^description:\s*(?:'((?:[^']|'')*)'|"((?:[^"\\]|\\.)*)"|(.*))\s*$/m); + // Parse frontmatter with yaml library to handle all quoting variants let description; - if (descMatch) { - if (descMatch[1] != null) { - // Single-quoted YAML: unescape '' to ' - description = descMatch[1].replaceAll("''", "'"); - } else if (descMatch[2] == null) { - description = descMatch[3]; - } else { - // Double-quoted YAML: unescape \" to " - description = descMatch[2].replaceAll(String.raw`\"`, '"'); - } - } else { + try { + const parsed = yaml.parse(frontmatter); + description = parsed?.description || `${skillName} skill`; + } catch { description = `${skillName} skill`; } - // Escape single quotes for YAML single-quoted scalar (a literal ' becomes '') - const safeDescription = description.replaceAll("'", "''"); - - // Build new frontmatter with only skills-spec fields - return `---\nname: ${skillName}\ndescription: '${safeDescription}'\n---\n${body}`; + // 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}`; } /** From d0ef58b4212e685a32f0e7e2fed535d90f263418 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 09:06:16 -0500 Subject: [PATCH 10/16] Handle Windows line-endings --- test/test-codex-transform.js | 11 ++++++++++- tools/cli/installers/lib/ide/codex.js | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js index 63db245bf..b372bf084 100644 --- a/test/test-codex-transform.js +++ b/test/test-codex-transform.js @@ -38,7 +38,7 @@ function assert(condition, testName, detail) { * Validates the output is well-formed YAML that parses back correctly. */ function parseOutputDescription(output) { - const match = output.match(/^---\n([\s\S]*?)\n---/); + const match = output.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return null; const parsed = yaml.parse(match[1]); return parsed?.description; @@ -103,6 +103,15 @@ console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.res assert(result.includes('Just some content without frontmatter.'), 'body preserved when no frontmatter'); } +// --- 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/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 07be97741..02447c398 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -197,7 +197,7 @@ class CodexSetup extends BaseIdeSetup { */ transformToSkillFormat(content, skillName) { // Parse frontmatter - const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!fmMatch) { // No frontmatter -- wrap with minimal frontmatter return `---\nname: ${skillName}\ndescription: '${skillName}'\n---\n\n${content}`; From dfd961944c072a13b65054ff7ac8b459fad7fc7d Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 09:17:53 -0500 Subject: [PATCH 11/16] add more tests --- test/test-codex-write-skills.js | 216 ++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 test/test-codex-write-skills.js 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); +}); From 6db629278a6be42cab9922b224bbf0b843e05fdf Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 09:49:16 -0500 Subject: [PATCH 12/16] Use /Users/wsmoak instead of ~ --- tools/cli/installers/lib/ide/codex.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 02447c398..38097e6c9 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -43,7 +43,7 @@ class CodexSetup extends BaseIdeSetup { value: 'project', }, { - name: 'Global - (~/.agents/skills)', + name: 'Global - ($HOME/.agents/skills)', value: 'global', }, ], @@ -54,7 +54,7 @@ class CodexSetup extends BaseIdeSetup { if (installLocation === 'project') { await prompts.log.info('Skills installed to: /.agents/skills'); } else { - await prompts.log.info('Skills installed to: ~/.agents/skills'); + await prompts.log.info('Skills installed to: $HOME/.agents/skills'); } // Confirm the choice @@ -377,7 +377,7 @@ class CodexSetup extends BaseIdeSetup { const lines = [ 'IMPORTANT: Codex Configuration', '', - 'Skills installed globally to your HOME DIRECTORY (~/.agents/skills).', + 'Skills installed globally to your HOME DIRECTORY ($HOME/.agents/skills).', '', 'These skills reference a specific _bmad path.', "To use with other projects, you'd need to copy the _bmad dir.", From b318d9242ebc81f66f9d9723dc9f5bf91f8312bf Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 10:03:54 -0500 Subject: [PATCH 13/16] fix(codex): use yaml.stringify for skill frontmatter to escape special characters Prevents invalid YAML when agentName or skillName contains quotes or other special characters. Aligns the fallback path in transformToSkillFormat and installCustomAgentLauncher with the existing yaml.stringify usage on the main code path. --- tools/cli/installers/lib/ide/codex.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 38097e6c9..e46b179fb 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -200,7 +200,8 @@ class CodexSetup extends BaseIdeSetup { const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!fmMatch) { // No frontmatter -- wrap with minimal frontmatter - return `---\nname: ${skillName}\ndescription: '${skillName}'\n---\n\n${content}`; + const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd(); + return `---\n${fm}\n---\n\n${content}`; } const frontmatter = fmMatch[1]; @@ -438,9 +439,9 @@ class CodexSetup extends BaseIdeSetup { const skillDir = path.join(destDir, skillName); await fs.ensureDir(skillDir); + const fm = yaml.stringify({ name: skillName, description: `${agentName} agent` }).trimEnd(); const skillContent = `--- -name: ${skillName} -description: '${agentName} agent' +${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. From 64e5a9c696585cf8ddc8fd510685043a71066535 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 10:12:49 -0500 Subject: [PATCH 14/16] guard against missing project directory --- tools/cli/installers/lib/ide/codex.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index e46b179fb..f7018f702 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -321,6 +321,9 @@ class CodexSetup extends BaseIdeSetup { if (location === 'project' && projectDir) { return path.join(projectDir, '.agents', 'skills'); } + if (location === 'project' && !projectDir) { + throw new Error('projectDir is required for project-scoped skill installation'); + } return path.join(os.homedir(), '.agents', 'skills'); } From decf15b5dad690d1869e5096859207d9679deb92 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 11:27:49 -0500 Subject: [PATCH 15/16] Add more tests for Coderabbit --- test/test-codex-transform.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/test-codex-transform.js b/test/test-codex-transform.js index b372bf084..203a790d0 100644 --- a/test/test-codex-transform.js +++ b/test/test-codex-transform.js @@ -94,6 +94,25 @@ console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.res 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.'; @@ -103,6 +122,15 @@ console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.res 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.'; From f0ad64efc4519df5c0073f8a5e63f129935a72c0 Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sun, 15 Feb 2026 11:54:30 -0500 Subject: [PATCH 16/16] Handle Windows line endings like manifest-generator.js:175 --- tools/cli/installers/lib/ide/codex.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index f7018f702..a261b0f6e 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -369,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); }