diff --git a/README.md b/README.md index 2590c7d0..34dfbe53 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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 diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 4ccb0260..dbfb5871 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -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. diff --git a/src/core/config.ts b/src/core/config.ts index d0bc2288..49ce0541 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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' }, diff --git a/src/core/configurators/iflow.ts b/src/core/configurators/iflow.ts new file mode 100644 index 00000000..1ca97442 --- /dev/null +++ b/src/core/configurators/iflow.ts @@ -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 { + const filePath = path.join(projectPath, this.configFileName); + const content = TemplateManager.getClaudeTemplate(); + + 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 b1be2072..70a1a207 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -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'; @@ -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 @@ -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); } diff --git a/src/core/configurators/slash/iflow.ts b/src/core/configurators/slash/iflow.ts new file mode 100644 index 00000000..c7c79618 --- /dev/null +++ b/src/core/configurators/slash/iflow.ts @@ -0,0 +1,42 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.iflow/commands/openspec-proposal.md', + apply: '.iflow/commands/openspec-apply.md', + archive: '.iflow/commands/openspec-archive.md' +}; + +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. +---` +}; + +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]; + } +} diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index 45f9dae5..0570f3c7 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -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'; @@ -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(); @@ -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); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 1ebe4471..519fe191 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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(''); + 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 --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); @@ -1376,6 +1431,22 @@ describe('InitCommand', () => { expect(content).toContain('openspec update'); expect(content).toContain(''); }); + + 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(''); + 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); @@ -1409,6 +1480,25 @@ describe('InitCommand', () => { expect(updatedContent).toContain(''); 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(''); + expect(updatedContent).toContain("@/openspec/AGENTS.md"); + expect(updatedContent).toContain('openspec update'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain('Custom instructions here'); + }); + }); describe('non-interactive mode', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index b53cca9c..2d29ce7e 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -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] +--- + +Old slash content +`; + 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 --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, @@ -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] +--- + +Old body +` + ); + + 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');