Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **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/`) |
| **iFlow CLI** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) see [docs](https://platform.iflow.cn/agents?type=workflows&category=all) |


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`.
Expand Down Expand Up @@ -235,7 +236,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, Qoder, RooCode) 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, RooCode, iFlow) 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
8 changes: 8 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ The init command SHALL generate slash command files for supported editors using
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage

#### Scenario: Generating slash commands for iFlow
- **WHEN** the user selects iFlow during initialization
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage

### Requirement: Non-Interactive Mode
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
Expand Down
23 changes: 23 additions & 0 deletions src/core/configurators/iflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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";

export class IflowConfigurator implements ToolConfigurator {
name = "iFlow";
configFileName = "IFLOW.md";
isAvailable = true;

async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClaudeTemplate();

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 @@ -4,6 +4,7 @@ import { ClineConfigurator } from './cline.js';
import { CodeBuddyConfigurator } from './codebuddy.js';
import { CostrictConfigurator } from './costrict.js';
import { QoderConfigurator } from './qoder.js';
import { IflowConfigurator } from './iflow.js';
import { AgentsStandardConfigurator } from './agents.js';
import { QwenConfigurator } from './qwen.js';

Expand All @@ -16,6 +17,7 @@ export class ToolRegistry {
const codeBuddyConfigurator = new CodeBuddyConfigurator();
const costrictConfigurator = new CostrictConfigurator();
const qoderConfigurator = new QoderConfigurator();
const iflowConfigurator = new IflowConfigurator();
const agentsConfigurator = new AgentsStandardConfigurator();
const qwenConfigurator = new QwenConfigurator();
// Register with the ID that matches the checkbox value
Expand All @@ -24,6 +26,7 @@ export class ToolRegistry {
this.tools.set('codebuddy', codeBuddyConfigurator);
this.tools.set('costrict', costrictConfigurator);
this.tools.set('qoder', qoderConfigurator);
this.tools.set('iflow', iflowConfigurator);
this.tools.set('agents', agentsConfigurator);
this.tools.set('qwen', qwenConfigurator);
}
Expand Down
42 changes: 42 additions & 0 deletions src/core/configurators/slash/iflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.iflow/commands/openspec-proposal.md',
apply: '.iflow/commands/openspec-apply.md',
archive: '.iflow/commands/openspec-archive.md'
};

const FRONTMATTER: Record<SlashCommandId, string> = {
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.
---`
};

export class IflowSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'iflow';
readonly isAvailable = true;

protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}

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
Expand Up @@ -16,6 +16,7 @@ import { ClineSlashCommandConfigurator } from './cline.js';
import { CrushSlashCommandConfigurator } from './crush.js';
import { CostrictSlashCommandConfigurator } from './costrict.js';
import { QwenSlashCommandConfigurator } from './qwen.js';
import { IflowSlashCommandConfigurator } from './iflow.js';
import { RooCodeSlashCommandConfigurator } from './roocode.js';
import { AntigravitySlashCommandConfigurator } from './antigravity.js';

Expand All @@ -40,6 +41,7 @@ export class SlashCommandRegistry {
const crush = new CrushSlashCommandConfigurator();
const costrict = new CostrictSlashCommandConfigurator();
const qwen = new QwenSlashCommandConfigurator();
const iflow = new IflowSlashCommandConfigurator();
const roocode = new RooCodeSlashCommandConfigurator();
const antigravity = new AntigravitySlashCommandConfigurator();

Expand All @@ -60,6 +62,7 @@ export class SlashCommandRegistry {
this.configurators.set(crush.toolId, crush);
this.configurators.set(costrict.toolId, costrict);
this.configurators.set(qwen.toolId, qwen);
this.configurators.set(iflow.toolId, iflow);
this.configurators.set(roocode.toolId, roocode);
this.configurators.set(antigravity.toolId, antigravity);
}
Expand Down
90 changes: 90 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,61 @@ describe('InitCommand', () => {
expect(qoderChoice.configured).toBe(true);
});

it('should create IFlow slash command files with templates', async () => {
queueSelections('iflow', DONE);

await initCommand.execute(testDir);

const iflowProposal = path.join(
testDir,
'.iflow/commands/openspec-proposal.md'
);
const iflowApply = path.join(
testDir,
'.iflow/commands/openspec-apply.md'
);
const iflowArchive = path.join(
testDir,
'.iflow/commands/openspec-archive.md'
);

expect(await fileExists(iflowProposal)).toBe(true);
expect(await fileExists(iflowApply)).toBe(true);
expect(await fileExists(iflowArchive)).toBe(true);

const proposalContent = await fs.readFile(iflowProposal, '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(iflowApply, '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(iflowArchive, '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 iFlow as already configured during extend mode', async () => {
queueSelections('iflow', DONE, 'iflow', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);

const secondRunArgs = mockPrompt.mock.calls[1][0];
const iflowChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'iflow'
);
expect(iflowChoice.configured).toBe(true);
});

it('should create COSTRICT.md when CoStrict is selected', async () => {
queueSelections('costrict', DONE);

Expand Down Expand Up @@ -1376,6 +1431,22 @@ describe('InitCommand', () => {
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});

it('should create IFLOW.md when iFlow is selected', async () => {
queueSelections('iflow', DONE);

await initCommand.execute(testDir);

const iflowPath = path.join(testDir, 'IFLOW.md');
expect(await fileExists(iflowPath)).toBe(true);

const content = await fs.readFile(iflowPath, '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 Down Expand Up @@ -1409,6 +1480,25 @@ describe('InitCommand', () => {
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('Custom instructions here');
});

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

const iflowPath = path.join(testDir, 'IFLOW.md');
const existingContent =
'# My iFlow Instructions\nCustom instructions here';
await fs.writeFile(iflowPath, existingContent);

await initCommand.execute(testDir);

const updatedContent = await fs.readFile(iflowPath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('Custom instructions here');
});

});

describe('non-interactive mode', () => {
Expand Down
78 changes: 78 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,47 @@ Old slash content
consoleSpy.mockRestore();
});

it('should refresh existing iFlow slash command files', async () => {
const iflowPath = path.join(
testDir,
'.iflow/commands/openspec-proposal.md'
);
await fs.mkdir(path.dirname(iflowPath), { recursive: true });
const initialContent = `---
name: /openspec-proposal
description: Old description
category: OpenSpec
tags: [openspec, change]
---
<!-- OPENSPEC:START -->
Old slash content
<!-- OPENSPEC:END -->`;
await fs.writeFile(iflowPath, initialContent);

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

const updated = await fs.readFile(iflowPath, 'utf-8');
expect(updated).toContain('name: /openspec-proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --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: .iflow/commands/openspec-proposal.md'
);

consoleSpy.mockRestore();
});

it('should refresh existing RooCode slash command files', async () => {
const rooPath = path.join(
testDir,
Expand Down Expand Up @@ -1244,6 +1285,43 @@ Old body
await expect(FileSystemUtils.fileExists(qoderArchive)).resolves.toBe(false);
});

it('should not create missing iFlow slash command files on update', async () => {
const iflowApply = path.join(
testDir,
'.iflow/commands/openspec-apply.md'
);

// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(iflowApply), { recursive: true });
await fs.writeFile(
iflowApply,
`---
name: /openspec-apply
description: Old description
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);

await updateCommand.execute(testDir);

const iflowProposal = path.join(
testDir,
'.iflow/commands/openspec-proposal.md'
);
const iflowArchive = path.join(
testDir,
'.iflow/commands/openspec-archive.md'
);

// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(iflowProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(iflowArchive)).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');
Expand Down