diff --git a/.gitignore b/.gitignore index 5077cdaf..eec37bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -149,4 +149,4 @@ CLAUDE.md .DS_Store # Pnpm -.pnpm-store/ \ No newline at end of file +.pnpm-store/ diff --git a/README.md b/README.md index 9161f1af..48a8ea42 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) | | **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) | | **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) | +| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) | 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`. diff --git a/package.json b/package.json index e68a1634..e28e8f32 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@changesets/cli": "^2.27.7", "@types/node": "^24.2.0", "@vitest/ui": "^3.2.4", - "typescript": "^5.9.2", + "typescript": "^5.9.3", "vitest": "^3.2.4" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98aa17f3..3868d20e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ importers: specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) typescript: - specifier: ^5.9.2 - version: 5.9.2 + specifier: ^5.9.3 + version: 5.9.3 vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4) @@ -1115,8 +1115,8 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -2223,7 +2223,7 @@ snapshots: type-fest@0.21.3: {} - typescript@5.9.2: {} + typescript@5.9.3: {} undici-types@7.10.0: {} diff --git a/src/core/config.ts b/src/core/config.ts index 960b8aff..ba60a427 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -32,5 +32,6 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' }, { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' }, { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' }, + { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/src/core/configurators/qwen.ts b/src/core/configurators/qwen.ts new file mode 100644 index 00000000..417b6ab7 --- /dev/null +++ b/src/core/configurators/qwen.ts @@ -0,0 +1,47 @@ +/** + * Qwen Code configurator for OpenSpec integration. + * This class handles the configuration of Qwen Code as an AI tool within OpenSpec. + * + * @implements {ToolConfigurator} + */ +import path from 'path'; +import { ToolConfigurator } from './base.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { TemplateManager } from '../templates/index.js'; +import { OPENSPEC_MARKERS } from '../config.js'; + +/** + * QwenConfigurator class provides integration with Qwen Code + * by creating and managing the necessary configuration files. + * Currently configures the QWEN.md file with OpenSpec instructions. + */ +export class QwenConfigurator implements ToolConfigurator { + /** Display name for the Qwen Code tool */ + name = 'Qwen Code'; + + /** Configuration file name for Qwen Code */ + configFileName = 'QWEN.md'; + + /** Availability status for the Qwen Code tool */ + isAvailable = true; + + /** + * Configures the Qwen Code integration by creating or updating the QWEN.md file + * with OpenSpec instructions and markers. + * + * @param {string} projectPath - The path to the project root + * @param {string} _openspecDir - The path to the openspec directory (unused) + * @returns {Promise} A promise that resolves when configuration is complete + */ + async configure(projectPath: string, _openspecDir: string): Promise { + const filePath = path.join(projectPath, this.configFileName); + const content = TemplateManager.getAgentsStandardTemplate(); + + await FileSystemUtils.updateFileWithMarkers( + filePath, + content, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); + } +} \ No newline at end of file diff --git a/src/core/configurators/registry.ts b/src/core/configurators/registry.ts index be7763c6..b1be2072 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -5,6 +5,7 @@ import { CodeBuddyConfigurator } from './codebuddy.js'; import { CostrictConfigurator } from './costrict.js'; import { QoderConfigurator } from './qoder.js'; import { AgentsStandardConfigurator } from './agents.js'; +import { QwenConfigurator } from './qwen.js'; export class ToolRegistry { private static tools: Map = new Map(); @@ -16,6 +17,7 @@ export class ToolRegistry { const costrictConfigurator = new CostrictConfigurator(); const qoderConfigurator = new QoderConfigurator(); const agentsConfigurator = new AgentsStandardConfigurator(); + const qwenConfigurator = new QwenConfigurator(); // Register with the ID that matches the checkbox value this.tools.set('claude', claudeConfigurator); this.tools.set('cline', clineConfigurator); @@ -23,6 +25,7 @@ export class ToolRegistry { this.tools.set('costrict', costrictConfigurator); this.tools.set('qoder', qoderConfigurator); this.tools.set('agents', agentsConfigurator); + this.tools.set('qwen', qwenConfigurator); } static register(tool: ToolConfigurator): void { diff --git a/src/core/configurators/slash/qwen.ts b/src/core/configurators/slash/qwen.ts new file mode 100644 index 00000000..7bc1d9ba --- /dev/null +++ b/src/core/configurators/slash/qwen.ts @@ -0,0 +1,80 @@ +/** + * Qwen slash command configurator for OpenSpec integration. + * This class handles the generation of Qwen-specific slash command files + * in the .qwen/commands directory structure. + * + * @implements {SlashCommandConfigurator} + */ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +/** + * Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory. + * @type {Record} + */ +const FILE_PATHS: Record = { + proposal: '.qwen/commands/openspec-proposal.md', + apply: '.qwen/commands/openspec-apply.md', + archive: '.qwen/commands/openspec-archive.md' +}; + +/** + * YAML frontmatter definitions for Qwen command files. + * These provide metadata for each slash command to ensure proper recognition by Qwen Code. + * @type {Record} + */ +const FRONTMATTER: Record = { + proposal: `--- +name: /openspec-proposal +id: openspec-proposal +category: OpenSpec +description: Scaffold a new OpenSpec change and validate strictly. +---`, + apply: `--- +name: /openspec-apply +id: openspec-apply +category: OpenSpec +description: Implement an approved OpenSpec change and keep tasks in sync. +---`, + archive: `--- +name: /openspec-archive +id: openspec-archive +category: OpenSpec +description: Archive a deployed OpenSpec change and update specs. +---` +}; + +/** + * QwenSlashCommandConfigurator class provides integration with Qwen Code + * by creating the necessary slash command files in the .qwen/commands directory. + * + * The slash commands include: + * - /openspec-proposal: Create an OpenSpec change proposal + * - /openspec-apply: Apply an approved OpenSpec change + * - /openspec-archive: Archive a deployed OpenSpec change + */ +export class QwenSlashCommandConfigurator extends SlashCommandConfigurator { + /** Unique identifier for the Qwen tool */ + readonly toolId = 'qwen'; + + /** Availability status for the Qwen tool */ + readonly isAvailable = true; + + /** + * Returns the relative file path for a given slash command ID. + * @param {SlashCommandId} id - The slash command identifier + * @returns {string} The relative path to the command file + */ + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + /** + * Returns the YAML frontmatter for a given slash command ID. + * @param {SlashCommandId} id - The slash command identifier + * @returns {string} The YAML frontmatter string + */ + protected getFrontmatter(id: SlashCommandId): string { + return FRONTMATTER[id]; + } +} \ No newline at end of file diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index 2e03835f..1872b619 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -14,6 +14,7 @@ import { AuggieSlashCommandConfigurator } from './auggie.js'; import { ClineSlashCommandConfigurator } from './cline.js'; import { CrushSlashCommandConfigurator } from './crush.js'; import { CostrictSlashCommandConfigurator } from './costrict.js'; +import { QwenSlashCommandConfigurator } from './qwen.js'; export class SlashCommandRegistry { private static configurators: Map = new Map(); @@ -34,6 +35,7 @@ export class SlashCommandRegistry { const cline = new ClineSlashCommandConfigurator(); const crush = new CrushSlashCommandConfigurator(); const costrict = new CostrictSlashCommandConfigurator(); + const qwen = new QwenSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); this.configurators.set(codeBuddy.toolId, codeBuddy); @@ -50,6 +52,7 @@ export class SlashCommandRegistry { this.configurators.set(cline.toolId, cline); this.configurators.set(crush.toolId, crush); this.configurators.set(costrict.toolId, costrict); + this.configurators.set(qwen.toolId, qwen); } static register(configurator: SlashCommandConfigurator): void { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 0c323846..dad27726 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -349,6 +349,71 @@ describe('InitCommand', () => { expect(archiveContent).toContain('openspec list --specs'); }); + it('should create Qwen configuration and slash command files with templates', async () => { + queueSelections('qwen', DONE); + + await initCommand.execute(testDir); + + const qwenConfigPath = path.join(testDir, 'QWEN.md'); + const proposalPath = path.join( + testDir, + '.qwen/commands/openspec-proposal.md' + ); + const applyPath = path.join( + testDir, + '.qwen/commands/openspec-apply.md' + ); + const archivePath = path.join( + testDir, + '.qwen/commands/openspec-archive.md' + ); + + expect(await fileExists(qwenConfigPath)).toBe(true); + expect(await fileExists(proposalPath)).toBe(true); + expect(await fileExists(applyPath)).toBe(true); + expect(await fileExists(archivePath)).toBe(true); + + const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8'); + expect(qwenConfigContent).toContain(''); + expect(qwenConfigContent).toContain("@/openspec/AGENTS.md"); + expect(qwenConfigContent).toContain(''); + + const proposalContent = await fs.readFile(proposalPath, 'utf-8'); + expect(proposalContent).toContain('name: /openspec-proposal'); + expect(proposalContent).toContain('category: OpenSpec'); + expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain(''); + + const applyContent = await fs.readFile(applyPath, 'utf-8'); + expect(applyContent).toContain('name: /openspec-apply'); + expect(applyContent).toContain('category: OpenSpec'); + expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(archivePath, 'utf-8'); + expect(archiveContent).toContain('name: /openspec-archive'); + expect(archiveContent).toContain('category: OpenSpec'); + expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('openspec archive '); + }); + + it('should update existing QWEN.md with markers', async () => { + queueSelections('qwen', DONE); + + const qwenPath = path.join(testDir, 'QWEN.md'); + const existingContent = '# My Qwen Instructions\nCustom instructions here'; + await fs.writeFile(qwenPath, existingContent); + + await initCommand.execute(testDir); + + const updatedContent = await fs.readFile(qwenPath, 'utf-8'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain("@/openspec/AGENTS.md"); + expect(updatedContent).toContain('openspec update'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain('Custom instructions here'); + }); + it('should create Cline rule files with templates', async () => { queueSelections('cline', DONE); @@ -688,6 +753,18 @@ describe('InitCommand', () => { expect(claudeChoice.configured).toBe(true); }); + it('should mark Qwen as already configured during extend mode', async () => { + queueSelections('qwen', DONE, 'qwen', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const qwenChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'qwen' + ); + expect(qwenChoice.configured).toBe(true); + }); + it('should preselect Kilo Code when workflows already exist', async () => { queueSelections('kilocode', DONE, 'kilocode', DONE); await initCommand.execute(testDir); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 9b4a91f6..9bb4a2f0 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -73,6 +73,41 @@ More content after.`; consoleSpy.mockRestore(); }); + it('should update only existing QWEN.md file', async () => { + const qwenPath = path.join(testDir, 'QWEN.md'); + const initialContent = `# Qwen Instructions + +Some existing content. + + +Old OpenSpec content + + +More notes here.`; + await fs.writeFile(qwenPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updatedContent = await fs.readFile(qwenPath, 'utf-8'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain("@/openspec/AGENTS.md"); + expect(updatedContent).toContain('openspec update'); + expect(updatedContent).toContain('Some existing content.'); + expect(updatedContent).toContain('More notes here.'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated OpenSpec instructions (openspec/AGENTS.md' + ); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Updated AI tool files: QWEN.md'); + + consoleSpy.mockRestore(); + }); + it('should refresh existing Claude slash command files', async () => { const proposalPath = path.join( testDir, @@ -114,6 +149,87 @@ Old slash content consoleSpy.mockRestore(); }); + it('should refresh existing Qwen slash command files', async () => { + const applyPath = path.join( + testDir, + '.qwen/commands/openspec-apply.md' + ); + await fs.mkdir(path.dirname(applyPath), { recursive: true }); + const initialContent = `--- +name: /openspec-apply +id: openspec-apply +category: OpenSpec +description: Old description +--- + + +Old body +`; + await fs.writeFile(applyPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(applyPath, 'utf-8'); + expect(updated).toContain('name: /openspec-apply'); + expect(updated).toContain('category: OpenSpec'); + expect(updated).toContain(''); + expect(updated).toContain('Work through tasks sequentially'); + expect(updated).not.toContain('Old body'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated OpenSpec instructions (openspec/AGENTS.md' + ); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain( + 'Updated slash commands: .qwen/commands/openspec-apply.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing Qwen slash command files on update', async () => { + const applyPath = path.join( + testDir, + '.qwen/commands/openspec-apply.md' + ); + + await fs.mkdir(path.dirname(applyPath), { recursive: true }); + await fs.writeFile( + applyPath, + `--- +name: /openspec-apply +id: openspec-apply +category: OpenSpec +description: Old description +--- + + +Old content +` + ); + + await updateCommand.execute(testDir); + + const updatedApply = await fs.readFile(applyPath, 'utf-8'); + expect(updatedApply).toContain('Work through tasks sequentially'); + expect(updatedApply).not.toContain('Old content'); + + const proposalPath = path.join( + testDir, + '.qwen/commands/openspec-proposal.md' + ); + const archivePath = path.join( + testDir, + '.qwen/commands/openspec-archive.md' + ); + + await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(archivePath)).resolves.toBe(false); + }); + it('should not create CLAUDE.md if it does not exist', async () => { // Ensure CLAUDE.md does not exist const claudePath = path.join(testDir, 'CLAUDE.md'); @@ -126,6 +242,12 @@ Old slash content expect(fileExists).toBe(false); }); + it('should not create QWEN.md if it does not exist', async () => { + const qwenPath = path.join(testDir, 'QWEN.md'); + await updateCommand.execute(testDir); + await expect(FileSystemUtils.fileExists(qwenPath)).resolves.toBe(false); + }); + it('should update only existing CLINE.md file', async () => { // Create CLINE.md file with initial content const clinePath = path.join(testDir, 'CLINE.md');