Skip to content

Commit 4e93d7a

Browse files
KUTEJiangpengjiahan.pjh
andauthored
feat: add Qoder CLI support to configuration and documentation (#261)
Co-authored-by: pengjiahan.pjh <[email protected]>
1 parent c4b6be4 commit 4e93d7a

File tree

8 files changed

+311
-2
lines changed

8 files changed

+311
-2
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
9999
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
100100
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
101101
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
102+
| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
102103
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
103104
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
104105
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
@@ -144,7 +145,7 @@ openspec init
144145
```
145146

146147
**What happens during initialization:**
147-
- 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
148+
- 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
148149
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
149150
- A new `openspec/` directory structure is created in your project
150151

@@ -230,7 +231,7 @@ Or run the command yourself in terminal:
230231
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
231232
```
232233

233-
**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".
234+
**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".
234235

235236
## Command Reference
236237

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const AI_TOOLS: AIToolOption[] = [
2727
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
2828
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
2929
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
30+
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
3031
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
3132
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
3233
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },

src/core/configurators/qoder.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import path from 'path';
2+
import { ToolConfigurator } from './base.js';
3+
import { FileSystemUtils } from '../../utils/file-system.js';
4+
import { TemplateManager } from '../templates/index.js';
5+
import { OPENSPEC_MARKERS } from '../config.js';
6+
7+
/**
8+
* Qoder AI Tool Configurator
9+
*
10+
* Configures OpenSpec integration for Qoder AI coding assistant.
11+
* Creates and manages QODER.md configuration file with OpenSpec instructions.
12+
*
13+
* @implements {ToolConfigurator}
14+
*/
15+
export class QoderConfigurator implements ToolConfigurator {
16+
/** Display name for the Qoder tool */
17+
name = 'Qoder';
18+
19+
/** Configuration file name at project root */
20+
configFileName = 'QODER.md';
21+
22+
/** Indicates tool is available for configuration */
23+
isAvailable = true;
24+
25+
/**
26+
* Configure Qoder integration for a project
27+
*
28+
* Creates or updates QODER.md file with OpenSpec instructions.
29+
* Uses Claude-compatible template for instruction content.
30+
* Wrapped with OpenSpec markers for future updates.
31+
*
32+
* @param {string} projectPath - Absolute path to project root directory
33+
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
34+
* @returns {Promise<void>} Resolves when configuration is complete
35+
*/
36+
async configure(projectPath: string, openspecDir: string): Promise<void> {
37+
// Construct full path to QODER.md at project root
38+
const filePath = path.join(projectPath, this.configFileName);
39+
40+
// Get Claude-compatible instruction template
41+
// This ensures Qoder receives the same high-quality OpenSpec instructions
42+
const content = TemplateManager.getClaudeTemplate();
43+
44+
// Write or update file with managed content between markers
45+
// This allows future updates to refresh instructions automatically
46+
await FileSystemUtils.updateFileWithMarkers(
47+
filePath,
48+
content,
49+
OPENSPEC_MARKERS.start,
50+
OPENSPEC_MARKERS.end
51+
);
52+
}
53+
}

src/core/configurators/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ClaudeConfigurator } from './claude.js';
33
import { ClineConfigurator } from './cline.js';
44
import { CodeBuddyConfigurator } from './codebuddy.js';
55
import { CostrictConfigurator } from './costrict.js';
6+
import { QoderConfigurator } from './qoder.js';
67
import { AgentsStandardConfigurator } from './agents.js';
78

89
export class ToolRegistry {
@@ -13,12 +14,14 @@ export class ToolRegistry {
1314
const clineConfigurator = new ClineConfigurator();
1415
const codeBuddyConfigurator = new CodeBuddyConfigurator();
1516
const costrictConfigurator = new CostrictConfigurator();
17+
const qoderConfigurator = new QoderConfigurator();
1618
const agentsConfigurator = new AgentsStandardConfigurator();
1719
// Register with the ID that matches the checkbox value
1820
this.tools.set('claude', claudeConfigurator);
1921
this.tools.set('cline', clineConfigurator);
2022
this.tools.set('codebuddy', codeBuddyConfigurator);
2123
this.tools.set('costrict', costrictConfigurator);
24+
this.tools.set('qoder', qoderConfigurator);
2225
this.tools.set('agents', agentsConfigurator);
2326
}
2427

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { SlashCommandConfigurator } from './base.js';
2+
import { SlashCommandId } from '../../templates/index.js';
3+
4+
/**
5+
* File paths for Qoder slash commands
6+
* Maps each OpenSpec workflow stage to its command file location
7+
* Commands are stored in .qoder/commands/openspec/ directory
8+
*/
9+
const FILE_PATHS: Record<SlashCommandId, string> = {
10+
// Create and validate new change proposals
11+
proposal: '.qoder/commands/openspec/proposal.md',
12+
13+
// Implement approved changes with task tracking
14+
apply: '.qoder/commands/openspec/apply.md',
15+
16+
// Archive completed changes and update specs
17+
archive: '.qoder/commands/openspec/archive.md'
18+
};
19+
20+
/**
21+
* YAML frontmatter for Qoder slash commands
22+
* Defines metadata displayed in Qoder's command palette
23+
* Each command is categorized and tagged for easy discovery
24+
*/
25+
const FRONTMATTER: Record<SlashCommandId, string> = {
26+
proposal: `---
27+
name: OpenSpec: Proposal
28+
description: Scaffold a new OpenSpec change and validate strictly.
29+
category: OpenSpec
30+
tags: [openspec, change]
31+
---`,
32+
apply: `---
33+
name: OpenSpec: Apply
34+
description: Implement an approved OpenSpec change and keep tasks in sync.
35+
category: OpenSpec
36+
tags: [openspec, apply]
37+
---`,
38+
archive: `---
39+
name: OpenSpec: Archive
40+
description: Archive a deployed OpenSpec change and update specs.
41+
category: OpenSpec
42+
tags: [openspec, archive]
43+
---`
44+
};
45+
46+
/**
47+
* Qoder Slash Command Configurator
48+
*
49+
* Manages OpenSpec slash commands for Qoder AI assistant.
50+
* Creates three workflow commands: proposal, apply, and archive.
51+
* Uses colon-separated command format (/openspec:proposal).
52+
*
53+
* @extends {SlashCommandConfigurator}
54+
*/
55+
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
56+
/** Unique identifier for Qoder tool */
57+
readonly toolId = 'qoder';
58+
59+
/** Indicates slash commands are available for this tool */
60+
readonly isAvailable = true;
61+
62+
/**
63+
* Get relative file path for a slash command
64+
*
65+
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
66+
* @returns {string} Relative path from project root to command file
67+
*/
68+
protected getRelativePath(id: SlashCommandId): string {
69+
return FILE_PATHS[id];
70+
}
71+
72+
/**
73+
* Get YAML frontmatter for a slash command
74+
*
75+
* Frontmatter defines how the command appears in Qoder's UI,
76+
* including display name, description, and categorization.
77+
*
78+
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
79+
* @returns {string} YAML frontmatter block with command metadata
80+
*/
81+
protected getFrontmatter(id: SlashCommandId): string {
82+
return FRONTMATTER[id];
83+
}
84+
}

src/core/configurators/slash/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SlashCommandConfigurator } from './base.js';
22
import { ClaudeSlashCommandConfigurator } from './claude.js';
33
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
4+
import { QoderSlashCommandConfigurator } from './qoder.js';
45
import { CursorSlashCommandConfigurator } from './cursor.js';
56
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
67
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
@@ -20,6 +21,7 @@ export class SlashCommandRegistry {
2021
static {
2122
const claude = new ClaudeSlashCommandConfigurator();
2223
const codeBuddy = new CodeBuddySlashCommandConfigurator();
24+
const qoder = new QoderSlashCommandConfigurator();
2325
const cursor = new CursorSlashCommandConfigurator();
2426
const windsurf = new WindsurfSlashCommandConfigurator();
2527
const kilocode = new KiloCodeSlashCommandConfigurator();
@@ -35,6 +37,7 @@ export class SlashCommandRegistry {
3537

3638
this.configurators.set(claude.toolId, claude);
3739
this.configurators.set(codeBuddy.toolId, codeBuddy);
40+
this.configurators.set(qoder.toolId, qoder);
3841
this.configurators.set(cursor.toolId, cursor);
3942
this.configurators.set(windsurf.toolId, windsurf);
4043
this.configurators.set(kilocode.toolId, kilocode);

test/core/init.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,61 @@ describe('InitCommand', () => {
10501050
expect(costrictChoice.configured).toBe(true);
10511051
});
10521052

1053+
it('should create Qoder slash command files with templates', async () => {
1054+
queueSelections('qoder', DONE);
1055+
1056+
await initCommand.execute(testDir);
1057+
1058+
const qoderProposal = path.join(
1059+
testDir,
1060+
'.qoder/commands/openspec/proposal.md'
1061+
);
1062+
const qoderApply = path.join(
1063+
testDir,
1064+
'.qoder/commands/openspec/apply.md'
1065+
);
1066+
const qoderArchive = path.join(
1067+
testDir,
1068+
'.qoder/commands/openspec/archive.md'
1069+
);
1070+
1071+
expect(await fileExists(qoderProposal)).toBe(true);
1072+
expect(await fileExists(qoderApply)).toBe(true);
1073+
expect(await fileExists(qoderArchive)).toBe(true);
1074+
1075+
const proposalContent = await fs.readFile(qoderProposal, 'utf-8');
1076+
expect(proposalContent).toContain('---');
1077+
expect(proposalContent).toContain('name: OpenSpec: Proposal');
1078+
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
1079+
expect(proposalContent).toContain('category: OpenSpec');
1080+
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
1081+
expect(proposalContent).toContain('**Guardrails**');
1082+
1083+
const applyContent = await fs.readFile(qoderApply, 'utf-8');
1084+
expect(applyContent).toContain('---');
1085+
expect(applyContent).toContain('name: OpenSpec: Apply');
1086+
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
1087+
expect(applyContent).toContain('Work through tasks sequentially');
1088+
1089+
const archiveContent = await fs.readFile(qoderArchive, 'utf-8');
1090+
expect(archiveContent).toContain('---');
1091+
expect(archiveContent).toContain('name: OpenSpec: Archive');
1092+
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
1093+
expect(archiveContent).toContain('openspec archive <id> --yes');
1094+
});
1095+
1096+
it('should mark Qoder as already configured during extend mode', async () => {
1097+
queueSelections('qoder', DONE, 'qoder', DONE);
1098+
await initCommand.execute(testDir);
1099+
await initCommand.execute(testDir);
1100+
1101+
const secondRunArgs = mockPrompt.mock.calls[1][0];
1102+
const qoderChoice = secondRunArgs.choices.find(
1103+
(choice: any) => choice.value === 'qoder'
1104+
);
1105+
expect(qoderChoice.configured).toBe(true);
1106+
});
1107+
10531108
it('should create COSTRICT.md when CoStrict is selected', async () => {
10541109
queueSelections('costrict', DONE);
10551110

@@ -1065,6 +1120,21 @@ describe('InitCommand', () => {
10651120
expect(content).toContain('<!-- OPENSPEC:END -->');
10661121
});
10671122

1123+
it('should create QODER.md when Qoder is selected', async () => {
1124+
queueSelections('qoder', DONE);
1125+
1126+
await initCommand.execute(testDir);
1127+
1128+
const qoderPath = path.join(testDir, 'QODER.md');
1129+
expect(await fileExists(qoderPath)).toBe(true);
1130+
1131+
const content = await fs.readFile(qoderPath, 'utf-8');
1132+
expect(content).toContain('<!-- OPENSPEC:START -->');
1133+
expect(content).toContain("@/openspec/AGENTS.md");
1134+
expect(content).toContain('openspec update');
1135+
expect(content).toContain('<!-- OPENSPEC:END -->');
1136+
});
1137+
10681138
it('should update existing COSTRICT.md with markers', async () => {
10691139
queueSelections('costrict', DONE);
10701140

@@ -1077,6 +1147,22 @@ describe('InitCommand', () => {
10771147

10781148
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
10791149
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
1150+
expect(updatedContent).toContain('# My CoStrict Instructions');
1151+
expect(updatedContent).toContain('Custom instructions here');
1152+
});
1153+
1154+
it('should update existing QODER.md with markers', async () => {
1155+
queueSelections('qoder', DONE);
1156+
1157+
const qoderPath = path.join(testDir, 'QODER.md');
1158+
const existingContent =
1159+
'# My Qoder Instructions\nCustom instructions here';
1160+
await fs.writeFile(qoderPath, existingContent);
1161+
1162+
await initCommand.execute(testDir);
1163+
1164+
const updatedContent = await fs.readFile(qoderPath, 'utf-8');
1165+
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
10801166
expect(updatedContent).toContain("@/openspec/AGENTS.md");
10811167
expect(updatedContent).toContain('openspec update');
10821168
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');

0 commit comments

Comments
 (0)