diff --git a/openspec/changes/multi-provider-skill-generation/.openspec.yaml b/openspec/changes/multi-provider-skill-generation/.openspec.yaml new file mode 100644 index 00000000..ec9a9903 --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-22 diff --git a/openspec/changes/multi-provider-skill-generation/design.md b/openspec/changes/multi-provider-skill-generation/design.md new file mode 100644 index 00000000..bdf87183 --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/design.md @@ -0,0 +1,144 @@ +## Context + +The `artifact-experimental-setup` command generates skill files and opsx slash commands for AI coding assistants. Currently it hardcodes paths to `.claude/skills` and `.claude/commands/opsx`. + +The existing `AI_TOOLS` array in `config.ts` lists 22 AI tools but lacks path information. There's also an existing `SlashCommandConfigurator` system for the old workflow commands, but it's tightly coupled to the old 3 commands (proposal, apply, archive) and can't be easily extended for the 9 opsx commands. + +Each AI tool has: +- Different skill directory conventions (`.claude/skills/`, `.cursor/skills/`, etc.) +- Different command file paths (`.claude/commands/opsx/`, `.cursor/commands/`, etc.) +- Different frontmatter formats (YAML keys, structure varies by tool) + +## Goals / Non-Goals + +**Goals:** +- Support skill generation for any AI tool following the Agent Skills spec +- Support command generation with tool-specific formatting via adapters +- Require explicit tool selection (no defaults) +- Create a generic, extensible command generation system + +**Non-Goals:** +- Global path installation (deferred to future work) +- Multi-tool generation in single command (future enhancement) +- Unifying with existing SlashCommandConfigurator (separate systems for now) + +## Decisions + +### 1. Add `skillsDir` to `AIToolOption` interface + +**Decision**: Add single `skillsDir` field to existing interface. No `commandsDir` or `globalSkillsDir`. + +```typescript +interface AIToolOption { + name: string; + value: string; + available: boolean; + successLabel?: string; + skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec +} +``` + +**Rationale**: +- Skills follow Agent Skills spec: `/skills/` - suffix is standard +- Commands need per-tool formatting, handled by adapters (not a simple path) +- Global paths deferred - can extend interface later + +### 2. Strategy/Adapter pattern for command generation + +**Decision**: Create generic command generation with tool-specific adapters. + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ CommandContent │ +│ (tool-agnostic: id, name, description, category, tags, body) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ generateCommand(content, adapter) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Claude │ │ Cursor │ │ Windsurf │ + │ Adapter │ │ Adapter │ │ Adapter │ + └──────────┘ └──────────┘ └──────────┘ +``` + +**Interfaces:** + +```typescript +// Tool-agnostic command data +interface CommandContent { + id: string; // e.g., 'explore', 'new', 'apply' + name: string; // e.g., 'OpenSpec Explore' + description: string; // e.g., 'Enter explore mode...' + category: string; // e.g., 'OpenSpec' + tags: string[]; // e.g., ['openspec', 'explore'] + body: string; // The command instructions +} + +// Per-tool formatting strategy +interface ToolCommandAdapter { + toolId: string; + getFilePath(commandId: string): string; + formatFile(content: CommandContent): string; +} +``` + +**Rationale**: +- Separates "what to generate" from "how to format it" +- Each tool's frontmatter quirks encapsulated in its adapter +- Easy to add new tools by implementing adapter interface +- Body content shared across all tools + +**Alternative considered**: Extend existing SlashCommandConfigurator +- Rejected: Tightly coupled to old 3 commands, significant refactor needed + +### 3. Adapter registry pattern + +**Decision**: Create `CommandAdapterRegistry` similar to existing `SlashCommandRegistry`. + +```typescript +class CommandAdapterRegistry { + private static adapters: Map = new Map(); + + static get(toolId: string): ToolCommandAdapter | undefined; + static getAll(): ToolCommandAdapter[]; +} +``` + +**Rationale**: +- Consistent with existing codebase patterns +- Easy lookup by tool ID +- Centralized registration + +### 4. Required tool flag + +**Decision**: Require `--tool` flag - error if omitted. + +**Rationale**: +- Explicit tool selection avoids assumptions +- Consistent with project convention of not providing defaults +- Users must consciously choose their target tool + +## Risks / Trade-offs + +**[Risk] Adapter maintenance burden** → Each new tool needs an adapter. Mitigated by simple interface - most adapters are ~20 lines. + +**[Risk] Frontmatter format drift** → Tools may change their formats. Mitigated by encapsulating format in adapter - single place to update. + +**[Trade-off] Two command systems** → Old SlashCommandConfigurator and new CommandAdapterRegistry coexist. Acceptable for now - can unify later if needed. + +**[Trade-off] skillsDir optional** → Tools without skillsDir configured will error. Acceptable - we add paths as tools are tested. + +## Implementation Approach + +1. Add `skillsDir` to `AIToolOption` and populate for known tools +2. Create `CommandContent` and `ToolCommandAdapter` interfaces +3. Implement adapters for Claude, Cursor, Windsurf (start with 3) +4. Create `CommandAdapterRegistry` +5. Create `generateCommand()` function +6. Update `artifact-experimental-setup` to use new system +7. Add `--tool` flag with validation diff --git a/openspec/changes/multi-provider-skill-generation/proposal.md b/openspec/changes/multi-provider-skill-generation/proposal.md new file mode 100644 index 00000000..930c948c --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/proposal.md @@ -0,0 +1,36 @@ +## Why + +The `artifact-experimental-setup` command currently hardcodes skill output paths to `.claude/skills` and `.claude/commands/opsx`. This prevents users of other AI coding tools (Cursor, Windsurf, Codex, etc.) from using OpenSpec's skill generation. We need to support the diverse ecosystem of AI coding assistants, each with their own conventions for skill/instruction file locations and command frontmatter formats. + +## What Changes + +- Add `skillsDir` path configuration to the existing `AIToolOption` interface in `config.ts` +- Add required `--tool ` flag to the `artifact-experimental-setup` command +- Create a generic command generation system using Strategy/Adapter pattern: + - `CommandContent`: tool-agnostic command data (id, name, description, body) + - `ToolCommandAdapter`: per-tool formatting (file paths, frontmatter format) + - `CommandGenerator`: orchestrates generation using content + adapter +- Require explicit tool selection (no default) for clarity + +## Capabilities + +### New Capabilities + +- `ai-tool-paths`: Configuration mapping AI tool IDs to their project-local skill directory paths +- `command-generation`: Generic command generation system with tool adapters for formatting differences + +### Modified Capabilities + +- `cli-artifact-workflow`: Adding `--tool` flag to setup command for provider selection + +## Impact + +- **Files Modified**: + - `src/core/config.ts` - Extend `AIToolOption` interface with `skillsDir` field + - `src/commands/artifact-workflow.ts` - Add `--tool` flag, use provider paths and adapters +- **New Files**: + - `src/core/command-generation/types.ts` - CommandContent, ToolCommandAdapter interfaces + - `src/core/command-generation/generator.ts` - Generic command generator + - `src/core/command-generation/adapters/*.ts` - Per-tool adapters +- **Backward Compatibility**: Existing workflows unaffected - this is a new command setup feature +- **User-Facing**: Required `--tool` flag on `artifact-experimental-setup` command for explicit tool selection diff --git a/openspec/changes/multi-provider-skill-generation/specs/ai-tool-paths/spec.md b/openspec/changes/multi-provider-skill-generation/specs/ai-tool-paths/spec.md new file mode 100644 index 00000000..7a0ded86 --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/specs/ai-tool-paths/spec.md @@ -0,0 +1,63 @@ +# ai-tool-paths Specification + +## Purpose + +Define the path configuration for AI coding tool skill directories, enabling skill generation to target different tools following the Agent Skills spec. + +## Requirements + +## ADDED Requirements + +### Requirement: AIToolOption skillsDir field + +The `AIToolOption` interface SHALL include an optional `skillsDir` field for skill generation path configuration. + +#### Scenario: Interface includes skillsDir field + +- **WHEN** a tool entry is defined in `AI_TOOLS` that supports skill generation +- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (e.g., `.claude`) + +#### Scenario: Skills path follows Agent Skills spec + +- **WHEN** generating skills for a tool with `skillsDir: '.claude'` +- **THEN** skills SHALL be written to `//skills/` +- **AND** the `/skills` suffix is appended per Agent Skills specification + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **WHEN** looking up the `windsurf` tool +- **THEN** `skillsDir` SHALL be `.windsurf` + +#### Scenario: Tools without skillsDir + +- **WHEN** a tool has no `skillsDir` defined +- **THEN** skill generation SHALL error with message indicating the tool is not supported + +### Requirement: Cross-platform path handling + +The system SHALL handle paths correctly across operating systems. + +#### Scenario: Path construction on Windows + +- **WHEN** constructing skill paths on Windows +- **THEN** the system SHALL use `path.join()` for all path construction +- **AND** SHALL NOT hardcode forward slashes + +#### Scenario: Path construction on Unix + +- **WHEN** constructing skill paths on macOS or Linux +- **THEN** the system SHALL use `path.join()` for consistency diff --git a/openspec/changes/multi-provider-skill-generation/specs/cli-artifact-workflow/spec.md b/openspec/changes/multi-provider-skill-generation/specs/cli-artifact-workflow/spec.md new file mode 100644 index 00000000..45d48508 --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,60 @@ +# cli-artifact-workflow Delta Specification + +## Purpose + +Add `--tool` flag to the `artifact-experimental-setup` command for multi-provider support. + +## ADDED Requirements + +### Requirement: Tool selection flag + +The `artifact-experimental-setup` command SHALL accept a `--tool ` flag to specify the target AI tool. + +#### Scenario: Specify tool via flag + +- **WHEN** user runs `openspec artifact-experimental-setup --tool cursor` +- **THEN** skill files are generated in `.cursor/skills/` +- **AND** command files are generated using Cursor's frontmatter format + +#### Scenario: Missing tool flag + +- **WHEN** user runs `openspec artifact-experimental-setup` without `--tool` +- **THEN** the system displays an error requiring the `--tool` flag +- **AND** lists valid tool IDs in the error message + +#### Scenario: Unknown tool ID + +- **WHEN** user runs `openspec artifact-experimental-setup --tool unknown-tool` +- **AND** the tool ID is not in `AI_TOOLS` +- **THEN** the system displays an error listing valid tool IDs + +#### Scenario: Tool without skillsDir + +- **WHEN** user specifies a tool that has no `skillsDir` configured +- **THEN** the system displays an error indicating skill generation is not supported for that tool + +#### Scenario: Tool without command adapter + +- **WHEN** user specifies a tool that has `skillsDir` but no command adapter registered +- **THEN** skill files are generated successfully +- **AND** command generation is skipped with informational message + +### Requirement: Output messaging + +The setup command SHALL display clear output about what was generated. + +#### Scenario: Show target tool in output + +- **WHEN** setup command runs successfully +- **THEN** output includes the target tool name (e.g., "Setting up for Cursor...") + +#### Scenario: Show generated paths + +- **WHEN** setup command completes +- **THEN** output lists all generated skill file paths +- **AND** lists all generated command file paths (if applicable) + +#### Scenario: Show skipped commands message + +- **WHEN** command generation is skipped due to missing adapter +- **THEN** output includes message: "Command generation skipped - no adapter for " diff --git a/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md b/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md new file mode 100644 index 00000000..7374b20a --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md @@ -0,0 +1,98 @@ +# command-generation Specification + +## Purpose + +Define a generic command generation system that supports multiple AI tools through a Strategy/Adapter pattern, separating command content from tool-specific formatting. + +## ADDED Requirements + +### Requirement: CommandContent interface + +The system SHALL define a tool-agnostic `CommandContent` interface for command data. + +#### Scenario: CommandContent structure + +- **WHEN** defining a command to generate +- **THEN** `CommandContent` SHALL include: + - `id`: string identifier (e.g., 'explore', 'apply') + - `name`: human-readable name (e.g., 'OpenSpec Explore') + - `description`: brief description of command purpose + - `category`: grouping category (e.g., 'OpenSpec') + - `tags`: array of tag strings + - `body`: the command instruction content + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns relative file path for command + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/commands/opsx/.md` + +### Requirement: Command generator function + +The system SHALL provide a `generateCommand` function that combines content with adapter. + +#### Scenario: Generate command file + +- **WHEN** calling `generateCommand(content, adapter)` +- **THEN** it SHALL return an object with: + - `path`: the file path from `adapter.getFilePath(content.id)` + - `fileContent`: the formatted content from `adapter.formatFile(content)` + +#### Scenario: Generate multiple commands + +- **WHEN** generating all opsx commands for a tool +- **THEN** the system SHALL iterate over command contents and generate each using the tool's adapter + +### Requirement: CommandAdapterRegistry + +The system SHALL provide a registry for looking up tool adapters. + +#### Scenario: Get adapter by tool ID + +- **WHEN** calling `CommandAdapterRegistry.get('cursor')` +- **THEN** it SHALL return the Cursor adapter or undefined if not registered + +#### Scenario: Get all adapters + +- **WHEN** calling `CommandAdapterRegistry.getAll()` +- **THEN** it SHALL return array of all registered adapters + +#### Scenario: Adapter not found + +- **WHEN** looking up an adapter for unregistered tool +- **THEN** `CommandAdapterRegistry.get()` SHALL return undefined +- **AND** caller SHALL handle missing adapter appropriately + +### Requirement: Shared command body content + +The body content of commands SHALL be shared across all tools. + +#### Scenario: Same instructions across tools + +- **WHEN** generating the 'explore' command for Claude and Cursor +- **THEN** both SHALL use the same `body` content +- **AND** only the frontmatter and file path SHALL differ diff --git a/openspec/changes/multi-provider-skill-generation/tasks.md b/openspec/changes/multi-provider-skill-generation/tasks.md new file mode 100644 index 00000000..3732f15d --- /dev/null +++ b/openspec/changes/multi-provider-skill-generation/tasks.md @@ -0,0 +1,55 @@ +## 1. Extend AIToolOption Interface + +- [x] 1.1 Add `skillsDir?: string` field to `AIToolOption` interface in `src/core/config.ts` + +## 2. Add skillsDir to AI_TOOLS + +- [x] 2.1 Add `skillsDir: '.claude'` to Claude Code tool entry +- [x] 2.2 Add `skillsDir: '.cursor'` to Cursor tool entry +- [x] 2.3 Add `skillsDir: '.windsurf'` to Windsurf tool entry +- [x] 2.4 Add skillsDir for other tools with known Agent Skills spec support (codex, opencode, roocode, kilocode, gemini, factory, github-copilot) + +## 3. Create Command Generation Types + +- [x] 3.1 Create `src/core/command-generation/types.ts` with `CommandContent` interface +- [x] 3.2 Add `ToolCommandAdapter` interface to types.ts +- [x] 3.3 Export types from module index + +## 4. Implement Tool Command Adapters + +- [x] 4.1 Create `src/core/command-generation/adapters/claude.ts` with Claude frontmatter format +- [x] 4.2 Create `src/core/command-generation/adapters/cursor.ts` with Cursor frontmatter format +- [x] 4.3 Create `src/core/command-generation/adapters/windsurf.ts` with Windsurf frontmatter format +- [x] 4.4 Create base adapter or utility for shared YAML formatting logic (if applicable) + +## 5. Create Command Adapter Registry + +- [x] 5.1 Create `src/core/command-generation/registry.ts` with `CommandAdapterRegistry` class +- [x] 5.2 Register Claude, Cursor, Windsurf adapters in static initializer +- [x] 5.3 Add `get(toolId)` and `getAll()` methods + +## 6. Create Command Generator + +- [x] 6.1 Create `src/core/command-generation/generator.ts` with `generateCommand()` function +- [x] 6.2 Add `generateCommands()` function for batch generation +- [x] 6.3 Create module index `src/core/command-generation/index.ts` exporting public API + +## 7. Update artifact-experimental-setup Command + +- [x] 7.1 Add `--tool ` option (required) to command in `src/commands/artifact-workflow.ts` +- [x] 7.2 Add validation: `--tool` flag is required (error if missing with list of valid tools) +- [x] 7.3 Add validation: tool exists in AI_TOOLS +- [x] 7.4 Add validation: tool has skillsDir configured +- [x] 7.5 Replace hardcoded `.claude` skill paths with `tool.skillsDir` +- [x] 7.6 Replace hardcoded command generation with `CommandAdapterRegistry.get()` + `generateCommands()` +- [x] 7.7 Handle missing adapter gracefully (skip commands with message) +- [x] 7.8 Update output messages to show target tool name and paths + +## 8. Testing + +- [x] 8.1 Add unit tests for `CommandContent` and `ToolCommandAdapter` contracts +- [x] 8.2 Add unit tests for Claude adapter (path + frontmatter format) +- [x] 8.3 Add unit tests for Cursor adapter (path + frontmatter format) +- [x] 8.4 Add unit tests for `CommandAdapterRegistry.get()` and missing adapter case +- [x] 8.5 Add integration test for `--tool` flag validation +- [x] 8.6 Verify cross-platform path handling uses `path.join()` throughout diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index 73dc5761..1b71d664 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -32,6 +32,12 @@ import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSk import { FileSystemUtils } from '../utils/file-system.js'; import { serializeConfig } from '../core/config-prompts.js'; import { readProjectConfig } from '../core/project-config.js'; +import { AI_TOOLS } from '../core/config.js'; +import { + generateCommands, + CommandAdapterRegistry, + type CommandContent, +} from '../core/command-generation/index.js'; // ----------------------------------------------------------------------------- // Types for Apply Instructions @@ -820,18 +826,55 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti // Artifact Experimental Setup Command // ----------------------------------------------------------------------------- +interface ArtifactExperimentalSetupOptions { + tool?: string; +} + +/** + * Gets the list of tools with skillsDir configured. + */ +function getToolsWithSkillsDir(): string[] { + return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); +} + /** * Generates Agent Skills and slash commands for the experimental artifact workflow. - * Creates .claude/skills/ directory with SKILL.md files following Agent Skills spec. - * Creates .claude/commands/opsx/ directory with slash command files. + * Creates /skills/ directory with SKILL.md files following Agent Skills spec. + * Creates slash commands using tool-specific adapters. */ -async function artifactExperimentalSetupCommand(): Promise { - const spinner = ora('Setting up experimental artifact workflow...').start(); +async function artifactExperimentalSetupCommand(options: ArtifactExperimentalSetupOptions): Promise { + const projectRoot = process.cwd(); + + // Validate --tool flag is provided + if (!options.tool) { + const validTools = getToolsWithSkillsDir(); + throw new Error( + `Missing required option --tool. Valid tools with skill generation support:\n ${validTools.join('\n ')}` + ); + } + + // Validate tool exists in AI_TOOLS + const tool = AI_TOOLS.find((t) => t.value === options.tool); + if (!tool) { + const validTools = AI_TOOLS.map((t) => t.value); + throw new Error( + `Unknown tool '${options.tool}'. Valid tools:\n ${validTools.join('\n ')}` + ); + } + + // Validate tool has skillsDir configured + if (!tool.skillsDir) { + const validTools = getToolsWithSkillsDir(); + throw new Error( + `Tool '${options.tool}' does not support skill generation (no skillsDir configured).\nTools with skill generation support:\n ${validTools.join('\n ')}` + ); + } + + const spinner = ora(`Setting up experimental artifact workflow for ${tool.name}...`).start(); try { - const projectRoot = process.cwd(); - const skillsDir = path.join(projectRoot, '.claude', 'skills'); - const commandsDir = path.join(projectRoot, '.claude', 'commands', 'opsx'); + // Use tool-specific skillsDir + const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); // Get skill templates const exploreSkill = getExploreSkillTemplate(); @@ -844,17 +887,6 @@ async function artifactExperimentalSetupCommand(): Promise { const bulkArchiveChangeSkill = getBulkArchiveChangeSkillTemplate(); const verifyChangeSkill = getVerifyChangeSkillTemplate(); - // Get command templates - const exploreCommand = getOpsxExploreCommandTemplate(); - const newCommand = getOpsxNewCommandTemplate(); - const continueCommand = getOpsxContinueCommandTemplate(); - const applyCommand = getOpsxApplyCommandTemplate(); - const ffCommand = getOpsxFfCommandTemplate(); - const syncCommand = getOpsxSyncCommandTemplate(); - const archiveCommand = getOpsxArchiveCommandTemplate(); - const bulkArchiveCommand = getOpsxBulkArchiveCommandTemplate(); - const verifyCommand = getOpsxVerifyCommandTemplate(); - // Create skill directories and SKILL.md files const skills = [ { template: exploreSkill, dirName: 'openspec-explore' }, @@ -888,56 +920,67 @@ ${template.instructions} createdSkillFiles.push(path.relative(projectRoot, skillFile)); } - // Create slash command files - const commands = [ - { template: exploreCommand, fileName: 'explore.md' }, - { template: newCommand, fileName: 'new.md' }, - { template: continueCommand, fileName: 'continue.md' }, - { template: applyCommand, fileName: 'apply.md' }, - { template: ffCommand, fileName: 'ff.md' }, - { template: syncCommand, fileName: 'sync.md' }, - { template: archiveCommand, fileName: 'archive.md' }, - { template: bulkArchiveCommand, fileName: 'bulk-archive.md' }, - { template: verifyCommand, fileName: 'verify.md' }, - ]; - + // Generate commands using the adapter system const createdCommandFiles: string[] = []; - - for (const { template, fileName } of commands) { - const commandFile = path.join(commandsDir, fileName); - - // Generate command content with YAML frontmatter - const commandContent = `--- -name: ${template.name} -description: ${template.description} -category: ${template.category} -tags: [${template.tags.join(', ')}] ---- - -${template.content} -`; - - // Write the command file - await FileSystemUtils.writeFile(commandFile, commandContent); - createdCommandFiles.push(path.relative(projectRoot, commandFile)); + let commandsSkipped = false; + + const adapter = CommandAdapterRegistry.get(tool.value); + if (adapter) { + // Get command templates and convert to CommandContent + const commandTemplates = [ + { template: getOpsxExploreCommandTemplate(), id: 'explore' }, + { template: getOpsxNewCommandTemplate(), id: 'new' }, + { template: getOpsxContinueCommandTemplate(), id: 'continue' }, + { template: getOpsxApplyCommandTemplate(), id: 'apply' }, + { template: getOpsxFfCommandTemplate(), id: 'ff' }, + { template: getOpsxSyncCommandTemplate(), id: 'sync' }, + { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, + { template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' }, + { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, + ]; + + const commandContents: CommandContent[] = commandTemplates.map(({ template, id }) => ({ + id, + name: template.name, + description: template.description, + category: template.category, + tags: template.tags, + body: template.content, + })); + + const generatedCommands = generateCommands(commandContents, adapter); + + for (const cmd of generatedCommands) { + const commandFile = path.join(projectRoot, cmd.path); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + createdCommandFiles.push(cmd.path); + } + } else { + commandsSkipped = true; } - spinner.succeed('Experimental artifact workflow setup complete!'); + spinner.succeed(`Experimental artifact workflow setup complete for ${tool.name}!`); // Print success message console.log(); - console.log(chalk.bold('🧪 Experimental Artifact Workflow Setup Complete')); + console.log(chalk.bold(`🧪 Experimental Artifact Workflow Setup Complete for ${tool.name}`)); console.log(); console.log(chalk.bold('Skills Created:')); for (const file of createdSkillFiles) { console.log(chalk.green(' ✓ ' + file)); } console.log(); - console.log(chalk.bold('Slash Commands Created:')); - for (const file of createdCommandFiles) { - console.log(chalk.green(' ✓ ' + file)); + + if (commandsSkipped) { + console.log(chalk.yellow(`Command generation skipped - no adapter for ${tool.value}`)); + console.log(); + } else { + console.log(chalk.bold('Slash Commands Created:')); + for (const file of createdCommandFiles) { + console.log(chalk.green(' ✓ ' + file)); + } + console.log(); } - console.log(); // Config creation section console.log('━'.repeat(70)); @@ -984,7 +1027,7 @@ ${template.content} // Git commit suggestion console.log(chalk.bold('To share with team:')); - console.log(chalk.dim(' git add openspec/config.yaml .claude/')); + console.log(chalk.dim(` git add openspec/config.yaml ${tool.skillsDir}/`)); console.log(chalk.dim(' git commit -m "Setup OpenSpec experimental workflow"')); console.log(); } catch (writeError) { @@ -1007,31 +1050,31 @@ ${template.content} console.log(chalk.bold('📖 Usage:')); console.log(); console.log(' ' + chalk.cyan('Skills') + ' work automatically in compatible editors:'); - console.log(' • Claude Code - Auto-detected, ready to use'); - console.log(' • Cursor - Enable in Settings → Rules → Import Settings'); - console.log(' • Windsurf - Auto-imports from .claude directory'); + console.log(` • ${tool.name} - Skills in ${tool.skillsDir}/skills/`); console.log(); - console.log(' Ask Claude naturally:'); + console.log(' Ask naturally:'); console.log(' • "I want to start a new OpenSpec change to add "'); console.log(' • "Continue working on this change"'); console.log(' • "Implement the tasks for this change"'); console.log(); - console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:'); - console.log(' • /opsx:explore - Think through ideas, investigate problems'); - console.log(' • /opsx:new - Start a new change'); - console.log(' • /opsx:continue - Create the next artifact'); - console.log(' • /opsx:apply - Implement tasks'); - console.log(' • /opsx:ff - Fast-forward: create all artifacts at once'); - console.log(' • /opsx:sync - Sync delta specs to main specs'); - console.log(' • /opsx:verify - Verify implementation matches artifacts'); - console.log(' • /opsx:archive - Archive a completed change'); - console.log(' • /opsx:bulk-archive - Archive multiple completed changes'); - console.log(); + if (!commandsSkipped) { + console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:'); + console.log(' • /opsx:explore - Think through ideas, investigate problems'); + console.log(' • /opsx:new - Start a new change'); + console.log(' • /opsx:continue - Create the next artifact'); + console.log(' • /opsx:apply - Implement tasks'); + console.log(' • /opsx:ff - Fast-forward: create all artifacts at once'); + console.log(' • /opsx:sync - Sync delta specs to main specs'); + console.log(' • /opsx:verify - Verify implementation matches artifacts'); + console.log(' • /opsx:archive - Archive a completed change'); + console.log(' • /opsx:bulk-archive - Archive multiple completed changes'); + console.log(); + } console.log(chalk.yellow('💡 This is an experimental feature.')); console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); console.log(); } catch (error) { - spinner.fail('Failed to setup experimental artifact workflow'); + spinner.fail(`Failed to setup experimental artifact workflow for ${tool.name}`); throw error; } } @@ -1171,9 +1214,10 @@ export function registerArtifactWorkflowCommands(program: Command): void { program .command('artifact-experimental-setup') .description('[Experimental] Setup Agent Skills for the experimental artifact workflow') - .action(async () => { + .option('--tool ', 'Target AI tool (e.g., claude, cursor, windsurf)') + .action(async (options: ArtifactExperimentalSetupOptions) => { try { - await artifactExperimentalSetupCommand(); + await artifactExperimentalSetupCommand(options); } catch (error) { console.log(); ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/core/command-generation/adapters/amazon-q.ts b/src/core/command-generation/adapters/amazon-q.ts new file mode 100644 index 00000000..0131c063 --- /dev/null +++ b/src/core/command-generation/adapters/amazon-q.ts @@ -0,0 +1,30 @@ +/** + * Amazon Q Developer Command Adapter + * + * Formats commands for Amazon Q Developer following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Amazon Q adapter for command generation. + * File path: .amazonq/prompts/opsx-.md + * Frontmatter: description + */ +export const amazonQAdapter: ToolCommandAdapter = { + toolId: 'amazon-q', + + getFilePath(commandId: string): string { + return path.join('.amazonq', 'prompts', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/antigravity.ts b/src/core/command-generation/adapters/antigravity.ts new file mode 100644 index 00000000..e7a5d491 --- /dev/null +++ b/src/core/command-generation/adapters/antigravity.ts @@ -0,0 +1,30 @@ +/** + * Antigravity Command Adapter + * + * Formats commands for Antigravity following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Antigravity adapter for command generation. + * File path: .agent/workflows/opsx-.md + * Frontmatter: description + */ +export const antigravityAdapter: ToolCommandAdapter = { + toolId: 'antigravity', + + getFilePath(commandId: string): string { + return path.join('.agent', 'workflows', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/auggie.ts b/src/core/command-generation/adapters/auggie.ts new file mode 100644 index 00000000..2a52104c --- /dev/null +++ b/src/core/command-generation/adapters/auggie.ts @@ -0,0 +1,31 @@ +/** + * Auggie (Augment CLI) Command Adapter + * + * Formats commands for Auggie following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Auggie adapter for command generation. + * File path: .augment/commands/opsx-.md + * Frontmatter: description, argument-hint + */ +export const auggieAdapter: ToolCommandAdapter = { + toolId: 'auggie', + + getFilePath(commandId: string): string { + return path.join('.augment', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +argument-hint: command arguments +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/claude.ts b/src/core/command-generation/adapters/claude.ts new file mode 100644 index 00000000..532b3a47 --- /dev/null +++ b/src/core/command-generation/adapters/claude.ts @@ -0,0 +1,56 @@ +/** + * Claude Code Command Adapter + * + * Formats commands for Claude Code following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes and backslashes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +/** + * Formats a tags array as a YAML array with proper escaping. + */ +function formatTagsArray(tags: string[]): string { + const escapedTags = tags.map((tag) => escapeYamlValue(tag)); + return `[${escapedTags.join(', ')}]`; +} + +/** + * Claude Code adapter for command generation. + * File path: .claude/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ +export const claudeAdapter: ToolCommandAdapter = { + toolId: 'claude', + + getFilePath(commandId: string): string { + return path.join('.claude', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/cline.ts b/src/core/command-generation/adapters/cline.ts new file mode 100644 index 00000000..abc64316 --- /dev/null +++ b/src/core/command-generation/adapters/cline.ts @@ -0,0 +1,31 @@ +/** + * Cline Command Adapter + * + * Formats commands for Cline following its workflow specification. + * Cline uses markdown headers instead of YAML frontmatter. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Cline adapter for command generation. + * File path: .clinerules/workflows/opsx-.md + * Format: Markdown header with description + */ +export const clineAdapter: ToolCommandAdapter = { + toolId: 'cline', + + getFilePath(commandId: string): string { + return path.join('.clinerules', 'workflows', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `# ${content.name} + +${content.description} + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/codebuddy.ts b/src/core/command-generation/adapters/codebuddy.ts new file mode 100644 index 00000000..54b7eebd --- /dev/null +++ b/src/core/command-generation/adapters/codebuddy.ts @@ -0,0 +1,32 @@ +/** + * CodeBuddy Command Adapter + * + * Formats commands for CodeBuddy following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * CodeBuddy adapter for command generation. + * File path: .codebuddy/commands/opsx/.md + * Frontmatter: name, description, argument-hint + */ +export const codebuddyAdapter: ToolCommandAdapter = { + toolId: 'codebuddy', + + getFilePath(commandId: string): string { + return path.join('.codebuddy', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${content.name} +description: "${content.description}" +argument-hint: "[command arguments]" +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts new file mode 100644 index 00000000..1593dff2 --- /dev/null +++ b/src/core/command-generation/adapters/codex.ts @@ -0,0 +1,31 @@ +/** + * Codex Command Adapter + * + * Formats commands for Codex following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Codex adapter for command generation. + * File path: .codex/prompts/opsx-.md + * Frontmatter: description, argument-hint + */ +export const codexAdapter: ToolCommandAdapter = { + toolId: 'codex', + + getFilePath(commandId: string): string { + return path.join('.codex', 'prompts', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +argument-hint: command arguments +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/continue.ts b/src/core/command-generation/adapters/continue.ts new file mode 100644 index 00000000..f6aac08b --- /dev/null +++ b/src/core/command-generation/adapters/continue.ts @@ -0,0 +1,32 @@ +/** + * Continue Command Adapter + * + * Formats commands for Continue following its .prompt specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Continue adapter for command generation. + * File path: .continue/prompts/opsx-.prompt + * Frontmatter: name, description, invokable + */ +export const continueAdapter: ToolCommandAdapter = { + toolId: 'continue', + + getFilePath(commandId: string): string { + return path.join('.continue', 'prompts', `opsx-${commandId}.prompt`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: opsx-${content.id} +description: ${content.description} +invokable: true +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/costrict.ts b/src/core/command-generation/adapters/costrict.ts new file mode 100644 index 00000000..17628a12 --- /dev/null +++ b/src/core/command-generation/adapters/costrict.ts @@ -0,0 +1,31 @@ +/** + * CoStrict Command Adapter + * + * Formats commands for CoStrict following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * CoStrict adapter for command generation. + * File path: .cospec/openspec/commands/opsx-.md + * Frontmatter: description, argument-hint + */ +export const costrictAdapter: ToolCommandAdapter = { + toolId: 'costrict', + + getFilePath(commandId: string): string { + return path.join('.cospec', 'openspec', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: "${content.description}" +argument-hint: command arguments +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/crush.ts b/src/core/command-generation/adapters/crush.ts new file mode 100644 index 00000000..b4d1a0b9 --- /dev/null +++ b/src/core/command-generation/adapters/crush.ts @@ -0,0 +1,34 @@ +/** + * Crush Command Adapter + * + * Formats commands for Crush following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Crush adapter for command generation. + * File path: .crush/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ +export const crushAdapter: ToolCommandAdapter = { + toolId: 'crush', + + getFilePath(commandId: string): string { + return path.join('.crush', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + const tagsStr = content.tags.join(', '); + return `--- +name: ${content.name} +description: ${content.description} +category: ${content.category} +tags: [${tagsStr}] +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/cursor.ts b/src/core/command-generation/adapters/cursor.ts new file mode 100644 index 00000000..85adedb0 --- /dev/null +++ b/src/core/command-generation/adapters/cursor.ts @@ -0,0 +1,49 @@ +/** + * Cursor Command Adapter + * + * Formats commands for Cursor following its frontmatter specification. + * Cursor uses a different frontmatter format and file naming convention. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes and backslashes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +/** + * Cursor adapter for command generation. + * File path: .cursor/commands/opsx-.md + * Frontmatter: name (as /opsx-), id, category, description + */ +export const cursorAdapter: ToolCommandAdapter = { + toolId: 'cursor', + + getFilePath(commandId: string): string { + return path.join('.cursor', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: /opsx-${content.id} +id: opsx-${content.id} +category: ${escapeYamlValue(content.category)} +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/factory.ts b/src/core/command-generation/adapters/factory.ts new file mode 100644 index 00000000..5031d5dc --- /dev/null +++ b/src/core/command-generation/adapters/factory.ts @@ -0,0 +1,31 @@ +/** + * Factory Droid Command Adapter + * + * Formats commands for Factory Droid following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Factory adapter for command generation. + * File path: .factory/commands/opsx-.md + * Frontmatter: description, argument-hint + */ +export const factoryAdapter: ToolCommandAdapter = { + toolId: 'factory', + + getFilePath(commandId: string): string { + return path.join('.factory', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +argument-hint: command arguments +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts new file mode 100644 index 00000000..2c08656f --- /dev/null +++ b/src/core/command-generation/adapters/gemini.ts @@ -0,0 +1,30 @@ +/** + * Gemini CLI Command Adapter + * + * Formats commands for Gemini CLI following its TOML specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Gemini adapter for command generation. + * File path: .gemini/commands/opsx/.toml + * Format: TOML with description and prompt fields + */ +export const geminiAdapter: ToolCommandAdapter = { + toolId: 'gemini', + + getFilePath(commandId: string): string { + return path.join('.gemini', 'commands', 'opsx', `${commandId}.toml`); + }, + + formatFile(content: CommandContent): string { + return `description = "${content.description}" + +prompt = """ +${content.body} +""" +`; + }, +}; diff --git a/src/core/command-generation/adapters/github-copilot.ts b/src/core/command-generation/adapters/github-copilot.ts new file mode 100644 index 00000000..4eac7f1b --- /dev/null +++ b/src/core/command-generation/adapters/github-copilot.ts @@ -0,0 +1,30 @@ +/** + * GitHub Copilot Command Adapter + * + * Formats commands for GitHub Copilot following its .prompt.md specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * GitHub Copilot adapter for command generation. + * File path: .github/prompts/opsx-.prompt.md + * Frontmatter: description + */ +export const githubCopilotAdapter: ToolCommandAdapter = { + toolId: 'github-copilot', + + getFilePath(commandId: string): string { + return path.join('.github', 'prompts', `opsx-${commandId}.prompt.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/iflow.ts b/src/core/command-generation/adapters/iflow.ts new file mode 100644 index 00000000..d60a3f0b --- /dev/null +++ b/src/core/command-generation/adapters/iflow.ts @@ -0,0 +1,33 @@ +/** + * iFlow Command Adapter + * + * Formats commands for iFlow following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * iFlow adapter for command generation. + * File path: .iflow/commands/opsx-.md + * Frontmatter: name, id, category, description + */ +export const iflowAdapter: ToolCommandAdapter = { + toolId: 'iflow', + + getFilePath(commandId: string): string { + return path.join('.iflow', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: /opsx-${content.id} +id: opsx-${content.id} +category: ${content.category} +description: ${content.description} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts new file mode 100644 index 00000000..83de1f0d --- /dev/null +++ b/src/core/command-generation/adapters/index.ts @@ -0,0 +1,27 @@ +/** + * Command Adapters Index + * + * Re-exports all tool command adapters. + */ + +export { amazonQAdapter } from './amazon-q.js'; +export { antigravityAdapter } from './antigravity.js'; +export { auggieAdapter } from './auggie.js'; +export { claudeAdapter } from './claude.js'; +export { clineAdapter } from './cline.js'; +export { codexAdapter } from './codex.js'; +export { codebuddyAdapter } from './codebuddy.js'; +export { continueAdapter } from './continue.js'; +export { costrictAdapter } from './costrict.js'; +export { crushAdapter } from './crush.js'; +export { cursorAdapter } from './cursor.js'; +export { factoryAdapter } from './factory.js'; +export { geminiAdapter } from './gemini.js'; +export { githubCopilotAdapter } from './github-copilot.js'; +export { iflowAdapter } from './iflow.js'; +export { kilocodeAdapter } from './kilocode.js'; +export { opencodeAdapter } from './opencode.js'; +export { qoderAdapter } from './qoder.js'; +export { qwenAdapter } from './qwen.js'; +export { roocodeAdapter } from './roocode.js'; +export { windsurfAdapter } from './windsurf.js'; diff --git a/src/core/command-generation/adapters/kilocode.ts b/src/core/command-generation/adapters/kilocode.ts new file mode 100644 index 00000000..bb60c4dd --- /dev/null +++ b/src/core/command-generation/adapters/kilocode.ts @@ -0,0 +1,27 @@ +/** + * Kilo Code Command Adapter + * + * Formats commands for Kilo Code following its workflow specification. + * Kilo Code workflows don't use frontmatter. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Kilo Code adapter for command generation. + * File path: .kilocode/workflows/opsx-.md + * Format: Plain markdown without frontmatter + */ +export const kilocodeAdapter: ToolCommandAdapter = { + toolId: 'kilocode', + + getFilePath(commandId: string): string { + return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts new file mode 100644 index 00000000..05f9cab1 --- /dev/null +++ b/src/core/command-generation/adapters/opencode.ts @@ -0,0 +1,30 @@ +/** + * OpenCode Command Adapter + * + * Formats commands for OpenCode following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * OpenCode adapter for command generation. + * File path: .opencode/command/opsx-.md + * Frontmatter: description + */ +export const opencodeAdapter: ToolCommandAdapter = { + toolId: 'opencode', + + getFilePath(commandId: string): string { + return path.join('.opencode', 'command', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +description: ${content.description} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/qoder.ts b/src/core/command-generation/adapters/qoder.ts new file mode 100644 index 00000000..608fc9ae --- /dev/null +++ b/src/core/command-generation/adapters/qoder.ts @@ -0,0 +1,34 @@ +/** + * Qoder Command Adapter + * + * Formats commands for Qoder following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Qoder adapter for command generation. + * File path: .qoder/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ +export const qoderAdapter: ToolCommandAdapter = { + toolId: 'qoder', + + getFilePath(commandId: string): string { + return path.join('.qoder', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + const tagsStr = content.tags.join(', '); + return `--- +name: ${content.name} +description: ${content.description} +category: ${content.category} +tags: [${tagsStr}] +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/qwen.ts b/src/core/command-generation/adapters/qwen.ts new file mode 100644 index 00000000..0ee640b3 --- /dev/null +++ b/src/core/command-generation/adapters/qwen.ts @@ -0,0 +1,30 @@ +/** + * Qwen Code Command Adapter + * + * Formats commands for Qwen Code following its TOML specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Qwen adapter for command generation. + * File path: .qwen/commands/opsx-.toml + * Format: TOML with description and prompt fields + */ +export const qwenAdapter: ToolCommandAdapter = { + toolId: 'qwen', + + getFilePath(commandId: string): string { + return path.join('.qwen', 'commands', `opsx-${commandId}.toml`); + }, + + formatFile(content: CommandContent): string { + return `description = "${content.description}" + +prompt = """ +${content.body} +""" +`; + }, +}; diff --git a/src/core/command-generation/adapters/roocode.ts b/src/core/command-generation/adapters/roocode.ts new file mode 100644 index 00000000..52929857 --- /dev/null +++ b/src/core/command-generation/adapters/roocode.ts @@ -0,0 +1,31 @@ +/** + * RooCode Command Adapter + * + * Formats commands for RooCode following its workflow specification. + * RooCode uses markdown headers instead of YAML frontmatter. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * RooCode adapter for command generation. + * File path: .roo/commands/opsx-.md + * Format: Markdown header with description + */ +export const roocodeAdapter: ToolCommandAdapter = { + toolId: 'roocode', + + getFilePath(commandId: string): string { + return path.join('.roo', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `# ${content.name} + +${content.description} + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/windsurf.ts b/src/core/command-generation/adapters/windsurf.ts new file mode 100644 index 00000000..3267808f --- /dev/null +++ b/src/core/command-generation/adapters/windsurf.ts @@ -0,0 +1,57 @@ +/** + * Windsurf Command Adapter + * + * Formats commands for Windsurf following its frontmatter specification. + * Windsurf uses a similar format to Claude but may have different conventions. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + // Check if value needs quoting (contains special YAML characters or starts/ends with whitespace) + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + // Use double quotes and escape internal double quotes and backslashes + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +/** + * Formats a tags array as a YAML array with proper escaping. + */ +function formatTagsArray(tags: string[]): string { + const escapedTags = tags.map((tag) => escapeYamlValue(tag)); + return `[${escapedTags.join(', ')}]`; +} + +/** + * Windsurf adapter for command generation. + * File path: .windsurf/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ +export const windsurfAdapter: ToolCommandAdapter = { + toolId: 'windsurf', + + getFilePath(commandId: string): string { + return path.join('.windsurf', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/generator.ts b/src/core/command-generation/generator.ts new file mode 100644 index 00000000..e8f22c05 --- /dev/null +++ b/src/core/command-generation/generator.ts @@ -0,0 +1,36 @@ +/** + * Command Generator + * + * Functions for generating command files using tool adapters. + */ + +import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js'; + +/** + * Generate a single command file using the provided adapter. + * @param content - The tool-agnostic command content + * @param adapter - The tool-specific adapter + * @returns Generated command with path and file content + */ +export function generateCommand( + content: CommandContent, + adapter: ToolCommandAdapter +): GeneratedCommand { + return { + path: adapter.getFilePath(content.id), + fileContent: adapter.formatFile(content), + }; +} + +/** + * Generate multiple command files using the provided adapter. + * @param contents - Array of tool-agnostic command contents + * @param adapter - The tool-specific adapter + * @returns Array of generated commands with paths and file contents + */ +export function generateCommands( + contents: CommandContent[], + adapter: ToolCommandAdapter +): GeneratedCommand[] { + return contents.map((content) => generateCommand(content, adapter)); +} diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts new file mode 100644 index 00000000..a067f33b --- /dev/null +++ b/src/core/command-generation/index.ts @@ -0,0 +1,33 @@ +/** + * Command Generation Module + * + * Generic command generation system with tool-specific adapters. + * + * Usage: + * ```typescript + * import { generateCommands, CommandAdapterRegistry, type CommandContent } from './command-generation/index.js'; + * + * const contents: CommandContent[] = [...]; + * const adapter = CommandAdapterRegistry.get('cursor'); + * if (adapter) { + * const commands = generateCommands(contents, adapter); + * // Write commands to disk + * } + * ``` + */ + +// Types +export type { + CommandContent, + ToolCommandAdapter, + GeneratedCommand, +} from './types.js'; + +// Registry +export { CommandAdapterRegistry } from './registry.js'; + +// Generator functions +export { generateCommand, generateCommands } from './generator.js'; + +// Adapters (for direct access if needed) +export { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts new file mode 100644 index 00000000..f99edac6 --- /dev/null +++ b/src/core/command-generation/registry.ts @@ -0,0 +1,95 @@ +/** + * Command Adapter Registry + * + * Centralized registry for tool command adapters. + * Similar pattern to existing SlashCommandRegistry in the codebase. + */ + +import type { ToolCommandAdapter } from './types.js'; +import { amazonQAdapter } from './adapters/amazon-q.js'; +import { antigravityAdapter } from './adapters/antigravity.js'; +import { auggieAdapter } from './adapters/auggie.js'; +import { claudeAdapter } from './adapters/claude.js'; +import { clineAdapter } from './adapters/cline.js'; +import { codexAdapter } from './adapters/codex.js'; +import { codebuddyAdapter } from './adapters/codebuddy.js'; +import { continueAdapter } from './adapters/continue.js'; +import { costrictAdapter } from './adapters/costrict.js'; +import { crushAdapter } from './adapters/crush.js'; +import { cursorAdapter } from './adapters/cursor.js'; +import { factoryAdapter } from './adapters/factory.js'; +import { geminiAdapter } from './adapters/gemini.js'; +import { githubCopilotAdapter } from './adapters/github-copilot.js'; +import { iflowAdapter } from './adapters/iflow.js'; +import { kilocodeAdapter } from './adapters/kilocode.js'; +import { opencodeAdapter } from './adapters/opencode.js'; +import { qoderAdapter } from './adapters/qoder.js'; +import { qwenAdapter } from './adapters/qwen.js'; +import { roocodeAdapter } from './adapters/roocode.js'; +import { windsurfAdapter } from './adapters/windsurf.js'; + +/** + * Registry for looking up tool command adapters. + */ +export class CommandAdapterRegistry { + private static adapters: Map = new Map(); + + // Static initializer - register built-in adapters + static { + CommandAdapterRegistry.register(amazonQAdapter); + CommandAdapterRegistry.register(antigravityAdapter); + CommandAdapterRegistry.register(auggieAdapter); + CommandAdapterRegistry.register(claudeAdapter); + CommandAdapterRegistry.register(clineAdapter); + CommandAdapterRegistry.register(codexAdapter); + CommandAdapterRegistry.register(codebuddyAdapter); + CommandAdapterRegistry.register(continueAdapter); + CommandAdapterRegistry.register(costrictAdapter); + CommandAdapterRegistry.register(crushAdapter); + CommandAdapterRegistry.register(cursorAdapter); + CommandAdapterRegistry.register(factoryAdapter); + CommandAdapterRegistry.register(geminiAdapter); + CommandAdapterRegistry.register(githubCopilotAdapter); + CommandAdapterRegistry.register(iflowAdapter); + CommandAdapterRegistry.register(kilocodeAdapter); + CommandAdapterRegistry.register(opencodeAdapter); + CommandAdapterRegistry.register(qoderAdapter); + CommandAdapterRegistry.register(qwenAdapter); + CommandAdapterRegistry.register(roocodeAdapter); + CommandAdapterRegistry.register(windsurfAdapter); + } + + /** + * Register a tool command adapter. + * @param adapter - The adapter to register + */ + static register(adapter: ToolCommandAdapter): void { + CommandAdapterRegistry.adapters.set(adapter.toolId, adapter); + } + + /** + * Get an adapter by tool ID. + * @param toolId - The tool identifier (e.g., 'claude', 'cursor') + * @returns The adapter or undefined if not registered + */ + static get(toolId: string): ToolCommandAdapter | undefined { + return CommandAdapterRegistry.adapters.get(toolId); + } + + /** + * Get all registered adapters. + * @returns Array of all registered adapters + */ + static getAll(): ToolCommandAdapter[] { + return Array.from(CommandAdapterRegistry.adapters.values()); + } + + /** + * Check if an adapter is registered for a tool. + * @param toolId - The tool identifier + * @returns True if an adapter exists + */ + static has(toolId: string): boolean { + return CommandAdapterRegistry.adapters.has(toolId); + } +} diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts new file mode 100644 index 00000000..96a74d3b --- /dev/null +++ b/src/core/command-generation/types.ts @@ -0,0 +1,57 @@ +/** + * Command Generation Types + * + * Tool-agnostic interfaces for command generation. + * These types separate "what to generate" from "how to format it". + */ + +/** + * Tool-agnostic command data. + * Represents the content of a command without any tool-specific formatting. + */ +export interface CommandContent { + /** Command identifier (e.g., 'explore', 'apply', 'new') */ + id: string; + /** Human-readable name (e.g., 'OpenSpec Explore') */ + name: string; + /** Brief description of command purpose */ + description: string; + /** Grouping category (e.g., 'Workflow') */ + category: string; + /** Array of tag strings */ + tags: string[]; + /** The command instruction content (body text) */ + body: string; +} + +/** + * Per-tool formatting strategy. + * Each AI tool implements this interface to handle its specific file path + * and frontmatter format requirements. + */ +export interface ToolCommandAdapter { + /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */ + toolId: string; + /** + * Returns the relative file path for a command. + * @param commandId - The command identifier (e.g., 'explore') + * @returns Relative path from project root (e.g., '.claude/commands/opsx/explore.md') + */ + getFilePath(commandId: string): string; + /** + * Formats the complete file content including frontmatter. + * @param content - The tool-agnostic command content + * @returns Complete file content ready to write + */ + formatFile(content: CommandContent): string; +} + +/** + * Result of generating a command file. + */ +export interface GeneratedCommand { + /** Relative file path from project root */ + path: string; + /** Complete file content (frontmatter + body) */ + fileContent: string; +} diff --git a/src/core/config.ts b/src/core/config.ts index e21361c3..a90beb52 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -14,29 +14,30 @@ export interface AIToolOption { value: string; available: boolean; successLabel?: string; + skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec } export const AI_TOOLS: AIToolOption[] = [ - { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' }, - { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' }, - { 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: 'Codex', value: 'codex', available: true, successLabel: 'Codex' }, - { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' }, - { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' }, - { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' }, - { 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' }, - { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' }, - { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' }, - { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' }, - { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' }, - { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' }, - { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder' }, - { 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: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' }, + { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' }, + { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' }, + { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' }, + { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, + { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' }, + { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' }, + { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' }, + { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' }, + { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' }, + { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' }, + { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' }, + { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' }, + { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' }, + { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' }, + { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' }, + { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, + { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' }, + { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' }, + { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' }, + { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 8d05518c..8eaf1de9 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -27,6 +27,13 @@ describe('artifact-workflow CLI commands', () => { return result.stdout + result.stderr; } + /** + * Normalizes path separators to forward slashes for cross-platform assertions. + */ + function normalizePaths(str: string): string { + return str.replace(/\\/g, '/'); + } + /** * Creates a test change with the specified artifacts completed. * Note: An "active" change requires at least a proposal.md file to be detected. @@ -577,6 +584,84 @@ artifacts: }); }); + describe('artifact-experimental-setup command', () => { + it('requires --tool flag', async () => { + const result = await runCLI(['artifact-experimental-setup'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--tool'); + }); + + it('errors for unknown tool', async () => { + const result = await runCLI(['artifact-experimental-setup', '--tool', 'unknown-tool'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain("Unknown tool 'unknown-tool'"); + }); + + it('errors for tool without skillsDir', async () => { + // Using 'agents' which doesn't have skillsDir configured + const result = await runCLI(['artifact-experimental-setup', '--tool', 'agents'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('does not support skill generation'); + }); + + it('creates skills for Claude tool', async () => { + const result = await runCLI(['artifact-experimental-setup', '--tool', 'claude'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + const output = normalizePaths(getOutput(result)); + expect(output).toContain('Claude Code'); + expect(output).toContain('.claude/skills/'); + + // Verify skill files were created + const skillFile = path.join(tempDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const stat = await fs.stat(skillFile); + expect(stat.isFile()).toBe(true); + }); + + it('creates skills for Cursor tool', async () => { + const result = await runCLI(['artifact-experimental-setup', '--tool', 'cursor'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + const output = normalizePaths(getOutput(result)); + expect(output).toContain('Cursor'); + expect(output).toContain('.cursor/skills/'); + + // Verify skill files were created + const skillFile = path.join(tempDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md'); + const stat = await fs.stat(skillFile); + expect(stat.isFile()).toBe(true); + + // Verify commands were created with Cursor format + const commandFile = path.join(tempDir, '.cursor', 'commands', 'opsx-explore.md'); + const content = await fs.readFile(commandFile, 'utf-8'); + expect(content).toContain('name: /opsx-explore'); + }); + + it('creates skills for Windsurf tool', async () => { + const result = await runCLI(['artifact-experimental-setup', '--tool', 'windsurf'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + const output = normalizePaths(getOutput(result)); + expect(output).toContain('Windsurf'); + expect(output).toContain('.windsurf/skills/'); + + // Verify skill files were created + const skillFile = path.join(tempDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md'); + const stat = await fs.stat(skillFile); + expect(stat.isFile()).toBe(true); + }); + }); + describe('project config integration', () => { describe('new change uses config schema', () => { it('creates change with schema from project config', async () => { diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts new file mode 100644 index 00000000..c40455de --- /dev/null +++ b/test/core/command-generation/adapters.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js'; +import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js'; +import { auggieAdapter } from '../../../src/core/command-generation/adapters/auggie.js'; +import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; +import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js'; +import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js'; +import { codebuddyAdapter } from '../../../src/core/command-generation/adapters/codebuddy.js'; +import { continueAdapter } from '../../../src/core/command-generation/adapters/continue.js'; +import { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js'; +import { crushAdapter } from '../../../src/core/command-generation/adapters/crush.js'; +import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js'; +import { factoryAdapter } from '../../../src/core/command-generation/adapters/factory.js'; +import { geminiAdapter } from '../../../src/core/command-generation/adapters/gemini.js'; +import { githubCopilotAdapter } from '../../../src/core/command-generation/adapters/github-copilot.js'; +import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js'; +import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js'; +import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js'; +import { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js'; +import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; +import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; +import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; +import type { CommandContent } from '../../../src/core/command-generation/types.js'; + +describe('command-generation/adapters', () => { + const sampleContent: CommandContent = { + id: 'explore', + name: 'OpenSpec Explore', + description: 'Enter explore mode for thinking', + category: 'Workflow', + tags: ['workflow', 'explore', 'experimental'], + body: 'This is the command body.\n\nWith multiple lines.', + }; + + describe('claudeAdapter', () => { + it('should have correct toolId', () => { + expect(claudeAdapter.toolId).toBe('claude'); + }); + + it('should generate correct file path', () => { + const filePath = claudeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.claude', 'commands', 'opsx', 'explore.md')); + }); + + it('should generate correct file path for different command IDs', () => { + expect(claudeAdapter.getFilePath('new')).toBe(path.join('.claude', 'commands', 'opsx', 'new.md')); + expect(claudeAdapter.getFilePath('bulk-archive')).toBe(path.join('.claude', 'commands', 'opsx', 'bulk-archive.md')); + }); + + it('should format file with correct YAML frontmatter', () => { + const output = claudeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.\n\nWith multiple lines.'); + }); + + it('should handle empty tags', () => { + const contentNoTags: CommandContent = { ...sampleContent, tags: [] }; + const output = claudeAdapter.formatFile(contentNoTags); + expect(output).toContain('tags: []'); + }); + }); + + describe('cursorAdapter', () => { + it('should have correct toolId', () => { + expect(cursorAdapter.toolId).toBe('cursor'); + }); + + it('should generate correct file path with opsx- prefix', () => { + const filePath = cursorAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.cursor', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(cursorAdapter.getFilePath('new')).toBe(path.join('.cursor', 'commands', 'opsx-new.md')); + expect(cursorAdapter.getFilePath('bulk-archive')).toBe(path.join('.cursor', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with Cursor-specific frontmatter', () => { + const output = cursorAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: /opsx-explore'); + expect(output).toContain('id: opsx-explore'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('should not include tags in Cursor format', () => { + const output = cursorAdapter.formatFile(sampleContent); + expect(output).not.toContain('tags:'); + }); + }); + + describe('windsurfAdapter', () => { + it('should have correct toolId', () => { + expect(windsurfAdapter.toolId).toBe('windsurf'); + }); + + it('should generate correct file path', () => { + const filePath = windsurfAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.windsurf', 'commands', 'opsx', 'explore.md')); + }); + + it('should format file similar to Claude format', () => { + const output = windsurfAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('amazonQAdapter', () => { + it('should have correct toolId', () => { + expect(amazonQAdapter.toolId).toBe('amazon-q'); + }); + + it('should generate correct file path', () => { + const filePath = amazonQAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.amazonq', 'prompts', 'opsx-explore.md')); + }); + + it('should format file with description frontmatter', () => { + const output = amazonQAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('antigravityAdapter', () => { + it('should have correct toolId', () => { + expect(antigravityAdapter.toolId).toBe('antigravity'); + }); + + it('should generate correct file path', () => { + const filePath = antigravityAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.agent', 'workflows', 'opsx-explore.md')); + }); + + it('should format file with description frontmatter', () => { + const output = antigravityAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('auggieAdapter', () => { + it('should have correct toolId', () => { + expect(auggieAdapter.toolId).toBe('auggie'); + }); + + it('should generate correct file path', () => { + const filePath = auggieAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.augment', 'commands', 'opsx-explore.md')); + }); + + it('should format file with description and argument-hint', () => { + const output = auggieAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('argument-hint: command arguments'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('clineAdapter', () => { + it('should have correct toolId', () => { + expect(clineAdapter.toolId).toBe('cline'); + }); + + it('should generate correct file path', () => { + const filePath = clineAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.clinerules', 'workflows', 'opsx-explore.md')); + }); + + it('should format file with markdown header (no YAML frontmatter)', () => { + const output = clineAdapter.formatFile(sampleContent); + expect(output).toContain('# OpenSpec Explore'); + expect(output).toContain('Enter explore mode for thinking'); + expect(output).toContain('This is the command body.'); + expect(output).not.toContain('---'); + }); + }); + + describe('codexAdapter', () => { + it('should have correct toolId', () => { + expect(codexAdapter.toolId).toBe('codex'); + }); + + it('should generate correct file path', () => { + const filePath = codexAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.codex', 'prompts', 'opsx-explore.md')); + }); + + it('should format file with description and argument-hint', () => { + const output = codexAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('argument-hint: command arguments'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('codebuddyAdapter', () => { + it('should have correct toolId', () => { + expect(codebuddyAdapter.toolId).toBe('codebuddy'); + }); + + it('should generate correct file path with nested opsx folder', () => { + const filePath = codebuddyAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.codebuddy', 'commands', 'opsx', 'explore.md')); + }); + + it('should format file with name, description, and argument-hint', () => { + const output = codebuddyAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('argument-hint: "[command arguments]"'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('continueAdapter', () => { + it('should have correct toolId', () => { + expect(continueAdapter.toolId).toBe('continue'); + }); + + it('should generate correct file path with .prompt extension', () => { + const filePath = continueAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.continue', 'prompts', 'opsx-explore.prompt')); + }); + + it('should format file with name, description, and invokable', () => { + const output = continueAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: opsx-explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('invokable: true'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('costrictAdapter', () => { + it('should have correct toolId', () => { + expect(costrictAdapter.toolId).toBe('costrict'); + }); + + it('should generate correct file path', () => { + const filePath = costrictAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.cospec', 'openspec', 'commands', 'opsx-explore.md')); + }); + + it('should format file with description and argument-hint', () => { + const output = costrictAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: "Enter explore mode for thinking"'); + expect(output).toContain('argument-hint: command arguments'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('crushAdapter', () => { + it('should have correct toolId', () => { + expect(crushAdapter.toolId).toBe('crush'); + }); + + it('should generate correct file path with nested opsx folder', () => { + const filePath = crushAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.crush', 'commands', 'opsx', 'explore.md')); + }); + + it('should format file with name, description, category, and tags', () => { + const output = crushAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('factoryAdapter', () => { + it('should have correct toolId', () => { + expect(factoryAdapter.toolId).toBe('factory'); + }); + + it('should generate correct file path', () => { + const filePath = factoryAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.factory', 'commands', 'opsx-explore.md')); + }); + + it('should format file with description and argument-hint', () => { + const output = factoryAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('argument-hint: command arguments'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('geminiAdapter', () => { + it('should have correct toolId', () => { + expect(geminiAdapter.toolId).toBe('gemini'); + }); + + it('should generate correct file path with .toml extension', () => { + const filePath = geminiAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.gemini', 'commands', 'opsx', 'explore.toml')); + }); + + it('should format file in TOML format', () => { + const output = geminiAdapter.formatFile(sampleContent); + expect(output).toContain('description = "Enter explore mode for thinking"'); + expect(output).toContain('prompt = """'); + expect(output).toContain('This is the command body.'); + expect(output).toContain('"""'); + }); + }); + + describe('githubCopilotAdapter', () => { + it('should have correct toolId', () => { + expect(githubCopilotAdapter.toolId).toBe('github-copilot'); + }); + + it('should generate correct file path with .prompt.md extension', () => { + const filePath = githubCopilotAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.github', 'prompts', 'opsx-explore.prompt.md')); + }); + + it('should format file with description frontmatter', () => { + const output = githubCopilotAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('iflowAdapter', () => { + it('should have correct toolId', () => { + expect(iflowAdapter.toolId).toBe('iflow'); + }); + + it('should generate correct file path', () => { + const filePath = iflowAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.iflow', 'commands', 'opsx-explore.md')); + }); + + it('should format file with name, id, category, and description', () => { + const output = iflowAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: /opsx-explore'); + expect(output).toContain('id: opsx-explore'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('kilocodeAdapter', () => { + it('should have correct toolId', () => { + expect(kilocodeAdapter.toolId).toBe('kilocode'); + }); + + it('should generate correct file path', () => { + const filePath = kilocodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.kilocode', 'workflows', 'opsx-explore.md')); + }); + + it('should format file without frontmatter', () => { + const output = kilocodeAdapter.formatFile(sampleContent); + expect(output).not.toContain('---'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('opencodeAdapter', () => { + it('should have correct toolId', () => { + expect(opencodeAdapter.toolId).toBe('opencode'); + }); + + it('should generate correct file path', () => { + const filePath = opencodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.opencode', 'command', 'opsx-explore.md')); + }); + + it('should format file with description frontmatter', () => { + const output = opencodeAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('qoderAdapter', () => { + it('should have correct toolId', () => { + expect(qoderAdapter.toolId).toBe('qoder'); + }); + + it('should generate correct file path with nested opsx folder', () => { + const filePath = qoderAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.qoder', 'commands', 'opsx', 'explore.md')); + }); + + it('should format file with name, description, category, and tags', () => { + const output = qoderAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + }); + + describe('qwenAdapter', () => { + it('should have correct toolId', () => { + expect(qwenAdapter.toolId).toBe('qwen'); + }); + + it('should generate correct file path with .toml extension', () => { + const filePath = qwenAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.qwen', 'commands', 'opsx-explore.toml')); + }); + + it('should format file in TOML format', () => { + const output = qwenAdapter.formatFile(sampleContent); + expect(output).toContain('description = "Enter explore mode for thinking"'); + expect(output).toContain('prompt = """'); + expect(output).toContain('This is the command body.'); + expect(output).toContain('"""'); + }); + }); + + describe('roocodeAdapter', () => { + it('should have correct toolId', () => { + expect(roocodeAdapter.toolId).toBe('roocode'); + }); + + it('should generate correct file path', () => { + const filePath = roocodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.roo', 'commands', 'opsx-explore.md')); + }); + + it('should format file with markdown header (no YAML frontmatter)', () => { + const output = roocodeAdapter.formatFile(sampleContent); + expect(output).toContain('# OpenSpec Explore'); + expect(output).toContain('Enter explore mode for thinking'); + expect(output).toContain('This is the command body.'); + expect(output).not.toContain('---'); + }); + }); + + describe('cross-platform path handling', () => { + it('Claude adapter uses path.join for paths', () => { + // path.join handles platform-specific separators + const filePath = claudeAdapter.getFilePath('test'); + // On any platform, path.join returns the correct separator + expect(filePath.split(path.sep)).toEqual(['.claude', 'commands', 'opsx', 'test.md']); + }); + + it('Cursor adapter uses path.join for paths', () => { + const filePath = cursorAdapter.getFilePath('test'); + expect(filePath.split(path.sep)).toEqual(['.cursor', 'commands', 'opsx-test.md']); + }); + + it('Windsurf adapter uses path.join for paths', () => { + const filePath = windsurfAdapter.getFilePath('test'); + expect(filePath.split(path.sep)).toEqual(['.windsurf', 'commands', 'opsx', 'test.md']); + }); + + it('All adapters use path.join for paths', () => { + // Verify all adapters produce valid paths + const adapters = [ + amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter, + codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, + crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, + iflowAdapter, kilocodeAdapter, opencodeAdapter, qoderAdapter, + qwenAdapter, roocodeAdapter + ]; + for (const adapter of adapters) { + const filePath = adapter.getFilePath('test'); + expect(filePath.length).toBeGreaterThan(0); + expect(filePath.includes(path.sep) || filePath.includes('.')).toBe(true); + } + }); + }); +}); diff --git a/test/core/command-generation/generator.test.ts b/test/core/command-generation/generator.test.ts new file mode 100644 index 00000000..903aac3e --- /dev/null +++ b/test/core/command-generation/generator.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { generateCommand, generateCommands } from '../../../src/core/command-generation/generator.js'; +import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; +import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js'; +import type { CommandContent, ToolCommandAdapter } from '../../../src/core/command-generation/types.js'; + +describe('command-generation/generator', () => { + const sampleContent: CommandContent = { + id: 'explore', + name: 'OpenSpec Explore', + description: 'Enter explore mode', + category: 'Workflow', + tags: ['workflow'], + body: 'Command body here.', + }; + + describe('generateCommand', () => { + it('should generate command with path and content using Claude adapter', () => { + const result = generateCommand(sampleContent, claudeAdapter); + + expect(result.path).toContain('.claude'); + expect(result.path).toContain('explore.md'); + expect(result.fileContent).toContain('name: OpenSpec Explore'); + expect(result.fileContent).toContain('Command body here.'); + }); + + it('should generate command with path and content using Cursor adapter', () => { + const result = generateCommand(sampleContent, cursorAdapter); + + expect(result.path).toContain('.cursor'); + expect(result.path).toContain('opsx-explore.md'); + expect(result.fileContent).toContain('name: /opsx-explore'); + expect(result.fileContent).toContain('id: opsx-explore'); + expect(result.fileContent).toContain('Command body here.'); + }); + + it('should use command id for path', () => { + const content: CommandContent = { ...sampleContent, id: 'custom-cmd' }; + const result = generateCommand(content, claudeAdapter); + + expect(result.path).toContain('custom-cmd.md'); + }); + + it('should work with custom adapter', () => { + const customAdapter: ToolCommandAdapter = { + toolId: 'custom', + getFilePath: (id) => `.custom/${id}.txt`, + formatFile: (content) => `# ${content.name}\n\n${content.body}`, + }; + + const result = generateCommand(sampleContent, customAdapter); + + expect(result.path).toBe('.custom/explore.txt'); + expect(result.fileContent).toBe('# OpenSpec Explore\n\nCommand body here.'); + }); + }); + + describe('generateCommands', () => { + it('should generate multiple commands', () => { + const contents: CommandContent[] = [ + { ...sampleContent, id: 'explore', name: 'Explore' }, + { ...sampleContent, id: 'new', name: 'New' }, + { ...sampleContent, id: 'apply', name: 'Apply' }, + ]; + + const results = generateCommands(contents, claudeAdapter); + + expect(results).toHaveLength(3); + expect(results[0].path).toContain('explore.md'); + expect(results[1].path).toContain('new.md'); + expect(results[2].path).toContain('apply.md'); + }); + + it('should return empty array for empty input', () => { + const results = generateCommands([], claudeAdapter); + expect(results).toEqual([]); + }); + + it('should preserve order of input', () => { + const contents: CommandContent[] = [ + { ...sampleContent, id: 'c', name: 'C' }, + { ...sampleContent, id: 'a', name: 'A' }, + { ...sampleContent, id: 'b', name: 'B' }, + ]; + + const results = generateCommands(contents, claudeAdapter); + + expect(results[0].path).toContain('c.md'); + expect(results[1].path).toContain('a.md'); + expect(results[2].path).toContain('b.md'); + }); + + it('should generate each command independently', () => { + const contents: CommandContent[] = [ + { id: 'a', name: 'A', description: 'DA', category: 'C1', tags: ['t1'], body: 'B1' }, + { id: 'b', name: 'B', description: 'DB', category: 'C2', tags: ['t2'], body: 'B2' }, + ]; + + const results = generateCommands(contents, claudeAdapter); + + expect(results[0].fileContent).toContain('name: A'); + expect(results[0].fileContent).toContain('B1'); + expect(results[0].fileContent).not.toContain('name: B'); + + expect(results[1].fileContent).toContain('name: B'); + expect(results[1].fileContent).toContain('B2'); + expect(results[1].fileContent).not.toContain('name: A'); + }); + }); +}); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts new file mode 100644 index 00000000..8a86cd3e --- /dev/null +++ b/test/core/command-generation/registry.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; + +describe('command-generation/registry', () => { + describe('get', () => { + it('should return Claude adapter for "claude"', () => { + const adapter = CommandAdapterRegistry.get('claude'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('claude'); + }); + + it('should return Cursor adapter for "cursor"', () => { + const adapter = CommandAdapterRegistry.get('cursor'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('cursor'); + }); + + it('should return Windsurf adapter for "windsurf"', () => { + const adapter = CommandAdapterRegistry.get('windsurf'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('windsurf'); + }); + + it('should return undefined for unregistered tool', () => { + const adapter = CommandAdapterRegistry.get('unknown-tool'); + expect(adapter).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + const adapter = CommandAdapterRegistry.get(''); + expect(adapter).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('should return array of all registered adapters', () => { + const adapters = CommandAdapterRegistry.getAll(); + expect(Array.isArray(adapters)).toBe(true); + expect(adapters.length).toBeGreaterThanOrEqual(3); // At least Claude, Cursor, Windsurf + }); + + it('should include Claude, Cursor, and Windsurf adapters', () => { + const adapters = CommandAdapterRegistry.getAll(); + const toolIds = adapters.map((a) => a.toolId); + + expect(toolIds).toContain('claude'); + expect(toolIds).toContain('cursor'); + expect(toolIds).toContain('windsurf'); + }); + }); + + describe('has', () => { + it('should return true for registered tools', () => { + expect(CommandAdapterRegistry.has('claude')).toBe(true); + expect(CommandAdapterRegistry.has('cursor')).toBe(true); + expect(CommandAdapterRegistry.has('windsurf')).toBe(true); + }); + + it('should return false for unregistered tools', () => { + expect(CommandAdapterRegistry.has('unknown')).toBe(false); + expect(CommandAdapterRegistry.has('')).toBe(false); + }); + }); + + describe('adapter functionality', () => { + it('registered adapters should have working getFilePath', () => { + const claudeAdapter = CommandAdapterRegistry.get('claude'); + const cursorAdapter = CommandAdapterRegistry.get('cursor'); + const windsurfAdapter = CommandAdapterRegistry.get('windsurf'); + + expect(claudeAdapter?.getFilePath('test')).toContain('.claude'); + expect(cursorAdapter?.getFilePath('test')).toContain('.cursor'); + expect(windsurfAdapter?.getFilePath('test')).toContain('.windsurf'); + }); + + it('registered adapters should have working formatFile', () => { + const content = { + id: 'test', + name: 'Test', + description: 'Test desc', + category: 'Test', + tags: ['tag1'], + body: 'Body content', + }; + + // Tools that don't use YAML frontmatter (markdown headers or TOML or plain) + const noYamlFrontmatter = ['cline', 'kilocode', 'roocode', 'gemini', 'qwen']; + + const adapters = CommandAdapterRegistry.getAll(); + for (const adapter of adapters) { + const output = adapter.formatFile(content); + // All adapters should include the body content + expect(output).toContain('Body content'); + // Only check for YAML frontmatter for tools that use it + if (!noYamlFrontmatter.includes(adapter.toolId)) { + expect(output).toContain('---'); + } + } + }); + }); +}); diff --git a/test/core/command-generation/types.test.ts b/test/core/command-generation/types.test.ts new file mode 100644 index 00000000..ded7daf5 --- /dev/null +++ b/test/core/command-generation/types.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from '../../../src/core/command-generation/types.js'; + +describe('command-generation/types', () => { + describe('CommandContent interface', () => { + it('should allow creating valid command content', () => { + const content: CommandContent = { + id: 'explore', + name: 'OpenSpec Explore', + description: 'Enter explore mode for thinking', + category: 'Workflow', + tags: ['workflow', 'explore'], + body: 'This is the command body content.', + }; + + expect(content.id).toBe('explore'); + expect(content.name).toBe('OpenSpec Explore'); + expect(content.description).toBe('Enter explore mode for thinking'); + expect(content.category).toBe('Workflow'); + expect(content.tags).toEqual(['workflow', 'explore']); + expect(content.body).toBe('This is the command body content.'); + }); + + it('should allow empty tags array', () => { + const content: CommandContent = { + id: 'test', + name: 'Test', + description: 'Test command', + category: 'Test', + tags: [], + body: 'Body', + }; + + expect(content.tags).toEqual([]); + }); + }); + + describe('ToolCommandAdapter interface contract', () => { + it('should implement adapter with getFilePath and formatFile', () => { + const mockAdapter: ToolCommandAdapter = { + toolId: 'test-tool', + getFilePath(commandId: string): string { + return `.test/${commandId}.md`; + }, + formatFile(content: CommandContent): string { + return `---\nname: ${content.name}\n---\n\n${content.body}\n`; + }, + }; + + expect(mockAdapter.toolId).toBe('test-tool'); + expect(mockAdapter.getFilePath('explore')).toBe('.test/explore.md'); + + const content: CommandContent = { + id: 'test', + name: 'Test Command', + description: 'Desc', + category: 'Cat', + tags: [], + body: 'Body content', + }; + + const formatted = mockAdapter.formatFile(content); + expect(formatted).toContain('name: Test Command'); + expect(formatted).toContain('Body content'); + }); + }); + + describe('GeneratedCommand interface', () => { + it('should represent generated command output', () => { + const generated: GeneratedCommand = { + path: '.claude/commands/opsx/explore.md', + fileContent: '---\nname: Test\n---\n\nBody\n', + }; + + expect(generated.path).toBe('.claude/commands/opsx/explore.md'); + expect(generated.fileContent).toContain('name: Test'); + }); + }); +});