mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-18 05:59:56 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7157a37a39 | ||
|
|
50f6c254d6 | ||
|
|
b085c17945 | ||
|
|
537e6078b7 | ||
|
|
5439ab0833 | ||
|
|
9b6a763eb8 | ||
|
|
235afac85e | ||
|
|
5f0c5909fb | ||
|
|
d32e50fe36 | ||
|
|
8386b91a71 | ||
|
|
8f9c3c7d0b | ||
|
|
9cdb0743f2 |
+1
-1
@@ -149,4 +149,4 @@ CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,9 +94,10 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
| **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`) |
|
||||
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/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) |
|
||||
@@ -105,6 +106,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
| **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`.
|
||||
@@ -114,7 +116,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Amp • Jules • Gemini CLI • Others |
|
||||
| Amp • Jules • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The Cline implementation was architecturally incorrect. According to Cline's official documentation, Cline uses workflows for on-demand automation and rules for behavioral guidelines. The OpenSpec slash commands are procedural workflows (scaffold → implement → archive), not behavioral rules, so they should be placed in `.clinerules/workflows/` instead of `.clinerules/`.
|
||||
|
||||
## What Changes
|
||||
- Update ClineSlashCommandConfigurator to use `.clinerules/workflows/` paths instead of `.clinerules/` paths
|
||||
- Update all tests to expect the correct workflow file locations
|
||||
- Update README.md documentation to reflect workflows instead of rules
|
||||
- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (corrected Cline workflow paths)
|
||||
- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md
|
||||
- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Delta for CLI Init
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Update ClineSlashCommandConfigurator
|
||||
- [x] Change FILE_PATHS in `src/core/configurators/slash/cline.ts` from `.clinerules/openspec-*.md` to `.clinerules/workflows/openspec-*.md`
|
||||
|
||||
## 2. Update Tests
|
||||
- [x] Update "should refresh existing Cline rule files" test in `test/core/update.test.ts` to use workflow paths
|
||||
- [x] Update "should create Cline rule files with templates" test in `test/core/init.test.ts` to use workflow paths
|
||||
|
||||
## 3. Update Documentation
|
||||
- [x] Update README.md table to show "Workflows in `.clinerules/workflows/` directory" for Cline
|
||||
|
||||
## 4. Validate Changes
|
||||
- [x] Ensure all tests pass with the new paths
|
||||
- [x] Verify the change follows OpenSpec conventions
|
||||
@@ -73,7 +73,7 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
#### Scenario: Configuring Cline
|
||||
|
||||
- **WHEN** Cline is selected
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **THEN** create or update `.clinerules/openspec-rules.md` (following Cline's convention for rules files)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
@@ -174,7 +174,7 @@ The init command SHALL generate slash command files for supported editors using
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -63,11 +63,16 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cline
|
||||
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating Cline rules file
|
||||
- **WHEN** `.clinerules/openspec-rules.md` exists
|
||||
- **THEN** refresh the managed block to point teammates to `@/openspec/AGENTS.md`
|
||||
- **AND** preserve any content outside the managed markers
|
||||
|
||||
#### Scenario: Updating slash commands for Crush
|
||||
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
@@ -117,6 +122,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
@@ -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": {
|
||||
|
||||
Generated
+5
-5
@@ -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: {}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
|
||||
{ name: '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' },
|
||||
@@ -32,5 +33,6 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ 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' }
|
||||
];
|
||||
|
||||
@@ -6,13 +6,13 @@ import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class ClineConfigurator implements ToolConfigurator {
|
||||
name = 'Cline';
|
||||
configFileName = 'CLINE.md';
|
||||
configFileName = '.clinerules/openspec-rules.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClineTemplate();
|
||||
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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();
|
||||
@@ -16,6 +17,7 @@ export class ToolRegistry {
|
||||
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);
|
||||
@@ -23,6 +25,7 @@ export class ToolRegistry {
|
||||
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 {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.clinerules/openspec-proposal.md',
|
||||
apply: '.clinerules/openspec-apply.md',
|
||||
archive: '.clinerules/openspec-archive.md'
|
||||
proposal: '.clinerules/workflows/openspec-proposal.md',
|
||||
apply: '.clinerules/workflows/openspec-apply.md',
|
||||
archive: '.clinerules/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -10,10 +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();
|
||||
@@ -30,10 +32,12 @@ 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);
|
||||
@@ -46,10 +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 {
|
||||
|
||||
@@ -126,6 +126,10 @@ export class FileSystemUtils {
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
static async deleteFile(filePath: string): Promise<void> {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
|
||||
static async updateFileWithMarkers(
|
||||
filePath: string,
|
||||
content: string,
|
||||
|
||||
+154
-27
@@ -10,7 +10,7 @@ type SelectionQueue = string[][];
|
||||
|
||||
let selectionQueue: SelectionQueue = [];
|
||||
|
||||
const mockPrompt = vi.fn(async () => {
|
||||
const mockPrompt = vi.fn(async (options: any) => {
|
||||
if (selectionQueue.length === 0) {
|
||||
throw new Error('No queued selections provided to init prompt.');
|
||||
}
|
||||
@@ -136,38 +136,21 @@ describe('InitCommand', () => {
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create CLINE.md when Cline is selected', async () => {
|
||||
it('should create .clinerules/openspec-rules.md when Cline is selected', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
expect(await fileExists(clinePath)).toBe(true);
|
||||
const clineRulesPath = path.join(testDir, '.clinerules', 'openspec-rules.md');
|
||||
expect(await fileExists(clineRulesPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(clinePath, 'utf-8');
|
||||
const content = await fs.readFile(clineRulesPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CLINE.md with markers', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
const existingContent =
|
||||
'# My Cline Rules\nCustom Cline instructions here';
|
||||
await fs.writeFile(clinePath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(clinePath, '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 Cline instructions here');
|
||||
});
|
||||
|
||||
it('should create Windsurf workflows when Windsurf is selected', async () => {
|
||||
queueSelections('windsurf', DONE);
|
||||
@@ -305,6 +288,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,22 +399,87 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Cline rule files with templates', async () => {
|
||||
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 workflow files with templates', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clineProposal = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
'.clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
const clineApply = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-apply.md'
|
||||
'.clinerules/workflows/openspec-apply.md'
|
||||
);
|
||||
const clineArchive = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-archive.md'
|
||||
'.clinerules/workflows/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(clineProposal)).toBe(true);
|
||||
@@ -688,6 +803,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);
|
||||
|
||||
+186
-15
@@ -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,9 +242,16 @@ Old slash content
|
||||
expect(fileExists).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');
|
||||
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 .clinerules/openspec-rules.md file', async () => {
|
||||
// Create .clinerules/openspec-rules.md file with initial content
|
||||
const clineRulesPath = path.join(testDir, '.clinerules', 'openspec-rules.md');
|
||||
await fs.mkdir(path.dirname(clineRulesPath), { recursive: true });
|
||||
const initialContent = `# Cline Rules
|
||||
|
||||
Some existing Cline rules here.
|
||||
@@ -138,15 +261,15 @@ Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More rules after.`;
|
||||
await fs.writeFile(clinePath, initialContent);
|
||||
await fs.writeFile(clineRulesPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLINE.md was updated
|
||||
const updatedContent = await fs.readFile(clinePath, 'utf-8');
|
||||
// Check that .clinerules/openspec-rules.md was updated
|
||||
const updatedContent = await fs.readFile(clineRulesPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
@@ -160,26 +283,26 @@ More rules after.`;
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLINE.md');
|
||||
expect(logMessage).toContain('Updated AI tool files: .clinerules/openspec-rules.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create CLINE.md if it does not exist', async () => {
|
||||
// Ensure CLINE.md does not exist
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
it('should not create .clinerules/openspec-rules.md if it does not exist', async () => {
|
||||
// Ensure .clinerules/openspec-rules.md does not exist
|
||||
const clineRulesPath = path.join(testDir, '.clinerules', 'openspec-rules.md');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLINE.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(clinePath);
|
||||
// Check that .clinerules/openspec-rules.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(clineRulesPath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Cline rule files', async () => {
|
||||
it('should refresh existing Cline workflow files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
'.clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
const initialContent = `# OpenSpec: Proposal
|
||||
@@ -209,7 +332,7 @@ Old slash content
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .clinerules/openspec-proposal.md'
|
||||
'Updated slash commands: .clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -466,6 +589,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,
|
||||
|
||||
Reference in New Issue
Block a user