From c12a8a5b9419d5bb292715da3ea3bf9c8ecdc50c Mon Sep 17 00:00:00 2001 From: David Everett <118162197+seattled23@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:20:07 -0700 Subject: [PATCH] fix(command-generation): escape YAML frontmatter name in 4 adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebuddy, crush, lingma, and qoder adapters interpolated `name: ${content.name}` raw into command frontmatter. Every OPSX command name contains a colon ("OPSX: Explore", "OPSX: Apply", ...), so the emitted frontmatter is `name: OPSX: Explore` — invalid YAML ("mapping values are not allowed here"). On a default `openspec init` for any of those 4 tools, the generated slash-commands ship broken to the user's repo. Fix routes the field through the existing `escapeYamlValue` helper, matching the claude/windsurf/trae adapters. The prior escapeYamlValue refactor (#1204/#1205) wired the helper into pi/claude/qwen but did not reach these 4. Adds tests that parse each adapter's frontmatter with the real YAML library and assert the name round-trips. Co-authored-by: Sōren Vale --- .../command-generation/adapters/codebuddy.ts | 5 ++- src/core/command-generation/adapters/crush.ts | 9 ++-- .../command-generation/adapters/lingma.ts | 9 ++-- src/core/command-generation/adapters/qoder.ts | 9 ++-- test/core/command-generation/adapters.test.ts | 44 ++++++++++++++++++- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/core/command-generation/adapters/codebuddy.ts b/src/core/command-generation/adapters/codebuddy.ts index 54b7eebdcf..51657e7664 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 b4d1a0b9dd..61d9de38d1 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 cf9bcc88b2..ee0e5d778d 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 608fc9ae25..42a60a4ed5 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 6255a4fc7b..d323f498b8 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