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:<verb>.
- 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 <noreply@anthropic.com>
This commit is contained in:
Clay Good
2026-09-15 07:58:52 -05:00
co-authored by Claude Opus 5
parent 5eb9a2725c
commit ef9892849b
6 changed files with 262 additions and 80 deletions
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -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.
+1 -1
View File
@@ -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(
+5 -1
View File
@@ -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 }
+179 -66
View File
@@ -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<string>(['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:<verb>` 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)] };
}
// 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
// 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)
);
if (installedEntries.length === 0) {
// The delivery mode left the holding tools with no invocation to name.
// Stay syntax-neutral rather than invent one.
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: run 'openspec update' to regenerate this project's workflow files.`],
details: ['Fix: use it in your assistant:', ...indent(invocableEntries)],
};
}
if (installedEntries.length === 1) {
return { message, details: [instruction(installedEntries[0], 'Fix:')] };
// What each configured tool will answer to once its files match the config.
const pendingEntries = invocationEntries(configuredTools, desired.delivery, verb);
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: [
`Delivery is set to '${desired.delivery}', which gives ${names} no workflow files.`,
"Fix: run 'openspec config set delivery both', then 'openspec update'.",
],
};
}
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<string>
): { workflows: ReadonlySet<string>; 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<string, unknown> {
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}`);
}
+71 -9
View File
@@ -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);