diff --git a/src/core/command-generation/adapters/codebuddy.ts b/src/core/command-generation/adapters/codebuddy.ts index 54b7eebdc..51657e766 100644 --- a/src/core/command-generation/adapters/codebuddy.ts +++ b/src/core/command-generation/adapters/codebuddy.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * CodeBuddy adapter for command generation. @@ -21,8 +22,8 @@ export const codebuddyAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: ${content.name} -description: "${content.description}" +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} argument-hint: "[command arguments]" --- diff --git a/src/core/command-generation/adapters/crush.ts b/src/core/command-generation/adapters/crush.ts index b4d1a0b9d..61d9de38d 100644 --- a/src/core/command-generation/adapters/crush.ts +++ b/src/core/command-generation/adapters/crush.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Crush adapter for command generation. @@ -20,11 +21,11 @@ export const crushAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); + const tagsStr = content.tags.map(escapeYamlValue).join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} tags: [${tagsStr}] --- diff --git a/src/core/command-generation/adapters/lingma.ts b/src/core/command-generation/adapters/lingma.ts index cf9bcc88b..ee0e5d778 100644 --- a/src/core/command-generation/adapters/lingma.ts +++ b/src/core/command-generation/adapters/lingma.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Lingma adapter for command generation. @@ -20,11 +21,11 @@ export const lingmaAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); + const tagsStr = content.tags.map(escapeYamlValue).join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} tags: [${tagsStr}] --- diff --git a/src/core/command-generation/adapters/qoder.ts b/src/core/command-generation/adapters/qoder.ts index 608fc9ae2..42a60a4ed 100644 --- a/src/core/command-generation/adapters/qoder.ts +++ b/src/core/command-generation/adapters/qoder.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { escapeYamlValue } from '../yaml.js'; /** * Qoder adapter for command generation. @@ -20,11 +21,11 @@ export const qoderAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const tagsStr = content.tags.join(', '); + const tagsStr = content.tags.map(escapeYamlValue).join(', '); return `--- -name: ${content.name} -description: ${content.description} -category: ${content.category} +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} tags: [${tagsStr}] --- diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 6255a4fc7..d323f498b 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { parse as parseYaml } from 'yaml'; import os from 'os'; import path from 'path'; import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js'; @@ -18,6 +19,7 @@ import { geminiAdapter } from '../../../src/core/command-generation/adapters/gem import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js'; import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js'; import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js'; +import { lingmaAdapter } from '../../../src/core/command-generation/adapters/lingma.js'; import { ohMyPiAdapter } from '../../../src/core/command-generation/adapters/oh-my-pi.js'; import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js'; import { piAdapter } from '../../../src/core/command-generation/adapters/pi.js'; @@ -339,7 +341,9 @@ describe('command-generation/adapters', () => { const output = codebuddyAdapter.formatFile(sampleContent); expect(output).toContain('---\n'); expect(output).toContain('name: OpenSpec Explore'); - expect(output).toContain('description: "Enter explore mode for thinking"'); + // escapeYamlValue only quotes when a value contains YAML-special chars; a + // safe scalar is emitted unquoted (valid YAML, consistent with claude/crush/lingma). + expect(output).toContain('description: Enter explore mode for thinking'); expect(output).toContain('argument-hint: "[command arguments]"'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); @@ -837,6 +841,44 @@ describe('command-generation/adapters', () => { }); }); + describe('frontmatter YAML validity for colon-bearing fields', () => { + // Real command names carry a colon (e.g. "OPSX: Explore"). Adapters that emit + // YAML frontmatter must route interpolated fields through escapeYamlValue so a + // colon does not produce a broken `key: value: rest` mapping. Before the fix + // these adapters interpolated the raw name and produced INVALID YAML; here we + // parse the emitted frontmatter with a real YAML parser to prove it round-trips. + const yamlFrontmatterAdapters = [ + { name: 'codebuddy', adapter: codebuddyAdapter }, + { name: 'crush', adapter: crushAdapter }, + { name: 'lingma', adapter: lingmaAdapter }, + { name: 'qoder', adapter: qoderAdapter }, + ]; + + const extractFrontmatter = (output: string): string => { + const match = output.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) { + throw new Error('No YAML frontmatter block found in adapter output'); + } + return match[1]; + }; + + for (const { name, adapter } of yamlFrontmatterAdapters) { + it(`${name} emits parseable YAML frontmatter when the name contains a colon`, () => { + const contentWithColon: CommandContent = { + ...sampleContent, + name: 'OPSX: Explore', + }; + + const output = adapter.formatFile(contentWithColon); + const frontmatter = extractFrontmatter(output); + + // Must parse without throwing and preserve the exact name (colon intact). + const parsed = parseYaml(frontmatter) as Record; + expect(parsed.name).toBe('OPSX: Explore'); + }); + } + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators