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: 5 additions & 0 deletions .changeset/qwen-markdown-commands.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.md` |
| RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.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-<id>.md` |
Expand Down
33 changes: 26 additions & 7 deletions src/core/command-generation/adapters/qwen.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +18 to +27

@coderabbitai coderabbitai Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add carriage return escape sequence for complete YAML compliance.

The regex on line 20 correctly detects \r as requiring quotes, but line 23 doesn't escape it. Per the YAML 1.2 specification, carriage returns inside double-quoted strings must be escaped. While carriage returns are rare in description strings, adding the escape ensures valid YAML output.

🛡️ Proposed fix to add carriage return escaping
   // Use double quotes and escape internal double quotes and backslashes
-  const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
+  const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
   return `"${escaped}"`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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').replace(/\r/g, '\\r');
return `"${escaped}"`;
}
return value;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/command-generation/adapters/qwen.ts` around lines 18 - 27, The
escapeYamlValue function correctly detects values needing quotes but doesn't
escape carriage returns; update the escaping step in escapeYamlValue to also
replace '\r' with '\\r' (in addition to existing backslash, double-quote, and
newline escapes) so double-quoted YAML strings conform to YAML 1.2; modify the
replace chain inside escapeYamlValue accordingly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the detect/escape asymmetry. This function is an intentional verbatim copy of the existing escapeYamlValue in pi.ts and claude.ts (current convention is one copy per adapter), so the same gap exists in both siblings. Since descriptions come from internal command templates, a literal CR can't occur in practice. Rather than diverging this copy from the other two or expanding this PR's scope, I'd prefer a follow-up PR that extracts escapeYamlValue into a shared util and adds \r escaping in one place. Happy to fix it here instead if maintainers prefer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mc856, that context makes sense — the detect/escape asymmetry is a pre-existing gap shared across all three adapters, and fixing it consistently via a shared utility is the right approach. Happy to leave this PR as-is.

Would you like me to open a follow-up GitHub issue to track:

  • Extracting escapeYamlValue into a shared YAML utility
  • Adding \r → \\r escaping in that shared location
  • Updating pi.ts, claude.ts, and qwen.ts to consume it

Just let me know and I'll file it.


✏️ Learnings added
Learnt from: mc856
Repo: Fission-AI/OpenSpec PR: 1191
File: src/core/command-generation/adapters/qwen.ts:18-27
Timestamp: 2026-06-09T19:17:10.070Z
Learning: In the OpenSpec repository, `escapeYamlValue` is intentionally duplicated per adapter file (e.g., `src/core/command-generation/adapters/pi.ts`, `claude.ts`, `qwen.ts`) — one copy per adapter is the current convention. There is a known detect/escape asymmetry where `\r` is detected as needing quoting but is not escaped in the replace chain; this gap exists in all three siblings and is tracked for a future refactor that would extract the helper into a shared util.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai yes, please file the follow-up issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

gh issue create \
  --repo Fission-AI/OpenSpec \
  --title "refactor(adapters): extract escapeYamlValue into shared util and fix \\r escape gap" \
  --assignee mc856 \
  --body "## Summary

The \`escapeYamlValue\` helper is currently duplicated verbatim across three adapter files:

- \`src/core/command-generation/adapters/pi.ts\`
- \`src/core/command-generation/adapters/claude.ts\`
- \`src/core/command-generation/adapters/qwen.ts\`

There is also a known detect/escape asymmetry: \`\\r\` (carriage return) is correctly detected as requiring YAML quoting (via the regex), but is **not** escaped in the double-quoted string replace chain. This gap exists consistently across all three siblings.

## Proposed Work

1. Extract \`escapeYamlValue\` into a shared utility (e.g., \`src/core/command-generation/utils/yaml.ts\` or similar).
2. Add \`\r → \\\\r\` escaping in the shared implementation so double-quoted YAML strings fully conform to YAML 1.2.
3. Update \`pi.ts\`, \`claude.ts\`, and \`qwen.ts\` to import and use the shared helper instead of their local copies.
4. Add or update unit tests for the shared utility to cover the \`\\r\` case.

## Context

- Discussed in PR `#1191` (comment: https://github.com/Fission-AI/OpenSpec/pull/1191#discussion_r3383171623)
- The \`\\r\` fix was intentionally deferred from PR `#1191` to avoid diverging the qwen adapter copy from its siblings before a proper shared-util refactor.

**Requested by:** `@mc856`
"

Length of output: 3193


🏁 Script executed:

gh issue create \
  --repo Fission-AI/OpenSpec \
  --title "refactor(adapters): extract escapeYamlValue into shared util and fix \\r escape gap" \
  --body "## Summary

The \`escapeYamlValue\` helper is currently duplicated verbatim across three adapter files:

- \`src/core/command-generation/adapters/pi.ts\`
- \`src/core/command-generation/adapters/claude.ts\`
- \`src/core/command-generation/adapters/qwen.ts\`

There is also a known detect/escape asymmetry: \`\\r\` (carriage return) is correctly detected as requiring YAML quoting (via the regex), but is **not** escaped in the double-quoted string replace chain. This gap exists consistently across all three siblings.

## Proposed Work

1. Extract \`escapeYamlValue\` into a shared utility (e.g., \`src/core/command-generation/utils/yaml.ts\` or similar).
2. Add \`\r → \\\\r\` escaping in the shared implementation so double-quoted YAML strings fully conform to YAML 1.2.
3. Update \`pi.ts\`, \`claude.ts\`, and \`qwen.ts\` to import and use the shared helper instead of their local copies.
4. Add or update unit tests for the shared utility to cover the \`\\r\` case.

## Context

- Discussed in PR `#1191` (comment: https://github.com/Fission-AI/OpenSpec/pull/1191#discussion_r3383171623)
- The \`\\r\` fix was intentionally deferred from PR `#1191` to avoid diverging the qwen adapter copy from its siblings before a proper shared-util refactor.

**Requested by:** `@mc856`
"

Length of output: 1527



@mc856, the follow-up issue has been filed: #1205

It tracks:

  1. Extracting escapeYamlValue into a shared utility (e.g., src/core/command-generation/utils/yaml.ts)
  2. Adding \r → \\r escaping in that shared implementation for YAML 1.2 compliance
  3. Updating pi.ts, claude.ts, and qwen.ts to consume the shared helper
  4. Adding/updating unit tests to cover the \r case

Note: the integration couldn't set the assignee automatically — you may want to self-assign it via the GitHub UI.


🧬 Code Graph Analysis Results

Relevant code snippets to understand src/core/command-generation/adapters/qwen.ts and its inputs/outputs:

  1. src/core/command-generation/types.ts (lines 12-25)
export interface CommandContent {
  /** Command identifier (e.g., 'explore', 'apply', 'new') */
  id: string;
  /** Human-readable name (e.g., 'OpenSpec Explore') */
  name: string;
  /** Brief description of command purpose */
  description: string;
  /** Grouping category (e.g., 'Workflow') */
  category: string;
  /** Array of tag strings */
  tags: string[];
  /** The command instruction content (body text) */
  body: string;
}
  1. src/core/command-generation/types.ts (lines 32-48)
export interface ToolCommandAdapter {
  /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */
  toolId: string;
  /**
   * Returns the file path for a command.
   * `@param` commandId - The command identifier (e.g., 'explore')
   * `@returns` Path from project root (e.g., '.claude/commands/opsx/explore.md').
   *          May be absolute for tools with global-scoped prompts (e.g., Codex).
   */
  getFilePath(commandId: string): string;
  /**
   * Formats the complete file content including frontmatter.
   * `@param` content - The tool-agnostic command content
   * `@returns` Complete file content ready to write
   */
  formatFile(content: CommandContent): string;
}

🧠 Learnings used
Learnt from: mc856
Repo: Fission-AI/OpenSpec PR: 1191
File: src/core/command-generation/adapters/qwen.ts:18-27
Timestamp: 2026-06-09T19:17:10.070Z
Learning: In this repo, `escapeYamlValue` is intentionally duplicated in each adapter file (one implementation per adapter) rather than extracted to a shared util. When reviewing adapter files under `src/core/command-generation/adapters/`, do not require deduplication/refactoring of `escapeYamlValue` across siblings. Also note the known detect/escape asymmetry for `\r` (it is detected as needing quoting but is not escaped in the current replace chain) and that this behavior currently exists consistently across the adapter siblings; treat this as an existing, tracked gap and don’t insist on fixing it unless the change is part of the planned refactor and updated consistently for all adapters.


/**
* Qwen adapter for command generation.
* File path: .qwen/commands/opsx-<id>.toml
* Format: TOML with description and prompt fields
* File path: .qwen/commands/opsx-<id>.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}
"""
`;
},
};
2 changes: 1 addition & 1 deletion src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
'qwen': { type: 'files', pattern: ['.qwen/commands/opsx-*.toml', '.qwen/commands/openspec-*.toml'] },
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
};

Expand Down
20 changes: 14 additions & 6 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,17 +576,25 @@ 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 escape special YAML characters in description', () => {
const output = qwenAdapter.formatFile({
...sampleContent,
description: 'Review: plan & apply "changes"',
});
expect(output).toContain('description: "Review: plan & apply \\"changes\\""');
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions test/core/legacy-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
8 changes: 4 additions & 4 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,19 +314,19 @@ Old instructions content

await updateCommand.execute(testDir);

// Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-<id>.toml
// Check Qwen command format (Markdown) - Qwen uses flat path structure: opsx-<id>.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 () => {
Expand Down