diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 85d8e63d8..cb13368ac 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -47,7 +47,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | -| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | +| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.md` | | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | diff --git a/src/core/command-generation/adapters/qwen.ts b/src/core/command-generation/adapters/qwen.ts index 0ee640b3c..e5c2685e5 100644 --- a/src/core/command-generation/adapters/qwen.ts +++ b/src/core/command-generation/adapters/qwen.ts @@ -1,30 +1,50 @@ /** * Qwen Code Command Adapter * - * Formats commands for Qwen Code following its TOML specification. + * Formats commands for Qwen Code following its Markdown command specification. */ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { transformToHyphenCommands } from '../../../utils/command-references.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); + return `"${escaped}"`; + } + return value; +} + +export function getLegacyQwenTomlFilePath(commandId: string): string { + return path.join('.qwen', 'commands', `opsx-${commandId}.toml`); +} /** * Qwen adapter for command generation. - * File path: .qwen/commands/opsx-.toml - * Format: TOML with description and prompt fields + * File path: .qwen/commands/opsx-.md + * Frontmatter: description */ export const qwenAdapter: ToolCommandAdapter = { toolId: 'qwen', getFilePath(commandId: string): string { - return path.join('.qwen', 'commands', `opsx-${commandId}.toml`); + return path.join('.qwen', 'commands', `opsx-${commandId}.md`); }, formatFile(content: CommandContent): string { - return `description = "${content.description}" + const transformedBody = transformToHyphenCommands(content.body); + + return `--- +description: ${escapeYamlValue(content.description)} +--- -prompt = """ -${content.body} -""" +${transformedBody} `; }, }; diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..bd6a1c6ec 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -24,6 +24,7 @@ import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js'; +import { getLegacyQwenTomlFilePath } from './command-generation/adapters/qwen.js'; import { detectLegacyArtifacts, cleanupLegacyArtifacts, @@ -501,6 +502,7 @@ export class InitCommand { commandsSkipped: string[]; removedCommandCount: number; removedSkillCount: number; + removedObsoleteCommandCount: number; }> { const createdTools: typeof tools = []; const refreshedTools: typeof tools = []; @@ -508,6 +510,7 @@ export class InitCommand { const commandsSkipped: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; + let removedObsoleteCommandCount = 0; // Read global config for profile and delivery settings (use --profile override if set) const globalConfig = getGlobalConfig(); @@ -560,12 +563,20 @@ export class InitCommand { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } + removedObsoleteCommandCount += await this.removeObsoleteCommandFiles( + projectPath, + tool.value + ); } else { commandsSkipped.push(tool.value); } } if (!shouldGenerateCommands) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); + removedObsoleteCommandCount += await this.removeObsoleteCommandFiles( + projectPath, + tool.value + ); } spinner.succeed(`Setup complete for ${tool.name}`); @@ -588,6 +599,7 @@ export class InitCommand { commandsSkipped, removedCommandCount, removedSkillCount, + removedObsoleteCommandCount, }; } @@ -633,6 +645,7 @@ export class InitCommand { commandsSkipped: string[]; removedCommandCount: number; removedSkillCount: number; + removedObsoleteCommandCount: number; }, configStatus: 'created' | 'exists' | 'skipped' ): void { @@ -682,6 +695,9 @@ export class InitCommand { if (results.removedSkillCount > 0) { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } + if (results.removedObsoleteCommandCount > 0) { + console.log(chalk.dim(`Removed: ${results.removedObsoleteCommandCount} obsolete command files`)); + } // Config status if (configStatus === 'created') { @@ -776,4 +792,25 @@ export class InitCommand { return removed; } + + private async removeObsoleteCommandFiles(projectPath: string, toolId: string): Promise { + if (toolId !== 'qwen') return 0; + + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + const fullPath = path.join(projectPath, getLegacyQwenTomlFilePath(workflow)); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } } diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..33d495413 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -17,6 +17,7 @@ import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js'; +import { getLegacyQwenTomlFilePath } from './command-generation/adapters/qwen.js'; import { getToolVersionStatus, getSkillTemplates, @@ -180,6 +181,7 @@ export class UpdateCommand { let removedSkillCount = 0; let removedDeselectedCommandCount = 0; let removedDeselectedSkillCount = 0; + let removedObsoleteCommandCount = 0; for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -221,6 +223,10 @@ export class UpdateCommand { await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } + removedObsoleteCommandCount += await this.removeObsoleteCommandFiles( + resolvedProjectPath, + toolId + ); removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( resolvedProjectPath, toolId, @@ -232,6 +238,10 @@ export class UpdateCommand { // Delete command files if delivery is skills-only if (!shouldGenerateCommands) { removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); + removedObsoleteCommandCount += await this.removeObsoleteCommandFiles( + resolvedProjectPath, + toolId + ); } spinner.succeed(`Updated ${tool.name}`); @@ -265,6 +275,9 @@ export class UpdateCommand { if (removedDeselectedSkillCount > 0) { console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`)); } + if (removedObsoleteCommandCount > 0) { + console.log(chalk.dim(`Removed: ${removedObsoleteCommandCount} obsolete command files`)); + } // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { @@ -479,6 +492,30 @@ export class UpdateCommand { return removed; } + private async removeObsoleteCommandFiles( + projectPath: string, + toolId: string, + ): Promise { + if (toolId !== 'qwen') return 0; + + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + const fullPath = path.join(projectPath, getLegacyQwenTomlFilePath(workflow)); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } + /** * Removes command files for workflows that are no longer selected in the active profile. * Returns the number of files removed. diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..dcf1d21fb 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -576,17 +576,50 @@ describe('command-generation/adapters', () => { expect(qwenAdapter.toolId).toBe('qwen'); }); - it('should generate correct file path with .toml extension', () => { + it('should generate correct file path with .md extension', () => { const filePath = qwenAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.toml')); + expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.md')); }); - it('should format file in TOML format', () => { + it('should format file with description frontmatter', () => { const output = qwenAdapter.formatFile(sampleContent); - expect(output).toContain('description = "Enter explore mode for thinking"'); - expect(output).toContain('prompt = """'); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); - expect(output).toContain('"""'); + }); + + it('should transform command references from colon to hyphen format', () => { + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', + }; + + const output = qwenAdapter.formatFile(contentWithRefs); + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-archive'); + expect(output).not.toContain('/opsx:apply'); + expect(output).not.toContain('/opsx:archive'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + + const output = qwenAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + + it('should escape carriage returns in description', () => { + const contentWithCarriageReturn: CommandContent = { + ...sampleContent, + description: 'Line 1\rLine 2', + }; + + const output = qwenAdapter.formatFile(contentWithCarriageReturn); + expect(output).toContain('description: "Line 1\\rLine 2"'); }); }); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51..3070b18bd 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -90,8 +90,8 @@ describe('command-generation/registry', () => { body: 'Body content', }; - // Tools that don't use YAML frontmatter (markdown headers or TOML or plain) - const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini', 'qwen']; + // Tools that don't use YAML frontmatter (markdown headers, TOML, or plain) + const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini']; const adapters = CommandAdapterRegistry.getAll(); for (const adapter of adapters) { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..3dae6af3e 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -449,6 +449,24 @@ describe('InitCommand', () => { expect(content).toContain('prompt ='); }); + it('should generate Qwen commands as Markdown files and remove old TOML commands', async () => { + const oldCmdFile = path.join(testDir, '.qwen', 'commands', 'opsx-explore.toml'); + await fs.mkdir(path.dirname(oldCmdFile), { recursive: true }); + await fs.writeFile(oldCmdFile, 'description = "old"\nprompt = "old"\n'); + + const initCommand = new InitCommand({ tools: 'qwen', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(testDir, '.qwen', 'commands', 'opsx-explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + expect(await fileExists(oldCmdFile)).toBe(false); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toContain('---\n'); + expect(content).toContain('description:'); + expect(content).toContain('---\n\n'); + }); + it('should generate Windsurf commands', async () => { const initCommand = new InitCommand({ tools: 'windsurf', force: true }); await initCommand.execute(testDir); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..c7a2d1368 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -311,22 +311,32 @@ Old instructions content path.join(qwenSkillsDir, 'openspec-explore', 'SKILL.md'), 'old' ); + const oldQwenCmd = path.join( + testDir, + '.qwen', + 'commands', + 'opsx-explore.toml' + ); + await fs.mkdir(path.dirname(oldQwenCmd), { recursive: true }); + await fs.writeFile(oldQwenCmd, 'description = "old"\nprompt = "old"\n'); await updateCommand.execute(testDir); - // Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-.toml + // Check Qwen command format (Markdown) - Qwen uses flat path structure: opsx-.md const qwenCmd = path.join( testDir, '.qwen', 'commands', - 'opsx-explore.toml' + 'opsx-explore.md' ); const exists = await FileSystemUtils.fileExists(qwenCmd); expect(exists).toBe(true); const content = await fs.readFile(qwenCmd, 'utf-8'); - expect(content).toContain('description ='); - expect(content).toContain('prompt ='); + expect(content).toContain('---\n'); + expect(content).toContain('description:'); + expect(content).toContain('---\n\n'); + expect(await FileSystemUtils.fileExists(oldQwenCmd)).toBe(false); }); it('should update Windsurf tool with correct command format', async () => {