Skip to content

Commit 8f9c3c7

Browse files
AndersHsuehqwencoderTabishB
authored
feat: add Qwen Code support with slash command integration (#250)
* feat: add Qwen Code support with slash command integration - Add QwenSlashCommandConfigurator for .qwen/commands/ structure - Add QwenConfigurator to main registry - Update README.md to include Qwen Code in supported tools list - Register Qwen in both slash command and main tool registries - Implement proper YAML frontmatter for Qwen command files - Follow OpenSpec's established patterns for AI tool integration Co-authored-by: Qwen-Coder <[email protected]> * docs: add docstrings and fix review comments for Qwen Code support - Add comprehensive JSDoc comments to Qwen configurator files - Add Qwen Code entry to README_CN.md support table - Fix file ending newline in src/core/configurators/qwen.ts - Address CodeRabbit review suggestions for improved documentation coverage Co-authored-by: Qwen-Coder <[email protected]> * fix: add language identifier to markdown code block in supportQwen.md Co-authored-by: Qwen-Coder <[email protected]> * fix: address CodeRabbit review comments - parameter naming fix - Fix unused parameter naming convention (_openspecDir) - Keep only necessary changes for Qwen Code support Co-authored-by: Qwen-Coder <[email protected]> * chore: add personal notes files to .gitignore Co-authored-by: Qwen-Coder <[email protected]> * chore: remove personal notes files from git tracking - Remove README_CN.md and supportQwen.md from git tracking - These files are now ignored via .gitignore - Keep only necessary files for the OpenSpec project Co-authored-by: Qwen-Coder <[email protected]> * fix: remove duplicate JSDoc comment in QwenConfigurator - Remove duplicate documentation comment for configure method - Keep only the updated comment that correctly documents the unused parameter Co-authored-by: Qwen-Coder <[email protected]> * test: cover qwen configurators * chore: revert gitignore changes * test: extend qwen init coverage --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: Tabish Bidiwale <[email protected]>
1 parent 9cdb074 commit 8f9c3c7

File tree

11 files changed

+341
-7
lines changed

11 files changed

+341
-7
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,4 @@ CLAUDE.md
149149
.DS_Store
150150

151151
# Pnpm
152-
.pnpm-store/
152+
.pnpm-store/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
105105
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
106106
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
107107
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
108+
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
108109

109110

110111
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@changesets/cli": "^2.27.7",
6060
"@types/node": "^24.2.0",
6161
"@vitest/ui": "^3.2.4",
62-
"typescript": "^5.9.2",
62+
"typescript": "^5.9.3",
6363
"vitest": "^3.2.4"
6464
},
6565
"dependencies": {

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ export const AI_TOOLS: AIToolOption[] = [
3232
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
3333
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
3434
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
35+
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
3536
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
3637
];

src/core/configurators/qwen.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Qwen Code configurator for OpenSpec integration.
3+
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
4+
*
5+
* @implements {ToolConfigurator}
6+
*/
7+
import path from 'path';
8+
import { ToolConfigurator } from './base.js';
9+
import { FileSystemUtils } from '../../utils/file-system.js';
10+
import { TemplateManager } from '../templates/index.js';
11+
import { OPENSPEC_MARKERS } from '../config.js';
12+
13+
/**
14+
* QwenConfigurator class provides integration with Qwen Code
15+
* by creating and managing the necessary configuration files.
16+
* Currently configures the QWEN.md file with OpenSpec instructions.
17+
*/
18+
export class QwenConfigurator implements ToolConfigurator {
19+
/** Display name for the Qwen Code tool */
20+
name = 'Qwen Code';
21+
22+
/** Configuration file name for Qwen Code */
23+
configFileName = 'QWEN.md';
24+
25+
/** Availability status for the Qwen Code tool */
26+
isAvailable = true;
27+
28+
/**
29+
* Configures the Qwen Code integration by creating or updating the QWEN.md file
30+
* with OpenSpec instructions and markers.
31+
*
32+
* @param {string} projectPath - The path to the project root
33+
* @param {string} _openspecDir - The path to the openspec directory (unused)
34+
* @returns {Promise<void>} A promise that resolves when configuration is complete
35+
*/
36+
async configure(projectPath: string, _openspecDir: string): Promise<void> {
37+
const filePath = path.join(projectPath, this.configFileName);
38+
const content = TemplateManager.getAgentsStandardTemplate();
39+
40+
await FileSystemUtils.updateFileWithMarkers(
41+
filePath,
42+
content,
43+
OPENSPEC_MARKERS.start,
44+
OPENSPEC_MARKERS.end
45+
);
46+
}
47+
}

src/core/configurators/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CodeBuddyConfigurator } from './codebuddy.js';
55
import { CostrictConfigurator } from './costrict.js';
66
import { QoderConfigurator } from './qoder.js';
77
import { AgentsStandardConfigurator } from './agents.js';
8+
import { QwenConfigurator } from './qwen.js';
89

910
export class ToolRegistry {
1011
private static tools: Map<string, ToolConfigurator> = new Map();
@@ -16,13 +17,15 @@ export class ToolRegistry {
1617
const costrictConfigurator = new CostrictConfigurator();
1718
const qoderConfigurator = new QoderConfigurator();
1819
const agentsConfigurator = new AgentsStandardConfigurator();
20+
const qwenConfigurator = new QwenConfigurator();
1921
// Register with the ID that matches the checkbox value
2022
this.tools.set('claude', claudeConfigurator);
2123
this.tools.set('cline', clineConfigurator);
2224
this.tools.set('codebuddy', codeBuddyConfigurator);
2325
this.tools.set('costrict', costrictConfigurator);
2426
this.tools.set('qoder', qoderConfigurator);
2527
this.tools.set('agents', agentsConfigurator);
28+
this.tools.set('qwen', qwenConfigurator);
2629
}
2730

2831
static register(tool: ToolConfigurator): void {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Qwen slash command configurator for OpenSpec integration.
3+
* This class handles the generation of Qwen-specific slash command files
4+
* in the .qwen/commands directory structure.
5+
*
6+
* @implements {SlashCommandConfigurator}
7+
*/
8+
import { SlashCommandConfigurator } from './base.js';
9+
import { SlashCommandId } from '../../templates/index.js';
10+
11+
/**
12+
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
13+
* @type {Record<SlashCommandId, string>}
14+
*/
15+
const FILE_PATHS: Record<SlashCommandId, string> = {
16+
proposal: '.qwen/commands/openspec-proposal.md',
17+
apply: '.qwen/commands/openspec-apply.md',
18+
archive: '.qwen/commands/openspec-archive.md'
19+
};
20+
21+
/**
22+
* YAML frontmatter definitions for Qwen command files.
23+
* These provide metadata for each slash command to ensure proper recognition by Qwen Code.
24+
* @type {Record<SlashCommandId, string>}
25+
*/
26+
const FRONTMATTER: Record<SlashCommandId, string> = {
27+
proposal: `---
28+
name: /openspec-proposal
29+
id: openspec-proposal
30+
category: OpenSpec
31+
description: Scaffold a new OpenSpec change and validate strictly.
32+
---`,
33+
apply: `---
34+
name: /openspec-apply
35+
id: openspec-apply
36+
category: OpenSpec
37+
description: Implement an approved OpenSpec change and keep tasks in sync.
38+
---`,
39+
archive: `---
40+
name: /openspec-archive
41+
id: openspec-archive
42+
category: OpenSpec
43+
description: Archive a deployed OpenSpec change and update specs.
44+
---`
45+
};
46+
47+
/**
48+
* QwenSlashCommandConfigurator class provides integration with Qwen Code
49+
* by creating the necessary slash command files in the .qwen/commands directory.
50+
*
51+
* The slash commands include:
52+
* - /openspec-proposal: Create an OpenSpec change proposal
53+
* - /openspec-apply: Apply an approved OpenSpec change
54+
* - /openspec-archive: Archive a deployed OpenSpec change
55+
*/
56+
export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
57+
/** Unique identifier for the Qwen tool */
58+
readonly toolId = 'qwen';
59+
60+
/** Availability status for the Qwen tool */
61+
readonly isAvailable = true;
62+
63+
/**
64+
* Returns the relative file path for a given slash command ID.
65+
* @param {SlashCommandId} id - The slash command identifier
66+
* @returns {string} The relative path to the command file
67+
*/
68+
protected getRelativePath(id: SlashCommandId): string {
69+
return FILE_PATHS[id];
70+
}
71+
72+
/**
73+
* Returns the YAML frontmatter for a given slash command ID.
74+
* @param {SlashCommandId} id - The slash command identifier
75+
* @returns {string} The YAML frontmatter string
76+
*/
77+
protected getFrontmatter(id: SlashCommandId): string {
78+
return FRONTMATTER[id];
79+
}
80+
}

src/core/configurators/slash/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AuggieSlashCommandConfigurator } from './auggie.js';
1414
import { ClineSlashCommandConfigurator } from './cline.js';
1515
import { CrushSlashCommandConfigurator } from './crush.js';
1616
import { CostrictSlashCommandConfigurator } from './costrict.js';
17+
import { QwenSlashCommandConfigurator } from './qwen.js';
1718

1819
export class SlashCommandRegistry {
1920
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -34,6 +35,7 @@ export class SlashCommandRegistry {
3435
const cline = new ClineSlashCommandConfigurator();
3536
const crush = new CrushSlashCommandConfigurator();
3637
const costrict = new CostrictSlashCommandConfigurator();
38+
const qwen = new QwenSlashCommandConfigurator();
3739

3840
this.configurators.set(claude.toolId, claude);
3941
this.configurators.set(codeBuddy.toolId, codeBuddy);
@@ -50,6 +52,7 @@ export class SlashCommandRegistry {
5052
this.configurators.set(cline.toolId, cline);
5153
this.configurators.set(crush.toolId, crush);
5254
this.configurators.set(costrict.toolId, costrict);
55+
this.configurators.set(qwen.toolId, qwen);
5356
}
5457

5558
static register(configurator: SlashCommandConfigurator): void {

test/core/init.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,71 @@ describe('InitCommand', () => {
349349
expect(archiveContent).toContain('openspec list --specs');
350350
});
351351

352+
it('should create Qwen configuration and slash command files with templates', async () => {
353+
queueSelections('qwen', DONE);
354+
355+
await initCommand.execute(testDir);
356+
357+
const qwenConfigPath = path.join(testDir, 'QWEN.md');
358+
const proposalPath = path.join(
359+
testDir,
360+
'.qwen/commands/openspec-proposal.md'
361+
);
362+
const applyPath = path.join(
363+
testDir,
364+
'.qwen/commands/openspec-apply.md'
365+
);
366+
const archivePath = path.join(
367+
testDir,
368+
'.qwen/commands/openspec-archive.md'
369+
);
370+
371+
expect(await fileExists(qwenConfigPath)).toBe(true);
372+
expect(await fileExists(proposalPath)).toBe(true);
373+
expect(await fileExists(applyPath)).toBe(true);
374+
expect(await fileExists(archivePath)).toBe(true);
375+
376+
const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8');
377+
expect(qwenConfigContent).toContain('<!-- OPENSPEC:START -->');
378+
expect(qwenConfigContent).toContain("@/openspec/AGENTS.md");
379+
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
380+
381+
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
382+
expect(proposalContent).toContain('name: /openspec-proposal');
383+
expect(proposalContent).toContain('category: OpenSpec');
384+
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
385+
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
386+
387+
const applyContent = await fs.readFile(applyPath, 'utf-8');
388+
expect(applyContent).toContain('name: /openspec-apply');
389+
expect(applyContent).toContain('category: OpenSpec');
390+
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
391+
expect(applyContent).toContain('Work through tasks sequentially');
392+
393+
const archiveContent = await fs.readFile(archivePath, 'utf-8');
394+
expect(archiveContent).toContain('name: /openspec-archive');
395+
expect(archiveContent).toContain('category: OpenSpec');
396+
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
397+
expect(archiveContent).toContain('openspec archive <id>');
398+
});
399+
400+
it('should update existing QWEN.md with markers', async () => {
401+
queueSelections('qwen', DONE);
402+
403+
const qwenPath = path.join(testDir, 'QWEN.md');
404+
const existingContent = '# My Qwen Instructions\nCustom instructions here';
405+
await fs.writeFile(qwenPath, existingContent);
406+
407+
await initCommand.execute(testDir);
408+
409+
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
410+
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
411+
expect(updatedContent).toContain("@/openspec/AGENTS.md");
412+
expect(updatedContent).toContain('openspec update');
413+
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
414+
expect(updatedContent).toContain('Custom instructions here');
415+
});
416+
352417
it('should create Cline rule files with templates', async () => {
353418
queueSelections('cline', DONE);
354419

@@ -688,6 +753,18 @@ describe('InitCommand', () => {
688753
expect(claudeChoice.configured).toBe(true);
689754
});
690755

756+
it('should mark Qwen as already configured during extend mode', async () => {
757+
queueSelections('qwen', DONE, 'qwen', DONE);
758+
await initCommand.execute(testDir);
759+
await initCommand.execute(testDir);
760+
761+
const secondRunArgs = mockPrompt.mock.calls[1][0];
762+
const qwenChoice = secondRunArgs.choices.find(
763+
(choice: any) => choice.value === 'qwen'
764+
);
765+
expect(qwenChoice.configured).toBe(true);
766+
});
767+
691768
it('should preselect Kilo Code when workflows already exist', async () => {
692769
queueSelections('kilocode', DONE, 'kilocode', DONE);
693770
await initCommand.execute(testDir);

0 commit comments

Comments
 (0)