Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/workflow-verbs-at-the-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

Answer workflow verbs typed at the CLI with the invocation this project actually uses. `openspec propose`, `openspec explore`, `openspec apply` and the other workflow names no longer fail with a bare `unknown command`; they explain that workflows run inside the AI assistant and name the spelling each configured tool answers to, or point at `openspec init`, `openspec config profile`, or `openspec update` when the workflow is not installed or the project does not match the global config. Real CLI commands (`new`, `update`, `archive`) and genuinely unknown commands are unchanged.
15 changes: 15 additions & 0 deletions docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ Every command takes `-h, --help`. The bare `openspec` command also takes:
- `-V, --version`: print the CLI version.
- `--no-color`: disable colored output.

**Workflow names**

Workflow names are not CLI commands. `openspec propose` prints how to invoke that workflow and runs nothing, and the same holds for `explore`, `apply`, `sync`, and every other workflow name the CLI does not already use. `openspec new`, `openspec update`, and `openspec archive` are real commands and keep doing their own work.

The answer is resolved for your project:

- No workflow files here yet: run `openspec init`. If your profile leaves the workflow out, run `openspec config profile` first.
- Installed: the spelling each configured tool answers to, such as `/opsx:propose`, `/opsx-propose`, `@opsx-propose`, or `/openspec-propose`. A tool that matches skills by description gets a plain-language request instead.
- Not in your profile: run `openspec config profile` to add it.
- In your profile, but this project's files do not match your global config yet: run `openspec update`. If your delivery setting gives the project's tools no files, set delivery to `both` first.

A spelling is named only for a tool that will answer to it. When no tool is detected, the answer stops at the setup step.

When your tools spell it differently, every spelling is listed with the tools it serves.

## openspec init

Initializes OpenSpec in a project.
Expand Down
40 changes: 40 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/i
import { maybeShowCompletionTip } from '../core/completion-tip.js';
import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
import { isInteractive } from '../utils/interactive.js';
import { WORKFLOW_VERBS, getWorkflowVerbGuidance } from '../core/workflow-verbs.js';

const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;

Expand Down Expand Up @@ -749,6 +750,45 @@ newCmd
}
});

// Workflow verbs are not CLI commands - the workflows run inside the user's AI
// assistant. Registering them hidden replaces commander's bare "unknown
// command" with the invocation this project's tools actually answer to, so a
// user (or an agent) who types `openspec propose` is routed to the workflow
// instead of hand-building the artifacts (#1221). Same reasoning as the
// removed options kept registered above: a reachable name can explain itself.
for (const verb of WORKFLOW_VERBS) {
const verbCommand = program
.command(verb, { hidden: true })
// The verb is typed with whatever the user meant to pass the workflow
// ("openspec propose add auth --fast"); accept it all and explain, rather
// than answer a discovery question with an argument error.
.argument('[args...]')
.allowUnknownOption()
.allowExcessArguments()
// No help option: `--help` and `-h` would otherwise print a usage page for
// a command that does not do anything, which is a worse dead end than the
// unknown-command error this replaced. Dropping it lets both fall through
// to allowUnknownOption and reach the guidance.
.helpOption(false)
.action(() => {
const guidance = getWorkflowVerbGuidance(verb, process.cwd());
ora().fail(`Error: ${guidance.message}`);
for (const detail of guidance.details) {
console.error(detail);
}
// exitCode rather than exit(): parse() is synchronous, and exiting from
// inside the action would cut off the postAction hook and risk
// truncating this very output on a pipe.
process.exitCode = 1;
});
// `openspec help propose` routes through the command's own help output
// rather than its action, so give that path the same answer.
verbCommand.helpInformation = () => {
const guidance = getWorkflowVerbGuidance(verb, process.cwd());
return [guidance.message, ...guidance.details, ''].join('\n');
};
}

export { program };

export function runCli(argv = process.argv): void {
Expand Down
64 changes: 64 additions & 0 deletions src/core/command-surface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { CommandAdapterRegistry } from './command-generation/index.js';
import { getInvocationForAdapter, type CommandInvocation } from './command-generation/invocation.js';
import type { Delivery } from './global-config.js';
import {
getSkillReferenceTransformer,
getTransformerForTool,
usesNaturalLanguageSkillReferences,
} from '../utils/command-references.js';

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

Expand Down Expand Up @@ -41,3 +46,62 @@ export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery
export function shouldReconcileCommandFilesForTool(toolId: string, delivery: Delivery): boolean {
return delivery === 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed';
}

/**
* How one tool spells an OpenSpec workflow reference, and whether that
* spelling is a slash invocation or prose.
*/
export interface WorkflowReference {
/** What the user types or asks for, e.g. `/opsx:propose`, `$openspec-propose`. */
reference: string;
/**
* True when the tool has no slash surface for skills, so the reference reads
* as prose ("the openspec-propose skill") and must be phrased as a request
* rather than printed as a command.
*/
naturalLanguage: boolean;
}

/**
* Resolves how one tool refers to a workflow under the effective delivery.
*
* The rule is the same one init prints in its getting-started hints: a tool
* that gets command files answers to the command name those files register
* (`/opsx:propose` when namespaced under `opsx/`, `/opsx-propose` when the
* filename is the command, `@opsx-propose` for Amazon Q's prompt library); a
* tool that only gets skills answers to its documented skill invocation
* (`/openspec-propose`, Kimi Code's `/skill:openspec-propose`, Codex's
* `$openspec-propose`, or prose for tools with no slash surface).
*
* @param toolId - The AI tool identifier (e.g. 'claude', 'kimi')
* @param delivery - The effective delivery mode
* @param canonicalCommand - The canonical reference to rewrite, e.g. `/opsx:propose`
* @returns The tool's spelling, or undefined when the delivery mode leaves
* that tool with neither commands nor skills: it has nothing to
* point at, so callers must not invent an invocation for it.
*/
export function resolveWorkflowReference(
toolId: string,
delivery: Delivery,
canonicalCommand: string
): WorkflowReference | undefined {
if (shouldGenerateCommandsForTool(toolId, delivery)) {
const transformer = getTransformerForTool(
toolId,
delivery,
resolveCommandSurfaceCapability(toolId),
resolveCommandInvocation(toolId)
);
return {
reference: transformer ? transformer(canonicalCommand) : canonicalCommand,
naturalLanguage: false,
};
}
if (shouldGenerateSkillsForTool(toolId, delivery)) {
return {
reference: getSkillReferenceTransformer(toolId)(canonicalCommand),
naturalLanguage: usesNaturalLanguageSkillReferences(toolId),
};
}
return undefined;
}
29 changes: 10 additions & 19 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { ANCHORED_OPENSPEC_DIRS, ensureDirectoryAnchor } from './openspec-root.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import { getTransformerForTool } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -71,6 +71,7 @@ import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptIn
import {
resolveCommandSurfaceCapability,
resolveCommandInvocation,
resolveWorkflowReference,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
Expand Down Expand Up @@ -1332,26 +1333,16 @@ export class InitCommand {
const startHintLines = (command: string): string[] => {
const hintToTools = new Map<string, string[]>();
for (const tool of successfulTools) {
let hint: string;
if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) {
const transformer = getTransformerForTool(
tool.value,
activeDelivery,
resolveCommandSurfaceCapability(tool.value),
resolveCommandInvocation(tool.value)
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
const skillReference = getSkillReferenceTransformer(tool.value)(command);
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
// prose ("the openspec-propose skill"); phrase the hint so it reads
// as an instruction rather than a dead command with an argument.
hint = usesNaturalLanguageSkillReferences(tool.value)
? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
: `Start your first change: ${skillReference} "your idea"`;
} else {
const workflowReference = resolveWorkflowReference(tool.value, activeDelivery, command);
if (!workflowReference) {
continue;
}
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
// prose ("the openspec-propose skill"); phrase the hint so it reads
// as an instruction rather than a dead command with an argument.
const hint = workflowReference.naturalLanguage
? `Start your first change: ask ${tool.name} to use ${workflowReference.reference} with "your idea"`
: `Start your first change: ${workflowReference.reference} "your idea"`;
hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]);
}
if (hintToTools.size === 0) {
Expand Down
6 changes: 5 additions & 1 deletion src/core/profile-sync-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,11 @@ export function getToolsNeedingProfileSync(
);
}

function getInstalledWorkflowsForTool(
/**
* Workflows one tool holds on the requested surfaces (skill files, command
* files, or both).
*/
export function getInstalledWorkflowsForTool(
projectPath: string,
toolId: string,
options: { includeSkills: boolean; includeCommands: boolean }
Expand Down
Loading
Loading