mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
fix(cli): name a workflow invocation only when a tool answers to it
- No detected tool (or none that gets files under the delivery): stop at the init instruction instead of inventing /opsx:<verb>. - Delivery drift (files on a surface the global delivery no longer uses) and workflows selected in the profile but not installed now route to openspec update, not a nonexistent invocation or config profile. - When update would leave the project's tools nothing (for example Kimi Code under delivery: commands), point at the delivery setting. - Mirror migrateIfNeeded when the global config has no profile, so a working install is not reported as drifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5eb9a2725c
commit
ef9892849b
@@ -2,4 +2,4 @@
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
Answer workflow verbs typed at the CLI with the invocation this project actually uses. `openspec propose`, `openspec explore`, `openspec apply` and the other workflow names no longer fail with a bare `unknown command`; they explain that workflows run inside the AI assistant and name the spelling each configured tool answers to, or point at `openspec init` or `openspec config profile` when the workflow is not installed. Real CLI commands (`new`, `update`, `archive`) and genuinely unknown commands are unchanged.
|
||||
Answer workflow verbs typed at the CLI with the invocation this project actually uses. `openspec propose`, `openspec explore`, `openspec apply` and the other workflow names no longer fail with a bare `unknown command`; they explain that workflows run inside the AI assistant and name the spelling each configured tool answers to, or point at `openspec init`, `openspec config profile`, or `openspec update` when the workflow is not installed or the project does not match the global config. Real CLI commands (`new`, `update`, `archive`) and genuinely unknown commands are unchanged.
|
||||
|
||||
@@ -71,9 +71,12 @@ Workflow names are not CLI commands. `openspec propose` prints how to invoke tha
|
||||
|
||||
The answer is resolved for your project:
|
||||
|
||||
- No workflow files here yet: run `openspec init`.
|
||||
- Workflows installed, but not this one: run `openspec config profile` to add it.
|
||||
- No workflow files here yet: run `openspec init`. If your profile leaves the workflow out, run `openspec config profile` first.
|
||||
- Installed: the spelling each configured tool answers to, such as `/opsx:propose`, `/opsx-propose`, `@opsx-propose`, or `/openspec-propose`. A tool that matches skills by description gets a plain-language request instead.
|
||||
- Not in your profile: run `openspec config profile` to add it.
|
||||
- In your profile, but this project's files do not match your global config yet: run `openspec update`. If your delivery setting gives the project's tools no files, set delivery to `both` first.
|
||||
|
||||
A spelling is named only for a tool that will answer to it. When no tool is detected, the answer stops at the setup step.
|
||||
|
||||
When your tools spell it differently, every spelling is listed with the tools it serves.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface WorkflowReference {
|
||||
* @param delivery - The effective delivery mode
|
||||
* @param canonicalCommand - The canonical reference to rewrite, e.g. `/opsx:propose`
|
||||
* @returns The tool's spelling, or undefined when the delivery mode leaves
|
||||
* that tool with neither commands nor skills — it has nothing to
|
||||
* that tool with neither commands nor skills: it has nothing to
|
||||
* point at, so callers must not invent an invocation for it.
|
||||
*/
|
||||
export function resolveWorkflowReference(
|
||||
|
||||
@@ -190,7 +190,11 @@ export function getToolsNeedingProfileSync(
|
||||
);
|
||||
}
|
||||
|
||||
function getInstalledWorkflowsForTool(
|
||||
/**
|
||||
* Workflows one tool holds on the requested surfaces (skill files, command
|
||||
* files, or both).
|
||||
*/
|
||||
export function getInstalledWorkflowsForTool(
|
||||
projectPath: string,
|
||||
toolId: string,
|
||||
options: { includeSkills: boolean; includeCommands: boolean }
|
||||
|
||||
+179
-66
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* OpenSpec's workflows (`propose`, `explore`, `apply`, ...) run inside the
|
||||
* user's AI assistant, not in the terminal. Users and agents nonetheless say
|
||||
* and type "openspec propose" — it is the natural way to name the thing — and
|
||||
* and type "openspec propose" (it is the natural way to name the thing), and
|
||||
* the bare `error: unknown command 'propose'` that came back taught them
|
||||
* nothing. Agents in particular read that failure as permission to hand-build
|
||||
* the artifacts with `openspec new change` plus manual file writes, bypassing
|
||||
@@ -11,21 +11,27 @@
|
||||
*
|
||||
* So the verbs are registered as hidden commands whose whole job is to answer
|
||||
* the question: this is a workflow, here is how *your* tools invoke it. That
|
||||
* mirrors the treatment retired flags already get in the CLI — keep the name
|
||||
* mirrors the treatment retired flags already get in the CLI: keep the name
|
||||
* reachable so it can explain itself instead of failing generically.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { AIToolOption } from './config.js';
|
||||
import { getAvailableTools } from './available-tools.js';
|
||||
import { getGlobalConfig, type Delivery } from './global-config.js';
|
||||
import { getGlobalConfig, getGlobalConfigPath, type Delivery } from './global-config.js';
|
||||
import { scanInstalledWorkflows } from './migration.js';
|
||||
import { ALL_WORKFLOWS } from './profiles.js';
|
||||
import { resolveWorkflowReference } from './command-surface.js';
|
||||
import { ALL_WORKFLOWS, getProfileWorkflows } from './profiles.js';
|
||||
import {
|
||||
resolveWorkflowReference,
|
||||
shouldGenerateCommandsForTool,
|
||||
shouldGenerateSkillsForTool,
|
||||
} from './command-surface.js';
|
||||
import { getInstalledWorkflowsForTool } from './profile-sync-drift.js';
|
||||
|
||||
/**
|
||||
* Workflow ids that the CLI already uses for real commands. `openspec new`,
|
||||
* `openspec update`, and `openspec archive` do their own work, so those names
|
||||
* are never rerouted to workflow guidance — the CLI command wins, as it
|
||||
* are never rerouted to workflow guidance; the CLI command wins, as it
|
||||
* always has.
|
||||
*/
|
||||
const CLI_RESERVED_WORKFLOW_IDS = new Set<string>(['new', 'update', 'archive']);
|
||||
@@ -118,25 +124,26 @@ function instruction(entry: InvocationEntry, lead: string): string {
|
||||
|
||||
/**
|
||||
* Builds the answer for a workflow verb typed at the CLI, grounded in what is
|
||||
* actually installed in this project.
|
||||
* actually installed in this project and in what `init` or `update` would do
|
||||
* with the global config.
|
||||
*
|
||||
* Three cases, in order of what the user can act on:
|
||||
* - No OpenSpec workflow artifacts at all: the project has never run `init`,
|
||||
* so point at `init`.
|
||||
* - Workflows installed, but not this one: the invocation exists only after it
|
||||
* is added, so lead with the profile picker (#1076) and still name the
|
||||
* spelling it will answer to.
|
||||
* - Otherwise: name the invocation each detected tool answers to.
|
||||
* In order of what the user can act on:
|
||||
* - No OpenSpec workflow artifacts at all: point at `init` (after `config
|
||||
* profile` when the profile leaves this workflow out).
|
||||
* - Some tool holds the artifact its spelling names: name that invocation.
|
||||
* - The profile leaves this workflow out: point at `config profile`.
|
||||
* - The profile selects it but the files do not match (not installed yet, or
|
||||
* installed on the surface a changed delivery no longer uses): point at
|
||||
* `update`, or at the delivery setting when update would leave the tools
|
||||
* nothing.
|
||||
*
|
||||
* An invocation is named only when a tool will answer to it. With no tool to
|
||||
* resolve a spelling for, the answer stops after the setup instruction rather
|
||||
* than guess one: `/opsx:<verb>` is Claude's spelling, not a universal one.
|
||||
*
|
||||
* The first case tests for installed artifacts, not for AI tool directories.
|
||||
* `getAvailableTools` reads a bare `.claude/` as Claude Code, which says the
|
||||
* user has an assistant and nothing about whether OpenSpec has ever run here;
|
||||
* branching on it sent a project that never ran `init` to `openspec config
|
||||
* profile`, a command that cannot help until there is something to configure.
|
||||
*
|
||||
* The spelling comes from the tool and the delivery mode, never from whether
|
||||
* the workflow happens to be installed - so the two installed/not-installed
|
||||
* branches cannot disagree about how the same tool spells the same workflow.
|
||||
* user has an assistant and nothing about whether OpenSpec has ever run here.
|
||||
*
|
||||
* @param verb - A workflow id from WORKFLOW_VERBS
|
||||
* @param projectPath - Directory to inspect for installed tools and workflows
|
||||
@@ -146,70 +153,176 @@ export function getWorkflowVerbGuidance(verb: string, projectPath: string): Work
|
||||
const tools = safeDetectTools(projectPath);
|
||||
const installedByTool = installedWorkflowsByTool(projectPath, tools);
|
||||
const installed = new Set([...installedByTool.values()].flatMap((ids) => [...ids]));
|
||||
const delivery: Delivery = getGlobalConfig().delivery ?? 'both';
|
||||
// Every detected tool, for the two branches where this workflow is installed
|
||||
// nowhere: there the question is what the tool will answer to once it is
|
||||
// added, which every detected tool can answer.
|
||||
const entries = invocationEntries(tools, delivery, verb);
|
||||
// Tools OpenSpec has written files for: the ones `update` acts on.
|
||||
const configuredTools = tools.filter((tool) => (installedByTool.get(tool.value)?.size ?? 0) > 0);
|
||||
const desired = resolveDesiredConfig(projectPath, configuredTools, installed);
|
||||
|
||||
if (installed.size === 0) {
|
||||
// Nothing installed, but a tool may still be detected - a repo with a
|
||||
// `.claude/` that has never run init is exactly this case. When one is,
|
||||
// name the spelling that tool will answer to rather than the canonical
|
||||
// form, so this branch cannot disagree with the other two about how the
|
||||
// same tool spells the same workflow.
|
||||
const setUp = "Fix: run 'openspec init' to install the workflows, then";
|
||||
if (entries.length > 1) {
|
||||
if (!desired.workflows.has(verb)) {
|
||||
return {
|
||||
message,
|
||||
details: [`${setUp} use it in your assistant:`, ...indent(entries)],
|
||||
details: [
|
||||
`The ${verb} workflow is not in your profile.`,
|
||||
"Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.",
|
||||
],
|
||||
};
|
||||
}
|
||||
const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false };
|
||||
return { message, details: [instruction(entry, setUp)] };
|
||||
// init sets up the detected tools, so their spellings are the ones it
|
||||
// will produce.
|
||||
return withInvocations(
|
||||
message,
|
||||
[],
|
||||
"Fix: run 'openspec init' to install the workflows",
|
||||
invocationEntries(tools, desired.delivery, verb)
|
||||
);
|
||||
}
|
||||
|
||||
if (!installed.has(verb)) {
|
||||
const notInstalled = `The ${verb} workflow is not installed in this project.`;
|
||||
const addIt = "Fix: run 'openspec config profile' to add it, then";
|
||||
if (entries.length > 1) {
|
||||
return {
|
||||
message,
|
||||
details: [notInstalled, `${addIt} use it in your assistant:`, ...indent(entries)],
|
||||
};
|
||||
}
|
||||
// One agreed spelling, or none at all. When no tool has one to offer, the
|
||||
// canonical form is the only honest answer - and it is what the workflow
|
||||
// will answer to once the profile installs it for a tool that invokes it.
|
||||
const entry = entries[0] ?? { text: canonicalCommand(verb), naturalLanguage: false };
|
||||
return { message, details: [notInstalled, instruction(entry, addIt)] };
|
||||
}
|
||||
|
||||
// Installed somewhere, so only the tools that actually hold it may be named.
|
||||
// A tool detected from a bare directory has no artifact to invoke.
|
||||
const installedEntries = invocationEntries(
|
||||
tools.filter((tool) => installedByTool.get(tool.value)?.has(verb)),
|
||||
delivery,
|
||||
verb
|
||||
// Only a tool that holds the file its spelling names can be told to use it.
|
||||
// A tool detected from a bare directory, or one whose files predate a
|
||||
// delivery change, has nothing that answers to that spelling yet.
|
||||
const invocable = configuredTools.filter((tool) =>
|
||||
holdsInvocableArtifact(projectPath, tool, desired.delivery, verb)
|
||||
);
|
||||
|
||||
if (installedEntries.length === 0) {
|
||||
// The delivery mode left the holding tools with no invocation to name.
|
||||
// Stay syntax-neutral rather than invent one.
|
||||
const invocableEntries = invocationEntries(invocable, desired.delivery, verb);
|
||||
if (invocableEntries.length === 1) {
|
||||
return { message, details: [instruction(invocableEntries[0], 'Fix:')] };
|
||||
}
|
||||
if (invocableEntries.length > 1) {
|
||||
return {
|
||||
message,
|
||||
details: [`Fix: run 'openspec update' to regenerate this project's workflow files.`],
|
||||
details: ['Fix: use it in your assistant:', ...indent(invocableEntries)],
|
||||
};
|
||||
}
|
||||
if (installedEntries.length === 1) {
|
||||
return { message, details: [instruction(installedEntries[0], 'Fix:')] };
|
||||
|
||||
// What each configured tool will answer to once its files match the config.
|
||||
const pendingEntries = invocationEntries(configuredTools, desired.delivery, verb);
|
||||
|
||||
if (!desired.workflows.has(verb)) {
|
||||
return withInvocations(
|
||||
message,
|
||||
[`The ${verb} workflow is not installed in this project.`],
|
||||
"Fix: run 'openspec config profile' to add it",
|
||||
pendingEntries
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingEntries.length === 0) {
|
||||
// update would remove what these tools hold and generate nothing, then say
|
||||
// to change delivery. Say that here instead of promising update helps.
|
||||
const names = configuredTools.map((tool) => tool.name).join(', ');
|
||||
return {
|
||||
message,
|
||||
details: [
|
||||
`Delivery is set to '${desired.delivery}', which gives ${names} no workflow files.`,
|
||||
"Fix: run 'openspec config set delivery both', then 'openspec update'.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return withInvocations(
|
||||
message,
|
||||
['This project does not match your global OpenSpec config yet.'],
|
||||
"Fix: run 'openspec update' to apply it",
|
||||
pendingEntries
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A setup instruction, followed by the invocation it leads to when there is
|
||||
* one. With no entries the instruction stands alone.
|
||||
*/
|
||||
function withInvocations(
|
||||
message: string,
|
||||
preamble: string[],
|
||||
lead: string,
|
||||
entries: InvocationEntry[]
|
||||
): WorkflowVerbGuidance {
|
||||
if (entries.length === 0) {
|
||||
return { message, details: [...preamble, `${lead}.`] };
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return { message, details: [...preamble, instruction(entries[0], `${lead}, then`)] };
|
||||
}
|
||||
return {
|
||||
message,
|
||||
details: ['Fix: use it in your assistant:', ...indent(installedEntries)],
|
||||
details: [...preamble, `${lead}, then use it in your assistant:`, ...indent(entries)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tool holds the file its spelling under this delivery refers to:
|
||||
* the command file when the tool gets commands, the skill otherwise. This is
|
||||
* the same split `resolveWorkflowReference` spells from.
|
||||
*/
|
||||
function holdsInvocableArtifact(
|
||||
projectPath: string,
|
||||
tool: AIToolOption,
|
||||
delivery: Delivery,
|
||||
verb: string
|
||||
): boolean {
|
||||
const includeCommands = shouldGenerateCommandsForTool(tool.value, delivery);
|
||||
const includeSkills = !includeCommands && shouldGenerateSkillsForTool(tool.value, delivery);
|
||||
if (!includeCommands && !includeSkills) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return getInstalledWorkflowsForTool(projectPath, tool.value, { includeSkills, includeCommands }).includes(
|
||||
verb as (typeof ALL_WORKFLOWS)[number]
|
||||
);
|
||||
} catch {
|
||||
// Unknown rather than absent, as in safeScanInstalledWorkflows.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile and delivery `init` or `update` would apply here.
|
||||
*
|
||||
* Mirrors migrateIfNeeded without writing anything: a global config with no
|
||||
* `profile` field is migrated on the next init/update to a custom profile of
|
||||
* exactly the installed workflows, and, when `delivery` is also unset, to the
|
||||
* delivery the installed files imply. Reading the defaulted config instead
|
||||
* would call a working install drifted.
|
||||
*/
|
||||
function resolveDesiredConfig(
|
||||
projectPath: string,
|
||||
configuredTools: AIToolOption[],
|
||||
installed: ReadonlySet<string>
|
||||
): { workflows: ReadonlySet<string>; delivery: Delivery } {
|
||||
const config = getGlobalConfig();
|
||||
const raw = readRawGlobalConfig();
|
||||
if (raw.profile !== undefined || installed.size === 0) {
|
||||
return {
|
||||
workflows: new Set(getProfileWorkflows(config.profile ?? 'core', config.workflows)),
|
||||
delivery: config.delivery ?? 'both',
|
||||
};
|
||||
}
|
||||
let delivery: Delivery = config.delivery ?? 'both';
|
||||
if (raw.delivery === undefined) {
|
||||
const holds = (surface: { includeSkills: boolean; includeCommands: boolean }) =>
|
||||
configuredTools.some((tool) => {
|
||||
try {
|
||||
return getInstalledWorkflowsForTool(projectPath, tool.value, surface).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const hasSkills = holds({ includeSkills: true, includeCommands: false });
|
||||
const hasCommands = holds({ includeSkills: false, includeCommands: true });
|
||||
delivery = hasSkills && hasCommands ? 'both' : hasCommands ? 'commands' : 'skills';
|
||||
}
|
||||
return { workflows: installed, delivery };
|
||||
}
|
||||
|
||||
function readRawGlobalConfig(): Record<string, unknown> {
|
||||
try {
|
||||
const configPath = getGlobalConfigPath();
|
||||
return fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function indent(entries: InvocationEntry[]): string[] {
|
||||
return entries.map((entry) => ` ${entry.text}`);
|
||||
}
|
||||
|
||||
@@ -86,14 +86,41 @@ describe('workflow verbs typed at the CLI', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('points at init when the project has no OpenSpec tools', async () => {
|
||||
it('points at init and invents no invocation when no tool is detected', async () => {
|
||||
// alfred-openspec's regression on #1776. With no tool detected there is no
|
||||
// spelling to report: after init, Amazon Q answers to `@opsx-*`, Copilot to
|
||||
// `/opsx-*`, Rovo Dev to a request. `/opsx:propose` is only Claude's.
|
||||
const projectDir = await makeProject();
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('propose', projectDir);
|
||||
|
||||
expect(guidance.message).toContain("'propose' is an OpenSpec workflow, not a CLI command");
|
||||
expect(guidance.details).toEqual(["Fix: run 'openspec init' to install the workflows."]);
|
||||
});
|
||||
|
||||
it('invents no invocation when the detected tool gets nothing under the delivery', async () => {
|
||||
// Kimi Code has no command surface, so commands-only delivery would give it
|
||||
// neither commands nor skills after init.
|
||||
const projectDir = await makeProject();
|
||||
await fs.mkdir(path.join(projectDir, '.kimi-code'), { recursive: true });
|
||||
await writeGlobalConfig({ delivery: 'commands' });
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('explore', projectDir);
|
||||
|
||||
expect(guidance.details).toEqual(["Fix: run 'openspec init' to install the workflows."]);
|
||||
});
|
||||
|
||||
it('sends init to the profile first when the profile leaves the workflow out', async () => {
|
||||
// init installs the profile, and the core profile has no verify, so
|
||||
// "init, then run /opsx:verify" would name a command init never writes.
|
||||
const projectDir = await makeProject();
|
||||
await fs.mkdir(path.join(projectDir, '.claude'), { recursive: true });
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('verify', projectDir);
|
||||
|
||||
expect(guidance.details).toEqual([
|
||||
"Fix: run 'openspec init' to install the workflows, then run /opsx:propose in your assistant.",
|
||||
'The verify workflow is not in your profile.',
|
||||
"Fix: run 'openspec config profile' to add it, then 'openspec init' to install it.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -156,7 +183,7 @@ describe('workflow verbs typed at the CLI', () => {
|
||||
|
||||
it('points at the profile picker when the workflow is not installed', async () => {
|
||||
const projectDir = await makeProject();
|
||||
await installSkill(projectDir, '.claude', 'openspec-propose');
|
||||
await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.md'));
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('verify', projectDir);
|
||||
|
||||
@@ -202,7 +229,7 @@ describe('workflow verbs typed at the CLI', () => {
|
||||
it("uses a tool's own prompt-library prefix", async () => {
|
||||
const projectDir = await makeProject();
|
||||
// Amazon Q loads these files into its prompt library, invoked with `@`.
|
||||
await installSkill(projectDir, '.amazonq', 'openspec-explore');
|
||||
await installCommand(projectDir, path.join('.amazonq', 'prompts', 'opsx-explore.md'));
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('explore', projectDir);
|
||||
|
||||
@@ -235,23 +262,58 @@ describe('workflow verbs typed at the CLI', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the user to update when the delivery mode leaves a tool nothing to invoke', async () => {
|
||||
it('sends the user to the delivery setting when update would leave a tool nothing', async () => {
|
||||
// alfred-openspec's regression on #1776. Kimi Code has no command surface,
|
||||
// so under commands-only delivery `openspec update` removes its skill and
|
||||
// generates nothing, then says to set delivery to both. Promising that
|
||||
// update regenerates the workflow was false.
|
||||
const projectDir = await makeProject();
|
||||
// Kimi Code has no command surface at all, so commands-only delivery
|
||||
// generates neither commands nor skills for it.
|
||||
await installSkill(projectDir, '.kimi-code', 'openspec-explore');
|
||||
await writeGlobalConfig({ delivery: 'commands' });
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('explore', projectDir);
|
||||
|
||||
expect(guidance.details).toEqual([
|
||||
"Fix: run 'openspec update' to regenerate this project's workflow files.",
|
||||
"Delivery is set to 'commands', which gives Kimi Code no workflow files.",
|
||||
"Fix: run 'openspec config set delivery both', then 'openspec update'.",
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes delivery drift to update instead of naming a file that does not exist', async () => {
|
||||
// alfred-openspec's regression on #1776. Global delivery says skills, but
|
||||
// the project still holds only the command file, so `/openspec-explore`
|
||||
// does not exist yet. update is what makes it exist.
|
||||
const projectDir = await makeProject();
|
||||
await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md'));
|
||||
await writeGlobalConfig({ delivery: 'skills' });
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('explore', projectDir);
|
||||
|
||||
expect(guidance.details).toEqual([
|
||||
'This project does not match your global OpenSpec config yet.',
|
||||
"Fix: run 'openspec update' to apply it, then run /openspec-explore in your assistant.",
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends a workflow the profile already selects to update, not the profile picker', async () => {
|
||||
// alfred-openspec's regression on #1776. verify is already in the global
|
||||
// profile; opening `config profile` again changes nothing. update installs it.
|
||||
const projectDir = await makeProject();
|
||||
await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'propose.md'));
|
||||
await installSkill(projectDir, '.claude', 'openspec-propose');
|
||||
await writeGlobalConfig({ profile: 'custom', workflows: ['propose', 'verify'] });
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('verify', projectDir);
|
||||
|
||||
expect(guidance.details).toEqual([
|
||||
'This project does not match your global OpenSpec config yet.',
|
||||
"Fix: run 'openspec update' to apply it, then run /opsx:verify in your assistant.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the tool's own invocation when the workflow is installed", async () => {
|
||||
const projectDir = await makeProject();
|
||||
await installSkill(projectDir, '.claude', 'openspec-explore');
|
||||
await installCommand(projectDir, path.join('.claude', 'commands', 'opsx', 'explore.md'));
|
||||
|
||||
const guidance = getWorkflowVerbGuidance('explore', projectDir);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user