Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 4afafcb2a3 Document Gemini CLI slash updates 2025-11-11 21:33:41 +11:00
Larry HopeandTabish Bidiwale 9b6a763eb8 feat: Add Gemini CLI support with TOML-based slash commands (#256)
* feat: add Gemini CLI support with TOML-based slash commands

- Add GeminiSlashCommandConfigurator for .gemini/commands/openspec/
- Register Gemini CLI in AI_TOOLS config and slash command registry
- Generate TOML files with description and prompt fields
- Add comprehensive test coverage for Gemini CLI integration
- Update README to list Gemini CLI under Native Slash Commands
- Remove Gemini CLI from AGENTS.md compatible list (now native)

Implements GitHub issue #248

* [add]

* feat: address PR feedback - remove changeset and add update test

- Remove .changeset/add-gemini-cli-support.md as requested by maintainer
- Add test for Gemini CLI TOML update/refresh path
- Test verifies that existing TOML files are properly updated when running init again

Addresses feedback from TabishB in PR #256

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2025-11-11 21:26:44 +11:00
github-actions[bot]andTabish Bidiwale d32e50fe36 chore(release): version packages (#271)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-04 19:39:29 +11:00
Tabish Bidiwale 8386b91a71 Add changeset for AI assistants support and configuration improvements (#270) 2025-11-03 11:20:27 +11:00
8f9c3c7d0b feat: add Qwen Code support with slash command integration (#250)
* feat: add Qwen Code support with slash command integration

- Add QwenSlashCommandConfigurator for .qwen/commands/ structure
- Add QwenConfigurator to main registry
- Update README.md to include Qwen Code in supported tools list
- Register Qwen in both slash command and main tool registries
- Implement proper YAML frontmatter for Qwen command files
- Follow OpenSpec's established patterns for AI tool integration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs: add docstrings and fix review comments for Qwen Code support

- Add comprehensive JSDoc comments to Qwen configurator files
- Add Qwen Code entry to README_CN.md support table
- Fix file ending newline in src/core/configurators/qwen.ts
- Address CodeRabbit review suggestions for improved documentation coverage

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: add language identifier to markdown code block in supportQwen.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address CodeRabbit review comments - parameter naming fix

- Fix unused parameter naming convention (_openspecDir)
- Keep only necessary changes for Qwen Code support

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: add personal notes files to .gitignore

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: remove personal notes files from git tracking

- Remove README_CN.md and supportQwen.md from git tracking
- These files are now ignored via .gitignore
- Keep only necessary files for the OpenSpec project

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: remove duplicate JSDoc comment in QwenConfigurator

- Remove duplicate documentation comment for configure method
- Keep only the updated comment that correctly documents the unused parameter

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test: cover qwen configurators

* chore: revert gitignore changes

* test: extend qwen init coverage

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-03 11:03:39 +11:00
Tabish Bidiwale 9cdb0743f2 feat: add $ARGUMENTS support to apply slash command (#244)
This change adds the $ARGUMENTS property to the OpenCode apply slash command template, allowing users to pass arguments when invoking the apply command. The template now includes instructions for the agent to find and implement the change proposal, with guidance to ask for clarification when ambiguous.
2025-11-02 23:12:02 +11:00
KUTEJiangandpengjiahan.pjh 4e93d7a881 feat: add Qoder CLI support to configuration and documentation (#261)
Co-authored-by: pengjiahan.pjh <pengjiahan.pjh@antgroup.com>
2025-11-01 19:49:36 +11:00
mini2s c4b6be41c1 feat: add CoStrict AI assistant support (#240)
* feat(ai-tools): add Costrict integration support

Add support for Costrict AI tool with slash commands and configuration template. Includes configurator, registry entries, templates, and comprehensive test coverage.

* feat(config): update branding from 'Costrict' to 'CoStrict'
2025-11-01 19:10:23 +11:00
HariKrishnanandClaude a66580735c docs: add guidance for populating project-level context (#241)
* docs: add optional project context setup instructions

Add documentation for the optional step of populating project.md with
project details, tech stack, and conventions after running openspec init.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: improve project context section formatting and clarity

- Change heading to "Optional: Populate Project Context" for consistency
- Fix double space typo in "After  openspec init"
- Explicitly reference openspec/project.md file
- Enhance explanation of project.md purpose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-27 10:12:14 +11:00
Tabish Bidiwale fb1d37e56e fix: recreate missing openspec template files in extend mode (#238)
* fix: recreate missing openspec template files in extend mode

The init command now checks for and recreates missing template files
(like openspec/AGENTS.md and openspec/project.md) when running in
extend mode, instead of skipping file generation entirely.

Previously, if a user deleted openspec/AGENTS.md and ran init again,
the file would not be recreated because the openspec/ directory
existed, triggering extend mode which skipped all file generation.

Changes:
- Added ensureTemplateFiles() method to check and recreate missing files
- Modified extend mode to call ensureTemplateFiles() instead of skipping
- Updated message to "Checking for missing files..." for clarity
- Added tests for recreating deleted openspec/AGENTS.md and project.md

* refactor: consolidate duplicate logic in template file generation

Extracted shared logic from generateFiles() and ensureTemplateFiles()
into a new writeTemplateFiles() method with a skipExisting parameter.
This eliminates code duplication while maintaining the same behavior.

* test: improve extend mode test coverage and reduce duplication

- Extracted testFileRecreationInExtendMode helper to reduce code duplication
- Added test to verify existing files are preserved in extend mode
- Ensures skipExisting behavior is properly tested
2025-10-25 23:55:49 +11:00
23 changed files with 1333 additions and 13 deletions
+1 -1
View File
@@ -149,4 +149,4 @@ CLAUDE.md
.DS_Store
# Pnpm
.pnpm-store/
.pnpm-store/
+16
View File
@@ -1,5 +1,21 @@
# @fission-ai/openspec
## 0.14.0
### Minor Changes
- 8386b91: Add support for new AI assistants and configuration improvements
- feat: add Qwen Code support with slash command integration
- feat: add $ARGUMENTS support to apply slash command for dynamic variable passing
- feat: add Qoder CLI support to configuration and documentation
- feat: add CoStrict AI assistant support
- fix: recreate missing openspec template files in extend mode
- fix: prevent false 'already configured' detection for tools
- fix: use change-id as fallback title instead of "Untitled Change"
- docs: add guidance for populating project-level context
- docs: add Crush to supported AI tools in README
## 0.13.0
### Minor Changes
+18 -3
View File
@@ -92,17 +92,21 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
| **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/`) |
| **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/`) |
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`.
@@ -112,7 +116,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
| Tools |
|-------|
| Amp • Jules • Gemini CLI • Others |
| Amp • Jules • Others |
### Install & Initialize
@@ -143,7 +147,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
@@ -153,6 +157,17 @@ openspec init
- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,
so a fresh launch ensures they appear
### Optional: Populate Project Context
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
```text
Populate your project context:
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
```
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
### Create Your First Change
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
@@ -218,7 +233,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
+7
View File
@@ -231,6 +231,13 @@ The init command SHALL generate slash command files for supported editors using
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Gemini CLI
- **WHEN** the user selects Gemini CLI during initialization
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
- **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
### Requirement: Non-Interactive Mode
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
+6
View File
@@ -117,6 +117,12 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Gemini CLI
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
- **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: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.13.0",
"version": "0.14.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -59,7 +59,7 @@
"@changesets/cli": "^2.27.7",
"@types/node": "^24.2.0",
"@vitest/ui": "^3.2.4",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"dependencies": {
+5 -5
View File
@@ -37,8 +37,8 @@ importers:
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4)
typescript:
specifier: ^5.9.2
version: 5.9.2
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
@@ -1115,8 +1115,8 @@ packages:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
@@ -2223,7 +2223,7 @@ snapshots:
type-fest@0.21.3: {}
typescript@5.9.2: {}
typescript@5.9.3: {}
undici-types@7.10.0: {}
+4
View File
@@ -21,14 +21,18 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ 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: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
{ 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 CostrictConfigurator implements ToolConfigurator {
name = 'CoStrict';
configFileName = 'COSTRICT.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getCostrictTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+53
View File
@@ -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
);
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Qwen Code configurator for OpenSpec integration.
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
*
* @implements {ToolConfigurator}
*/
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';
/**
* QwenConfigurator class provides integration with Qwen Code
* by creating and managing the necessary configuration files.
* Currently configures the QWEN.md file with OpenSpec instructions.
*/
export class QwenConfigurator implements ToolConfigurator {
/** Display name for the Qwen Code tool */
name = 'Qwen Code';
/** Configuration file name for Qwen Code */
configFileName = 'QWEN.md';
/** Availability status for the Qwen Code tool */
isAvailable = true;
/**
* Configures the Qwen Code integration by creating or updating the QWEN.md file
* with OpenSpec instructions and markers.
*
* @param {string} projectPath - The path to the project root
* @param {string} _openspecDir - The path to the openspec directory (unused)
* @returns {Promise<void>} A promise that resolves when configuration is complete
*/
async configure(projectPath: string, _openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getAgentsStandardTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+9
View File
@@ -2,7 +2,10 @@ import { ToolConfigurator } from './base.js';
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';
import { QwenConfigurator } from './qwen.js';
export class ToolRegistry {
private static tools: Map<string, ToolConfigurator> = new Map();
@@ -11,12 +14,18 @@ export class ToolRegistry {
const claudeConfigurator = new ClaudeConfigurator();
const clineConfigurator = new ClineConfigurator();
const codeBuddyConfigurator = new CodeBuddyConfigurator();
const costrictConfigurator = new CostrictConfigurator();
const qoderConfigurator = new QoderConfigurator();
const agentsConfigurator = new AgentsStandardConfigurator();
const qwenConfigurator = new QwenConfigurator();
// 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);
this.tools.set('qwen', qwenConfigurator);
}
static register(tool: ToolConfigurator): void {
+36
View File
@@ -0,0 +1,36 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS = {
proposal: '.cospec/openspec/commands/openspec-proposal.md',
apply: '.cospec/openspec/commands/openspec-apply.md',
archive: '.cospec/openspec/commands/openspec-archive.md',
} as const satisfies Record<SlashCommandId, string>;
const FRONTMATTER = {
proposal: `---
description: "Scaffold a new OpenSpec change and validate strictly."
argument-hint: feature description or request
---`,
apply: `---
description: "Implement an approved OpenSpec change and keep tasks in sync."
argument-hint: change-id
---`,
archive: `---
description: "Archive a deployed OpenSpec change and update specs."
argument-hint: change-id
---`
} as const satisfies Record<SlashCommandId, string>;
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'costrict';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
return FRONTMATTER[id];
}
}
+83
View File
@@ -0,0 +1,83 @@
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';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.gemini/commands/openspec/proposal.toml',
apply: '.gemini/commands/openspec/apply.toml',
archive: '.gemini/commands/openspec/archive.toml'
};
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 GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'gemini';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
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);
}
}
+6 -1
View File
@@ -22,7 +22,12 @@ The user has requested the following change proposal. Use the openspec instructi
apply: `---
agent: build
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
---
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
<UserRequest>
$ARGUMENTS
</UserRequest>
`,
archive: `---
agent: build
description: Archive a deployed OpenSpec change and update specs.
+84
View File
@@ -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];
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Qwen slash command configurator for OpenSpec integration.
* This class handles the generation of Qwen-specific slash command files
* in the .qwen/commands directory structure.
*
* @implements {SlashCommandConfigurator}
*/
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
/**
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
* @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'
};
/**
* 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.
---`
};
/**
* QwenSlashCommandConfigurator class provides integration with Qwen Code
* by creating the necessary slash command files in the .qwen/commands directory.
*
* The slash commands include:
* - /openspec-proposal: Create an OpenSpec change proposal
* - /openspec-apply: Apply an approved OpenSpec change
* - /openspec-archive: Archive a deployed OpenSpec change
*/
export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
/** Unique identifier for the Qwen tool */
readonly toolId = 'qwen';
/** Availability status for the Qwen tool */
readonly isAvailable = true;
/**
* Returns the relative file path for a given slash command ID.
* @param {SlashCommandId} id - The slash command identifier
* @returns {string} The relative path to the command file
*/
protected getRelativePath(id: SlashCommandId): string {
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];
}
}
+12
View File
@@ -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';
@@ -9,9 +10,12 @@ import { CodexSlashCommandConfigurator } from './codex.js';
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
import { FactorySlashCommandConfigurator } from './factory.js';
import { GeminiSlashCommandConfigurator } from './gemini.js';
import { AuggieSlashCommandConfigurator } from './auggie.js';
import { ClineSlashCommandConfigurator } from './cline.js';
import { CrushSlashCommandConfigurator } from './crush.js';
import { CostrictSlashCommandConfigurator } from './costrict.js';
import { QwenSlashCommandConfigurator } from './qwen.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -19,6 +23,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();
@@ -27,12 +32,16 @@ export class SlashCommandRegistry {
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
const amazonQ = new AmazonQSlashCommandConfigurator();
const factory = new FactorySlashCommandConfigurator();
const gemini = new GeminiSlashCommandConfigurator();
const auggie = new AuggieSlashCommandConfigurator();
const cline = new ClineSlashCommandConfigurator();
const crush = new CrushSlashCommandConfigurator();
const costrict = new CostrictSlashCommandConfigurator();
const qwen = new QwenSlashCommandConfigurator();
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);
@@ -41,9 +50,12 @@ export class SlashCommandRegistry {
this.configurators.set(githubCopilot.toolId, githubCopilot);
this.configurators.set(amazonQ.toolId, amazonQ);
this.configurators.set(factory.toolId, factory);
this.configurators.set(gemini.toolId, gemini);
this.configurators.set(auggie.toolId, auggie);
this.configurators.set(cline.toolId, cline);
this.configurators.set(crush.toolId, crush);
this.configurators.set(costrict.toolId, costrict);
this.configurators.set(qwen.toolId, qwen);
}
static register(configurator: SlashCommandConfigurator): void {
+24 -1
View File
@@ -428,9 +428,11 @@ export class InitCommand {
} else {
ora({ stream: process.stdout }).info(
PALETTE.midGray(
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
'ℹ OpenSpec already initialized. Checking for missing files...'
)
);
await this.createDirectoryStructure(openspecPath);
await this.ensureTemplateFiles(openspecPath, config);
}
// Step 2: Configure AI tools
@@ -719,6 +721,21 @@ export class InitCommand {
private async generateFiles(
openspecPath: string,
config: OpenSpecConfig
): Promise<void> {
await this.writeTemplateFiles(openspecPath, config, false);
}
private async ensureTemplateFiles(
openspecPath: string,
config: OpenSpecConfig
): Promise<void> {
await this.writeTemplateFiles(openspecPath, config, true);
}
private async writeTemplateFiles(
openspecPath: string,
config: OpenSpecConfig,
skipExisting: boolean
): Promise<void> {
const context: ProjectContext = {
// Could be enhanced with prompts for project details
@@ -728,6 +745,12 @@ export class InitCommand {
for (const template of templates) {
const filePath = path.join(openspecPath, template.path);
// Skip if file exists and we're in skipExisting mode
if (skipExisting && (await FileSystemUtils.fileExists(filePath))) {
continue;
}
const content =
typeof template.content === 'function'
? template.content(context)
+1
View File
@@ -0,0 +1 @@
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
+5
View File
@@ -2,6 +2,7 @@ import { agentsTemplate } from './agents-template.js';
import { projectTemplate, ProjectContext } from './project-template.js';
import { claudeTemplate } from './claude-template.js';
import { clineTemplate } from './cline-template.js';
import { costrictTemplate } from './costrict-template.js';
import { agentsRootStubTemplate } from './agents-root-stub.js';
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
@@ -32,6 +33,10 @@ export class TemplateManager {
return clineTemplate;
}
static getCostrictTemplate(): string {
return costrictTemplate;
}
static getAgentsStandardTemplate(): string {
return agentsRootStubTemplate;
}
+381
View File
@@ -305,6 +305,73 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('openspec list --specs');
});
it('should create Gemini CLI TOML files when selected', async () => {
queueSelections('gemini', DONE);
await initCommand.execute(testDir);
const geminiProposal = path.join(
testDir,
'.gemini/commands/openspec/proposal.toml'
);
const geminiApply = path.join(
testDir,
'.gemini/commands/openspec/apply.toml'
);
const geminiArchive = path.join(
testDir,
'.gemini/commands/openspec/archive.toml'
);
expect(await fileExists(geminiProposal)).toBe(true);
expect(await fileExists(geminiApply)).toBe(true);
expect(await fileExists(geminiArchive)).toBe(true);
const proposalContent = await fs.readFile(geminiProposal, 'utf-8');
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('prompt = """');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
const applyContent = await fs.readFile(geminiApply, '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(geminiArchive, 'utf-8');
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('openspec archive <id>');
});
it('should update existing Gemini CLI TOML files with refreshed content', async () => {
queueSelections('gemini', DONE);
await initCommand.execute(testDir);
const geminiProposal = path.join(
testDir,
'.gemini/commands/openspec/proposal.toml'
);
// Modify the file to simulate user customization
const originalContent = await fs.readFile(geminiProposal, 'utf-8');
const modifiedContent = originalContent.replace(
'<!-- OPENSPEC:START -->',
'<!-- OPENSPEC:START -->\nCustom instruction added by user\n'
);
await fs.writeFile(geminiProposal, modifiedContent);
// Run init again to test update/refresh path
queueSelections('gemini', DONE);
await initCommand.execute(testDir);
const updatedContent = await fs.readFile(geminiProposal, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('**Guardrails**');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).not.toContain('Custom instruction added by user');
});
it('should create OpenCode slash command files with templates', async () => {
queueSelections('opencode', DONE);
@@ -349,6 +416,71 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('openspec list --specs');
});
it('should create Qwen configuration and slash command files with templates', async () => {
queueSelections('qwen', DONE);
await initCommand.execute(testDir);
const qwenConfigPath = path.join(testDir, 'QWEN.md');
const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
);
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
);
expect(await fileExists(qwenConfigPath)).toBe(true);
expect(await fileExists(proposalPath)).toBe(true);
expect(await fileExists(applyPath)).toBe(true);
expect(await fileExists(archivePath)).toBe(true);
const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8');
expect(qwenConfigContent).toContain('<!-- OPENSPEC:START -->');
expect(qwenConfigContent).toContain("@/openspec/AGENTS.md");
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('<!-- 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('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('openspec archive <id>');
});
it('should update existing QWEN.md with markers', async () => {
queueSelections('qwen', DONE);
const qwenPath = path.join(testDir, 'QWEN.md');
const existingContent = '# My Qwen Instructions\nCustom instructions here';
await fs.writeFile(qwenPath, existingContent);
await initCommand.execute(testDir);
const updatedContent = await fs.readFile(qwenPath, '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 Cline rule files with templates', async () => {
queueSelections('cline', DONE);
@@ -579,6 +711,44 @@ describe('InitCommand', () => {
await expect(initCommand.execute(testDir)).resolves.toBeUndefined();
});
it('should recreate deleted openspec/AGENTS.md in extend mode', async () => {
await testFileRecreationInExtendMode(
testDir,
initCommand,
'openspec/AGENTS.md',
'OpenSpec Instructions'
);
});
it('should recreate deleted openspec/project.md in extend mode', async () => {
await testFileRecreationInExtendMode(
testDir,
initCommand,
'openspec/project.md',
'Project Context'
);
});
it('should preserve existing template files in extend mode', async () => {
queueSelections('claude', DONE, DONE);
// First init
await initCommand.execute(testDir);
const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');
const customContent = '# My Custom AGENTS Content\nDo not overwrite this!';
// Modify the file with custom content
await fs.writeFile(agentsPath, customContent);
// Run init again - should NOT overwrite
await initCommand.execute(testDir);
const content = await fs.readFile(agentsPath, 'utf-8');
expect(content).toBe(customContent);
expect(content).not.toContain('OpenSpec Instructions');
});
it('should handle non-existent target directory', async () => {
queueSelections('claude', DONE);
@@ -650,6 +820,18 @@ describe('InitCommand', () => {
expect(claudeChoice.configured).toBe(true);
});
it('should mark Qwen as already configured during extend mode', async () => {
queueSelections('qwen', DONE, 'qwen', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const qwenChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'qwen'
);
expect(qwenChoice.configured).toBe(true);
});
it('should preselect Kilo Code when workflows already exist', async () => {
queueSelections('kilocode', DONE, 'kilocode', DONE);
await initCommand.execute(testDir);
@@ -957,6 +1139,179 @@ describe('InitCommand', () => {
);
expect(crushChoice.configured).toBe(true);
});
it('should create CoStrict slash command files with templates', async () => {
queueSelections('costrict', DONE);
await initCommand.execute(testDir);
const costrictProposal = path.join(
testDir,
'.cospec/openspec/commands/openspec-proposal.md'
);
const costrictApply = path.join(
testDir,
'.cospec/openspec/commands/openspec-apply.md'
);
const costrictArchive = path.join(
testDir,
'.cospec/openspec/commands/openspec-archive.md'
);
expect(await fileExists(costrictProposal)).toBe(true);
expect(await fileExists(costrictApply)).toBe(true);
expect(await fileExists(costrictArchive)).toBe(true);
const proposalContent = await fs.readFile(costrictProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('argument-hint: feature description or request');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(costrictApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
expect(applyContent).toContain('argument-hint: change-id');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(costrictArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('argument-hint: change-id');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
it('should mark CoStrict as already configured during extend mode', async () => {
queueSelections('costrict', DONE, 'costrict', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const costrictChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'costrict'
);
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);
await initCommand.execute(testDir);
const costrictPath = path.join(testDir, 'COSTRICT.md');
expect(await fileExists(costrictPath)).toBe(true);
const content = await fs.readFile(costrictPath, '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 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);
const costrictPath = path.join(testDir, 'COSTRICT.md');
const existingContent =
'# My CoStrict Instructions\nCustom instructions here';
await fs.writeFile(costrictPath, existingContent);
await initCommand.execute(testDir);
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 -->');
expect(updatedContent).toContain('Custom instructions here');
});
});
describe('non-interactive mode', () => {
@@ -1160,6 +1515,32 @@ describe('InitCommand', () => {
});
});
async function testFileRecreationInExtendMode(
testDir: string,
initCommand: InitCommand,
relativePath: string,
expectedContent: string
): Promise<void> {
queueSelections('claude', DONE, DONE);
// First init
await initCommand.execute(testDir);
const filePath = path.join(testDir, relativePath);
expect(await fileExists(filePath)).toBe(true);
// Delete the file
await fs.unlink(filePath);
expect(await fileExists(filePath)).toBe(false);
// Run init again - should recreate the file
await initCommand.execute(testDir);
expect(await fileExists(filePath)).toBe(true);
const content = await fs.readFile(filePath, 'utf-8');
expect(content).toContain(expectedContent);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
+430
View File
@@ -73,6 +73,41 @@ More content after.`;
consoleSpy.mockRestore();
});
it('should update only existing QWEN.md file', async () => {
const qwenPath = path.join(testDir, 'QWEN.md');
const initialContent = `# Qwen Instructions
Some existing content.
<!-- OPENSPEC:START -->
Old OpenSpec content
<!-- OPENSPEC:END -->
More notes here.`;
await fs.writeFile(qwenPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('Some existing content.');
expect(updatedContent).toContain('More notes here.');
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 AI tool files: QWEN.md');
consoleSpy.mockRestore();
});
it('should refresh existing Claude slash command files', async () => {
const proposalPath = path.join(
testDir,
@@ -114,6 +149,87 @@ Old slash content
consoleSpy.mockRestore();
});
it('should refresh existing Qwen slash command files', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
);
await fs.mkdir(path.dirname(applyPath), { recursive: true });
const initialContent = `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(applyPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
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('<!-- OPENSPEC:START -->');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
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: .qwen/commands/openspec-apply.md'
);
consoleSpy.mockRestore();
});
it('should not create missing Qwen slash command files on update', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
);
await fs.mkdir(path.dirname(applyPath), { recursive: true });
await fs.writeFile(
applyPath,
`---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
<!-- OPENSPEC:START -->
Old content
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const updatedApply = await fs.readFile(applyPath, 'utf-8');
expect(updatedApply).toContain('Work through tasks sequentially');
expect(updatedApply).not.toContain('Old content');
const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
);
await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(archivePath)).resolves.toBe(false);
});
it('should not create CLAUDE.md if it does not exist', async () => {
// Ensure CLAUDE.md does not exist
const claudePath = path.join(testDir, 'CLAUDE.md');
@@ -126,6 +242,12 @@ Old slash content
expect(fileExists).toBe(false);
});
it('should not create QWEN.md if it does not exist', async () => {
const qwenPath = path.join(testDir, 'QWEN.md');
await updateCommand.execute(testDir);
await expect(FileSystemUtils.fileExists(qwenPath)).resolves.toBe(false);
});
it('should update only existing CLINE.md file', async () => {
// Create CLINE.md file with initial content
const clinePath = path.join(testDir, 'CLINE.md');
@@ -466,6 +588,54 @@ Old body
await expect(FileSystemUtils.fileExists(ghArchive)).resolves.toBe(false);
});
it('should refresh existing Gemini CLI TOML files without creating new ones', async () => {
const geminiProposal = path.join(
testDir,
'.gemini/commands/openspec/proposal.toml'
);
await fs.mkdir(path.dirname(geminiProposal), { recursive: true });
const initialContent = `description = "Scaffold a new OpenSpec change and validate strictly."
prompt = """
<!-- OPENSPEC:START -->
Old Gemini body
<!-- OPENSPEC:END -->
"""
`;
await fs.writeFile(geminiProposal, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(geminiProposal, 'utf-8');
expect(updated).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
expect(updated).toContain('prompt = """');
expect(updated).toContain('<!-- OPENSPEC:START -->');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain('<!-- OPENSPEC:END -->');
expect(updated).not.toContain('Old Gemini body');
const geminiApply = path.join(
testDir,
'.gemini/commands/openspec/apply.toml'
);
const geminiArchive = path.join(
testDir,
'.gemini/commands/openspec/archive.toml'
);
await expect(FileSystemUtils.fileExists(geminiApply)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(geminiArchive)).resolves.toBe(false);
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated slash commands: .gemini/commands/openspec/proposal.toml'
);
consoleSpy.mockRestore();
});
it('should refresh existing Factory slash commands', async () => {
const factoryPath = path.join(
testDir,
@@ -819,6 +989,266 @@ Old body
await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false);
});
it('should refresh existing CoStrict slash command files', async () => {
const costrictPath = path.join(
testDir,
'.cospec/openspec/commands/openspec-proposal.md'
);
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
const initialContent = `---
description: "Old description"
argument-hint: old-hint
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(costrictPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(costrictPath, 'utf-8');
// For slash commands, only the content between OpenSpec markers is updated
expect(updated).toContain('description: "Old description"');
expect(updated).toContain('argument-hint: old-hint');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
);
expect(updated).not.toContain('Old body');
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: .cospec/openspec/commands/openspec-proposal.md'
);
consoleSpy.mockRestore();
});
it('should refresh existing Qoder slash command files', async () => {
const qoderPath = path.join(
testDir,
'.qoder/commands/openspec/proposal.md'
);
await fs.mkdir(path.dirname(qoderPath), { 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(qoderPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(qoderPath, '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: .qoder/commands/openspec/proposal.md'
);
consoleSpy.mockRestore();
});
it('should not create missing CoStrict slash command files on update', async () => {
const costrictApply = path.join(
testDir,
'.cospec/openspec/commands/openspec-apply.md'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(costrictApply), { recursive: true });
await fs.writeFile(
costrictApply,
`---
description: "Old"
argument-hint: old
---
<!-- OPENSPEC:START -->
Old
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const costrictProposal = path.join(
testDir,
'.cospec/openspec/commands/openspec-proposal.md'
);
const costrictArchive = path.join(
testDir,
'.cospec/openspec/commands/openspec-archive.md'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(costrictProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(costrictArchive)).resolves.toBe(false);
});
it('should not create missing Qoder slash command files on update', async () => {
const qoderApply = path.join(
testDir,
'.qoder/commands/openspec/apply.md'
);
// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(qoderApply), { recursive: true });
await fs.writeFile(
qoderApply,
`---
name: OpenSpec: Apply
description: Old description
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const qoderProposal = path.join(
testDir,
'.qoder/commands/openspec/proposal.md'
);
const qoderArchive = path.join(
testDir,
'.qoder/commands/openspec/archive.md'
);
// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(qoderProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(qoderArchive)).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');
const initialContent = `# CoStrict Instructions
Some existing CoStrict instructions here.
<!-- OPENSPEC:START -->
Old OpenSpec content
<!-- OPENSPEC:END -->
More instructions after.`;
await fs.writeFile(costrictPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
// Execute update command
await updateCommand.execute(testDir);
// Check that COSTRICT.md was updated
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('Some existing CoStrict instructions here');
expect(updatedContent).toContain('More instructions after');
// Check console output
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 AI tool files: COSTRICT.md');
consoleSpy.mockRestore();
});
it('should not create COSTRICT.md if it does not exist', async () => {
// Ensure COSTRICT.md does not exist
const costrictPath = path.join(testDir, 'COSTRICT.md');
// Execute update command
await updateCommand.execute(testDir);
// Check that COSTRICT.md was not created
const fileExists = await FileSystemUtils.fileExists(costrictPath);
expect(fileExists).toBe(false);
});
it('should preserve CoStrict content outside markers during update', async () => {
const costrictPath = path.join(
testDir,
'.cospec/openspec/commands/openspec-proposal.md'
);
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
const initialContent = `## Custom Intro Title\nSome intro text\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\n\nFooter stays`;
await fs.writeFile(costrictPath, initialContent);
await updateCommand.execute(testDir);
const updated = await fs.readFile(costrictPath, 'utf-8');
expect(updated).toContain('## Custom Intro Title');
expect(updated).toContain('Footer stays');
expect(updated).not.toContain('Old body');
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
});
it('should handle configurator errors gracefully for CoStrict', async () => {
// Create COSTRICT.md file but make it read-only to cause an error
const costrictPath = path.join(testDir, 'COSTRICT.md');
await fs.writeFile(
costrictPath,
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
const consoleSpy = vi.spyOn(console, 'log');
const errorSpy = vi.spyOn(console, 'error');
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
const writeSpy = vi
.spyOn(FileSystemUtils, 'writeFile')
.mockImplementation(async (filePath, content) => {
if (filePath.endsWith('COSTRICT.md')) {
throw new Error('EACCES: permission denied, open');
}
return originalWriteFile(filePath, content);
});
// Execute update command - should not throw
await updateCommand.execute(testDir);
// Should report the failure
expect(errorSpy).toHaveBeenCalled();
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Failed to update: COSTRICT.md');
consoleSpy.mockRestore();
errorSpy.mockRestore();
writeSpy.mockRestore();
});
it('should preserve Windsurf content outside markers during update', async () => {
const wsPath = path.join(
testDir,