From 18a4a489c8b8ae99f64a0514b3ee9ed172b90b1a Mon Sep 17 00:00:00 2001 From: Wendy Smoak Date: Sat, 14 Feb 2026 15:06:12 -0500 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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) ---