Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions openspec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,26 @@ Skip proposal for:
- Configuration changes
- Tests for existing behavior

**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.

### Stage 2: Implementing Changes
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Mark complete immediately** - Update `- [x]` after each task
6. **Validate strictly** - Run `openspec validate [change] --strict` and address issues
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved

### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive [change] --skip-specs` for tooling-only changes
- Run `openspec validate --strict` to confirm the archived change passes checks

## Before Any Task

Expand Down
14 changes: 7 additions & 7 deletions openspec/changes/add-slash-command-support/tasks.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# Implementation Tasks

## 1. Templates and Configurators
- [ ] 1.1 Create shared templates for the Proposal, Apply, and Archive commands with instructions for each workflow stage from `openspec/README.md`.
- [ ] 1.2 Implement a `SlashCommandConfigurator` base and tool-specific configurators for Claude Code and Cursor.
- [x] 1.1 Create shared templates for the Proposal, Apply, and Archive commands with instructions for each workflow stage from `openspec/README.md`.
- [x] 1.2 Implement a `SlashCommandConfigurator` base and tool-specific configurators for Claude Code and Cursor.

## 2. Claude Code Integration
- [ ] 2.1 Generate `.claude/commands/openspec/{proposal,apply,archive}.md` during `openspec init` using shared templates.
- [ ] 2.2 Update existing `.claude/commands/openspec/*` files during `openspec update`.
- [x] 2.1 Generate `.claude/commands/openspec/{proposal,apply,archive}.md` during `openspec init` using shared templates.
- [x] 2.2 Update existing `.claude/commands/openspec/*` files during `openspec update`.

## 3. Cursor Integration
- [ ] 3.1 Generate `.cursor/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
- [ ] 3.2 Update existing `.cursor/commands/*` files during `openspec update`.
- [x] 3.1 Generate `.cursor/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
- [x] 3.2 Update existing `.cursor/commands/*` files during `openspec update`.

## 4. Verification
- [ ] 4.1 Add tests verifying slash command files are created and updated correctly.
- [x] 4.1 Add tests verifying slash command files are created and updated correctly.
4 changes: 2 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const OPENSPEC_MARKERS = {

export const AI_TOOLS = [
{ name: 'Claude Code', value: 'claude', available: true },
{ name: 'Cursor', value: 'cursor', available: false },
{ name: 'Cursor', value: 'cursor', available: true },
{ name: 'Aider', value: 'aider', available: false },
{ name: 'Continue', value: 'continue', available: false }
];
];
85 changes: 85 additions & 0 deletions src/core/configurators/slash/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import path from 'path';
import { FileSystemUtils } from '../../../utils/file-system.js';
import { TemplateManager, SlashCommandId } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';

export interface SlashCommandTarget {
id: SlashCommandId;
path: string;
kind: 'slash';
}

const ALL_COMMANDS: SlashCommandId[] = ['proposal', 'apply', 'archive'];

export abstract class SlashCommandConfigurator {
abstract readonly toolId: string;
abstract readonly isAvailable: boolean;

getTargets(): SlashCommandTarget[] {
return ALL_COMMANDS.map((id) => ({
id,
path: this.getRelativePath(id),
kind: 'slash'
}));
}

async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
const createdOrUpdated: string[] = [];

for (const target of this.getTargets()) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
const filePath = path.join(projectPath, target.path);

if (await FileSystemUtils.fileExists(filePath)) {
await this.updateBody(filePath, body);
} else {
const frontmatter = this.getFrontmatter(target.id);
const sections: string[] = [];
if (frontmatter) {
sections.push(frontmatter.trim());
}
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
const content = sections.join('\n') + '\n';
await FileSystemUtils.writeFile(filePath, content);
}

createdOrUpdated.push(target.path);
}

return createdOrUpdated;
}

async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
const updated: string[] = [];

for (const target of this.getTargets()) {
const filePath = path.join(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
await this.updateBody(filePath, body);
updated.push(target.path);
}
}

return updated;
}

protected abstract getRelativePath(id: SlashCommandId): string;
protected abstract getFrontmatter(id: SlashCommandId): string | undefined;

private 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);
}
}
42 changes: 42 additions & 0 deletions src/core/configurators/slash/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.claude/commands/openspec/proposal.md',
apply: '.claude/commands/openspec/apply.md',
archive: '.claude/commands/openspec/archive.md'
};

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]
---`
};

export class ClaudeSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'claude';
readonly isAvailable = true;

protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}

protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
42 changes: 42 additions & 0 deletions src/core/configurators/slash/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.cursor/commands/openspec-proposal.md',
apply: '.cursor/commands/openspec-apply.md',
archive: '.cursor/commands/openspec-archive.md'
};

const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: /openspec-proposal
id: openspec-proposal
category: OpenSpec
description: Scaffold a new OpenSpec change and validate strictly.
---`,
apply: `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
archive: `---
name: /openspec-archive
id: openspec-archive
category: OpenSpec
description: Archive a deployed OpenSpec change and update specs.
---`
};

export class CursorSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'cursor';
readonly isAvailable = true;

protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}

protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
27 changes: 27 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { ClaudeSlashCommandConfigurator } from './claude.js';
import { CursorSlashCommandConfigurator } from './cursor.js';

export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();

static {
const claude = new ClaudeSlashCommandConfigurator();
const cursor = new CursorSlashCommandConfigurator();

this.configurators.set(claude.toolId, claude);
this.configurators.set(cursor.toolId, cursor);
}

static register(configurator: SlashCommandConfigurator): void {
this.configurators.set(configurator.toolId, configurator);
}

static get(toolId: string): SlashCommandConfigurator | undefined {
return this.configurators.get(toolId);
}

static getAll(): SlashCommandConfigurator[] {
return Array.from(this.configurators.values());
}
}
8 changes: 7 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ora from 'ora';
import { FileSystemUtils } from '../utils/file-system.js';
import { TemplateManager, ProjectContext } from './templates/index.js';
import { ToolRegistry } from './configurators/registry.js';
import { SlashCommandRegistry } from './configurators/slash/registry.js';
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';

export class InitCommand {
Expand Down Expand Up @@ -105,6 +106,11 @@ export class InitCommand {
if (configurator && configurator.isAvailable) {
await configurator.configure(projectPath, openspecDir);
}

const slashConfigurator = SlashCommandRegistry.get(toolId);
if (slashConfigurator && slashConfigurator.isAvailable) {
await slashConfigurator.generateAll(projectPath, openspecDir);
}
}
}

Expand All @@ -130,4 +136,4 @@ export class InitCommand {
console.log(' and how I should work with you on this project"');
console.log('────────────────────────────────────────────────────────────\n');
}
}
}
8 changes: 7 additions & 1 deletion src/core/templates/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readmeTemplate } from './readme-template.js';
import { projectTemplate, ProjectContext } from './project-template.js';
import { claudeTemplate } from './claude-template.js';
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';

export interface Template {
path: string;
Expand All @@ -24,6 +25,11 @@ export class TemplateManager {
static getClaudeTemplate(): string {
return claudeTemplate;
}

static getSlashCommandBody(id: SlashCommandId): string {
return getSlashCommandBody(id);
}
}

export { ProjectContext } from './project-template.js';
export { ProjectContext } from './project-template.js';
export type { SlashCommandId } from './slash-command-templates.js';
10 changes: 8 additions & 2 deletions src/core/templates/readme-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,26 @@ Skip proposal for:
- Configuration changes
- Tests for existing behavior

**Workflow**
1. Review \`openspec/project.md\`, \`openspec list\`, and \`openspec list --specs\` to understand current context.
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes/<id>/\`.
3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement.
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.

### Stage 2: Implementing Changes
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Mark complete immediately** - Update \`- [x]\` after each task
6. **Validate strictly** - Run \`openspec validate [change] --strict\` and address issues
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved

### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
- Update \`specs/\` if capabilities changed
- Use \`openspec archive [change] --skip-specs\` for tooling-only changes
- Run \`openspec validate --strict\` to confirm the archived change passes checks

## Before Any Task

Expand Down
45 changes: 45 additions & 0 deletions src/core/templates/slash-command-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type SlashCommandId = 'proposal' | 'apply' | 'archive';

const baseGuardrails = `**Guardrails**
- Default to <100 lines of new code, single-file solutions, and avoid new frameworks unless OpenSpec data requires it.
- Use pnpm for Node.js tooling and keep changes scoped to the requested outcome.`;

const proposalGuardrails = `${baseGuardrails}\n- Ask up to two clarifying questions if the request is ambiguous before editing files.`;

const proposalSteps = `**Steps**
1. Review \`openspec/project.md\`, run \`openspec list\`, and \`openspec list --specs\` to understand current work and capabilities.
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, and optional \`design.md\` under \`openspec/changes/<id>/\`.
3. Draft spec deltas in \`changes/<id>/specs/\` using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement.
4. Validate with \`openspec validate <id> --strict\` and resolve every issue before sharing the proposal.`;

const proposalReferences = `**Reference**
- Use \`openspec show <id> --json --deltas-only\` or \`openspec show <spec> --type spec\` to inspect details when validation fails.
- Search existing requirements with \`rg -n "Requirement:|Scenario:" openspec/specs\` before writing new ones.`;

const applySteps = `**Steps**
1. Read \`changes/<id>/proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Mark each task \`- [x]\` immediately after completing it to keep the checklist in sync.
4. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;

const applyReferences = `**Reference**
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;

const archiveSteps = `**Steps**
1. Confirm deployment is complete, then move \`changes/<id>/\` to \`changes/archive/YYYY-MM-DD-<id>/\`.
2. Update \`openspec/specs/\` to capture production behaviour, editing existing capabilities before creating new ones.
3. Run \`openspec archive <id> --skip-specs\` only for tooling-only work; otherwise ensure spec deltas are committed.
4. Re-run \`openspec validate --strict\` and review with \`openspec show <id>\` to verify archive changes.`;

const archiveReferences = `**Reference**
- Cross-check capabilities with \`openspec list --specs\` and resolve any outstanding validation issues before finishing.`;

export const slashCommandBodies: Record<SlashCommandId, string> = {
proposal: [proposalGuardrails, proposalSteps, proposalReferences].join('\n\n'),
apply: [baseGuardrails, applySteps, applyReferences].join('\n\n'),
archive: [baseGuardrails, archiveSteps, archiveReferences].join('\n\n')
};

export function getSlashCommandBody(id: SlashCommandId): string {
return slashCommandBodies[id];
}
Loading
Loading