diff --git a/README.md b/README.md index cb78ab23..901ae7a9 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | Tool | Commands | |------|----------| | **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` | +| **CodeBuddy** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) | | **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) | | **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) | @@ -102,6 +103,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) | | **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/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`. #### AGENTS.md Compatible @@ -140,7 +142,7 @@ openspec init ``` **What happens during initialization:** -- You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub +- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, 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 @@ -148,7 +150,7 @@ openspec init - Primary AI tools can trigger `/openspec` workflows without additional configuration - Run `openspec list` to verify the setup and view any active changes - 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. + so a fresh launch ensures they appear ### Create Your First Change @@ -215,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, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change". +**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex) 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 @@ -327,7 +329,7 @@ Without specs, AI coding assistants generate code from vague prompts, often miss 1. **Initialize OpenSpec** – Run `openspec init` in your repo. 2. **Start with new features** – Ask your AI to capture upcoming work as change proposals. 3. **Grow incrementally** – Each change archives into living specs that document your system. -4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs. +4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs. Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings. diff --git a/src/core/config.ts b/src/core/config.ts index 60cfa0c0..6a49ee29 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -20,6 +20,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' }, { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' }, + { name: 'CodeBuddy', value: 'codebuddy', available: true, successLabel: 'CodeBuddy' }, { 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' }, diff --git a/src/core/configurators/codebuddy.ts b/src/core/configurators/codebuddy.ts new file mode 100644 index 00000000..9cd3dfa5 --- /dev/null +++ b/src/core/configurators/codebuddy.ts @@ -0,0 +1,24 @@ +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 CodeBuddyConfigurator implements ToolConfigurator { + name = 'CodeBuddy'; + configFileName = 'CodeBuddy.md'; + isAvailable = true; + + async configure(projectPath: string, openspecDir: string): Promise { + const filePath = path.join(projectPath, this.configFileName); + const content = TemplateManager.getClaudeTemplate(); + + await FileSystemUtils.updateFileWithMarkers( + filePath, + content, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); + } +} + diff --git a/src/core/configurators/registry.ts b/src/core/configurators/registry.ts index bea85032..a9bfc974 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -1,6 +1,7 @@ import { ToolConfigurator } from './base.js'; import { ClaudeConfigurator } from './claude.js'; import { ClineConfigurator } from './cline.js'; +import { CodeBuddyConfigurator } from './codebuddy.js'; import { AgentsStandardConfigurator } from './agents.js'; export class ToolRegistry { @@ -9,10 +10,12 @@ export class ToolRegistry { static { const claudeConfigurator = new ClaudeConfigurator(); const clineConfigurator = new ClineConfigurator(); + const codeBuddyConfigurator = new CodeBuddyConfigurator(); 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('agents', agentsConfigurator); } diff --git a/src/core/configurators/slash/codebuddy.ts b/src/core/configurators/slash/codebuddy.ts new file mode 100644 index 00000000..94a098d7 --- /dev/null +++ b/src/core/configurators/slash/codebuddy.ts @@ -0,0 +1,43 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.codebuddy/commands/openspec/proposal.md', + apply: '.codebuddy/commands/openspec/apply.md', + archive: '.codebuddy/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 CodeBuddySlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'codebuddy'; + 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 index 9f34b781..ebeef5ac 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -1,5 +1,6 @@ import { SlashCommandConfigurator } from './base.js'; import { ClaudeSlashCommandConfigurator } from './claude.js'; +import { CodeBuddySlashCommandConfigurator } from './codebuddy.js'; import { CursorSlashCommandConfigurator } from './cursor.js'; import { WindsurfSlashCommandConfigurator } from './windsurf.js'; import { KiloCodeSlashCommandConfigurator } from './kilocode.js'; @@ -17,6 +18,7 @@ export class SlashCommandRegistry { static { const claude = new ClaudeSlashCommandConfigurator(); + const codeBuddy = new CodeBuddySlashCommandConfigurator(); const cursor = new CursorSlashCommandConfigurator(); const windsurf = new WindsurfSlashCommandConfigurator(); const kilocode = new KiloCodeSlashCommandConfigurator(); @@ -30,6 +32,7 @@ export class SlashCommandRegistry { const crush = new CrushSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); + this.configurators.set(codeBuddy.toolId, codeBuddy); this.configurators.set(cursor.toolId, cursor); this.configurators.set(windsurf.toolId, windsurf); this.configurators.set(kilocode.toolId, kilocode); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 765647f5..2dd37921 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -810,6 +810,61 @@ describe('InitCommand', () => { expect(auggieChoice.configured).toBe(true); }); + it('should create CodeBuddy slash command files with templates', async () => { + queueSelections('codebuddy', DONE); + + await initCommand.execute(testDir); + + const codeBuddyProposal = path.join( + testDir, + '.codebuddy/commands/openspec/proposal.md' + ); + const codeBuddyApply = path.join( + testDir, + '.codebuddy/commands/openspec/apply.md' + ); + const codeBuddyArchive = path.join( + testDir, + '.codebuddy/commands/openspec/archive.md' + ); + + expect(await fileExists(codeBuddyProposal)).toBe(true); + expect(await fileExists(codeBuddyApply)).toBe(true); + expect(await fileExists(codeBuddyArchive)).toBe(true); + + const proposalContent = await fs.readFile(codeBuddyProposal, '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(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(codeBuddyApply, '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(codeBuddyArchive, '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 --yes'); + }); + + it('should mark CodeBuddy as already configured during extend mode', async () => { + queueSelections('codebuddy', DONE, 'codebuddy', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const codeBuddyChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'codebuddy' + ); + expect(codeBuddyChoice.configured).toBe(true); + }); + it('should create Crush slash command files with templates', async () => { queueSelections('crush', DONE); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 7296c6ce..45388218 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -663,6 +663,84 @@ Old body await expect(FileSystemUtils.fileExists(auggieArchive)).resolves.toBe(false); }); + it('should refresh existing CodeBuddy slash command files', async () => { + const codeBuddyPath = path.join( + testDir, + '.codebuddy/commands/openspec/proposal.md' + ); + await fs.mkdir(path.dirname(codeBuddyPath), { recursive: true }); + const initialContent = `--- +name: OpenSpec: Proposal +description: Old description +category: OpenSpec +tags: [openspec, change] +--- + +Old slash content +`; + await fs.writeFile(codeBuddyPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(codeBuddyPath, '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'); + + 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: .codebuddy/commands/openspec/proposal.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing CodeBuddy slash command files on update', async () => { + const codeBuddyApply = path.join( + testDir, + '.codebuddy/commands/openspec/apply.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(codeBuddyApply), { recursive: true }); + await fs.writeFile( + codeBuddyApply, + `--- +name: OpenSpec: Apply +description: Old description +category: OpenSpec +tags: [openspec, apply] +--- + +Old body +` + ); + + await updateCommand.execute(testDir); + + const codeBuddyProposal = path.join( + testDir, + '.codebuddy/commands/openspec/proposal.md' + ); + const codeBuddyArchive = path.join( + testDir, + '.codebuddy/commands/openspec/archive.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(codeBuddyProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(codeBuddyArchive)).resolves.toBe(false); + }); + it('should refresh existing Crush slash command files', async () => { const crushPath = path.join( testDir,