Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`) |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
53 changes: 53 additions & 0 deletions src/core/configurators/qoder.ts
Original file line number Diff line number Diff line change
@@ -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<void>} Resolves when configuration is complete
*/
async configure(projectPath: string, openspecDir: string): Promise<void> {
// 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
);
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}

Expand Down
84 changes: 84 additions & 0 deletions src/core/configurators/slash/qoder.ts
Original file line number Diff line number Diff line change
@@ -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<SlashCommandId, string> = {
// 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<SlashCommandId, string> = {
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];
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
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 <id> --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);

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

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('<!-- OPENSPEC:START -->');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});

it('should update existing COSTRICT.md with markers', async () => {
queueSelections('costrict', DONE);

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

const updatedContent = await fs.readFile(costrictPath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
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('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
Expand Down
Loading
Loading