diff --git a/.changeset/adapterless-skill-references.md b/.changeset/adapterless-skill-references.md new file mode 100644 index 000000000..bffe78285 --- /dev/null +++ b/.changeset/adapterless-skill-references.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +Generated skills for tools without a command adapter (Kimi Code, Mistral Vibe, Hermes, ForgeCode, CodeArts) no longer reference `/opsx:*` commands that were never generated: skill cross-references, the init getting-started hint, and the profile-migration message now use each tool's documented skill invocation (Kimi Code: `/skill:openspec-*`; others: `/openspec-*`), and Codex — skills-invocable with no slash surface — gets a syntax-neutral hint that names the skill. Selections that mix invocation syntaxes print one labeled hint per distinct form, so every advertised instruction is usable by the tool it names. When `delivery: commands` would generate nothing for a selected tool, init prints a configuration correction naming that tool, even when other tools did get commands or skills. The committed skills.sh distribution is regenerated with skill references (default `/openspec-*` form, as that channel installs skills only). diff --git a/scripts/generate-skillssh.mjs b/scripts/generate-skillssh.mjs index 2ef87988c..c68137f92 100644 --- a/scripts/generate-skillssh.mjs +++ b/scripts/generate-skillssh.mjs @@ -19,6 +19,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getSkillTemplates, generateSkillContent } from '../dist/core/shared/skill-generation.js'; +import { transformToSkillReferences } from '../dist/utils/command-references.js'; import { cleanSkillSubdirectories, prepareSkillDirectory, @@ -33,7 +34,11 @@ cleanSkillSubdirectories(outDir); let count = 0; for (const { template, dirName } of getSkillTemplates()) { - const content = stripVolatileFrontmatter(generateSkillContent(template, 'skills.sh')); + // skills.sh installs SKILL.md files only — no /opsx:* commands exist in + // that channel, so references must point at the skills themselves. + const content = stripVolatileFrontmatter( + generateSkillContent(template, 'skills.sh', transformToSkillReferences) + ); const skillDir = prepareSkillDirectory(outDir, dirName); writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf8'); count++; diff --git a/skills/openspec-apply-change/SKILL.md b/skills/openspec-apply-change/SKILL.md index c62317063..49f3b4bd2 100644 --- a/skills/openspec-apply-change/SKILL.md +++ b/skills/openspec-apply-change/SKILL.md @@ -24,7 +24,7 @@ Implement tasks from an OpenSpec change. - Auto-select if only one active change exists - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select - Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + Always announce: "Using change: " and how to override (e.g., `/openspec-apply-change `). 2. **Check status to understand the schema** ```bash diff --git a/skills/openspec-explore/SKILL.md b/skills/openspec-explore/SKILL.md index 2aacb3902..08cea1046 100644 --- a/skills/openspec-explore/SKILL.md +++ b/skills/openspec-explore/SKILL.md @@ -202,7 +202,7 @@ You: [reads codebase] **User is stuck mid-implementation:** ``` -User: /opsx:explore add-auth-system +User: /openspec-explore add-auth-system The OAuth integration is more complex than expected You: [reads change artifacts] diff --git a/skills/openspec-ff-change/SKILL.md b/skills/openspec-ff-change/SKILL.md index 17b13b806..7e4c7a6b2 100644 --- a/skills/openspec-ff-change/SKILL.md +++ b/skills/openspec-ff-change/SKILL.md @@ -84,7 +84,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." +- Prompt: "Run `/openspec-apply-change` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index a06f0fd26..c5910700e 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -27,7 +27,7 @@ openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" ``` **If CLI not installed:** -> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`. +> OpenSpec CLI is not installed. Install it first, then come back to `/openspec-onboard`. Stop here if not installed. @@ -154,7 +154,7 @@ Spend 1-2 minutes investigating the relevant code: │ [Optional: ASCII diagram if helpful] │ └─────────────────────────────────────────┘ -Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. +Explore mode (`/openspec-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. Now let's create a change to hold our work. ``` @@ -470,25 +470,25 @@ This same rhythm works for any size change—a small fix or a major feature. | Command | What it does | |-------------------|--------------------------------------------| - | `/opsx:propose` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems before/during work | - | `/opsx:apply` | Implement tasks from a change | - | `/opsx:archive` | Archive a completed change | + | `/openspec-propose` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems before/during work | + | `/openspec-apply-change` | Implement tasks from a change | + | `/openspec-archive-change` | Archive a completed change | **Additional commands:** | Command | What it does | |--------------------|----------------------------------------------------------| - | `/opsx:new` | Start a new change, step through artifacts one at a time | - | `/opsx:continue` | Continue working on an existing change | - | `/opsx:ff` | Fast-forward: create all artifacts at once | - | `/opsx:verify` | Verify implementation matches artifacts | + | `/openspec-new-change` | Start a new change, step through artifacts one at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | --- ## What's Next? -Try `/opsx:propose` on something you actually want to build. You've got the rhythm now! +Try `/openspec-propose` on something you actually want to build. You've got the rhythm now! ``` --- @@ -503,8 +503,8 @@ If the user says they need to stop, want to pause, or seem disengaged: No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "" --json`. To pick up where we left off later: -- `/opsx:continue ` - Resume artifact creation -- `/opsx:apply ` - Jump to implementation (if tasks exist) +- `/openspec-continue-change ` - Resume artifact creation +- `/openspec-apply-change ` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. ``` @@ -522,21 +522,21 @@ If the user says they just want to see the commands or skip the tutorial: | Command | What it does | |--------------------------|--------------------------------------------| - | `/opsx:propose ` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems (no code changes) | - | `/opsx:apply ` | Implement tasks | - | `/opsx:archive ` | Archive when done | + | `/openspec-propose ` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change ` | Implement tasks | + | `/openspec-archive-change ` | Archive when done | **Additional commands:** | Command | What it does | |---------------------------|-------------------------------------| - | `/opsx:new ` | Start a new change, step by step | - | `/opsx:continue ` | Continue an existing change | - | `/opsx:ff ` | Fast-forward: all artifacts at once | - | `/opsx:verify ` | Verify implementation | + | `/openspec-new-change ` | Start a new change, step by step | + | `/openspec-continue-change ` | Continue an existing change | + | `/openspec-ff-change ` | Fast-forward: all artifacts at once | + | `/openspec-verify-change ` | Verify implementation | -Try `/opsx:propose` to start your first change. +Try `/openspec-propose` to start your first change. ``` Exit gracefully. diff --git a/skills/openspec-propose/SKILL.md b/skills/openspec-propose/SKILL.md index ab2dd4c4a..8b2b4b001 100644 --- a/skills/openspec-propose/SKILL.md +++ b/skills/openspec-propose/SKILL.md @@ -16,7 +16,7 @@ I'll create a change with artifacts: - design.md (how) - tasks.md (implementation steps) -When ready to implement, run /opsx:apply +When ready to implement, run /openspec-apply-change --- @@ -93,7 +93,7 @@ After completing all artifacts, summarize: - Change name and location - List of artifacts created with brief descriptions - What's ready: "All artifacts created! Ready for implementation." -- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." +- Prompt: "Run `/openspec-apply-change` or ask me to implement to start working on the tasks." **Artifact Creation Guidelines** diff --git a/skills/openspec-update-change/SKILL.md b/skills/openspec-update-change/SKILL.md index 68187f7e8..b17762c49 100644 --- a/skills/openspec-update-change/SKILL.md +++ b/skills/openspec-update-change/SKILL.md @@ -53,7 +53,7 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit - Read the artifact(s) the request touches and the change's other existing artifacts. - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. - Note everything that is now inconsistent, missing, or contradictory. - - Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/opsx:continue` to create them. + - Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. - If the change is already coherent, say so and make no edits. 5. **Confirm and apply, one artifact at a time** @@ -65,21 +65,21 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit ``` 6. **Point to the next step (guidance only - NEVER act on it)** - - Artifacts still missing -> suggest `/opsx:continue` to create them. - - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/opsx:apply` to carry the delta into code. - - Everything done and implemented -> suggest `/opsx:archive`. + - Artifacts still missing -> suggest `/openspec-continue-change` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest `/openspec-apply-change` to carry the delta into code. + - Everything done and implemented -> suggest `/openspec-archive-change`. **Output** After each invocation, show: - Which artifacts were revised (and which proposed revisions were rejected) -- Anything deferred to `/opsx:continue` (not-yet-created artifacts or files) +- Anything deferred to `/openspec-continue-change` (not-yet-created artifacts or files) - Where the change stands and the recommended next command **Guardrails** -- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/opsx:apply`. +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to `/openspec-apply-change`. - Use the artifact ids and paths reported by `openspec status`; never branch on hardcoded artifact names. - Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`. -- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/opsx:continue`'s job. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/opsx:new` (the "Update vs. Start Fresh" heuristic). +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). diff --git a/src/core/init.ts b/src/core/init.ts index 40848f3c6..895c2270b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,7 +13,7 @@ import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; import { classifyOpenSpecDir, storePointerProblem } from './project-config.js'; import { findRepoPlanningRootSync } from './planning-home.js'; -import { getTransformerForTool } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -689,7 +689,7 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - const transformer = getTransformerForTool(tool.value, delivery); + const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -868,13 +868,78 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; - console.log(); - if (activeWorkflows.includes('propose')) { + // When no tool got /opsx:* commands, point at the skill instead of a + // command that does not exist. + const activeDelivery: Delivery = globalCfg.delivery ?? 'both'; + const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); + const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + // Each hint line must be a usable instruction for the tool it serves. + // Tools that generated commands are told the /opsx:* command; tools that + // only got skills are told their documented skill invocation (Kimi Code: + // /skill:openspec-*; skills-invocable codex has no slash surface at all, + // so its hint names the skill; others: /openspec-*). Tools that got no + // artifacts are covered by the configuration correction instead. When + // the selection disagrees, print one line per distinct instruction, + // labeled with the tools it applies to. + const startHintLines = (command: string): string[] => { + const skillName = transformToSkillReferences(command).slice(1); + const hintToTools = new Map(); + for (const tool of successfulTools) { + let hint: string; + if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) { + hint = `Start your first change: ${command} "your idea"`; + } else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) { + hint = + resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' + ? `Start your first change with the ${skillName} skill` + : `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`; + } else { + continue; + } + hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]); + } + if (hintToTools.size === 0) { + // No successful tools: keep the generic command hint + return [`Start your first change: ${command} "your idea"`]; + } + if (hintToTools.size === 1) { + return [[...hintToTools.keys()][0]]; + } + return [...hintToTools.entries()].map(([hint, toolNames]) => `${hint} (${toolNames.join(', ')})`); + }; + const printStartHints = (command: string): void => { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); + for (const line of startHintLines(command)) { + console.log(` ${line}`); + } + }; + console.log(); + // delivery=commands with tools that only support skills: those tools get + // no artifacts at all, so print a per-tool configuration correction + // rather than leave them with a dead (or missing) instruction — even + // when other selected tools did get commands or skills. + const zeroArtifactTools = successfulTools.filter( + (tool) => + !shouldGenerateSkillsForTool(tool.value, activeDelivery) && + !shouldGenerateCommandsForTool(tool.value, activeDelivery) + ); + if (zeroArtifactTools.length > 0) { + const names = zeroArtifactTools.map((tool) => tool.name).join(', '); + console.log( + chalk.yellow( + `No skills or commands were generated for ${names}: delivery is set to 'commands' but ` + + `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` + + `Run 'openspec config set delivery both' to generate skills.` + ) + ); + } + if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) { + // Nothing was generated for any tool: the correction above is the + // whole story, so don't advertise an invocation that doesn't exist. + } else if (activeWorkflows.includes('propose')) { + printStartHints('/opsx:propose'); } else if (activeWorkflows.includes('new')) { - console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:new "your idea"'); + printStartHints('/opsx:new'); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } @@ -884,10 +949,18 @@ export class InitCommand { console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`); - // Restart instruction if any tools were configured - if (results.createdTools.length > 0 || results.refreshedTools.length > 0) { + // Restart instruction if any tools were configured and got a surface + // (when nothing was generated there is nothing a restart would pick up); + // only mention slash commands when slash commands were actually generated + if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) { console.log(); - console.log(chalk.white('Restart your IDE for slash commands to take effect.')); + console.log( + chalk.white( + commandsGenerated + ? 'Restart your IDE for slash commands to take effect.' + : 'Restart your IDE for the new skills to take effect.' + ) + ); } console.log(); diff --git a/src/core/migration.ts b/src/core/migration.ts index 163dac74f..9334caeb4 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -8,8 +8,10 @@ import { AI_TOOLS, type AIToolOption } from './config.js'; import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; +import { resolveCommandSurfaceCapability, shouldGenerateCommandsForTool } from './command-surface.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; +import { getSkillReferenceTransformer } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -207,5 +209,26 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi saveGlobalConfig(config); console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); - console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience."); + // Each detected tool resolves to a propose reference for its surface: + // the shared /opsx:propose command form when commands will exist for it + // under the effective delivery, its documented skill invocation + // otherwise (skills-invocable codex has no slash surface and always + // gets the syntax-neutral form). When the tools disagree — including + // command tools mixed with skill-only tools — stay syntax-neutral + // rather than advertise a form that is wrong for one of them. + const effectiveDelivery: Delivery = config.delivery ?? 'both'; + const proposeReferences = new Set( + tools.map((tool) => { + if (shouldGenerateCommandsForTool(tool.value, effectiveDelivery)) { + return '/opsx:propose'; + } + if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { + return 'the openspec-propose skill'; + } + return getSkillReferenceTransformer(tool.value)('/opsx:propose'); + }) + ); + const proposeReference = + proposeReferences.size === 1 ? [...proposeReferences][0] : 'the openspec-propose skill'; + console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`); } diff --git a/src/core/update.ts b/src/core/update.ts index 83a0351ca..bf7122aaa 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { getTransformerForTool } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, @@ -247,7 +247,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getTransformerForTool(tool.value, delivery); + const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -325,13 +325,37 @@ export class UpdateCommand { console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`)); } - // 12. Show onboarding message for newly configured tools from legacy upgrade + // 12. Show onboarding message for newly configured tools from legacy upgrade. + // Command tools keep the shared /opsx:* form, skill-only tools get their + // documented skill invocation, and disagreements (or skills-invocable + // codex, which has no slash surface) fall back to naming the skill. if (newlyConfiguredTools.length > 0) { + const referenceFor = (command: string): string => { + const neutralForm = `the ${transformToSkillReferences(command).slice(1)} skill`; + const forms = new Set( + newlyConfiguredTools.map((toolId) => { + if (shouldGenerateCommandsForTool(toolId, delivery)) { + return command; + } + if (resolveCommandSurfaceCapability(toolId) === 'skills-invocable') { + return neutralForm; + } + return getSkillReferenceTransformer(toolId)(command); + }) + ); + return forms.size === 1 ? [...forms][0] : neutralForm; + }; + const entries: Array<[string, string]> = [ + [referenceFor('/opsx:new'), 'Start a new change'], + [referenceFor('/opsx:continue'), 'Create the next artifact'], + [referenceFor('/opsx:apply'), 'Implement tasks'], + ]; + const width = Math.max(...entries.map(([reference]) => reference.length)); console.log(); console.log(chalk.bold('Getting started:')); - console.log(' /opsx:new Start a new change'); - console.log(' /opsx:continue Create the next artifact'); - console.log(' /opsx:apply Implement tasks'); + for (const [reference, description] of entries) { + console.log(` ${reference.padEnd(width)} ${description}`); + } console.log(); console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } @@ -855,7 +879,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - const transformer = getTransformerForTool(tool.value, delivery); + const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value)); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index dfdcd1ba5..b3cadf766 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,6 +4,10 @@ * Utilities for transforming command references to tool-specific formats. */ +// Type-only import: a value import would close a module cycle +// (command-generation adapters import this file). +import type { CommandSurfaceCapability } from '../core/command-surface.js'; + /** * Transforms colon-based command references to hyphen-based format. * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. @@ -20,29 +24,48 @@ export function transformToHyphenCommands(text: string): string { } /** - * Maps command short names to their skill directory references. + * Maps command short names to their skill names. * Keep in sync with WORKFLOW_TO_SKILL_DIR, which exists in both * src/core/profile-sync-drift.ts (exported) and src/core/init.ts (local copy). */ -const COMMAND_TO_SKILL_REFERENCE: Record = { - 'explore': '/openspec-explore', - 'new': '/openspec-new-change', - 'continue': '/openspec-continue-change', - 'apply': '/openspec-apply-change', - 'update': '/openspec-update-change', - 'ff': '/openspec-ff-change', - 'sync': '/openspec-sync-specs', - 'archive': '/openspec-archive-change', - 'bulk-archive': '/openspec-bulk-archive-change', - 'verify': '/openspec-verify-change', - 'onboard': '/openspec-onboard', - 'propose': '/openspec-propose', +const COMMAND_TO_SKILL_NAME: Record = { + 'explore': 'openspec-explore', + 'new': 'openspec-new-change', + 'continue': 'openspec-continue-change', + 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', + 'ff': 'openspec-ff-change', + 'sync': 'openspec-sync-specs', + 'archive': 'openspec-archive-change', + 'bulk-archive': 'openspec-bulk-archive-change', + 'verify': 'openspec-verify-change', + 'onboard': 'openspec-onboard', + 'propose': 'openspec-propose', +}; + +/** + * Tools whose skill invocation uses a non-default prefix. The default is `/` + * (e.g. `/openspec-propose`); Kimi Code invokes skills as `/skill:` + * (see docs/supported-tools.md). + */ +const SKILL_INVOCATION_PREFIX: Record = { + kimi: '/skill:', }; +function replaceCommandsWithSkillReferences(text: string, prefix: string): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { + const skillName = COMMAND_TO_SKILL_NAME[commandId]; + return skillName === undefined ? match : `${prefix}${skillName}`; + }); +} + /** - * Transforms command references to skill references for skills-only delivery. - * Converts `/opsx:` patterns to `/openspec-` so that - * generated skills do not reference commands that were never generated. + * Transforms command references to skill references using the default `/` + * invocation prefix. Converts `/opsx:` patterns to + * `/openspec-` so that generated skills do not reference commands + * that were never generated. Used for channels that are not tied to one + * tool (e.g. the skills.sh distribution); tool-targeted generation should + * go through getSkillReferenceTransformer instead. * * Unknown command references are left unchanged. * @@ -54,30 +77,51 @@ const COMMAND_TO_SKILL_REFERENCE: Record = { * transformToSkillReferences('Use /opsx:archive next') // returns 'Use /openspec-archive-change next' */ export function transformToSkillReferences(text: string): string { - return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { - return COMMAND_TO_SKILL_REFERENCE[commandId] ?? match; - }); + return replaceCommandsWithSkillReferences(text, '/'); +} + +/** + * Returns the skill-reference transformer for a specific tool, honoring the + * tool's documented skill invocation syntax (e.g. Kimi Code's + * `/skill:openspec-propose`). Falls back to the default `/openspec-*` form. + * + * @param toolId - The AI tool identifier (e.g. 'kimi', 'vibe') + * @returns A transformer converting `/opsx:*` references to skill invocations + */ +export function getSkillReferenceTransformer(toolId: string): (text: string) => string { + const prefix = SKILL_INVOCATION_PREFIX[toolId]; + if (prefix === undefined) { + return transformToSkillReferences; + } + return (text: string) => replaceCommandsWithSkillReferences(text, prefix); } /** * Selects the command-reference transformer for a skill generation target. * - * Skills-only delivery always uses skill references — for every tool — so - * generated skills never point at commands that were not generated. When - * commands are generated, tools where the command filename doubles as the - * command name (oh-my-pi, opencode, pi) use hyphen-based command references. - * All other cases keep the default `/opsx:*` references. + * Skill references are used whenever the tool ends up without `/opsx:*` + * commands — either because delivery is skills-only (for every tool) or + * because the tool has no command surface at all (capability 'none', e.g. + * Kimi Code or Mistral Vibe) — so those skills never point at commands + * that were not generated. When commands are generated, tools where the + * command filename doubles as the command name (oh-my-pi, opencode, pi) use + * hyphen-based command references. All other cases keep the default + * `/opsx:*` references; notably skills-invocable tools (codex) are + * deliberately left untouched here to keep codex output stable while its + * reference rewriting is reworked separately. * * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') * @param delivery - The configured delivery mode + * @param capability - The tool's command surface capability * @returns The transformer to pass to generateSkillContent, or undefined */ export function getTransformerForTool( toolId: string, - delivery: 'both' | 'skills' | 'commands' + delivery: 'both' | 'skills' | 'commands', + capability: CommandSurfaceCapability ): ((text: string) => string) | undefined { - if (delivery === 'skills') { - return transformToSkillReferences; + if (delivery === 'skills' || capability === 'none') { + return getSkillReferenceTransformer(toolId); } if (toolId === 'opencode' || toolId === 'pi' || toolId === 'oh-my-pi') { return transformToHyphenCommands; diff --git a/src/utils/index.ts b/src/utils/index.ts index 391f0abcb..6a5309f5d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -18,5 +18,6 @@ export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; export { transformToHyphenCommands, transformToSkillReferences, + getSkillReferenceTransformer, getTransformerForTool, } from './command-references.js'; \ No newline at end of file diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 79f5fdc87..8bfddd17b 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; +import { randomUUID } from 'crypto'; import path from 'path'; import os from 'os'; import { InitCommand } from '../../src/core/init.js'; @@ -29,11 +30,11 @@ describe('InitCommand', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`); + testDir = path.join(os.tmpdir(), `openspec-init-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); originalEnv = { ...process.env }; // Use a temp dir for global config to avoid reading real config - configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`); + configTempDir = path.join(os.tmpdir(), `openspec-config-init-${randomUUID()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; process.env.CODEX_HOME = path.join(testDir, 'codex-home'); @@ -645,11 +646,11 @@ describe('InitCommand - profile and detection features', () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { - testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${Date.now()}`); + testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); originalEnv = { ...process.env }; // Use a temp dir for global config to avoid polluting real config - configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`); + configTempDir = path.join(os.tmpdir(), `openspec-config-test-${randomUUID()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; process.env.CODEX_HOME = path.join(testDir, 'codex-home'); @@ -947,6 +948,215 @@ describe('InitCommand - profile and detection features', () => { expect(updateSkillContent).toContain('/openspec-'); }); + it('should use skill references for adapterless tools under default delivery (#1155)', async () => { + // Kimi Code has no command adapter: commands are skipped even when + // delivery is 'both', so generated skills must not reference /opsx:* + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).not.toContain('/opsx:'); + expect(skillContent).not.toContain('/opsx-'); + // Kimi Code documents /skill: invocations (docs/supported-tools.md) + expect(skillContent).toContain('/skill:openspec-'); + + // The getting-started hint must point at the skill, not a missing command + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('/skill:openspec-propose'); + expect(startHint).not.toContain('/opsx:propose'); + }); + + it('should print a configuration correction, not a dead hint, when delivery=commands generates nothing (adapterless tool)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'kimi', force: true }); + await initCommand.execute(testDir); + + // Kimi has no command adapter and delivery excludes skills: nothing is generated + expect(await fileExists(path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'))).toBe(false); + expect(await fileExists(path.join(testDir, '.kimi-code', 'commands'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + // No invocation hint may be shown — neither /opsx:* nor a skill reference exists + expect(logCalls.some((entry) => entry.includes('Start your first change'))).toBe(false); + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated')); + expect(correction).toBeTruthy(); + expect(correction).toContain("openspec config set delivery both"); + // Nothing was generated, so there is nothing an IDE restart would pick up + expect(logCalls.some((entry) => entry.includes('Restart your IDE'))).toBe(false); + }); + + it('should print one usable hint per invocation syntax when adapterless tools disagree', async () => { + // kimi documents /skill:, vibe documents / — every advertised + // instruction must be usable by the tool it is labeled for + const initCommand = new InitCommand({ tools: 'kimi,vibe', force: true }); + await initCommand.execute(testDir); + + // Each tool's own skill files still use its documented syntax + const kimiSkill = await fs.readFile( + path.join(testDir, '.kimi-code', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + const vibeSkill = await fs.readFile( + path.join(testDir, '.vibe', 'skills', 'openspec-apply-change', 'SKILL.md'), + 'utf-8' + ); + expect(kimiSkill).toContain('/skill:openspec-'); + expect(vibeSkill).toContain('/openspec-'); + expect(vibeSkill).not.toContain('/skill:'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const kimiHint = startHints.find((entry) => entry.includes('Kimi Code')); + const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe')); + expect(kimiHint).toContain('/skill:openspec-propose'); + expect(vibeHint).toContain('/openspec-propose'); + expect(vibeHint).not.toContain('/skill:'); + for (const hint of startHints) { + expect(hint).not.toContain('/opsx:'); + } + }); + + it('should print a syntax-neutral hint for codex (skills-invocable, no slash surface)', async () => { + // Codex has no slash-command surface: docs direct users to + // .codex/skills/openspec-*, so the hint must not advertise a slash form + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + // Codex skill generation itself is deliberately untouched by #1155 + // (codex reference rewriting is owned by a separate change) + const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('with the openspec-propose skill'); + expect(startHint).not.toContain('/openspec-propose'); + expect(startHint).not.toContain('/opsx:propose'); + + // No slash commands were generated, so the restart line must not claim any + const restartHint = logCalls.find((entry) => entry.includes('Restart your IDE')); + expect(restartHint).toContain('Restart your IDE for the new skills to take effect.'); + expect(restartHint).not.toContain('slash commands'); + }); + + it('should label the codex hint separately when mixed with a slash-invocable adapterless tool', async () => { + const initCommand = new InitCommand({ tools: 'codex,vibe', force: true }); + await initCommand.execute(testDir); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const codexHint = startHints.find((entry) => entry.includes('(Codex)')); + const vibeHint = startHints.find((entry) => entry.includes('Mistral Vibe')); + expect(codexHint).toContain('with the openspec-propose skill'); + expect(codexHint).not.toContain('/openspec-propose'); + expect(vibeHint).toContain('/openspec-propose'); + for (const hint of startHints) { + expect(hint).not.toContain('/opsx:'); + } + }); + + it('should not advertise an instruction for a tool that got no skills (delivery=commands, codex+kimi)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'codex,kimi', force: true }); + await initCommand.execute(testDir); + + // Codex is skills-invocable so its skills are generated even under + // delivery=commands; kimi (capability none) gets nothing at all + expect(await fileExists(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + // Only the codex instruction may be advertised — a Kimi line would point + // at skills that were never generated + expect(startHints).toHaveLength(1); + expect(startHints[0]).toContain('with the openspec-propose skill'); + expect(startHints[0]).not.toContain('Kimi'); + expect(logCalls.some((entry) => entry.includes('/skill:openspec-'))).toBe(false); + // Kimi got zero artifacts, so it still deserves the configuration correction + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated for')); + expect(correction).toContain('Kimi Code'); + expect(correction).not.toContain('Codex'); + expect(correction).toContain("openspec config set delivery both"); + }); + + it('should print a per-tool correction when an adapter-backed tool masks an adapterless one (delivery=commands, claude+kimi)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'claude,kimi', force: true }); + await initCommand.execute(testDir); + + // Claude gets commands; kimi (no adapter, delivery excludes skills) gets nothing + expect(await fileExists(path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + // The /opsx: hint is correct for Claude, but Kimi must not be left with + // a dead instruction: the correction names it even though another tool + // generated commands + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(1); + expect(startHints[0]).toContain('/opsx:propose'); + const correction = logCalls.find((entry) => entry.includes('No skills or commands were generated for')); + expect(correction).toContain('Kimi Code'); + expect(correction).not.toContain('Claude'); + expect(correction).toContain("openspec config set delivery both"); + expect(logCalls.some((entry) => entry.includes('/skill:openspec-'))).toBe(false); + }); + + it('should label per-tool hints when adapter-backed and adapterless tools are mixed (claude+kimi)', async () => { + // Claude gets /opsx:* commands; kimi only gets skills invoked as + // /skill:openspec-*. A single unlabeled /opsx: hint would be unusable + // for the Kimi user, so each tool gets its own labeled instruction. + const initCommand = new InitCommand({ tools: 'claude,kimi', force: true }); + await initCommand.execute(testDir); + + expect(await fileExists(path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'))).toBe(true); + expect(await fileExists(path.join(testDir, '.kimi-code', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHints = logCalls.filter((entry) => entry.includes('Start your first change')); + expect(startHints).toHaveLength(2); + const claudeHint = startHints.find((entry) => entry.includes('Claude Code')); + const kimiHint = startHints.find((entry) => entry.includes('Kimi Code')); + expect(claudeHint).toContain('/opsx:propose'); + expect(kimiHint).toContain('/skill:openspec-propose'); + expect(kimiHint).not.toContain('/opsx:'); + }); + + it('should keep /opsx: command hints for adapter-backed tools under default delivery', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md'); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('/opsx:'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + const startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('/opsx:propose'); + }); + it('should use skill references for opencode in skills-only delivery', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 409206e94..e1b6f4f7c 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import os from 'os'; import { randomUUID } from 'crypto'; @@ -18,12 +18,30 @@ function ensureClaudeTool(): AIToolOption { return CLAUDE_TOOL; } -async function writeSkill(projectPath: string, dirName: string): Promise { - const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md'); +async function writeSkill(projectPath: string, dirName: string, toolRoot = '.claude'): Promise { + const skillFile = path.join(projectPath, toolRoot, 'skills', dirName, 'SKILL.md'); await fsp.mkdir(path.dirname(skillFile), { recursive: true }); await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); } +function requireTool(toolId: string): AIToolOption { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool) { + throw new Error(`${toolId} tool definition not found`); + } + return tool; +} + +function captureMigrationLogs(projectDir: string, tools: AIToolOption[]): string[] { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + migrateIfNeeded(projectDir, tools); + return logSpy.mock.calls.flat().map(String); + } finally { + logSpy.mockRestore(); + } +} + async function writeManagedCommand(projectPath: string, workflowId: string): Promise { const adapter = CommandAdapterRegistry.get('claude'); if (!adapter) { @@ -135,6 +153,81 @@ describe('migration', () => { expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); + it('prints a syntax-neutral propose reference when migrating a codex-only project', async () => { + // Codex is skills-invocable with no slash surface: the migration message + // must name the skill, not advertise a /openspec-* or /opsx:* form + await writeSkill(projectDir, 'openspec-propose', '.codex'); + + const message = captureMigrationLogs(projectDir, [requireTool('codex')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toBeTruthy(); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('prints the documented /skill: propose reference when migrating a kimi-only project', async () => { + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/skill:openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('falls back to a syntax-neutral reference when detected tools disagree (codex+kimi)', async () => { + await writeSkill(projectDir, 'openspec-propose', '.codex'); + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [requireTool('codex'), requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/skill:'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('falls back to a syntax-neutral reference when command and skill-only tools mix (claude+kimi)', async () => { + // Claude will get /opsx:* commands but Kimi cannot invoke them; the one + // shared message must not advertise a form that is wrong for either tool + await writeManagedCommand(projectDir, 'propose'); + await writeSkill(projectDir, 'openspec-propose', '.kimi-code'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool(), requireTool('kimi')]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('the openspec-propose skill'); + expect(message).not.toContain('/opsx:propose'); + expect(message).not.toContain('/skill:'); + }); + + it('does not advertise /opsx:propose when explicit delivery is skills', async () => { + // Adapter-backed tool, but the effective delivery will never generate + // commands — the message must use the skill reference instead + saveGlobalConfig({ + featureFlags: {}, + delivery: 'skills', + }); + await writeSkill(projectDir, 'openspec-propose'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool()]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/openspec-propose'); + expect(message).not.toContain('/opsx:propose'); + }); + + it('advertises /opsx:propose when commands are installed for an adapter-backed tool', async () => { + await writeManagedCommand(projectDir, 'propose'); + + const message = captureMigrationLogs(projectDir, [ensureClaudeTool()]).find((entry) => + entry.includes('New in this version') + ); + expect(message).toContain('/opsx:propose'); + }); + it('ignores unknown custom skill and command files when scanning workflows', async () => { await writeSkill(projectDir, 'my-custom-skill'); const customCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'my-custom.md'); diff --git a/test/core/templates/skillssh-parity.test.ts b/test/core/templates/skillssh-parity.test.ts index 95a42fa71..e5e26928b 100644 --- a/test/core/templates/skillssh-parity.test.ts +++ b/test/core/templates/skillssh-parity.test.ts @@ -8,6 +8,7 @@ import { generateSkillContent, getSkillTemplates, } from '../../../src/core/shared/skill-generation.js'; +import { transformToSkillReferences } from '../../../src/utils/command-references.js'; // @ts-expect-error - plain ESM helper shared with the generator script import { SKILLS_DIR, stripVolatileFrontmatter } from '../../../scripts/skillssh-shared.mjs'; @@ -19,7 +20,9 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') describe('skills.sh distribution parity', () => { it('keeps committed skills/ in sync with the workflow templates', () => { for (const { template, dirName } of getSkillTemplates()) { - const expected = stripVolatileFrontmatter(generateSkillContent(template, 'skills.sh')); + const expected = stripVolatileFrontmatter( + generateSkillContent(template, 'skills.sh', transformToSkillReferences) + ); const committedPath = join(repoRoot, SKILLS_DIR, dirName, 'SKILL.md'); const committed = readFileSync(committedPath, 'utf8'); expect(committed, `${dirName} is stale — run \`pnpm generate:skills\``).toBe(expected); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 43f662baf..df238b730 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -211,6 +211,12 @@ Old instructions content ); expect(migratedSkill).toContain('name: openspec-explore'); expect(migratedSkill).not.toContain('Old instructions content'); + // Kimi Code has no command adapter, so the refreshed skill must use + // its documented /skill: invocations, never /opsx:* commands + // that were not generated + expect(migratedSkill).not.toContain('/opsx:'); + expect(migratedSkill).not.toContain('/opsx-'); + expect(migratedSkill).toContain('/skill:openspec-'); // Legacy managed skill is gone; user files stay where they were await expect(fs.access(legacySkillDir)).rejects.toThrow(); @@ -1115,6 +1121,36 @@ ${OPENSPEC_MARKERS.end} )).toBe(false); }); + it('should print a skill-based getting-started menu when a legacy upgrade newly configures codex', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + // Legacy managed Codex prompt with codex not yet configured: the + // upgrade newly configures codex, whose onboarding menu must not + // advertise /opsx:* commands (codex has no slash surface) + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'legacy explore prompt'); + + const consoleSpy = vi.spyOn(console, 'log'); + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + const logCalls = consoleSpy.mock.calls.flat().map(String); + consoleSpy.mockRestore(); + + expect(logCalls.some((entry) => entry.includes('Getting started'))).toBe(true); + const menuLines = logCalls.filter((entry) => entry.includes('Start a new change')); + expect(menuLines).toHaveLength(1); + expect(menuLines[0]).toContain('the openspec-new-change skill'); + expect(logCalls.some((entry) => entry.includes('/opsx:new'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('/opsx:continue'))).toBe(false); + expect(logCalls.some((entry) => entry.includes('/opsx:apply'))).toBe(false); + }); + it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => { setMockConfig({ featureFlags: {}, diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 10d5546dc..1f2367e51 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + getSkillReferenceTransformer, getTransformerForTool, transformToHyphenCommands, transformToSkillReferences, @@ -167,27 +168,56 @@ Then /openspec-apply-change to implement`; }); }); +describe('getSkillReferenceTransformer', () => { + it('uses the default / form for tools without a custom prefix', () => { + expect(getSkillReferenceTransformer('vibe')).toBe(transformToSkillReferences); + expect(getSkillReferenceTransformer('hermes')('/opsx:apply')).toBe('/openspec-apply-change'); + }); + + it('uses /skill: for Kimi Code, per its documented invocation syntax', () => { + const transformer = getSkillReferenceTransformer('kimi'); + expect(transformer('/opsx:propose')).toBe('/skill:openspec-propose'); + expect(transformer('Run `/opsx:apply` then /opsx:archive')).toBe( + 'Run `/skill:openspec-apply-change` then /skill:openspec-archive-change' + ); + expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command'); + }); +}); + describe('getTransformerForTool', () => { it('selects skill references for skills-only delivery for every tool', () => { - expect(getTransformerForTool('claude', 'skills')).toBe(transformToSkillReferences); - expect(getTransformerForTool('codex', 'skills')).toBe(transformToSkillReferences); + expect(getTransformerForTool('claude', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool('codex', 'skills', 'skills-invocable')).toBe(transformToSkillReferences); // hyphen-command tools must not fall back to hyphen commands when no commands are generated - expect(getTransformerForTool('opencode', 'skills')).toBe(transformToSkillReferences); - expect(getTransformerForTool('pi', 'skills')).toBe(transformToSkillReferences); - expect(getTransformerForTool('oh-my-pi', 'skills')).toBe(transformToSkillReferences); + expect(getTransformerForTool('opencode', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool('pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences); + }); + + it('selects skill references for tools without a command surface, regardless of delivery', () => { + // Tools like Kimi Code or Mistral Vibe have no command adapter, so their + // skills must never reference /opsx:* commands that were not generated. + expect(getTransformerForTool('vibe', 'both', 'none')).toBe(transformToSkillReferences); + expect(getTransformerForTool('hermes', 'both', 'none')).toBe(transformToSkillReferences); + // Kimi Code documents /skill: invocations (docs/supported-tools.md) + for (const delivery of ['both', 'commands', 'skills'] as const) { + const transformer = getTransformerForTool('kimi', delivery, 'none'); + expect(transformer?.('/opsx:propose')).toBe('/skill:openspec-propose'); + } }); it('selects hyphen commands for opencode, pi, and oh-my-pi when commands are generated', () => { - expect(getTransformerForTool('opencode', 'both')).toBe(transformToHyphenCommands); - expect(getTransformerForTool('opencode', 'commands')).toBe(transformToHyphenCommands); - expect(getTransformerForTool('pi', 'both')).toBe(transformToHyphenCommands); - expect(getTransformerForTool('pi', 'commands')).toBe(transformToHyphenCommands); - expect(getTransformerForTool('oh-my-pi', 'both')).toBe(transformToHyphenCommands); - expect(getTransformerForTool('oh-my-pi', 'commands')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('opencode', 'both', 'adapter-backed')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('opencode', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('pi', 'both', 'adapter-backed')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('pi', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('oh-my-pi', 'both', 'adapter-backed')).toBe(transformToHyphenCommands); + expect(getTransformerForTool('oh-my-pi', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands); }); - it('selects no transformer for other tools when commands are generated', () => { - expect(getTransformerForTool('claude', 'both')).toBeUndefined(); - expect(getTransformerForTool('claude', 'commands')).toBeUndefined(); + it('selects no transformer for adapter-backed and skills-invocable tools when commands are generated', () => { + expect(getTransformerForTool('claude', 'both', 'adapter-backed')).toBeUndefined(); + expect(getTransformerForTool('claude', 'commands', 'adapter-backed')).toBeUndefined(); + expect(getTransformerForTool('codex', 'both', 'skills-invocable')).toBeUndefined(); }); });