Skip to content

Commit b5a7d09

Browse files
authored
fix: generate TOML commands for Qwen Code (fixes #293) (#317)
1 parent c54079a commit b5a7d09

File tree

5 files changed

+108
-132
lines changed

5 files changed

+108
-132
lines changed
Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { FileSystemUtils } from '../../../utils/file-system.js';
2-
import { SlashCommandConfigurator } from './base.js';
3-
import { SlashCommandId, TemplateManager } from '../../templates/index.js';
4-
import { OPENSPEC_MARKERS } from '../../config.js';
1+
import { TomlSlashCommandConfigurator } from './toml-base.js';
2+
import { SlashCommandId } from '../../templates/index.js';
53

64
const FILE_PATHS: Record<SlashCommandId, string> = {
75
proposal: '.gemini/commands/openspec/proposal.toml',
@@ -15,69 +13,15 @@ const DESCRIPTIONS: Record<SlashCommandId, string> = {
1513
archive: 'Archive a deployed OpenSpec change and update specs.'
1614
};
1715

18-
export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
16+
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
1917
readonly toolId = 'gemini';
2018
readonly isAvailable = true;
2119

2220
protected getRelativePath(id: SlashCommandId): string {
2321
return FILE_PATHS[id];
2422
}
2523

26-
protected getFrontmatter(_id: SlashCommandId): string | undefined {
27-
// TOML doesn't use separate frontmatter - it's all in one structure
28-
return undefined;
29-
}
30-
31-
// Override to generate TOML format with markers inside the prompt field
32-
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
33-
const createdOrUpdated: string[] = [];
34-
35-
for (const target of this.getTargets()) {
36-
const body = this.getBody(target.id);
37-
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
38-
39-
if (await FileSystemUtils.fileExists(filePath)) {
40-
await this.updateBody(filePath, body);
41-
} else {
42-
const tomlContent = this.generateTOML(target.id, body);
43-
await FileSystemUtils.writeFile(filePath, tomlContent);
44-
}
45-
46-
createdOrUpdated.push(target.path);
47-
}
48-
49-
return createdOrUpdated;
50-
}
51-
52-
private generateTOML(id: SlashCommandId, body: string): string {
53-
const description = DESCRIPTIONS[id];
54-
55-
// TOML format with triple-quoted string for multi-line prompt
56-
// Markers are inside the prompt value
57-
return `description = "${description}"
58-
59-
prompt = """
60-
${OPENSPEC_MARKERS.start}
61-
${body}
62-
${OPENSPEC_MARKERS.end}
63-
"""
64-
`;
65-
}
66-
67-
// Override updateBody to handle TOML format
68-
protected async updateBody(filePath: string, body: string): Promise<void> {
69-
const content = await FileSystemUtils.readFile(filePath);
70-
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
71-
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
72-
73-
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
74-
throw new Error(`Missing OpenSpec markers in ${filePath}`);
75-
}
76-
77-
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
78-
const after = content.slice(endIndex);
79-
const updatedContent = `${before}\n${body}\n${after}`;
80-
81-
await FileSystemUtils.writeFile(filePath, updatedContent);
24+
protected getDescription(id: SlashCommandId): string {
25+
return DESCRIPTIONS[id];
8226
}
8327
}

src/core/configurators/slash/qwen.ts

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,23 @@
55
*
66
* @implements {SlashCommandConfigurator}
77
*/
8-
import { SlashCommandConfigurator } from './base.js';
8+
import { TomlSlashCommandConfigurator } from './toml-base.js';
99
import { SlashCommandId } from '../../templates/index.js';
1010

1111
/**
1212
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
1313
* @type {Record<SlashCommandId, string>}
1414
*/
1515
const FILE_PATHS: Record<SlashCommandId, string> = {
16-
proposal: '.qwen/commands/openspec-proposal.md',
17-
apply: '.qwen/commands/openspec-apply.md',
18-
archive: '.qwen/commands/openspec-archive.md'
16+
proposal: '.qwen/commands/openspec-proposal.toml',
17+
apply: '.qwen/commands/openspec-apply.toml',
18+
archive: '.qwen/commands/openspec-archive.toml'
1919
};
2020

21-
/**
22-
* YAML frontmatter definitions for Qwen command files.
23-
* These provide metadata for each slash command to ensure proper recognition by Qwen Code.
24-
* @type {Record<SlashCommandId, string>}
25-
*/
26-
const FRONTMATTER: Record<SlashCommandId, string> = {
27-
proposal: `---
28-
name: /openspec-proposal
29-
id: openspec-proposal
30-
category: OpenSpec
31-
description: Scaffold a new OpenSpec change and validate strictly.
32-
---`,
33-
apply: `---
34-
name: /openspec-apply
35-
id: openspec-apply
36-
category: OpenSpec
37-
description: Implement an approved OpenSpec change and keep tasks in sync.
38-
---`,
39-
archive: `---
40-
name: /openspec-archive
41-
id: openspec-archive
42-
category: OpenSpec
43-
description: Archive a deployed OpenSpec change and update specs.
44-
---`
21+
const DESCRIPTIONS: Record<SlashCommandId, string> = {
22+
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
23+
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
24+
archive: 'Archive a deployed OpenSpec change and update specs.'
4525
};
4626

4727
/**
@@ -53,10 +33,10 @@ description: Archive a deployed OpenSpec change and update specs.
5333
* - /openspec-apply: Apply an approved OpenSpec change
5434
* - /openspec-archive: Archive a deployed OpenSpec change
5535
*/
56-
export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
36+
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
5737
/** Unique identifier for the Qwen tool */
5838
readonly toolId = 'qwen';
59-
39+
6040
/** Availability status for the Qwen tool */
6141
readonly isAvailable = true;
6242

@@ -69,12 +49,7 @@ export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
6949
return FILE_PATHS[id];
7050
}
7151

72-
/**
73-
* Returns the YAML frontmatter for a given slash command ID.
74-
* @param {SlashCommandId} id - The slash command identifier
75-
* @returns {string} The YAML frontmatter string
76-
*/
77-
protected getFrontmatter(id: SlashCommandId): string {
78-
return FRONTMATTER[id];
52+
protected getDescription(id: SlashCommandId): string {
53+
return DESCRIPTIONS[id];
7954
}
8055
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { FileSystemUtils } from '../../../utils/file-system.js';
2+
import { SlashCommandConfigurator } from './base.js';
3+
import { SlashCommandId } from '../../templates/index.js';
4+
import { OPENSPEC_MARKERS } from '../../config.js';
5+
6+
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
7+
protected getFrontmatter(_id: SlashCommandId): string | undefined {
8+
// TOML doesn't use separate frontmatter - it's all in one structure
9+
return undefined;
10+
}
11+
12+
protected abstract getDescription(id: SlashCommandId): string;
13+
14+
// Override to generate TOML format with markers inside the prompt field
15+
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
16+
const createdOrUpdated: string[] = [];
17+
18+
for (const target of this.getTargets()) {
19+
const body = this.getBody(target.id);
20+
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
21+
22+
if (await FileSystemUtils.fileExists(filePath)) {
23+
await this.updateBody(filePath, body);
24+
} else {
25+
const tomlContent = this.generateTOML(target.id, body);
26+
await FileSystemUtils.writeFile(filePath, tomlContent);
27+
}
28+
29+
createdOrUpdated.push(target.path);
30+
}
31+
32+
return createdOrUpdated;
33+
}
34+
35+
private generateTOML(id: SlashCommandId, body: string): string {
36+
const description = this.getDescription(id);
37+
38+
// TOML format with triple-quoted string for multi-line prompt
39+
// Markers are inside the prompt value
40+
return `description = "${description}"
41+
42+
prompt = """
43+
${OPENSPEC_MARKERS.start}
44+
${body}
45+
${OPENSPEC_MARKERS.end}
46+
"""
47+
`;
48+
}
49+
50+
// Override updateBody to handle TOML format
51+
protected async updateBody(filePath: string, body: string): Promise<void> {
52+
const content = await FileSystemUtils.readFile(filePath);
53+
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
54+
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
55+
56+
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
57+
throw new Error(`Missing OpenSpec markers in ${filePath}`);
58+
}
59+
60+
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
61+
const after = content.slice(endIndex);
62+
const updatedContent = `${before}\n${body}\n${after}`;
63+
64+
await FileSystemUtils.writeFile(filePath, updatedContent);
65+
}
66+
}

test/core/init.test.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('InitCommand', () => {
5050
process.env.CODEX_HOME = path.join(testDir, '.codex');
5151

5252
// Mock console.log to suppress output during tests
53-
vi.spyOn(console, 'log').mockImplementation(() => {});
53+
vi.spyOn(console, 'log').mockImplementation(() => { });
5454
});
5555

5656
afterEach(async () => {
@@ -424,15 +424,15 @@ describe('InitCommand', () => {
424424
const qwenConfigPath = path.join(testDir, 'QWEN.md');
425425
const proposalPath = path.join(
426426
testDir,
427-
'.qwen/commands/openspec-proposal.md'
427+
'.qwen/commands/openspec-proposal.toml'
428428
);
429429
const applyPath = path.join(
430430
testDir,
431-
'.qwen/commands/openspec-apply.md'
431+
'.qwen/commands/openspec-apply.toml'
432432
);
433433
const archivePath = path.join(
434434
testDir,
435-
'.qwen/commands/openspec-archive.md'
435+
'.qwen/commands/openspec-archive.toml'
436436
);
437437

438438
expect(await fileExists(qwenConfigPath)).toBe(true);
@@ -446,21 +446,16 @@ describe('InitCommand', () => {
446446
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
447447

448448
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
449-
expect(proposalContent).toContain('name: /openspec-proposal');
450-
expect(proposalContent).toContain('category: OpenSpec');
451-
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
449+
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
450+
expect(proposalContent).toContain('prompt = """');
452451
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
453452

454453
const applyContent = await fs.readFile(applyPath, 'utf-8');
455-
expect(applyContent).toContain('name: /openspec-apply');
456-
expect(applyContent).toContain('category: OpenSpec');
457-
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
454+
expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
458455
expect(applyContent).toContain('Work through tasks sequentially');
459456

460457
const archiveContent = await fs.readFile(archivePath, 'utf-8');
461-
expect(archiveContent).toContain('name: /openspec-archive');
462-
expect(archiveContent).toContain('category: OpenSpec');
463-
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
458+
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
464459
expect(archiveContent).toContain('openspec archive <id>');
465460
});
466461

test/core/update.test.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -152,28 +152,26 @@ Old slash content
152152
it('should refresh existing Qwen slash command files', async () => {
153153
const applyPath = path.join(
154154
testDir,
155-
'.qwen/commands/openspec-apply.md'
155+
'.qwen/commands/openspec-apply.toml'
156156
);
157157
await fs.mkdir(path.dirname(applyPath), { recursive: true });
158-
const initialContent = `---
159-
name: /openspec-apply
160-
id: openspec-apply
161-
category: OpenSpec
162-
description: Old description
163-
---
158+
const initialContent = `description = "Implement an approved OpenSpec change and keep tasks in sync."
164159
160+
prompt = """
165161
<!-- OPENSPEC:START -->
166162
Old body
167-
<!-- OPENSPEC:END -->`;
163+
<!-- OPENSPEC:END -->
164+
"""
165+
`;
168166
await fs.writeFile(applyPath, initialContent);
169167

170168
const consoleSpy = vi.spyOn(console, 'log');
171169

172170
await updateCommand.execute(testDir);
173171

174172
const updated = await fs.readFile(applyPath, 'utf-8');
175-
expect(updated).toContain('name: /openspec-apply');
176-
expect(updated).toContain('category: OpenSpec');
173+
expect(updated).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
174+
expect(updated).toContain('prompt = """');
177175
expect(updated).toContain('<!-- OPENSPEC:START -->');
178176
expect(updated).toContain('Work through tasks sequentially');
179177
expect(updated).not.toContain('Old body');
@@ -184,7 +182,7 @@ Old body
184182
);
185183
expect(logMessage).toContain('AGENTS.md (created)');
186184
expect(logMessage).toContain(
187-
'Updated slash commands: .qwen/commands/openspec-apply.md'
185+
'Updated slash commands: .qwen/commands/openspec-apply.toml'
188186
);
189187

190188
consoleSpy.mockRestore();
@@ -193,22 +191,20 @@ Old body
193191
it('should not create missing Qwen slash command files on update', async () => {
194192
const applyPath = path.join(
195193
testDir,
196-
'.qwen/commands/openspec-apply.md'
194+
'.qwen/commands/openspec-apply.toml'
197195
);
198196

199197
await fs.mkdir(path.dirname(applyPath), { recursive: true });
200198
await fs.writeFile(
201199
applyPath,
202-
`---
203-
name: /openspec-apply
204-
id: openspec-apply
205-
category: OpenSpec
206-
description: Old description
207-
---
200+
`description = "Old description"
208201
202+
prompt = """
209203
<!-- OPENSPEC:START -->
210204
Old content
211-
<!-- OPENSPEC:END -->`
205+
<!-- OPENSPEC:END -->
206+
"""
207+
`
212208
);
213209

214210
await updateCommand.execute(testDir);
@@ -219,11 +215,11 @@ Old content
219215

220216
const proposalPath = path.join(
221217
testDir,
222-
'.qwen/commands/openspec-proposal.md'
218+
'.qwen/commands/openspec-proposal.toml'
223219
);
224220
const archivePath = path.join(
225221
testDir,
226-
'.qwen/commands/openspec-archive.md'
222+
'.qwen/commands/openspec-archive.toml'
227223
);
228224

229225
await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);

0 commit comments

Comments
 (0)