Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 65ffe1ce64 fix: manually merge and archive four completed OpenSpec changes
Merged and archived four concurrent changes that modified the same specs:
- add-cline-support: Added Cline AI tool configuration
- add-crush-support: Added Crush AI tool configuration
- add-factory-slash-commands: Added Factory Droid slash commands
- add-archive-command-arguments: Added archive command argument support

Changes to cli-init/spec.md:
- Added Cline and CodeBuddy Code configuration scenarios
- Added slash command scenarios for CodeBuddy Code, Cline, Crush, Factory Droid

Changes to cli-update/spec.md:
- Added update scenarios for CodeBuddy Code, Cline, Crush, Factory Droid
- Added Archive Command Argument Support requirement
- Modified OpenCode scenario to support $ARGUMENTS placeholder

This required manual intervention to prevent data loss from OpenSpec's
requirement-level replacement during archiving. The archive operations
initially overwrote scenarios from earlier changes, requiring restoration
of all missing content to preserve complete spec state.

All four changes successfully archived to openspec/changes/archive/.
2025-10-22 16:03:14 +11:00
18 changed files with 34 additions and 945 deletions
-29
View File
@@ -1,34 +1,5 @@
# @fission-ai/openspec
## 0.13.0
### Minor Changes
- 668a125: Add support for multiple AI assistants and improve validation
This release adds support for several new AI coding assistants:
- CodeBuddy Code - AI-powered coding assistant
- CodeRabbit - AI code review assistant
- Cline - Claude-powered CLI assistant
- Crush AI - AI assistant platform
- Auggie (Augment CLI) - Code augmentation tool
New features:
- Archive slash command now supports arguments for more flexible workflows
Bug fixes:
- Delta spec validation now handles case-insensitive headers and properly detects empty sections
- Archive validation now correctly honors --no-validate flag and ignores metadata
Documentation improvements:
- Added VS Code dev container configuration for easier development setup
- Updated AGENTS.md with explicit change-id notation
- Enhanced slash commands documentation with restart notes
## 0.12.0
### Minor Changes
+2 -16
View File
@@ -92,14 +92,11 @@ 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/`) |
| **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/`) |
@@ -145,7 +142,7 @@ openspec init
```
**What happens during initialization:**
- 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
- 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
- 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
@@ -155,17 +152,6 @@ 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.
@@ -231,7 +217,7 @@ Or run the command yourself in terminal:
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
```
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder) 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) 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.13.0",
"version": "0.12.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+11 -11
View File
@@ -28,7 +28,7 @@ export class ChangeCommand {
*/
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const changes = await this.getActiveChanges(changesPath);
@@ -49,25 +49,25 @@ export class ChangeCommand {
return;
}
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
try {
await fs.access(proposalPath);
} catch {
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}
if (options?.json) {
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
if (options.requirementsOnly) {
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
}
const parsed: Change = JSON.parse(jsonOutput);
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(contentForTitle, changeName);
const title = this.extractTitle(contentForTitle);
const id = parsed.name;
const deltas = parsed.deltas || [];
@@ -124,7 +124,7 @@ export class ChangeCommand {
return {
id: changeName,
title: this.extractTitle(content, changeName),
title: this.extractTitle(content),
deltaCount: change.deltas.length,
taskStatus,
};
@@ -159,7 +159,7 @@ export class ChangeCommand {
const tasksPath = path.join(changesPath, changeName, 'tasks.md');
try {
const content = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(content, changeName);
const title = this.extractTitle(content);
let taskStatusText = '';
try {
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
@@ -258,9 +258,9 @@ export class ChangeCommand {
}
}
private extractTitle(content: string, changeName: string): string {
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
return match ? match[1].trim() : changeName;
private extractTitle(content: string): string {
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/m);
return match ? match[1].trim() : 'Untitled Change';
}
private countTasks(content: string): { total: number; completed: number } {
-2
View File
@@ -21,13 +21,11 @@ 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: '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' },
-23
View File
@@ -1,23 +0,0 @@
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
@@ -1,53 +0,0 @@
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
);
}
}
-6
View File
@@ -2,8 +2,6 @@ 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';
export class ToolRegistry {
@@ -13,15 +11,11 @@ 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();
// 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);
}
-36
View File
@@ -1,36 +0,0 @@
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];
}
}
+1 -6
View File
@@ -22,12 +22,7 @@ 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
@@ -1,84 +0,0 @@
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];
}
}
-6
View File
@@ -1,7 +1,6 @@
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';
@@ -13,7 +12,6 @@ import { FactorySlashCommandConfigurator } from './factory.js';
import { AuggieSlashCommandConfigurator } from './auggie.js';
import { ClineSlashCommandConfigurator } from './cline.js';
import { CrushSlashCommandConfigurator } from './crush.js';
import { CostrictSlashCommandConfigurator } from './costrict.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -21,7 +19,6 @@ 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();
@@ -33,11 +30,9 @@ export class SlashCommandRegistry {
const auggie = new AuggieSlashCommandConfigurator();
const cline = new ClineSlashCommandConfigurator();
const crush = new CrushSlashCommandConfigurator();
const costrict = new CostrictSlashCommandConfigurator();
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);
@@ -49,7 +44,6 @@ export class SlashCommandRegistry {
this.configurators.set(auggie.toolId, auggie);
this.configurators.set(cline.toolId, cline);
this.configurators.set(crush.toolId, crush);
this.configurators.set(costrict.toolId, costrict);
}
static register(configurator: SlashCommandConfigurator): void {
+19 -86
View File
@@ -21,7 +21,6 @@ import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
AIToolOption,
OPENSPEC_MARKERS,
} from './config.js';
import { PALETTE } from './styles/palette.js';
@@ -389,7 +388,7 @@ export class InitCommand {
// Validation happens silently in the background
const extendMode = await this.validate(projectPath, openspecPath);
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);
const existingToolStates = await this.getExistingToolStates(projectPath);
this.renderBanner(extendMode);
@@ -428,11 +427,9 @@ export class InitCommand {
} else {
ora({ stream: process.stdout }).info(
PALETTE.midGray(
'ℹ OpenSpec already initialized. Checking for missing files...'
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
)
);
await this.createDirectoryStructure(openspecPath);
await this.ensureTemplateFiles(openspecPath, config);
}
// Step 2: Configure AI tools
@@ -630,78 +627,35 @@ export class InitCommand {
}
private async getExistingToolStates(
projectPath: string,
extendMode: boolean
projectPath: string
): Promise<Record<string, boolean>> {
// Fresh initialization - no tools configured yet
if (!extendMode) {
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
const states: Record<string, boolean> = {};
for (const tool of AI_TOOLS) {
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
}
// Extend mode - check all tools in parallel for better performance
const entries = await Promise.all(
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
);
return Object.fromEntries(entries);
return states;
}
private async isToolConfigured(
projectPath: string,
toolId: string
): Promise<boolean> {
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
// For tools with both config files and slash commands, BOTH must have markers.
// For slash commands, at least one file with markers is sufficient (not all required).
// Helper to check if a file exists and contains OpenSpec markers
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
try {
const content = await FileSystemUtils.readFile(absolutePath);
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
} catch {
return false;
}
};
let hasConfigFile = false;
let hasSlashCommands = false;
// Check if the tool has a config file with OpenSpec markers
const configFile = ToolRegistry.get(toolId)?.configFileName;
if (configFile) {
const configPath = path.join(projectPath, configFile);
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
}
if (
configFile &&
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
)
return true;
// Check if any slash command file exists with OpenSpec markers
const slashConfigurator = SlashCommandRegistry.get(toolId);
if (slashConfigurator) {
for (const target of slashConfigurator.getTargets()) {
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
hasSlashCommands = true;
break; // At least one file with markers is sufficient
}
}
if (!slashConfigurator) return false;
for (const target of slashConfigurator.getTargets()) {
const absolute = slashConfigurator.resolveAbsolutePath(
projectPath,
target.id
);
if (await FileSystemUtils.fileExists(absolute)) return true;
}
// Tool is only configured if BOTH exist with markers
// OR if the tool has no config file requirement (slash commands only)
// OR if the tool has no slash commands requirement (config file only)
const hasConfigFileRequirement = configFile !== undefined;
const hasSlashCommandRequirement = slashConfigurator !== undefined;
if (hasConfigFileRequirement && hasSlashCommandRequirement) {
// Both are required - both must be present with markers
return hasConfigFile && hasSlashCommands;
} else if (hasConfigFileRequirement) {
// Only config file required
return hasConfigFile;
} else if (hasSlashCommandRequirement) {
// Only slash commands required
return hasSlashCommands;
}
return false;
}
@@ -721,21 +675,6 @@ 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
@@ -745,12 +684,6 @@ 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)
-2
View File
@@ -160,8 +160,6 @@ New request?
2. **Write proposal.md:**
\`\`\`markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
-1
View File
@@ -1 +0,0 @@
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
-5
View File
@@ -2,7 +2,6 @@ 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';
@@ -33,10 +32,6 @@ export class TemplateManager {
return clineTemplate;
}
static getCostrictTemplate(): string {
return costrictTemplate;
}
static getAgentsStandardTemplate(): string {
return agentsRootStubTemplate;
}
-318
View File
@@ -579,44 +579,6 @@ 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);
@@ -995,179 +957,6 @@ 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', () => {
@@ -1262,87 +1051,6 @@ describe('InitCommand', () => {
});
});
describe('already configured detection', () => {
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
// Simulate user having their own CLAUDE.md before running openspec init
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');
queueSelections('claude', DONE);
await initCommand.execute(testDir);
// In the first run (non-interactive mode via queueSelections),
// the prompt is called with configured: false for claude
const firstCallArgs = mockPrompt.mock.calls[0][0];
const claudeChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);
expect(claudeChoice.configured).toBe(false);
});
it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
// Simulate user having their own custom slash commands
const customCommandDir = path.join(testDir, '.claude/commands/custom');
await fs.mkdir(customCommandDir, { recursive: true });
await fs.writeFile(
path.join(customCommandDir, 'mycommand.md'),
'# My Custom Command\n'
);
queueSelections('claude', DONE);
await initCommand.execute(testDir);
const firstCallArgs = mockPrompt.mock.calls[0][0];
const claudeChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);
expect(claudeChoice.configured).toBe(false);
});
it('should show tools as already configured in extend mode', async () => {
// First initialization
queueSelections('claude', DONE);
await initCommand.execute(testDir);
// Second initialization (extend mode)
queueSelections('cursor', DONE);
await initCommand.execute(testDir);
const secondCallArgs = mockPrompt.mock.calls[1][0];
const claudeChoice = secondCallArgs.choices.find(
(choice: any) => choice.value === 'claude'
);
expect(claudeChoice.configured).toBe(true);
});
it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
// Create global Codex prompts (simulating previous installation)
const codexPromptsDir = path.join(testDir, '.codex/prompts');
await fs.mkdir(codexPromptsDir, { recursive: true });
await fs.writeFile(
path.join(codexPromptsDir, 'openspec-proposal.md'),
'# Existing prompt\n'
);
queueSelections('claude', DONE);
await initCommand.execute(testDir);
const firstCallArgs = mockPrompt.mock.calls[0][0];
const codexChoice = firstCallArgs.choices.find(
(choice: any) => choice.value === 'codex'
);
// In fresh init, even global tools should not show as configured
expect(codexChoice.configured).toBe(false);
});
});
describe('error handling', () => {
it('should provide helpful error for insufficient permissions', async () => {
// This is tricky to test cross-platform, but we can test the error message
@@ -1371,32 +1079,6 @@ 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);
-260
View File
@@ -819,266 +819,6 @@ 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,