Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -671,7 +676,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
| Windsurf | `/opsx-propose`, `/opsx-apply` |
| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |
| CodeArts | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) |
| Codex | Skill-based invocations from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) |
| Codex | `$openspec-propose`, `$openspec-apply-change` — skills from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) |
| Oh My Pi | `/opsx-propose`, `/opsx-apply` |
| Kimi Code | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) |
| Trae | `/opsx-propose`, `/opsx-apply` |
Expand Down
4 changes: 2 additions & 2 deletions docs/how-commands-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ The intent is identical everywhere. The punctuation differs. Use the form that m
| Windsurf | `/opsx-propose`, `/opsx-apply` |
| GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |
| CodeArts | skill-style, e.g. `/openspec-propose` |
| Codex | skill-style via `.codex/skills/openspec-*` |
| Codex | skill-style with `$`, e.g. `$openspec-propose` |
| Oh My Pi | `/opsx-propose`, `/opsx-apply` |
| Kimi CLI | skill-style, e.g. `/skill:openspec-propose` |
| Trae | `/opsx-propose`, `/opsx-apply` |

Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`). A few tools surface OpenSpec as named skills instead of slash commands; for those you invoke the skill by name. The full per-tool list, including exactly which files get written where, lives in [Supported Tools](supported-tools.md).
Most tools use either the colon form (`/opsx:propose`) or the dash form (`/opsx-propose`); the dash form is the common one, used by tools whose command file is named `opsx-<id>`. A handful wrap that same name differently — Cline and Kilo Code want the `.md` suffix, Amazon Q expands prompts with `@` — and a few surface OpenSpec as named skills instead of slash commands, invoked by name with the prefix that tool expects. Every tool's exact form is listed in [How To Invoke](supported-tools.md#how-to-invoke).

When in doubt, type a slash in your AI chat and look at the autocomplete. Your tool will show you the form it expects.

Expand Down
24 changes: 24 additions & 0 deletions docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ For each selected tool, OpenSpec can install:

Codex is skills-only: OpenSpec installs `.codex/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files.

## How To Invoke

Docs use `/opsx:propose` as the canonical name, but each tool spells it the way
it loads the file OpenSpec wrote. Read your tool's command path in the table
below and match it to a row here — the generated files and the post-setup
"Getting started" hint already use the right form for the tools you selected.

| Command path pattern | You type | Tools |
|----------------------|----------|-------|
| `.../commands/opsx/<id>.md` (namespaced) | `/opsx:<id>` | Claude Code, CodeBuddy, Crush, Gemini CLI, Lingma, Qoder, ZCode |
| `.../opsx-<id>.md` (filename is the command) | `/opsx-<id>` | Antigravity, Auggie, IBM Bob Shell, CoStrict, Continue, Cursor, Factory Droid, GitHub Copilot, iFlow, Junie, Kiro, Oh My Pi, OpenCode, Pi, Qwen Code, Trae, Windsurf, Zoo Code |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| `.../opsx-<id>.md`, invoked with the extension | `/opsx-<id>.md` | Cline, Kilo Code |
| `.amazonq/prompts/opsx-<id>.md`, expanded as a prompt | `@opsx-<id>` | Amazon Q Developer |
| No command files — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, Mistral Vibe |
| No command files — Kimi Code | `/skill:openspec-<skill>` | Kimi Code |
| No command files — Codex CLI | `$openspec-<skill>` | Codex ([the `/openspec-<skill>` form is not recognized](https://github.com/openai/codex/issues/11817)) |

So `/opsx:propose` is `/opsx-propose` in Cursor and `$openspec-propose` in Codex.
Skill names are listed under [Generated Skill Names](#generated-skill-names).

The last four rows are the exceptions worth knowing: the `opsx-<id>` part is the
same everywhere, but the wrapper around it is the tool's own. If a slash does not
autocomplete, type your tool's prefix in front of `opsx-propose`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

By default, OpenSpec uses the `core` profile, which includes:
- `propose`
- `explore`
Expand Down
1 change: 1 addition & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ The command SHALL provide clear, actionable next steps upon successful initializ
- `/opsx:new` - Start a new change
- `/opsx:continue` - Create the next artifact
- `/opsx:apply` - Implement tasks
- **AND** spell each command the way the configured tool registers it: `/opsx-<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** display links to documentation and feedback

#### Scenario: Displaying restart instruction
Expand Down
9 changes: 8 additions & 1 deletion openspec/specs/command-generation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ The system SHALL provide a `generateCommand` function that combines content with
- `path`: the file path from `adapter.getFilePath(content.id)`
- `fileContent`: the formatted content from `adapter.formatFile(content)`

#### Scenario: Command references match the name the tool registers

- **WHEN** the adapter's file path names the command by filename (`opsx-<id>`)
- **THEN** `generateCommand` SHALL rewrite `/opsx:<id>` references in the body to `/opsx-<id>` before formatting
- **WHEN** the adapter's file path namespaces the command under an `opsx/` directory
- **THEN** the body's `/opsx:<id>` references SHALL be left unchanged

#### Scenario: Generate multiple commands

- **WHEN** generating all opsx commands for a tool
Expand Down Expand Up @@ -99,4 +106,4 @@ The body content of commands SHALL be shared across all tools.

- **WHEN** generating the 'explore' command for Claude and Cursor
- **THEN** both SHALL use the same `body` content
- **AND** only the frontmatter and file path SHALL differ
- **AND** only the frontmatter, the file path, and the spelling of `/opsx:*` command references SHALL differ
5 changes: 1 addition & 4 deletions src/core/command-generation/adapters/bob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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}
`;
},
};
5 changes: 1 addition & 4 deletions src/core/command-generation/adapters/oh-my-pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 changes: 1 addition & 5 deletions src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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}
`;
},
};
6 changes: 1 addition & 5 deletions src/core/command-generation/adapters/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)}
`;
},
};
7 changes: 1 addition & 6 deletions src/core/command-generation/adapters/qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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}
`;
},
};
19 changes: 17 additions & 2 deletions src/core/command-generation/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@
*/

import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js';
import { getInvocationStyleForPath } from './invocation.js';
import { transformToHyphenCommands } from '../../utils/command-references.js';

/**
* Generate a single command file using the provided adapter.
*
* Command bodies are authored with `/opsx:<id>` references. Tools whose command
* files are invoked by filename register `/opsx-<id>` instead, so the body is
* rewritten to the form that tool answers to before the adapter formats it.
* Doing it here rather than per adapter keeps every tool in step (#727, #1307);
* adapters stay pure formatters.
*
* @param content - The tool-agnostic command content
* @param adapter - The tool-specific adapter
* @returns Generated command with path and file content
Expand All @@ -16,9 +25,15 @@ export function generateCommand(
content: CommandContent,
adapter: ToolCommandAdapter
): GeneratedCommand {
const filePath = adapter.getFilePath(content.id);
const formatted =
getInvocationStyleForPath(filePath) === 'flat'
? { ...content, body: transformToHyphenCommands(content.body) }
: content;

return {
path: adapter.getFilePath(content.id),
fileContent: adapter.formatFile(content),
path: filePath,
fileContent: adapter.formatFile(formatted),
};
}

Expand Down
44 changes: 44 additions & 0 deletions src/core/command-generation/invocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Command Invocation Styles
*
* How a tool spells an OpenSpec slash command is decided by the file the
* adapter writes, not by a list maintained by hand:
*
* - `.../commands/opsx/<id>.md` — the directory namespaces the command, so the
* tool registers `/opsx:<id>` (Claude Code, Gemini, Crush, ...).
* - `.../commands/opsx-<id>.md` — the filename *is* the command name, so the
* tool registers `/opsx-<id>` (Cursor, GitHub Copilot, OpenCode, ...).
*
* Deriving the style from `getFilePath` keeps generated cross-references and
* onboarding hints in step with the files OpenSpec actually writes. A
* hand-maintained list drifted before: only OpenCode was rewritten when the
* hyphen form was introduced (#727), and Cursor still advertised `/opsx:`
* commands its palette never registered (#1307).
*/

import path from 'path';
import type { ToolCommandAdapter } from './types.js';

export type CommandInvocationStyle = 'namespaced' | 'flat';

/**
* Classifies a generated command file by the name the tool will answer to.
*
* @param commandFilePath - Path returned by an adapter's `getFilePath`
* @returns 'flat' when the filename carries the `opsx-` prefix, otherwise
* 'namespaced'
*/
export function getInvocationStyleForPath(commandFilePath: string): CommandInvocationStyle {
return path.basename(commandFilePath).startsWith('opsx-') ? 'flat' : 'namespaced';
}

/**
* Classifies an adapter by the command files it writes.
*
* @param adapter - The tool-specific command adapter
* @returns The invocation style the tool's command files produce
*/
export function getInvocationStyleForAdapter(adapter: ToolCommandAdapter): CommandInvocationStyle {
// Any command id works: every adapter applies one naming rule to all of them.
return getInvocationStyleForPath(adapter.getFilePath('explore'));
}
11 changes: 11 additions & 0 deletions src/core/command-surface.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { CommandAdapterRegistry } from './command-generation/index.js';
import { getInvocationStyleForAdapter, type CommandInvocationStyle } from './command-generation/invocation.js';
import type { Delivery } from './global-config.js';

export type CommandSurfaceCapability = 'adapter-backed' | 'skills-invocable' | 'none';

/**
* How the tool spells its OpenSpec slash commands, derived from the command
* files its adapter writes. Returns undefined for tools with no command
* adapter, which have no command names to spell.
*/
export function resolveCommandInvocationStyle(toolId: string): CommandInvocationStyle | undefined {
const adapter = CommandAdapterRegistry.get(toolId);
return adapter ? getInvocationStyleForAdapter(adapter) : undefined;
}

export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceCapability {
if (CommandAdapterRegistry.has(toolId)) {
return 'adapter-backed';
Expand Down
36 changes: 19 additions & 17 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,6 +53,7 @@ import { getAvailableTools } from './available-tools.js';
import { migrateIfNeeded, migrateLegacySkillDirs, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
import {
resolveCommandSurfaceCapability,
resolveCommandInvocationStyle,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
Expand Down Expand Up @@ -705,7 +706,12 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');

// Generate SKILL.md content with YAML frontmatter including generatedBy
const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
const transformer = getTransformerForTool(
tool.value,
delivery,
resolveCommandSurfaceCapability(tool.value),
resolveCommandInvocationStyle(tool.value)
);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
Expand Down Expand Up @@ -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),
resolveCommandInvocationStyle(tool.value)
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
hint =
resolveCommandSurfaceCapability(tool.value) === 'skills-invocable'
? `Start your first change with the ${skillName} skill`
: `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
hint = `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`;
} else {
continue;
}
Expand Down
Loading
Loading