Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 7726d3668e feat: add factory function support for slash commands
This change adds support for factory functions in slash command configuration,
allowing slash commands to be defined as functions that return command objects.
2025-10-14 21:13:57 +11:00
12 changed files with 323 additions and 3 deletions
+1
View File
@@ -92,6 +92,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **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/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
@@ -0,0 +1,12 @@
## Why
Factory's Droid CLI recently shipped custom slash commands that mirror other native assistant integrations. Teams using OpenSpec want the same managed workflows they already get for Cursor, Windsurf, and others so init/update can provision and refresh Factory commands without manual setup.
## What Changes
- Extend the native tool registry so Factory/Droid appears alongside other slash-command integrations during `openspec init`.
- Add shared templates that generate the three Factory custom commands (proposal, apply, archive) and wrap them in OpenSpec markers for safe refreshes.
- Update the init and update command flows so they create or refresh Factory command files when the tool is selected or already present.
- Refresh CLI specs to document the Factory support and align validation expectations.
## Impact
- Affected specs: `specs/cli-init`, `specs/cli-update`
- Affected code (expected): tool registry, slash-command template manager, init/update command helpers, documentation snippets
@@ -0,0 +1,54 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Claude Code
- **WHEN** the user selects Claude Code during initialization
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cursor
- **WHEN** the user selects Cursor during initialization
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
#### Scenario: Generating slash commands for GitHub Copilot
- **WHEN** the user selects GitHub Copilot during initialization
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
- **AND** include `$ARGUMENTS` placeholder to capture user input
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,54 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
#### Scenario: Updating slash commands for Claude Code
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Cursor
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Factory Droid
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
- **AND** skip creating missing files during update
#### Scenario: Updating slash commands for OpenCode
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Kilo Code
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Codex
- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **WHEN** a user runs `openspec update`
- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)
- **AND** preserve any unmanaged content outside the OpenSpec marker block
- **AND** skip creation when a Codex prompt file is missing
#### Scenario: Updating slash commands for GitHub Copilot
- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`
- **THEN** refresh each file using shared templates while preserving the YAML frontmatter
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
@@ -0,0 +1,11 @@
## 1. Factory tool registration
- [x] 1.1 Add Factory/Droid metadata to the native tool registry used by init/update (ID, display name, command paths, availability flags).
- [x] 1.2 Surface Factory in interactive prompts and non-interactive `--tools` parsing alongside existing slash-command integrations.
## 2. Slash command templates
- [x] 2.1 Create shared templates for Factory's `openspec-proposal`, `openspec-apply`, and `openspec-archive` custom commands following Factory's CLI format.
- [x] 2.2 Wire the templates into init/update so generation happens on create and refresh respects OpenSpec markers.
## 3. Verification
- [x] 3.1 Update or add automated coverage that ensures Factory command files are scaffolded and refreshed correctly.
- [x] 3.2 Document the new option in any user-facing copy (help text, README snippets) if required by spec.
+1
View File
@@ -19,6 +19,7 @@ export interface AIToolOption {
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ 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: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
+6 -2
View File
@@ -26,7 +26,7 @@ export abstract class SlashCommandConfigurator {
const createdOrUpdated: string[] = [];
for (const target of this.getTargets()) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
const body = this.getBody(target.id);
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
@@ -54,7 +54,7 @@ export abstract class SlashCommandConfigurator {
for (const target of this.getTargets()) {
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
const body = this.getBody(target.id);
await this.updateBody(filePath, body);
updated.push(target.path);
}
@@ -66,6 +66,10 @@ export abstract class SlashCommandConfigurator {
protected abstract getRelativePath(id: SlashCommandId): string;
protected abstract getFrontmatter(id: SlashCommandId): string | undefined;
protected getBody(id: SlashCommandId): string {
return TemplateManager.getSlashCommandBody(id).trim();
}
// Resolve absolute path for a given slash command target. Subclasses may override
// to redirect to tool-specific locations (e.g., global directories).
resolveAbsolutePath(projectPath: string, id: SlashCommandId): string {
+41
View File
@@ -0,0 +1,41 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.factory/commands/openspec-proposal.md',
apply: '.factory/commands/openspec-apply.md',
archive: '.factory/commands/openspec-archive.md'
};
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
description: Scaffold a new OpenSpec change and validate strictly.
argument-hint: request or feature description
---`,
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
---`
};
export class FactorySlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'factory';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
protected getBody(id: SlashCommandId): string {
const baseBody = super.getBody(id);
return `${baseBody}\n\n$ARGUMENTS`;
}
}
+3
View File
@@ -7,6 +7,7 @@ import { OpenCodeSlashCommandConfigurator } from './opencode.js';
import { CodexSlashCommandConfigurator } from './codex.js';
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
import { FactorySlashCommandConfigurator } from './factory.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -20,6 +21,7 @@ export class SlashCommandRegistry {
const codex = new CodexSlashCommandConfigurator();
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
const amazonQ = new AmazonQSlashCommandConfigurator();
const factory = new FactorySlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(cursor.toolId, cursor);
@@ -29,6 +31,7 @@ export class SlashCommandRegistry {
this.configurators.set(codex.toolId, codex);
this.configurators.set(githubCopilot.toolId, githubCopilot);
this.configurators.set(amazonQ.toolId, amazonQ);
this.configurators.set(factory.toolId, factory);
}
static register(configurator: SlashCommandConfigurator): void {
+5 -1
View File
@@ -84,7 +84,11 @@ describe('openspec CLI e2e basics', () => {
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
await fs.mkdir(emptyProjectDir, { recursive: true });
const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir });
const codexHome = path.join(emptyProjectDir, '.codex');
const result = await runCLI(['init', '--tools', 'all'], {
cwd: emptyProjectDir,
env: { CODEX_HOME: codexHome },
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Tool summary:');
+65
View File
@@ -316,6 +316,59 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('openspec list --specs');
});
it('should create Factory slash command files with templates', async () => {
queueSelections('factory', DONE);
await initCommand.execute(testDir);
const factoryProposal = path.join(
testDir,
'.factory/commands/openspec-proposal.md'
);
const factoryApply = path.join(
testDir,
'.factory/commands/openspec-apply.md'
);
const factoryArchive = path.join(
testDir,
'.factory/commands/openspec-archive.md'
);
expect(await fileExists(factoryProposal)).toBe(true);
expect(await fileExists(factoryApply)).toBe(true);
expect(await fileExists(factoryArchive)).toBe(true);
const proposalContent = await fs.readFile(factoryProposal, 'utf-8');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('argument-hint: request or feature description');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
proposalContent
)?.[1]
).toContain('$ARGUMENTS');
const applyContent = await fs.readFile(factoryApply, 'utf-8');
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');
expect(
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
applyContent
)?.[1]
).toContain('$ARGUMENTS');
const archiveContent = await fs.readFile(factoryArchive, 'utf-8');
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');
expect(
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
archiveContent
)?.[1]
).toContain('$ARGUMENTS');
});
it('should create Codex prompts with templates and placeholders', async () => {
queueSelections('codex', DONE);
@@ -559,6 +612,18 @@ describe('InitCommand', () => {
expect(codexChoice.configured).toBe(true);
});
it('should mark Factory Droid as already configured during extend mode', async () => {
queueSelections('factory', DONE, 'factory', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const factoryChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'factory'
);
expect(factoryChoice.configured).toBe(true);
});
it('should mark GitHub Copilot as already configured during extend mode', async () => {
queueSelections('github-copilot', DONE, 'github-copilot', DONE);
await initCommand.execute(testDir);
+70
View File
@@ -377,6 +377,76 @@ Old body
await expect(FileSystemUtils.fileExists(ghArchive)).resolves.toBe(false);
});
it('should refresh existing Factory slash commands', async () => {
const factoryPath = path.join(
testDir,
'.factory/commands/openspec-proposal.md'
);
await fs.mkdir(path.dirname(factoryPath), { recursive: true });
const initialContent = `---
description: Scaffold a new OpenSpec change and validate strictly.
argument-hint: request or feature description
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(factoryPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(factoryPath, 'utf-8');
expect(updated).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(updated).toContain('argument-hint: request or feature description');
expect(
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(updated)?.[1]
).toContain('$ARGUMENTS');
expect(updated).toContain('**Guardrails**');
expect(updated).not.toContain('Old body');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('.factory/commands/openspec-proposal.md')
);
consoleSpy.mockRestore();
});
it('should not create missing Factory slash command files on update', async () => {
const factoryApply = path.join(
testDir,
'.factory/commands/openspec-apply.md'
);
await fs.mkdir(path.dirname(factoryApply), { recursive: true });
await fs.writeFile(
factoryApply,
`---
description: Old
argument-hint: old
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const factoryProposal = path.join(
testDir,
'.factory/commands/openspec-proposal.md'
);
const factoryArchive = path.join(
testDir,
'.factory/commands/openspec-archive.md'
);
await expect(FileSystemUtils.fileExists(factoryProposal)).resolves.toBe(false);
await expect(FileSystemUtils.fileExists(factoryArchive)).resolves.toBe(false);
});
it('should refresh existing Amazon Q Developer prompts', async () => {
const aqPath = path.join(
testDir,