diff --git a/.changeset/workflow-verbs-at-the-cli.md b/.changeset/workflow-verbs-at-the-cli.md new file mode 100644 index 0000000000..58f346a144 --- /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`, `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 e9e4eb14fd..a64e1b5e71 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -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. diff --git a/src/cli/index.ts b/src/cli/index.ts index 75324cfa38..c82c2ec426 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,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 { diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts index 4162e92532..16841a7e4f 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 f8fa0773b5..b963eed26b 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, @@ -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(); 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/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 new file mode 100644 index 0000000000..34f99e6930 --- /dev/null +++ b/src/core/workflow-verbs.ts @@ -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(['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(); + 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:` 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 +): { 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}`); +} + +/** + * 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> { + 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]; + } +} diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index c05965ea72..75de3632a6 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -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); diff --git a/test/core/workflow-verbs.test.ts b/test/core/workflow-verbs.test.ts new file mode 100644 index 0000000000..af8b3dd0af --- /dev/null +++ b/test/core/workflow-verbs.test.ts @@ -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 = {}; + +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'); +} + +async function writeGlobalConfig(config: Record): 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 + // 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)', + ]); + }); +});