mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9892849b | ||
|
|
5eb9a2725c | ||
|
|
030df59e6f | ||
|
|
ad6cc288be | ||
|
|
1a7355780b | ||
|
|
2dd2b53b4f | ||
|
|
719b6313e8 |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user