Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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
36 changes: 28 additions & 8 deletions src/core/command-generation/adapters/qwen.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function getLegacyQwenTomlFilePath(commandId: string): string {
return path.join('.qwen', 'commands', `opsx-${commandId}.toml`);
}

/**
* 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
* 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}
`;
},
};
37 changes: 37 additions & 0 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
generateCommands,
CommandAdapterRegistry,
} from './command-generation/index.js';
import { getLegacyQwenTomlFilePath } from './command-generation/adapters/qwen.js';
import {
detectLegacyArtifacts,
cleanupLegacyArtifacts,
Expand Down Expand Up @@ -501,13 +502,15 @@ export class InitCommand {
commandsSkipped: string[];
removedCommandCount: number;
removedSkillCount: number;
removedObsoleteCommandCount: number;
}> {
const createdTools: typeof tools = [];
const refreshedTools: typeof tools = [];
const failedTools: Array<{ name: string; error: Error }> = [];
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();
Expand Down Expand Up @@ -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}`);
Expand All @@ -588,6 +599,7 @@ export class InitCommand {
commandsSkipped,
removedCommandCount,
removedSkillCount,
removedObsoleteCommandCount,
};
}

Expand Down Expand Up @@ -633,6 +645,7 @@ export class InitCommand {
commandsSkipped: string[];
removedCommandCount: number;
removedSkillCount: number;
removedObsoleteCommandCount: number;
},
configStatus: 'created' | 'exists' | 'skipped'
): void {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -776,4 +792,25 @@ export class InitCommand {

return removed;
}

private async removeObsoleteCommandFiles(projectPath: string, toolId: string): Promise<number> {
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;
}
}
37 changes: 37 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
generateCommands,
CommandAdapterRegistry,
} from './command-generation/index.js';
import { getLegacyQwenTomlFilePath } from './command-generation/adapters/qwen.js';
import {
getToolVersionStatus,
getSkillTemplates,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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}`);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -479,6 +492,30 @@ export class UpdateCommand {
return removed;
}

private async removeObsoleteCommandFiles(
projectPath: string,
toolId: string,
): Promise<number> {
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.
Expand Down
45 changes: 39 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,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"');
});
});

Expand Down
4 changes: 2 additions & 2 deletions test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 14 additions & 4 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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('---\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 () => {
Expand Down