From c9bc915f6283231800f5c539bca89929a7f41795 Mon Sep 17 00:00:00 2001 From: TabishB Date: Sat, 21 Feb 2026 16:53:05 -0800 Subject: [PATCH 1/2] fix: unify tool command reference rendering across generation --- .../design.md | 1 + .../proposal.md | 1 + .../tasks.md | 1 + openspec/specs/cli-init/spec.md | 28 ++++--- openspec/specs/command-generation/spec.md | 16 ++-- .../command-generation/adapters/opencode.ts | 6 +- src/core/command-invocation-style.ts | 47 +++++++++++ src/core/config.ts | 39 ++++----- src/core/init.ts | 18 +++-- src/core/update.ts | 26 +++--- src/utils/command-references.ts | 81 ++++++++++++++++++- src/utils/index.ts | 9 ++- test/core/command-generation/adapters.test.ts | 18 ++--- test/core/command-invocation-style.test.ts | 50 ++++++++++++ test/core/init.test.ts | 26 ++++++ test/utils/command-references.test.ts | 55 ++++++++++++- 16 files changed, 350 insertions(+), 72 deletions(-) create mode 100644 src/core/command-invocation-style.ts create mode 100644 test/core/command-invocation-style.test.ts diff --git a/openspec/changes/unify-template-generation-pipeline/design.md b/openspec/changes/unify-template-generation-pipeline/design.md index 88228f326..526422ca6 100644 --- a/openspec/changes/unify-template-generation-pipeline/design.md +++ b/openspec/changes/unify-template-generation-pipeline/design.md @@ -59,6 +59,7 @@ interface ToolProfile { toolId: string; skillsDir?: string; commandAdapterId?: string; + commandReferenceStyle: 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; transforms: string[]; } ``` diff --git a/openspec/changes/unify-template-generation-pipeline/proposal.md b/openspec/changes/unify-template-generation-pipeline/proposal.md index fc7dde577..987f02e07 100644 --- a/openspec/changes/unify-template-generation-pipeline/proposal.md +++ b/openspec/changes/unify-template-generation-pipeline/proposal.md @@ -15,6 +15,7 @@ This fragmentation creates drift risk (missing exports, missing metadata parity, - Introduce a `ToolProfileRegistry` to centralize tool capabilities (skills path, command adapter, transforms) - Introduce a first-class transform pipeline with explicit phases (`preAdapter`, `postAdapter`) and scopes (`skill`, `command`, `both`) - Introduce a shared `ArtifactSyncEngine` used by `init`, `update`, and legacy upgrade paths +- Carry forward the incremental command-invocation-style model (`opsx-colon`, `opsx-hyphen`, `openspec-hyphen`) as a first-class tool profile attribute - Add strict validation and test guardrails to preserve fidelity during migration and future changes ## Capabilities diff --git a/openspec/changes/unify-template-generation-pipeline/tasks.md b/openspec/changes/unify-template-generation-pipeline/tasks.md index a587503d4..12cb483b5 100644 --- a/openspec/changes/unify-template-generation-pipeline/tasks.md +++ b/openspec/changes/unify-template-generation-pipeline/tasks.md @@ -18,6 +18,7 @@ - [ ] 3.2 Implement transform runner with deterministic ordering - [ ] 3.3 Migrate OpenCode command reference rewrite to transform pipeline - [ ] 3.4 Remove ad-hoc transform invocation from `init` and `update` +- [ ] 3.5 Fold `commandReferenceStyle`-based invocation rendering (`opsx-colon`, `opsx-hyphen`, `openspec-hyphen`) into tool profiles + transform registry ## 4. Artifact Sync Engine diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index a1a70e59b..c6d653418 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -85,10 +85,11 @@ The command SHALL provide clear, actionable next steps upon successful initializ - "Created: " for newly configured tools - "Refreshed: " for already-configured tools that were updated - Count of skills and commands generated -- **AND** display getting started section with: - - `/opsx:new` - Start a new change - - `/opsx:continue` - Create the next artifact - - `/opsx:apply` - Implement tasks +- **AND** display getting started section with tool-appropriate command syntax for: + - Start a new change + - Create the next artifact + - Implement tasks +- **AND** when all selected tools share the same syntax style, the displayed commands SHALL match that style - **AND** display links to documentation and feedback #### Scenario: Displaying restart instruction @@ -206,15 +207,16 @@ The command SHALL generate opsx slash commands for selected AI tools. - **WHEN** a tool is selected during initialization - **THEN** create 9 slash command files using the tool's command adapter: - - `/opsx:explore` - - `/opsx:new` - - `/opsx:continue` - - `/opsx:apply` - - `/opsx:ff` - - `/opsx:verify` - - `/opsx:sync` - - `/opsx:archive` - - `/opsx:bulk-archive` + - `explore` + - `new` + - `continue` + - `apply` + - `ff` + - `verify` + - `sync` + - `archive` + - `bulk-archive` +- **AND** invocation references rendered inside command/skill content SHALL use the configured tool syntax style (for example `/opsx:`, `/opsx-`, or `/openspec-`) - **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) - **AND** include tool-specific frontmatter format diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index ea598a75a..d97a2d834 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -85,13 +85,17 @@ The system SHALL provide a registry for looking up tool adapters. - **THEN** `CommandAdapterRegistry.get()` SHALL return undefined - **AND** caller SHALL handle missing adapter appropriately -### Requirement: Shared command body content +### Requirement: Canonical body content with tool-specific invocation rendering -The body content of commands SHALL be shared across all tools. +The system SHALL treat workflow command body content as canonical and apply tool-specific invocation rendering when needed. -#### Scenario: Same instructions across tools +#### Scenario: Canonical body is shared before rendering -- **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 +- **WHEN** generating command content for multiple tools +- **THEN** the canonical workflow instructions SHALL be sourced from one shared template body +#### Scenario: Tool-specific invocation style rendering + +- **WHEN** a tool requires a different invocation syntax (for example `/opsx:`, `/opsx-`, or `/openspec-`) +- **THEN** generation SHALL rewrite command references in canonical body content to the selected tool style before adapter formatting +- **AND** this rewrite SHALL apply consistently to command artifacts and skill artifacts for that tool diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 2b078fc6c..05f9cab1b 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -6,7 +6,6 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; /** * OpenCode adapter for command generation. @@ -21,14 +20,11 @@ export const opencodeAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform command references from colon to hyphen format for OpenCode - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${content.description} --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-invocation-style.ts b/src/core/command-invocation-style.ts new file mode 100644 index 000000000..12048e3da --- /dev/null +++ b/src/core/command-invocation-style.ts @@ -0,0 +1,47 @@ +import type { CommandContent } from './command-generation/types.js'; +import { AI_TOOLS } from './config.js'; +import { + formatCommandInvocation, + getCommandReferenceTransformer, + type CommandReferenceStyle, +} from '../utils/command-references.js'; + +const DEFAULT_COMMAND_REFERENCE_STYLE: CommandReferenceStyle = 'opsx-colon'; + +export function getToolCommandReferenceStyle(toolId: string): CommandReferenceStyle { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + return tool?.commandReferenceStyle ?? DEFAULT_COMMAND_REFERENCE_STYLE; +} + +export function getToolCommandReferenceTransformer(toolId: string): ((text: string) => string) | undefined { + return getCommandReferenceTransformer(getToolCommandReferenceStyle(toolId)); +} + +export function formatToolCommandInvocation(toolId: string, commandId: string): string { + return formatCommandInvocation(commandId, getToolCommandReferenceStyle(toolId)); +} + +export function transformCommandContentsForTool(contents: CommandContent[], toolId: string): CommandContent[] { + const transform = getToolCommandReferenceTransformer(toolId); + if (!transform) { + return contents; + } + + return contents.map((content) => ({ + ...content, + body: transform(content.body), + })); +} + +export function getDisplayCommandReferenceStyle(toolIds: readonly string[]): CommandReferenceStyle { + if (toolIds.length === 0) { + return DEFAULT_COMMAND_REFERENCE_STYLE; + } + + const styles = new Set(toolIds.map((toolId) => getToolCommandReferenceStyle(toolId))); + if (styles.size === 1) { + return [...styles][0]; + } + + return DEFAULT_COMMAND_REFERENCE_STYLE; +} diff --git a/src/core/config.ts b/src/core/config.ts index f35f92861..63bac6c29 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,32 +15,33 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + commandReferenceStyle?: 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; } export const AI_TOOLS: AIToolOption[] = [ - { 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: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment', commandReferenceStyle: 'opsx-hyphen' }, { 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: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex', commandReferenceStyle: 'opsx-hyphen' }, { 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: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', commandReferenceStyle: 'opsx-hyphen' }, { 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: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory', commandReferenceStyle: 'opsx-hyphen' }, { 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: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, - { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, - { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, + { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi', commandReferenceStyle: 'opsx-hyphen' }, { 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: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' }, - { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' }, + { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', commandReferenceStyle: 'openspec-hyphen' }, + { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf', commandReferenceStyle: 'opsx-hyphen' }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/src/core/init.ts b/src/core/init.ts index cf72a5b6f..a922f1a9c 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; +import { formatCommandInvocation } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -45,6 +45,11 @@ import { getGlobalConfig, type Delivery, type Profile } from './global-config.js import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; +import { + getDisplayCommandReferenceStyle, + getToolCommandReferenceTransformer, + transformCommandContentsForTool, +} from './command-invocation-style.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -542,8 +547,7 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const transformer = getToolCommandReferenceTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -559,7 +563,8 @@ export class InitCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const generatedCommands = generateCommands(commandContents, adapter); + const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); + const generatedCommands = generateCommands(transformedCommandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); @@ -705,13 +710,14 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; + const commandStyle = getDisplayCommandReferenceStyle(successfulTools.map((tool) => tool.value)); console.log(); if (activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); + console.log(` Start your first change: ${formatCommandInvocation('propose', commandStyle)} "your idea"`); } else if (activeWorkflows.includes('new')) { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:new "your idea"'); + console.log(` Start your first change: ${formatCommandInvocation('new', commandStyle)} "your idea"`); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } diff --git a/src/core/update.ts b/src/core/update.ts index 87ddb73ac..f22d515a8 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; +import { formatCommandInvocation } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, @@ -47,6 +47,11 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; +import { + getDisplayCommandReferenceStyle, + getToolCommandReferenceTransformer, + transformCommandContentsForTool, +} from './command-invocation-style.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -192,8 +197,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const transformer = getToolCommandReferenceTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -208,7 +212,8 @@ export class UpdateCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const generatedCommands = generateCommands(commandContents, adapter); + const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); + const generatedCommands = generateCommands(transformedCommandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); @@ -250,11 +255,12 @@ export class UpdateCommand { // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { + const commandStyle = getDisplayCommandReferenceStyle(newlyConfiguredTools); console.log(); console.log(chalk.bold('Getting started:')); - console.log(' /opsx:new Start a new change'); - console.log(' /opsx:continue Create the next artifact'); - console.log(' /opsx:apply Implement tasks'); + console.log(` ${formatCommandInvocation('new', commandStyle)} Start a new change`); + console.log(` ${formatCommandInvocation('continue', commandStyle)} Create the next artifact`); + console.log(` ${formatCommandInvocation('apply', commandStyle)} Implement tasks`); console.log(); console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } @@ -585,8 +591,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const transformer = getToolCommandReferenceTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -596,7 +601,8 @@ export class UpdateCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const generatedCommands = generateCommands(commandContents, adapter); + const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); + const generatedCommands = generateCommands(transformedCommandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index bfa49b9ff..a6af66a3d 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,6 +4,85 @@ * Utilities for transforming command references to tool-specific formats. */ +/** + * Supported command invocation styles across tools. + */ +export type CommandReferenceStyle = 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; + +const OPSX_COMMAND_ID_PATTERN = /\/opsx:([a-z][a-z-]*)/g; + +const OPSX_TO_OPENSPEC_COMMAND: Record = { + 'explore': '/openspec-explore', + 'new': '/openspec-new-change', + 'continue': '/openspec-continue-change', + 'apply': '/openspec-apply-change', + 'ff': '/openspec-ff-change', + 'sync': '/openspec-sync-specs', + 'archive': '/openspec-archive-change', + 'bulk-archive': '/openspec-bulk-archive-change', + 'verify': '/openspec-verify-change', + 'onboard': '/openspec-onboard', + 'propose': '/openspec-propose', +}; + +/** + * Formats a command invocation string for a workflow command ID. + */ +export function formatCommandInvocation(commandId: string, style: CommandReferenceStyle): string { + switch (style) { + case 'opsx-colon': + return `/opsx:${commandId}`; + case 'opsx-hyphen': + return `/opsx-${commandId}`; + case 'openspec-hyphen': + return OPSX_TO_OPENSPEC_COMMAND[commandId] ?? `/openspec-${commandId}`; + default: + return `/opsx:${commandId}`; + } +} + +/** + * Formats the wildcard/family syntax used in UI hints. + */ +export function formatCommandFamily(style: CommandReferenceStyle): string { + switch (style) { + case 'opsx-colon': + return '/opsx:*'; + case 'opsx-hyphen': + return '/opsx-*'; + case 'openspec-hyphen': + return '/openspec-*'; + default: + return '/opsx:*'; + } +} + +/** + * Transforms command references from canonical `/opsx:` form to a tool style. + */ +export function transformCommandReferences(text: string, style: CommandReferenceStyle): string { + if (style === 'opsx-colon') { + return text; + } + + return text.replace(OPSX_COMMAND_ID_PATTERN, (_match, commandId: string) => + formatCommandInvocation(commandId, style) + ); +} + +/** + * Returns a text transformer for the given command-reference style. + */ +export function getCommandReferenceTransformer( + style: CommandReferenceStyle +): ((text: string) => string) | undefined { + if (style === 'opsx-colon') { + return undefined; + } + + return (text: string) => transformCommandReferences(text, style); +} + /** * Transforms colon-based command references to hyphen-based format. * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. @@ -16,5 +95,5 @@ * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement' */ export function transformToHyphenCommands(text: string): string { - return text.replace(/\/opsx:/g, '/opsx-'); + return transformCommandReferences(text, 'opsx-hyphen'); } diff --git a/src/utils/index.ts b/src/utils/index.ts index e77ddf476..92dd3008e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,4 +15,11 @@ export { export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities -export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file +export { + transformToHyphenCommands, + transformCommandReferences, + getCommandReferenceTransformer, + formatCommandInvocation, + formatCommandFamily, + type CommandReferenceStyle, +} from './command-references.js'; diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 5e4444ddb..2474b2d1e 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -455,19 +455,17 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); - it('should transform colon-based command references to hyphen-based', () => { + it('should preserve body content without inline command rewriting', () => { const contentWithCommands: CommandContent = { ...sampleContent, body: 'Use /opsx:new to start, then /opsx:apply to implement.', }; const output = opencodeAdapter.formatFile(contentWithCommands); - expect(output).toContain('/opsx-new'); - expect(output).toContain('/opsx-apply'); - expect(output).not.toContain('/opsx:new'); - expect(output).not.toContain('/opsx:apply'); + expect(output).toContain('/opsx:new'); + expect(output).toContain('/opsx:apply'); }); - it('should handle multiple command references in body', () => { + it('should keep multiline command references unchanged', () => { const contentWithMultipleCommands: CommandContent = { ...sampleContent, body: `/opsx:explore for ideas @@ -476,10 +474,10 @@ describe('command-generation/adapters', () => { /opsx:apply to implement`, }; const output = opencodeAdapter.formatFile(contentWithMultipleCommands); - expect(output).toContain('/opsx-explore'); - expect(output).toContain('/opsx-new'); - expect(output).toContain('/opsx-continue'); - expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx:explore'); + expect(output).toContain('/opsx:new'); + expect(output).toContain('/opsx:continue'); + expect(output).toContain('/opsx:apply'); }); }); diff --git a/test/core/command-invocation-style.test.ts b/test/core/command-invocation-style.test.ts new file mode 100644 index 000000000..9de3ad996 --- /dev/null +++ b/test/core/command-invocation-style.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + getDisplayCommandReferenceStyle, + getToolCommandReferenceStyle, + transformCommandContentsForTool, +} from '../../src/core/command-invocation-style.js'; +import type { CommandContent } from '../../src/core/command-generation/types.js'; + +describe('command-invocation-style', () => { + it('should resolve command reference style from tool metadata', () => { + expect(getToolCommandReferenceStyle('claude')).toBe('opsx-colon'); + expect(getToolCommandReferenceStyle('codex')).toBe('opsx-hyphen'); + expect(getToolCommandReferenceStyle('trae')).toBe('openspec-hyphen'); + }); + + it('should transform command bodies for hyphen-style tools', () => { + const contents: CommandContent[] = [{ + id: 'new', + name: 'OpenSpec New', + description: 'Start a new change', + category: 'Workflow', + tags: [], + body: 'Run /opsx:new then /opsx:apply', + }]; + + const transformed = transformCommandContentsForTool(contents, 'codex'); + expect(transformed[0].body).toContain('/opsx-new'); + expect(transformed[0].body).toContain('/opsx-apply'); + }); + + it('should transform command bodies for openspec-style tools', () => { + const contents: CommandContent[] = [{ + id: 'new', + name: 'OpenSpec New', + description: 'Start a new change', + category: 'Workflow', + tags: [], + body: 'Run /opsx:new then /opsx:continue', + }]; + + const transformed = transformCommandContentsForTool(contents, 'trae'); + expect(transformed[0].body).toContain('/openspec-new-change'); + expect(transformed[0].body).toContain('/openspec-continue-change'); + }); + + it('should keep display style only when all tools agree', () => { + expect(getDisplayCommandReferenceStyle(['codex', 'cursor'])).toBe('opsx-hyphen'); + expect(getDisplayCommandReferenceStyle(['claude', 'cursor'])).toBe('opsx-colon'); + }); +}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 0f6fb42b7..000971094 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -354,6 +354,17 @@ describe('InitCommand', () => { // Should contain generatedBy field with a version string expect(content).toMatch(/generatedBy:\s*["']?\d+\.\d+\.\d+["']?/); }); + + it('should rewrite skill command references for Trae invocation style', async () => { + const initCommand = new InitCommand({ tools: 'trae', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-propose', 'SKILL.md'); + const content = await fs.readFile(skillFile, 'utf-8'); + + expect(content).toContain('/openspec-apply-change'); + expect(content).not.toContain('/opsx:apply'); + }); }); describe('command generation', () => { @@ -380,6 +391,21 @@ describe('InitCommand', () => { const content = await fs.readFile(cmdFile, 'utf-8'); expect(content).toMatch(/^---\n/); }); + + it('should rewrite Codex command references to hyphen syntax', async () => { + const codexHome = path.join(testDir, '.codex-home'); + process.env.CODEX_HOME = codexHome; + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(codexHome, 'prompts', 'opsx-propose.md'); + expect(await fileExists(cmdFile)).toBe(true); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toContain('/opsx-apply'); + expect(content).not.toContain('/opsx:apply'); + }); }); describe('error handling', () => { diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85..4ecc356df 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { transformToHyphenCommands } from '../../src/utils/command-references.js'; +import { + transformToHyphenCommands, + transformCommandReferences, + formatCommandInvocation, + formatCommandFamily, +} from '../../src/utils/command-references.js'; describe('transformToHyphenCommands', () => { describe('basic transformations', () => { @@ -81,3 +86,51 @@ Finally /opsx-apply to implement`; } }); }); + +describe('transformCommandReferences', () => { + it('should preserve colon style as-is', () => { + const input = 'Run /opsx:new then /opsx:apply'; + expect(transformCommandReferences(input, 'opsx-colon')).toBe(input); + }); + + it('should transform to hyphen style', () => { + const input = 'Run /opsx:new then /opsx:apply'; + expect(transformCommandReferences(input, 'opsx-hyphen')).toBe('Run /opsx-new then /opsx-apply'); + }); + + it('should transform to openspec skill invocation style', () => { + const input = 'Run /opsx:new then /opsx:continue then /opsx:apply'; + expect(transformCommandReferences(input, 'openspec-hyphen')).toBe( + 'Run /openspec-new-change then /openspec-continue-change then /openspec-apply-change' + ); + }); +}); + +describe('formatCommandInvocation', () => { + it('should format opsx colon syntax', () => { + expect(formatCommandInvocation('propose', 'opsx-colon')).toBe('/opsx:propose'); + }); + + it('should format opsx hyphen syntax', () => { + expect(formatCommandInvocation('propose', 'opsx-hyphen')).toBe('/opsx-propose'); + }); + + it('should format openspec skill syntax for known workflow IDs', () => { + expect(formatCommandInvocation('new', 'openspec-hyphen')).toBe('/openspec-new-change'); + expect(formatCommandInvocation('sync', 'openspec-hyphen')).toBe('/openspec-sync-specs'); + }); +}); + +describe('formatCommandFamily', () => { + it('should format wildcard hint for opsx colon style', () => { + expect(formatCommandFamily('opsx-colon')).toBe('/opsx:*'); + }); + + it('should format wildcard hint for opsx hyphen style', () => { + expect(formatCommandFamily('opsx-hyphen')).toBe('/opsx-*'); + }); + + it('should format wildcard hint for openspec hyphen style', () => { + expect(formatCommandFamily('openspec-hyphen')).toBe('/openspec-*'); + }); +}); From 1f9d39c327f94992262a2cd62714439f8ae2dc3e Mon Sep 17 00:00:00 2001 From: TabishB Date: Sat, 21 Feb 2026 17:13:06 -0800 Subject: [PATCH 2/2] chore: narrow invocation work into unify pipeline proposal --- .../design.md | 65 +++++++++++---- .../proposal.md | 13 ++- .../tasks.md | 21 +++-- openspec/specs/cli-init/spec.md | 28 +++---- openspec/specs/command-generation/spec.md | 16 ++-- .../command-generation/adapters/opencode.ts | 6 +- src/core/command-invocation-style.ts | 47 ----------- src/core/config.ts | 39 +++++---- src/core/init.ts | 18 ++--- src/core/update.ts | 26 +++--- src/utils/command-references.ts | 81 +------------------ src/utils/index.ts | 9 +-- test/core/command-generation/adapters.test.ts | 18 +++-- test/core/command-invocation-style.test.ts | 50 ------------ test/core/init.test.ts | 26 ------ test/utils/command-references.test.ts | 55 +------------ 16 files changed, 145 insertions(+), 373 deletions(-) delete mode 100644 src/core/command-invocation-style.ts delete mode 100644 test/core/command-invocation-style.test.ts diff --git a/openspec/changes/unify-template-generation-pipeline/design.md b/openspec/changes/unify-template-generation-pipeline/design.md index 526422ca6..a3698473c 100644 --- a/openspec/changes/unify-template-generation-pipeline/design.md +++ b/openspec/changes/unify-template-generation-pipeline/design.md @@ -15,19 +15,21 @@ The design goal is to preserve current behavior while making extension points ex - Define one canonical source for workflow content and metadata - Make tool/agent-specific behavior explicit and centrally discoverable - Keep command adapters as the formatting boundary for tool syntax differences +- Represent tool-specific command surfaces and terminology explicitly (not as scattered string rewrites) - Consolidate artifact generation/write orchestration into one reusable engine - Improve correctness with enforceable validation and parity tests **Non-Goals:** - Redesigning command semantics or workflow instruction content - Changing user-facing CLI command names/flags in this proposal +- Guaranteeing fully accurate literal slash-command strings for every supported tool on day one - Merging unrelated legacy cleanup behavior beyond artifact generation reuse ## Decisions ### 1. Canonical `WorkflowManifest` -**Decision**: Represent each workflow once in a manifest entry containing canonical skill and command definitions plus metadata defaults. +**Decision**: Represent each workflow once in a manifest entry containing canonical skill and command definitions plus metadata defaults. Canonical text uses semantic tokens for tool-specific references. Suggested shape: @@ -41,6 +43,12 @@ interface WorkflowManifestEntry { tags: string[]; compatibility: string; } + +// Examples in canonical workflow text: +// - {{cmd.apply}} +// - {{cmd.continue.withArg}} +// - {{term.change}} +// - {{term.workflow}} ``` **Rationale**: @@ -50,7 +58,7 @@ interface WorkflowManifestEntry { ### 2. `ToolProfileRegistry` for capability wiring -**Decision**: Add a tool profile layer that maps tool IDs to generation capabilities and behavior. +**Decision**: Add a tool profile layer that maps tool IDs to generation capabilities, command-surface rendering, and terminology. Suggested shape: @@ -59,7 +67,16 @@ interface ToolProfile { toolId: string; skillsDir?: string; commandAdapterId?: string; - commandReferenceStyle: 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; + commandSurface: { + pattern: 'opsx-colon' | 'opsx-hyphen' | 'opsx-slash' | 'openspec-hyphen' | 'custom'; + verified: boolean; + aliases?: string[]; + }; + terminology: { + change: string; + workflow: string; + command: string; + }; transforms: string[]; } ``` @@ -68,10 +85,12 @@ interface ToolProfile { - Prevents capability drift between `AI_TOOLS`, adapter registry, and detection logic - Allows intentional "skills-only" tools without implicit special casing - Provides one place to answer "what does this tool support?" +- Makes command rendering decisions explicit and testable +- Supports future terminology tailoring without copy/paste template forks ### 3. First-class transform pipeline -**Decision**: Model transforms as ordered plugins with scope + phase + applicability. +**Decision**: Model transforms as ordered plugins with scope + phase + applicability. Include token rendering in the transform pipeline instead of hardcoding literal command strings in templates. Suggested shape: @@ -88,17 +107,32 @@ interface ArtifactTransform { Execution order: 1. Render canonical content from manifest -2. Apply matching `preAdapter` transforms -3. For commands, run adapter formatting -4. Apply matching `postAdapter` transforms -5. Validate and write +2. Apply token-render transform (`{{cmd.*}}`, `{{term.*}}`) using tool profile +3. Apply matching `preAdapter` transforms +4. For commands, run adapter formatting +5. Apply matching `postAdapter` transforms +6. Validate and write **Rationale**: - Keeps adapters focused on tool formatting, not scattered behavioral rewrites - Makes agent-specific modifications explicit and testable - Replaces ad-hoc transform calls in `init`/`update` +- Enables neutral fallback rendering when a tool profile is not verified for literal command syntax + +### 4. Fallback policy for unverified command surfaces + +**Decision**: When a tool profile has `commandSurface.verified === false`, command tokens SHALL render to neutral workflow guidance instead of literal slash-command strings. + +Examples: +- Literal (verified): `Run {{cmd.apply}}` +- Neutral (unverified): `Run the Apply workflow` or `use the apply skill` + +**Rationale**: +- Prevents confidently wrong guidance in generated artifacts +- Allows incremental tool-surface verification without blocking rollout +- Keeps templates stable while rendering policy evolves -### 4. Shared `ArtifactSyncEngine` +### 5. Shared `ArtifactSyncEngine` **Decision**: Introduce a single orchestration engine used by all generation entry points. @@ -113,13 +147,15 @@ Responsibilities: - Enables dry-run and future preview features without re-implementing logic - Improves reliability of updates and legacy migrations -### 5. Validation + parity guardrails +### 6. Validation + parity guardrails **Decision**: Add strict checks in tests (and optional runtime assertions in dev builds) for: - Required skill metadata fields (`license`, `compatibility`, `metadata`) present for all manifest entries - Projection consistency (skills, commands, detection names derived from manifest) - Tool profile consistency (adapter existence, expected capabilities) +- Token coverage checks (no unresolved `{{...}}` tokens in rendered outputs) +- Tool command-surface verification matrix and fallback expectations - Golden/parity output for key workflows/tools **Rationale**: @@ -144,7 +180,8 @@ Adding manifest/profile/transform registries increases conceptual surface area. ## Implementation Approach 1. Build manifest + profile + transform types and registries behind current public API -2. Rewire `getSkillTemplates`/`getCommandContents` to derive from manifest -3. Introduce `ArtifactSyncEngine` and switch `init` to use it with parity checks -4. Switch `update` and legacy upgrade flows to same engine -5. Remove duplicate/hardcoded lists after parity is green +2. Tokenize command/terminology references in workflow templates +3. Rewire `getSkillTemplates`/`getCommandContents` to derive from manifest +4. Introduce `ArtifactSyncEngine` and switch `init` to use it with parity checks +5. Switch `update` and legacy upgrade flows to same engine +6. Remove duplicate/hardcoded lists after parity is green diff --git a/openspec/changes/unify-template-generation-pipeline/proposal.md b/openspec/changes/unify-template-generation-pipeline/proposal.md index 987f02e07..2a440b860 100644 --- a/openspec/changes/unify-template-generation-pipeline/proposal.md +++ b/openspec/changes/unify-template-generation-pipeline/proposal.md @@ -4,7 +4,8 @@ The recent split of `skill-templates.ts` into workflow modules improved readabil - Workflow definitions are split from projection logic (`getSkillTemplates`, `getCommandTemplates`, `getCommandContents`) - Tool capability and compatibility are spread across `AI_TOOLS`, `CommandAdapterRegistry`, and hardcoded lists like `SKILL_NAMES` -- Agent/tool-specific transformations (for example OpenCode command reference rewrites) are applied in different places (`init`, `update`, and adapter code) +- Agent/tool-specific transformations are applied in different places (`init`, `update`, and adapter code) +- Command and terminology references are currently hardcoded in workflow text, but tool invocation surfaces vary (`/opsx:apply`, `/opsx-apply`, `/opsx/apply`, and tool-specific naming) - Artifact writing logic is duplicated across `init`, `update`, and legacy-upgrade flow This fragmentation creates drift risk (missing exports, missing metadata parity, mismatched counts/support) and makes future workflow/tool additions slower and less predictable. @@ -15,14 +16,16 @@ This fragmentation creates drift risk (missing exports, missing metadata parity, - Introduce a `ToolProfileRegistry` to centralize tool capabilities (skills path, command adapter, transforms) - Introduce a first-class transform pipeline with explicit phases (`preAdapter`, `postAdapter`) and scopes (`skill`, `command`, `both`) - Introduce a shared `ArtifactSyncEngine` used by `init`, `update`, and legacy upgrade paths -- Carry forward the incremental command-invocation-style model (`opsx-colon`, `opsx-hyphen`, `openspec-hyphen`) as a first-class tool profile attribute +- Add tokenized workflow text rendering so command references and tool terminology are resolved per tool profile at generation time +- Add explicit command-surface profiles per tool (pattern, namespace/path style, alias support, verification status) +- Add safe fallback behavior: when a tool command surface is not verified, render neutral workflow guidance (for example skill/workflow names) instead of potentially wrong literal command strings - Add strict validation and test guardrails to preserve fidelity during migration and future changes ## Capabilities ### New Capabilities -- `template-artifact-pipeline`: Unified workflow manifest, tool profile registry, transform pipeline, and sync engine for skill/command generation +- `template-artifact-pipeline`: Unified workflow manifest, tool profile registry, token-aware transform pipeline, and sync engine for skill/command generation ### Modified Capabilities @@ -42,7 +45,9 @@ This fragmentation creates drift risk (missing exports, missing metadata parity, - **Testing additions**: - Manifest completeness tests (workflows, required metadata, projection parity) - Transform ordering and applicability tests + - Tool command-surface/terminology profile validation tests - End-to-end parity tests for generated skill/command outputs across tools - **User-facing behavior**: - No new CLI surface area required - - Existing generated artifacts remain behaviorally equivalent unless explicitly changed in future deltas + - Generated text may become more tool-accurate for verified tool profiles + - Generated text may intentionally use neutral workflow wording for unverified tools to avoid incorrect slash-command guidance diff --git a/openspec/changes/unify-template-generation-pipeline/tasks.md b/openspec/changes/unify-template-generation-pipeline/tasks.md index 12cb483b5..02509fd7a 100644 --- a/openspec/changes/unify-template-generation-pipeline/tasks.md +++ b/openspec/changes/unify-template-generation-pipeline/tasks.md @@ -10,15 +10,18 @@ - [ ] 2.1 Add `ToolProfile` types and `ToolProfileRegistry` - [ ] 2.2 Map all currently supported tools to explicit profile entries - [ ] 2.3 Wire profile lookups to command adapter resolution and skills path resolution -- [ ] 2.4 Replace hardcoded detection arrays (for example `SKILL_NAMES`) with manifest-derived values +- [ ] 2.4 Add per-tool `commandSurface` metadata (pattern, aliases, `verified` flag) +- [ ] 2.5 Add per-tool terminology metadata (for example change/workflow/command labels) +- [ ] 2.6 Replace hardcoded detection arrays (for example `SKILL_NAMES`) with manifest-derived values ## 3. Transform Pipeline - [ ] 3.1 Introduce transform interfaces (`scope`, `phase`, `priority`, `applies`, `transform`) - [ ] 3.2 Implement transform runner with deterministic ordering -- [ ] 3.3 Migrate OpenCode command reference rewrite to transform pipeline -- [ ] 3.4 Remove ad-hoc transform invocation from `init` and `update` -- [ ] 3.5 Fold `commandReferenceStyle`-based invocation rendering (`opsx-colon`, `opsx-hyphen`, `openspec-hyphen`) into tool profiles + transform registry +- [ ] 3.3 Add token renderer transform for command + terminology tokens (`{{cmd.*}}`, `{{term.*}}`) +- [ ] 3.4 Implement neutral fallback rendering for tools with unverified command surfaces +- [ ] 3.5 Migrate OpenCode command reference rewrite to transform pipeline +- [ ] 3.6 Remove ad-hoc transform invocation from `init` and `update` ## 4. Artifact Sync Engine @@ -30,10 +33,12 @@ ## 5. Validation and Tests - [ ] 5.1 Add manifest completeness tests (metadata required fields, command IDs, dir names) -- [ ] 5.2 Add tool-profile consistency tests (skillsDir support and adapter/profile alignment) -- [ ] 5.3 Add transform applicability/order tests -- [ ] 5.4 Expand parity tests for representative workflow/tool matrix -- [ ] 5.5 Run full test suite and verify generated artifacts remain stable +- [ ] 5.2 Add tool-profile consistency tests (skillsDir support, adapter/profile alignment, command-surface metadata) +- [ ] 5.3 Add token rendering tests (all tokens resolved, per-tool rendering correctness) +- [ ] 5.4 Add fallback tests for unverified tool command surfaces +- [ ] 5.5 Add transform applicability/order tests +- [ ] 5.6 Expand parity tests for representative workflow/tool matrix +- [ ] 5.7 Run full test suite and verify generated artifacts remain stable ## 6. Cleanup and Documentation diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index c6d653418..a1a70e59b 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -85,11 +85,10 @@ The command SHALL provide clear, actionable next steps upon successful initializ - "Created: " for newly configured tools - "Refreshed: " for already-configured tools that were updated - Count of skills and commands generated -- **AND** display getting started section with tool-appropriate command syntax for: - - Start a new change - - Create the next artifact - - Implement tasks -- **AND** when all selected tools share the same syntax style, the displayed commands SHALL match that style +- **AND** display getting started section with: + - `/opsx:new` - Start a new change + - `/opsx:continue` - Create the next artifact + - `/opsx:apply` - Implement tasks - **AND** display links to documentation and feedback #### Scenario: Displaying restart instruction @@ -207,16 +206,15 @@ The command SHALL generate opsx slash commands for selected AI tools. - **WHEN** a tool is selected during initialization - **THEN** create 9 slash command files using the tool's command adapter: - - `explore` - - `new` - - `continue` - - `apply` - - `ff` - - `verify` - - `sync` - - `archive` - - `bulk-archive` -- **AND** invocation references rendered inside command/skill content SHALL use the configured tool syntax style (for example `/opsx:`, `/opsx-`, or `/openspec-`) + - `/opsx:explore` + - `/opsx:new` + - `/opsx:continue` + - `/opsx:apply` + - `/opsx:ff` + - `/opsx:verify` + - `/opsx:sync` + - `/opsx:archive` + - `/opsx:bulk-archive` - **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) - **AND** include tool-specific frontmatter format diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index d97a2d834..ea598a75a 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -85,17 +85,13 @@ The system SHALL provide a registry for looking up tool adapters. - **THEN** `CommandAdapterRegistry.get()` SHALL return undefined - **AND** caller SHALL handle missing adapter appropriately -### Requirement: Canonical body content with tool-specific invocation rendering +### Requirement: Shared command body content -The system SHALL treat workflow command body content as canonical and apply tool-specific invocation rendering when needed. +The body content of commands SHALL be shared across all tools. -#### Scenario: Canonical body is shared before rendering +#### Scenario: Same instructions across tools -- **WHEN** generating command content for multiple tools -- **THEN** the canonical workflow instructions SHALL be sourced from one shared template body +- **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 -#### Scenario: Tool-specific invocation style rendering - -- **WHEN** a tool requires a different invocation syntax (for example `/opsx:`, `/opsx-`, or `/openspec-`) -- **THEN** generation SHALL rewrite command references in canonical body content to the selected tool style before adapter formatting -- **AND** this rewrite SHALL apply consistently to command artifacts and skill artifacts for that tool diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 05f9cab1b..2b078fc6c 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -6,6 +6,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { transformToHyphenCommands } from '../../../utils/command-references.js'; /** * OpenCode adapter for command generation. @@ -20,11 +21,14 @@ export const opencodeAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { + // Transform command references from colon to hyphen format for OpenCode + const transformedBody = transformToHyphenCommands(content.body); + return `--- description: ${content.description} --- -${content.body} +${transformedBody} `; }, }; diff --git a/src/core/command-invocation-style.ts b/src/core/command-invocation-style.ts deleted file mode 100644 index 12048e3da..000000000 --- a/src/core/command-invocation-style.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { CommandContent } from './command-generation/types.js'; -import { AI_TOOLS } from './config.js'; -import { - formatCommandInvocation, - getCommandReferenceTransformer, - type CommandReferenceStyle, -} from '../utils/command-references.js'; - -const DEFAULT_COMMAND_REFERENCE_STYLE: CommandReferenceStyle = 'opsx-colon'; - -export function getToolCommandReferenceStyle(toolId: string): CommandReferenceStyle { - const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); - return tool?.commandReferenceStyle ?? DEFAULT_COMMAND_REFERENCE_STYLE; -} - -export function getToolCommandReferenceTransformer(toolId: string): ((text: string) => string) | undefined { - return getCommandReferenceTransformer(getToolCommandReferenceStyle(toolId)); -} - -export function formatToolCommandInvocation(toolId: string, commandId: string): string { - return formatCommandInvocation(commandId, getToolCommandReferenceStyle(toolId)); -} - -export function transformCommandContentsForTool(contents: CommandContent[], toolId: string): CommandContent[] { - const transform = getToolCommandReferenceTransformer(toolId); - if (!transform) { - return contents; - } - - return contents.map((content) => ({ - ...content, - body: transform(content.body), - })); -} - -export function getDisplayCommandReferenceStyle(toolIds: readonly string[]): CommandReferenceStyle { - if (toolIds.length === 0) { - return DEFAULT_COMMAND_REFERENCE_STYLE; - } - - const styles = new Set(toolIds.map((toolId) => getToolCommandReferenceStyle(toolId))); - if (styles.size === 1) { - return [...styles][0]; - } - - return DEFAULT_COMMAND_REFERENCE_STYLE; -} diff --git a/src/core/config.ts b/src/core/config.ts index 63bac6c29..f35f92861 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,33 +15,32 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec - commandReferenceStyle?: 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; } export const AI_TOOLS: AIToolOption[] = [ - { name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment', commandReferenceStyle: 'opsx-hyphen' }, + { 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', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex', commandReferenceStyle: 'opsx-hyphen' }, + { 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', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec', commandReferenceStyle: 'opsx-hyphen' }, + { 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', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory', commandReferenceStyle: 'opsx-hyphen' }, + { 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', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi', commandReferenceStyle: 'opsx-hyphen' }, + { 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: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' }, + { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, + { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' }, - { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo', commandReferenceStyle: 'opsx-hyphen' }, - { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae', commandReferenceStyle: 'openspec-hyphen' }, - { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf', commandReferenceStyle: 'opsx-hyphen' }, + { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' }, + { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' }, + { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' }, + { 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/src/core/init.ts b/src/core/init.ts index a922f1a9c..cf72a5b6f 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { formatCommandInvocation } from '../utils/command-references.js'; +import { transformToHyphenCommands } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -45,11 +45,6 @@ import { getGlobalConfig, type Delivery, type Profile } from './global-config.js import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; -import { - getDisplayCommandReferenceStyle, - getToolCommandReferenceTransformer, - transformCommandContentsForTool, -} from './command-invocation-style.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -547,7 +542,8 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - const transformer = getToolCommandReferenceTransformer(tool.value); + // Use hyphen-based command references for OpenCode + const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -563,8 +559,7 @@ export class InitCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); - const generatedCommands = generateCommands(transformedCommandContents, adapter); + const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); @@ -710,14 +705,13 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; - const commandStyle = getDisplayCommandReferenceStyle(successfulTools.map((tool) => tool.value)); console.log(); if (activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${formatCommandInvocation('propose', commandStyle)} "your idea"`); + console.log(' Start your first change: /opsx:propose "your idea"'); } else if (activeWorkflows.includes('new')) { console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${formatCommandInvocation('new', commandStyle)} "your idea"`); + console.log(' Start your first change: /opsx:new "your idea"'); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } diff --git a/src/core/update.ts b/src/core/update.ts index f22d515a8..87ddb73ac 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { formatCommandInvocation } from '../utils/command-references.js'; +import { transformToHyphenCommands } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, @@ -47,11 +47,6 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; -import { - getDisplayCommandReferenceStyle, - getToolCommandReferenceTransformer, - transformCommandContentsForTool, -} from './command-invocation-style.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -197,7 +192,8 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getToolCommandReferenceTransformer(tool.value); + // Use hyphen-based command references for OpenCode + const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -212,8 +208,7 @@ export class UpdateCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); - const generatedCommands = generateCommands(transformedCommandContents, adapter); + const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); @@ -255,12 +250,11 @@ export class UpdateCommand { // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { - const commandStyle = getDisplayCommandReferenceStyle(newlyConfiguredTools); console.log(); console.log(chalk.bold('Getting started:')); - console.log(` ${formatCommandInvocation('new', commandStyle)} Start a new change`); - console.log(` ${formatCommandInvocation('continue', commandStyle)} Create the next artifact`); - console.log(` ${formatCommandInvocation('apply', commandStyle)} Implement tasks`); + console.log(' /opsx:new Start a new change'); + console.log(' /opsx:continue Create the next artifact'); + console.log(' /opsx:apply Implement tasks'); console.log(); console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } @@ -591,7 +585,8 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getToolCommandReferenceTransformer(tool.value); + // Use hyphen-based command references for OpenCode + const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -601,8 +596,7 @@ export class UpdateCommand { if (shouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { - const transformedCommandContents = transformCommandContentsForTool(commandContents, tool.value); - const generatedCommands = generateCommands(transformedCommandContents, adapter); + const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index a6af66a3d..bfa49b9ff 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,85 +4,6 @@ * Utilities for transforming command references to tool-specific formats. */ -/** - * Supported command invocation styles across tools. - */ -export type CommandReferenceStyle = 'opsx-colon' | 'opsx-hyphen' | 'openspec-hyphen'; - -const OPSX_COMMAND_ID_PATTERN = /\/opsx:([a-z][a-z-]*)/g; - -const OPSX_TO_OPENSPEC_COMMAND: Record = { - 'explore': '/openspec-explore', - 'new': '/openspec-new-change', - 'continue': '/openspec-continue-change', - 'apply': '/openspec-apply-change', - 'ff': '/openspec-ff-change', - 'sync': '/openspec-sync-specs', - 'archive': '/openspec-archive-change', - 'bulk-archive': '/openspec-bulk-archive-change', - 'verify': '/openspec-verify-change', - 'onboard': '/openspec-onboard', - 'propose': '/openspec-propose', -}; - -/** - * Formats a command invocation string for a workflow command ID. - */ -export function formatCommandInvocation(commandId: string, style: CommandReferenceStyle): string { - switch (style) { - case 'opsx-colon': - return `/opsx:${commandId}`; - case 'opsx-hyphen': - return `/opsx-${commandId}`; - case 'openspec-hyphen': - return OPSX_TO_OPENSPEC_COMMAND[commandId] ?? `/openspec-${commandId}`; - default: - return `/opsx:${commandId}`; - } -} - -/** - * Formats the wildcard/family syntax used in UI hints. - */ -export function formatCommandFamily(style: CommandReferenceStyle): string { - switch (style) { - case 'opsx-colon': - return '/opsx:*'; - case 'opsx-hyphen': - return '/opsx-*'; - case 'openspec-hyphen': - return '/openspec-*'; - default: - return '/opsx:*'; - } -} - -/** - * Transforms command references from canonical `/opsx:` form to a tool style. - */ -export function transformCommandReferences(text: string, style: CommandReferenceStyle): string { - if (style === 'opsx-colon') { - return text; - } - - return text.replace(OPSX_COMMAND_ID_PATTERN, (_match, commandId: string) => - formatCommandInvocation(commandId, style) - ); -} - -/** - * Returns a text transformer for the given command-reference style. - */ -export function getCommandReferenceTransformer( - style: CommandReferenceStyle -): ((text: string) => string) | undefined { - if (style === 'opsx-colon') { - return undefined; - } - - return (text: string) => transformCommandReferences(text, style); -} - /** * Transforms colon-based command references to hyphen-based format. * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. @@ -95,5 +16,5 @@ export function getCommandReferenceTransformer( * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement' */ export function transformToHyphenCommands(text: string): string { - return transformCommandReferences(text, 'opsx-hyphen'); + return text.replace(/\/opsx:/g, '/opsx-'); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 92dd3008e..e77ddf476 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,11 +15,4 @@ export { export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities -export { - transformToHyphenCommands, - transformCommandReferences, - getCommandReferenceTransformer, - formatCommandInvocation, - formatCommandFamily, - type CommandReferenceStyle, -} from './command-references.js'; +export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 2474b2d1e..5e4444ddb 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -455,17 +455,19 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); - it('should preserve body content without inline command rewriting', () => { + it('should transform colon-based command references to hyphen-based', () => { const contentWithCommands: CommandContent = { ...sampleContent, body: 'Use /opsx:new to start, then /opsx:apply to implement.', }; const output = opencodeAdapter.formatFile(contentWithCommands); - expect(output).toContain('/opsx:new'); - expect(output).toContain('/opsx:apply'); + expect(output).toContain('/opsx-new'); + expect(output).toContain('/opsx-apply'); + expect(output).not.toContain('/opsx:new'); + expect(output).not.toContain('/opsx:apply'); }); - it('should keep multiline command references unchanged', () => { + it('should handle multiple command references in body', () => { const contentWithMultipleCommands: CommandContent = { ...sampleContent, body: `/opsx:explore for ideas @@ -474,10 +476,10 @@ describe('command-generation/adapters', () => { /opsx:apply to implement`, }; const output = opencodeAdapter.formatFile(contentWithMultipleCommands); - expect(output).toContain('/opsx:explore'); - expect(output).toContain('/opsx:new'); - expect(output).toContain('/opsx:continue'); - expect(output).toContain('/opsx:apply'); + expect(output).toContain('/opsx-explore'); + expect(output).toContain('/opsx-new'); + expect(output).toContain('/opsx-continue'); + expect(output).toContain('/opsx-apply'); }); }); diff --git a/test/core/command-invocation-style.test.ts b/test/core/command-invocation-style.test.ts deleted file mode 100644 index 9de3ad996..000000000 --- a/test/core/command-invocation-style.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getDisplayCommandReferenceStyle, - getToolCommandReferenceStyle, - transformCommandContentsForTool, -} from '../../src/core/command-invocation-style.js'; -import type { CommandContent } from '../../src/core/command-generation/types.js'; - -describe('command-invocation-style', () => { - it('should resolve command reference style from tool metadata', () => { - expect(getToolCommandReferenceStyle('claude')).toBe('opsx-colon'); - expect(getToolCommandReferenceStyle('codex')).toBe('opsx-hyphen'); - expect(getToolCommandReferenceStyle('trae')).toBe('openspec-hyphen'); - }); - - it('should transform command bodies for hyphen-style tools', () => { - const contents: CommandContent[] = [{ - id: 'new', - name: 'OpenSpec New', - description: 'Start a new change', - category: 'Workflow', - tags: [], - body: 'Run /opsx:new then /opsx:apply', - }]; - - const transformed = transformCommandContentsForTool(contents, 'codex'); - expect(transformed[0].body).toContain('/opsx-new'); - expect(transformed[0].body).toContain('/opsx-apply'); - }); - - it('should transform command bodies for openspec-style tools', () => { - const contents: CommandContent[] = [{ - id: 'new', - name: 'OpenSpec New', - description: 'Start a new change', - category: 'Workflow', - tags: [], - body: 'Run /opsx:new then /opsx:continue', - }]; - - const transformed = transformCommandContentsForTool(contents, 'trae'); - expect(transformed[0].body).toContain('/openspec-new-change'); - expect(transformed[0].body).toContain('/openspec-continue-change'); - }); - - it('should keep display style only when all tools agree', () => { - expect(getDisplayCommandReferenceStyle(['codex', 'cursor'])).toBe('opsx-hyphen'); - expect(getDisplayCommandReferenceStyle(['claude', 'cursor'])).toBe('opsx-colon'); - }); -}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 000971094..0f6fb42b7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -354,17 +354,6 @@ describe('InitCommand', () => { // Should contain generatedBy field with a version string expect(content).toMatch(/generatedBy:\s*["']?\d+\.\d+\.\d+["']?/); }); - - it('should rewrite skill command references for Trae invocation style', async () => { - const initCommand = new InitCommand({ tools: 'trae', force: true }); - await initCommand.execute(testDir); - - const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-propose', 'SKILL.md'); - const content = await fs.readFile(skillFile, 'utf-8'); - - expect(content).toContain('/openspec-apply-change'); - expect(content).not.toContain('/opsx:apply'); - }); }); describe('command generation', () => { @@ -391,21 +380,6 @@ describe('InitCommand', () => { const content = await fs.readFile(cmdFile, 'utf-8'); expect(content).toMatch(/^---\n/); }); - - it('should rewrite Codex command references to hyphen syntax', async () => { - const codexHome = path.join(testDir, '.codex-home'); - process.env.CODEX_HOME = codexHome; - - const initCommand = new InitCommand({ tools: 'codex', force: true }); - await initCommand.execute(testDir); - - const cmdFile = path.join(codexHome, 'prompts', 'opsx-propose.md'); - expect(await fileExists(cmdFile)).toBe(true); - - const content = await fs.readFile(cmdFile, 'utf-8'); - expect(content).toContain('/opsx-apply'); - expect(content).not.toContain('/opsx:apply'); - }); }); describe('error handling', () => { diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 4ecc356df..c7ff2ed85 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - transformToHyphenCommands, - transformCommandReferences, - formatCommandInvocation, - formatCommandFamily, -} from '../../src/utils/command-references.js'; +import { transformToHyphenCommands } from '../../src/utils/command-references.js'; describe('transformToHyphenCommands', () => { describe('basic transformations', () => { @@ -86,51 +81,3 @@ Finally /opsx-apply to implement`; } }); }); - -describe('transformCommandReferences', () => { - it('should preserve colon style as-is', () => { - const input = 'Run /opsx:new then /opsx:apply'; - expect(transformCommandReferences(input, 'opsx-colon')).toBe(input); - }); - - it('should transform to hyphen style', () => { - const input = 'Run /opsx:new then /opsx:apply'; - expect(transformCommandReferences(input, 'opsx-hyphen')).toBe('Run /opsx-new then /opsx-apply'); - }); - - it('should transform to openspec skill invocation style', () => { - const input = 'Run /opsx:new then /opsx:continue then /opsx:apply'; - expect(transformCommandReferences(input, 'openspec-hyphen')).toBe( - 'Run /openspec-new-change then /openspec-continue-change then /openspec-apply-change' - ); - }); -}); - -describe('formatCommandInvocation', () => { - it('should format opsx colon syntax', () => { - expect(formatCommandInvocation('propose', 'opsx-colon')).toBe('/opsx:propose'); - }); - - it('should format opsx hyphen syntax', () => { - expect(formatCommandInvocation('propose', 'opsx-hyphen')).toBe('/opsx-propose'); - }); - - it('should format openspec skill syntax for known workflow IDs', () => { - expect(formatCommandInvocation('new', 'openspec-hyphen')).toBe('/openspec-new-change'); - expect(formatCommandInvocation('sync', 'openspec-hyphen')).toBe('/openspec-sync-specs'); - }); -}); - -describe('formatCommandFamily', () => { - it('should format wildcard hint for opsx colon style', () => { - expect(formatCommandFamily('opsx-colon')).toBe('/opsx:*'); - }); - - it('should format wildcard hint for opsx hyphen style', () => { - expect(formatCommandFamily('opsx-hyphen')).toBe('/opsx-*'); - }); - - it('should format wildcard hint for openspec hyphen style', () => { - expect(formatCommandFamily('openspec-hyphen')).toBe('/openspec-*'); - }); -});