Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale fdcf9621db feat(init): add IDE restart instruction after init
Add prominent restart instruction in success message to inform users
they need to restart their IDE/coding tool for slash commands to appear.
Applies to all tools when slash commands are created or refreshed.

Also updates cli-init spec to document the restart instruction requirement.
2025-11-21 00:28:21 +11:00
dkmos2016 19ccaabfc7 feat(iflow-cli): add iFlow-cli integration (#268)
* support iflow-cli

* docs: add iFlow to supported AI tools in README ([#268](https://github.com/Fission-AI/OpenSpec/pull/268))

Add iFlow to the Native Slash Commands table in the README. iFlow support was implemented but was missing from the documentation.

* add UTs for iflow-cli
2025-11-20 21:57:58 +11:00
Tabish Bidiwale 2e382b9898 Add Antigravity slash command support (#318) 2025-11-19 17:18:52 +11:00
Tabish Bidiwale b5a7d096f0 fix: generate TOML commands for Qwen Code (fixes #293) (#317) 2025-11-19 16:52:44 +11:00
Tabish Bidiwale c54079a0cd Clarify scaffold proposal (#310)
* clarify scaffold proposal

* Update scaffold command proposal to support idempotent execution
2025-11-19 16:23:57 +11:00
20 changed files with 498 additions and 139 deletions
+6
View File
@@ -1,5 +1,11 @@
# @fission-ai/openspec
## Unreleased
### Minor Changes
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
## 0.15.0
### Minor Changes
+2
View File
@@ -102,12 +102,14 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **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) |
| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |
| **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/`) |
| **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 (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |
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`.
@@ -0,0 +1,11 @@
## Why
Google is rolling out Antigravity, a Windsurf-derived IDE that discovers workflows from `.agent/workflows/*.md`. Today OpenSpec can only scaffold slash commands for Windsurf directories, so Antigravity users cannot run the proposal/apply/archive flows from the IDE.
## What Changes
- Add Antigravity as a selectable native tool in `openspec init` so it creates `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter containing only a `description` field plus the standard OpenSpec-managed body.
- Ensure `openspec update` refreshes the body of any existing Antigravity workflows inside `.agent/workflows/` without creating missing files, mirroring the Windsurf behavior.
- Share e2e/template coverage confirming the generator writes the proper directory, filename casing, and frontmatter format so Antigravity picks up the workflows.
## Impact
- Affected specs: `specs/cli-init`, `specs/cli-update`
- Expected code: CLI init/update tool registries, slash-command templates, associated tests
@@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
@@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
@@ -0,0 +1,12 @@
## 1. CLI init support
- [x] 1.1 Surface Antigravity in the native-tool picker (interactive + `--tools`) so it toggles alongside other IDEs.
- [x] 1.2 Generate `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter restricted to a single `description` field for each stage and wrap the body in OpenSpec markers.
- [x] 1.3 Confirm workspace scaffolding covers missing directory creation and re-run scenarios so repeated init refreshes the managed block.
## 2. CLI update support
- [x] 2.1 Detect existing Antigravity workflow files during `openspec update` and refresh only the managed body, skipping creation when files are missing.
- [x] 2.2 Ensure update logic preserves the `description` frontmatter block exactly as written by init, including case and spacing, and refreshes body templates alongside other tools.
## 3. Templates and tests
- [x] 3.1 Add shared template entries for Antigravity that reuse the Windsurf copy but target `.agent/workflows` plus the description-only frontmatter requirement.
- [x] 3.2 Expand automated coverage (unit or integration) verifying init and update produce the expected file paths and frontmatter + body markers for Antigravity.
+20
View File
@@ -76,6 +76,12 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring iFlow CLI
- **WHEN** iFlow CLI is selected
- **THEN** create or update `IFLOW.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
@@ -118,6 +124,12 @@ The command SHALL provide clear, actionable next steps upon successful initializ
- **WHEN** initialization completes successfully
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
#### Scenario: Displaying restart instruction
- **WHEN** initialization completes successfully and tools were created or refreshed
- **THEN** display a prominent restart instruction before the "Next steps" section
- **AND** inform users that slash commands are loaded at startup
- **AND** instruct users to restart their coding assistant to ensure /openspec commands appear
### Requirement: Exit Codes
The command SHALL use consistent exit codes to indicate different failure modes.
@@ -238,6 +250,14 @@ The init command SHALL generate slash command files for supported editors using
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
#### Scenario: Generating slash commands for iFlow CLI
- **WHEN** the user selects iFlow CLI 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 YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for RooCode
- **WHEN** the user selects RooCode during initialization
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
+7
View File
@@ -123,6 +123,13 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
#### Scenario: Updating slash commands for iFlow CLI
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
+9 -7
View File
@@ -17,23 +17,25 @@ export interface AIToolOption {
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ 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: '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' },
{ 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' },
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+23
View File
@@ -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
View File
@@ -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);
}
@@ -0,0 +1,28 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.agent/workflows/openspec-proposal.md',
apply: '.agent/workflows/openspec-apply.md',
archive: '.agent/workflows/openspec-archive.md'
};
const DESCRIPTIONS: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
export class AntigravitySlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'antigravity';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const description = DESCRIPTIONS[id];
return `---\ndescription: ${description}\n---`;
}
}
+5 -61
View File
@@ -1,7 +1,5 @@
import { FileSystemUtils } from '../../../utils/file-system.js';
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId, TemplateManager } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.gemini/commands/openspec/proposal.toml',
@@ -15,7 +13,7 @@ const DESCRIPTIONS: Record<SlashCommandId, string> = {
archive: 'Archive a deployed OpenSpec change and update specs.'
};
export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
readonly toolId = 'gemini';
readonly isAvailable = true;
@@ -23,61 +21,7 @@ export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
return FILE_PATHS[id];
}
protected getFrontmatter(_id: SlashCommandId): string | undefined {
// TOML doesn't use separate frontmatter - it's all in one structure
return undefined;
}
// Override to generate TOML format with markers inside the prompt field
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
const createdOrUpdated: string[] = [];
for (const target of this.getTargets()) {
const body = this.getBody(target.id);
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
await this.updateBody(filePath, body);
} else {
const tomlContent = this.generateTOML(target.id, body);
await FileSystemUtils.writeFile(filePath, tomlContent);
}
createdOrUpdated.push(target.path);
}
return createdOrUpdated;
}
private generateTOML(id: SlashCommandId, body: string): string {
const description = DESCRIPTIONS[id];
// TOML format with triple-quoted string for multi-line prompt
// Markers are inside the prompt value
return `description = "${description}"
prompt = """
${OPENSPEC_MARKERS.start}
${body}
${OPENSPEC_MARKERS.end}
"""
`;
}
// Override updateBody to handle TOML format
protected async updateBody(filePath: string, body: string): Promise<void> {
const content = await FileSystemUtils.readFile(filePath);
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
throw new Error(`Missing OpenSpec markers in ${filePath}`);
}
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
const after = content.slice(endIndex);
const updatedContent = `${before}\n${body}\n${after}`;
await FileSystemUtils.writeFile(filePath, updatedContent);
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
+42
View File
@@ -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];
}
}
+12 -37
View File
@@ -5,7 +5,7 @@
*
* @implements {SlashCommandConfigurator}
*/
import { SlashCommandConfigurator } from './base.js';
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';
/**
@@ -13,35 +13,15 @@ import { SlashCommandId } from '../../templates/index.js';
* @type {Record<SlashCommandId, string>}
*/
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.qwen/commands/openspec-proposal.md',
apply: '.qwen/commands/openspec-apply.md',
archive: '.qwen/commands/openspec-archive.md'
proposal: '.qwen/commands/openspec-proposal.toml',
apply: '.qwen/commands/openspec-apply.toml',
archive: '.qwen/commands/openspec-archive.toml'
};
/**
* YAML frontmatter definitions for Qwen command files.
* These provide metadata for each slash command to ensure proper recognition by Qwen Code.
* @type {Record<SlashCommandId, string>}
*/
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.
---`
const DESCRIPTIONS: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
/**
@@ -53,10 +33,10 @@ description: Archive a deployed OpenSpec change and update specs.
* - /openspec-apply: Apply an approved OpenSpec change
* - /openspec-archive: Archive a deployed OpenSpec change
*/
export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
/** Unique identifier for the Qwen tool */
readonly toolId = 'qwen';
/** Availability status for the Qwen tool */
readonly isAvailable = true;
@@ -69,12 +49,7 @@ export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
return FILE_PATHS[id];
}
/**
* Returns the YAML frontmatter for a given slash command ID.
* @param {SlashCommandId} id - The slash command identifier
* @returns {string} The YAML frontmatter string
*/
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
+6
View File
@@ -17,6 +17,8 @@ import { CrushSlashCommandConfigurator } from './crush.js';
import { CostrictSlashCommandConfigurator } from './costrict.js';
import { QwenSlashCommandConfigurator } from './qwen.js';
import { RooCodeSlashCommandConfigurator } from './roocode.js';
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
import { IflowSlashCommandConfigurator } from './iflow.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -40,6 +42,8 @@ export class SlashCommandRegistry {
const costrict = new CostrictSlashCommandConfigurator();
const qwen = new QwenSlashCommandConfigurator();
const roocode = new RooCodeSlashCommandConfigurator();
const antigravity = new AntigravitySlashCommandConfigurator();
const iflow = new IflowSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
@@ -59,6 +63,8 @@ export class SlashCommandRegistry {
this.configurators.set(costrict.toolId, costrict);
this.configurators.set(qwen.toolId, qwen);
this.configurators.set(roocode.toolId, roocode);
this.configurators.set(antigravity.toolId, antigravity);
this.configurators.set(iflow.toolId, iflow);
}
static register(configurator: SlashCommandConfigurator): void {
+66
View File
@@ -0,0 +1,66 @@
import { FileSystemUtils } from '../../../utils/file-system.js';
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
protected getFrontmatter(_id: SlashCommandId): string | undefined {
// TOML doesn't use separate frontmatter - it's all in one structure
return undefined;
}
protected abstract getDescription(id: SlashCommandId): string;
// Override to generate TOML format with markers inside the prompt field
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
const createdOrUpdated: string[] = [];
for (const target of this.getTargets()) {
const body = this.getBody(target.id);
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
await this.updateBody(filePath, body);
} else {
const tomlContent = this.generateTOML(target.id, body);
await FileSystemUtils.writeFile(filePath, tomlContent);
}
createdOrUpdated.push(target.path);
}
return createdOrUpdated;
}
private generateTOML(id: SlashCommandId, body: string): string {
const description = this.getDescription(id);
// TOML format with triple-quoted string for multi-line prompt
// Markers are inside the prompt value
return `description = "${description}"
prompt = """
${OPENSPEC_MARKERS.start}
${body}
${OPENSPEC_MARKERS.end}
"""
`;
}
// Override updateBody to handle TOML format
protected async updateBody(filePath: string, body: string): Promise<void> {
const content = await FileSystemUtils.readFile(filePath);
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
throw new Error(`Missing OpenSpec markers in ${filePath}`);
}
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
const after = content.slice(endIndex);
const updatedContent = `${before}\n${body}\n${after}`;
await FileSystemUtils.writeFile(filePath, updatedContent);
}
}
+16
View File
@@ -862,6 +862,22 @@ export class InitCommand {
)
);
// Show restart instruction if any tools were configured
if (created.length > 0 || refreshed.length > 0) {
console.log();
console.log(PALETTE.white('Important: Restart your IDE'));
console.log(
PALETTE.midGray(
'Slash commands are loaded at startup. Please restart your coding assistant'
)
);
console.log(
PALETTE.midGray(
'to ensure the new /openspec commands appear in your command palette.'
)
);
}
// Get the selected tool name(s) for display
const toolName = this.formatToolNames(selectedTools);
+117 -13
View File
@@ -50,7 +50,7 @@ describe('InitCommand', () => {
process.env.CODEX_HOME = path.join(testDir, '.codex');
// Mock console.log to suppress output during tests
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => { });
});
afterEach(async () => {
@@ -213,6 +213,50 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
});
it('should create Antigravity workflows when Antigravity is selected', async () => {
queueSelections('antigravity', DONE);
await initCommand.execute(testDir);
const agProposal = path.join(
testDir,
'.agent/workflows/openspec-proposal.md'
);
const agApply = path.join(
testDir,
'.agent/workflows/openspec-apply.md'
);
const agArchive = path.join(
testDir,
'.agent/workflows/openspec-archive.md'
);
expect(await fileExists(agProposal)).toBe(true);
expect(await fileExists(agApply)).toBe(true);
expect(await fileExists(agArchive)).toBe(true);
const proposalContent = await fs.readFile(agProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
expect(proposalContent).not.toContain('auto_execution_mode');
const applyContent = await fs.readFile(agApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('<!-- OPENSPEC:START -->');
expect(applyContent).toContain('Work through tasks sequentially');
expect(applyContent).not.toContain('auto_execution_mode');
const archiveContent = await fs.readFile(agArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('<!-- OPENSPEC:START -->');
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
expect(archiveContent).not.toContain('auto_execution_mode');
});
it('should always create AGENTS.md in project root', async () => {
queueSelections(DONE);
@@ -372,6 +416,59 @@ describe('InitCommand', () => {
expect(updatedContent).not.toContain('Custom instruction added by user');
});
it('should create IFlow CLI 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('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
const applyContent = await fs.readFile(iflowApply, 'utf-8');
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('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('openspec archive <id>');
});
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');
});
it('should create OpenCode slash command files with templates', async () => {
queueSelections('opencode', DONE);
@@ -424,15 +521,15 @@ describe('InitCommand', () => {
const qwenConfigPath = path.join(testDir, 'QWEN.md');
const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
'.qwen/commands/openspec-proposal.toml'
);
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
'.qwen/commands/openspec-archive.toml'
);
expect(await fileExists(qwenConfigPath)).toBe(true);
@@ -446,21 +543,16 @@ describe('InitCommand', () => {
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
expect(proposalContent).toContain('name: /openspec-proposal');
expect(proposalContent).toContain('category: OpenSpec');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('prompt = """');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
const applyContent = await fs.readFile(applyPath, 'utf-8');
expect(applyContent).toContain('name: /openspec-apply');
expect(applyContent).toContain('category: OpenSpec');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
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(archivePath, 'utf-8');
expect(archiveContent).toContain('name: /openspec-archive');
expect(archiveContent).toContain('category: OpenSpec');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('openspec archive <id>');
});
@@ -854,6 +946,18 @@ describe('InitCommand', () => {
expect(wsChoice.configured).toBe(true);
});
it('should mark Antigravity as already configured during extend mode', async () => {
queueSelections('antigravity', DONE, 'antigravity', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const antigravityChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'antigravity'
);
expect(antigravityChoice.configured).toBe(true);
});
it('should mark Codex as already configured during extend mode', async () => {
queueSelections('codex', DONE, 'codex', DONE);
await initCommand.execute(testDir);
+96 -21
View File
@@ -152,19 +152,17 @@ Old slash content
it('should refresh existing Qwen slash command files', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);
await fs.mkdir(path.dirname(applyPath), { recursive: true });
const initialContent = `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
const initialContent = `description = "Implement an approved OpenSpec change and keep tasks in sync."
prompt = """
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
<!-- OPENSPEC:END -->
"""
`;
await fs.writeFile(applyPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
@@ -172,8 +170,8 @@ Old body
await updateCommand.execute(testDir);
const updated = await fs.readFile(applyPath, 'utf-8');
expect(updated).toContain('name: /openspec-apply');
expect(updated).toContain('category: OpenSpec');
expect(updated).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
expect(updated).toContain('prompt = """');
expect(updated).toContain('<!-- OPENSPEC:START -->');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
@@ -184,7 +182,7 @@ Old body
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .qwen/commands/openspec-apply.md'
'Updated slash commands: .qwen/commands/openspec-apply.toml'
);
consoleSpy.mockRestore();
@@ -193,22 +191,20 @@ Old body
it('should not create missing Qwen slash command files on update', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);
await fs.mkdir(path.dirname(applyPath), { recursive: true });
await fs.writeFile(
applyPath,
`---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
`description = "Old description"
prompt = """
<!-- OPENSPEC:START -->
Old content
<!-- OPENSPEC:END -->`
<!-- OPENSPEC:END -->
"""
`
);
await updateCommand.execute(testDir);
@@ -219,11 +215,11 @@ Old content
const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
'.qwen/commands/openspec-proposal.toml'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
'.qwen/commands/openspec-archive.toml'
);
await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);
@@ -467,6 +463,38 @@ Old body
consoleSpy.mockRestore();
});
it('should refresh existing Antigravity workflows', async () => {
const agPath = path.join(
testDir,
'.agent/workflows/openspec-apply.md'
);
await fs.mkdir(path.dirname(agPath), { recursive: true });
const initialContent = `---
description: Implement an approved OpenSpec change and keep tasks in sync.
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(agPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(agPath, 'utf-8');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
expect(updated).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(updated).not.toContain('auto_execution_mode: 3');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated slash commands: .agent/workflows/openspec-apply.md'
);
consoleSpy.mockRestore();
});
it('should refresh existing Codex prompts', async () => {
const codexPath = path.join(
testDir,
@@ -635,6 +663,53 @@ Old Gemini body
consoleSpy.mockRestore();
});
it('should refresh existing IFLOW slash commands', async () => {
const iflowProposal = path.join(
testDir,
'.iflow/commands/openspec-proposal.md'
);
await fs.mkdir(path.dirname(iflowProposal), { recursive: true });
const initialContent = `description: Scaffold a new OpenSpec change and validate strictly."
prompt = """
<!-- OPENSPEC:START -->
Old IFlow body
<!-- OPENSPEC:END -->
"""
`;
await fs.writeFile(iflowProposal, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(iflowProposal, 'utf-8');
expect(updated).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(updated).toContain('<!-- OPENSPEC:START -->');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain('<!-- OPENSPEC:END -->');
expect(updated).not.toContain('Old IFlow body');
const iflowApply = path.join(
testDir,
'.iflow/commands/openspec-apply.md'
);
const iflowArchive = path.join(
testDir,
'.iflow/commands/openspec-archive.md'
);
await expect(FileSystemUtils.fileExists(iflowApply)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(iflowArchive)).resolves.toBe(false);
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated slash commands: .iflow/commands/openspec-proposal.md'
);
consoleSpy.mockRestore();
});
it('should refresh existing Factory slash commands', async () => {
const factoryPath = path.join(