diff --git a/.changeset/command-invocation-parity.md b/.changeset/command-invocation-parity.md new file mode 100644 index 0000000000..3e488e0de0 --- /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 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/README.md b/README.md index ed3c1aafea..697dbccc95 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), `@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/commands.md b/docs/commands.md index 836a39a6b2..6a484dc8d7 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) @@ -664,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 | Skill-based invocations 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..d5081d97f7 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. 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? -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 577d6b6d01..69efb7aceb 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -71,30 +71,33 @@ 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. - -| 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 via `.codex/skills/openspec-*` | -| 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). - -When in doubt, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects. +The intent is identical everywhere. The spelling follows the file your tool loads. + +| 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 | +| `.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. 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 +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 +107,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 fcb02bffe9..0d17d880c4 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, @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 fc6b261a4c..d024d6bc40 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -21,6 +21,44 @@ 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, 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, `@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. + ## Tool Directory Reference | Tool (ID) | Skills path pattern | Command path pattern | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ef59a65a3a..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 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. 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 diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 2c6d53326e..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,10 +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 cb0fb2c385..cb270ae914 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -66,6 +66,21 @@ 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 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 @@ -99,4 +114,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/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/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/adapters/bob.ts b/src/core/command-generation/adapters/bob.ts index 3e81ded345..84e201eec4 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'; /** @@ -16,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', @@ -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..4842b458df 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; @@ -30,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. */ @@ -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..03cd43f0d3 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; @@ -29,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', @@ -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..ec445085a4 100644 --- a/src/core/command-generation/generator.ts +++ b/src/core/command-generation/generator.ts @@ -5,9 +5,19 @@ */ import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.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, 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 * @returns Generated command with path and file content @@ -16,9 +26,14 @@ export function generateCommand( content: CommandContent, adapter: ToolCommandAdapter ): GeneratedCommand { + const invocation = getInvocationForAdapter(adapter); + const formatted = needsInvocationRewrite(invocation) + ? { ...content, body: transformCommandInvocations(content.body, invocation) } + : content; + return { path: adapter.getFilePath(content.id), - fileContent: adapter.formatFile(content), + 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..ccd67478c9 --- /dev/null +++ b/src/core/command-generation/invocation.ts @@ -0,0 +1,100 @@ +/** + * Command Invocation + * + * How a tool spells an OpenSpec command has two parts, and only one of them + * can be read off the file the adapter writes: + * + * - 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 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). 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'; +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. + * + * 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' + */ +export function getInvocationStyleForPath(commandFilePath: string): CommandInvocationStyle { + return path.basename(commandFilePath).startsWith('opsx-') ? 'flat' : 'namespaced'; +} + +/** + * 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 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 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 2be86dbefd..4162e92532 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 { 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 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 resolveCommandInvocation(toolId: string): CommandInvocation | undefined { + const adapter = CommandAdapterRegistry.get(toolId); + return adapter ? getInvocationForAdapter(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..a846fc56cb 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, + resolveCommandInvocation, 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), + resolveCommandInvocation(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), + resolveCommandInvocation(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; } @@ -972,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/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/core/migration.ts b/src/core/migration.ts index 9334caeb41..5f1fcb7fe1 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 { + resolveCommandInvocation, + 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), + resolveCommandInvocation(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..e6790138a6 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -56,6 +56,7 @@ import { } from './migration.js'; import { resolveCommandSurfaceCapability, + resolveCommandInvocation, 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), + resolveCommandInvocation(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), + resolveCommandInvocation(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), + 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 32db8b1c65..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(''); } @@ -39,7 +45,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 987f7f8634..af437b03f4 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,23 +4,46 @@ * 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 { 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 + * 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 + * @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 { - return text.replace(/\/opsx:/g, '/opsx-'); +export function transformCommandInvocations( + text: string, + invocation: CommandInvocation +): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => + commandId in COMMAND_TO_SKILL_NAME + ? formatCommandInvocation(invocation, commandId) + : match + ); } /** @@ -45,11 +68,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,37 +125,42 @@ 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 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 - * @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 + capability: CommandSurfaceCapability, + invocation: CommandInvocation | 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' - ) { - 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/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 2e813914ac..03c5466aa3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -33,6 +33,7 @@ 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 { parse as parseYaml } from 'yaml'; describe('command-generation/adapters', () => { @@ -219,12 +220,12 @@ 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.', }; - 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'); @@ -477,19 +478,19 @@ 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.', }; - 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'); 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 @@ -497,7 +498,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'); @@ -553,13 +554,13 @@ 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, 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'); @@ -590,13 +591,13 @@ 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.', }; - 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'); @@ -654,12 +655,12 @@ 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.', }; - 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'); @@ -692,12 +693,12 @@ 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.', }; - 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..c4a74ff7e9 --- /dev/null +++ b/test/core/command-generation/invocation.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { + formatCommandInvocation, + getInvocationForAdapter, + getInvocationStyleForPath, + needsInvocationRewrite, +} from '../../../src/core/command-generation/invocation.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.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'; + +/** + * 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']; + +/** + * 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', + 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()) { + expect( + getInvocationForAdapter(adapter), + `${adapter.toolId} writes ${adapter.getFilePath('apply')}` + ).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] + ); + } + }); + + it('classifies every command id as that adapter is expected to be classified', () => { + for (const adapter of CommandAdapterRegistry.getAll()) { + 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('resolveCommandInvocation', () => { + it('resolves the invocation for every registered tool', () => { + // Compared against the expected table, not against + // getInvocationForAdapter — asserting f(x) === f(x) can never fail. + for (const adapter of CommandAdapterRegistry.getAll()) { + expect(resolveCommandInvocation(adapter.toolId), adapter.toolId).toEqual( + expectedInvocation(adapter.toolId) + ); + } + 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(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']) { + 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("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)!; + const { fileContent } = generateCommand(sampleContent, adapter); + expect(fileContent, toolId).toContain('/opsx:archive'); + expect(fileContent, toolId).not.toContain('/opsx-archive'); + } + }); + + it('rewrites nothing but the command references', () => { + const adapter = CommandAdapterRegistry.get('cursor')!; + 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 0121b99b56..dd691ff425 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'); @@ -1047,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); @@ -1056,7 +1087,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 +1095,39 @@ 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'); + // 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'); + expect(cursorSkill).not.toContain('/opsx:'); + + // Claude's namespaced commands are unchanged + const claudeCommand = await read('.claude', 'commands', 'opsx', 'apply.md'); + expect(claudeCommand).toContain('/opsx:archive'); + 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 +1164,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/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', () => { diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index b819400826..6d0d4d46a1 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) @@ -150,20 +154,78 @@ 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'); }); + 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('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. + 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 8c801b767b..ee6805043b 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -296,6 +296,111 @@ 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'); + expect(await fs.readFile(skillFile, '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'); + // 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:'); + }); + + 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).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('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'); @@ -1146,7 +1251,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); @@ -1155,6 +1260,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/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index 69238e56a4..0a4ce5f134 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -122,6 +122,46 @@ 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('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(); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 8a9d7dced1..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'); @@ -51,6 +60,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', () => { @@ -86,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', () => { @@ -186,40 +230,65 @@ 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_SLASH)).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_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', () => { // 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'); } }); - 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) { + 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')).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_SLASH)).toBeUndefined(); + expect(getTransformerForTool('claude', 'commands', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); }); - 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 $-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', undefined); + expect(transformer?.('/opsx:propose')).toBe('$openspec-propose'); + expect(transformer?.('Run /opsx:apply next')).toBe('Run $openspec-apply-change next'); + } }); });