Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5 ef9892849b 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>
2026-09-15 07:58:52 -05:00
Clay Good 5eb9a2725c Merge remote-tracking branch 'origin/main' into harden-1776 2026-09-15 07:51:33 -05:00
Clay GoodandClaude Opus 5 030df59e6f 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 <noreply@anthropic.com>
2026-09-09 07:54:21 -05:00
Clay GoodandClaude Opus 5 ad6cc288be 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 <noreply@anthropic.com>
2026-09-07 12:22:03 -05:00
Clay GoodandClaude Opus 5 1a7355780b 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 <noreply@anthropic.com>
2026-09-07 11:27:17 -05:00
Clay GoodandClaude Opus 5 2dd2b53b4f 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 <noreply@anthropic.com>
2026-09-03 13:45:46 -05:00
Clay GoodandClaude Opus 5 719b6313e8 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 <noreply@anthropic.com>
2026-09-03 13:19:41 -05:00
9 changed files with 978 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---
Answer workflow verbs typed at the CLI with the invocation this project actually uses. `openspec propose`, `openspec explore`, `openspec apply` and the other workflow names no longer fail with a bare `unknown command`; they explain that workflows run inside the AI assistant and name the spelling each configured tool answers to, or point at `openspec init`, `openspec config profile`, or `openspec update` when the workflow is not installed or the project does not match the global config. Real CLI commands (`new`, `update`, `archive`) and genuinely unknown commands are unchanged.
+15
View File
@@ -65,6 +65,21 @@ Every command takes `-h, --help`. The bare `openspec` command also takes:
- `-V, --version`: print the CLI version.
- `--no-color`: disable colored output.
**Workflow names**
Workflow names are not CLI commands. `openspec propose` prints how to invoke that workflow and runs nothing, and the same holds for `explore`, `apply`, `sync`, and every other workflow name the CLI does not already use. `openspec new`, `openspec update`, and `openspec archive` are real commands and keep doing their own work.
The answer is resolved for your project:
- No workflow files here yet: run `openspec init`. If your profile leaves the workflow out, run `openspec config profile` first.
- Installed: the spelling each configured tool answers to, such as `/opsx:propose`, `/opsx-propose`, `@opsx-propose`, or `/openspec-propose`. A tool that matches skills by description gets a plain-language request instead.
- Not in your profile: run `openspec config profile` to add it.
- In your profile, but this project's files do not match your global config yet: run `openspec update`. If your delivery setting gives the project's tools no files, set delivery to `both` first.
A spelling is named only for a tool that will answer to it. When no tool is detected, the answer stops at the setup step.
When your tools spell it differently, every spelling is listed with the tools it serves.
## openspec init
Initializes OpenSpec in a project.
+40
View File
@@ -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,45 @@ newCmd
}
});
// Workflow verbs are not CLI commands - the workflows run inside the user's AI
// assistant. Registering them hidden replaces commander's bare "unknown
// command" with the invocation this project's tools actually answer to, so a
// user (or an agent) who types `openspec propose` is routed to the workflow
// instead of hand-building the artifacts (#1221). Same reasoning as the
// removed options kept registered above: a reachable name can explain itself.
for (const verb of WORKFLOW_VERBS) {
const verbCommand = program
.command(verb, { hidden: true })
// The verb is typed with whatever the user meant to pass the workflow
// ("openspec propose add auth --fast"); accept it all and explain, rather
// than answer a discovery question with an argument error.
.argument('[args...]')
.allowUnknownOption()
.allowExcessArguments()
// No help option: `--help` and `-h` would otherwise print a usage page for
// a command that does not do anything, which is a worse dead end than the
// unknown-command error this replaced. Dropping it lets both fall through
// to allowUnknownOption and reach the guidance.
.helpOption(false)
.action(() => {
const guidance = getWorkflowVerbGuidance(verb, process.cwd());
ora().fail(`Error: ${guidance.message}`);
for (const detail of guidance.details) {
console.error(detail);
}
// exitCode rather than exit(): parse() is synchronous, and exiting from
// inside the action would cut off the postAction hook and risk
// truncating this very output on a pipe.
process.exitCode = 1;
});
// `openspec help propose` routes through the command's own help output
// rather than its action, so give that path the same answer.
verbCommand.helpInformation = () => {
const guidance = getWorkflowVerbGuidance(verb, process.cwd());
return [guidance.message, ...guidance.details, ''].join('\n');
};
}
export { program };
export function runCli(argv = process.argv): void {
+64
View File
@@ -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;
}
+10 -19
View File
@@ -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,
@@ -71,6 +71,7 @@ import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptIn
import {
resolveCommandSurfaceCapability,
resolveCommandInvocation,
resolveWorkflowReference,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
shouldReconcileCommandFilesForTool,
@@ -1332,26 +1333,16 @@ export class InitCommand {
const startHintLines = (command: string): string[] => {
const hintToTools = new Map<string, string[]>();
for (const tool of successfulTools) {
let hint: string;
if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) {
const transformer = getTransformerForTool(
tool.value,
activeDelivery,
resolveCommandSurfaceCapability(tool.value),
resolveCommandInvocation(tool.value)
);
hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
} else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
const skillReference = getSkillReferenceTransformer(tool.value)(command);
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
// prose ("the openspec-propose skill"); phrase the hint so it reads
// as an instruction rather than a dead command with an argument.
hint = usesNaturalLanguageSkillReferences(tool.value)
? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
: `Start your first change: ${skillReference} "your idea"`;
} else {
const workflowReference = resolveWorkflowReference(tool.value, activeDelivery, command);
if (!workflowReference) {
continue;
}
// Tools with no slash surface (e.g. Rovo Dev) reference skills as
// prose ("the openspec-propose skill"); phrase the hint so it reads
// as an instruction rather than a dead command with an argument.
const hint = workflowReference.naturalLanguage
? `Start your first change: ask ${tool.name} to use ${workflowReference.reference} with "your idea"`
: `Start your first change: ${workflowReference.reference} "your idea"`;
hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]);
}
if (hintToTools.size === 0) {
+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 }
+373
View File
@@ -0,0 +1,373 @@
/**
* 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 * as fs from 'fs';
import type { AIToolOption } from './config.js';
import { getAvailableTools } from './available-tools.js';
import { getGlobalConfig, getGlobalConfigPath, type Delivery } from './global-config.js';
import { scanInstalledWorkflows } from './migration.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
* always has.
*/
const CLI_RESERVED_WORKFLOW_IDS = new Set<string>(['new', 'update', 'archive']);
/**
* The workflow ids reachable as bare CLI verbs. Every workflow whose name is
* not already a CLI command; see CLI_RESERVED_WORKFLOW_IDS for the ones that
* are.
*/
export const WORKFLOW_VERBS: readonly string[] = ALL_WORKFLOWS.filter(
(workflowId) => !CLI_RESERVED_WORKFLOW_IDS.has(workflowId)
);
/**
* The canonical reference every generated file is authored with. Per-tool
* spellings are rewritten from this form.
*/
function canonicalCommand(verb: string): string {
return `/opsx:${verb}`;
}
export interface WorkflowVerbGuidance {
/** The headline: what went wrong, in one sentence. */
message: string;
/** Supporting lines, already ordered; may be empty. */
details: string[];
}
/**
* 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 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 invocationEntries(
tools: AIToolOption[],
delivery: Delivery,
verb: string
): InvocationEntry[] {
const textToTools = new Map<string, { toolNames: string[]; naturalLanguage: boolean }>();
for (const tool of tools) {
const workflowReference = resolveWorkflowReference(tool.value, delivery, canonicalCommand(verb));
if (!workflowReference) {
continue;
}
const text = workflowReference.naturalLanguage
? `ask ${tool.name} to use ${workflowReference.reference}`
: workflowReference.reference;
const existing = textToTools.get(text);
textToTools.set(text, {
toolNames: [...(existing?.toolNames ?? []), tool.name],
naturalLanguage: workflowReference.naturalLanguage,
});
}
const entries = [...textToTools.entries()];
if (entries.length === 1) {
const [text, { naturalLanguage }] = entries[0];
return [{ text, naturalLanguage }];
}
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.`;
}
/**
* Builds the answer for a workflow verb typed at the CLI, grounded in what is
* actually installed in this project and in what `init` or `update` would do
* with the global config.
*
* 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.
*
* @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);
const installedByTool = installedWorkflowsByTool(projectPath, tools);
const installed = new Set([...installedByTool.values()].flatMap((ids) => [...ids]));
// 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) {
if (!desired.workflows.has(verb)) {
return {
message,
details: [
`The ${verb} workflow is not in your profile.`,
"Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.",
],
};
}
// 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)
);
}
// 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)],
};
}
// 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: [...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}`);
}
/**
* 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 [];
}
}
/**
* 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<string, ReadonlySet<string>> {
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);
} 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, or to `init` over a project that already has one, over
// an unreadable directory.
return [...ALL_WORKFLOWS];
}
}
+73
View File
@@ -41,6 +41,79 @@ 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('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']);
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);
+393
View File
@@ -0,0 +1,393 @@
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<string, string | undefined> = {};
async function makeProject(): Promise<string> {
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<void> {
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<void> {
const target = path.join(projectDir, filePath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, '# command\n');
}
async function writeGlobalConfig(config: Record<string, unknown>): Promise<void> {
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
// 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 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([
'The verify workflow is not in your profile.',
"Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.",
]);
});
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 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.",
]);
});
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 installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.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 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 installCommand(projectDir, path.join('.amazonq', 'prompts', 'opsx-explore.md'));
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 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();
await installSkill(projectDir, '.kimi-code', 'openspec-explore');
await writeGlobalConfig({ delivery: 'commands' });
const guidance = getWorkflowVerbGuidance('explore', projectDir);
expect(guidance.details).toEqual([
"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 installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md'));
const guidance = getWorkflowVerbGuidance('explore', projectDir);
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');
await writeGlobalConfig({ 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: use it in your assistant:');
expect(guidance.details.slice(1)).toEqual([
' /opsx:explore (Claude Code)',
' /opsx-explore (GitHub Copilot)',
]);
});
});