From c19d62946eabcee1ce23fef7d5c1b918eab7ee95 Mon Sep 17 00:00:00 2001 From: mc856 Date: Tue, 9 Jun 2026 18:31:06 +0000 Subject: [PATCH 1/2] fix(qwen): generate Markdown commands instead of deprecated TOML format Qwen Code deprecated TOML custom commands in favor of Markdown files with YAML frontmatter. Update the Qwen adapter to emit .qwen/commands/opsx-.md and register the old opsx-*.toml files as legacy artifacts so they are cleaned up on update. Closes #838 Generated with Claude (Cowork) using claude-fable-5; tested with the full vitest suite (1663 tests passing). --- .changeset/qwen-markdown-commands.md | 5 +++ docs/supported-tools.md | 2 +- src/core/command-generation/adapters/qwen.ts | 33 +++++++++++++++---- src/core/legacy-cleanup.ts | 2 +- test/core/command-generation/adapters.test.ts | 20 +++++++---- test/core/legacy-cleanup.test.ts | 18 ++++++++++ test/core/update.test.ts | 8 ++--- 7 files changed, 69 insertions(+), 19 deletions(-) create mode 100644 .changeset/qwen-markdown-commands.md diff --git a/.changeset/qwen-markdown-commands.md b/.changeset/qwen-markdown-commands.md new file mode 100644 index 000000000..6f74472c9 --- /dev/null +++ b/.changeset/qwen-markdown-commands.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +Generate Markdown commands for Qwen Code instead of deprecated TOML format. Qwen Code now recommends Markdown custom commands with YAML frontmatter; the old `.qwen/commands/opsx-*.toml` files are cleaned up as legacy artifacts on update. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..e0e4fc557 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -48,7 +48,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..9d31a0771 100644 --- a/src/core/command-generation/adapters/qwen.ts +++ b/src/core/command-generation/adapters/qwen.ts @@ -1,30 +1,49 @@ /** * Qwen Code Command Adapter * - * Formats commands for Qwen Code following its TOML specification. + * Formats commands for Qwen Code following its Markdown custom command + * specification. Qwen Code has deprecated TOML commands in favor of + * Markdown files with YAML frontmatter. + * + * @see https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/#markdown-file-format-specification-recommended */ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes and backslashes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + /** * Qwen adapter for command generation. - * File path: .qwen/commands/opsx-.toml - * Format: TOML with description and prompt fields + * File path: .qwen/commands/opsx-.md + * Format: Markdown with description frontmatter */ 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}" + return `--- +description: ${escapeYamlValue(content.description)} +--- -prompt = """ ${content.body} -""" `; }, }; diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index f3cbb560e..74b04813c 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -55,7 +55,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record { 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 escape special YAML characters in description', () => { + const output = qwenAdapter.formatFile({ + ...sampleContent, + description: 'Review: plan & apply "changes"', + }); + expect(output).toContain('description: "Review: plan & apply \\"changes\\""'); }); }); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index bfae37805..0f6ebc86e 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -327,6 +327,24 @@ ${OPENSPEC_MARKERS.end}`); expect(result.files).toContain('.qwen/commands/openspec-proposal.toml'); }); + it('should detect deprecated opsx TOML commands for Qwen', async () => { + const dirPath = path.join(testDir, '.qwen', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'opsx-explore.toml'), 'content'); + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).toContain('.qwen/commands/opsx-explore.toml'); + }); + + it('should not detect new Markdown commands for Qwen as legacy', async () => { + const dirPath = path.join(testDir, '.qwen', 'commands'); + await fs.mkdir(dirPath, { recursive: true }); + await fs.writeFile(path.join(dirPath, 'opsx-explore.md'), 'content'); + + const result = await detectLegacySlashCommands(testDir); + expect(result.files).not.toContain('.qwen/commands/opsx-explore.md'); + }); + it('should detect Continue prompt files', async () => { const dirPath = path.join(testDir, '.continue', 'prompts'); await fs.mkdir(dirPath, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..541ad116b 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -314,19 +314,19 @@ Old instructions content 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('---'); + expect(content).toContain('description:'); }); it('should update Windsurf tool with correct command format', async () => { From 930de0cfb79ae777414a0a79e9efff16ed1f1fe0 Mon Sep 17 00:00:00 2001 From: mc856 Date: Fri, 12 Jun 2026 02:27:24 +0000 Subject: [PATCH 2/2] test(qwen): assert YAML frontmatter for qwen in registry adapter test --- test/core/command-generation/registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51..52fe8b1be 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -91,7 +91,7 @@ describe('command-generation/registry', () => { }; // Tools that don't use YAML frontmatter (markdown headers or TOML or plain) - const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini', 'qwen']; + const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini']; const adapters = CommandAdapterRegistry.getAll(); for (const adapter of adapters) {