From 719b6313e8be966046793e3d7deb0064118574f0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 13:19:41 -0500 Subject: [PATCH 1/6] fix(cli): answer workflow verbs typed at the CLI OpenSpec's workflows run inside the user's AI assistant, but users and agents type "openspec propose" - it is the natural way to name the thing. The bare `error: unknown command 'propose'` taught them nothing, and agents read that failure as permission to hand-build the artifacts with `openspec new change` plus manual writes, bypassing the workflow. Register the workflow verbs as hidden commands that answer the question: this is a workflow, and here is how your tools invoke it. The answer is grounded in the project - the invocation each detected tool answers to, `openspec init` when no tools are configured, `openspec config profile` when the workflow is not installed. The per-tool spelling now comes from one resolver shared with init's getting-started hints, so the two surfaces cannot drift. Relates to #1221 (the CLI-time half; the generated verb-to-command mapping that issue asks for is not added here) Co-Authored-By: Claude Opus 5 --- .changeset/workflow-verbs-at-the-cli.md | 5 + docs/cli.md | 2 + src/cli/index.ts | 26 ++++ src/core/command-surface.ts | 64 +++++++++ src/core/init.ts | 29 ++--- src/core/workflow-verbs.ts | 166 ++++++++++++++++++++++++ test/cli-e2e/basic.test.ts | 31 +++++ test/core/workflow-verbs.test.ts | 142 ++++++++++++++++++++ 8 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 .changeset/workflow-verbs-at-the-cli.md create mode 100644 src/core/workflow-verbs.ts create mode 100644 test/core/workflow-verbs.test.ts diff --git a/.changeset/workflow-verbs-at-the-cli.md b/.changeset/workflow-verbs-at-the-cli.md new file mode 100644 index 0000000000..abc62dcb0b --- /dev/null +++ b/.changeset/workflow-verbs-at-the-cli.md @@ -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. diff --git a/docs/cli.md b/docs/cli.md index e74bc60f2d..7a2fa6251f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. + ## Summary | Category | Commands | Purpose | diff --git a/src/cli/index.ts b/src/cli/index.ts index 75324cfa38..bb99f10cf1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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; @@ -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 { diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index 4162e92532..85262e967d 100644 --- a/src/core/command-surface.ts +++ b/src/core/command-surface.ts @@ -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'; @@ -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; +} diff --git a/src/core/init.ts b/src/core/init.ts index e6dc36519d..df38e09f9b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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, @@ -70,6 +70,7 @@ import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptIn import { resolveCommandSurfaceCapability, resolveCommandInvocation, + resolveWorkflowReference, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, @@ -1331,26 +1332,16 @@ export class InitCommand { const startHintLines = (command: string): string[] => { const hintToTools = new Map(); 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) { diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts new file mode 100644 index 0000000000..1a8256ac56 --- /dev/null +++ b/src/core/workflow-verbs.ts @@ -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(['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(); + 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.`, + ], + }; + } + + 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]; + } +} diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index c05965ea72..95937d404e 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -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); diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts new file mode 100644 index 0000000000..6483ea243a --- /dev/null +++ b/test/core/workflow-verbs.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +import { WORKFLOW_VERBS, getWorkflowVerbGuidance } from '../../src/core/workflow-verbs.js'; +import { ALL_WORKFLOWS } from '../../src/core/profiles.js'; +import { program } from '../../src/cli/index.js'; + +const tempRoots: string[] = []; +const savedEnv: Record = {}; + +async function makeProject(): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-workflow-verbs-')); + tempRoots.push(base); + const projectDir = path.join(base, 'project'); + await fs.mkdir(projectDir, { recursive: true }); + return projectDir; +} + +async function installSkill(projectDir: string, toolDir: string, skillName: string): Promise { + const skillDir = path.join(projectDir, toolDir, 'skills', skillName); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), '# skill\n'); +} + +async function installCommand(projectDir: string, filePath: string): Promise { + const target = path.join(projectDir, filePath); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, '# command\n'); +} + +beforeEach(async () => { + // Detection reads global skill roots (e.g. ~/.minimax/skills) and the global + // config; point both at an empty directory so the machine running the tests + // cannot add tools this project never installed. + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-workflow-verbs-home-')); + tempRoots.push(home); + for (const key of ['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME']) { + savedEnv[key] = process.env[key]; + process.env[key] = home; + } +}); + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('workflow verbs typed at the CLI', () => { + it('covers every workflow whose name the CLI does not already use', () => { + // Only the visible commands: the verbs themselves are registered hidden. + const registeredCommandNames = new Set( + program.commands + .filter((command) => !(command as unknown as { _hidden?: boolean })._hidden) + .map((command) => command.name()) + ); + // A verb that collides with a real command cannot ship: commander throws + // at registration time, so the module fails to load. What can rot silently + // is the reserved list - drop `openspec archive` and 'archive' would be + // neither a CLI command nor a verb, leaving the workflow unreachable from + // the terminal with no error anywhere. + const missing = ALL_WORKFLOWS.filter( + (workflowId) => !WORKFLOW_VERBS.includes(workflowId) && !registeredCommandNames.has(workflowId) + ); + expect(missing).toEqual([]); + }); + + it('registers each verb as a hidden command so help output stays unchanged', () => { + for (const verb of WORKFLOW_VERBS) { + const command = program.commands.find((candidate) => candidate.name() === verb); + expect(command, `${verb} should be registered`).toBeDefined(); + expect((command as unknown as { _hidden?: boolean })._hidden).toBe(true); + } + }); + + it('points at init when the project has no OpenSpec tools', async () => { + const projectDir = await makeProject(); + + const guidance = getWorkflowVerbGuidance('propose', projectDir); + + expect(guidance.message).toContain("'propose' is an OpenSpec workflow, not a CLI command"); + expect(guidance.details).toEqual([ + "Fix: run 'openspec init' to install the workflows, then invoke /opsx:propose in your assistant.", + ]); + }); + + it('points at the profile picker when the workflow is not installed', async () => { + const projectDir = await makeProject(); + await installSkill(projectDir, '.claude', 'openspec-propose'); + + const guidance = getWorkflowVerbGuidance('verify', projectDir); + + expect(guidance.details).toEqual([ + 'The verify workflow is not installed in this project.', + "Fix: run 'openspec config profile' to add it, then invoke /opsx:verify in your assistant.", + ]); + }); + + it("names the tool's own invocation when the workflow is installed", async () => { + const projectDir = await makeProject(); + await installSkill(projectDir, '.claude', 'openspec-explore'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual(['Fix: run /opsx:explore in your assistant.']); + }); + + it('spells the invocation as a skill when delivery is skills-only', async () => { + const projectDir = await makeProject(); + await installSkill(projectDir, '.claude', 'openspec-explore'); + const configDir = path.join(process.env.XDG_CONFIG_HOME as string, 'openspec'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, 'config.json'), JSON.stringify({ delivery: 'skills' })); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual(['Fix: run /openspec-explore in your assistant.']); + }); + + it('labels each invocation when the project tools spell it differently', async () => { + const projectDir = await makeProject(); + // Claude Code namespaces commands under opsx/, GitHub Copilot names the + // command with the filename - the two tools answer to different spellings. + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + await installCommand(projectDir, path.join('.github', 'prompts', 'opsx-explore.prompt.md')); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details[0]).toBe('Fix: run it in your assistant:'); + expect(guidance.details.slice(1)).toEqual([ + ' /opsx:explore (Claude Code)', + ' /opsx-explore (GitHub Copilot)', + ]); + }); +}); From 2dd2b53b4f55d66ecf691053ee779891c0f54708 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 13:45:46 -0500 Subject: [PATCH 2/6] fix(cli): answer the help paths and match each tool's spelling Three gaps from the first pass: - `openspec explore --help`, `-h`, and `openspec help explore` printed a usage page for a command that does nothing - a worse dead end than the unknown-command error this replaced. All three now answer with the guidance; the explicit `help` request answers on stdout and exits 0. - The not-installed branch hardcoded the canonical `/opsx:verify` even where the project's tools spell it `/opsx-verify` or `/openspec-verify-change`. Spelling now comes from the tool and the delivery mode in both branches, so they cannot disagree. - A tool with no slash surface read as "run ask Rovo Dev CLI to use the openspec-explore skill". A natural-language reference is already a request, so it is no longer wrapped in a verb, and it does not get a redundant `(Tool)` label in a multi-tool list. Exit through `process.exitCode` rather than `process.exit()`, so the postAction hook still runs and the guidance cannot be truncated on a pipe. Co-Authored-By: Claude Opus 5 --- docs/cli.md | 2 +- src/cli/index.ts | 18 +++++- src/core/workflow-verbs.ts | 100 +++++++++++++++++++++++-------- test/cli-e2e/basic.test.ts | 42 +++++++++++++ test/core/workflow-verbs.test.ts | 97 ++++++++++++++++++++++++++++-- 5 files changed, 225 insertions(+), 34 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7a2fa6251f..7a451ab078 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,7 +2,7 @@ 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. +Workflow names are not CLI commands. Typing `openspec propose` (or `explore`, `apply`, `sync`, ...) prints how to invoke that workflow rather than running anything. The answer is resolved for your project: the spelling each configured tool answers to (`/opsx:propose`, `/opsx-propose`, `@opsx-propose`, `/openspec-propose`, or a plain-language request for tools that match skills by description), or a pointer to `openspec init` when no tools are configured and to `openspec config profile` when the workflow is not installed. ## Summary diff --git a/src/cli/index.ts b/src/cli/index.ts index bb99f10cf1..c82c2ec426 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -757,7 +757,7 @@ newCmd // 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 + 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 @@ -765,14 +765,28 @@ for (const verb of WORKFLOW_VERBS) { .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); } - process.exit(1); + // 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 }; diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts index 1a8256ac56..afe0b88e3b 100644 --- a/src/core/workflow-verbs.ts +++ b/src/core/workflow-verbs.ts @@ -55,33 +55,65 @@ export interface WorkflowVerbGuidance { } /** - * One invocation line per distinct spelling, labeled with the tools it serves - * when the project's tools disagree. + * How one detected tool is told to invoke the workflow. + */ +interface InvocationEntry { + /** `/opsx:explore`, or `ask Rovo Dev CLI to use the openspec-explore skill`. */ + text: string; + /** True when `text` is already a sentence naming its own tool. */ + naturalLanguage: boolean; +} + +/** + * One entry 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. + * rather than per reference: their text names the tool inside the sentence, so + * two such tools sharing a reference still need two entries - and appending a + * `(Tool)` label to a sentence that already says "ask Tool to..." would just + * repeat it. */ -function invocationLines( +function invocationEntries( tools: AIToolOption[], delivery: Delivery, verb: string -): string[] { - const lineToTools = new Map(); +): InvocationEntry[] { + const textToTools = new Map(); for (const tool of tools) { const workflowReference = resolveWorkflowReference(tool.value, delivery, canonicalCommand(verb)); if (!workflowReference) { continue; } - const line = workflowReference.naturalLanguage + const text = workflowReference.naturalLanguage ? `ask ${tool.name} to use ${workflowReference.reference}` : workflowReference.reference; - lineToTools.set(line, [...(lineToTools.get(line) ?? []), tool.name]); + const existing = textToTools.get(text); + textToTools.set(text, { + toolNames: [...(existing?.toolNames ?? []), tool.name], + naturalLanguage: workflowReference.naturalLanguage, + }); } - if (lineToTools.size === 1) { - return [...lineToTools.keys()]; + const entries = [...textToTools.entries()]; + if (entries.length === 1) { + const [text, { naturalLanguage }] = entries[0]; + return [{ text, naturalLanguage }]; } - return [...lineToTools.entries()].map(([line, toolNames]) => `${line} (${toolNames.join(', ')})`); + return entries.map(([text, { toolNames, naturalLanguage }]) => ({ + text: naturalLanguage ? text : `${text} (${toolNames.join(', ')})`, + naturalLanguage, + })); +} + +/** + * Turns one entry into an instruction. A slash invocation is something to run; + * a natural-language reference is already a request, so it is quoted as-is + * rather than wrapped in a verb that would read as "run ask Tool to...". + */ +function instruction(entry: InvocationEntry, lead: string): string { + return entry.naturalLanguage + ? `${lead} ${entry.text}.` + : `${lead} run ${entry.text} in your assistant.`; } /** @@ -91,9 +123,14 @@ function invocationLines( * 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). + * invocation exists only after it is added, so lead with the profile picker + * (#1076) and still name the spelling it will answer to. * - Otherwise: name the invocation each detected tool answers to. * + * The spelling comes from the tool and the delivery mode, never from whether + * the workflow happens to be installed - so the two installed/not-installed + * branches cannot disagree about how the same tool spells the same workflow. + * * @param verb - A workflow id from WORKFLOW_VERBS * @param projectPath - Directory to inspect for installed tools and workflows */ @@ -110,20 +147,27 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work }; } + const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; + const entries = invocationEntries(tools, delivery, verb); 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.`, - ], - }; + const notInstalled = `The ${verb} workflow is not installed in this project.`; + const addIt = "Fix: run 'openspec config profile' to add it, then"; + if (entries.length > 1) { + return { + message, + details: [notInstalled, `${addIt} use it in your assistant:`, ...indent(entries)], + }; + } + // One agreed spelling, or none at all. When no tool has one to offer, the + // canonical form is the only honest answer - and it is what the workflow + // will answer to once the profile installs it for a tool that invokes it. + const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false }; + return { message, details: [notInstalled, instruction(entry, addIt)] }; } - const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; - const lines = invocationLines(tools, delivery, verb); - if (lines.length === 0) { + if (entries.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 { @@ -131,15 +175,19 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work 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.`] }; + if (entries.length === 1) { + return { message, details: [instruction(entries[0], 'Fix:')] }; } return { message, - details: ['Fix: run it in your assistant:', ...lines.map((line) => ` ${line}`)], + details: ['Fix: use it in your assistant:', ...indent(entries)], }; } +function indent(entries: InvocationEntry[]): string[] { + return entries.map((entry) => ` ${entry.text}`); +} + /** * Detection walks the project directory, and this runs on an error path: a * permission error or an unreadable directory must not replace the guidance diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 95937d404e..75de3632a6 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -65,6 +65,48 @@ describe('openspec CLI e2e basics', () => { expect(result.stderr).toContain('Fix: run /opsx:propose in your assistant.'); }); + it('answers the help paths for a workflow verb instead of printing an empty usage page', async () => { + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-workflow-verb-help-')); + tempRoots.push(base); + const projectDir = path.join(base, 'project'); + await fs.mkdir(path.join(projectDir, '.claude', 'commands', 'opsx'), { recursive: true }); + await fs.writeFile( + path.join(projectDir, '.claude', 'commands', 'opsx', 'explore.md'), + '# explore\n' + ); + const home = path.join(base, 'home'); + await fs.mkdir(home, { recursive: true }); + const env = { HOME: home, USERPROFILE: home }; + + // `--help` and `-h` reach the guidance rather than a usage page for a + // command that does nothing. + for (const flag of ['--help', '-h']) { + const result = await runCLI(['explore', flag], { cwd: projectDir, env }); + expect(result.exitCode, flag).toBe(1); + expect(result.stderr, flag).toContain('Fix: run /opsx:explore in your assistant.'); + expect(result.stdout, flag).not.toContain('Usage: openspec explore'); + } + + // `openspec help explore` is an explicit request for help, so it answers + // on stdout and succeeds. + const helpResult = await runCLI(['help', 'explore'], { cwd: projectDir, env }); + expect(helpResult.exitCode).toBe(0); + expect(helpResult.stdout).toContain('Fix: run /opsx:explore in your assistant.'); + expect(helpResult.stdout).not.toContain('Usage: openspec explore'); + }); + + it('keeps the workflow verbs out of the top-level help', async () => { + const result = await runCLI(['--help']); + + expect(result.exitCode).toBe(0); + // A listed command starts its own line and is followed by whitespace; + // matching the bare word alone would hit prose in another command's + // wrapped description ("...instructions for artifacts, apply, or archive"). + for (const verb of ['propose', 'explore', 'apply', 'sync', 'verify', 'onboard']) { + expect(result.stdout, verb).not.toMatch(new RegExp(`^\\s{2,}${verb}(\\s|$)`, 'm')); + } + }); + it('still reports a genuinely unknown command as unknown', async () => { const result = await runCLI(['definitely-not-a-command']); diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts index 6483ea243a..f421ff2492 100644 --- a/test/core/workflow-verbs.test.ts +++ b/test/core/workflow-verbs.test.ts @@ -30,6 +30,12 @@ async function installCommand(projectDir: string, filePath: string): Promise): Promise { + const configDir = path.join(process.env.XDG_CONFIG_HOME as string, 'openspec'); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, 'config.json'), JSON.stringify(config)); +} + beforeEach(async () => { // Detection reads global skill roots (e.g. ~/.minimax/skills) and the global // config; point both at an empty directory so the machine running the tests @@ -99,7 +105,90 @@ describe('workflow verbs typed at the CLI', () => { expect(guidance.details).toEqual([ 'The verify workflow is not installed in this project.', - "Fix: run 'openspec config profile' to add it, then invoke /opsx:verify in your assistant.", + "Fix: run 'openspec config profile' to add it, then run /opsx:verify in your assistant.", + ]); + }); + + it('spells the workflow the same way whether or not it is installed', async () => { + const projectDir = await makeProject(); + await installSkill(projectDir, '.claude', 'openspec-propose'); + await writeGlobalConfig({ delivery: 'skills' }); + + const missing = getWorkflowVerbGuidance('verify', projectDir); + const present = getWorkflowVerbGuidance('propose', projectDir); + + // The spelling follows the tool and the delivery mode, never whether the + // workflow happens to be installed - so a skills-only project is told to + // add `verify` and invoke it the same way it already invokes `propose`. + expect(missing.details).toEqual([ + 'The verify workflow is not installed in this project.', + "Fix: run 'openspec config profile' to add it, then run /openspec-verify-change in your assistant.", + ]); + expect(present.details).toEqual(['Fix: run /openspec-propose in your assistant.']); + }); + + it('labels every spelling when a missing workflow serves tools that disagree', async () => { + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.md')); + await installCommand(projectDir, path.join('.github', 'prompts', 'opsx-propose.prompt.md')); + + const guidance = getWorkflowVerbGuidance('verify', projectDir); + + expect(guidance.details).toEqual([ + 'The verify workflow is not installed in this project.', + "Fix: run 'openspec config profile' to add it, then use it in your assistant:", + ' /opsx:verify (Claude Code)', + ' /opsx-verify (GitHub Copilot)', + ]); + }); + + it("uses a tool's own prompt-library prefix", async () => { + const projectDir = await makeProject(); + // Amazon Q loads these files into its prompt library, invoked with `@`. + await installSkill(projectDir, '.amazonq', 'openspec-explore'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual(['Fix: run @opsx-explore in your assistant.']); + }); + + it('phrases the fix as a request for tools with no slash surface', async () => { + const projectDir = await makeProject(); + // Rovo Dev matches skills by description; it has no slash invocation. + await installSkill(projectDir, '.rovodev', 'openspec-explore'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + 'Fix: ask Rovo Dev CLI to use the openspec-explore skill.', + ]); + }); + + it('does not repeat the tool name on a line that already says it', async () => { + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + await installSkill(projectDir, '.rovodev', 'openspec-explore'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + 'Fix: use it in your assistant:', + ' /opsx:explore (Claude Code)', + ' ask Rovo Dev CLI to use the openspec-explore skill', + ]); + }); + + it('sends the user to update when the delivery mode leaves a tool nothing to invoke', async () => { + const projectDir = await makeProject(); + // Kimi Code has no command surface at all, so commands-only delivery + // generates neither commands nor skills for it. + await installSkill(projectDir, '.kimi-code', 'openspec-explore'); + await writeGlobalConfig({ delivery: 'commands' }); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + "Fix: run 'openspec update' to regenerate this project's workflow files.", ]); }); @@ -115,9 +204,7 @@ describe('workflow verbs typed at the CLI', () => { it('spells the invocation as a skill when delivery is skills-only', async () => { const projectDir = await makeProject(); await installSkill(projectDir, '.claude', 'openspec-explore'); - const configDir = path.join(process.env.XDG_CONFIG_HOME as string, 'openspec'); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile(path.join(configDir, 'config.json'), JSON.stringify({ delivery: 'skills' })); + await writeGlobalConfig({ delivery: 'skills' }); const guidance = getWorkflowVerbGuidance('explore', projectDir); @@ -133,7 +220,7 @@ describe('workflow verbs typed at the CLI', () => { const guidance = getWorkflowVerbGuidance('explore', projectDir); - expect(guidance.details[0]).toBe('Fix: run it in your assistant:'); + expect(guidance.details[0]).toBe('Fix: use it in your assistant:'); expect(guidance.details.slice(1)).toEqual([ ' /opsx:explore (Claude Code)', ' /opsx-explore (GitHub Copilot)', From 1a7355780b0a979225f5cfc858d85b70811562f7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 7 Sep 2026 11:27:17 -0500 Subject: [PATCH 3/6] fix(cli): branch the workflow-verb hint on installed artifacts, not tool dirs alfred-openspec on #1776: the first-run branch tested getAvailableTools(), which reads a bare .claude/ as Claude Code even in a repo that has never run openspec init. Such a project fell through to the profile branch and was told to run 'openspec config profile', a command that cannot help until there is something to configure. The branch now tests the installed workflow artifacts, which is what the doc comment always claimed it tested: scanInstalledWorkflows returns nothing when no skill or command file exists under any detected tool, whatever tool directories happen to be present. The tools.length === 0 case is subsumed, since no tools means no artifacts, and the unreadable-directory fallback still reports every workflow as installed so a permission error cannot send an initialized project back to init. Two fixtures added: an unrelated .claude/ directory with settings and an empty commands folder now points at init, and the same directory with one installed skill still points a different missing workflow at the profile picker. Verified the first bites by restoring the tools.length test. Also moves the documentation to its canonical home: docs-lab/README.md makes the old docs/ tree legacy, so the docs/cli.md paragraph is dropped and the contract is documented under Commands in docs-lab/reference/cli.md. Co-Authored-By: Claude Opus 5 --- docs-lab/reference/cli.md | 12 ++++++++++++ docs/cli.md | 2 -- src/core/workflow-verbs.ts | 22 +++++++++++++++------- test/core/workflow-verbs.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index e9e4eb14fd..4a8aa390f6 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -65,6 +65,18 @@ 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`. +- Workflows installed, but not this one: run `openspec config profile` to add it. +- 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. + +When your tools spell it differently, every spelling is listed with the tools it serves. + ## openspec init Initializes OpenSpec in a project. diff --git a/docs/cli.md b/docs/cli.md index 7a451ab078..e74bc60f2d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,8 +2,6 @@ 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 how to invoke that workflow rather than running anything. The answer is resolved for your project: the spelling each configured tool answers to (`/opsx:propose`, `/opsx-propose`, `@opsx-propose`, `/openspec-propose`, or a plain-language request for tools that match skills by description), or a pointer to `openspec init` when no tools are configured and to `openspec config profile` when the workflow is not installed. - ## Summary | Category | Commands | Purpose | diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts index afe0b88e3b..cffb967385 100644 --- a/src/core/workflow-verbs.ts +++ b/src/core/workflow-verbs.ts @@ -121,12 +121,19 @@ function instruction(entry: InvocationEntry, lead: string): string { * 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 exists only after it is added, so lead with the profile picker - * (#1076) and still name the spelling it will answer to. + * - No OpenSpec workflow artifacts at all: the project has never run `init`, + * so point at `init`. + * - Workflows installed, but not this one: the invocation exists only after it + * is added, so lead with the profile picker (#1076) and still name the + * spelling it will answer to. * - Otherwise: name the invocation each detected tool answers to. * + * The first case tests for installed artifacts, not for AI tool directories. + * `getAvailableTools` reads a bare `.claude/` as Claude Code, which says the + * user has an assistant and nothing about whether OpenSpec has ever run here; + * branching on it sent a project that never ran `init` to `openspec config + * profile`, a command that cannot help until there is something to configure. + * * The spelling comes from the tool and the delivery mode, never from whether * the workflow happens to be installed - so the two installed/not-installed * branches cannot disagree about how the same tool spells the same workflow. @@ -137,8 +144,9 @@ function instruction(entry: InvocationEntry, lead: string): string { 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); + const installed = new Set(safeScanInstalledWorkflows(projectPath, tools)); - if (tools.length === 0) { + if (installed.size === 0) { return { message, details: [ @@ -149,7 +157,6 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; const entries = invocationEntries(tools, delivery, verb); - const installed = new Set(safeScanInstalledWorkflows(projectPath, tools)); if (!installed.has(verb)) { const notInstalled = `The ${verb} workflow is not installed in this project.`; @@ -208,7 +215,8 @@ function safeScanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): } 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. + // profile picker, or to `init` over a project that already has one, over + // an unreadable directory. return [...ALL_WORKFLOWS]; } } diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts index f421ff2492..76aa4f0763 100644 --- a/test/core/workflow-verbs.test.ts +++ b/test/core/workflow-verbs.test.ts @@ -97,6 +97,36 @@ describe('workflow verbs typed at the CLI', () => { ]); }); + it('points at init when a tool directory exists but OpenSpec never ran here', async () => { + // Detection reads a bare `.claude/` as Claude Code, which says the user has + // an assistant and nothing about whether OpenSpec has ever run in this + // project. Branching on tool presence sent this project to + // `openspec config profile`, which cannot help until there is something to + // configure. + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.claude', 'commands'), { recursive: true }); + await fs.writeFile(path.join(projectDir, '.claude', 'settings.json'), '{}\n'); + + const guidance = getWorkflowVerbGuidance('propose', projectDir); + + expect(guidance.details).toEqual([ + "Fix: run 'openspec init' to install the workflows, then invoke /opsx:propose in your assistant.", + ]); + }); + + it('sends an initialized project to the profile picker, not back to init', async () => { + // The other side of the same branch: one installed workflow is enough to + // prove init has run, so a *different* missing workflow is a profile + // question rather than an install question. + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.claude', 'commands'), { recursive: true }); + await installSkill(projectDir, '.claude', 'openspec-propose'); + + expect(getWorkflowVerbGuidance('verify', projectDir).details[0]).toBe( + 'The verify workflow is not installed in this project.' + ); + }); + it('points at the profile picker when the workflow is not installed', async () => { const projectDir = await makeProject(); await installSkill(projectDir, '.claude', 'openspec-propose'); From ad6cc288befed07496f73ea590d270c9f27e1803 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 7 Sep 2026 12:22:03 -0500 Subject: [PATCH 4/6] fix(cli): name the detected tool's spelling in the init answer CodeRabbit on #1776: with the branch now keyed on installed artifacts, the init answer can be reached with a tool detected (a repo with a bare .claude/ that never ran init is exactly that case), and it still named the canonical /opsx: form. That contradicted this module's own rule, that the spelling comes from the tool and the delivery mode and never from whether the workflow happens to be installed, so the three branches could disagree about how one tool spells one workflow. Delivery and the invocation entries are resolved before the branch, and the init answer renders them the same way the other two do: one entry inline, several listed with their tools, and the canonical form only when no detected tool has a spelling to offer. Two fixtures pin the cases that discriminate: a bare .amazonq/ gets '@opsx-explore', not '/opsx:explore', and a bare .rovodev/ gets the plain-language request rather than a slash command. The two existing init assertions move from 'invoke' to 'run', which is the shared instruction() wording the other branches already used. Co-Authored-By: Claude Opus 5 --- src/core/workflow-verbs.ts | 25 ++++++++++++++++--------- test/core/workflow-verbs.test.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts index cffb967385..2a37dfc60b 100644 --- a/src/core/workflow-verbs.ts +++ b/src/core/workflow-verbs.ts @@ -145,19 +145,26 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work const message = `'${verb}' is an OpenSpec workflow, not a CLI command. Workflows run inside your AI assistant.`; const tools = safeDetectTools(projectPath); const installed = new Set(safeScanInstalledWorkflows(projectPath, tools)); + const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; + const entries = invocationEntries(tools, delivery, verb); if (installed.size === 0) { - return { - message, - details: [ - `Fix: run 'openspec init' to install the workflows, then invoke ${canonicalCommand(verb)} in your assistant.`, - ], - }; + // Nothing installed, but a tool may still be detected - a repo with a + // `.claude/` that has never run init is exactly this case. When one is, + // name the spelling that tool will answer to rather than the canonical + // form, so this branch cannot disagree with the other two about how the + // same tool spells the same workflow. + const setUp = "Fix: run 'openspec init' to install the workflows, then"; + if (entries.length > 1) { + return { + message, + details: [`${setUp} use it in your assistant:`, ...indent(entries)], + }; + } + const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false }; + return { message, details: [instruction(entry, setUp)] }; } - const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; - const entries = invocationEntries(tools, delivery, verb); - if (!installed.has(verb)) { const notInstalled = `The ${verb} workflow is not installed in this project.`; const addIt = "Fix: run 'openspec config profile' to add it, then"; diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts index 76aa4f0763..d5d43b459f 100644 --- a/test/core/workflow-verbs.test.ts +++ b/test/core/workflow-verbs.test.ts @@ -93,7 +93,7 @@ describe('workflow verbs typed at the CLI', () => { expect(guidance.message).toContain("'propose' is an OpenSpec workflow, not a CLI command"); expect(guidance.details).toEqual([ - "Fix: run 'openspec init' to install the workflows, then invoke /opsx:propose in your assistant.", + "Fix: run 'openspec init' to install the workflows, then run /opsx:propose in your assistant.", ]); }); @@ -110,7 +110,34 @@ describe('workflow verbs typed at the CLI', () => { const guidance = getWorkflowVerbGuidance('propose', projectDir); expect(guidance.details).toEqual([ - "Fix: run 'openspec init' to install the workflows, then invoke /opsx:propose in your assistant.", + "Fix: run 'openspec init' to install the workflows, then run /opsx:propose in your assistant.", + ]); + }); + + it("names the detected tool's own spelling in the init answer", async () => { + // Nothing is installed, but a tool can still be detected, so this branch + // must not fall back to the canonical form when it knows better. Amazon Q + // loads these into its prompt library, invoked with `@`, and the three + // branches are not allowed to disagree about how one tool spells one + // workflow. + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.amazonq'), { recursive: true }); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + "Fix: run 'openspec init' to install the workflows, then run @opsx-explore in your assistant.", + ]); + }); + + it('phrases the init answer as a request for a tool with no slash surface', async () => { + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.rovodev'), { recursive: true }); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + "Fix: run 'openspec init' to install the workflows, then ask Rovo Dev CLI to use the openspec-explore skill.", ]); }); From 030df59e6f993c2f63b1474768655d7297e799f7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 9 Sep 2026 07:34:12 -0500 Subject: [PATCH 5/6] fix(cli): attribute an installed workflow to the tool that holds it alfred-openspec on #1776: installation was collected as a union across every detected tool, then an invocation was emitted for every detected tool. A repo with .claude/commands/opsx/explore.md and a bare .github/ directory therefore advertised '/opsx-explore (GitHub Copilot)' beside the real Claude Code command, for a Copilot command that was never generated. Workflows are now scanned per tool. The union still decides whether OpenSpec has ever run here, which is what the init and profile branches ask; the installed branch names only the tools whose own scan holds this workflow. Three regressions: alfred's exact mixed installed/detected case, the other side of the filter (two holding tools are both still listed, the bare one is not), and a workflow held by no tool, which must stay the profile answer rather than becoming the update answer. Verified the first two fail against the union. Co-Authored-By: Claude Opus 5 --- src/core/workflow-verbs.ts | 45 +++++++++++++++++++++++++++----- test/core/workflow-verbs.test.ts | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts index 2a37dfc60b..6d64192f4c 100644 --- a/src/core/workflow-verbs.ts +++ b/src/core/workflow-verbs.ts @@ -144,8 +144,12 @@ function instruction(entry: InvocationEntry, lead: string): string { 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); - const installed = new Set(safeScanInstalledWorkflows(projectPath, tools)); + const installedByTool = installedWorkflowsByTool(projectPath, tools); + const installed = new Set([...installedByTool.values()].flatMap((ids) => [...ids])); const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; + // Every detected tool, for the two branches where this workflow is installed + // nowhere: there the question is what the tool will answer to once it is + // added, which every detected tool can answer. const entries = invocationEntries(tools, delivery, verb); if (installed.size === 0) { @@ -181,20 +185,28 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work return { message, details: [notInstalled, instruction(entry, addIt)] }; } - if (entries.length === 0) { - // Detected tools, but the delivery mode left none of them with an - // invocation to name. Stay syntax-neutral rather than invent one. + // Installed somewhere, so only the tools that actually hold it may be named. + // A tool detected from a bare directory has no artifact to invoke. + const installedEntries = invocationEntries( + tools.filter((tool) => installedByTool.get(tool.value)?.has(verb)), + delivery, + verb + ); + + if (installedEntries.length === 0) { + // The delivery mode left the holding tools with no 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 (entries.length === 1) { - return { message, details: [instruction(entries[0], 'Fix:')] }; + if (installedEntries.length === 1) { + return { message, details: [instruction(installedEntries[0], 'Fix:')] }; } return { message, - details: ['Fix: use it in your assistant:', ...indent(entries)], + details: ['Fix: use it in your assistant:', ...indent(installedEntries)], }; } @@ -216,6 +228,25 @@ function safeDetectTools(projectPath: string): AIToolOption[] { } } +/** + * Which workflows each detected tool actually holds. + * + * The union answers whether OpenSpec has ever run here; it cannot answer who + * to name. A repo with `.claude/commands/opsx/explore.md` and a bare + * `.github/` has GitHub Copilot detected and no Copilot artifacts, so an + * answer built from the union advertised `/opsx-explore (GitHub Copilot)`, a + * command that does not exist. Scanning per tool keeps an installed workflow + * attributed to the tool that holds it. + */ +function installedWorkflowsByTool( + projectPath: string, + tools: AIToolOption[] +): Map> { + return new Map( + tools.map((tool) => [tool.value, new Set(safeScanInstalledWorkflows(projectPath, [tool]))]) + ); +} + function safeScanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] { try { return scanInstalledWorkflows(projectPath, tools); diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts index d5d43b459f..62bafc8014 100644 --- a/test/core/workflow-verbs.test.ts +++ b/test/core/workflow-verbs.test.ts @@ -258,6 +258,51 @@ describe('workflow verbs typed at the CLI', () => { expect(guidance.details).toEqual(['Fix: run /opsx:explore in your assistant.']); }); + it('does not advertise a tool that has no artifact for this workflow', async () => { + // alfred-openspec's regression on #1776. Installation was collected as a + // union across every detected tool, so a bare `.github/` directory made + // the answer advertise `/opsx-explore (GitHub Copilot)` next to the real + // Claude Code command, for a Copilot command that was never generated. + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + await fs.mkdir(path.join(projectDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(projectDir, '.github', 'copilot-instructions.md'), '# Copilot\n'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual(['Fix: run /opsx:explore in your assistant.']); + }); + + it('still lists every tool that does hold the workflow', async () => { + // The other side of the same filter: attribution must not become + // exclusion. Two tools with the artifact are both named, and the third, + // detected from a bare directory, is not. + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + await installCommand(projectDir, path.join('.cursor', 'commands', 'opsx-explore.md')); + await fs.mkdir(path.join(projectDir, '.github'), { recursive: true }); + await fs.writeFile(path.join(projectDir, '.github', 'copilot-instructions.md'), '# Copilot\n'); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + 'Fix: use it in your assistant:', + ' /opsx:explore (Claude Code)', + ' /opsx-explore (Cursor)', + ]); + }); + + it('sends a tool with no artifact for this workflow to the profile picker', async () => { + // A workflow installed for no tool at all is still the profile answer, and + // the per-tool filter must not turn that into the update answer. + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + + const guidance = getWorkflowVerbGuidance('verify', projectDir); + + expect(guidance.details[0]).toBe('The verify workflow is not installed in this project.'); + }); + it('spells the invocation as a skill when delivery is skills-only', async () => { const projectDir = await makeProject(); await installSkill(projectDir, '.claude', 'openspec-explore'); From ef9892849bfb264a7e8d934abeeef1b8a959bdc0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 07:58:52 -0500 Subject: [PATCH 6/6] fix(cli): name a workflow invocation only when a tool answers to it - No detected tool (or none that gets files under the delivery): stop at the init instruction instead of inventing /opsx:. - Delivery drift (files on a surface the global delivery no longer uses) and workflows selected in the profile but not installed now route to openspec update, not a nonexistent invocation or config profile. - When update would leave the project's tools nothing (for example Kimi Code under delivery: commands), point at the delivery setting. - Mirror migrateIfNeeded when the global config has no profile, so a working install is not reported as drifted. Co-Authored-By: Claude Opus 5 --- .changeset/workflow-verbs-at-the-cli.md | 2 +- docs-lab/reference/cli.md | 7 +- src/core/command-surface.ts | 2 +- src/core/profile-sync-drift.ts | 6 +- src/core/workflow-verbs.ts | 241 +++++++++++++++++------- test/core/workflow-verbs.test.ts | 80 +++++++- 6 files changed, 260 insertions(+), 78 deletions(-) diff --git a/.changeset/workflow-verbs-at-the-cli.md b/.changeset/workflow-verbs-at-the-cli.md index abc62dcb0b..58f346a144 100644 --- a/.changeset/workflow-verbs-at-the-cli.md +++ b/.changeset/workflow-verbs-at-the-cli.md @@ -2,4 +2,4 @@ "@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. +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. diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index 4a8aa390f6..a64e1b5e71 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -71,9 +71,12 @@ Workflow names are not CLI commands. `openspec propose` prints how to invoke tha The answer is resolved for your project: -- No workflow files here yet: run `openspec init`. -- Workflows installed, but not this one: run `openspec config profile` to add it. +- 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. diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index 85262e967d..16841a7e4f 100644 --- a/src/core/command-surface.ts +++ b/src/core/command-surface.ts @@ -77,7 +77,7 @@ export interface WorkflowReference { * @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 + * 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( diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index b731780df6..852292b47a 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -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 } diff --git a/src/core/workflow-verbs.ts b/src/core/workflow-verbs.ts index 6d64192f4c..34f99e6930 100644 --- a/src/core/workflow-verbs.ts +++ b/src/core/workflow-verbs.ts @@ -3,7 +3,7 @@ * * 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 + * 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 @@ -11,21 +11,27 @@ * * 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 + * mirrors the treatment retired flags already get in the CLI: keep the name * reachable so it can explain itself instead of failing generically. */ +import * as fs from 'fs'; import type { AIToolOption } from './config.js'; import { getAvailableTools } from './available-tools.js'; -import { getGlobalConfig, type Delivery } from './global-config.js'; +import { getGlobalConfig, getGlobalConfigPath, type Delivery } from './global-config.js'; import { scanInstalledWorkflows } from './migration.js'; -import { ALL_WORKFLOWS } from './profiles.js'; -import { resolveWorkflowReference } from './command-surface.js'; +import { ALL_WORKFLOWS, getProfileWorkflows } from './profiles.js'; +import { + resolveWorkflowReference, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from './command-surface.js'; +import { getInstalledWorkflowsForTool } from './profile-sync-drift.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 + * are never rerouted to workflow guidance; the CLI command wins, as it * always has. */ const CLI_RESERVED_WORKFLOW_IDS = new Set(['new', 'update', 'archive']); @@ -118,25 +124,26 @@ function instruction(entry: InvocationEntry, lead: string): string { /** * Builds the answer for a workflow verb typed at the CLI, grounded in what is - * actually installed in this project. + * actually installed in this project and in what `init` or `update` would do + * with the global config. * - * Three cases, in order of what the user can act on: - * - No OpenSpec workflow artifacts at all: the project has never run `init`, - * so point at `init`. - * - Workflows installed, but not this one: the invocation exists only after it - * is added, so lead with the profile picker (#1076) and still name the - * spelling it will answer to. - * - Otherwise: name the invocation each detected tool answers to. + * In order of what the user can act on: + * - No OpenSpec workflow artifacts at all: point at `init` (after `config + * profile` when the profile leaves this workflow out). + * - Some tool holds the artifact its spelling names: name that invocation. + * - The profile leaves this workflow out: point at `config profile`. + * - The profile selects it but the files do not match (not installed yet, or + * installed on the surface a changed delivery no longer uses): point at + * `update`, or at the delivery setting when update would leave the tools + * nothing. + * + * An invocation is named only when a tool will answer to it. With no tool to + * resolve a spelling for, the answer stops after the setup instruction rather + * than guess one: `/opsx:` is Claude's spelling, not a universal one. * * The first case tests for installed artifacts, not for AI tool directories. * `getAvailableTools` reads a bare `.claude/` as Claude Code, which says the - * user has an assistant and nothing about whether OpenSpec has ever run here; - * branching on it sent a project that never ran `init` to `openspec config - * profile`, a command that cannot help until there is something to configure. - * - * The spelling comes from the tool and the delivery mode, never from whether - * the workflow happens to be installed - so the two installed/not-installed - * branches cannot disagree about how the same tool spells the same workflow. + * user has an assistant and nothing about whether OpenSpec has ever run here. * * @param verb - A workflow id from WORKFLOW_VERBS * @param projectPath - Directory to inspect for installed tools and workflows @@ -146,70 +153,176 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work const tools = safeDetectTools(projectPath); const installedByTool = installedWorkflowsByTool(projectPath, tools); const installed = new Set([...installedByTool.values()].flatMap((ids) => [...ids])); - const delivery: Delivery = getGlobalConfig().delivery ?? 'both'; - // Every detected tool, for the two branches where this workflow is installed - // nowhere: there the question is what the tool will answer to once it is - // added, which every detected tool can answer. - const entries = invocationEntries(tools, delivery, verb); + // Tools OpenSpec has written files for: the ones `update` acts on. + const configuredTools = tools.filter((tool) => (installedByTool.get(tool.value)?.size ?? 0) > 0); + const desired = resolveDesiredConfig(projectPath, configuredTools, installed); if (installed.size === 0) { - // Nothing installed, but a tool may still be detected - a repo with a - // `.claude/` that has never run init is exactly this case. When one is, - // name the spelling that tool will answer to rather than the canonical - // form, so this branch cannot disagree with the other two about how the - // same tool spells the same workflow. - const setUp = "Fix: run 'openspec init' to install the workflows, then"; - if (entries.length > 1) { + if (!desired.workflows.has(verb)) { return { message, - details: [`${setUp} use it in your assistant:`, ...indent(entries)], + details: [ + `The ${verb} workflow is not in your profile.`, + "Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.", + ], }; } - const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false }; - return { message, details: [instruction(entry, setUp)] }; + // init sets up the detected tools, so their spellings are the ones it + // will produce. + return withInvocations( + message, + [], + "Fix: run 'openspec init' to install the workflows", + invocationEntries(tools, desired.delivery, verb) + ); } - if (!installed.has(verb)) { - const notInstalled = `The ${verb} workflow is not installed in this project.`; - const addIt = "Fix: run 'openspec config profile' to add it, then"; - if (entries.length > 1) { - return { - message, - details: [notInstalled, `${addIt} use it in your assistant:`, ...indent(entries)], - }; - } - // One agreed spelling, or none at all. When no tool has one to offer, the - // canonical form is the only honest answer - and it is what the workflow - // will answer to once the profile installs it for a tool that invokes it. - const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false }; - return { message, details: [notInstalled, instruction(entry, addIt)] }; + // Only a tool that holds the file its spelling names can be told to use it. + // A tool detected from a bare directory, or one whose files predate a + // delivery change, has nothing that answers to that spelling yet. + const invocable = configuredTools.filter((tool) => + holdsInvocableArtifact(projectPath, tool, desired.delivery, verb) + ); + const invocableEntries = invocationEntries(invocable, desired.delivery, verb); + if (invocableEntries.length === 1) { + return { message, details: [instruction(invocableEntries[0], 'Fix:')] }; + } + if (invocableEntries.length > 1) { + return { + message, + details: ['Fix: use it in your assistant:', ...indent(invocableEntries)], + }; } - // Installed somewhere, so only the tools that actually hold it may be named. - // A tool detected from a bare directory has no artifact to invoke. - const installedEntries = invocationEntries( - tools.filter((tool) => installedByTool.get(tool.value)?.has(verb)), - delivery, - verb - ); + // What each configured tool will answer to once its files match the config. + const pendingEntries = invocationEntries(configuredTools, desired.delivery, verb); - if (installedEntries.length === 0) { - // The delivery mode left the holding tools with no invocation to name. - // Stay syntax-neutral rather than invent one. + if (!desired.workflows.has(verb)) { + return withInvocations( + message, + [`The ${verb} workflow is not installed in this project.`], + "Fix: run 'openspec config profile' to add it", + pendingEntries + ); + } + + if (pendingEntries.length === 0) { + // update would remove what these tools hold and generate nothing, then say + // to change delivery. Say that here instead of promising update helps. + const names = configuredTools.map((tool) => tool.name).join(', '); return { message, - details: [`Fix: run 'openspec update' to regenerate this project's workflow files.`], + details: [ + `Delivery is set to '${desired.delivery}', which gives ${names} no workflow files.`, + "Fix: run 'openspec config set delivery both', then 'openspec update'.", + ], }; } - if (installedEntries.length === 1) { - return { message, details: [instruction(installedEntries[0], 'Fix:')] }; + + return withInvocations( + message, + ['This project does not match your global OpenSpec config yet.'], + "Fix: run 'openspec update' to apply it", + pendingEntries + ); +} + +/** + * A setup instruction, followed by the invocation it leads to when there is + * one. With no entries the instruction stands alone. + */ +function withInvocations( + message: string, + preamble: string[], + lead: string, + entries: InvocationEntry[] +): WorkflowVerbGuidance { + if (entries.length === 0) { + return { message, details: [...preamble, `${lead}.`] }; + } + if (entries.length === 1) { + return { message, details: [...preamble, instruction(entries[0], `${lead}, then`)] }; } return { message, - details: ['Fix: use it in your assistant:', ...indent(installedEntries)], + details: [...preamble, `${lead}, then use it in your assistant:`, ...indent(entries)], }; } +/** + * Whether the tool holds the file its spelling under this delivery refers to: + * the command file when the tool gets commands, the skill otherwise. This is + * the same split `resolveWorkflowReference` spells from. + */ +function holdsInvocableArtifact( + projectPath: string, + tool: AIToolOption, + delivery: Delivery, + verb: string +): boolean { + const includeCommands = shouldGenerateCommandsForTool(tool.value, delivery); + const includeSkills = !includeCommands && shouldGenerateSkillsForTool(tool.value, delivery); + if (!includeCommands && !includeSkills) { + return false; + } + try { + return getInstalledWorkflowsForTool(projectPath, tool.value, { includeSkills, includeCommands }).includes( + verb as (typeof ALL_WORKFLOWS)[number] + ); + } catch { + // Unknown rather than absent, as in safeScanInstalledWorkflows. + return true; + } +} + +/** + * The profile and delivery `init` or `update` would apply here. + * + * Mirrors migrateIfNeeded without writing anything: a global config with no + * `profile` field is migrated on the next init/update to a custom profile of + * exactly the installed workflows, and, when `delivery` is also unset, to the + * delivery the installed files imply. Reading the defaulted config instead + * would call a working install drifted. + */ +function resolveDesiredConfig( + projectPath: string, + configuredTools: AIToolOption[], + installed: ReadonlySet +): { workflows: ReadonlySet; delivery: Delivery } { + const config = getGlobalConfig(); + const raw = readRawGlobalConfig(); + if (raw.profile !== undefined || installed.size === 0) { + return { + workflows: new Set(getProfileWorkflows(config.profile ?? 'core', config.workflows)), + delivery: config.delivery ?? 'both', + }; + } + let delivery: Delivery = config.delivery ?? 'both'; + if (raw.delivery === undefined) { + const holds = (surface: { includeSkills: boolean; includeCommands: boolean }) => + configuredTools.some((tool) => { + try { + return getInstalledWorkflowsForTool(projectPath, tool.value, surface).length > 0; + } catch { + return false; + } + }); + const hasSkills = holds({ includeSkills: true, includeCommands: false }); + const hasCommands = holds({ includeSkills: false, includeCommands: true }); + delivery = hasSkills && hasCommands ? 'both' : hasCommands ? 'commands' : 'skills'; + } + return { workflows: installed, delivery }; +} + +function readRawGlobalConfig(): Record { + try { + const configPath = getGlobalConfigPath(); + return fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {}; + } catch { + return {}; + } +} + function indent(entries: InvocationEntry[]): string[] { return entries.map((entry) => ` ${entry.text}`); } diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts index 62bafc8014..af8b3dd0af 100644 --- a/test/core/workflow-verbs.test.ts +++ b/test/core/workflow-verbs.test.ts @@ -86,14 +86,41 @@ describe('workflow verbs typed at the CLI', () => { } }); - it('points at init when the project has no OpenSpec tools', async () => { + it('points at init and invents no invocation when no tool is detected', async () => { + // alfred-openspec's regression on #1776. With no tool detected there is no + // spelling to report: after init, Amazon Q answers to `@opsx-*`, Copilot to + // `/opsx-*`, Rovo Dev to a request. `/opsx:propose` is only Claude's. const projectDir = await makeProject(); const guidance = getWorkflowVerbGuidance('propose', projectDir); expect(guidance.message).toContain("'propose' is an OpenSpec workflow, not a CLI command"); + expect(guidance.details).toEqual(["Fix: run 'openspec init' to install the workflows."]); + }); + + it('invents no invocation when the detected tool gets nothing under the delivery', async () => { + // Kimi Code has no command surface, so commands-only delivery would give it + // neither commands nor skills after init. + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.kimi-code'), { recursive: true }); + await writeGlobalConfig({ delivery: 'commands' }); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual(["Fix: run 'openspec init' to install the workflows."]); + }); + + it('sends init to the profile first when the profile leaves the workflow out', async () => { + // init installs the profile, and the core profile has no verify, so + // "init, then run /opsx:verify" would name a command init never writes. + const projectDir = await makeProject(); + await fs.mkdir(path.join(projectDir, '.claude'), { recursive: true }); + + const guidance = getWorkflowVerbGuidance('verify', projectDir); + expect(guidance.details).toEqual([ - "Fix: run 'openspec init' to install the workflows, then run /opsx:propose in your assistant.", + 'The verify workflow is not in your profile.', + "Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.", ]); }); @@ -156,7 +183,7 @@ describe('workflow verbs typed at the CLI', () => { it('points at the profile picker when the workflow is not installed', async () => { const projectDir = await makeProject(); - await installSkill(projectDir, '.claude', 'openspec-propose'); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.md')); const guidance = getWorkflowVerbGuidance('verify', projectDir); @@ -202,7 +229,7 @@ describe('workflow verbs typed at the CLI', () => { it("uses a tool's own prompt-library prefix", async () => { const projectDir = await makeProject(); // Amazon Q loads these files into its prompt library, invoked with `@`. - await installSkill(projectDir, '.amazonq', 'openspec-explore'); + await installCommand(projectDir, path.join('.amazonq', 'prompts', 'opsx-explore.md')); const guidance = getWorkflowVerbGuidance('explore', projectDir); @@ -235,23 +262,58 @@ describe('workflow verbs typed at the CLI', () => { ]); }); - it('sends the user to update when the delivery mode leaves a tool nothing to invoke', async () => { + it('sends the user to the delivery setting when update would leave a tool nothing', async () => { + // alfred-openspec's regression on #1776. Kimi Code has no command surface, + // so under commands-only delivery `openspec update` removes its skill and + // generates nothing, then says to set delivery to both. Promising that + // update regenerates the workflow was false. const projectDir = await makeProject(); - // Kimi Code has no command surface at all, so commands-only delivery - // generates neither commands nor skills for it. await installSkill(projectDir, '.kimi-code', 'openspec-explore'); await writeGlobalConfig({ delivery: 'commands' }); const guidance = getWorkflowVerbGuidance('explore', projectDir); expect(guidance.details).toEqual([ - "Fix: run 'openspec update' to regenerate this project's workflow files.", + "Delivery is set to 'commands', which gives Kimi Code no workflow files.", + "Fix: run 'openspec config set delivery both', then 'openspec update'.", + ]); + }); + + it('routes delivery drift to update instead of naming a file that does not exist', async () => { + // alfred-openspec's regression on #1776. Global delivery says skills, but + // the project still holds only the command file, so `/openspec-explore` + // does not exist yet. update is what makes it exist. + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); + await writeGlobalConfig({ delivery: 'skills' }); + + const guidance = getWorkflowVerbGuidance('explore', projectDir); + + expect(guidance.details).toEqual([ + 'This project does not match your global OpenSpec config yet.', + "Fix: run 'openspec update' to apply it, then run /openspec-explore in your assistant.", + ]); + }); + + it('sends a workflow the profile already selects to update, not the profile picker', async () => { + // alfred-openspec's regression on #1776. verify is already in the global + // profile; opening `config profile` again changes nothing. update installs it. + const projectDir = await makeProject(); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.md')); + await installSkill(projectDir, '.claude', 'openspec-propose'); + await writeGlobalConfig({ profile: 'custom', workflows: ['propose', 'verify'] }); + + const guidance = getWorkflowVerbGuidance('verify', projectDir); + + expect(guidance.details).toEqual([ + 'This project does not match your global OpenSpec config yet.', + "Fix: run 'openspec update' to apply it, then run /opsx:verify in your assistant.", ]); }); it("names the tool's own invocation when the workflow is installed", async () => { const projectDir = await makeProject(); - await installSkill(projectDir, '.claude', 'openspec-explore'); + await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md')); const guidance = getWorkflowVerbGuidance('explore', projectDir);