Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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` or `openspec config profile` when the workflow is not installed. Real CLI commands (`new`, `update`, `archive`) and genuinely unknown commands are unchanged.
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The OpenSpec CLI (`openspec`) provides terminal commands for project setup, validation, status inspection, and management. These commands complement the AI slash commands (like `/opsx:propose`) documented in [Commands](commands.md).

Workflow names are not CLI commands. Typing `openspec propose` (or `explore`, `apply`, `sync`, ...) prints the invocation your configured tools answer to — `/opsx:propose`, `/opsx-propose`, `/openspec-propose`, depending on the tool — rather than running anything.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Summary

| Category | Commands | Purpose |
Expand Down
26 changes: 26 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,31 @@ 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) {
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()
.action(() => {
const guidance = getWorkflowVerbGuidance(verb, process.cwd());
ora().fail(`Error: ${guidance.message}`);
for (const detail of guidance.details) {
console.error(detail);
}
process.exit(1);
});
}

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 @@ -70,6 +70,7 @@ import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptIn
import {
resolveCommandSurfaceCapability,
resolveCommandInvocation,
resolveWorkflowReference,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
Expand Down Expand Up @@ -1331,26 +1332,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
166 changes: 166 additions & 0 deletions src/core/workflow-verbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Workflow Verbs Typed At The CLI
*
* OpenSpec's workflows (`propose`, `explore`, `apply`, ...) run inside the
* user's AI assistant, not in the terminal. Users and agents nonetheless say
* and type "openspec propose" — it is the natural way to name the thing — and
* the bare `error: unknown command 'propose'` that came back taught them
* nothing. Agents in particular read that failure as permission to hand-build
* the artifacts with `openspec new change` plus manual file writes, bypassing
* the workflow entirely (#1221).
*
* So the verbs are registered as hidden commands whose whole job is to answer
* the question: this is a workflow, here is how *your* tools invoke it. That
* mirrors the treatment retired flags already get in the CLI — keep the name
* reachable so it can explain itself instead of failing generically.
*/

import type { AIToolOption } from './config.js';
import { getAvailableTools } from './available-tools.js';
import { getGlobalConfig, type Delivery } from './global-config.js';
import { scanInstalledWorkflows } from './migration.js';
import { ALL_WORKFLOWS } from './profiles.js';
import { resolveWorkflowReference } from './command-surface.js';

/**
* Workflow ids that the CLI already uses for real commands. `openspec new`,
* `openspec update`, and `openspec archive` do their own work, so those names
* are never rerouted to workflow guidance — the CLI command wins, as it
* always has.
*/
const CLI_RESERVED_WORKFLOW_IDS = new Set<string>(['new', 'update', 'archive']);

/**
* The workflow ids reachable as bare CLI verbs. Every workflow whose name is
* not already a CLI command; see CLI_RESERVED_WORKFLOW_IDS for the ones that
* are.
*/
export const WORKFLOW_VERBS: readonly string[] = ALL_WORKFLOWS.filter(
(workflowId) => !CLI_RESERVED_WORKFLOW_IDS.has(workflowId)
);

/**
* The canonical reference every generated file is authored with. Per-tool
* spellings are rewritten from this form.
*/
function canonicalCommand(verb: string): string {
return `/opsx:${verb}`;
}

export interface WorkflowVerbGuidance {
/** The headline: what went wrong, in one sentence. */
message: string;
/** Supporting lines, already ordered; may be empty. */
details: string[];
}

/**
* One invocation line per distinct spelling, labeled with the tools it serves
* when the project's tools disagree.
*
* Natural-language tools (no slash surface for skills) are grouped per tool
* rather than per reference: their line names the tool inside the sentence, so
* two such tools sharing a reference still need two lines.
*/
function invocationLines(
tools: AIToolOption[],
delivery: Delivery,
verb: string
): string[] {
const lineToTools = new Map<string, string[]>();
for (const tool of tools) {
const workflowReference = resolveWorkflowReference(tool.value, delivery, canonicalCommand(verb));
if (!workflowReference) {
continue;
}
const line = workflowReference.naturalLanguage
? `ask ${tool.name} to use ${workflowReference.reference}`
: workflowReference.reference;
lineToTools.set(line, [...(lineToTools.get(line) ?? []), tool.name]);
}
if (lineToTools.size === 1) {
return [...lineToTools.keys()];
}
return [...lineToTools.entries()].map(([line, toolNames]) => `${line} (${toolNames.join(', ')})`);
}

/**
* Builds the answer for a workflow verb typed at the CLI, grounded in what is
* actually installed in this project.
*
* Three cases, in order of what the user can act on:
* - No OpenSpec tools detected: nothing is installed yet, so point at `init`.
* - Tools detected but this workflow is not among the installed ones: the
* invocation would be dead text, so point at the profile picker (#1076).
* - Otherwise: name the invocation each detected tool answers to.
*
* @param verb - A workflow id from WORKFLOW_VERBS
* @param projectPath - Directory to inspect for installed tools and workflows
*/
export function getWorkflowVerbGuidance(verb: string, projectPath: string): WorkflowVerbGuidance {
const message = `'${verb}' is an OpenSpec workflow, not a CLI command. Workflows run inside your AI assistant.`;
const tools = safeDetectTools(projectPath);

if (tools.length === 0) {
return {
message,
details: [
`Fix: run 'openspec init' to install the workflows, then invoke ${canonicalCommand(verb)} in your assistant.`,
],
};
}

const installed = new Set(safeScanInstalledWorkflows(projectPath, tools));
if (!installed.has(verb)) {
return {
message,
details: [
`The ${verb} workflow is not installed in this project.`,
`Fix: run 'openspec config profile' to add it, then invoke ${canonicalCommand(verb)} in your assistant.`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
],
};
}

const delivery: Delivery = getGlobalConfig().delivery ?? 'both';
const lines = invocationLines(tools, delivery, verb);
if (lines.length === 0) {
// Detected tools, but the delivery mode left none of them with an
// invocation to name. Stay syntax-neutral rather than invent one.
return {
message,
details: [`Fix: run 'openspec update' to regenerate this project's workflow files.`],
};
}
if (lines.length === 1) {
return { message, details: [`Fix: run ${lines[0]} in your assistant.`] };
}
return {
message,
details: ['Fix: run it in your assistant:', ...lines.map((line) => ` ${line}`)],
};
}

/**
* Detection walks the project directory, and this runs on an error path: a
* permission error or an unreadable directory must not replace the guidance
* with a stack trace. An empty list degrades to the `init` wording, which is
* still true and still actionable.
*/
function safeDetectTools(projectPath: string): AIToolOption[] {
try {
return getAvailableTools(projectPath);
} catch {
return [];
}
}

function safeScanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] {
try {
return scanInstalledWorkflows(projectPath, tools);
} catch {
// Unknown rather than absent: treat every workflow as installed so the
// guidance names the invocation instead of sending the user to the
// profile picker over an unreadable directory.
return [...ALL_WORKFLOWS];
}
}
31 changes: 31 additions & 0 deletions test/cli-e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ afterAll(async () => {
});

describe('openspec CLI e2e basics', () => {
it('answers a workflow verb typed at the CLI with the invocation for this project', async () => {
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-workflow-verb-'));
tempRoots.push(base);
const projectDir = path.join(base, 'project');
// A project with Claude Code commands installed, and a HOME with nothing
// in it so no globally installed tool joins the answer.
await fs.mkdir(path.join(projectDir, '.claude', 'commands', 'opsx'), { recursive: true });
await fs.writeFile(
path.join(projectDir, '.claude', 'commands', 'opsx', 'propose.md'),
'# propose\n'
);
const home = path.join(base, 'home');
await fs.mkdir(home, { recursive: true });

const result = await runCLI(['propose', 'add auth'], {
cwd: projectDir,
env: { HOME: home, USERPROFILE: home },
});

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("'propose' is an OpenSpec workflow, not a CLI command");
expect(result.stderr).toContain('Fix: run /opsx:propose in your assistant.');
});

it('still reports a genuinely unknown command as unknown', async () => {
const result = await runCLI(['definitely-not-a-command']);

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("unknown command 'definitely-not-a-command'");
});

it('preserves initialized directories through a Git clone without listing anchors as work', async () => {
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-init-clone-'));
tempRoots.push(base);
Expand Down
Loading
Loading