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
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