diff --git a/README.md b/README.md index 6866d360..9161f1af 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) | | **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) | +| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) | | **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) | | **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) | | **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) | @@ -144,7 +145,7 @@ openspec init ``` **What happens during initialization:** -- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub +- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub - OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root - A new `openspec/` directory structure is created in your project @@ -230,7 +231,7 @@ Or run the command yourself in terminal: $ openspec archive add-profile-filters --yes # Archive the completed change without prompts ``` -**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change". +**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change". ## Command Reference diff --git a/src/core/config.ts b/src/core/config.ts index e3e9c656..960b8aff 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -27,6 +27,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' }, + { name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' }, { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' }, { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' }, { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' }, diff --git a/src/core/configurators/qoder.ts b/src/core/configurators/qoder.ts new file mode 100644 index 00000000..db5e6dc9 --- /dev/null +++ b/src/core/configurators/qoder.ts @@ -0,0 +1,53 @@ +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'; + +/** + * Qoder AI Tool Configurator + * + * Configures OpenSpec integration for Qoder AI coding assistant. + * Creates and manages QODER.md configuration file with OpenSpec instructions. + * + * @implements {ToolConfigurator} + */ +export class QoderConfigurator implements ToolConfigurator { + /** Display name for the Qoder tool */ + name = 'Qoder'; + + /** Configuration file name at project root */ + configFileName = 'QODER.md'; + + /** Indicates tool is available for configuration */ + isAvailable = true; + + /** + * Configure Qoder integration for a project + * + * Creates or updates QODER.md file with OpenSpec instructions. + * Uses Claude-compatible template for instruction content. + * Wrapped with OpenSpec markers for future updates. + * + * @param {string} projectPath - Absolute path to project root directory + * @param {string} openspecDir - Path to openspec directory (unused but required by interface) + * @returns {Promise} Resolves when configuration is complete + */ + async configure(projectPath: string, openspecDir: string): Promise { + // Construct full path to QODER.md at project root + const filePath = path.join(projectPath, this.configFileName); + + // Get Claude-compatible instruction template + // This ensures Qoder receives the same high-quality OpenSpec instructions + const content = TemplateManager.getClaudeTemplate(); + + // Write or update file with managed content between markers + // This allows future updates to refresh instructions automatically + await FileSystemUtils.updateFileWithMarkers( + filePath, + content, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); + } +} diff --git a/src/core/configurators/registry.ts b/src/core/configurators/registry.ts index fedc8ff0..be7763c6 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -3,6 +3,7 @@ import { ClaudeConfigurator } from './claude.js'; import { ClineConfigurator } from './cline.js'; import { CodeBuddyConfigurator } from './codebuddy.js'; import { CostrictConfigurator } from './costrict.js'; +import { QoderConfigurator } from './qoder.js'; import { AgentsStandardConfigurator } from './agents.js'; export class ToolRegistry { @@ -13,12 +14,14 @@ export class ToolRegistry { const clineConfigurator = new ClineConfigurator(); const codeBuddyConfigurator = new CodeBuddyConfigurator(); const costrictConfigurator = new CostrictConfigurator(); + const qoderConfigurator = new QoderConfigurator(); const agentsConfigurator = new AgentsStandardConfigurator(); // Register with the ID that matches the checkbox value this.tools.set('claude', claudeConfigurator); this.tools.set('cline', clineConfigurator); this.tools.set('codebuddy', codeBuddyConfigurator); this.tools.set('costrict', costrictConfigurator); + this.tools.set('qoder', qoderConfigurator); this.tools.set('agents', agentsConfigurator); } diff --git a/src/core/configurators/slash/qoder.ts b/src/core/configurators/slash/qoder.ts new file mode 100644 index 00000000..f147e08c --- /dev/null +++ b/src/core/configurators/slash/qoder.ts @@ -0,0 +1,84 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +/** + * File paths for Qoder slash commands + * Maps each OpenSpec workflow stage to its command file location + * Commands are stored in .qoder/commands/openspec/ directory + */ +const FILE_PATHS: Record = { + // Create and validate new change proposals + proposal: '.qoder/commands/openspec/proposal.md', + + // Implement approved changes with task tracking + apply: '.qoder/commands/openspec/apply.md', + + // Archive completed changes and update specs + archive: '.qoder/commands/openspec/archive.md' +}; + +/** + * YAML frontmatter for Qoder slash commands + * Defines metadata displayed in Qoder's command palette + * Each command is categorized and tagged for easy discovery + */ +const FRONTMATTER: Record = { + proposal: `--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +---`, + apply: `--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +---`, + archive: `--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +---` +}; + +/** + * Qoder Slash Command Configurator + * + * Manages OpenSpec slash commands for Qoder AI assistant. + * Creates three workflow commands: proposal, apply, and archive. + * Uses colon-separated command format (/openspec:proposal). + * + * @extends {SlashCommandConfigurator} + */ +export class QoderSlashCommandConfigurator extends SlashCommandConfigurator { + /** Unique identifier for Qoder tool */ + readonly toolId = 'qoder'; + + /** Indicates slash commands are available for this tool */ + readonly isAvailable = true; + + /** + * Get relative file path for a slash command + * + * @param {SlashCommandId} id - Command identifier (proposal, apply, or archive) + * @returns {string} Relative path from project root to command file + */ + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + /** + * Get YAML frontmatter for a slash command + * + * Frontmatter defines how the command appears in Qoder's UI, + * including display name, description, and categorization. + * + * @param {SlashCommandId} id - Command identifier (proposal, apply, or archive) + * @returns {string} YAML frontmatter block with command metadata + */ + 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 7c1b36fc..2e03835f 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -1,6 +1,7 @@ import { SlashCommandConfigurator } from './base.js'; import { ClaudeSlashCommandConfigurator } from './claude.js'; import { CodeBuddySlashCommandConfigurator } from './codebuddy.js'; +import { QoderSlashCommandConfigurator } from './qoder.js'; import { CursorSlashCommandConfigurator } from './cursor.js'; import { WindsurfSlashCommandConfigurator } from './windsurf.js'; import { KiloCodeSlashCommandConfigurator } from './kilocode.js'; @@ -20,6 +21,7 @@ export class SlashCommandRegistry { static { const claude = new ClaudeSlashCommandConfigurator(); const codeBuddy = new CodeBuddySlashCommandConfigurator(); + const qoder = new QoderSlashCommandConfigurator(); const cursor = new CursorSlashCommandConfigurator(); const windsurf = new WindsurfSlashCommandConfigurator(); const kilocode = new KiloCodeSlashCommandConfigurator(); @@ -35,6 +37,7 @@ export class SlashCommandRegistry { this.configurators.set(claude.toolId, claude); this.configurators.set(codeBuddy.toolId, codeBuddy); + this.configurators.set(qoder.toolId, qoder); this.configurators.set(cursor.toolId, cursor); this.configurators.set(windsurf.toolId, windsurf); this.configurators.set(kilocode.toolId, kilocode); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index fcf0fab3..0c323846 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1050,6 +1050,61 @@ describe('InitCommand', () => { expect(costrictChoice.configured).toBe(true); }); + it('should create Qoder slash command files with templates', async () => { + queueSelections('qoder', DONE); + + await initCommand.execute(testDir); + + const qoderProposal = path.join( + testDir, + '.qoder/commands/openspec/proposal.md' + ); + const qoderApply = path.join( + testDir, + '.qoder/commands/openspec/apply.md' + ); + const qoderArchive = path.join( + testDir, + '.qoder/commands/openspec/archive.md' + ); + + expect(await fileExists(qoderProposal)).toBe(true); + expect(await fileExists(qoderApply)).toBe(true); + expect(await fileExists(qoderArchive)).toBe(true); + + const proposalContent = await fs.readFile(qoderProposal, 'utf-8'); + expect(proposalContent).toContain('---'); + expect(proposalContent).toContain('name: OpenSpec: Proposal'); + expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain('category: OpenSpec'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(qoderApply, 'utf-8'); + expect(applyContent).toContain('---'); + expect(applyContent).toContain('name: OpenSpec: Apply'); + 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(qoderArchive, 'utf-8'); + expect(archiveContent).toContain('---'); + expect(archiveContent).toContain('name: OpenSpec: Archive'); + expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('openspec archive --yes'); + }); + + it('should mark Qoder as already configured during extend mode', async () => { + queueSelections('qoder', DONE, 'qoder', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const qoderChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'qoder' + ); + expect(qoderChoice.configured).toBe(true); + }); + it('should create COSTRICT.md when CoStrict is selected', async () => { queueSelections('costrict', DONE); @@ -1065,6 +1120,21 @@ describe('InitCommand', () => { expect(content).toContain(''); }); + it('should create QODER.md when Qoder is selected', async () => { + queueSelections('qoder', DONE); + + await initCommand.execute(testDir); + + const qoderPath = path.join(testDir, 'QODER.md'); + expect(await fileExists(qoderPath)).toBe(true); + + const content = await fs.readFile(qoderPath, 'utf-8'); + expect(content).toContain(''); + expect(content).toContain("@/openspec/AGENTS.md"); + expect(content).toContain('openspec update'); + expect(content).toContain(''); + }); + it('should update existing COSTRICT.md with markers', async () => { queueSelections('costrict', DONE); @@ -1077,6 +1147,22 @@ describe('InitCommand', () => { const updatedContent = await fs.readFile(costrictPath, 'utf-8'); expect(updatedContent).toContain(''); + expect(updatedContent).toContain('# My CoStrict Instructions'); + expect(updatedContent).toContain('Custom instructions here'); + }); + + it('should update existing QODER.md with markers', async () => { + queueSelections('qoder', DONE); + + const qoderPath = path.join(testDir, 'QODER.md'); + const existingContent = + '# My Qoder Instructions\nCustom instructions here'; + await fs.writeFile(qoderPath, existingContent); + + await initCommand.execute(testDir); + + const updatedContent = await fs.readFile(qoderPath, 'utf-8'); + expect(updatedContent).toContain(''); expect(updatedContent).toContain("@/openspec/AGENTS.md"); expect(updatedContent).toContain('openspec update'); expect(updatedContent).toContain(''); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index a1c9f1d2..9b4a91f6 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -860,6 +860,47 @@ Old body consoleSpy.mockRestore(); }); + it('should refresh existing Qoder slash command files', async () => { + const qoderPath = path.join( + testDir, + '.qoder/commands/openspec/proposal.md' + ); + await fs.mkdir(path.dirname(qoderPath), { recursive: true }); + const initialContent = `--- +name: OpenSpec: Proposal +description: Old description +category: OpenSpec +tags: [openspec, change] +--- + +Old slash content +`; + await fs.writeFile(qoderPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(qoderPath, 'utf-8'); + expect(updated).toContain('name: OpenSpec: Proposal'); + expect(updated).toContain('**Guardrails**'); + expect(updated).toContain( + 'Validate with `openspec validate --strict`' + ); + expect(updated).not.toContain('Old slash content'); + + 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: .qoder/commands/openspec/proposal.md' + ); + + consoleSpy.mockRestore(); + }); + it('should not create missing CoStrict slash command files on update', async () => { const costrictApply = path.join( testDir, @@ -895,6 +936,43 @@ Old await expect(FileSystemUtils.fileExists(costrictArchive)).resolves.toBe(false); }); + it('should not create missing Qoder slash command files on update', async () => { + const qoderApply = path.join( + testDir, + '.qoder/commands/openspec/apply.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(qoderApply), { recursive: true }); + await fs.writeFile( + qoderApply, + `--- +name: OpenSpec: Apply +description: Old description +category: OpenSpec +tags: [openspec, apply] +--- + +Old body +` + ); + + await updateCommand.execute(testDir); + + const qoderProposal = path.join( + testDir, + '.qoder/commands/openspec/proposal.md' + ); + const qoderArchive = path.join( + testDir, + '.qoder/commands/openspec/archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(qoderProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(qoderArchive)).resolves.toBe(false); + }); + it('should update only existing COSTRICT.md file', async () => { // Create COSTRICT.md file with initial content const costrictPath = path.join(testDir, 'COSTRICT.md');