From 4bbd42d23c00fce62f013ecf0204133761b1d571 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 20 Jul 2026 22:02:21 -0500 Subject: [PATCH 1/9] fix(init): use skill references for tools without a command adapter Adapterless tools (kimi, vibe, hermes, forgecode, codeartsagent, agents) skip command generation even under the default 'both' delivery, but their generated SKILL.md files still told agents to run /opsx:* commands that were never created, and the init summary suggested /opsx:propose. Route the existing skill-reference transform by command-surface capability so these tools get /openspec-* references, and point the getting-started hint at the skill when no selected tool got commands. Fixes #1155 Co-Authored-By: Claude Fable 5 --- src/core/init.ts | 16 +++++++++--- src/core/update.ts | 4 +-- src/utils/command-references.ts | 19 +++++++++----- test/core/init.test.ts | 34 ++++++++++++++++++++++++ test/utils/command-references.test.ts | 37 +++++++++++++++++---------- 5 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 40848f3c6..0d6c1ce3d 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 { 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,21 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; + // When skills were generated but no tool got /opsx:* commands, point at + // the skill instead of a command that does not exist. + const activeDelivery: Delivery = globalCfg.delivery ?? 'both'; + const skillsOnlyHint = + successfulTools.length > 0 && + !successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)) && + successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + const startReference = (command: string) => (skillsOnlyHint ? transformToSkillReferences(command) : command); console.log(); if (activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); + console.log(` Start your first change: ${startReference('/opsx:propose')} "your idea"`); } else if (activeWorkflows.includes('new')) { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:new "your idea"'); + console.log(` Start your first change: ${startReference('/opsx:new')} "your idea"`); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } diff --git a/src/core/update.ts b/src/core/update.ts index 83a0351ca..e404286b8 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -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); } @@ -855,7 +855,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..aa232040b 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -62,21 +62,26 @@ export function transformToSkillReferences(text: string): string { /** * 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 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. * * @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: 'adapter-backed' | 'skills-invocable' | 'none' ): ((text: string) => string) | undefined { - if (delivery === 'skills') { + if (delivery === 'skills' || capability === 'none') { return transformToSkillReferences; } if (toolId === 'opencode' || toolId === 'pi' || toolId === 'oh-my-pi') { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 79f5fdc87..53731a467 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -947,6 +947,40 @@ 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-'); + expect(skillContent).toContain('/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('/openspec-propose'); + expect(startHint).not.toContain('/opsx:propose'); + }); + + 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/utils/command-references.test.ts b/test/utils/command-references.test.ts index 10d5546dc..5fcece075 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -169,25 +169,34 @@ Then /openspec-apply-change to implement`; 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('kimi', 'both', 'none')).toBe(transformToSkillReferences); + expect(getTransformerForTool('vibe', 'both', 'none')).toBe(transformToSkillReferences); + expect(getTransformerForTool('kimi', 'commands', 'none')).toBe(transformToSkillReferences); }); 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(); }); }); From 323f0123572f404a87ed1109ef86331869bc15f5 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 20 Jul 2026 22:27:06 -0500 Subject: [PATCH 2/9] fix(init): address adversarial review findings for adapterless skill references - transform the committed skills.sh distribution too: pass transformToSkillReferences in generate-skillssh.mjs and the parity test, regenerate skills/ (that channel installs SKILL.md files only, so /opsx:* commands never exist there) - key the getting-started hint purely on whether any selected tool got commands, so the delivery=commands + adapterless corner can no longer print /opsx:propose - make the one-time profile-migration message capability-aware for projects whose detected tools have no command adapter - import CommandSurfaceCapability type-only instead of duplicating the union inline (a value import would close a module cycle) - cover the update path: the kimi migration test now asserts refreshed skills contain no /opsx references Co-Authored-By: Claude Fable 5 --- scripts/generate-skillssh.mjs | 7 +++- skills/openspec-apply-change/SKILL.md | 2 +- skills/openspec-explore/SKILL.md | 2 +- skills/openspec-ff-change/SKILL.md | 2 +- skills/openspec-onboard/SKILL.md | 44 ++++++++++----------- skills/openspec-propose/SKILL.md | 4 +- skills/openspec-update-change/SKILL.md | 16 ++++---- src/core/init.ts | 7 ++-- src/core/migration.ts | 7 +++- src/utils/command-references.ts | 11 ++++-- test/core/templates/skillssh-parity.test.ts | 5 ++- test/core/update.test.ts | 5 +++ 12 files changed, 67 insertions(+), 45 deletions(-) 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 0d6c1ce3d..a011db7f0 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -868,13 +868,12 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; - // When skills were generated but no tool got /opsx:* commands, point at - // the skill instead of a command that does not exist. + // 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 skillsOnlyHint = successfulTools.length > 0 && - !successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)) && - successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + !successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); const startReference = (command: string) => (skillsOnlyHint ? transformToSkillReferences(command) : command); console.log(); if (activeWorkflows.includes('propose')) { diff --git a/src/core/migration.ts b/src/core/migration.ts index 163dac74f..50a649840 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,6 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; +import { transformToSkillReferences } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -207,5 +208,9 @@ 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."); + // Tools without a command adapter never get /opsx:* commands; point them + // at the skill instead. + const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); + const proposeReference = hasCommandSurface ? '/opsx:propose' : transformToSkillReferences('/opsx:propose'); + console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index aa232040b..6e3fcfb36 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. @@ -65,11 +69,12 @@ export function transformToSkillReferences(text: string): string { * 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 generated skills never point at commands + * 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. + * `/opsx:*` references; notably skills-invocable tools (codex) are left + * untouched here because their reference rewriting is handled separately. * * @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi') * @param delivery - The configured delivery mode @@ -79,7 +84,7 @@ export function transformToSkillReferences(text: string): string { export function getTransformerForTool( toolId: string, delivery: 'both' | 'skills' | 'commands', - capability: 'adapter-backed' | 'skills-invocable' | 'none' + capability: CommandSurfaceCapability ): ((text: string) => string) | undefined { if (delivery === 'skills' || capability === 'none') { return transformToSkillReferences; 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..c4401dc8d 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -211,6 +211,11 @@ 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 + // skill references, never /opsx:* commands that were not generated + expect(migratedSkill).not.toContain('/opsx:'); + expect(migratedSkill).not.toContain('/opsx-'); + expect(migratedSkill).toContain('/openspec-'); // Legacy managed skill is gone; user files stay where they were await expect(fs.access(legacySkillDir)).rejects.toThrow(); From 881d8932aa02e8a8edd333a68f9efb2376a89409 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 20 Jul 2026 22:31:49 -0500 Subject: [PATCH 3/9] fix(init): honor Kimi Code's documented /skill: invocation syntax Per review: the blanket /openspec-* rewrite contradicted Kimi's documented invocation contract (/skill:openspec-*, see docs/supported-tools.md). Skill-reference transforms are now selected per tool via getSkillReferenceTransformer, with Kimi mapped to /skill: and every other tool keeping the documented / form; the getting-started hint and migration message use the same per-tool syntax. End-to-end Kimi assertions cover generated skill content, the refreshed update path, and the hint. Co-Authored-By: Claude Fable 5 --- src/core/init.ts | 5 +- src/core/migration.ts | 8 +-- src/utils/command-references.ts | 75 +++++++++++++++++++-------- src/utils/index.ts | 1 + test/core/init.test.ts | 5 +- test/core/update.test.ts | 5 +- test/utils/command-references.test.ts | 25 ++++++++- 7 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index a011db7f0..961d0550b 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, transformToSkillReferences } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -874,7 +874,8 @@ export class InitCommand { const skillsOnlyHint = successfulTools.length > 0 && !successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); - const startReference = (command: string) => (skillsOnlyHint ? transformToSkillReferences(command) : command); + const startReference = (command: string) => + skillsOnlyHint ? getSkillReferenceTransformer(successfulTools[0].value)(command) : command; console.log(); if (activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); diff --git a/src/core/migration.ts b/src/core/migration.ts index 50a649840..648336820 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,7 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { transformToSkillReferences } from '../utils/command-references.js'; +import { getSkillReferenceTransformer } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -209,8 +209,10 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); // Tools without a command adapter never get /opsx:* commands; point them - // at the skill instead. + // at the skill instead, using the first detected tool's invocation syntax. const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); - const proposeReference = hasCommandSurface ? '/opsx:propose' : transformToSkillReferences('/opsx:propose'); + const proposeReference = hasCommandSurface + ? '/opsx:propose' + : getSkillReferenceTransformer(tools[0]?.value ?? '')('/opsx:propose'); console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 6e3fcfb36..6e6f979f4 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -24,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', }; /** - * 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. + * 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 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. * @@ -58,9 +77,23 @@ 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); } /** @@ -87,7 +120,7 @@ export function getTransformerForTool( capability: CommandSurfaceCapability ): ((text: string) => string) | undefined { if (delivery === 'skills' || capability === 'none') { - return transformToSkillReferences; + 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 53731a467..2b4de2eb9 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -959,12 +959,13 @@ describe('InitCommand - profile and detection features', () => { const skillContent = await fs.readFile(skillFile, 'utf-8'); expect(skillContent).not.toContain('/opsx:'); expect(skillContent).not.toContain('/opsx-'); - expect(skillContent).toContain('/openspec-'); + // 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('/openspec-propose'); + expect(startHint).toContain('/skill:openspec-propose'); expect(startHint).not.toContain('/opsx:propose'); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c4401dc8d..2fcfeb950 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -212,10 +212,11 @@ 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 - // skill references, never /opsx:* commands that were not generated + // its documented /skill: invocations, never /opsx:* commands + // that were not generated expect(migratedSkill).not.toContain('/opsx:'); expect(migratedSkill).not.toContain('/opsx-'); - expect(migratedSkill).toContain('/openspec-'); + expect(migratedSkill).toContain('/skill:openspec-'); // Legacy managed skill is gone; user files stay where they were await expect(fs.access(legacySkillDir)).rejects.toThrow(); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 5fcece075..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,6 +168,22 @@ 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', 'adapter-backed')).toBe(transformToSkillReferences); @@ -180,9 +197,13 @@ describe('getTransformerForTool', () => { 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('kimi', 'both', 'none')).toBe(transformToSkillReferences); expect(getTransformerForTool('vibe', 'both', 'none')).toBe(transformToSkillReferences); - expect(getTransformerForTool('kimi', 'commands', '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', () => { From 8f6d324d6212699b3dd939dcfecf21651197e437 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 20 Jul 2026 23:20:22 -0500 Subject: [PATCH 4/9] fix(init): gate the getting-started hint on a generated surface Per review: with delivery=commands and only adapterless tools selected, init generated neither skills nor commands yet still advertised an invocation. Print a configuration correction instead, with the exact 'openspec config set delivery both' remedy, covered by an end-to-end commands-only adapterless test. Also from the adversarial review round: mixed selections that disagree on invocation syntax (kimi + vibe) now fall back to the default /openspec-* form in the shared hint and migration message instead of picking the first tool's syntax; add the missing changeset; correct the codex doc comment. Co-Authored-By: Claude Fable 5 --- .changeset/adapterless-skill-references.md | 5 +++ src/core/init.ts | 32 +++++++++++---- src/core/migration.ts | 11 ++--- src/utils/command-references.ts | 5 ++- test/core/init.test.ts | 48 ++++++++++++++++++++++ 5 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 .changeset/adapterless-skill-references.md diff --git a/.changeset/adapterless-skill-references.md b/.changeset/adapterless-skill-references.md new file mode 100644 index 000000000..6047e3592 --- /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-*`). The committed skills.sh distribution is regenerated the same way. diff --git a/src/core/init.ts b/src/core/init.ts index 961d0550b..ec348ad62 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,7 +13,11 @@ 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 { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; +import { + getSkillReferenceTransformer, + getTransformerForTool, + transformToSkillReferences, +} from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -871,13 +875,27 @@ export class InitCommand { // 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 skillsOnlyHint = - successfulTools.length > 0 && - !successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); - const startReference = (command: string) => - skillsOnlyHint ? getSkillReferenceTransformer(successfulTools[0].value)(command) : command; + const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); + const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + const skillsOnlyHint = successfulTools.length > 0 && !commandsGenerated; + // Mixed selections may span invocation syntaxes; use a tool-specific + // form only when every selected tool agrees on it. + const hintTransformers = new Set(successfulTools.map((tool) => getSkillReferenceTransformer(tool.value))); + const hintTransformer = hintTransformers.size === 1 ? [...hintTransformers][0] : transformToSkillReferences; + const startReference = (command: string) => (skillsOnlyHint ? hintTransformer(command) : command); console.log(); - if (activeWorkflows.includes('propose')) { + if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) { + // delivery=commands with tools that only support skills: nothing was + // generated, so don't advertise an invocation that doesn't exist. + console.log( + chalk.yellow( + `No skills or commands were generated: delivery is set to 'commands' but ` + + `${successfulTools.map((tool) => tool.name).join(', ')} ` + + `${successfulTools.length === 1 ? 'supports' : 'support'} only skills. ` + + `Run 'openspec config set delivery both' to generate skills.` + ) + ); + } else if (activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); console.log(` Start your first change: ${startReference('/opsx:propose')} "your idea"`); } else if (activeWorkflows.includes('new')) { diff --git a/src/core/migration.ts b/src/core/migration.ts index 648336820..e3e233fcd 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,7 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.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 { getSkillReferenceTransformer, transformToSkillReferences } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -209,10 +209,11 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); // Tools without a command adapter never get /opsx:* commands; point them - // at the skill instead, using the first detected tool's invocation syntax. + // at the skill instead. Use a tool-specific invocation syntax only when + // every detected tool agrees on it. const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); - const proposeReference = hasCommandSurface - ? '/opsx:propose' - : getSkillReferenceTransformer(tools[0]?.value ?? '')('/opsx:propose'); + const skillTransformers = new Set(tools.map((tool) => getSkillReferenceTransformer(tool.value))); + const skillTransformer = skillTransformers.size === 1 ? [...skillTransformers][0] : transformToSkillReferences; + const proposeReference = hasCommandSurface ? '/opsx:propose' : skillTransformer('/opsx:propose'); console.log(`New in this version: ${proposeReference}. Try 'openspec config profile core' for the streamlined experience.`); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index 6e6f979f4..b3cadf766 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -106,8 +106,9 @@ export function getSkillReferenceTransformer(toolId: string): (text: string) => * 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 left - * untouched here because their reference rewriting is handled separately. + * `/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 diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 2b4de2eb9..df1f764e7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -969,6 +969,54 @@ describe('InitCommand - profile and detection features', () => { 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"); + }); + + it('should fall back to the default skill form when adapterless tools disagree on syntax', async () => { + // kimi documents /skill:, vibe documents / — a shared hint + // line cannot serve both, so the default form wins for mixed selections + 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 startHint = logCalls.find((entry) => entry.includes('Start your first change')); + expect(startHint).toContain('/openspec-propose'); + expect(startHint).not.toContain('/skill:'); + expect(startHint).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); From 86155d0466058023886b02f08d4e40fa3493d079 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 20 Jul 2026 23:40:27 -0500 Subject: [PATCH 5/9] fix(init): suppress the restart hint when no surface was generated From the third adversarial review round: the 'Restart your IDE for slash commands' line printed directly after the message saying nothing was generated. Gate it on an actually generated surface and pin that in the commands-only adapterless test. Also: use randomUUID() for init test temp dirs (matches update.test.ts, removes a theoretical Date.now collision), and clarify the changeset wording about the skills.sh channel's default reference form. Co-Authored-By: Claude Fable 5 --- .changeset/adapterless-skill-references.md | 2 +- src/core/init.ts | 5 +++-- test/core/init.test.ts | 7 +++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.changeset/adapterless-skill-references.md b/.changeset/adapterless-skill-references.md index 6047e3592..96c4b1744 100644 --- a/.changeset/adapterless-skill-references.md +++ b/.changeset/adapterless-skill-references.md @@ -2,4 +2,4 @@ '@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-*`). The committed skills.sh distribution is regenerated the same way. +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-*`). When `delivery: commands` would generate nothing for such tools, init now prints a configuration correction instead of a dead hint. The committed skills.sh distribution is regenerated with skill references (default `/openspec-*` form, as that channel installs skills only). diff --git a/src/core/init.ts b/src/core/init.ts index ec348ad62..ef063f20b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -910,8 +910,9 @@ 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) + 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.')); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index df1f764e7..6d40660a2 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'); @@ -989,6 +990,8 @@ describe('InitCommand - profile and detection features', () => { 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 fall back to the default skill form when adapterless tools disagree on syntax', async () => { From 36997e83b5ada3593768c76ac2098f14df772d4e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 02:17:51 -0500 Subject: [PATCH 6/9] fix(init): print one usable getting-started hint per invocation syntax Per review: the mixed-syntax fallback advertised /openspec-propose, which Mistral Vibe accepts but Kimi Code does not. Group successful tools by their transformed reference and print one labeled hint line per distinct form, so every advertised instruction is usable by the tool it names; the mixed-tool test asserts exactly that. The migration message compares transformed outputs instead of function identities (also per review) and stays syntax-neutral ('the openspec-propose skill') when detected tools disagree. Co-Authored-By: Claude Fable 5 --- src/core/init.ts | 41 +++++++++++++++++++++++++++-------------- src/core/migration.ts | 14 +++++++++----- test/core/init.test.ts | 20 +++++++++++++------- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index ef063f20b..e09212c3f 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,11 +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 { - getSkillReferenceTransformer, - getTransformerForTool, - transformToSkillReferences, -} from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -878,11 +874,30 @@ export class InitCommand { const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); const skillsOnlyHint = successfulTools.length > 0 && !commandsGenerated; - // Mixed selections may span invocation syntaxes; use a tool-specific - // form only when every selected tool agrees on it. - const hintTransformers = new Set(successfulTools.map((tool) => getSkillReferenceTransformer(tool.value))); - const hintTransformer = hintTransformers.size === 1 ? [...hintTransformers][0] : transformToSkillReferences; - const startReference = (command: string) => (skillsOnlyHint ? hintTransformer(command) : command); + // Each hint line must be a usable instruction for the tool it serves; + // when selected tools disagree on invocation syntax, print one line per + // distinct form, labeled with the tools it applies to. + const startInvocations = (command: string): Array<{ reference: string; toolNames?: string[] }> => { + if (!skillsOnlyHint) { + return [{ reference: command }]; + } + const referenceToTools = new Map(); + for (const tool of successfulTools) { + const reference = getSkillReferenceTransformer(tool.value)(command); + referenceToTools.set(reference, [...(referenceToTools.get(reference) ?? []), tool.name]); + } + if (referenceToTools.size === 1) { + return [{ reference: [...referenceToTools.keys()][0] }]; + } + return [...referenceToTools.entries()].map(([reference, toolNames]) => ({ reference, toolNames })); + }; + const printStartHints = (command: string): void => { + console.log(chalk.bold('Getting started:')); + for (const { reference, toolNames } of startInvocations(command)) { + const label = toolNames ? ` (${toolNames.join(', ')})` : ''; + console.log(` Start your first change: ${reference} "your idea"${label}`); + } + }; console.log(); if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) { // delivery=commands with tools that only support skills: nothing was @@ -896,11 +911,9 @@ export class InitCommand { ) ); } else if (activeWorkflows.includes('propose')) { - console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${startReference('/opsx:propose')} "your idea"`); + printStartHints('/opsx:propose'); } else if (activeWorkflows.includes('new')) { - console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${startReference('/opsx:new')} "your idea"`); + printStartHints('/opsx:new'); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } diff --git a/src/core/migration.ts b/src/core/migration.ts index e3e233fcd..1e51ea52b 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,7 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { getSkillReferenceTransformer, transformToSkillReferences } from '../utils/command-references.js'; +import { getSkillReferenceTransformer } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; @@ -210,10 +210,14 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); // Tools without a command adapter never get /opsx:* commands; point them // at the skill instead. Use a tool-specific invocation syntax only when - // every detected tool agrees on it. + // every detected tool produces the same reference; otherwise stay + // syntax-neutral rather than advertise a form that is wrong for one tool. const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); - const skillTransformers = new Set(tools.map((tool) => getSkillReferenceTransformer(tool.value))); - const skillTransformer = skillTransformers.size === 1 ? [...skillTransformers][0] : transformToSkillReferences; - const proposeReference = hasCommandSurface ? '/opsx:propose' : skillTransformer('/opsx:propose'); + const proposeReferences = new Set(tools.map((tool) => getSkillReferenceTransformer(tool.value)('/opsx:propose'))); + const proposeReference = hasCommandSurface + ? '/opsx:propose' + : 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/test/core/init.test.ts b/test/core/init.test.ts index 6d40660a2..67aa30910 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -994,9 +994,9 @@ describe('InitCommand - profile and detection features', () => { expect(logCalls.some((entry) => entry.includes('Restart your IDE'))).toBe(false); }); - it('should fall back to the default skill form when adapterless tools disagree on syntax', async () => { - // kimi documents /skill:, vibe documents / — a shared hint - // line cannot serve both, so the default form wins for mixed selections + 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); @@ -1014,10 +1014,16 @@ describe('InitCommand - profile and detection features', () => { expect(vibeSkill).not.toContain('/skill:'); 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('/openspec-propose'); - expect(startHint).not.toContain('/skill:'); - expect(startHint).not.toContain('/opsx:'); + 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 keep /opsx: command hints for adapter-backed tools under default delivery', async () => { From efda4c3aff4904495e133e7b6ae7f482515dc42a Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 09:58:10 -0500 Subject: [PATCH 7/9] fix(init): keep codex hints syntax-neutral (skills-invocable, no slash surface) Codex has no slash-command surface: docs direct users to .codex/skills/openspec-*. The getting-started hint and the one-time migration message now name the skill ('the openspec-propose skill') instead of advertising a /openspec-* form Codex does not accept, and the restart line only claims slash commands when commands were generated. Hint lines are also limited to tools that actually got skills: under delivery=commands, codex+kimi previously advertised /skill:openspec-propose for Kimi while .kimi-code was never created. Co-Authored-By: Claude Fable 5 --- src/core/init.ts | 48 ++++++++++++++++++--------- src/core/migration.ts | 11 ++++++- test/core/init.test.ts | 65 +++++++++++++++++++++++++++++++++++++ test/core/migration.test.ts | 29 ++++++++++++++++- 4 files changed, 135 insertions(+), 18 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index e09212c3f..5271def4d 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 { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; +import { getSkillReferenceTransformer, getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -876,26 +876,35 @@ export class InitCommand { const skillsOnlyHint = successfulTools.length > 0 && !commandsGenerated; // Each hint line must be a usable instruction for the tool it serves; // when selected tools disagree on invocation syntax, print one line per - // distinct form, labeled with the tools it applies to. - const startInvocations = (command: string): Array<{ reference: string; toolNames?: string[] }> => { + // distinct form, labeled with the tools it applies to. Skills-invocable + // tools (codex) have no slash invocation at all, so their hint names + // the skill instead of advertising a slash form. + const startHintLines = (command: string): string[] => { if (!skillsOnlyHint) { - return [{ reference: command }]; + return [`Start your first change: ${command} "your idea"`]; } - const referenceToTools = new Map(); - for (const tool of successfulTools) { - const reference = getSkillReferenceTransformer(tool.value)(command); - referenceToTools.set(reference, [...(referenceToTools.get(reference) ?? []), tool.name]); + const skillName = transformToSkillReferences(command).slice(1); + const hintToTools = new Map(); + // Only advertise instructions for tools that actually got skills: + // under delivery=commands a skills-invocable tool (codex) still + // generates them while adapterless tools generate nothing. + const skillTools = successfulTools.filter((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); + for (const tool of skillTools) { + const hint = + resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' + ? `Start your first change with the ${skillName} skill` + : `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`; + hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]); } - if (referenceToTools.size === 1) { - return [{ reference: [...referenceToTools.keys()][0] }]; + if (hintToTools.size === 1) { + return [[...hintToTools.keys()][0]]; } - return [...referenceToTools.entries()].map(([reference, toolNames]) => ({ reference, toolNames })); + return [...hintToTools.entries()].map(([hint, toolNames]) => `${hint} (${toolNames.join(', ')})`); }; const printStartHints = (command: string): void => { console.log(chalk.bold('Getting started:')); - for (const { reference, toolNames } of startInvocations(command)) { - const label = toolNames ? ` (${toolNames.join(', ')})` : ''; - console.log(` Start your first change: ${reference} "your idea"${label}`); + for (const line of startHintLines(command)) { + console.log(` ${line}`); } }; console.log(); @@ -924,10 +933,17 @@ export class InitCommand { console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`); // Restart instruction if any tools were configured and got a surface - // (when nothing was generated there is nothing a restart would pick up) + // (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 1e51ea52b..d42c404e1 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -8,6 +8,7 @@ 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 } 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'; @@ -212,8 +213,16 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi // at the skill instead. Use a tool-specific invocation syntax only when // every detected tool produces the same reference; otherwise stay // syntax-neutral rather than advertise a form that is wrong for one tool. + // Skills-invocable tools (codex) have no slash invocation, so they only + // ever get the syntax-neutral form. const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); - const proposeReferences = new Set(tools.map((tool) => getSkillReferenceTransformer(tool.value)('/opsx:propose'))); + const proposeReferences = new Set( + tools.map((tool) => + resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' + ? 'the openspec-propose skill' + : getSkillReferenceTransformer(tool.value)('/opsx:propose') + ) + ); const proposeReference = hasCommandSurface ? '/opsx:propose' : proposeReferences.size === 1 diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 67aa30910..cdba8e984 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -1026,6 +1026,71 @@ describe('InitCommand - profile and detection features', () => { } }); + 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); + }); + 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); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 409206e94..c2594f1ad 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'; @@ -135,6 +135,33 @@ 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 + const codexTool = AI_TOOLS.find((tool) => tool.value === 'codex'); + if (!codexTool) { + throw new Error('Codex tool definition not found'); + } + const skillFile = path.join(projectDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'); + await fsp.mkdir(path.dirname(skillFile), { recursive: true }); + await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + let logCalls: string[]; + try { + migrateIfNeeded(projectDir, [codexTool]); + logCalls = logSpy.mock.calls.flat().map(String); + } finally { + logSpy.mockRestore(); + } + + const message = logCalls.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('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'); From 3f681cd35afd38bc4c50b54c7a85b126f6cad661 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 10:11:34 -0500 Subject: [PATCH 8/9] fix(init): advertise a usable instruction for every configured tool Adversarial-review round fixes: - Mixed adapter-backed + skill-only selections (claude+kimi, claude+codex) printed a single unlabeled /opsx:propose hint that the skill-only tool cannot use; hints are now derived per tool from its generated surface and labeled when the selection disagrees. - The delivery=commands configuration correction keyed on the global aggregate, so a tool that got zero artifacts lost its correction as soon as any other tool generated something; it is now per-tool. - The migration message advertised /opsx:propose under an explicit 'delivery: skills' config where commands will never exist; the command form is now gated on the effective delivery. - Migration-message coverage extended (kimi, codex+kimi, delivery=skills, commands-installed); profile-describe init tests use randomUUID temp dirs like the first describe block. Co-Authored-By: Claude Fable 5 --- .changeset/adapterless-skill-references.md | 2 +- src/core/init.ts | 65 +++++++++------ src/core/migration.ts | 9 ++- test/core/init.test.ts | 57 +++++++++++++- test/core/migration.test.ts | 92 +++++++++++++++++----- 5 files changed, 175 insertions(+), 50 deletions(-) diff --git a/.changeset/adapterless-skill-references.md b/.changeset/adapterless-skill-references.md index 96c4b1744..bffe78285 100644 --- a/.changeset/adapterless-skill-references.md +++ b/.changeset/adapterless-skill-references.md @@ -2,4 +2,4 @@ '@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-*`). When `delivery: commands` would generate nothing for such tools, init now prints a configuration correction instead of a dead hint. The committed skills.sh distribution is regenerated with skill references (default `/openspec-*` form, as that channel installs skills only). +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/src/core/init.ts b/src/core/init.ts index 5271def4d..895c2270b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -873,29 +873,35 @@ export class InitCommand { const activeDelivery: Delivery = globalCfg.delivery ?? 'both'; const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)); const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); - const skillsOnlyHint = successfulTools.length > 0 && !commandsGenerated; - // Each hint line must be a usable instruction for the tool it serves; - // when selected tools disagree on invocation syntax, print one line per - // distinct form, labeled with the tools it applies to. Skills-invocable - // tools (codex) have no slash invocation at all, so their hint names - // the skill instead of advertising a slash form. + // 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[] => { - if (!skillsOnlyHint) { - return [`Start your first change: ${command} "your idea"`]; - } const skillName = transformToSkillReferences(command).slice(1); const hintToTools = new Map(); - // Only advertise instructions for tools that actually got skills: - // under delivery=commands a skills-invocable tool (codex) still - // generates them while adapterless tools generate nothing. - const skillTools = successfulTools.filter((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery)); - for (const tool of skillTools) { - const hint = - resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' - ? `Start your first change with the ${skillName} skill` - : `Start your first change: ${getSkillReferenceTransformer(tool.value)(command)} "your idea"`; + 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]]; } @@ -908,17 +914,28 @@ export class InitCommand { } }; console.log(); - if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) { - // delivery=commands with tools that only support skills: nothing was - // generated, so don't advertise an invocation that doesn't exist. + // 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: delivery is set to 'commands' but ` + - `${successfulTools.map((tool) => tool.name).join(', ')} ` + - `${successfulTools.length === 1 ? 'supports' : 'support'} only skills. ` + + `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')) { diff --git a/src/core/migration.ts b/src/core/migration.ts index d42c404e1..3302f2574 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -8,7 +8,7 @@ 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 } from './command-surface.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'; @@ -214,8 +214,11 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi // every detected tool produces the same reference; otherwise stay // syntax-neutral rather than advertise a form that is wrong for one tool. // Skills-invocable tools (codex) have no slash invocation, so they only - // ever get the syntax-neutral form. - const hasCommandSurface = tools.some((tool) => CommandAdapterRegistry.has(tool.value)); + // ever get the syntax-neutral form. Commands must also be allowed by the + // effective delivery: with delivery 'skills', /opsx:* will never exist + // even for adapter-backed tools. + const effectiveDelivery: Delivery = config.delivery ?? 'both'; + const hasCommandSurface = tools.some((tool) => shouldGenerateCommandsForTool(tool.value, effectiveDelivery)); const proposeReferences = new Set( tools.map((tool) => resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' diff --git a/test/core/init.test.ts b/test/core/init.test.ts index cdba8e984..8bfddd17b 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -646,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'); @@ -1089,6 +1089,59 @@ describe('InitCommand - profile and detection features', () => { 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 () => { diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index c2594f1ad..8780a2d55 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -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) { @@ -138,30 +156,64 @@ describe('migration', () => { 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 - const codexTool = AI_TOOLS.find((tool) => tool.value === 'codex'); - if (!codexTool) { - throw new Error('Codex tool definition not found'); - } - const skillFile = path.join(projectDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'); - await fsp.mkdir(path.dirname(skillFile), { recursive: true }); - await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - let logCalls: string[]; - try { - migrateIfNeeded(projectDir, [codexTool]); - logCalls = logSpy.mock.calls.flat().map(String); - } finally { - logSpy.mockRestore(); - } - - const message = logCalls.find((entry) => entry.includes('New in this version')); + 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('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'); From f5c2de14a87c7560fec09b99729a0d3a8805df75 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 21 Jul 2026 10:35:36 -0500 Subject: [PATCH 9/9] fix(update): derive migration and legacy-upgrade references per tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-time migration message collapsed mixed command + skill-only selections to /opsx:propose (Claude commands + a Kimi skill told the Kimi user to run a command it cannot invoke); the reference is now computed per detected tool and falls back to the syntax-neutral form on disagreement. The legacy-upgrade getting-started menu had the same capability blindness with hard-coded /opsx:new/continue/apply — a legacy Codex upgrade advertised commands Codex lost in #1283; menu lines are now derived the same way (byte-identical for command-tool upgrades). Co-Authored-By: Claude Fable 5 --- src/core/migration.ts | 37 ++++++++++++++++++------------------- src/core/update.ts | 34 +++++++++++++++++++++++++++++----- test/core/migration.test.ts | 14 ++++++++++++++ test/core/update.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/core/migration.ts b/src/core/migration.ts index 3302f2574..9334caeb4 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -209,27 +209,26 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi saveGlobalConfig(config); console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); - // Tools without a command adapter never get /opsx:* commands; point them - // at the skill instead. Use a tool-specific invocation syntax only when - // every detected tool produces the same reference; otherwise stay - // syntax-neutral rather than advertise a form that is wrong for one tool. - // Skills-invocable tools (codex) have no slash invocation, so they only - // ever get the syntax-neutral form. Commands must also be allowed by the - // effective delivery: with delivery 'skills', /opsx:* will never exist - // even for adapter-backed tools. + // 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 hasCommandSurface = tools.some((tool) => shouldGenerateCommandsForTool(tool.value, effectiveDelivery)); const proposeReferences = new Set( - tools.map((tool) => - resolveCommandSurfaceCapability(tool.value) === 'skills-invocable' - ? 'the openspec-propose skill' - : getSkillReferenceTransformer(tool.value)('/opsx:propose') - ) + 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 = hasCommandSurface - ? '/opsx:propose' - : proposeReferences.size === 1 - ? [...proposeReferences][0] - : 'the openspec-propose skill'; + 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 e404286b8..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, @@ -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')}`); } diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 8780a2d55..e1b6f4f7c 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -189,6 +189,20 @@ describe('migration', () => { 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 diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 2fcfeb950..df238b730 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1121,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: {},