Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
| **Neovate Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.neovate/commands/openspec/`) — see [docs](https://neovateai.dev/en) |

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`.

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'Neovate Code', value: 'neovate', available: true, successLabel: 'Neovate Code' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
78 changes: 78 additions & 0 deletions src/core/configurators/slash/neovate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';

/**
* File paths for Neovate slash commands
* Maps each OpenSpec workflow stage to its command file location
* Commands are stored in .neovate/commands/openspec/ directory
*/
const FILE_PATHS: Record<SlashCommandId, string> = {
// Create and validate new change proposals
proposal: '.neovate/commands/openspec/proposal.md',

// Implement approved changes with task tracking
apply: '.neovate/commands/openspec/apply.md',

// Archive completed changes and update specs
archive: '.neovate/commands/openspec/archive.md'
};

/**
* YAML frontmatter for Neovate slash commands
* Defines metadata displayed in Neovate's command palette
* Each command is categorized and tagged for easy discovery
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
---`,
apply: `---
name: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
archive: `---
name: Archive
description: Archive a deployed OpenSpec change and update specs.
---`
};

/**
* Neovate Slash Command Configurator
*
* Manages OpenSpec slash commands for Neovate Code AI assistant.
* Creates three workflow commands: proposal, apply, and archive.
* Uses colon-separated command format (/openspec:proposal).
*
* @extends {SlashCommandConfigurator}
*/
export class NeovateSlashCommandConfigurator extends SlashCommandConfigurator {
/** Unique identifier for Neovate tool */
readonly toolId = 'neovate';

/** 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 Neovate'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];
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SlashCommandConfigurator } from './base.js';
import { ClaudeSlashCommandConfigurator } from './claude.js';
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
import { QoderSlashCommandConfigurator } from './qoder.js';
import { NeovateSlashCommandConfigurator } from './neovate.js';
import { CursorSlashCommandConfigurator } from './cursor.js';
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
Expand All @@ -27,6 +28,7 @@ export class SlashCommandRegistry {
const claude = new ClaudeSlashCommandConfigurator();
const codeBuddy = new CodeBuddySlashCommandConfigurator();
const qoder = new QoderSlashCommandConfigurator();
const neovate = new NeovateSlashCommandConfigurator();
const cursor = new CursorSlashCommandConfigurator();
const windsurf = new WindsurfSlashCommandConfigurator();
const kilocode = new KiloCodeSlashCommandConfigurator();
Expand All @@ -48,6 +50,7 @@ export class SlashCommandRegistry {
this.configurators.set(claude.toolId, claude);
this.configurators.set(codeBuddy.toolId, codeBuddy);
this.configurators.set(qoder.toolId, qoder);
this.configurators.set(neovate.toolId, neovate);
this.configurators.set(cursor.toolId, cursor);
this.configurators.set(windsurf.toolId, windsurf);
this.configurators.set(kilocode.toolId, kilocode);
Expand Down
54 changes: 54 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,60 @@ describe('InitCommand', () => {
expect(qoderChoice.configured).toBe(true);
});

it('should create Neovate slash command files with templates', async () => {
queueSelections('neovate', DONE);

await initCommand.execute(testDir);

const neovateProposal = path.join(
testDir,
'.neovate/commands/openspec/proposal.md'
);
const neovateApply = path.join(
testDir,
'.neovate/commands/openspec/apply.md'
);
const neovateArchive = path.join(
testDir,
'.neovate/commands/openspec/archive.md'
);

expect(await fileExists(neovateProposal)).toBe(true);
expect(await fileExists(neovateApply)).toBe(true);
expect(await fileExists(neovateArchive)).toBe(true);

const proposalContent = await fs.readFile(neovateProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: Proposal');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');

const applyContent = await fs.readFile(neovateApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('name: 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(neovateArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('name: Archive');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('openspec archive <id> --yes');
});

it('should mark Neovate as already configured during extend mode', async () => {
queueSelections('neovate', DONE, 'neovate', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);

const secondRunArgs = mockPrompt.mock.calls[1][0];
const neovateChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'neovate'
);
expect(neovateChoice.configured).toBe(true);
});

it('should create COSTRICT.md when CoStrict is selected', async () => {
queueSelections('costrict', DONE);

Expand Down
74 changes: 74 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,80 @@ More instructions after.`;
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
});

it('should refresh existing Neovate slash command files', async () => {
const neovatePath = path.join(
testDir,
'.neovate/commands/openspec/proposal.md'
);
await fs.mkdir(path.dirname(neovatePath), { recursive: true });
const initialContent = `---
name: Proposal
description: Old description
---
<!-- OPENSPEC:START -->
Old slash content
<!-- OPENSPEC:END -->`;
await fs.writeFile(neovatePath, initialContent);

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

const updated = await fs.readFile(neovatePath, 'utf-8');
expect(updated).toContain('name: 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: .neovate/commands/openspec/proposal.md'
);

consoleSpy.mockRestore();
});

it('should not create missing Neovate slash command files on update', async () => {
const neovateApply = path.join(
testDir,
'.neovate/commands/openspec/apply.md'
);

// Only create apply; leave proposal and archive missing
await fs.mkdir(path.dirname(neovateApply), { recursive: true });
await fs.writeFile(
neovateApply,
`---
name: Apply
description: Old description
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);

await updateCommand.execute(testDir);

const neovateProposal = path.join(
testDir,
'.neovate/commands/openspec/proposal.md'
);
const neovateArchive = path.join(
testDir,
'.neovate/commands/openspec/archive.md'
);

// Confirm they weren't created by update
await expect(FileSystemUtils.fileExists(neovateProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(neovateArchive)).resolves.toBe(false);
});

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');
Expand Down