diff --git a/src/core/configurators/slash/gemini.ts b/src/core/configurators/slash/gemini.ts index efc4243d..91bacc3e 100644 --- a/src/core/configurators/slash/gemini.ts +++ b/src/core/configurators/slash/gemini.ts @@ -1,7 +1,5 @@ -import { FileSystemUtils } from '../../../utils/file-system.js'; -import { SlashCommandConfigurator } from './base.js'; -import { SlashCommandId, TemplateManager } from '../../templates/index.js'; -import { OPENSPEC_MARKERS } from '../../config.js'; +import { TomlSlashCommandConfigurator } from './toml-base.js'; +import { SlashCommandId } from '../../templates/index.js'; const FILE_PATHS: Record = { proposal: '.gemini/commands/openspec/proposal.toml', @@ -15,7 +13,7 @@ const DESCRIPTIONS: Record = { archive: 'Archive a deployed OpenSpec change and update specs.' }; -export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator { +export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator { readonly toolId = 'gemini'; readonly isAvailable = true; @@ -23,61 +21,7 @@ export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator { return FILE_PATHS[id]; } - protected getFrontmatter(_id: SlashCommandId): string | undefined { - // TOML doesn't use separate frontmatter - it's all in one structure - return undefined; - } - - // Override to generate TOML format with markers inside the prompt field - async generateAll(projectPath: string, _openspecDir: string): Promise { - const createdOrUpdated: string[] = []; - - for (const target of this.getTargets()) { - const body = this.getBody(target.id); - const filePath = FileSystemUtils.joinPath(projectPath, target.path); - - if (await FileSystemUtils.fileExists(filePath)) { - await this.updateBody(filePath, body); - } else { - const tomlContent = this.generateTOML(target.id, body); - await FileSystemUtils.writeFile(filePath, tomlContent); - } - - createdOrUpdated.push(target.path); - } - - return createdOrUpdated; - } - - private generateTOML(id: SlashCommandId, body: string): string { - const description = DESCRIPTIONS[id]; - - // TOML format with triple-quoted string for multi-line prompt - // Markers are inside the prompt value - return `description = "${description}" - -prompt = """ -${OPENSPEC_MARKERS.start} -${body} -${OPENSPEC_MARKERS.end} -""" -`; - } - - // Override updateBody to handle TOML format - protected async updateBody(filePath: string, body: string): Promise { - const content = await FileSystemUtils.readFile(filePath); - const startIndex = content.indexOf(OPENSPEC_MARKERS.start); - const endIndex = content.indexOf(OPENSPEC_MARKERS.end); - - if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { - throw new Error(`Missing OpenSpec markers in ${filePath}`); - } - - const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length); - const after = content.slice(endIndex); - const updatedContent = `${before}\n${body}\n${after}`; - - await FileSystemUtils.writeFile(filePath, updatedContent); + protected getDescription(id: SlashCommandId): string { + return DESCRIPTIONS[id]; } } diff --git a/src/core/configurators/slash/qwen.ts b/src/core/configurators/slash/qwen.ts index 7bc1d9ba..b1f9ebfb 100644 --- a/src/core/configurators/slash/qwen.ts +++ b/src/core/configurators/slash/qwen.ts @@ -5,7 +5,7 @@ * * @implements {SlashCommandConfigurator} */ -import { SlashCommandConfigurator } from './base.js'; +import { TomlSlashCommandConfigurator } from './toml-base.js'; import { SlashCommandId } from '../../templates/index.js'; /** @@ -13,35 +13,15 @@ import { SlashCommandId } from '../../templates/index.js'; * @type {Record} */ const FILE_PATHS: Record = { - proposal: '.qwen/commands/openspec-proposal.md', - apply: '.qwen/commands/openspec-apply.md', - archive: '.qwen/commands/openspec-archive.md' + proposal: '.qwen/commands/openspec-proposal.toml', + apply: '.qwen/commands/openspec-apply.toml', + archive: '.qwen/commands/openspec-archive.toml' }; -/** - * YAML frontmatter definitions for Qwen command files. - * These provide metadata for each slash command to ensure proper recognition by Qwen Code. - * @type {Record} - */ -const FRONTMATTER: Record = { - proposal: `--- -name: /openspec-proposal -id: openspec-proposal -category: OpenSpec -description: Scaffold a new OpenSpec change and validate strictly. ----`, - apply: `--- -name: /openspec-apply -id: openspec-apply -category: OpenSpec -description: Implement an approved OpenSpec change and keep tasks in sync. ----`, - archive: `--- -name: /openspec-archive -id: openspec-archive -category: OpenSpec -description: Archive a deployed OpenSpec change and update specs. ----` +const DESCRIPTIONS: Record = { + proposal: 'Scaffold a new OpenSpec change and validate strictly.', + apply: 'Implement an approved OpenSpec change and keep tasks in sync.', + archive: 'Archive a deployed OpenSpec change and update specs.' }; /** @@ -53,10 +33,10 @@ description: Archive a deployed OpenSpec change and update specs. * - /openspec-apply: Apply an approved OpenSpec change * - /openspec-archive: Archive a deployed OpenSpec change */ -export class QwenSlashCommandConfigurator extends SlashCommandConfigurator { +export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator { /** Unique identifier for the Qwen tool */ readonly toolId = 'qwen'; - + /** Availability status for the Qwen tool */ readonly isAvailable = true; @@ -69,12 +49,7 @@ export class QwenSlashCommandConfigurator extends SlashCommandConfigurator { return FILE_PATHS[id]; } - /** - * Returns the YAML frontmatter for a given slash command ID. - * @param {SlashCommandId} id - The slash command identifier - * @returns {string} The YAML frontmatter string - */ - protected getFrontmatter(id: SlashCommandId): string { - return FRONTMATTER[id]; + protected getDescription(id: SlashCommandId): string { + return DESCRIPTIONS[id]; } } \ No newline at end of file diff --git a/src/core/configurators/slash/toml-base.ts b/src/core/configurators/slash/toml-base.ts new file mode 100644 index 00000000..233e1dd4 --- /dev/null +++ b/src/core/configurators/slash/toml-base.ts @@ -0,0 +1,66 @@ +import { FileSystemUtils } from '../../../utils/file-system.js'; +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; +import { OPENSPEC_MARKERS } from '../../config.js'; + +export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator { + protected getFrontmatter(_id: SlashCommandId): string | undefined { + // TOML doesn't use separate frontmatter - it's all in one structure + return undefined; + } + + protected abstract getDescription(id: SlashCommandId): string; + + // Override to generate TOML format with markers inside the prompt field + async generateAll(projectPath: string, _openspecDir: string): Promise { + const createdOrUpdated: string[] = []; + + for (const target of this.getTargets()) { + const body = this.getBody(target.id); + const filePath = FileSystemUtils.joinPath(projectPath, target.path); + + if (await FileSystemUtils.fileExists(filePath)) { + await this.updateBody(filePath, body); + } else { + const tomlContent = this.generateTOML(target.id, body); + await FileSystemUtils.writeFile(filePath, tomlContent); + } + + createdOrUpdated.push(target.path); + } + + return createdOrUpdated; + } + + private generateTOML(id: SlashCommandId, body: string): string { + const description = this.getDescription(id); + + // TOML format with triple-quoted string for multi-line prompt + // Markers are inside the prompt value + return `description = "${description}" + +prompt = """ +${OPENSPEC_MARKERS.start} +${body} +${OPENSPEC_MARKERS.end} +""" +`; + } + + // Override updateBody to handle TOML format + protected async updateBody(filePath: string, body: string): Promise { + const content = await FileSystemUtils.readFile(filePath); + const startIndex = content.indexOf(OPENSPEC_MARKERS.start); + const endIndex = content.indexOf(OPENSPEC_MARKERS.end); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + throw new Error(`Missing OpenSpec markers in ${filePath}`); + } + + const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length); + const after = content.slice(endIndex); + const updatedContent = `${before}\n${body}\n${after}`; + + await FileSystemUtils.writeFile(filePath, updatedContent); + } +} diff --git a/test/core/init.test.ts b/test/core/init.test.ts index a76b7fee..ca8469e7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -50,7 +50,7 @@ describe('InitCommand', () => { process.env.CODEX_HOME = path.join(testDir, '.codex'); // Mock console.log to suppress output during tests - vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => { }); }); afterEach(async () => { @@ -424,15 +424,15 @@ describe('InitCommand', () => { const qwenConfigPath = path.join(testDir, 'QWEN.md'); const proposalPath = path.join( testDir, - '.qwen/commands/openspec-proposal.md' + '.qwen/commands/openspec-proposal.toml' ); const applyPath = path.join( testDir, - '.qwen/commands/openspec-apply.md' + '.qwen/commands/openspec-apply.toml' ); const archivePath = path.join( testDir, - '.qwen/commands/openspec-archive.md' + '.qwen/commands/openspec-archive.toml' ); expect(await fileExists(qwenConfigPath)).toBe(true); @@ -446,21 +446,16 @@ describe('InitCommand', () => { expect(qwenConfigContent).toContain(''); const proposalContent = await fs.readFile(proposalPath, 'utf-8'); - expect(proposalContent).toContain('name: /openspec-proposal'); - expect(proposalContent).toContain('category: OpenSpec'); - expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."'); + expect(proposalContent).toContain('prompt = """'); expect(proposalContent).toContain(''); const applyContent = await fs.readFile(applyPath, 'utf-8'); - expect(applyContent).toContain('name: /openspec-apply'); - expect(applyContent).toContain('category: OpenSpec'); - expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."'); expect(applyContent).toContain('Work through tasks sequentially'); const archiveContent = await fs.readFile(archivePath, 'utf-8'); - expect(archiveContent).toContain('name: /openspec-archive'); - expect(archiveContent).toContain('category: OpenSpec'); - expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."'); expect(archiveContent).toContain('openspec archive '); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index d462d64e..05fa52f2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -152,19 +152,17 @@ Old slash content it('should refresh existing Qwen slash command files', async () => { const applyPath = path.join( testDir, - '.qwen/commands/openspec-apply.md' + '.qwen/commands/openspec-apply.toml' ); await fs.mkdir(path.dirname(applyPath), { recursive: true }); - const initialContent = `--- -name: /openspec-apply -id: openspec-apply -category: OpenSpec -description: Old description ---- + const initialContent = `description = "Implement an approved OpenSpec change and keep tasks in sync." +prompt = """ Old body -`; + +""" +`; await fs.writeFile(applyPath, initialContent); const consoleSpy = vi.spyOn(console, 'log'); @@ -172,8 +170,8 @@ Old body await updateCommand.execute(testDir); const updated = await fs.readFile(applyPath, 'utf-8'); - expect(updated).toContain('name: /openspec-apply'); - expect(updated).toContain('category: OpenSpec'); + expect(updated).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."'); + expect(updated).toContain('prompt = """'); expect(updated).toContain(''); expect(updated).toContain('Work through tasks sequentially'); expect(updated).not.toContain('Old body'); @@ -184,7 +182,7 @@ Old body ); expect(logMessage).toContain('AGENTS.md (created)'); expect(logMessage).toContain( - 'Updated slash commands: .qwen/commands/openspec-apply.md' + 'Updated slash commands: .qwen/commands/openspec-apply.toml' ); consoleSpy.mockRestore(); @@ -193,22 +191,20 @@ Old body it('should not create missing Qwen slash command files on update', async () => { const applyPath = path.join( testDir, - '.qwen/commands/openspec-apply.md' + '.qwen/commands/openspec-apply.toml' ); await fs.mkdir(path.dirname(applyPath), { recursive: true }); await fs.writeFile( applyPath, - `--- -name: /openspec-apply -id: openspec-apply -category: OpenSpec -description: Old description ---- + `description = "Old description" +prompt = """ Old content -` + +""" +` ); await updateCommand.execute(testDir); @@ -219,11 +215,11 @@ Old content const proposalPath = path.join( testDir, - '.qwen/commands/openspec-proposal.md' + '.qwen/commands/openspec-proposal.toml' ); const archivePath = path.join( testDir, - '.qwen/commands/openspec-archive.md' + '.qwen/commands/openspec-archive.toml' ); await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);