Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/core/command-generation/adapters/codebuddy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]"
---

Expand Down
9 changes: 5 additions & 4 deletions src/core/command-generation/adapters/crush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}]
---

Expand Down
9 changes: 5 additions & 4 deletions src/core/command-generation/adapters/lingma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}]
---

Expand Down
9 changes: 5 additions & 4 deletions src/core/command-generation/adapters/qoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}]
---

Expand Down
44 changes: 43 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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<string, unknown>;
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
Expand Down