fix(adapters): reference slash commands by the names each tool registers (#1471)

* fix(adapters): reference slash commands by the names each tool registers

Generated command bodies, skills and the post-setup hints all advertised
/opsx:<id>, but only 7 of 28 adapter-backed tools register that name. The
other 21 write .../opsx-<id>.md, where the filename is the command, so
their users were told to type a command their palette never had. Codex,
which registers no slash commands at all, was told to type them too.

The invocation style is now derived from the command file each adapter
writes rather than a hand-maintained tool list, so every tool-specific
surface - command bodies, SKILL.md cross-references, and the init,
update and migration hints - names the command that tool answers to.

Closes #1307
Closes #727
Closes #1379
Closes #1110
Refs #1129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name the per-tool invocation exceptions in the table itself

Review follow-up: the "every other adapter-backed tool" row swept Amazon Q,
Cline and Kilo Code into the plain /opsx-<id> form. Each is now its own row
with the wrapper it actually uses, and the command-references tests pass the
now-required invocation style explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: make every invocation reference match what OpenSpec generates

Review follow-up across the docs, the living specs and two hardcoded
strings:

- supported-tools: the How To Invoke section no longer splits the "How It
  Works" profile paragraph from its heading, keys rows on the file shape
  rather than a `.md` extension the Gemini/Continue/Copilot/Kiro adapters
  do not use, and drops the Cline/Kilo Code/Amazon Q rows. Kilo Code's
  docs say the current format drops the `.md` suffix, and the Cline and
  Amazon Q forms could not be confirmed - a wrong exception row is worse
  than none, so the caveat now describes the shape without asserting a
  spelling OpenSpec does not generate.
- commands, how-commands-work: the two partial nine-row tables that drifted
  into #727/#1307 now key on the same file shape and defer to the
  authoritative table; both note that skill rows carry skill names, which
  are not command ids.
- faq, troubleshooting, installation, README: stop telling skills-only
  users they have no slash command, stop offering "/opsx autocompletes" as
  a health check on tools where it never will, and name Hermes with the
  other adapterless tools.
- specs: cli-init no longer claims every tool gets `commands/opsx/`,
  cli-update no longer frames the hyphen rewrite as OpenCode-specific, and
  command-generation describes the classifier the code implements.
- the legacy-cleanup summary and the pre-selection welcome banner no longer
  print `/opsx:*` at users whose tool never registers it.
- adds the missing changeset; it supersedes the Codex sentence in the
  pending adapterless-skill-references note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: cover the update and migration paths a mutation run found unguarded

Mutation testing showed five ways to delete parts of this change without
failing a single test. All five now fail:

- `openspec update` had no flat-tool coverage at all, so the headline
  upgrade path - an existing Cursor project still carrying `/opsx:`
  references - was asserted nowhere. Two tests now cover it: one heals a
  project seeded with stale references, one runs claude+qwen together and
  pins each to its own form.
- the legacy-upgrade getting-started menu is covered for a newly
  configured Cursor project, so passing the wrong invocation style there
  is caught.
- migration.ts had no flat-tool case: reverting it to a hard-coded
  `/opsx:propose` passed the whole suite. A qwen-only migration and a
  claude+qwen disagreement now pin the message.
- the unknown-command-id guard in `transformToHyphenCommands` was new
  behaviour with no test; removing it was invisible.

Also tightened assertions the same run showed were weak: the
`resolveCommandInvocationStyle` loop compared the implementation against
itself, the per-id consistency check asserted only that a style was
uniform rather than which one, and the init test's `/opsx-` assertion was
satisfied by frontmatter rather than a body reference. The adapter tests
that moved to `generateCommand` are renamed after their real subject, and
a new case pins the contract those five adapters now rely on: they stay
pure formatters and do not rewrite the body themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: assert the rewritten form, not just the absence of the old one

Review follow-up: the refreshed-skill checks were negative-only, so a
regression that dropped every command reference rather than rewriting it
would have passed. Each now pins the invocation its tool registers, the
stale fixture asserts it really seeded a colon reference into the skill,
and the claude+qwen case pins Claude's namespaced skill alongside Qwen's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(adapters): spell Amazon Q's prompts with @, not as slash commands

The invocation model derived the whole command name from the file an
adapter writes, which covers `/opsx:<id>` versus `/opsx-<id>` but not the
wrapper around it. Amazon Q loads `.amazonq/prompts/opsx-<id>.md` into its
prompt library, invoked as `@opsx-propose`; it registers no slash command,
so its command bodies, skills, and the "Getting started" hint all named
something the tool never answers to.

The name still comes from the file path. The prefix is now adapter
metadata (`invocationPrefix`, defaulting to `/`), so it cannot be guessed
wrong and a new adapter has to declare it deliberately — invocation.test.ts
fails if one appears undeclared.

Also fixes three copy issues:
- The FAQ told users to run `openspec update` when command files are
  missing; update only refreshes files for already-configured tools, so a
  tool that was never initialized needs `openspec init`.
- The installation prompt omitted Kimi Code's `/skill:openspec-propose`.
- The welcome screen promised "opsx slash commands" before tool selection,
  which is wrong for skills-only tools that correctly get no command files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(init): stop naming slash commands where none are registered

Two spots still promised a slash command to users who get none:

- The welcome screen's quick start shows canonical names (/opsx:propose),
  but renders one prompt before tools are picked — an Amazon Q user types
  @opsx-propose and a Codex user $openspec-propose. It now says the
  spelling varies by tool, so the canonical form stops reading as the
  literal thing to type. "Getting started" still prints the real form.
- The post-setup restart line said "slash commands to take effect"
  whenever commands were generated. Amazon Q's generated files are prompt
  library entries, not slash commands, so it now says "the new commands".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: name Amazon Q's @ form where the other exceptions are listed

The README's one-line exception list and the troubleshooting checklist
both enumerated the per-tool spellings and skipped Amazon Q. The
troubleshooting entry was actively misleading: it explains that /opsx
never autocompletes for tools without command files, and Amazon Q is not
one of those — it has command files, they just land in the prompt library.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(migration): cover the legacy-upgrade hint for amazon-q

The migration hint resolves its propose reference through the same
transformer as init and update, but no case exercised a non-slash prefix
there. The second test is the one that matters: @opsx-propose and
/opsx-propose are both "flat", so a style-only model would treat Amazon Q
and Qwen as agreeing and advertise one form to both. Reverting the prefix
to a constant '/' fails 5 tests, so neither assertion is a tautology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-07-28 17:09:36 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 10fa39b1c3
commit 9a937cb9b3
37 changed files with 994 additions and 196 deletions
+5
View File
@@ -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:<id>`, 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-<id>.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-<id>` 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-<skill>` — 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.
+2
View File
@@ -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.
>
+13 -12
View File
@@ -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:<command>` 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` |
| Your tool's command file | Syntax example | Example tools |
|--------------------------|----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/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.
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.
+2 -2
View File
@@ -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?
+19 -16
View File
@@ -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.
The intent is identical everywhere. The spelling follows the file your tool loads.
| Tool | How you type it |
|------|-----------------|
| Claude Code | `/opsx:propose`, `/opsx:apply` |
| Cursor | `/opsx-propose`, `/opsx-apply` |
| Windsurf | `/opsx-propose`, `/opsx-apply` |
| GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |
| CodeArts | skill-style, e.g. `/openspec-propose` |
| Codex | skill-style 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` |
| Your tool's command file | How you type it | Example tools |
|--------------------------|-----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/opsx-propose` | Cursor, GitHub Copilot (IDE), Windsurf, Trae, Oh My Pi |
| `.amazonq/prompts/opsx-<id>.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 |
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).
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, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects.
When in doubt, read the "Getting started" line `openspec init` printed: it already
uses the form your tools registered. Typing a slash and watching the autocomplete
works too, for the tools that surface slash commands at all.
## How the commands got there: skills and commands
When you run `openspec init` (or `openspec update`), OpenSpec writes small files into your project so your AI tool can find the workflow. Depending on your tool and settings, these are **skills**, **commands**, or both.
- **Skills** live in places like `.claude/skills/openspec-*/SKILL.md`. They're the emerging cross-tool standard: a folder of instructions your assistant auto-detects.
- **Commands** live in places like `.claude/commands/opsx/<id>.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-<id>.md` or `.claude/commands/opsx/<id>.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.
+3 -2
View File
@@ -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.
+38
View File
@@ -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/<id>.*` — an `opsx/` folder namespaces it | `/opsx:<id>` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode |
| `.../opsx-<id>.*` — the filename is the command | `/opsx-<id>` | Every other tool with generated command files, except Amazon Q |
| `.amazonq/prompts/opsx-<id>.md` — a prompt, not a command | `@opsx-<id>` | Amazon Q Developer |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, Mistral Vibe |
| none — Kimi Code | `/skill:openspec-<skill>` | Kimi Code |
| none — Codex CLI | `$openspec-<skill>` | Codex ([`/openspec-<skill>` 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-<id>` / `opsx:<id>` 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 |
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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 `.<tool>/skills/` directory for each selected tool
- **AND** generate slash commands in `.<tool>/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/<id>.md` or `.cursor/commands/opsx-<id>.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: <tools>" for newly configured tools
- "Refreshed: <tools>" 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-<id>` for tools whose command files are named `opsx-<id>`, and the tool's skill invocation (`$openspec-<skill>` for Codex, `/skill:openspec-<skill>` for Kimi Code, `/openspec-<skill>` 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
+1 -1
View File
@@ -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-<id>`
- **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
+16 -1
View File
@@ -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-<id>`)
- **THEN** `generateCommand` SHALL rewrite `/opsx:<id>` references in the body to `/opsx-<id>` 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:<id>` 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-<id>.md` into a prompt library invoked with `@`)
- **THEN** `generateCommand` SHALL rewrite `/opsx:<id>` references in the body to `<prefix>opsx-<id>` — for Amazon Q, `@opsx-<id>` — 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
+1 -1
View File
@@ -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
@@ -12,6 +12,10 @@ import { escapeYamlValue } from '../yaml.js';
* Amazon Q adapter for command generation.
* File path: .amazonq/prompts/opsx-<id>.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)}
+3 -6
View File
@@ -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}
`;
},
};
@@ -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)}
`;
},
};
@@ -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}
`;
},
};
+3 -7
View File
@@ -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)}
`;
},
};
+1 -6
View File
@@ -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-<id>), so cross-references
// must use the hyphen form too.
const transformedBody = transformToHyphenCommands(content.body);
return `---
description: ${escapeYamlValue(content.description)}
---
${transformedBody}
${content.body}
`;
},
};
+16 -1
View File
@@ -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:<id>` references. Tools whose command
* files are invoked by filename register `/opsx-<id>` instead, and Amazon Q
* surfaces them in its prompt library as `@opsx-<id>`, 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),
};
}
+100
View File
@@ -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/<id>.md` is namespaced
* by its directory, so the tool registers `opsx:<id>` (Claude Code, Gemini,
* Crush, ...). `.../commands/opsx-<id>.md` names the command with the
* filename, so the tool registers `opsx-<id>` (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/<id>.*` 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:<id>` 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
);
}
+7
View File
@@ -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
+11
View File
@@ -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';
+23 -19
View File
@@ -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<string, string[]>();
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.'
)
);
+1 -1
View File
@@ -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) {
+20 -13
View File
@@ -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');
})
+25 -9
View File
@@ -55,6 +55,7 @@ import {
} from './migration.js';
import {
resolveCommandSurfaceCapability,
resolveCommandInvocation,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
@@ -245,7 +246,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);
}
@@ -324,19 +330,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-<id> 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);
})
@@ -886,7 +897,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);
}
+10 -1
View File
@@ -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...'),
+60 -30
View File
@@ -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:<command>` references that command bodies and
* skill templates are authored with into the form one tool actually registers
* — `/opsx-<command>` for tools that name the command by filename,
* `@opsx-<command>` 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:<something>` 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<string, string> = {
/**
* Tools whose skill invocation uses a non-default prefix. The default is `/`
* (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:<name>`
* (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:<name>` and
* Codex CLI as `$<name>` — a `/<name>` form Codex does not recognize
* (see docs/supported-tools.md).
*/
const SKILL_INVOCATION_PREFIX: Record<string, string> = {
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:<id>`
*/
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;
}
+1 -1
View File
@@ -16,7 +16,7 @@ export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
// Command reference utilities
export {
transformToHyphenCommands,
transformCommandInvocations,
transformToSkillReferences,
getSkillReferenceTransformer,
getTransformerForTool,
+15 -14
View File
@@ -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-<id>), 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');
});
@@ -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:<id>`. Every other registered
* adapter writes `opsx-<id>` as the filename and therefore registers
* `/opsx-<id>`.
*
* 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-<id>`.
*/
const NON_SLASH_PREFIXES: Record<string, string> = { '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-<id>.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');
}
});
});
});
+72 -8
View File
@@ -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 $<name>, 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-<id>.md into its prompt library,
// invoked as @opsx-<id>. 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
+1 -1
View File
@@ -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', () => {
+69 -7
View File
@@ -41,10 +41,14 @@ function captureMigrationLogs(projectDir: string, tools: AIToolOption[]): string
}
}
async function writeManagedCommand(projectPath: string, workflowId: string): Promise<void> {
const adapter = CommandAdapterRegistry.get('claude');
async function writeManagedCommand(
projectPath: string,
workflowId: string,
toolId = 'claude'
): Promise<void> {
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
// $<name>, 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');
+131 -1
View File
@@ -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');
@@ -1163,7 +1268,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);
@@ -1172,6 +1277,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: {},
+40
View File
@@ -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();
+88 -19
View File
@@ -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-<id>` 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:<name> 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-<id>), 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 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 Amazon Q's @-prefixed prompt form when commands are generated", () => {
// Amazon Q loads .amazonq/prompts/opsx-<id>.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 $-prefixed skill references for codex, which registers no slash commands', () => {
// Codex CLI invokes skills as $<name>; the /<name> 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');
}
});
});