From bf2b09ac065422ab009ebb1d7873744d569a6cd0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 07:52:29 -0500 Subject: [PATCH 1/9] fix(adapters): reference slash commands by the names each tool registers Generated command bodies, skills and the post-setup hints all advertised /opsx:, but only 7 of 28 adapter-backed tools register that name. The other 21 write .../opsx-.md, where the filename is the command, so their users were told to type a command their palette never had. Codex, which registers no slash commands at all, was told to type them too. The invocation style is now derived from the command file each adapter writes rather than a hand-maintained tool list, so every tool-specific surface - command bodies, SKILL.md cross-references, and the init, update and migration hints - names the command that tool answers to. Closes #1307 Closes #727 Closes #1379 Closes #1110 Refs #1129 Co-Authored-By: Claude Opus 5 (1M context) --- docs/commands.md | 7 +- docs/how-commands-work.md | 4 +- docs/supported-tools.md | 23 ++++ openspec/specs/cli-init/spec.md | 1 + openspec/specs/command-generation/spec.md | 9 +- src/core/command-generation/adapters/bob.ts | 5 +- .../command-generation/adapters/oh-my-pi.ts | 5 +- .../command-generation/adapters/opencode.ts | 6 +- src/core/command-generation/adapters/pi.ts | 6 +- src/core/command-generation/adapters/qwen.ts | 7 +- src/core/command-generation/generator.ts | 19 ++- src/core/command-generation/invocation.ts | 44 +++++++ src/core/command-surface.ts | 11 ++ src/core/init.ts | 36 +++--- src/core/migration.ts | 33 +++-- src/core/update.ts | 34 ++++-- src/utils/command-references.ts | 62 ++++++---- test/core/command-generation/adapters.test.ts | 16 +-- .../command-generation/invocation.test.ts | 114 ++++++++++++++++++ test/core/init.test.ts | 48 ++++++-- test/core/migration.test.ts | 9 +- test/core/update.test.ts | 2 +- test/utils/command-references.test.ts | 37 +++--- 23 files changed, 412 insertions(+), 126 deletions(-) create mode 100644 src/core/command-generation/invocation.ts create mode 100644 test/core/command-generation/invocation.test.ts diff --git a/docs/commands.md b/docs/commands.md index 836a39a6b2..a6eabc057b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,6 +4,11 @@ This is the reference for OpenSpec's slash commands. These commands are invoked For workflow patterns and when to use each command, see [Workflows](workflows.md). For CLI commands, see [CLI](cli.md). +These pages use `/opsx:` as the canonical name. Some tools spell it +differently — Cursor and GitHub Copilot register `/opsx-propose`, Codex uses +`$openspec-propose` — so check [How To Invoke](supported-tools.md#how-to-invoke) +for your tool. The files OpenSpec generates already use the right form. + ## Quick Reference ### Default Quick Path (`core` profile) @@ -671,7 +676,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | -| Codex | Skill-based invocations from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | +| Codex | `$openspec-propose`, `$openspec-apply-change` — skills from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | | Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi Code | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | | Trae | `/opsx-propose`, `/opsx-apply` | diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 577d6b6d01..153806fb56 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -80,12 +80,12 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Windsurf | `/opsx-propose`, `/opsx-apply` | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | skill-style, e.g. `/openspec-propose` | -| Codex | skill-style via `.codex/skills/openspec-*` | +| Codex | skill-style with `$`, e.g. `$openspec-propose` | | Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | | Trae | `/opsx-propose`, `/opsx-apply` | -Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`). A few tools surface OpenSpec as named skills instead of slash commands; for those you invoke the skill by name. The full per-tool list, including exactly which files get written where, lives in [Supported Tools](supported-tools.md). +Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`); the dash form is the common one, used by every tool whose command file is named `opsx-`. A few tools surface OpenSpec as named skills instead of slash commands; for those you invoke the skill by name, with the prefix that tool expects. The full per-tool list, including exactly which files get written where, lives in [Supported Tools](supported-tools.md#how-to-invoke). When in doubt, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index fc6b261a4c..796aa0a368 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -11,6 +11,29 @@ For each selected tool, OpenSpec can install: Codex is skills-only: OpenSpec installs `.codex/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. +## How To Invoke + +Docs use `/opsx:propose` as the canonical name, but each tool spells it the way +it loads the file OpenSpec wrote. Read your tool's command path in the table +below and match it to a row here — the generated files and the post-setup +"Getting started" hint already use the right form for the tools you selected. + +| Command path pattern | You type | Examples | +|----------------------|----------|----------| +| `.../commands/opsx/.md` (namespaced) | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | +| `.../opsx-.md` (filename is the command) | `/opsx-` | Cursor, GitHub Copilot, Windsurf, OpenCode, Qwen Code, and every other adapter-backed tool | +| No command files — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | +| No command files — Kimi Code | `/skill:openspec-` | Kimi Code | +| No command files — Codex CLI | `$openspec-` | Codex ([the `/openspec-` form is not recognized](https://github.com/openai/codex/issues/11817)) | + +So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex. +Skill names are listed under [Generated Skill Names](#generated-skill-names). + +A few tools wrap the same file name in their own syntax — Cline and Kilo Code +invoke workflows including the `.md` suffix, and Amazon Q expands saved prompts +with `@`. The `opsx-` part is the same either way; if a slash does not +autocomplete, type the tool's own prefix in front of `opsx-propose`. + By default, OpenSpec uses the `core` profile, which includes: - `propose` - `explore` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 2c6d53326e..4a9b36f986 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -89,6 +89,7 @@ The command SHALL provide clear, actionable next steps upon successful initializ - `/opsx:new` - Start a new change - `/opsx:continue` - Create the next artifact - `/opsx:apply` - Implement tasks +- **AND** spell each command the way the configured tool registers it: `/opsx-` for tools whose command files are named `opsx-`, and the tool's skill invocation (`$openspec-` for Codex, `/skill:openspec-` for Kimi Code, `/openspec-` otherwise) for tools that receive no command files - **AND** display links to documentation and feedback #### Scenario: Displaying restart instruction diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index cb0fb2c385..9f9437f3e9 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -66,6 +66,13 @@ The system SHALL provide a `generateCommand` function that combines content with - `path`: the file path from `adapter.getFilePath(content.id)` - `fileContent`: the formatted content from `adapter.formatFile(content)` +#### Scenario: Command references match the name the tool registers + +- **WHEN** the adapter's file path names the command by filename (`opsx-`) +- **THEN** `generateCommand` SHALL rewrite `/opsx:` references in the body to `/opsx-` before formatting +- **WHEN** the adapter's file path namespaces the command under an `opsx/` directory +- **THEN** the body's `/opsx:` references SHALL be left unchanged + #### Scenario: Generate multiple commands - **WHEN** generating all opsx commands for a tool @@ -99,4 +106,4 @@ The body content of commands SHALL be shared across all 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 +- **AND** only the frontmatter, the file path, and the spelling of `/opsx:*` command references SHALL differ diff --git a/src/core/command-generation/adapters/bob.ts b/src/core/command-generation/adapters/bob.ts index 3e81ded345..dd60b51d79 100644 --- a/src/core/command-generation/adapters/bob.ts +++ b/src/core/command-generation/adapters/bob.ts @@ -7,7 +7,6 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; import { escapeYamlValue } from '../yaml.js'; /** @@ -27,14 +26,12 @@ export const bobAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} argument-hint: command arguments --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-generation/adapters/oh-my-pi.ts b/src/core/command-generation/adapters/oh-my-pi.ts index 0bbc7fb1a8..d4771eb460 100644 --- a/src/core/command-generation/adapters/oh-my-pi.ts +++ b/src/core/command-generation/adapters/oh-my-pi.ts @@ -8,7 +8,6 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; import { escapeYamlValue } from '../yaml.js'; const OMP_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; @@ -43,13 +42,11 @@ export const ohMyPiAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} --- -${injectOmpArgs(transformedBody)} +${injectOmpArgs(content.body)} `; }, }; diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 15d88dfc02..74f645022e 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'; import { escapeYamlValue } from '../yaml.js'; /** @@ -22,14 +21,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: ${escapeYamlValue(content.description)} --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index 80963ec810..f4d3c473d8 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -7,7 +7,6 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; import { escapeYamlValue } from '../yaml.js'; const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; @@ -40,14 +39,11 @@ export const piAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Transform /opsx: references to /opsx- and inject $@ for template args - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} --- -${injectPiArgs(transformedBody)} +${injectPiArgs(content.body)} `; }, }; diff --git a/src/core/command-generation/adapters/qwen.ts b/src/core/command-generation/adapters/qwen.ts index 44d55371d9..c24ccbf880 100644 --- a/src/core/command-generation/adapters/qwen.ts +++ b/src/core/command-generation/adapters/qwen.ts @@ -10,7 +10,6 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; -import { transformToHyphenCommands } from '../../../utils/command-references.js'; import { escapeYamlValue } from '../yaml.js'; /** @@ -26,15 +25,11 @@ export const qwenAdapter: ToolCommandAdapter = { }, formatFile(content: CommandContent): string { - // Qwen commands are invoked by filename (/opsx-), so cross-references - // must use the hyphen form too. - const transformedBody = transformToHyphenCommands(content.body); - return `--- description: ${escapeYamlValue(content.description)} --- -${transformedBody} +${content.body} `; }, }; diff --git a/src/core/command-generation/generator.ts b/src/core/command-generation/generator.ts index e8f22c054e..67b738f0e6 100644 --- a/src/core/command-generation/generator.ts +++ b/src/core/command-generation/generator.ts @@ -5,9 +5,18 @@ */ import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js'; +import { getInvocationStyleForPath } from './invocation.js'; +import { transformToHyphenCommands } from '../../utils/command-references.js'; /** * Generate a single command file using the provided adapter. + * + * Command bodies are authored with `/opsx:` references. Tools whose command + * files are invoked by filename register `/opsx-` instead, so the body is + * rewritten to the form that tool answers to before the adapter formats it. + * Doing it here rather than per adapter keeps every tool in step (#727, #1307); + * adapters stay pure formatters. + * * @param content - The tool-agnostic command content * @param adapter - The tool-specific adapter * @returns Generated command with path and file content @@ -16,9 +25,15 @@ export function generateCommand( content: CommandContent, adapter: ToolCommandAdapter ): GeneratedCommand { + const filePath = adapter.getFilePath(content.id); + const formatted = + getInvocationStyleForPath(filePath) === 'flat' + ? { ...content, body: transformToHyphenCommands(content.body) } + : content; + return { - path: adapter.getFilePath(content.id), - fileContent: adapter.formatFile(content), + path: filePath, + fileContent: adapter.formatFile(formatted), }; } diff --git a/src/core/command-generation/invocation.ts b/src/core/command-generation/invocation.ts new file mode 100644 index 0000000000..124f9a5cd7 --- /dev/null +++ b/src/core/command-generation/invocation.ts @@ -0,0 +1,44 @@ +/** + * Command Invocation Styles + * + * How a tool spells an OpenSpec slash command is decided by the file the + * adapter writes, not by a list maintained by hand: + * + * - `.../commands/opsx/.md` — the directory namespaces the command, so the + * tool registers `/opsx:` (Claude Code, Gemini, Crush, ...). + * - `.../commands/opsx-.md` — the filename *is* the command name, so the + * tool registers `/opsx-` (Cursor, GitHub Copilot, OpenCode, ...). + * + * Deriving the style from `getFilePath` keeps generated cross-references and + * onboarding hints in step with the files OpenSpec actually writes. A + * hand-maintained list drifted before: only OpenCode was rewritten when the + * hyphen form was introduced (#727), and Cursor still advertised `/opsx:` + * commands its palette never registered (#1307). + */ + +import path from 'path'; +import type { ToolCommandAdapter } from './types.js'; + +export type CommandInvocationStyle = 'namespaced' | 'flat'; + +/** + * Classifies a generated command file by the name the tool will answer to. + * + * @param commandFilePath - Path returned by an adapter's `getFilePath` + * @returns 'flat' when the filename carries the `opsx-` prefix, otherwise + * 'namespaced' + */ +export function getInvocationStyleForPath(commandFilePath: string): CommandInvocationStyle { + return path.basename(commandFilePath).startsWith('opsx-') ? 'flat' : 'namespaced'; +} + +/** + * Classifies an adapter by the command files it writes. + * + * @param adapter - The tool-specific command adapter + * @returns The invocation style the tool's command files produce + */ +export function getInvocationStyleForAdapter(adapter: ToolCommandAdapter): CommandInvocationStyle { + // Any command id works: every adapter applies one naming rule to all of them. + return getInvocationStyleForPath(adapter.getFilePath('explore')); +} diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index 2be86dbefd..ede9ea144e 100644 --- a/src/core/command-surface.ts +++ b/src/core/command-surface.ts @@ -1,8 +1,19 @@ import { CommandAdapterRegistry } from './command-generation/index.js'; +import { getInvocationStyleForAdapter, type CommandInvocationStyle } from './command-generation/invocation.js'; import type { Delivery } from './global-config.js'; export type CommandSurfaceCapability = 'adapter-backed' | 'skills-invocable' | 'none'; +/** + * How the tool spells its OpenSpec slash commands, derived from the command + * files its adapter writes. Returns undefined for tools with no command + * adapter, which have no command names to spell. + */ +export function resolveCommandInvocationStyle(toolId: string): CommandInvocationStyle | undefined { + const adapter = CommandAdapterRegistry.get(toolId); + return adapter ? getInvocationStyleForAdapter(adapter) : undefined; +} + export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceCapability { if (CommandAdapterRegistry.has(toolId)) { return 'adapter-backed'; diff --git a/src/core/init.ts b/src/core/init.ts index 0090fd66e6..8ac63dfe89 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,7 +13,7 @@ import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; import { findRepoPlanningRootSync } from './planning-home.js'; -import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -53,6 +53,7 @@ import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded, migrateLegacySkillDirs, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, + resolveCommandInvocationStyle, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, @@ -705,7 +706,12 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocationStyle(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -888,32 +894,28 @@ export class InitCommand { const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); // Each hint line must be a usable instruction for the tool it serves. - // Tools that generated commands are told the /opsx:* command; tools that - // only got skills are told their documented skill invocation (Kimi Code: - // /skill:openspec-*; skills-invocable codex has no slash surface at all, - // so its hint names the skill; others: /openspec-*). Tools that got no - // artifacts are covered by the configuration correction instead. When - // the selection disagrees, print one line per distinct instruction, - // labeled with the tools it applies to. + // Tools that generated commands are told the command name their files + // answer to (/opsx:* when namespaced under opsx/, /opsx-* when the + // filename is the command); tools that only got skills are told their + // documented skill invocation (Kimi Code: /skill:openspec-*; Codex CLI: + // $openspec-*; others: /openspec-*). Tools that got no artifacts are + // covered by the configuration correction instead. When the selection + // disagrees, print one line per distinct instruction, labeled with the + // tools it applies to. const startHintLines = (command: string): string[] => { - const skillName = transformToSkillReferences(command).slice(1); const hintToTools = new Map(); for (const tool of successfulTools) { let hint: string; if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) { - // Tools that invoke commands by filename (bob, qwen, ...) need the - // hyphen form here too, not just inside generated bodies. const transformer = getTransformerForTool( tool.value, activeDelivery, - resolveCommandSurfaceCapability(tool.value) + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocationStyle(tool.value) ); hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`; } else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) { - hint = - resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' - ? `Start your first change with the ${skillName} skill` - : `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`; + hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`; } else { continue; } diff --git a/src/core/migration.ts b/src/core/migration.ts index 9334caeb41..08b9fbf677 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -8,10 +8,14 @@ import { AI_TOOLS, type AIToolOption } from './config.js'; import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; -import { resolveCommandSurfaceCapability, shouldGenerateCommandsForTool } from './command-surface.js'; +import { + resolveCommandInvocationStyle, + resolveCommandSurfaceCapability, + shouldGenerateCommandsForTool, +} from './command-surface.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { getSkillReferenceTransformer } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -209,21 +213,24 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi saveGlobalConfig(config); console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); - // Each detected tool resolves to a propose reference for its surface: - // the shared /opsx:propose command form when commands will exist for it - // under the effective delivery, its documented skill invocation - // otherwise (skills-invocable codex has no slash surface and always - // gets the syntax-neutral form). When the tools disagree — including - // command tools mixed with skill-only tools — stay syntax-neutral - // rather than advertise a form that is wrong for one of them. + // Each detected tool resolves to a propose reference for its surface: the + // command name its generated files answer to when commands will exist for it + // under the effective delivery (/opsx:propose when namespaced under opsx/, + // /opsx-propose when the filename is the command), its documented skill + // invocation otherwise. When the tools disagree — including command tools + // mixed with skill-only tools — stay syntax-neutral rather than advertise a + // form that is wrong for one of them. const effectiveDelivery: Delivery = config.delivery ?? 'both'; const proposeReferences = new Set( tools.map((tool) => { if (shouldGenerateCommandsForTool(tool.value, effectiveDelivery)) { - return '/opsx:propose'; - } - if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { - return 'the openspec-propose skill'; + const transformer = getTransformerForTool( + tool.value, + effectiveDelivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocationStyle(tool.value) + ); + return transformer ? transformer('/opsx:propose') : '/opsx:propose'; } return getSkillReferenceTransformer(tool.value)('/opsx:propose'); }) diff --git a/src/core/update.ts b/src/core/update.ts index e983dd8383..68d0887b22 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -56,6 +56,7 @@ import { } from './migration.js'; import { resolveCommandSurfaceCapability, + resolveCommandInvocationStyle, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, @@ -248,7 +249,12 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocationStyle(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -327,19 +333,24 @@ export class UpdateCommand { } // 12. Show onboarding message for newly configured tools from legacy upgrade. - // Command tools keep the shared /opsx:* form, skill-only tools get their - // documented skill invocation, and disagreements (or skills-invocable - // codex, which has no slash surface) fall back to naming the skill. + // Command tools get the command name their files answer to, skill-only + // tools their documented skill invocation, and disagreements fall back to + // naming the skill. if (newlyConfiguredTools.length > 0) { const referenceFor = (command: string): string => { const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`; const forms = new Set( newlyConfiguredTools.map((toolId) => { if (shouldGenerateCommandsForTool(toolId, delivery)) { - return command; - } - if (resolveCommandSurfaceCapability(toolId) === 'skills-invocable') { - return neutralForm; + // Name the command the tool's files actually answer to: + // /opsx- where the filename is the command name. + const transformer = getTransformerForTool( + toolId, + delivery, + resolveCommandSurfaceCapability(toolId), + resolveCommandInvocationStyle(toolId) + ); + return transformer ? transformer(command) : command; } return getSkillReferenceTransformer(toolId)(command); }) @@ -889,7 +900,12 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); + const transformer = getTransformerForTool( + tool.value, + delivery, + resolveCommandSurfaceCapability(tool.value), + resolveCommandInvocationStyle(tool.value) + ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 987f7f8634..5548bd4916 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,13 +4,21 @@ * Utilities for transforming command references to tool-specific formats. */ -// Type-only import: a value import would close a module cycle -// (command-generation adapters import this file). +// Type-only imports: a value import would close a module cycle +// (command-generation imports this file). Callers resolve the concrete +// capability and invocation style and pass them in. import type { CommandSurfaceCapability } from '../core/command-surface.js'; +import type { CommandInvocationStyle } from '../core/command-generation/invocation.js'; /** * Transforms colon-based command references to hyphen-based format. - * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. + * Converts `/opsx:` patterns to `/opsx-` for tools that use + * hyphen syntax. + * + * Only known command ids are rewritten, matching how + * `transformToSkillReferences` leaves unrecognized references alone, so a + * mistyped or invented `/opsx:` is left as written rather than + * silently reshaped into a command that does not exist either. * * @param text - The text containing command references * @returns Text with command references transformed to hyphen format @@ -20,7 +28,9 @@ import type { CommandSurfaceCapability } from '../core/command-surface.js'; * 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 text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => + commandId in COMMAND_TO_SKILL_NAME ? `/opsx-${commandId}` : match + ); } /** @@ -45,11 +55,13 @@ const COMMAND_TO_SKILL_NAME: Record = { /** * Tools whose skill invocation uses a non-default prefix. The default is `/` - * (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:` + * (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:` and + * Codex CLI as `$` — a `/` form Codex does not recognize * (see docs/supported-tools.md). */ const SKILL_INVOCATION_PREFIX: Record = { kimi: '/skill:', + codex: '$', }; function replaceCommandsWithSkillReferences(text: string, prefix: string): string { @@ -100,36 +112,40 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * Selects the command-reference transformer for a skill generation target. * * Skill references are used whenever the tool ends up without `/opsx:*` - * commands — either because delivery is skills-only (for every tool) or - * because the tool has no command surface at all (capability 'none', e.g. - * Kimi Code or Mistral Vibe) — so those skills never point at commands - * that were not generated. When commands are generated, tools where the - * command filename doubles as the command name (bob, oh-my-pi, opencode, - * pi, qwen) use hyphen-based command references. All other cases keep the default - * `/opsx:*` references; notably skills-invocable tools (codex) are - * deliberately left untouched here to keep codex output stable while its - * reference rewriting is reworked separately. + * commands — because delivery is skills-only, because the tool has no command + * surface at all (capability 'none', e.g. Kimi Code or Mistral Vibe), or + * because the tool invokes skills directly and OpenSpec generates no command + * files for it (capability 'skills-invocable', i.e. Codex) — so those skills + * never point at commands that were not generated. + * + * When commands are generated, the spelling follows the command files the + * tool's adapter writes: a `flat` adapter names the command by filename + * (`.cursor/commands/opsx-apply.md` → `/opsx-apply`), while a `namespaced` + * adapter puts it in an `opsx/` directory (`.claude/commands/opsx/apply.md` → + * `/opsx:apply`). Passing the style in keeps this module free of a + * hand-maintained tool list — the list drifted and left 16 tools advertising + * commands their palettes never registered (#727, #1307). * * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') * @param delivery - The configured delivery mode * @param capability - The tool's command surface capability + * @param invocationStyle - How the tool's generated command files are invoked, + * from resolveCommandInvocationStyle(); undefined for tools with no + * command adapter. Required rather than optional so a caller that + * forgets it fails to compile instead of silently getting the + * namespaced form. * @returns The transformer to pass to generateSkillContent, or undefined */ export function getTransformerForTool( toolId: string, delivery: 'both' | 'skills' | 'commands', - capability: CommandSurfaceCapability + capability: CommandSurfaceCapability, + invocationStyle: CommandInvocationStyle | undefined ): ((text: string) => string) | undefined { - if (delivery === 'skills' || capability === 'none') { + if (delivery === 'skills' || capability !== 'adapter-backed') { return getSkillReferenceTransformer(toolId); } - if ( - toolId === 'bob' || - toolId === 'oh-my-pi' || - toolId === 'opencode' || - toolId === 'pi' || - toolId === 'qwen' - ) { + if (invocationStyle === 'flat') { return transformToHyphenCommands; } return undefined; diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 2e813914ac..db1fead9e4 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -33,6 +33,8 @@ import type { ToolCommandAdapter, } from '../../../src/core/command-generation/types.js'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import { getInvocationStyleForAdapter } from '../../../src/core/command-generation/invocation.js'; import { parse as parseYaml } from 'yaml'; describe('command-generation/adapters', () => { @@ -224,7 +226,7 @@ describe('command-generation/adapters', () => { ...sampleContent, body: 'Run /opsx:apply to implement. Then use /opsx:verify.', }; - const output = bobAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, bobAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-verify'); expect(output).not.toContain('/opsx:apply'); @@ -482,7 +484,7 @@ describe('command-generation/adapters', () => { ...sampleContent, body: 'Use /opsx:new to start, then /opsx:apply to implement.', }; - const output = opencodeAdapter.formatFile(contentWithCommands); + const output = generateCommand(contentWithCommands, opencodeAdapter).fileContent; expect(output).toContain('/opsx-new'); expect(output).toContain('/opsx-apply'); expect(output).not.toContain('/opsx:new'); @@ -497,7 +499,7 @@ describe('command-generation/adapters', () => { /opsx:continue to proceed /opsx:apply to implement`, }; - const output = opencodeAdapter.formatFile(contentWithMultipleCommands); + const output = generateCommand(contentWithMultipleCommands, opencodeAdapter).fileContent; expect(output).toContain('/opsx-explore'); expect(output).toContain('/opsx-new'); expect(output).toContain('/opsx-continue'); @@ -559,7 +561,7 @@ describe('command-generation/adapters', () => { ...sampleContent, body: 'Run /opsx:apply to implement. Then use /opsx:archive.', }; - const output = qwenAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, qwenAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-archive'); expect(output).not.toContain('/opsx:apply'); @@ -596,7 +598,7 @@ describe('command-generation/adapters', () => { body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', }; - const output = piAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, piAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-archive'); expect(output).not.toContain('/opsx:apply'); @@ -659,7 +661,7 @@ describe('command-generation/adapters', () => { ...sampleContent, body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', }; - const output = ohMyPiAdapter.formatFile(contentWithRefs); + const output = generateCommand(contentWithRefs, ohMyPiAdapter).fileContent; expect(output).toContain('/opsx-apply'); expect(output).toContain('/opsx-archive'); expect(output).not.toContain('/opsx:apply'); @@ -697,7 +699,7 @@ describe('command-generation/adapters', () => { ...sampleContent, body: '**Input**: The argument is the change name.\n\nRun /opsx:apply.', }; - const output = ohMyPiAdapter.formatFile(contentWithInput); + const output = generateCommand(contentWithInput, ohMyPiAdapter).fileContent; expect(output).toContain('**Provided arguments**: $@'); expect(output).toContain('/opsx-apply'); }); diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts new file mode 100644 index 0000000000..640a5a9192 --- /dev/null +++ b/test/core/command-generation/invocation.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { + getInvocationStyleForAdapter, + getInvocationStyleForPath, +} from '../../../src/core/command-generation/invocation.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { resolveCommandInvocationStyle } from '../../../src/core/command-surface.js'; +import { generateCommand } from '../../../src/core/command-generation/generator.js'; +import type { CommandContent } from '../../../src/core/command-generation/types.js'; + +/** + * Tools whose command files live in an `opsx/` directory, so the tool + * namespaces the command and registers `/opsx:`. Every other registered + * adapter writes `opsx-` as the filename and therefore registers + * `/opsx-`. + * + * This list is a tripwire, not the source of truth: production classifies a + * tool from its own `getFilePath`. A new adapter that lands on the wrong side + * of the split fails here, which is the point. + */ +const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'gemini', 'lingma', 'qoder', 'zcode']; + +const sampleContent: CommandContent = { + id: 'apply', + name: 'OpenSpec Apply', + description: 'Implement tasks', + category: 'Workflow', + tags: ['openspec'], + body: 'Run /opsx:archive when done. See /opsx:continue for the next artifact.', +}; + +describe('command-generation/invocation', () => { + describe('getInvocationStyleForPath', () => { + it('classifies an opsx- prefixed filename as flat', () => { + expect(getInvocationStyleForPath(path.join('.cursor', 'commands', 'opsx-apply.md'))).toBe('flat'); + expect(getInvocationStyleForPath(path.join('.github', 'prompts', 'opsx-apply.prompt.md'))).toBe('flat'); + }); + + it('classifies a file inside an opsx/ directory as namespaced', () => { + expect(getInvocationStyleForPath(path.join('.claude', 'commands', 'opsx', 'apply.md'))).toBe('namespaced'); + expect(getInvocationStyleForPath(path.join('.gemini', 'commands', 'opsx', 'apply.toml'))).toBe('namespaced'); + }); + }); + + describe('every registered adapter', () => { + it('is classified by the command files it writes, not by a hand-kept list', () => { + for (const adapter of CommandAdapterRegistry.getAll()) { + const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; + expect( + getInvocationStyleForAdapter(adapter), + `${adapter.toolId} writes ${adapter.getFilePath('apply')}` + ).toBe(expected); + } + }); + + it('classifies every command id the same way', () => { + for (const adapter of CommandAdapterRegistry.getAll()) { + const styles = new Set( + ['apply', 'archive', 'bulk-archive', 'propose'].map((id) => + getInvocationStyleForPath(adapter.getFilePath(id)) + ) + ); + expect(styles.size, `${adapter.toolId} must use one naming rule`).toBe(1); + } + }); + }); + + describe('resolveCommandInvocationStyle', () => { + it('resolves the style for every registered tool', () => { + expect(resolveCommandInvocationStyle('cursor')).toBe('flat'); + expect(resolveCommandInvocationStyle('claude')).toBe('namespaced'); + for (const adapter of CommandAdapterRegistry.getAll()) { + expect(resolveCommandInvocationStyle(adapter.toolId), adapter.toolId).toBe( + getInvocationStyleForAdapter(adapter) + ); + } + }); + + it('returns undefined for tools with no command adapter', () => { + // These tools receive skills only, so they have no command name to spell. + for (const toolId of ['codex', 'kimi', 'vibe', 'hermes', 'not-a-tool']) { + expect(resolveCommandInvocationStyle(toolId), toolId).toBeUndefined(); + } + }); + }); + + describe('generateCommand', () => { + it('rewrites command references to the names a flat tool registers', () => { + for (const toolId of ['cursor', 'github-copilot', 'windsurf', 'opencode', 'qwen']) { + const adapter = CommandAdapterRegistry.get(toolId)!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent, toolId).toContain('/opsx-archive'); + expect(fileContent, toolId).toContain('/opsx-continue'); + expect(fileContent, toolId).not.toContain('/opsx:'); + } + }); + + it('leaves command references alone for namespaced tools', () => { + for (const toolId of NAMESPACED_TOOLS) { + const adapter = CommandAdapterRegistry.get(toolId)!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent, toolId).toContain('/opsx:archive'); + expect(fileContent, toolId).not.toContain('/opsx-archive'); + } + }); + + it('does not alter the body of a tool that ships no command references', () => { + const adapter = CommandAdapterRegistry.get('cursor')!; + const plain = { ...sampleContent, body: 'Plain body with no command references.' }; + expect(generateCommand(plain, adapter).fileContent).toContain('Plain body with no command references.'); + }); + }); +}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 0121b99b56..2b3a84df16 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1024,20 +1024,21 @@ describe('InitCommand - profile and detection features', () => { } }); - it('should print a syntax-neutral hint for codex (skills-invocable, no slash surface)', async () => { - // Codex has no slash-command surface: docs direct users to - // .codex/skills/openspec-*, so the hint must not advertise a slash form + it('should print the $-prefixed skill hint for codex (skills-invocable, no slash surface)', async () => { + // Codex has no slash-command surface: it invokes skills as $, so the + // hint - and the generated skills - must use that form, never /opsx:* const initCommand = new InitCommand({ tools: 'codex', force: true }); await initCommand.execute(testDir); - // Codex skill generation itself is deliberately untouched by #1155 - // (codex reference rewriting is owned by a separate change) const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).toContain('$openspec-'); const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); const startHint = logCalls.find((entry) => entry.includes('Start your first change')); - expect(startHint).toContain('with the openspec-propose skill'); + expect(startHint).toContain('$openspec-propose'); expect(startHint).not.toContain('/openspec-propose'); expect(startHint).not.toContain('/opsx:propose'); @@ -1056,7 +1057,7 @@ describe('InitCommand - profile and detection features', () => { expect(startHints).toHaveLength(2); const codexHint = startHints.find((entry) => entry.includes('(Codex)')); const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe')); - expect(codexHint).toContain('with the openspec-propose skill'); + expect(codexHint).toContain('$openspec-propose'); expect(codexHint).not.toContain('/openspec-propose'); expect(vibeHint).toContain('/openspec-propose'); for (const hint of startHints) { @@ -1064,6 +1065,37 @@ describe('InitCommand - profile and detection features', () => { } }); + it('should reference commands by the names each tool registers (cursor+claude)', async () => { + // Cursor registers commands by filename (.cursor/commands/opsx-apply.md -> + // /opsx-apply) while Claude namespaces them under opsx/ (-> /opsx:apply). + // Command bodies, skills and the onboarding hint must each follow the tool + // they are written for. + const initCommand = new InitCommand({ tools: 'cursor,claude', force: true }); + await initCommand.execute(testDir); + + const read = (...segments: string[]) => fs.readFile(path.join(testDir, ...segments), 'utf-8'); + + const cursorCommand = await read('.cursor', 'commands', 'opsx-apply.md'); + expect(cursorCommand).toContain('/opsx-'); + expect(cursorCommand).not.toContain('/opsx:'); + + const cursorSkill = await read('.cursor', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(cursorSkill).not.toContain('/opsx:'); + + // Claude's namespaced commands are unchanged + const claudeCommand = await read('.claude', 'commands', 'opsx', 'apply.md'); + expect(claudeCommand).toContain('/opsx:'); + expect(claudeCommand).not.toContain('/opsx-'); + + const claudeSkill = await read('.claude', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(claudeSkill).not.toContain('/opsx-'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints.find((entry) => entry.includes('Cursor'))).toContain('/opsx-propose'); + expect(startHints.find((entry) => entry.includes('Claude Code'))).toContain('/opsx:propose'); + }); + it('should print the hyphen command hint for filename-invoked tools (claude+qwen)', async () => { const initCommand = new InitCommand({ tools: 'claude,qwen', force: true }); await initCommand.execute(testDir); @@ -1100,7 +1132,7 @@ describe('InitCommand - profile and detection features', () => { // Only the codex instruction may be advertised — a Kimi line would point // at skills that were never generated expect(startHints).toHaveLength(1); - expect(startHints[0]).toContain('with the openspec-propose skill'); + expect(startHints[0]).toContain('$openspec-propose'); expect(startHints[0]).not.toContain('Kimi'); expect(logCalls.some((entry) => entry.includes('/skill:openspec-'))).toBe(false); // Kimi got zero artifacts, so it still deserves the configuration correction diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index b819400826..f1331c9281 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -150,16 +150,17 @@ describe('migration', () => { expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); - it('prints a syntax-neutral propose reference when migrating a codex-only project', async () => { - // Codex is skills-invocable with no slash surface: the migration message - // must name the skill, not advertise a /openspec-* or /opsx:* form + it('prints the $-prefixed propose reference when migrating a codex-only project', async () => { + // Codex is skills-invocable with no slash surface: it invokes skills as + // $, so the migration message must not advertise a /openspec-* or + // /opsx:* form await writeSkill(projectDir, 'openspec-propose', '.codex'); const message = captureMigrationLogs(projectDir, [requireTool('codex')]).find((entry) => entry.includes('New in this version') ); expect(message).toBeTruthy(); - expect(message).toContain('the openspec-propose skill'); + expect(message).toContain('$openspec-propose'); expect(message).not.toContain('/openspec-propose'); expect(message).not.toContain('/opsx:propose'); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 8c801b767b..4f6f938acb 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1146,7 +1146,7 @@ ${OPENSPEC_MARKERS.end} expect(logCalls.some((entry) => entry.includes('Getting started'))).toBe(true); const menuLines = logCalls.filter((entry) => entry.includes('Scaffold a change')); expect(menuLines).toHaveLength(1); - expect(menuLines[0]).toContain('the openspec-new-change skill'); + expect(menuLines[0]).toContain('$openspec-new-change'); expect(logCalls.some((entry) => entry.includes('/opsx:new'))).toBe(false); expect(logCalls.some((entry) => entry.includes('/opsx:continue'))).toBe(false); expect(logCalls.some((entry) => entry.includes('/opsx:apply'))).toBe(false); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 8a9d7dced1..0741d36a07 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -186,12 +186,13 @@ describe('getSkillReferenceTransformer', () => { describe('getTransformerForTool', () => { it('selects skill references for skills-only delivery for every tool', () => { - expect(getTransformerForTool('claude', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); - expect(getTransformerForTool('codex', 'skills', 'skills-invocable')).toBe(transformToSkillReferences); + expect(getTransformerForTool('claude', 'skills', 'adapter-backed', 'namespaced')).toBe( + transformToSkillReferences + ); // hyphen-command tools must not fall back to hyphen commands when no commands are generated - expect(getTransformerForTool('opencode', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); - expect(getTransformerForTool('pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); - expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool('opencode', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); + expect(getTransformerForTool('pi', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); + expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); }); it('selects skill references for tools without a command surface, regardless of delivery', () => { @@ -206,20 +207,28 @@ describe('getTransformerForTool', () => { } }); - it('selects hyphen commands for bob, oh-my-pi, opencode, pi, and qwen when commands are generated', () => { + it('selects hyphen commands for every flat-invocation tool when commands are generated', () => { // These tools invoke commands by filename (/opsx-), so skills must // reference the hyphen form their command files actually answer to. - for (const toolId of ['bob', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) { - expect(getTransformerForTool(toolId, 'both', 'adapter-backed')).toBe(transformToHyphenCommands); - expect(getTransformerForTool(toolId, 'commands', 'adapter-backed')).toBe(transformToHyphenCommands); + for (const toolId of ['bob', 'cursor', 'github-copilot', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) { + expect(getTransformerForTool(toolId, 'both', 'adapter-backed', 'flat')).toBe(transformToHyphenCommands); + expect(getTransformerForTool(toolId, 'commands', 'adapter-backed', 'flat')).toBe(transformToHyphenCommands); // ...but must not fall back to hyphen commands when no commands are generated - expect(getTransformerForTool(toolId, 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool(toolId, 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); } }); - it('selects no transformer for adapter-backed and skills-invocable tools when commands are generated', () => { - expect(getTransformerForTool('claude', 'both', 'adapter-backed')).toBeUndefined(); - expect(getTransformerForTool('claude', 'commands', 'adapter-backed')).toBeUndefined(); - expect(getTransformerForTool('codex', 'both', 'skills-invocable')).toBeUndefined(); + it('selects no transformer for namespaced tools when commands are generated', () => { + expect(getTransformerForTool('claude', 'both', 'adapter-backed', 'namespaced')).toBeUndefined(); + expect(getTransformerForTool('claude', 'commands', 'adapter-backed', 'namespaced')).toBeUndefined(); + }); + + it('selects $-prefixed skill references for codex, which registers no slash commands', () => { + // Codex CLI invokes skills as $; the / form is unrecognized. + for (const delivery of ['both', 'commands', 'skills'] as const) { + const transformer = getTransformerForTool('codex', delivery, 'skills-invocable'); + expect(transformer?.('/opsx:propose')).toBe('$openspec-propose'); + expect(transformer?.('Run /opsx:apply next')).toBe('Run $openspec-apply-change next'); + } }); }); From 6ce948a40c9d2e06e98a9b8b9922c3a2e474b8c7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 08:26:26 -0500 Subject: [PATCH 2/9] docs: name the per-tool invocation exceptions in the table itself Review follow-up: the "every other adapter-backed tool" row swept Amazon Q, Cline and Kilo Code into the plain /opsx- form. Each is now its own row with the wrapper it actually uses, and the command-references tests pass the now-required invocation style explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/how-commands-work.md | 2 +- docs/supported-tools.md | 15 ++++++++------- test/utils/command-references.test.ts | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 153806fb56..684938804e 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -85,7 +85,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | | Trae | `/opsx-propose`, `/opsx-apply` | -Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`); the dash form is the common one, used by every tool whose command file is named `opsx-`. A few tools surface OpenSpec as named skills instead of slash commands; for those you invoke the skill by name, with the prefix that tool expects. The full per-tool list, including exactly which files get written where, lives in [Supported Tools](supported-tools.md#how-to-invoke). +Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`); the dash form is the common one, used by tools whose command file is named `opsx-`. A handful wrap that same name differently — Cline and Kilo Code want the `.md` suffix, Amazon Q expands prompts with `@` — and a few surface OpenSpec as named skills instead of slash commands, invoked by name with the prefix that tool expects. Every tool's exact form is listed in [How To Invoke](supported-tools.md#how-to-invoke). When in doubt, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 796aa0a368..8ecf59e9ad 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -18,10 +18,12 @@ it loads the file OpenSpec wrote. Read your tool's command path in the table below and match it to a row here — the generated files and the post-setup "Getting started" hint already use the right form for the tools you selected. -| Command path pattern | You type | Examples | -|----------------------|----------|----------| +| Command path pattern | You type | Tools | +|----------------------|----------|-------| | `.../commands/opsx/.md` (namespaced) | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | -| `.../opsx-.md` (filename is the command) | `/opsx-` | Cursor, GitHub Copilot, Windsurf, OpenCode, Qwen Code, and every other adapter-backed tool | +| `.../opsx-.md` (filename is the command) | `/opsx-` | Antigravity, Auggie, IBM Bob Shell, CoStrict, Continue, Cursor, Factory Droid, GitHub Copilot, iFlow, Junie, Kiro, Oh My Pi, OpenCode, Pi, Qwen Code, Trae, Windsurf, Zoo Code | +| `.../opsx-.md`, invoked with the extension | `/opsx-.md` | Cline, Kilo Code | +| `.amazonq/prompts/opsx-.md`, expanded as a prompt | `@opsx-` | Amazon Q Developer | | No command files — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | No command files — Kimi Code | `/skill:openspec-` | Kimi Code | | No command files — Codex CLI | `$openspec-` | Codex ([the `/openspec-` form is not recognized](https://github.com/openai/codex/issues/11817)) | @@ -29,10 +31,9 @@ below and match it to a row here — the generated files and the post-setup So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex. Skill names are listed under [Generated Skill Names](#generated-skill-names). -A few tools wrap the same file name in their own syntax — Cline and Kilo Code -invoke workflows including the `.md` suffix, and Amazon Q expands saved prompts -with `@`. The `opsx-` part is the same either way; if a slash does not -autocomplete, type the tool's own prefix in front of `opsx-propose`. +The last four rows are the exceptions worth knowing: the `opsx-` part is the +same everywhere, but the wrapper around it is the tool's own. If a slash does not +autocomplete, type your tool's prefix in front of `opsx-propose`. By default, OpenSpec uses the `core` profile, which includes: - `propose` diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 0741d36a07..a512288f33 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -198,11 +198,11 @@ describe('getTransformerForTool', () => { it('selects skill references for tools without a command surface, regardless of delivery', () => { // Tools like Kimi Code or Mistral Vibe have no command adapter, so their // skills must never reference /opsx:* commands that were not generated. - expect(getTransformerForTool('vibe', 'both', 'none')).toBe(transformToSkillReferences); - expect(getTransformerForTool('hermes', 'both', 'none')).toBe(transformToSkillReferences); + expect(getTransformerForTool('vibe', 'both', 'none', undefined)).toBe(transformToSkillReferences); + expect(getTransformerForTool('hermes', 'both', 'none', undefined)).toBe(transformToSkillReferences); // Kimi Code documents /skill: invocations (docs/supported-tools.md) for (const delivery of ['both', 'commands', 'skills'] as const) { - const transformer = getTransformerForTool('kimi', delivery, 'none'); + const transformer = getTransformerForTool('kimi', delivery, 'none', undefined); expect(transformer?.('/opsx:propose')).toBe('/skill:openspec-propose'); } }); @@ -226,7 +226,7 @@ describe('getTransformerForTool', () => { it('selects $-prefixed skill references for codex, which registers no slash commands', () => { // Codex CLI invokes skills as $; the / form is unrecognized. for (const delivery of ['both', 'commands', 'skills'] as const) { - const transformer = getTransformerForTool('codex', delivery, 'skills-invocable'); + const transformer = getTransformerForTool('codex', delivery, 'skills-invocable', undefined); expect(transformer?.('/opsx:propose')).toBe('$openspec-propose'); expect(transformer?.('Run /opsx:apply next')).toBe('Run $openspec-apply-change next'); } From 4e5e35869d6fcd8e63e0c25fac48fccd77abdf7e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 08:57:09 -0500 Subject: [PATCH 3/9] docs: make every invocation reference match what OpenSpec generates Review follow-up across the docs, the living specs and two hardcoded strings: - supported-tools: the How To Invoke section no longer splits the "How It Works" profile paragraph from its heading, keys rows on the file shape rather than a `.md` extension the Gemini/Continue/Copilot/Kiro adapters do not use, and drops the Cline/Kilo Code/Amazon Q rows. Kilo Code's docs say the current format drops the `.md` suffix, and the Cline and Amazon Q forms could not be confirmed - a wrong exception row is worse than none, so the caveat now describes the shape without asserting a spelling OpenSpec does not generate. - commands, how-commands-work: the two partial nine-row tables that drifted into #727/#1307 now key on the same file shape and defer to the authoritative table; both note that skill rows carry skill names, which are not command ids. - faq, troubleshooting, installation, README: stop telling skills-only users they have no slash command, stop offering "/opsx autocompletes" as a health check on tools where it never will, and name Hermes with the other adapterless tools. - specs: cli-init no longer claims every tool gets `commands/opsx/`, cli-update no longer frames the hyphen rewrite as OpenCode-specific, and command-generation describes the classifier the code implements. - the legacy-cleanup summary and the pre-selection welcome banner no longer print `/opsx:*` at users whose tool never registers it. - adds the missing changeset; it supersedes the Codex sentence in the pending adapterless-skill-references note. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/command-invocation-parity.md | 5 ++ README.md | 2 + docs/commands.md | 22 ++++---- docs/faq.md | 4 +- docs/how-commands-work.md | 33 ++++++------ docs/installation.md | 5 +- docs/supported-tools.md | 50 ++++++++++--------- docs/troubleshooting.md | 2 +- openspec/specs/cli-init/spec.md | 8 ++- openspec/specs/cli-update/spec.md | 2 +- openspec/specs/command-generation/spec.md | 2 +- openspec/specs/legacy-cleanup/spec.md | 2 +- src/core/command-generation/adapters/bob.ts | 4 +- .../command-generation/adapters/oh-my-pi.ts | 4 +- src/core/command-generation/adapters/pi.ts | 4 +- src/core/legacy-cleanup.ts | 2 +- src/ui/welcome-screen.ts | 2 +- test/core/legacy-cleanup.test.ts | 2 +- 18 files changed, 80 insertions(+), 75 deletions(-) create mode 100644 .changeset/command-invocation-parity.md diff --git a/.changeset/command-invocation-parity.md b/.changeset/command-invocation-parity.md new file mode 100644 index 0000000000..5603032830 --- /dev/null +++ b/.changeset/command-invocation-parity.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +Reference slash commands by the name each tool actually registers. Command bodies, generated `SKILL.md` cross-references, and the `init`/`update`/migration hints all advertised `/opsx:`, but only 7 of the 28 tools with a command adapter register that name — the ones whose files sit in an `opsx/` directory. The other 21 write `.../opsx-.md`, where the filename is the command, so tools such as Cursor, GitHub Copilot, Windsurf and Kilo Code were told to type a command their palette never had; a single generated Cursor file named itself `/opsx-apply` in frontmatter and then told the reader to run `/opsx:apply`. The invocation style is now derived from the command file each adapter writes rather than a hand-maintained tool list, so a newly added adapter cannot drift. Codex, which generates no command files at all, now gets `$openspec-` — the syntax its CLI actually accepts — everywhere it previously advertised `/opsx:*`, superseding the syntax-neutral hint described in the pending `adapterless-skill-references` note. Command filenames and paths are unchanged, and Claude Code output is byte-identical. diff --git a/README.md b/README.md index ed3c1aafea..8cb97e7193 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ Now talk to your AI: Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`. +`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke). + > [!NOTE] > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing. > diff --git a/docs/commands.md b/docs/commands.md index a6eabc057b..6a484dc8d7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -669,19 +669,15 @@ AI: Welcome to OpenSpec! Different AI tools use slightly different command syntax. Use the format that matches your tool: -| Tool | Syntax Example | -|------|----------------| -| Claude Code | `/opsx:propose`, `/opsx:apply` | -| Cursor | `/opsx-propose`, `/opsx-apply` | -| Windsurf | `/opsx-propose`, `/opsx-apply` | -| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | -| CodeArts | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | -| Codex | `$openspec-propose`, `$openspec-apply-change` — skills from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | -| Oh My Pi | `/opsx-propose`, `/opsx-apply` | -| Kimi Code | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | -| Trae | `/opsx-propose`, `/opsx-apply` | - -The intent is the same across tools, but how commands are surfaced can differ by integration. +| Your tool's command file | Syntax example | Example tools | +|--------------------------|----------------|---------------| +| `.../commands/opsx/.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush | +| `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Windsurf, Copilot (IDE), Trae, Oh My Pi | +| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, Mistral Vibe | +| none — Kimi Code | `/skill:openspec-propose` | Kimi Code | +| none — Codex CLI | `$openspec-propose` | Codex | + +The intent is the same across tools, but how commands are surfaced can differ by integration. [How To Invoke](supported-tools.md#how-to-invoke) lists every supported tool; this table shows only examples of each shape. > **Note:** GitHub Copilot commands (`.github/prompts/*.prompt.md`) are only available in IDE extensions (VS Code, JetBrains, Visual Studio). GitHub Copilot CLI does not currently support custom prompt files — see [Supported Tools](supported-tools.md) for details and workarounds. diff --git a/docs/faq.md b/docs/faq.md index a76da98823..c0d26a091e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -36,11 +36,11 @@ There isn't a separate mode to start. You open your AI assistant like normal and ### I typed a slash command and nothing happened. Why? -Most likely you typed it in the terminal instead of your AI chat, or the commands aren't installed yet. Run `openspec update` in your project, restart your assistant, then try typing `/opsx` in chat and watch for autocomplete. [Troubleshooting](troubleshooting.md#commands-dont-show-up) has the full checklist. +Most likely you typed it in the terminal instead of your AI chat, you used a spelling your tool doesn't register, or the commands aren't installed yet. Run `openspec update` in your project, restart your assistant, then use the form it prints under "Getting started" — see [How To Invoke](supported-tools.md#how-to-invoke). [Troubleshooting](troubleshooting.md#commands-dont-show-up) has the full checklist. ### Why is the syntax `/opsx:propose` in one tool and `/opsx-propose` in another? -Each AI tool surfaces custom commands a little differently. The intent is identical; only the punctuation changes. Type a slash in your chat and the autocomplete shows you the form your tool expects. The per-tool table is in [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). +Each AI tool surfaces custom commands a little differently, and OpenSpec spells them the way your tool loads the file it wrote. A command file named `opsx-propose.md` is typed `/opsx-propose`; one filed under `commands/opsx/` is typed `/opsx:propose`. Tools that take skills instead of commands use the skill name — Codex needs `$openspec-propose`, Kimi Code `/skill:openspec-propose`. The `openspec init` "Getting started" line already prints the right form for the tools you picked; the full table is in [How To Invoke](supported-tools.md#how-to-invoke). ### What's the difference between a skill and a command? diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 684938804e..8f2ef2c13a 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -71,30 +71,31 @@ The strength of this design: you learn the workflow once and carry it across too ## Slash command syntax by tool -The intent is identical everywhere. The punctuation differs. Use the form that matches your assistant. +The intent is identical everywhere. The spelling follows the file your tool loads. -| Tool | How you type it | -|------|-----------------| -| Claude Code | `/opsx:propose`, `/opsx:apply` | -| Cursor | `/opsx-propose`, `/opsx-apply` | -| Windsurf | `/opsx-propose`, `/opsx-apply` | -| GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | -| CodeArts | skill-style, e.g. `/openspec-propose` | -| Codex | skill-style with `$`, e.g. `$openspec-propose` | -| Oh My Pi | `/opsx-propose`, `/opsx-apply` | -| Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | -| Trae | `/opsx-propose`, `/opsx-apply` | +| Your tool's command file | How you type it | Example tools | +|--------------------------|-----------------|---------------| +| `.../commands/opsx/.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush | +| `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Windsurf, Trae, Oh My Pi | +| none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe | +| none — Kimi Code | `/skill:openspec-propose` | Kimi Code | +| none — Codex CLI | `$openspec-propose` | Codex | -Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`); the dash form is the common one, used by tools whose command file is named `opsx-`. A handful wrap that same name differently — Cline and Kilo Code want the `.md` suffix, Amazon Q expands prompts with `@` — and a few surface OpenSpec as named skills instead of slash commands, invoked by name with the prefix that tool expects. Every tool's exact form is listed in [How To Invoke](supported-tools.md#how-to-invoke). +Every tool is listed in [How To Invoke](supported-tools.md#how-to-invoke) — that +table is the authoritative one, and it covers the tools that wrap the name +further still. Note that the last three rows use the *skill* name, which is not +the command id (`/opsx:apply` is the `openspec-apply-change` skill). -When in doubt, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects. +When in doubt, read the "Getting started" line `openspec init` printed: it already +uses the form your tools registered. Typing a slash and watching the autocomplete +works too, for the tools that surface slash commands at all. ## How the commands got there: skills and commands When you run `openspec init` (or `openspec update`), OpenSpec writes small files into your project so your AI tool can find the workflow. Depending on your tool and settings, these are **skills**, **commands**, or both. - **Skills** live in places like `.claude/skills/openspec-*/SKILL.md`. They're the emerging cross-tool standard: a folder of instructions your assistant auto-detects. -- **Commands** live in places like `.claude/commands/opsx/.md`. They're the older per-tool slash command files. Codex does not get generated command files; use `.codex/skills/openspec-*`. +- **Commands** live in places like `.cursor/commands/opsx-.md` or `.claude/commands/opsx/.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.codex/skills/openspec-*`. You don't have to care which one your tool uses. You just type the slash command and it works. But knowing these files exist helps when something goes wrong: if your commands vanish, it usually means these files are missing or stale, and `openspec update` regenerates them. @@ -104,7 +105,7 @@ See [Supported Tools](supported-tools.md) for the exact paths per tool, and [Mig Quick checks, fastest first: -1. **Type a slash in your AI chat.** Start typing `/opsx` and watch for autocomplete suggestions. If they appear, you're set. +1. **Type a slash in your AI chat.** Start typing `/opsx` and watch for autocomplete suggestions. If they appear, you're set. On a skills-only tool (Codex, Kimi Code, CodeArts, ForgeCode, Hermes, Mistral Vibe) `/opsx` never completes even on a healthy install — try the skill name from the table above instead. 2. **Look for the files.** For Claude Code, check that `.claude/skills/` contains `openspec-*` folders. Other tools use their own directories ([Supported Tools](supported-tools.md) lists them). 3. **Re-run setup.** From your project root, run `openspec update`. This regenerates the skill and command files for whatever tools you configured. 4. **Restart your assistant.** Many tools scan for skills and commands at startup, so a fresh window can be the missing step. diff --git a/docs/installation.md b/docs/installation.md index e045e4d6e3..bec912e214 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -69,8 +69,9 @@ order, and stop where a step tells you to stop. it suggested instead of retrying. Finish by telling me how to invoke OpenSpec in my tool, and take the exact spelling from the files init created rather than from its summary line: the punctuation differs per tool (/opsx:propose - in some, /opsx-propose in others), and skills-only tools have no slash - command at all. + in some, /opsx-propose in others), and tools that get skills instead of + commands are invoked by skill name (/openspec-propose, or $openspec-propose + in Codex). ``` Nothing in the prompt is vendor-specific: it's plain instructions plus the same commands documented on this page. It works on macOS, Linux, and Windows, and it deliberately stops rather than improvising when a step needs your permission. Your assistant does need to be able to run shell commands — a few IDE integrations can't. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 8ecf59e9ad..68ce6e698b 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -11,30 +11,6 @@ For each selected tool, OpenSpec can install: Codex is skills-only: OpenSpec installs `.codex/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. -## How To Invoke - -Docs use `/opsx:propose` as the canonical name, but each tool spells it the way -it loads the file OpenSpec wrote. Read your tool's command path in the table -below and match it to a row here — the generated files and the post-setup -"Getting started" hint already use the right form for the tools you selected. - -| Command path pattern | You type | Tools | -|----------------------|----------|-------| -| `.../commands/opsx/.md` (namespaced) | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | -| `.../opsx-.md` (filename is the command) | `/opsx-` | Antigravity, Auggie, IBM Bob Shell, CoStrict, Continue, Cursor, Factory Droid, GitHub Copilot, iFlow, Junie, Kiro, Oh My Pi, OpenCode, Pi, Qwen Code, Trae, Windsurf, Zoo Code | -| `.../opsx-.md`, invoked with the extension | `/opsx-.md` | Cline, Kilo Code | -| `.amazonq/prompts/opsx-.md`, expanded as a prompt | `@opsx-` | Amazon Q Developer | -| No command files — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | -| No command files — Kimi Code | `/skill:openspec-` | Kimi Code | -| No command files — Codex CLI | `$openspec-` | Codex ([the `/openspec-` form is not recognized](https://github.com/openai/codex/issues/11817)) | - -So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex. -Skill names are listed under [Generated Skill Names](#generated-skill-names). - -The last four rows are the exceptions worth knowing: the `opsx-` part is the -same everywhere, but the wrapper around it is the tool's own. If a slash does not -autocomplete, type your tool's prefix in front of `opsx-propose`. - By default, OpenSpec uses the `core` profile, which includes: - `propose` - `explore` @@ -45,6 +21,32 @@ By default, OpenSpec uses the `core` profile, which includes: You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-archive`, `onboard`) via `openspec config profile`, then run `openspec update`. +## How To Invoke + +These docs use `/opsx:propose` as the canonical name, but each tool spells it the +way it loads the file OpenSpec wrote. Find your tool's command path in the +[Tool Directory Reference](#tool-directory-reference) below, then match its shape here. + +| Command file OpenSpec writes | You type | Tools | +|------------------------------|----------|-------| +| `.../commands/opsx/.*` — an `opsx/` folder namespaces it | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | +| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files | +| none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | +| none — Kimi Code | `/skill:openspec-` | Kimi Code | +| none — Codex CLI | `$openspec-` | Codex ([`/openspec-` is not recognized](https://github.com/openai/codex/issues/11817)) | + +So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex. +The last three rows use skill names, not command names — those are listed under +[Generated Skill Names](#generated-skill-names), and they do not map one-to-one +onto command ids (`/opsx:apply` is the `openspec-apply-change` skill). + +The files OpenSpec generates, and the "Getting started" hint printed after setup, +already use the right form for the tools you selected — so the fastest answer is +to read the hint. A few tools wrap the name further still (some show the file +name with its extension; Amazon Q surfaces prompts rather than slash commands), +so if a slash does not autocomplete, check how your tool loads files from the +directory listed below. + ## Tool Directory Reference | Tool (ID) | Skills path pattern | Command path pattern | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b0a56d28c3..e4297c4c5c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,7 +57,7 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt 5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there. -6. **Confirm your tool supports command files.** Codex and a few other tools (CodeArts, Kimi CLI, ForgeCode, Mistral Vibe) don't get generated `opsx-*` command files; they use skill-based invocations instead. For Codex, check `.codex/skills/openspec-*`. The forms differ per tool: see [Supported Tools](supported-tools.md) and [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). +6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code and Mistral Vibe don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke). ## Working with changes diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 4a9b36f986..d35b2ad390 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -52,7 +52,7 @@ The command SHALL configure AI coding assistants with skills and slash commands - **WHEN** user selects tools and confirms - **THEN** generate skills in `./skills/` directory for each selected tool -- **AND** generate slash commands in `./commands/opsx/` directory for each selected tool +- **AND** generate slash commands for each selected tool with a command adapter, at that adapter's own path (for example `.claude/commands/opsx/.md` or `.cursor/commands/opsx-.md`) - **AND** create `openspec/config.yaml` with default schema setting ### Requirement: Interactive Mode @@ -85,11 +85,9 @@ 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 a getting started section naming an installed onboarding workflow (for example `/opsx:propose` - Start a change) - **AND** spell each command the way the configured tool registers it: `/opsx-` for tools whose command files are named `opsx-`, and the tool's skill invocation (`$openspec-` for Codex, `/skill:openspec-` for Kimi Code, `/openspec-` otherwise) for tools that receive no command files +- **AND** print one labeled line per distinct form when the selected tools disagree - **AND** display links to documentation and feedback #### Scenario: Displaying restart instruction diff --git a/openspec/specs/cli-update/spec.md b/openspec/specs/cli-update/spec.md index fd599e4f71..676bf9d603 100644 --- a/openspec/specs/cli-update/spec.md +++ b/openspec/specs/cli-update/spec.md @@ -101,7 +101,7 @@ The update command SHALL refresh existing slash command files for configured too #### Scenario: Updating slash commands for OpenCode - **WHEN** `.opencode/commands/` contains OpenSpec-managed `opsx-*.md` command files for the configured profile (for example `opsx-propose.md`, `opsx-apply.md`, and `opsx-archive.md`) - **THEN** refresh each file using shared templates -- **AND** transform command references to hyphen form (for example `/opsx-propose`) for OpenCode compatibility +- **AND** transform command references to hyphen form (for example `/opsx-propose`), as for every tool whose command files are named `opsx-` - **AND** ensure templates include instructions for the relevant workflow stage - **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index 9f9437f3e9..c12ab9f802 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -70,7 +70,7 @@ The system SHALL provide a `generateCommand` function that combines content with - **WHEN** the adapter's file path names the command by filename (`opsx-`) - **THEN** `generateCommand` SHALL rewrite `/opsx:` references in the body to `/opsx-` before formatting -- **WHEN** the adapter's file path namespaces the command under an `opsx/` directory +- **WHEN** the adapter's file path does not name the command by filename (for example it namespaces the command under an `opsx/` directory) - **THEN** the body's `/opsx:` references SHALL be left unchanged #### Scenario: Generate multiple commands diff --git a/openspec/specs/legacy-cleanup/spec.md b/openspec/specs/legacy-cleanup/spec.md index a187769521..e72a1803d6 100644 --- a/openspec/specs/legacy-cleanup/spec.md +++ b/openspec/specs/legacy-cleanup/spec.md @@ -144,7 +144,7 @@ The system SHALL report what was cleaned up. ``` Cleaned up legacy files: ✓ Removed OpenSpec markers from CLAUDE.md - ✓ Removed .claude/commands/openspec/ (replaced by /opsx:*) + ✓ Removed .claude/commands/openspec/ (replaced by OpenSpec skills and commands) ✓ Removed openspec/AGENTS.md (no longer needed) ``` - **AND IF** `openspec/project.md` exists diff --git a/src/core/command-generation/adapters/bob.ts b/src/core/command-generation/adapters/bob.ts index dd60b51d79..84e201eec4 100644 --- a/src/core/command-generation/adapters/bob.ts +++ b/src/core/command-generation/adapters/bob.ts @@ -15,8 +15,8 @@ import { escapeYamlValue } from '../yaml.js'; * Frontmatter: description * * Bob uses the filename (minus .md) as the slash command name, so - * opsx-propose.md → /opsx-propose. Command references in the body - * are transformed from /opsx: to /opsx- for consistency. + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it. */ export const bobAdapter: ToolCommandAdapter = { toolId: 'bob', diff --git a/src/core/command-generation/adapters/oh-my-pi.ts b/src/core/command-generation/adapters/oh-my-pi.ts index d4771eb460..4842b458df 100644 --- a/src/core/command-generation/adapters/oh-my-pi.ts +++ b/src/core/command-generation/adapters/oh-my-pi.ts @@ -29,8 +29,8 @@ function injectOmpArgs(body: string): string { * Frontmatter: description * * OMP uses the filename (minus .md) as the slash command name, so - * opsx-propose.md → /opsx-propose. Command references in the body - * are transformed from /opsx: to /opsx- for consistency, and + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it, and * $@ is injected after **Input**: headings so user-supplied arguments * (e.g. /opsx-propose my-feature) are visible to the agent. */ diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index f4d3c473d8..03cd43f0d3 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -28,8 +28,8 @@ function injectPiArgs(body: string): string { * Frontmatter: description * * Pi uses the filename (minus .md) as the slash command name, so - * opsx-propose.md → /opsx-propose. Command references in the body - * are transformed from /opsx: to /opsx- for consistency. + * opsx-propose.md → /opsx-propose. generateCommand rewrites the body's + * command references to that form before this adapter formats it. */ export const piAdapter: ToolCommandAdapter = { toolId: 'pi', diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 1318a6b0f2..978fc441a2 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -623,7 +623,7 @@ export function formatCleanupSummary(result: CleanupResult): string { } for (const dir of result.deletedDirs) { - lines.push(` ✓ Removed ${dir}/ (replaced by /opsx:*)`); + lines.push(` ✓ Removed ${dir}/ (replaced by OpenSpec skills and commands)`); } for (const file of result.modifiedFiles) { diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index 32db8b1c65..fb69a01244 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -39,7 +39,7 @@ function getWelcomeText(workflows: readonly string[]): string[] { '', chalk.white('This setup will configure:'), chalk.dim(' • Agent Skills for AI tools'), - chalk.dim(' • /opsx:* slash commands'), + chalk.dim(' • opsx slash commands'), '', ...quickStart, chalk.cyan('Press Enter to select tools...'), diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index 48dc941a2a..9ef0197ac6 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -774,7 +774,7 @@ ${OPENSPEC_MARKERS.end}`); }; const summary = formatCleanupSummary(result); - expect(summary).toContain('✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)'); + expect(summary).toContain('✓ Removed .claude/commands/openspec/ (replaced by OpenSpec skills and commands)'); }); it('should format modified files', () => { From 101feceec9c70fc858fb31bb1ec0cdcc9e46196b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 09:26:20 -0500 Subject: [PATCH 4/9] test: cover the update and migration paths a mutation run found unguarded Mutation testing showed five ways to delete parts of this change without failing a single test. All five now fail: - `openspec update` had no flat-tool coverage at all, so the headline upgrade path - an existing Cursor project still carrying `/opsx:` references - was asserted nowhere. Two tests now cover it: one heals a project seeded with stale references, one runs claude+qwen together and pins each to its own form. - the legacy-upgrade getting-started menu is covered for a newly configured Cursor project, so passing the wrong invocation style there is caught. - migration.ts had no flat-tool case: reverting it to a hard-coded `/opsx:propose` passed the whole suite. A qwen-only migration and a claude+qwen disagreement now pin the message. - the unknown-command-id guard in `transformToHyphenCommands` was new behaviour with no test; removing it was invisible. Also tightened assertions the same run showed were weak: the `resolveCommandInvocationStyle` loop compared the implementation against itself, the per-id consistency check asserted only that a style was uniform rather than which one, and the init test's `/opsx-` assertion was satisfied by frontmatter rather than a body reference. The adapter tests that moved to `generateCommand` are renamed after their real subject, and a new case pins the contract those five adapters now rely on: they stay pure formatters and do not rewrite the body themselves. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/command-generation/invocation.ts | 6 ++ test/core/command-generation/adapters.test.ts | 15 ++-- .../command-generation/invocation.test.ts | 44 ++++++---- test/core/init.test.ts | 6 +- test/core/migration.test.ts | 38 ++++++++- test/core/update.test.ts | 83 +++++++++++++++++++ test/utils/command-references.test.ts | 13 +++ 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/src/core/command-generation/invocation.ts b/src/core/command-generation/invocation.ts index 124f9a5cd7..a40a667440 100644 --- a/src/core/command-generation/invocation.ts +++ b/src/core/command-generation/invocation.ts @@ -24,6 +24,12 @@ export type CommandInvocationStyle = 'namespaced' | 'flat'; /** * Classifies a generated command file by the name the tool will answer to. * + * The test is the filename, not the directory: an `opsx-` prefix means the + * filename is the command. Every other shape is treated as namespaced, which + * is what all seven `opsx/.*` adapters need. An adapter that neither + * prefixes the filename nor nests under `opsx/` would land here too — none + * does, and the registry-wide test in invocation.test.ts fails if one appears. + * * @param commandFilePath - Path returned by an adapter's `getFilePath` * @returns 'flat' when the filename carries the `opsx-` prefix, otherwise * 'namespaced' diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index db1fead9e4..03c5466aa3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -34,7 +34,6 @@ import type { } from '../../../src/core/command-generation/types.js'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; import { generateCommand } from '../../../src/core/command-generation/generator.js'; -import { getInvocationStyleForAdapter } from '../../../src/core/command-generation/invocation.js'; import { parse as parseYaml } from 'yaml'; describe('command-generation/adapters', () => { @@ -221,7 +220,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.\n\nWith multiple lines.'); }); - it('should transform colon command references to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithRefs: CommandContent = { ...sampleContent, body: 'Run /opsx:apply to implement. Then use /opsx:verify.', @@ -479,7 +478,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); - it('should transform colon-based command references to hyphen-based', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithCommands: CommandContent = { ...sampleContent, body: 'Use /opsx:new to start, then /opsx:apply to implement.', @@ -491,7 +490,7 @@ describe('command-generation/adapters', () => { expect(output).not.toContain('/opsx:apply'); }); - it('should handle multiple command references in body', () => { + it('is generated by generateCommand with every reference hyphenated', () => { const contentWithMultipleCommands: CommandContent = { ...sampleContent, body: `/opsx:explore for ideas @@ -555,7 +554,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('description: "Review: plan & apply \\"changes\\""'); }); - it('should transform colon command references to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { // Qwen commands are invoked by filename (/opsx-), like bob/opencode. const contentWithRefs: CommandContent = { ...sampleContent, @@ -592,7 +591,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); - it('should transform command references from colon to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithRefs: CommandContent = { ...sampleContent, body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', @@ -656,7 +655,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); - it('should transform command references from colon to hyphen format', () => { + it('is generated by generateCommand with hyphen command references', () => { const contentWithRefs: CommandContent = { ...sampleContent, body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', @@ -694,7 +693,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('**Input**: The argument is the change name.\n**Provided arguments**: $@'); }); - it('should inject $@ independently of hyphen transform', () => { + it('injects $@ alongside generateCommand\'s hyphen rewrite', () => { const contentWithInput: CommandContent = { ...sampleContent, body: '**Input**: The argument is the change name.\n\nRun /opsx:apply.', diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts index 640a5a9192..21af36e545 100644 --- a/test/core/command-generation/invocation.test.ts +++ b/test/core/command-generation/invocation.test.ts @@ -8,6 +8,7 @@ import { CommandAdapterRegistry } from '../../../src/core/command-generation/reg import { resolveCommandInvocationStyle } from '../../../src/core/command-surface.js'; import { generateCommand } from '../../../src/core/command-generation/generator.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; +import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; /** * Tools whose command files live in an `opsx/` directory, so the tool @@ -54,27 +55,29 @@ describe('command-generation/invocation', () => { } }); - it('classifies every command id the same way', () => { + it('classifies every command id as that adapter is expected to be classified', () => { for (const adapter of CommandAdapterRegistry.getAll()) { - const styles = new Set( - ['apply', 'archive', 'bulk-archive', 'propose'].map((id) => - getInvocationStyleForPath(adapter.getFilePath(id)) - ) - ); - expect(styles.size, `${adapter.toolId} must use one naming rule`).toBe(1); + const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; + for (const id of ALL_WORKFLOWS) { + expect( + getInvocationStyleForPath(adapter.getFilePath(id)), + `${adapter.toolId} ${id}` + ).toBe(expected); + } } }); }); describe('resolveCommandInvocationStyle', () => { it('resolves the style for every registered tool', () => { - expect(resolveCommandInvocationStyle('cursor')).toBe('flat'); - expect(resolveCommandInvocationStyle('claude')).toBe('namespaced'); + // Compared against the expected table, not against + // getInvocationStyleForAdapter — asserting f(x) === f(x) can never fail. for (const adapter of CommandAdapterRegistry.getAll()) { - expect(resolveCommandInvocationStyle(adapter.toolId), adapter.toolId).toBe( - getInvocationStyleForAdapter(adapter) - ); + const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; + expect(resolveCommandInvocationStyle(adapter.toolId), adapter.toolId).toBe(expected); } + expect(resolveCommandInvocationStyle('cursor')).toBe('flat'); + expect(resolveCommandInvocationStyle('claude')).toBe('namespaced'); }); it('returns undefined for tools with no command adapter', () => { @@ -105,10 +108,21 @@ describe('command-generation/invocation', () => { } }); - it('does not alter the body of a tool that ships no command references', () => { + it('rewrites nothing but the command references', () => { const adapter = CommandAdapterRegistry.get('cursor')!; - const plain = { ...sampleContent, body: 'Plain body with no command references.' }; - expect(generateCommand(plain, adapter).fileContent).toContain('Plain body with no command references.'); + const plain = { ...sampleContent, body: 'Plain body. See docs/opsx.md and openspec/changes/.' }; + const { fileContent } = generateCommand(plain, adapter); + expect(fileContent).toContain('Plain body. See docs/opsx.md and openspec/changes/.'); + }); + + it('leaves the adapters themselves as pure formatters', () => { + // generateCommand owns the rewrite; an adapter that re-added its own + // body transform would break this contract even though the output of + // generateCommand happens to be identical (the rewrite is idempotent). + for (const toolId of ['bob', 'oh-my-pi', 'opencode', 'pi', 'qwen', 'cursor']) { + const adapter = CommandAdapterRegistry.get(toolId)!; + expect(adapter.formatFile(sampleContent), toolId).toContain('/opsx:archive'); + } }); }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 2b3a84df16..b7ad004f20 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1076,7 +1076,9 @@ describe('InitCommand - profile and detection features', () => { const read = (...segments: string[]) => fs.readFile(path.join(testDir, ...segments), 'utf-8'); const cursorCommand = await read('.cursor', 'commands', 'opsx-apply.md'); - expect(cursorCommand).toContain('/opsx-'); + // A body cross-reference, not the frontmatter name, which already + // carried the hyphen form before this behaviour existed. + expect(cursorCommand).toContain('/opsx-archive'); expect(cursorCommand).not.toContain('/opsx:'); const cursorSkill = await read('.cursor', 'skills', 'openspec-apply-change', 'SKILL.md'); @@ -1084,7 +1086,7 @@ describe('InitCommand - profile and detection features', () => { // Claude's namespaced commands are unchanged const claudeCommand = await read('.claude', 'commands', 'opsx', 'apply.md'); - expect(claudeCommand).toContain('/opsx:'); + expect(claudeCommand).toContain('/opsx:archive'); expect(claudeCommand).not.toContain('/opsx-'); const claudeSkill = await read('.claude', 'skills', 'openspec-apply-change', 'SKILL.md'); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index f1331c9281..7649726c76 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -41,10 +41,14 @@ function captureMigrationLogs(projectDir: string, tools: AIToolOption[]): string } } -async function writeManagedCommand(projectPath: string, workflowId: string): Promise { - const adapter = CommandAdapterRegistry.get('claude'); +async function writeManagedCommand( + projectPath: string, + workflowId: string, + toolId = 'claude' +): Promise { + const adapter = CommandAdapterRegistry.get(toolId); if (!adapter) { - throw new Error('Claude adapter not found'); + throw new Error(`${toolId} adapter not found`); } const commandPath = adapter.getFilePath(workflowId); const fullPath = path.isAbsolute(commandPath) @@ -165,6 +169,34 @@ describe('migration', () => { expect(message).not.toContain('/opsx:propose'); }); + it('prints the hyphen propose reference when migrating a qwen-only project', async () => { + // Qwen invokes commands by filename (.qwen/commands/opsx-propose.md -> + // /opsx-propose), so the upgrade message must not advertise the colon form + // its palette never registers. + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [requireTool('qwen')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/opsx-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('falls back to the skill name when a namespaced and a flat tool disagree', async () => { + // Claude registers /opsx:propose, Qwen registers /opsx-propose: no single + // slash form is right for both, so neither may be advertised. + await writeManagedCommand(projectDir, 'apply', 'claude'); + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [ + requireTool('claude'), + requireTool('qwen'), + ]).find((entry) => entry.includes('New in this version')); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/opsx-propose'); + }); + it('prints the documented /skill: propose reference when migrating a kimi-only project', async () => { await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 4f6f938acb..88f7e768c1 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -296,6 +296,64 @@ Old instructions content }); describe('command updates', () => { + it('heals stale colon references for a filename-invoked tool (cursor)', async () => { + // The headline upgrade path for #1307: a project generated before the + // fix carries /opsx: references that Cursor's palette never registers. + // `openspec update` must rewrite both the command bodies and the skills. + const initCommand = new InitCommand({ tools: 'cursor', force: true }); + await initCommand.execute(testDir); + + const commandFile = path.join(testDir, '.cursor', 'commands', 'opsx-apply.md'); + const skillFile = path.join( + testDir, + '.cursor', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + for (const file of [commandFile, skillFile]) { + const stale = (await fs.readFile(file, 'utf-8')).replace(/\/opsx-/g, '/opsx:'); + await fs.writeFile(file, stale); + } + expect(await fs.readFile(commandFile, 'utf-8')).toContain('/opsx:apply'); + + await new UpdateCommand({ force: true }).execute(testDir); + + const command = await fs.readFile(commandFile, 'utf-8'); + expect(command).toContain('/opsx-archive'); + expect(command).not.toContain('/opsx:'); + + const skill = await fs.readFile(skillFile, 'utf-8'); + expect(skill).not.toContain('/opsx:'); + }); + + it('keeps namespaced references for claude while hyphenating qwen in one run', async () => { + const initCommand = new InitCommand({ tools: 'claude,qwen', force: true }); + await initCommand.execute(testDir); + + await new UpdateCommand({ force: true }).execute(testDir); + + const claudeCommand = await fs.readFile( + path.join(testDir, '.claude', 'commands', 'opsx', 'apply.md'), + 'utf-8' + ); + expect(claudeCommand).toContain('/opsx:archive'); + expect(claudeCommand).not.toContain('/opsx-archive'); + + const qwenCommand = await fs.readFile( + path.join(testDir, '.qwen', 'commands', 'opsx-apply.md'), + 'utf-8' + ); + expect(qwenCommand).toContain('/opsx-archive'); + expect(qwenCommand).not.toContain('/opsx:'); + + const qwenSkill = await fs.readFile( + path.join(testDir, '.qwen', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(qwenSkill).not.toContain('/opsx:'); + }); + it('should update opsx commands for configured Claude tool', async () => { // Set up a configured Claude tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1155,6 +1213,31 @@ ${OPENSPEC_MARKERS.end} expect(logCalls.some((entry) => entry.includes('Implement tasks'))).toBe(false); }); + it('should print the hyphen getting-started menu when a legacy upgrade newly configures cursor', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + // A pre-opsx Cursor project: legacy .cursor/commands/openspec-*.md files + // make the upgrade newly configure cursor, whose menu must name the + // commands its palette registers (/opsx-propose), not /opsx:propose. + const legacyDir = path.join(testDir, '.cursor', 'commands'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, 'openspec-proposal.md'), 'legacy proposal command'); + + const consoleSpy = vi.spyOn(console, 'log'); + await new UpdateCommand({ force: true }).execute(testDir); + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + const menuLines = logCalls.filter((entry) => entry.includes('Start a change')); + expect(menuLines).toHaveLength(1); + expect(menuLines[0]).toContain('/opsx-propose'); + expect(logCalls.some((entry) => entry.includes('/opsx:propose'))).toBe(false); + }); + it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => { setMockConfig({ featureFlags: {}, diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index a512288f33..a4b4f49c10 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -51,6 +51,19 @@ describe('transformToHyphenCommands', () => { const expected = '/opsx-new /opsx-continue /opsx-apply'; expect(transformToHyphenCommands(input)).toBe(expected); }); + + it('should leave unknown command references unchanged', () => { + // Mirrors transformToSkillReferences: an invented id is left as written + // rather than reshaped into a command that does not exist either. + const input = 'Try /opsx:unknown-command here'; + expect(transformToHyphenCommands(input)).toBe(input); + }); + + it('should rewrite only the known id on a mixed line', () => { + expect(transformToHyphenCommands('/opsx:apply and /opsx:bogus')).toBe( + '/opsx-apply and /opsx:bogus' + ); + }); }); describe('multiline content', () => { From e4db819b5bdc86a44487fa2e9c426bf5a2ab8d2e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 09:34:49 -0500 Subject: [PATCH 5/9] test: assert the rewritten form, not just the absence of the old one Review follow-up: the refreshed-skill checks were negative-only, so a regression that dropped every command reference rather than rewriting it would have passed. Each now pins the invocation its tool registers, the stale fixture asserts it really seeded a colon reference into the skill, and the claude+qwen case pins Claude's namespaced skill alongside Qwen's. Co-Authored-By: Claude Opus 5 (1M context) --- test/core/update.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 88f7e768c1..c11ef236c6 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -316,6 +316,7 @@ Old instructions content await fs.writeFile(file, stale); } expect(await fs.readFile(commandFile, 'utf-8')).toContain('/opsx:apply'); + expect(await fs.readFile(skillFile, 'utf-8')).toContain('/opsx:apply'); await new UpdateCommand({ force: true }).execute(testDir); @@ -324,6 +325,9 @@ Old instructions content expect(command).not.toContain('/opsx:'); const skill = await fs.readFile(skillFile, 'utf-8'); + // Positive assertion too: a skill that simply dropped every reference + // would satisfy the negative one. + expect(skill).toContain('/opsx-apply'); expect(skill).not.toContain('/opsx:'); }); @@ -351,7 +355,15 @@ Old instructions content path.join(testDir, '.qwen', 'skills', 'openspec-apply-change', 'SKILL.md'), 'utf-8' ); + expect(qwenSkill).toContain('/opsx-apply'); expect(qwenSkill).not.toContain('/opsx:'); + + const claudeSkill = await fs.readFile( + path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(claudeSkill).toContain('/opsx:apply'); + expect(claudeSkill).not.toContain('/opsx-'); }); it('should update opsx commands for configured Claude tool', async () => { From a264853c721253a345981681efba902a67e1bfff Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 10:53:41 -0500 Subject: [PATCH 6/9] fix(adapters): spell Amazon Q's prompts with @, not as slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invocation model derived the whole command name from the file an adapter writes, which covers `/opsx:` versus `/opsx-` but not the wrapper around it. Amazon Q loads `.amazonq/prompts/opsx-.md` into its prompt library, invoked as `@opsx-propose`; it registers no slash command, so its command bodies, skills, and the "Getting started" hint all named something the tool never answers to. The name still comes from the file path. The prefix is now adapter metadata (`invocationPrefix`, defaulting to `/`), so it cannot be guessed wrong and a new adapter has to declare it deliberately — invocation.test.ts fails if one appears undeclared. Also fixes three copy issues: - The FAQ told users to run `openspec update` when command files are missing; update only refreshes files for already-configured tools, so a tool that was never initialized needs `openspec init`. - The installation prompt omitted Kimi Code's `/skill:openspec-propose`. - The welcome screen promised "opsx slash commands" before tool selection, which is wrong for skills-only tools that correctly get no command files. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/command-invocation-parity.md | 2 +- docs/faq.md | 2 +- docs/how-commands-work.md | 8 +- docs/installation.md | 6 +- docs/supported-tools.md | 30 ++++--- openspec/specs/command-generation/spec.md | 8 ++ .../command-generation/adapters/amazon-q.ts | 6 ++ src/core/command-generation/generator.ts | 24 +++--- src/core/command-generation/invocation.ts | 78 ++++++++++++++---- src/core/command-generation/types.ts | 7 ++ src/core/command-surface.ts | 12 +-- src/core/init.ts | 6 +- src/core/migration.ts | 4 +- src/core/update.ts | 8 +- src/ui/welcome-screen.ts | 5 +- src/utils/command-references.ts | 64 +++++++++------ src/utils/index.ts | 2 +- .../command-generation/invocation.test.ts | 80 ++++++++++++++++--- test/core/update.test.ts | 35 ++++++++ test/ui/welcome-screen.test.ts | 16 ++++ test/utils/command-references.test.ts | 69 +++++++++++++--- 21 files changed, 363 insertions(+), 109 deletions(-) diff --git a/.changeset/command-invocation-parity.md b/.changeset/command-invocation-parity.md index 5603032830..3e488e0de0 100644 --- a/.changeset/command-invocation-parity.md +++ b/.changeset/command-invocation-parity.md @@ -2,4 +2,4 @@ '@fission-ai/openspec': patch --- -Reference slash commands by the name each tool actually registers. Command bodies, generated `SKILL.md` cross-references, and the `init`/`update`/migration hints all advertised `/opsx:`, but only 7 of the 28 tools with a command adapter register that name — the ones whose files sit in an `opsx/` directory. The other 21 write `.../opsx-.md`, where the filename is the command, so tools such as Cursor, GitHub Copilot, Windsurf and Kilo Code were told to type a command their palette never had; a single generated Cursor file named itself `/opsx-apply` in frontmatter and then told the reader to run `/opsx:apply`. The invocation style is now derived from the command file each adapter writes rather than a hand-maintained tool list, so a newly added adapter cannot drift. Codex, which generates no command files at all, now gets `$openspec-` — the syntax its CLI actually accepts — everywhere it previously advertised `/opsx:*`, superseding the syntax-neutral hint described in the pending `adapterless-skill-references` note. Command filenames and paths are unchanged, and Claude Code output is byte-identical. +Reference slash commands by the name each tool actually registers. Command bodies, generated `SKILL.md` cross-references, and the `init`/`update`/migration hints all advertised `/opsx:`, but only 7 of the 28 tools with a command adapter register that name — the ones whose files sit in an `opsx/` directory. The other 21 write `.../opsx-.md`, where the filename is the command, so tools such as Cursor, GitHub Copilot, Windsurf and Kilo Code were told to type a command their palette never had; a single generated Cursor file named itself `/opsx-apply` in frontmatter and then told the reader to run `/opsx:apply`. The command *name* is now derived from the command file each adapter writes rather than a hand-maintained tool list, so a newly added adapter cannot drift, and the *wrapper* around it is adapter metadata: Amazon Q loads its files into a prompt library invoked with `@`, so it now gets `@opsx-` in command bodies, skills, and the onboarding hint instead of a slash command it never registers. Codex, which generates no command files at all, now gets `$openspec-` — the syntax its CLI actually accepts — everywhere it previously advertised `/opsx:*`, superseding the syntax-neutral hint described in the pending `adapterless-skill-references` note. Command filenames and paths are unchanged, and Claude Code output is byte-identical. diff --git a/docs/faq.md b/docs/faq.md index c0d26a091e..d5081d97f7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -36,7 +36,7 @@ There isn't a separate mode to start. You open your AI assistant like normal and ### I typed a slash command and nothing happened. Why? -Most likely you typed it in the terminal instead of your AI chat, you used a spelling your tool doesn't register, or the commands aren't installed yet. Run `openspec update` in your project, restart your assistant, then use the form it prints under "Getting started" — see [How To Invoke](supported-tools.md#how-to-invoke). [Troubleshooting](troubleshooting.md#commands-dont-show-up) has the full checklist. +Most likely you typed it in the terminal instead of your AI chat, you used a spelling your tool doesn't register, or the commands aren't installed yet. If the files are missing — or you never set the tool up — run `openspec init`; `openspec update` only refreshes files that already exist. Then restart your assistant and use the form printed under "Getting started" — see [How To Invoke](supported-tools.md#how-to-invoke). [Troubleshooting](troubleshooting.md#commands-dont-show-up) has the full checklist. ### Why is the syntax `/opsx:propose` in one tool and `/opsx-propose` in another? diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 8f2ef2c13a..69efb7aceb 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -77,14 +77,16 @@ The intent is identical everywhere. The spelling follows the file your tool load |--------------------------|-----------------|---------------| | `.../commands/opsx/.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush | | `.../opsx-.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Windsurf, Trae, Oh My Pi | +| `.amazonq/prompts/opsx-.md` | `@opsx-propose` | Amazon Q Developer | | none — skills only | `/openspec-propose` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | none — Kimi Code | `/skill:openspec-propose` | Kimi Code | | none — Codex CLI | `$openspec-propose` | Codex | Every tool is listed in [How To Invoke](supported-tools.md#how-to-invoke) — that -table is the authoritative one, and it covers the tools that wrap the name -further still. Note that the last three rows use the *skill* name, which is not -the command id (`/opsx:apply` is the `openspec-apply-change` skill). +table is the authoritative one. Two rows are not slash commands at all: Amazon Q +loads its files into a prompt library invoked with `@`, and the last three rows +use the *skill* name, which is not the command id (`/opsx:apply` is the +`openspec-apply-change` skill). When in doubt, read the "Getting started" line `openspec init` printed: it already uses the form your tools registered. Typing a slash and watching the autocomplete diff --git a/docs/installation.md b/docs/installation.md index bec912e214..d86b1dc304 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -69,9 +69,9 @@ order, and stop where a step tells you to stop. it suggested instead of retrying. Finish by telling me how to invoke OpenSpec in my tool, and take the exact spelling from the files init created rather than from its summary line: the punctuation differs per tool (/opsx:propose - in some, /opsx-propose in others), and tools that get skills instead of - commands are invoked by skill name (/openspec-propose, or $openspec-propose - in Codex). + in some, /opsx-propose in others, @opsx-propose in Amazon Q), and tools that + get skills instead of commands are invoked by skill name (/openspec-propose, + or $openspec-propose in Codex, or /skill:openspec-propose in Kimi Code). ``` Nothing in the prompt is vendor-specific: it's plain instructions plus the same commands documented on this page. It works on macOS, Linux, and Windows, and it deliberately stops rather than improvising when a step needs your permission. Your assistant does need to be able to run shell commands — a few IDE integrations can't. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 68ce6e698b..d024d6bc40 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -30,22 +30,34 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the | Command file OpenSpec writes | You type | Tools | |------------------------------|----------|-------| | `.../commands/opsx/.*` — an `opsx/` folder namespaces it | `/opsx:` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode | -| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files | +| `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q | +| `.amazonq/prompts/opsx-.md` — a prompt, not a command | `@opsx-` | Amazon Q Developer | | none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe | | none — Kimi Code | `/skill:openspec-` | Kimi Code | | none — Codex CLI | `$openspec-` | Codex ([`/openspec-` is not recognized](https://github.com/openai/codex/issues/11817)) | -So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex. -The last three rows use skill names, not command names — those are listed under -[Generated Skill Names](#generated-skill-names), and they do not map one-to-one -onto command ids (`/opsx:apply` is the `openspec-apply-change` skill). +So `/opsx:propose` is `/opsx-propose` in Cursor, `@opsx-propose` in Amazon Q, and +`$openspec-propose` in Codex. + +Two things vary independently, which is why the rows do not collapse: + +- **The name.** Rows 1–2 differ only in how the file names the command, and the + `opsx-` / `opsx:` stem is the same for every tool with generated + command files. +- **The wrapper.** Amazon Q loads its files into a prompt library invoked with + `@`. Skills-only tools generate no command files at all, so their last three + rows use *skill* names — listed under + [Generated Skill Names](#generated-skill-names) — which do not map one-to-one + onto command ids (`/opsx:apply` is the `openspec-apply-change` skill). + +The command path patterns above are extension-neutral (`.*`) on purpose: the +extension is the tool's (`.toml` for Gemini CLI, `.prompt` for Continue, +`.prompt.md` for Kiro and GitHub Copilot), and a few tools show the name with +its extension in the picker. Match the directory shape, not the extension. The files OpenSpec generates, and the "Getting started" hint printed after setup, already use the right form for the tools you selected — so the fastest answer is -to read the hint. A few tools wrap the name further still (some show the file -name with its extension; Amazon Q surfaces prompts rather than slash commands), -so if a slash does not autocomplete, check how your tool loads files from the -directory listed below. +to read the hint. ## Tool Directory Reference diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index c12ab9f802..cb270ae914 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -73,6 +73,14 @@ The system SHALL provide a `generateCommand` function that combines content with - **WHEN** the adapter's file path does not name the command by filename (for example it namespaces the command under an `opsx/` directory) - **THEN** the body's `/opsx:` references SHALL be left unchanged +#### Scenario: Command references use the tool's own invocation prefix + +- **WHEN** an adapter declares an `invocationPrefix` because its files are not invoked with a slash (Amazon Q loads `.amazonq/prompts/opsx-.md` into a prompt library invoked with `@`) +- **THEN** `generateCommand` SHALL rewrite `/opsx:` references in the body to `opsx-` — for Amazon Q, `@opsx-` — replacing the leading slash rather than adding to it +- **AND** generated skills and the `init`/`update` "Getting started" hint SHALL use the same form +- **WHEN** an adapter declares no `invocationPrefix` +- **THEN** the prefix SHALL default to `/` + #### Scenario: Generate multiple commands - **WHEN** generating all opsx commands for a tool diff --git a/src/core/command-generation/adapters/amazon-q.ts b/src/core/command-generation/adapters/amazon-q.ts index c75bd2ee58..4875a27ee3 100644 --- a/src/core/command-generation/adapters/amazon-q.ts +++ b/src/core/command-generation/adapters/amazon-q.ts @@ -12,6 +12,10 @@ import { escapeYamlValue } from '../yaml.js'; * Amazon Q adapter for command generation. * File path: .amazonq/prompts/opsx-.md * Frontmatter: description + * + * Amazon Q surfaces these files as its prompt library rather than as slash + * commands: the user types `@opsx-propose`, not `/opsx-propose`. + * https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-prompts.html */ export const amazonQAdapter: ToolCommandAdapter = { toolId: 'amazon-q', @@ -20,6 +24,8 @@ export const amazonQAdapter: ToolCommandAdapter = { return path.join('.amazonq', 'prompts', `opsx-${commandId}.md`); }, + invocationPrefix: '@', + formatFile(content: CommandContent): string { return `--- description: ${escapeYamlValue(content.description)} diff --git a/src/core/command-generation/generator.ts b/src/core/command-generation/generator.ts index 67b738f0e6..ec445085a4 100644 --- a/src/core/command-generation/generator.ts +++ b/src/core/command-generation/generator.ts @@ -5,17 +5,18 @@ */ import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js'; -import { getInvocationStyleForPath } from './invocation.js'; -import { transformToHyphenCommands } from '../../utils/command-references.js'; +import { getInvocationForAdapter, needsInvocationRewrite } from './invocation.js'; +import { transformCommandInvocations } from '../../utils/command-references.js'; /** * Generate a single command file using the provided adapter. * * Command bodies are authored with `/opsx:` references. Tools whose command - * files are invoked by filename register `/opsx-` instead, so the body is - * rewritten to the form that tool answers to before the adapter formats it. - * Doing it here rather than per adapter keeps every tool in step (#727, #1307); - * adapters stay pure formatters. + * files are invoked by filename register `/opsx-` instead, and Amazon Q + * surfaces them in its prompt library as `@opsx-`, so the body is rewritten + * to the form that tool answers to before the adapter formats it. Doing it here + * rather than per adapter keeps every tool in step (#727, #1307); adapters stay + * pure formatters. * * @param content - The tool-agnostic command content * @param adapter - The tool-specific adapter @@ -25,14 +26,13 @@ export function generateCommand( content: CommandContent, adapter: ToolCommandAdapter ): GeneratedCommand { - const filePath = adapter.getFilePath(content.id); - const formatted = - getInvocationStyleForPath(filePath) === 'flat' - ? { ...content, body: transformToHyphenCommands(content.body) } - : content; + const invocation = getInvocationForAdapter(adapter); + const formatted = needsInvocationRewrite(invocation) + ? { ...content, body: transformCommandInvocations(content.body, invocation) } + : content; return { - path: filePath, + path: adapter.getFilePath(content.id), fileContent: adapter.formatFile(formatted), }; } diff --git a/src/core/command-generation/invocation.ts b/src/core/command-generation/invocation.ts index a40a667440..ccd67478c9 100644 --- a/src/core/command-generation/invocation.ts +++ b/src/core/command-generation/invocation.ts @@ -1,19 +1,25 @@ /** - * Command Invocation Styles + * Command Invocation * - * How a tool spells an OpenSpec slash command is decided by the file the - * adapter writes, not by a list maintained by hand: + * How a tool spells an OpenSpec command has two parts, and only one of them + * can be read off the file the adapter writes: * - * - `.../commands/opsx/.md` — the directory namespaces the command, so the - * tool registers `/opsx:` (Claude Code, Gemini, Crush, ...). - * - `.../commands/opsx-.md` — the filename *is* the command name, so the - * tool registers `/opsx-` (Cursor, GitHub Copilot, OpenCode, ...). + * - The *name* comes from the file. `.../commands/opsx/.md` is namespaced + * by its directory, so the tool registers `opsx:` (Claude Code, Gemini, + * Crush, ...). `.../commands/opsx-.md` names the command with the + * filename, so the tool registers `opsx-` (Cursor, GitHub Copilot, + * OpenCode, ...). + * - The *prefix* is the tool's own and cannot be derived. Almost every tool + * uses `/`; Amazon Q loads these files into its prompt library, which is + * invoked with `@` (`@opsx-propose`), so its adapter declares that prefix. * - * Deriving the style from `getFilePath` keeps generated cross-references and + * Deriving the name from `getFilePath` keeps generated cross-references and * onboarding hints in step with the files OpenSpec actually writes. A * hand-maintained list drifted before: only OpenCode was rewritten when the * hyphen form was introduced (#727), and Cursor still advertised `/opsx:` - * commands its palette never registered (#1307). + * commands its palette never registered (#1307). Carrying the prefix as + * adapter metadata rather than inferring it keeps the one tool that does not + * use a slash from being advertised as if it did. */ import path from 'path'; @@ -21,6 +27,19 @@ import type { ToolCommandAdapter } from './types.js'; export type CommandInvocationStyle = 'namespaced' | 'flat'; +/** + * Everything needed to spell one of a tool's OpenSpec commands. + */ +export interface CommandInvocation { + /** How the command file names the command. */ + style: CommandInvocationStyle; + /** What the user types before the name, e.g. `/` or Amazon Q's `@`. */ + prefix: string; +} + +/** The form these docs, command bodies, and skill templates are authored in. */ +export const CANONICAL_INVOCATION: CommandInvocation = { style: 'namespaced', prefix: '/' }; + /** * Classifies a generated command file by the name the tool will answer to. * @@ -39,12 +58,43 @@ export function getInvocationStyleForPath(commandFilePath: string): CommandInvoc } /** - * Classifies an adapter by the command files it writes. + * Resolves how a tool's generated commands are invoked: the name from the + * files its adapter writes, the prefix from the adapter's own declaration. * * @param adapter - The tool-specific command adapter - * @returns The invocation style the tool's command files produce + * @returns The invocation shared by every command that adapter generates + */ +export function getInvocationForAdapter(adapter: ToolCommandAdapter): CommandInvocation { + return { + // Any command id works: every adapter applies one naming rule to all of them. + style: getInvocationStyleForPath(adapter.getFilePath('explore')), + prefix: adapter.invocationPrefix ?? CANONICAL_INVOCATION.prefix, + }; +} + +/** + * Spells one command the way the tool registers it. + * + * @param invocation - The tool's invocation, from getInvocationForAdapter() + * @param commandId - The command identifier (e.g. 'apply') + * @returns What the user types, e.g. `/opsx:apply`, `/opsx-apply`, `@opsx-apply` + */ +export function formatCommandInvocation( + invocation: CommandInvocation, + commandId: string +): string { + const separator = invocation.style === 'namespaced' ? ':' : '-'; + return `${invocation.prefix}opsx${separator}${commandId}`; +} + +/** + * Whether a tool's invocation differs from the canonical `/opsx:` that + * command bodies and skill templates are authored in — that is, whether + * generated text has to be rewritten for that tool at all. */ -export function getInvocationStyleForAdapter(adapter: ToolCommandAdapter): CommandInvocationStyle { - // Any command id works: every adapter applies one naming rule to all of them. - return getInvocationStyleForPath(adapter.getFilePath('explore')); +export function needsInvocationRewrite(invocation: CommandInvocation): boolean { + return ( + invocation.style !== CANONICAL_INVOCATION.style || + invocation.prefix !== CANONICAL_INVOCATION.prefix + ); } diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 6cc35ae666..c0b1f5e104 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -39,6 +39,13 @@ export interface ToolCommandAdapter { * May be absolute for tools with global-scoped command files. */ getFilePath(commandId: string): string; + /** + * What the user types before the command name, when it is not the default + * `/`. Amazon Q loads these files into its prompt library, which is invoked + * with `@` (`@opsx-propose`), so its adapter sets '@'. The name itself is + * still derived from getFilePath — see invocation.ts. + */ + invocationPrefix?: string; /** * Formats the complete file content including frontmatter. * @param content - The tool-agnostic command content diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index ede9ea144e..4162e92532 100644 --- a/src/core/command-surface.ts +++ b/src/core/command-surface.ts @@ -1,17 +1,17 @@ import { CommandAdapterRegistry } from './command-generation/index.js'; -import { getInvocationStyleForAdapter, type CommandInvocationStyle } from './command-generation/invocation.js'; +import { getInvocationForAdapter, type CommandInvocation } from './command-generation/invocation.js'; import type { Delivery } from './global-config.js'; export type CommandSurfaceCapability = 'adapter-backed' | 'skills-invocable' | 'none'; /** - * How the tool spells its OpenSpec slash commands, derived from the command - * files its adapter writes. Returns undefined for tools with no command - * adapter, which have no command names to spell. + * How the tool spells its OpenSpec commands: the name from the command files + * its adapter writes, the prefix the adapter declares. Returns undefined for + * tools with no command adapter, which have no command names to spell. */ -export function resolveCommandInvocationStyle(toolId: string): CommandInvocationStyle | undefined { +export function resolveCommandInvocation(toolId: string): CommandInvocation | undefined { const adapter = CommandAdapterRegistry.get(toolId); - return adapter ? getInvocationStyleForAdapter(adapter) : undefined; + return adapter ? getInvocationForAdapter(adapter) : undefined; } export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceCapability { diff --git a/src/core/init.ts b/src/core/init.ts index 8ac63dfe89..f2cc0ca790 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -53,7 +53,7 @@ import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded, migrateLegacySkillDirs, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, - resolveCommandInvocationStyle, + resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, @@ -710,7 +710,7 @@ export class InitCommand { tool.value, delivery, resolveCommandSurfaceCapability(tool.value), - resolveCommandInvocationStyle(tool.value) + resolveCommandInvocation(tool.value) ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); @@ -911,7 +911,7 @@ export class InitCommand { tool.value, activeDelivery, resolveCommandSurfaceCapability(tool.value), - resolveCommandInvocationStyle(tool.value) + resolveCommandInvocation(tool.value) ); hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`; } else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) { diff --git a/src/core/migration.ts b/src/core/migration.ts index 08b9fbf677..5f1fcb7fe1 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -9,7 +9,7 @@ import { AI_TOOLS, type AIToolOption } from './config.js'; import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; import { - resolveCommandInvocationStyle, + resolveCommandInvocation, resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, } from './command-surface.js'; @@ -228,7 +228,7 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi tool.value, effectiveDelivery, resolveCommandSurfaceCapability(tool.value), - resolveCommandInvocationStyle(tool.value) + resolveCommandInvocation(tool.value) ); return transformer ? transformer('/opsx:propose') : '/opsx:propose'; } diff --git a/src/core/update.ts b/src/core/update.ts index 68d0887b22..e6790138a6 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -56,7 +56,7 @@ import { } from './migration.js'; import { resolveCommandSurfaceCapability, - resolveCommandInvocationStyle, + resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, @@ -253,7 +253,7 @@ export class UpdateCommand { tool.value, delivery, resolveCommandSurfaceCapability(tool.value), - resolveCommandInvocationStyle(tool.value) + resolveCommandInvocation(tool.value) ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); @@ -348,7 +348,7 @@ export class UpdateCommand { toolId, delivery, resolveCommandSurfaceCapability(toolId), - resolveCommandInvocationStyle(toolId) + resolveCommandInvocation(toolId) ); return transformer ? transformer(command) : command; } @@ -904,7 +904,7 @@ export class UpdateCommand { tool.value, delivery, resolveCommandSurfaceCapability(tool.value), - resolveCommandInvocationStyle(tool.value) + resolveCommandInvocation(tool.value) ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index fb69a01244..3855a96917 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -39,7 +39,10 @@ function getWelcomeText(workflows: readonly string[]): string[] { '', chalk.white('This setup will configure:'), chalk.dim(' • Agent Skills for AI tools'), - chalk.dim(' • opsx slash commands'), + // Not "opsx slash commands": this screen runs before tool selection, and + // skills-only tools (Codex, Kimi Code, ...) correctly get no command files + // at all. The exact spelling per tool is printed in "Getting started". + chalk.dim(' • Workflow commands, if supported'), '', ...quickStart, chalk.cyan('Press Enter to select tools...'), diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 5548bd4916..af437b03f4 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -8,12 +8,19 @@ // (command-generation imports this file). Callers resolve the concrete // capability and invocation style and pass them in. import type { CommandSurfaceCapability } from '../core/command-surface.js'; -import type { CommandInvocationStyle } from '../core/command-generation/invocation.js'; +import type { CommandInvocation } from '../core/command-generation/invocation.js'; +// Value import of a pure, dependency-free helper: invocation.ts imports only +// `path` and a type, so this does not close the cycle the note above guards. +import { + formatCommandInvocation, + needsInvocationRewrite, +} from '../core/command-generation/invocation.js'; /** - * Transforms colon-based command references to hyphen-based format. - * Converts `/opsx:` patterns to `/opsx-` for tools that use - * hyphen syntax. + * Rewrites the canonical `/opsx:` references that command bodies and + * skill templates are authored with into the form one tool actually registers + * — `/opsx-` for tools that name the command by filename, + * `@opsx-` for Amazon Q's prompt library. * * Only known command ids are rewritten, matching how * `transformToSkillReferences` leaves unrecognized references alone, so a @@ -21,15 +28,21 @@ import type { CommandInvocationStyle } from '../core/command-generation/invocati * silently reshaped into a command that does not exist either. * * @param text - The text containing command references - * @returns Text with command references transformed to hyphen format + * @param invocation - The tool's invocation, from resolveCommandInvocation() + * @returns Text with command references spelled the tool's way * * @example - * transformToHyphenCommands('/opsx:new') // returns '/opsx-new' - * transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement' + * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '/' }) // '/opsx-new' + * transformCommandInvocations('/opsx:new', { style: 'flat', prefix: '@' }) // '@opsx-new' */ -export function transformToHyphenCommands(text: string): string { +export function transformCommandInvocations( + text: string, + invocation: CommandInvocation +): string { return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => - commandId in COMMAND_TO_SKILL_NAME ? `/opsx-${commandId}` : match + commandId in COMMAND_TO_SKILL_NAME + ? formatCommandInvocation(invocation, commandId) + : match ); } @@ -118,35 +131,36 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * files for it (capability 'skills-invocable', i.e. Codex) — so those skills * never point at commands that were not generated. * - * When commands are generated, the spelling follows the command files the - * tool's adapter writes: a `flat` adapter names the command by filename - * (`.cursor/commands/opsx-apply.md` → `/opsx-apply`), while a `namespaced` - * adapter puts it in an `opsx/` directory (`.claude/commands/opsx/apply.md` → - * `/opsx:apply`). Passing the style in keeps this module free of a - * hand-maintained tool list — the list drifted and left 16 tools advertising - * commands their palettes never registered (#727, #1307). + * When commands are generated, the spelling follows the tool's invocation: a + * `flat` adapter names the command by filename (`.cursor/commands/opsx-apply.md` + * → `/opsx-apply`), a `namespaced` adapter puts it in an `opsx/` directory + * (`.claude/commands/opsx/apply.md` → `/opsx:apply`), and a non-slash prefix + * wraps it further (`.amazonq/prompts/opsx-apply.md` → `@opsx-apply`). Passing + * the invocation in keeps this module free of a hand-maintained tool list — + * the list drifted and left 16 tools advertising commands their palettes never + * registered (#727, #1307). * * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') * @param delivery - The configured delivery mode * @param capability - The tool's command surface capability - * @param invocationStyle - How the tool's generated command files are invoked, - * from resolveCommandInvocationStyle(); undefined for tools with no - * command adapter. Required rather than optional so a caller that - * forgets it fails to compile instead of silently getting the - * namespaced form. - * @returns The transformer to pass to generateSkillContent, or undefined + * @param invocation - How the tool's generated commands are invoked, from + * resolveCommandInvocation(); undefined for tools with no command + * adapter. Required rather than optional so a caller that forgets it + * fails to compile instead of silently getting the canonical form. + * @returns The transformer to pass to generateSkillContent, or undefined when + * the tool already answers to the canonical `/opsx:` */ export function getTransformerForTool( toolId: string, delivery: 'both' | 'skills' | 'commands', capability: CommandSurfaceCapability, - invocationStyle: CommandInvocationStyle | undefined + invocation: CommandInvocation | undefined ): ((text: string) => string) | undefined { if (delivery === 'skills' || capability !== 'adapter-backed') { return getSkillReferenceTransformer(toolId); } - if (invocationStyle === 'flat') { - return transformToHyphenCommands; + if (invocation !== undefined && needsInvocationRewrite(invocation)) { + return (text: string) => transformCommandInvocations(text, invocation); } return undefined; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 6a5309f5de..d106653a15 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -16,7 +16,7 @@ export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities export { - transformToHyphenCommands, + transformCommandInvocations, transformToSkillReferences, getSkillReferenceTransformer, getTransformerForTool, diff --git a/test/core/command-generation/invocation.test.ts b/test/core/command-generation/invocation.test.ts index 21af36e545..c4a74ff7e9 100644 --- a/test/core/command-generation/invocation.test.ts +++ b/test/core/command-generation/invocation.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect } from 'vitest'; import path from 'path'; import { - getInvocationStyleForAdapter, + formatCommandInvocation, + getInvocationForAdapter, getInvocationStyleForPath, + needsInvocationRewrite, } from '../../../src/core/command-generation/invocation.js'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; -import { resolveCommandInvocationStyle } from '../../../src/core/command-surface.js'; +import { resolveCommandInvocation } from '../../../src/core/command-surface.js'; import { generateCommand } from '../../../src/core/command-generation/generator.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; @@ -22,6 +24,20 @@ import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; */ const NAMESPACED_TOOLS = ['claude', 'codebuddy', 'crush', 'gemini', 'lingma', 'qoder', 'zcode']; +/** + * Tools whose command name is wrapped in something other than a slash. The + * prefix cannot be read off the file path, so it is adapter metadata — and + * this list is the tripwire that a new one was declared deliberately. Amazon Q + * loads its `.amazonq/prompts/` files into its prompt library, invoked as + * `@opsx-`. + */ +const NON_SLASH_PREFIXES: Record = { 'amazon-q': '@' }; + +const expectedInvocation = (toolId: string) => ({ + style: NAMESPACED_TOOLS.includes(toolId) ? ('namespaced' as const) : ('flat' as const), + prefix: NON_SLASH_PREFIXES[toolId] ?? '/', +}); + const sampleContent: CommandContent = { id: 'apply', name: 'OpenSpec Apply', @@ -47,11 +63,20 @@ describe('command-generation/invocation', () => { describe('every registered adapter', () => { it('is classified by the command files it writes, not by a hand-kept list', () => { for (const adapter of CommandAdapterRegistry.getAll()) { - const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; expect( - getInvocationStyleForAdapter(adapter), + getInvocationForAdapter(adapter), `${adapter.toolId} writes ${adapter.getFilePath('apply')}` - ).toBe(expected); + ).toEqual(expectedInvocation(adapter.toolId)); + } + }); + + it('defaults to the slash prefix unless the adapter declares another', () => { + // The prefix is the one part that cannot be derived from the file path, + // so an adapter that quietly grew one should show up here. + for (const adapter of CommandAdapterRegistry.getAll()) { + expect(adapter.invocationPrefix, adapter.toolId).toBe( + NON_SLASH_PREFIXES[adapter.toolId] + ); } }); @@ -68,26 +93,44 @@ describe('command-generation/invocation', () => { }); }); - describe('resolveCommandInvocationStyle', () => { - it('resolves the style for every registered tool', () => { + describe('resolveCommandInvocation', () => { + it('resolves the invocation for every registered tool', () => { // Compared against the expected table, not against - // getInvocationStyleForAdapter — asserting f(x) === f(x) can never fail. + // getInvocationForAdapter — asserting f(x) === f(x) can never fail. for (const adapter of CommandAdapterRegistry.getAll()) { - const expected = NAMESPACED_TOOLS.includes(adapter.toolId) ? 'namespaced' : 'flat'; - expect(resolveCommandInvocationStyle(adapter.toolId), adapter.toolId).toBe(expected); + expect(resolveCommandInvocation(adapter.toolId), adapter.toolId).toEqual( + expectedInvocation(adapter.toolId) + ); } - expect(resolveCommandInvocationStyle('cursor')).toBe('flat'); - expect(resolveCommandInvocationStyle('claude')).toBe('namespaced'); + expect(resolveCommandInvocation('cursor')).toEqual({ style: 'flat', prefix: '/' }); + expect(resolveCommandInvocation('claude')).toEqual({ style: 'namespaced', prefix: '/' }); + expect(resolveCommandInvocation('amazon-q')).toEqual({ style: 'flat', prefix: '@' }); }); it('returns undefined for tools with no command adapter', () => { // These tools receive skills only, so they have no command name to spell. for (const toolId of ['codex', 'kimi', 'vibe', 'hermes', 'not-a-tool']) { - expect(resolveCommandInvocationStyle(toolId), toolId).toBeUndefined(); + expect(resolveCommandInvocation(toolId), toolId).toBeUndefined(); } }); }); + describe('formatCommandInvocation', () => { + it('spells each shape the way the tool registers it', () => { + expect(formatCommandInvocation({ style: 'namespaced', prefix: '/' }, 'apply')).toBe('/opsx:apply'); + expect(formatCommandInvocation({ style: 'flat', prefix: '/' }, 'apply')).toBe('/opsx-apply'); + expect(formatCommandInvocation({ style: 'flat', prefix: '@' }, 'bulk-archive')).toBe( + '@opsx-bulk-archive' + ); + }); + + it('rewrites only what differs from the canonical authored form', () => { + expect(needsInvocationRewrite({ style: 'namespaced', prefix: '/' })).toBe(false); + expect(needsInvocationRewrite({ style: 'flat', prefix: '/' })).toBe(true); + expect(needsInvocationRewrite({ style: 'namespaced', prefix: '@' })).toBe(true); + }); + }); + describe('generateCommand', () => { it('rewrites command references to the names a flat tool registers', () => { for (const toolId of ['cursor', 'github-copilot', 'windsurf', 'opencode', 'qwen']) { @@ -99,6 +142,17 @@ describe('command-generation/invocation', () => { } }); + it("writes Amazon Q's prompt-library form, not a slash command", () => { + // .amazonq/prompts/opsx-.md is a prompt, invoked with @ — a body + // telling the user to type /opsx-archive names nothing Amazon Q registers. + const adapter = CommandAdapterRegistry.get('amazon-q')!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent).toContain('@opsx-archive'); + expect(fileContent).toContain('@opsx-continue'); + expect(fileContent).not.toContain('/opsx-'); + expect(fileContent).not.toContain('/opsx:'); + }); + it('leaves command references alone for namespaced tools', () => { for (const toolId of NAMESPACED_TOOLS) { const adapter = CommandAdapterRegistry.get(toolId)!; diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c11ef236c6..ee6805043b 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -366,6 +366,41 @@ Old instructions content expect(claudeSkill).not.toContain('/opsx-'); }); + it('heals stale slash references for a prompt-library tool (amazon-q)', async () => { + // Amazon Q registers no slash command at all: .amazonq/prompts files are + // its prompt library, invoked with @. A project generated before this fix + // carries /opsx: references that Amazon Q answers to under no spelling. + const initCommand = new InitCommand({ tools: 'amazon-q', force: true }); + await initCommand.execute(testDir); + + const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md'); + const skillFile = path.join( + testDir, + '.amazonq', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + for (const file of [promptFile, skillFile]) { + const stale = (await fs.readFile(file, 'utf-8')).replace(/@opsx-/g, '/opsx:'); + await fs.writeFile(file, stale); + } + expect(await fs.readFile(promptFile, 'utf-8')).toContain('/opsx:apply'); + + await new UpdateCommand({ force: true }).execute(testDir); + + for (const file of [promptFile, skillFile]) { + const refreshed = await fs.readFile(file, 'utf-8'); + // Positive assertion too: dropping every reference would satisfy the + // negative ones. And no stray slash may survive the rewrite. + expect(refreshed).toContain('@opsx-apply'); + expect(refreshed).not.toContain('/opsx:'); + expect(refreshed).not.toContain('/opsx-'); + } + // The prompt body cross-references other prompts; those move too. + expect(await fs.readFile(promptFile, 'utf-8')).toContain('@opsx-archive'); + }); + it('should update opsx commands for configured Claude tool', async () => { // Set up a configured Claude tool const skillsDir = path.join(testDir, '.claude', 'skills'); diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index 69238e56a4..bfa064869c 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -122,6 +122,22 @@ describe('welcome screen', () => { expect(output).not.toContain('Quick start after setup:'); }); + it('does not promise opsx commands in the setup summary', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + // This screen runs before tool selection, and skills-only tools (Codex, + // Kimi Code, ...) correctly receive no command files, so the summary must + // not state that opsx slash commands are part of every setup. + await showWelcomeScreen(['archive']); + + const output = writtenOutput(); + + expect(output).toContain('Agent Skills for AI tools'); + expect(output).toContain('Workflow commands, if supported'); + expect(output).not.toContain('opsx slash commands'); + }); + it('keeps every rendered line inside the animation width budget', async () => { const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); renderStatically(); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index a4b4f49c10..5fb8ebcc60 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -2,11 +2,20 @@ import { describe, it, expect } from 'vitest'; import { getSkillReferenceTransformer, getTransformerForTool, - transformToHyphenCommands, + transformCommandInvocations, transformToSkillReferences, } from '../../src/utils/command-references.js'; +import type { CommandInvocation } from '../../src/core/command-generation/invocation.js'; -describe('transformToHyphenCommands', () => { +const FLAT_SLASH: CommandInvocation = { style: 'flat', prefix: '/' }; +const FLAT_AT: CommandInvocation = { style: 'flat', prefix: '@' }; +const NAMESPACED_SLASH: CommandInvocation = { style: 'namespaced', prefix: '/' }; + +/** The `/opsx-` case, which most flat tools use. */ +const transformToHyphenCommands = (text: string): string => + transformCommandInvocations(text, FLAT_SLASH); + +describe('transformCommandInvocations', () => { describe('basic transformations', () => { it('should transform single command reference', () => { expect(transformToHyphenCommands('/opsx:new')).toBe('/opsx-new'); @@ -99,6 +108,28 @@ Finally /opsx-apply to implement`; }); } }); + + describe('non-slash prefixes', () => { + it("spells Amazon Q's prompt library form, replacing the slash", () => { + // The whole `/opsx:` is consumed, so no stray slash survives: it is + // `@opsx-apply`, never `/@opsx-apply` or `@/opsx-apply`. + expect(transformCommandInvocations('/opsx:apply', FLAT_AT)).toBe('@opsx-apply'); + expect(transformCommandInvocations('Run `/opsx:archive` when done.', FLAT_AT)).toBe( + 'Run `@opsx-archive` when done.' + ); + }); + + it('leaves unknown ids alone under a non-slash prefix too', () => { + expect(transformCommandInvocations('/opsx:apply and /opsx:bogus', FLAT_AT)).toBe( + '@opsx-apply and /opsx:bogus' + ); + }); + + it('is a no-op for the canonical namespaced slash form', () => { + const input = 'Use /opsx:new then /opsx:apply'; + expect(transformCommandInvocations(input, NAMESPACED_SLASH)).toBe(input); + }); + }); }); describe('transformToSkillReferences', () => { @@ -199,13 +230,13 @@ describe('getSkillReferenceTransformer', () => { describe('getTransformerForTool', () => { it('selects skill references for skills-only delivery for every tool', () => { - expect(getTransformerForTool('claude', 'skills', 'adapter-backed', 'namespaced')).toBe( + expect(getTransformerForTool('claude', 'skills', 'adapter-backed', NAMESPACED_SLASH)).toBe( transformToSkillReferences ); // hyphen-command tools must not fall back to hyphen commands when no commands are generated - expect(getTransformerForTool('opencode', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); - expect(getTransformerForTool('pi', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); - expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); + expect(getTransformerForTool('opencode', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + expect(getTransformerForTool('pi', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); }); it('selects skill references for tools without a command surface, regardless of delivery', () => { @@ -224,16 +255,32 @@ describe('getTransformerForTool', () => { // These tools invoke commands by filename (/opsx-), so skills must // reference the hyphen form their command files actually answer to. for (const toolId of ['bob', 'cursor', 'github-copilot', 'oh-my-pi', 'opencode', 'pi', 'qwen'] as const) { - expect(getTransformerForTool(toolId, 'both', 'adapter-backed', 'flat')).toBe(transformToHyphenCommands); - expect(getTransformerForTool(toolId, 'commands', 'adapter-backed', 'flat')).toBe(transformToHyphenCommands); + for (const delivery of ['both', 'commands'] as const) { + const transformer = getTransformerForTool(toolId, delivery, 'adapter-backed', FLAT_SLASH); + expect(transformer?.('/opsx:apply'), `${toolId} ${delivery}`).toBe('/opsx-apply'); + } // ...but must not fall back to hyphen commands when no commands are generated - expect(getTransformerForTool(toolId, 'skills', 'adapter-backed', 'flat')).toBe(transformToSkillReferences); + expect(getTransformerForTool(toolId, 'skills', 'adapter-backed', FLAT_SLASH)).toBe(transformToSkillReferences); + } + }); + + it("selects Amazon Q's @-prefixed prompt form when commands are generated", () => { + // Amazon Q loads .amazonq/prompts/opsx-.md into its prompt library, + // which is invoked with @ — it registers no slash command at all. + for (const delivery of ['both', 'commands'] as const) { + const transformer = getTransformerForTool('amazon-q', delivery, 'adapter-backed', FLAT_AT); + expect(transformer?.('/opsx:apply'), delivery).toBe('@opsx-apply'); + expect(transformer?.('Run /opsx:archive next'), delivery).toBe('Run @opsx-archive next'); } + // Skills-only delivery generates no prompt files, so point at the skill. + expect(getTransformerForTool('amazon-q', 'skills', 'adapter-backed', FLAT_AT)).toBe( + transformToSkillReferences + ); }); it('selects no transformer for namespaced tools when commands are generated', () => { - expect(getTransformerForTool('claude', 'both', 'adapter-backed', 'namespaced')).toBeUndefined(); - expect(getTransformerForTool('claude', 'commands', 'adapter-backed', 'namespaced')).toBeUndefined(); + expect(getTransformerForTool('claude', 'both', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); + expect(getTransformerForTool('claude', 'commands', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); }); it('selects $-prefixed skill references for codex, which registers no slash commands', () => { From a7ebe5266703e8b5d8d9fd8032a9151c9616b48f Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 11:10:14 -0500 Subject: [PATCH 7/9] fix(init): stop naming slash commands where none are registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spots still promised a slash command to users who get none: - The welcome screen's quick start shows canonical names (/opsx:propose), but renders one prompt before tools are picked — an Amazon Q user types @opsx-propose and a Codex user $openspec-propose. It now says the spelling varies by tool, so the canonical form stops reading as the literal thing to type. "Getting started" still prints the real form. - The post-setup restart line said "slash commands to take effect" whenever commands were generated. Amazon Q's generated files are prompt library entries, not slash commands, so it now says "the new commands". Co-Authored-By: Claude Opus 5 (1M context) --- src/core/init.ts | 6 ++++-- src/ui/welcome-screen.ts | 6 ++++++ test/core/init.test.ts | 30 ++++++++++++++++++++++++++++++ test/ui/welcome-screen.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index f2cc0ca790..a846fc56cb 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -974,13 +974,15 @@ export class InitCommand { // Restart instruction if any tools were configured and got a surface // (when nothing was generated there is nothing a restart would pick up); - // only mention slash commands when slash commands were actually generated + // only mention commands when commands were actually generated. Not "slash + // commands": Amazon Q's generated files are prompt-library entries invoked + // with @, so a restart line promising slash commands would be wrong for it. if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) { console.log(); console.log( chalk.white( commandsGenerated - ? 'Restart your IDE for slash commands to take effect.' + ? 'Restart your IDE for the new commands to take effect.' : 'Restart your IDE for the new skills to take effect.' ) ); diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index 3855a96917..e8e9fef2c6 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -30,6 +30,12 @@ function getWelcomeText(workflows: readonly string[]): string[] { for (const { command, description } of onboardingCommands) { quickStart.push(` ${chalk.yellow(command.padEnd(commandWidth + 1))} ${chalk.dim(description)}`); } + // These are the canonical names. How each tool spells them differs + // (/opsx-propose, @opsx-propose, $openspec-propose ...) and cannot be known + // until tools are picked, one prompt later — so flag it rather than let the + // canonical form read as the literal thing to type. "Getting started" + // prints the real spelling once the selection is known. + quickStart.push(chalk.dim(' (spelling varies by tool)')); quickStart.push(''); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index b7ad004f20..dd691ff425 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1048,6 +1048,36 @@ describe('InitCommand - profile and detection features', () => { expect(restartHint).not.toContain('slash commands'); }); + it('should print the @-prefixed prompt hint for amazon-q (prompt library, no slash surface)', async () => { + // Amazon Q loads .amazonq/prompts/opsx-.md into its prompt library, + // invoked as @opsx-. It registers no slash command under any spelling, + // so neither the hint, the generated prompts, the skills, nor the restart + // line may name one. + const initCommand = new InitCommand({ tools: 'amazon-q', force: true }); + await initCommand.execute(testDir); + + const promptFile = path.join(testDir, '.amazonq', 'prompts', 'opsx-apply.md'); + const skillFile = path.join(testDir, '.amazonq', 'skills', 'openspec-apply-change', 'SKILL.md'); + for (const file of [promptFile, skillFile]) { + expect(await fileExists(file)).toBe(true); + const content = await fs.readFile(file, 'utf-8'); + expect(content).toContain('@opsx-apply'); + expect(content).not.toContain('/opsx:'); + expect(content).not.toContain('/opsx-'); + } + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('@opsx-propose'); + expect(startHint).not.toContain('/opsx-propose'); + expect(startHint).not.toContain('/opsx:propose'); + + // Commands were generated, but they are not slash commands. + const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE')); + expect(restartHint).toContain('Restart your IDE for the new commands to take effect.'); + expect(restartHint).not.toContain('slash commands'); + }); + it('should label the codex hint separately when mixed with a slash-invocable adapterless tool', async () => { const initCommand = new InitCommand({ tools: 'codex,vibe', force: true }); await initCommand.execute(testDir); diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index bfa064869c..0a4ce5f134 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -138,6 +138,30 @@ describe('welcome screen', () => { expect(output).not.toContain('opsx slash commands'); }); + it('flags that the quick-start spelling varies by tool', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + // The quick start shows canonical names, but this screen renders one + // prompt before tools are picked — an Amazon Q user types @opsx-propose + // and a Codex user $openspec-propose, neither of which is shown here. + await showWelcomeScreen(['propose']); + + const output = writtenOutput(); + + expect(output).toContain('/opsx:propose'); + expect(output).toContain('spelling varies by tool'); + }); + + it('omits the spelling caveat when there is no quick start block', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + renderStatically(); + + await showWelcomeScreen(['archive']); + + expect(writtenOutput()).not.toContain('spelling varies by tool'); + }); + it('keeps every rendered line inside the animation width budget', async () => { const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); renderStatically(); From bc270eaff6acd94f7837783785beaf7f3772013b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 11:14:27 -0500 Subject: [PATCH 8/9] docs: name Amazon Q's @ form where the other exceptions are listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's one-line exception list and the troubleshooting checklist both enumerated the per-tool spellings and skipped Amazon Q. The troubleshooting entry was actively misleading: it explains that /opsx never autocompletes for tools without command files, and Amazon Q is not one of those — it has command files, they just land in the prompt library. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- docs/troubleshooting.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8cb97e7193..697dbccc95 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Now talk to your AI: Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`. -`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke). +`/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke). > [!NOTE] > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 249a620917..72f47a5e59 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -59,7 +59,7 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt 5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there. -6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code and Mistral Vibe don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke). +6. **Confirm your tool supports command files.** Codex, CodeArts, ForgeCode, Hermes, Kimi Code and Mistral Vibe don't get generated `opsx-*` command files; they use skill-based invocations instead, so `/opsx` will never autocomplete for them. Type `$openspec-propose` in Codex, `/skill:openspec-propose` in Kimi Code, and `/openspec-propose` in the rest. Amazon Q does get command files, but loads them into its prompt library rather than its slash menu — type `@opsx-propose` there, not `/opsx`. Every tool's form is listed in [How To Invoke](supported-tools.md#how-to-invoke). ## Working with changes From 8fea739d928f9ea33ac3317f8e072aa2e3811ec1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 28 Jul 2026 11:56:29 -0500 Subject: [PATCH 9/9] test(migration): cover the legacy-upgrade hint for amazon-q The migration hint resolves its propose reference through the same transformer as init and update, but no case exercised a non-slash prefix there. The second test is the one that matters: @opsx-propose and /opsx-propose are both "flat", so a style-only model would treat Amazon Q and Qwen as agreeing and advertise one form to both. Reverting the prefix to a constant '/' fails 5 tests, so neither assertion is a tautology. Co-Authored-By: Claude Opus 5 (1M context) --- test/core/migration.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 7649726c76..6d0d4d46a1 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -182,6 +182,35 @@ describe('migration', () => { expect(message).not.toContain('/opsx:propose'); }); + it('prints the @ propose reference when migrating an amazon-q-only project', async () => { + // Amazon Q's generated files land in its prompt library, invoked as + // @opsx-propose. It registers no slash command, so the upgrade message + // must advertise neither the colon nor the plain hyphen form. + await writeManagedCommand(projectDir, 'apply', 'amazon-q'); + + const message = captureMigrationLogs(projectDir, [requireTool('amazon-q')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('@opsx-propose'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/opsx-propose'); + }); + + it('falls back to the skill name when amazon-q and a slash tool disagree', async () => { + // @opsx-propose and /opsx-propose are both "flat", so a style-only model + // would wrongly treat these as agreeing and advertise one form to both. + await writeManagedCommand(projectDir, 'apply', 'amazon-q'); + await writeManagedCommand(projectDir, 'apply', 'qwen'); + + const message = captureMigrationLogs(projectDir, [ + requireTool('amazon-q'), + requireTool('qwen'), + ]).find((entry) => entry.includes('New in this version')); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('@opsx-propose'); + expect(message).not.toContain('/opsx-propose'); + }); + it('falls back to the skill name when a namespaced and a flat tool disagree', async () => { // Claude registers /opsx:propose, Qwen registers /opsx-propose: no single // slash form is right for both, so neither may be advertised.