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}`; } /**