diff --git a/openspec/README.md b/openspec/README.md index a9d0cafe..978fd069 100644 --- a/openspec/README.md +++ b/openspec/README.md @@ -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//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --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 diff --git a/openspec/changes/add-slash-command-support/tasks.md b/openspec/changes/add-slash-command-support/tasks.md index cb194c04..f3a71bf8 100644 --- a/openspec/changes/add-slash-command-support/tasks.md +++ b/openspec/changes/add-slash-command-support/tasks.md @@ -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. diff --git a/src/core/config.ts b/src/core/config.ts index 889c5d86..867ae9be 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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 } -]; \ No newline at end of file +]; diff --git a/src/core/configurators/slash/base.ts b/src/core/configurators/slash/base.ts new file mode 100644 index 00000000..8ece4861 --- /dev/null +++ b/src/core/configurators/slash/base.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/core/configurators/slash/claude.ts b/src/core/configurators/slash/claude.ts new file mode 100644 index 00000000..012f661e --- /dev/null +++ b/src/core/configurators/slash/claude.ts @@ -0,0 +1,42 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.claude/commands/openspec/proposal.md', + apply: '.claude/commands/openspec/apply.md', + archive: '.claude/commands/openspec/archive.md' +}; + +const FRONTMATTER: Record = { + 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]; + } +} diff --git a/src/core/configurators/slash/cursor.ts b/src/core/configurators/slash/cursor.ts new file mode 100644 index 00000000..58b07cef --- /dev/null +++ b/src/core/configurators/slash/cursor.ts @@ -0,0 +1,42 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.cursor/commands/openspec-proposal.md', + apply: '.cursor/commands/openspec-apply.md', + archive: '.cursor/commands/openspec-archive.md' +}; + +const FRONTMATTER: Record = { + 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]; + } +} diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts new file mode 100644 index 00000000..ba2fdad0 --- /dev/null +++ b/src/core/configurators/slash/registry.ts @@ -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 = 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()); + } +} diff --git a/src/core/init.ts b/src/core/init.ts index d9a9c01a..4cd12edb 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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 { @@ -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); + } } } @@ -130,4 +136,4 @@ export class InitCommand { console.log(' and how I should work with you on this project"'); console.log('────────────────────────────────────────────────────────────\n'); } -} \ No newline at end of file +} diff --git a/src/core/templates/index.ts b/src/core/templates/index.ts index bae48274..d9be6107 100644 --- a/src/core/templates/index.ts +++ b/src/core/templates/index.ts @@ -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; @@ -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'; \ No newline at end of file +export { ProjectContext } from './project-template.js'; +export type { SlashCommandId } from './slash-command-templates.js'; diff --git a/src/core/templates/readme-template.ts b/src/core/templates/readme-template.ts index aa78424d..22a6ae00 100644 --- a/src/core/templates/readme-template.ts +++ b/src/core/templates/readme-template.ts @@ -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//\`. +3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement. +4. Run \`openspec validate --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 diff --git a/src/core/templates/slash-command-templates.ts b/src/core/templates/slash-command-templates.ts new file mode 100644 index 00000000..6e20efde --- /dev/null +++ b/src/core/templates/slash-command-templates.ts @@ -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//\`. +3. Draft spec deltas in \`changes//specs/\` using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement. +4. Validate with \`openspec validate --strict\` and resolve every issue before sharing the proposal.`; + +const proposalReferences = `**Reference** +- Use \`openspec show --json --deltas-only\` or \`openspec show --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//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 \` when additional context is required.`; + +const applyReferences = `**Reference** +- Use \`openspec show --json --deltas-only\` if you need additional context from the proposal while implementing.`; + +const archiveSteps = `**Steps** +1. Confirm deployment is complete, then move \`changes//\` to \`changes/archive/YYYY-MM-DD-/\`. +2. Update \`openspec/specs/\` to capture production behaviour, editing existing capabilities before creating new ones. +3. Run \`openspec archive --skip-specs\` only for tooling-only work; otherwise ensure spec deltas are committed. +4. Re-run \`openspec validate --strict\` and review with \`openspec show \` 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 = { + 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]; +} diff --git a/src/core/update.ts b/src/core/update.ts index 5566561c..6d0cb6e4 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -3,6 +3,7 @@ import { FileSystemUtils } from '../utils/file-system.js'; import { OPENSPEC_DIR_NAME } from './config.js'; import { readmeTemplate } from './templates/readme-template.js'; import { ToolRegistry } from './configurators/registry.js'; +import { SlashCommandRegistry } from './configurators/slash/registry.js'; export class UpdateCommand { async execute(projectPath: string): Promise { @@ -21,8 +22,11 @@ export class UpdateCommand { // 3. Update existing AI tool configuration files only const configurators = ToolRegistry.getAll(); + const slashConfigurators = SlashCommandRegistry.getAll(); let updatedFiles: string[] = []; let failedFiles: string[] = []; + let updatedSlashFiles: string[] = []; + let failedSlashTools: string[] = []; for (const configurator of configurators) { const configFilePath = path.join(resolvedProjectPath, configurator.configFileName); @@ -39,16 +43,40 @@ export class UpdateCommand { } } + for (const slashConfigurator of slashConfigurators) { + if (!slashConfigurator.isAvailable) { + continue; + } + + try { + const updated = await slashConfigurator.updateExisting(resolvedProjectPath, openspecPath); + updatedSlashFiles = updatedSlashFiles.concat(updated); + } catch (error) { + failedSlashTools.push(slashConfigurator.toolId); + console.error( + `Failed to update slash commands for ${slashConfigurator.toolId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + // 4. Success message (ASCII-safe) const messages: string[] = ['Updated OpenSpec instructions (README.md)']; if (updatedFiles.length > 0) { messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`); } + + if (updatedSlashFiles.length > 0) { + messages.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`); + } if (failedFiles.length > 0) { messages.push(`Failed to update: ${failedFiles.join(', ')}`); } + + if (failedSlashTools.length > 0) { + messages.push(`Failed slash command updates: ${failedSlashTools.join(', ')}`); + } console.log(messages.join('\n')); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 3e5349cc..164d2c0e 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -86,6 +86,59 @@ describe('InitCommand', () => { expect(updatedContent).toContain('Custom instructions here'); }); + it('should create Claude slash command files with templates', async () => { + vi.mocked(prompts.select).mockResolvedValue('claude'); + + await initCommand.execute(testDir); + + const claudeProposal = path.join(testDir, '.claude/commands/openspec/proposal.md'); + const claudeApply = path.join(testDir, '.claude/commands/openspec/apply.md'); + const claudeArchive = path.join(testDir, '.claude/commands/openspec/archive.md'); + + expect(await fileExists(claudeProposal)).toBe(true); + expect(await fileExists(claudeApply)).toBe(true); + expect(await fileExists(claudeArchive)).toBe(true); + + const proposalContent = await fs.readFile(claudeProposal, 'utf-8'); + expect(proposalContent).toContain('name: OpenSpec: Proposal'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(claudeApply, 'utf-8'); + expect(applyContent).toContain('name: OpenSpec: Apply'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(claudeArchive, 'utf-8'); + expect(archiveContent).toContain('name: OpenSpec: Archive'); + expect(archiveContent).toContain('openspec archive --skip-specs'); + }); + + it('should create Cursor slash command files with templates', async () => { + vi.mocked(prompts.select).mockResolvedValue('cursor'); + + await initCommand.execute(testDir); + + const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md'); + const cursorApply = path.join(testDir, '.cursor/commands/openspec-apply.md'); + const cursorArchive = path.join(testDir, '.cursor/commands/openspec-archive.md'); + + expect(await fileExists(cursorProposal)).toBe(true); + expect(await fileExists(cursorApply)).toBe(true); + expect(await fileExists(cursorArchive)).toBe(true); + + const proposalContent = await fs.readFile(cursorProposal, 'utf-8'); + expect(proposalContent).toContain('name: /openspec-proposal'); + expect(proposalContent).toContain(''); + + const applyContent = await fs.readFile(cursorApply, 'utf-8'); + expect(applyContent).toContain('id: openspec-apply'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(cursorArchive, 'utf-8'); + expect(archiveContent).toContain('name: /openspec-archive'); + expect(archiveContent).toContain('openspec list --specs'); + }); + it('should throw error if OpenSpec already exists', async () => { const openspecPath = path.join(testDir, 'openspec'); await fs.mkdir(openspecPath, { recursive: true }); @@ -180,4 +233,4 @@ async function directoryExists(dirPath: string): Promise { } catch { return false; } -} \ No newline at end of file +} diff --git a/test/core/update.test.ts b/test/core/update.test.ts index a6a4cbd1..c08dbf4c 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -61,6 +61,37 @@ More content after.`; consoleSpy.mockRestore(); }); + it('should refresh existing Claude slash command files', async () => { + const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md'); + await fs.mkdir(path.dirname(proposalPath), { recursive: true }); + const initialContent = `--- +name: OpenSpec: Proposal +description: Old description +category: OpenSpec +tags: [openspec, change] +--- + +Old slash content +`; + await fs.writeFile(proposalPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(proposalPath, 'utf-8'); + expect(updated).toContain('name: OpenSpec: Proposal'); + expect(updated).toContain('**Guardrails**'); + expect(updated).toContain('Validate with `openspec validate --strict`'); + expect(updated).not.toContain('Old slash content'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Updated OpenSpec instructions (README.md)\nUpdated slash commands: .claude/commands/openspec/proposal.md' + ); + + consoleSpy.mockRestore(); + }); + 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'); @@ -73,6 +104,36 @@ More content after.`; expect(fileExists).toBe(false); }); + it('should refresh existing Cursor slash command files', async () => { + const cursorPath = path.join(testDir, '.cursor/commands/openspec-apply.md'); + await fs.mkdir(path.dirname(cursorPath), { recursive: true }); + const initialContent = `--- +name: /openspec-apply +id: openspec-apply +category: OpenSpec +description: Old description +--- + +Old body +`; + await fs.writeFile(cursorPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(cursorPath, 'utf-8'); + expect(updated).toContain('id: openspec-apply'); + expect(updated).toContain('Work through tasks sequentially'); + expect(updated).not.toContain('Old body'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Updated OpenSpec instructions (README.md)\nUpdated slash commands: .cursor/commands/openspec-apply.md' + ); + + consoleSpy.mockRestore(); + }); + it('should handle no AI tool files present', async () => { // Execute update command with no AI tool files const consoleSpy = vi.spyOn(console, 'log'); @@ -89,6 +150,7 @@ More content after.`; // that all existing files are updated in a single operation. // For now, we test with just CLAUDE.md. const claudePath = path.join(testDir, 'CLAUDE.md'); + await fs.mkdir(path.dirname(claudePath), { recursive: true }); await fs.writeFile(claudePath, '\nOld\n'); const consoleSpy = vi.spyOn(console, 'log'); @@ -101,6 +163,28 @@ More content after.`; consoleSpy.mockRestore(); }); + it('should skip creating missing slash commands during update', async () => { + const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md'); + await fs.mkdir(path.dirname(proposalPath), { recursive: true }); + await fs.writeFile(proposalPath, `--- +name: OpenSpec: Proposal +description: Existing file +category: OpenSpec +tags: [openspec, change] +--- + +Old content +`); + + await updateCommand.execute(testDir); + + const applyExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/apply.md')); + const archiveExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/archive.md')); + + expect(applyExists).toBe(false); + expect(archiveExists).toBe(false); + }); + it('should never create new AI tool files', async () => { // Get all configurators const configurators = ToolRegistry.getAll(); @@ -162,4 +246,4 @@ More content after.`; consoleSpy.mockRestore(); errorSpy.mockRestore(); }); -}); \ No newline at end of file +});