From 5490c93dbd28e3b41a45584ed90d3c86fce7b8b9 Mon Sep 17 00:00:00 2001 From: YuXiang Zhu Date: Sun, 31 May 2026 20:06:54 +0800 Subject: [PATCH 1/4] fix(codex): prefer skills over deprecated prompts --- README.md | 4 +- docs/supported-tools.md | 10 ++-- src/core/command-generation/adapters/codex.ts | 11 ++-- src/core/init.ts | 54 ++++++++++++------- src/core/profile-sync-drift.ts | 15 ++++-- src/core/tool-delivery.ts | 21 ++++++++ src/core/update.ts | 42 ++++++++------- src/core/workspace/skills.ts | 8 ++- src/utils/command-references.ts | 25 +++++++++ src/utils/index.ts | 5 +- test/core/init.test.ts | 22 ++++++++ test/core/profile-sync-drift.test.ts | 44 +++++++++++---- test/core/update.test.ts | 2 +- test/utils/command-references.test.ts | 23 +++++++- 14 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 src/core/tool-delivery.ts diff --git a/README.md b/README.md index dcf6586b4b..e40f32ea3d 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ AI coding assistants are powerful but unpredictable when requirements live only - **Agree before you build** — human and AI align on specs before code gets written - **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks - **Work fluidly** — update any artifact anytime, no rigid phase gates -- **Use your tools** — works with 20+ AI assistants via slash commands +- **Use your tools** — works with 20+ AI assistants via skills and slash commands ### How we compare @@ -149,7 +149,7 @@ npm install -g @fission-ai/openspec@latest **Refresh agent instructions** -Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active: +Run this inside each project to regenerate AI guidance and ensure the latest skills and slash commands are active: ```bash openspec update diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..12bd9fef25 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -7,7 +7,7 @@ OpenSpec works with many AI coding assistants. When you run `openspec init`, Ope For each selected tool, OpenSpec can install: 1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md` -2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files +2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files, except Codex where custom prompts are deprecated By default, OpenSpec uses the `core` profile, which includes: - `propose` @@ -29,7 +29,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/.md` | | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-.md`\* | +| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (Codex custom prompts are deprecated; use `$openspec-*` skills) | | ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-.prompt` | | CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-.md` | @@ -37,7 +37,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Cursor (`cursor`) | `.cursor/skills/openspec-*/SKILL.md` | `.cursor/commands/opsx-.md` | | Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-.md` | | Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/.toml` | -| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-.prompt.md`\*\* | +| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-.prompt.md`\* | | iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-.md` | | Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-.md` | | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | @@ -53,9 +53,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | -\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. - -\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. ## Non-Interactive Setup diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts index 64e73550b9..91a153a0ae 100644 --- a/src/core/command-generation/adapters/codex.ts +++ b/src/core/command-generation/adapters/codex.ts @@ -1,10 +1,9 @@ /** - * Codex Command Adapter + * Codex Legacy Prompt Adapter * - * Formats commands for Codex following its frontmatter specification. - * Codex custom prompts live in the global home directory (~/.codex/prompts/) - * and are not shared through the repository. The CODEX_HOME env var can - * override the default ~/.codex location. + * Codex custom prompts are deprecated and are no longer generated for new + * setups. This adapter is kept so update/init can find and remove previously + * generated opsx prompt files from the global Codex home. */ import os from 'os'; @@ -21,7 +20,7 @@ function getCodexHome(): string { } /** - * Codex adapter for command generation. + * Codex adapter for legacy prompt cleanup. * File path: /prompts/opsx-.md (absolute, global) * Frontmatter: description, argument-hint */ diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f22..8f0569d77f 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -1,7 +1,7 @@ /** * Init Command * - * Sets up OpenSpec with Agent Skills and /opsx:* slash commands. + * Sets up OpenSpec with Agent Skills and supported tool command surfaces. * This is the unified setup command that replaces both the old init and experimental commands. */ @@ -11,12 +11,16 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption, } from './config.js'; +import { + getSkillInstructionTransformer, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from './tool-delivery.js'; import { PALETTE } from './styles/palette.js'; import { isInteractive } from '../utils/interactive.js'; import { serializeConfig } from './config-prompts.js'; @@ -516,18 +520,19 @@ export class InitCommand { const workflows = getProfileWorkflows(profile, globalConfig.workflows); // Get skill and command templates filtered by profile workflows - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + const skillTemplates = getSkillTemplates(workflows); + const commandContents = getCommandContents(workflows); // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); try { + const toolShouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const toolShouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + // Generate skill files if delivery includes skills - if (shouldGenerateSkills) { + if (toolShouldGenerateSkills) { // Use tool-specific skillsDir const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); @@ -537,21 +542,20 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for tools where filename = command name - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getSkillInstructionTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file await FileSystemUtils.writeFile(skillFile, skillContent); } } - if (!shouldGenerateSkills) { + if (!toolShouldGenerateSkills) { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); removedSkillCount += await this.removeSkillDirs(skillsDir); } // Generate commands if delivery includes commands - if (shouldGenerateCommands) { + if (toolShouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); @@ -564,7 +568,7 @@ export class InitCommand { commandsSkipped.push(tool.value); } } - if (!shouldGenerateCommands) { + if (!toolShouldGenerateCommands) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } @@ -656,8 +660,14 @@ export class InitCommand { const delivery: Delivery = globalConfig.delivery ?? 'both'; const workflows = getProfileWorkflows(profile, globalConfig.workflows); const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0; - const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0; + const hasSkillGeneratingTool = successfulTools.some((tool) => + shouldGenerateSkillsForTool(tool.value, delivery) + ); + const hasCommandGeneratingTool = successfulTools.some((tool) => + shouldGenerateCommandsForTool(tool.value, delivery) + ); + const skillCount = hasSkillGeneratingTool ? getSkillTemplates(workflows).length : 0; + const commandCount = hasCommandGeneratingTool ? getCommandContents(workflows).length : 0; if (skillCount > 0 && commandCount > 0) { console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); } else if (skillCount > 0) { @@ -700,13 +710,19 @@ export class InitCommand { const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; + const gettingStartedTools = [...results.createdTools, ...results.refreshedTools]; + const hasCommandSurface = gettingStartedTools.some((tool) => + shouldGenerateCommandsForTool(tool.value, globalCfg.delivery ?? 'both') + ); + const commandPrefix = hasCommandSurface ? '/opsx:' : '$openspec-'; + const newInvocation = hasCommandSurface ? '/opsx:new' : '$openspec-new-change'; console.log(); - if (activeWorkflows.includes('propose')) { + if (gettingStartedTools.length > 0 && activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); - } else if (activeWorkflows.includes('new')) { + console.log(` Start your first change: ${commandPrefix}propose "your idea"`); + } else if (gettingStartedTools.length > 0 && 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: ${newInvocation} "your idea"`); } else { console.log("Done. Run 'openspec config profile' to configure your workflows."); } @@ -719,7 +735,7 @@ export class InitCommand { // Restart instruction if any tools were configured if (results.createdTools.length > 0 || results.refreshedTools.length > 0) { console.log(); - console.log(chalk.white('Restart your IDE for slash commands to take effect.')); + console.log(chalk.white('Restart your IDE or agent for generated guidance to take effect.')); } console.log(); diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..8db5c1e84d 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -5,6 +5,10 @@ import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from './tool-delivery.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -98,8 +102,8 @@ export function hasToolProfileOrDeliveryDrift( const desiredWorkflowSet = new Set(knownDesiredWorkflows); const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); const adapter = CommandAdapterRegistry.get(toolId); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; + const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); if (shouldGenerateSkills) { for (const workflow of knownDesiredWorkflows) { @@ -226,11 +230,12 @@ export function hasProjectConfigDrift( } const desiredSet = new Set(toKnownWorkflows(desiredWorkflows)); - const includeSkills = delivery !== 'commands'; - const includeCommands = delivery !== 'skills'; for (const toolId of configuredTools) { - const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands }); + const installed = getInstalledWorkflowsForTool(projectPath, toolId, { + includeSkills: shouldGenerateSkillsForTool(toolId, delivery), + includeCommands: shouldGenerateCommandsForTool(toolId, delivery), + }); if (installed.some((workflow) => !desiredSet.has(workflow))) { return true; } diff --git a/src/core/tool-delivery.ts b/src/core/tool-delivery.ts new file mode 100644 index 0000000000..0c6306575b --- /dev/null +++ b/src/core/tool-delivery.ts @@ -0,0 +1,21 @@ +import type { Delivery } from './global-config.js'; +import { + transformToCodexSkillReferences, + transformToHyphenCommands, +} from '../utils/command-references.js'; + +export function shouldGenerateSkillsForTool(toolId: string, delivery: Delivery): boolean { + return toolId === 'codex' || delivery !== 'commands'; +} + +export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery): boolean { + return toolId !== 'codex' && delivery !== 'skills'; +} + +export function getSkillInstructionTransformer( + toolId: string +): ((instructions: string) => string) | undefined { + if (toolId === 'codex') return transformToCodexSkillReferences; + if (toolId === 'opencode' || toolId === 'pi') return transformToHyphenCommands; + return undefined; +} diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b1..2259c938e1 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -11,8 +11,12 @@ import ora from 'ora'; import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; -import { transformToHyphenCommands } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; +import { + getSkillInstructionTransformer, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from './tool-delivery.js'; import { generateCommands, CommandAdapterRegistry, @@ -102,8 +106,6 @@ export class UpdateCommand { const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => (ALL_WORKFLOWS as readonly string[]).includes(workflow) ); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config const newlyConfiguredTools = await this.handleLegacyCleanup( @@ -169,8 +171,8 @@ export class UpdateCommand { console.log(); // 9. Determine what to generate based on delivery - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const skillTemplates = getSkillTemplates(desiredWorkflows); + const commandContents = getCommandContents(desiredWorkflows); // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; @@ -189,15 +191,16 @@ export class UpdateCommand { try { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const toolShouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); + const toolShouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); // Generate skill files if delivery includes skills - if (shouldGenerateSkills) { + if (toolShouldGenerateSkills) { for (const { template, dirName } of skillTemplates) { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getSkillInstructionTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -206,12 +209,12 @@ export class UpdateCommand { } // Delete skill directories if delivery is commands-only - if (!shouldGenerateSkills) { + if (!toolShouldGenerateSkills) { removedSkillCount += await this.removeSkillDirs(skillsDir); } // Generate commands if delivery includes commands - if (shouldGenerateCommands) { + if (toolShouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); @@ -230,7 +233,7 @@ export class UpdateCommand { } // Delete command files if delivery is skills-only - if (!shouldGenerateCommands) { + if (!toolShouldGenerateCommands) { removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); } @@ -293,7 +296,7 @@ export class UpdateCommand { } console.log(); - console.log(chalk.dim('Restart your IDE for changes to take effect.')); + console.log(chalk.dim('Restart your IDE or agent for changes to take effect.')); } /** @@ -670,10 +673,8 @@ export class UpdateCommand { // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const skillTemplates = getSkillTemplates(desiredWorkflows); + const commandContents = getCommandContents(desiredWorkflows); for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -683,22 +684,23 @@ export class UpdateCommand { try { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const toolShouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); + const toolShouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); // Create skill files when delivery includes skills - if (shouldGenerateSkills) { + if (toolShouldGenerateSkills) { for (const { template, dirName } of skillTemplates) { const skillDir = path.join(skillsDir, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; + const transformer = getSkillInstructionTransformer(tool.value); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } } // Create commands when delivery includes commands - if (shouldGenerateCommands) { + if (toolShouldGenerateCommands) { const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); diff --git a/src/core/workspace/skills.ts b/src/core/workspace/skills.ts index 9caea04a9d..3cdc095d41 100644 --- a/src/core/workspace/skills.ts +++ b/src/core/workspace/skills.ts @@ -2,8 +2,8 @@ import * as nodeFs from 'node:fs'; import { createRequire } from 'node:module'; import { FileSystemUtils } from '../../utils/file-system.js'; -import { transformToHyphenCommands } from '../../utils/command-references.js'; import { AI_TOOLS, type AIToolOption } from '../config.js'; +import { getSkillInstructionTransformer } from '../tool-delivery.js'; import { getGlobalConfig, type Delivery, type Profile } from '../global-config.js'; import { getProfileWorkflows } from '../profiles.js'; import { @@ -353,8 +353,7 @@ export async function generateWorkspaceAgentSkills( try { const skillsDir = getWorkspaceSkillDirectoryForTool(workspaceRoot, tool); - const transformer = - tool.value === 'opencode' || tool.value === 'pi' ? transformToHyphenCommands : undefined; + const transformer = getSkillInstructionTransformer(tool.value); for (const { template, dirName } of skillTemplates) { const skillFile = FileSystemUtils.joinPath(skillsDir, dirName, 'SKILL.md'); @@ -465,8 +464,7 @@ export async function updateWorkspaceAgentSkills( try { const skillsDir = getWorkspaceSkillDirectoryForTool(workspaceRoot, tool); - const transformer = - tool.value === 'opencode' || tool.value === 'pi' ? transformToHyphenCommands : undefined; + const transformer = getSkillInstructionTransformer(tool.value); for (const { template, dirName } of skillTemplates) { const skillFile = FileSystemUtils.joinPath(skillsDir, dirName, 'SKILL.md'); diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index bfa49b9ff0..abee51c1da 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -18,3 +18,28 @@ export function transformToHyphenCommands(text: string): string { return text.replace(/\/opsx:/g, '/opsx-'); } + +const OPSX_TO_CODEX_SKILL: Record = { + propose: '$openspec-propose', + explore: '$openspec-explore', + new: '$openspec-new-change', + continue: '$openspec-continue-change', + apply: '$openspec-apply-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', +}; + +/** + * Transforms OpenSpec slash-command references to Codex skill invocations. + * Codex custom prompts are deprecated, so generated Codex skills should point + * users at the supported `$openspec-*` skill surface instead of `/opsx:*`. + */ +export function transformToCodexSkillReferences(text: string): string { + return text.replace(/\/opsx[:\-]([a-z][a-z-]*)/g, (match, commandId: string) => { + return OPSX_TO_CODEX_SKILL[commandId] ?? match; + }); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e77ddf4766..c4f893c516 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,4 +15,7 @@ export { export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities -export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file +export { + transformToCodexSkillReferences, + transformToHyphenCommands, +} from './command-references.js'; diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed1..90dc77dcf4 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -168,6 +168,28 @@ describe('InitCommand', () => { expect(await fileExists(skillFile)).toBe(true); }); + it('should install Codex skills without generating deprecated prompt commands', async () => { + const codexHome = path.join(testDir, 'codex-home'); + process.env.CODEX_HOME = codexHome; + + const legacyPrompt = path.join(codexHome, 'prompts', 'opsx-apply.md'); + await fs.mkdir(path.dirname(legacyPrompt), { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy prompt'); + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const content = await fs.readFile(skillFile, 'utf-8'); + expect(content).toContain('$openspec-apply-change'); + expect(content).not.toContain('/opsx:'); + expect(content).not.toContain('/opsx-'); + expect(await fileExists(legacyPrompt)).toBe(false); + expect(await directoryExists(path.join(codexHome, 'prompts'))).toBe(true); + }); + it('should support Kimi CLI as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts index 116a6e5706..b5f46f2068 100644 --- a/test/core/profile-sync-drift.test.ts +++ b/test/core/profile-sync-drift.test.ts @@ -9,31 +9,31 @@ import { import { CORE_WORKFLOWS } from '../../src/core/profiles.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js'; -function writeSkill(projectDir: string, workflowId: string): void { +function writeSkill(projectDir: string, workflowId: string, toolDir = '.claude'): void { const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId as keyof typeof WORKFLOW_TO_SKILL_DIR]; - const skillPath = path.join(projectDir, '.claude', 'skills', skillDirName, 'SKILL.md'); + const skillPath = path.join(projectDir, toolDir, 'skills', skillDirName, 'SKILL.md'); fs.mkdirSync(path.dirname(skillPath), { recursive: true }); fs.writeFileSync(skillPath, `name: ${skillDirName}\n`); } -function writeCommand(projectDir: string, workflowId: string): void { - const adapter = CommandAdapterRegistry.get('claude'); - if (!adapter) throw new Error('Claude adapter unavailable in test environment'); +function writeCommand(projectDir: string, workflowId: string, toolId = 'claude'): void { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) throw new Error(`${toolId} adapter unavailable in test environment`); const cmdPath = adapter.getFilePath(workflowId); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectDir, cmdPath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, `# ${workflowId}\n`); } -function setupCoreSkills(projectDir: string): void { +function setupCoreSkills(projectDir: string, toolDir = '.claude'): void { for (const workflow of CORE_WORKFLOWS) { - writeSkill(projectDir, workflow); + writeSkill(projectDir, workflow, toolDir); } } -function setupCoreCommands(projectDir: string): void { +function setupCoreCommands(projectDir: string, toolId = 'claude'): void { for (const workflow of CORE_WORKFLOWS) { - writeCommand(projectDir, workflow); + writeCommand(projectDir, workflow, toolId); } } @@ -89,4 +89,30 @@ describe('profile sync drift detection', () => { const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); expect(hasDrift).toBe(true); }); + + it('treats Codex as skills-only even when delivery is commands', () => { + setupCoreSkills(tempDir, '.codex'); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'commands'); + expect(hasDrift).toBe(false); + }); + + it('detects deprecated Codex prompt files as drift', () => { + const originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = path.join(tempDir, 'codex-home'); + + try { + setupCoreSkills(tempDir, '.codex'); + writeCommand(tempDir, 'apply', 'codex'); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); + expect(hasDrift).toBe(true); + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + } + }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..e5030331ca 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -581,7 +581,7 @@ Old instructions content await updateCommand.execute(testDir); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Restart your IDE') + expect.stringContaining('Restart your IDE or agent') ); consoleSpy.mockRestore(); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85b..fc59443462 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { transformToHyphenCommands } from '../../src/utils/command-references.js'; +import { + transformToCodexSkillReferences, + transformToHyphenCommands, +} from '../../src/utils/command-references.js'; describe('transformToHyphenCommands', () => { describe('basic transformations', () => { @@ -81,3 +84,21 @@ Finally /opsx-apply to implement`; } }); }); + +describe('transformToCodexSkillReferences', () => { + it('should transform slash command references to Codex skill invocations', () => { + const input = 'Run /opsx:propose, then /opsx:apply and /opsx:archive.'; + + expect(transformToCodexSkillReferences(input)).toBe( + 'Run $openspec-propose, then $openspec-apply-change and $openspec-archive-change.' + ); + }); + + it('should transform hyphenated opsx references to Codex skill invocations', () => { + const input = 'Use /opsx-new, /opsx-continue, /opsx-bulk-archive, and /opsx-onboard.'; + + expect(transformToCodexSkillReferences(input)).toBe( + 'Use $openspec-new-change, $openspec-continue-change, $openspec-bulk-archive-change, and $openspec-onboard.' + ); + }); +}); From 03da339008ea812fe1e5d6d060a0a2eed2c72da5 Mon Sep 17 00:00:00 2001 From: YuXiang Zhu Date: Sun, 31 May 2026 21:07:05 +0800 Subject: [PATCH 2/4] fix(codex): address skills-first review --- README.md | 2 +- src/core/init.ts | 28 ++++++++++++---- src/core/update.ts | 25 +++++++++++--- test/core/init.test.ts | 14 ++++++++ test/core/update.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e40f32ea3d..0623dce22d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ npm install -g @fission-ai/openspec@latest **Refresh agent instructions** -Run this inside each project to regenerate AI guidance and ensure the latest skills and slash commands are active: +Run this inside each project to regenerate AI guidance and ensure the latest skills and/or slash commands (tool-dependent) are active: ```bash openspec update diff --git a/src/core/init.ts b/src/core/init.ts index 8f0569d77f..ddb812901f 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -687,10 +687,10 @@ export class InitCommand { console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`)); } if (results.removedCommandCount > 0) { - console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`)); + console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (commands disabled for selected tools)`)); } if (results.removedSkillCount > 0) { - console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); + console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (skills disabled for selected tools)`)); } // Config status @@ -714,15 +714,31 @@ export class InitCommand { const hasCommandSurface = gettingStartedTools.some((tool) => shouldGenerateCommandsForTool(tool.value, globalCfg.delivery ?? 'both') ); - const commandPrefix = hasCommandSurface ? '/opsx:' : '$openspec-'; - const newInvocation = hasCommandSurface ? '/opsx:new' : '$openspec-new-change'; + const hasSkillOnlySurface = gettingStartedTools.some((tool) => + shouldGenerateSkillsForTool(tool.value, globalCfg.delivery ?? 'both') && + !shouldGenerateCommandsForTool(tool.value, globalCfg.delivery ?? 'both') + ); console.log(); if (gettingStartedTools.length > 0 && activeWorkflows.includes('propose')) { console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${commandPrefix}propose "your idea"`); + if (hasCommandSurface && hasSkillOnlySurface) { + console.log(` Start your first change: /opsx:propose "your idea"`); + console.log(` Or with skills: $openspec-propose "your idea"`); + } else if (hasCommandSurface) { + console.log(` Start your first change: /opsx:propose "your idea"`); + } else { + console.log(` Start your first change: $openspec-propose "your idea"`); + } } else if (gettingStartedTools.length > 0 && activeWorkflows.includes('new')) { console.log(chalk.bold('Getting started:')); - console.log(` Start your first change: ${newInvocation} "your idea"`); + if (hasCommandSurface && hasSkillOnlySurface) { + console.log(` Start your first change: /opsx:new "your idea"`); + console.log(` Or with skills: $openspec-new-change "your idea"`); + } else if (hasCommandSurface) { + console.log(` Start your first change: /opsx:new "your idea"`); + } else { + console.log(` Start your first change: $openspec-new-change "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 2259c938e1..34836ecdaf 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -257,10 +257,10 @@ export class UpdateCommand { console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`)); } if (removedCommandCount > 0) { - console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`)); + console.log(chalk.dim(`Removed: ${removedCommandCount} command files (commands disabled for selected tools)`)); } if (removedSkillCount > 0) { - console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`)); + console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (skills disabled for selected tools)`)); } if (removedDeselectedCommandCount > 0) { console.log(chalk.dim(`Removed: ${removedDeselectedCommandCount} command files (deselected workflows)`)); @@ -271,11 +271,26 @@ export class UpdateCommand { // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { + const hasCommandSurface = newlyConfiguredTools.some((toolId) => + shouldGenerateCommandsForTool(toolId, delivery) + ); + const hasSkillOnlySurface = newlyConfiguredTools.some((toolId) => + shouldGenerateSkillsForTool(toolId, delivery) && + !shouldGenerateCommandsForTool(toolId, delivery) + ); + 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'); + if (hasCommandSurface) { + console.log(' /opsx:new Start a new change'); + console.log(' /opsx:continue Create the next artifact'); + console.log(' /opsx:apply Implement tasks'); + } + if (hasSkillOnlySurface) { + console.log(' $openspec-new-change Start a new change'); + console.log(' $openspec-continue-change Create the next artifact'); + console.log(' $openspec-apply-change Implement tasks'); + } console.log(); console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 90dc77dcf4..03a34f3801 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -190,6 +190,20 @@ describe('InitCommand', () => { expect(await directoryExists(path.join(codexHome, 'prompts'))).toBe(true); }); + it('should show both command and skill invocations when mixed tools have different surfaces', async () => { + const codexHome = path.join(testDir, 'codex-home'); + process.env.CODEX_HOME = codexHome; + + const initCommand = new InitCommand({ tools: 'claude,codex', force: true }); + await initCommand.execute(testDir); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(logCalls).toEqual(expect.arrayContaining([ + expect.stringContaining('/opsx:propose "your idea"'), + expect.stringContaining('$openspec-propose "your idea"'), + ])); + }); + it('should support Kimi CLI as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index e5030331ca..ab3b48f34b 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -251,6 +251,45 @@ Old instructions content } }); + it('should report per-tool command removal without blaming global delivery', async () => { + const previousCodexHome = process.env.CODEX_HOME; + const codexHome = path.join(testDir, 'codex-home'); + process.env.CODEX_HOME = codexHome; + + try { + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old content' + ); + + const promptFile = path.join(codexHome, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(promptFile), { recursive: true }); + await fs.writeFile(promptFile, 'old prompt'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(promptFile)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('command files (commands disabled for selected tools)') + ); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('command files (delivery: skills)') + ); + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + } + }); + }); describe('multi-tool support', () => { @@ -1157,6 +1196,37 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should show Codex skill invocations when upgrading legacy Codex artifacts', async () => { + await fs.mkdir(path.join(testDir, '.codex', 'prompts'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.codex', 'prompts', 'openspec-new.md'), + 'old codex prompt' + ); + + const consoleSpy = vi.spyOn(console, 'log'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Getting started') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-new-change') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-continue-change') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-apply-change') + ); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('/opsx:new') + ); + + consoleSpy.mockRestore(); + }); + it('should upgrade multiple legacy tools with --force', async () => { // Create legacy command directories for Claude and Cursor await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true }); From 95235b7b14767a5f7064e0c7593c3d57f2d2e263 Mon Sep 17 00:00:00 2001 From: YuXiang Zhu Date: Sun, 31 May 2026 23:22:59 +0800 Subject: [PATCH 3/4] fix(codex): detect global legacy prompts --- README.md | 5 ++++ src/core/legacy-cleanup.ts | 43 +++++++++++++++++++++++++++- test/core/update.test.ts | 57 ++++++++++++++++++++++---------------- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0623dce22d..409e3378e3 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,11 @@ Run this inside each project to regenerate AI guidance and ensure the latest ski openspec update ``` +After refresh, use the invocation style for your tool: + +- Codex skills: `$openspec-propose "your idea"` +- Non-Codex slash-command tools: `/opsx:propose "your idea"` + ## Usage Notes **Model selection**: OpenSpec works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation. diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index f3cbb560e1..c62081a58a 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -4,6 +4,7 @@ */ import path from 'path'; +import os from 'os'; import { promises as fs } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; @@ -202,9 +203,37 @@ export async function detectLegacySlashCommands( } } + files.push(...await findLegacyCodexPromptFiles()); + return { directories, files }; } +/** + * Returns the Codex home directory. + * Respects the CODEX_HOME env var, defaulting to ~/.codex. + */ +function getCodexHome(): string { + const envHome = process.env.CODEX_HOME?.trim(); + return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex')); +} + +async function findLegacyCodexPromptFiles(): Promise { + const promptsDir = path.join(getCodexHome(), 'prompts'); + + if (!(await FileSystemUtils.directoryExists(promptsDir))) { + return []; + } + + try { + const entries = await fs.readdir(promptsDir); + return entries + .filter((entry) => /^opsx-.*\.md$/.test(entry)) + .map((entry) => path.join(promptsDir, entry)); + } catch { + return []; + } +} + /** * Finds legacy slash command files matching a glob pattern. * @@ -401,7 +430,9 @@ export async function cleanupLegacyArtifacts( // Delete legacy slash command files (these are 100% OpenSpec-managed) for (const filePath of detection.slashCommandFiles) { - const fullPath = FileSystemUtils.joinPath(projectPath, filePath); + const fullPath = path.isAbsolute(filePath) + ? filePath + : FileSystemUtils.joinPath(projectPath, filePath); try { await fs.unlink(fullPath); result.deletedFiles.push(filePath); @@ -590,6 +621,7 @@ export function formatDetectionSummary(detection: LegacyDetectionResult): string */ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] { const tools = new Set(); + const codexPromptsDir = path.join(getCodexHome(), 'prompts').replace(/\\/g, '/'); // Match directories to tool IDs for (const dir of detection.slashCommandDirs) { @@ -605,6 +637,15 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s for (const file of detection.slashCommandFiles) { // Normalize file path to use forward slashes for consistent matching (Windows compatibility) const normalizedFile = file.replace(/\\/g, '/'); + if ( + path.isAbsolute(file) && + normalizedFile.startsWith(`${codexPromptsDir}/`) && + /^opsx-.*\.md$/.test(path.basename(file)) + ) { + tools.add('codex'); + continue; + } + for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'files' && pattern.pattern) { // Convert glob pattern to regex for matching diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ab3b48f34b..f73ce3010f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1197,34 +1197,43 @@ More user content after markers. }); it('should show Codex skill invocations when upgrading legacy Codex artifacts', async () => { - await fs.mkdir(path.join(testDir, '.codex', 'prompts'), { recursive: true }); - await fs.writeFile( - path.join(testDir, '.codex', 'prompts', 'openspec-new.md'), - 'old codex prompt' - ); - + const previousCodexHome = process.env.CODEX_HOME; + const codexHome = path.join(testDir, 'codex-home'); + process.env.CODEX_HOME = codexHome; const consoleSpy = vi.spyOn(console, 'log'); - const forceUpdateCommand = new UpdateCommand({ force: true }); - await forceUpdateCommand.execute(testDir); + try { + const legacyPrompt = path.join(codexHome, 'prompts', 'opsx-new.md'); + await fs.mkdir(path.dirname(legacyPrompt), { recursive: true }); + await fs.writeFile(legacyPrompt, 'old codex prompt'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Getting started') - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('$openspec-new-change') - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('$openspec-continue-change') - ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('$openspec-apply-change') - ); - expect(consoleSpy).not.toHaveBeenCalledWith( - expect.stringContaining('/opsx:new') - ); + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); - consoleSpy.mockRestore(); + expect(await FileSystemUtils.fileExists(legacyPrompt)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Getting started') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-new-change') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-continue-change') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('$openspec-apply-change') + ); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('/opsx:new') + ); + } finally { + consoleSpy.mockRestore(); + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + } }); it('should upgrade multiple legacy tools with --force', async () => { From f7a1938ee070ac5b8863f9dce38992f21d31c6a6 Mon Sep 17 00:00:00 2001 From: YuXiang Zhu Date: Mon, 1 Jun 2026 16:14:31 +0800 Subject: [PATCH 4/4] fix(codex): avoid partial command transforms --- src/utils/command-references.ts | 2 +- test/utils/command-references.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index abee51c1da..44c053c625 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -39,7 +39,7 @@ const OPSX_TO_CODEX_SKILL: Record = { * users at the supported `$openspec-*` skill surface instead of `/opsx:*`. */ export function transformToCodexSkillReferences(text: string): string { - return text.replace(/\/opsx[:\-]([a-z][a-z-]*)/g, (match, commandId: string) => { + return text.replace(/\/opsx[:\-]([a-z][a-z-]*)(?![a-z0-9-])/g, (match, commandId: string) => { return OPSX_TO_CODEX_SKILL[commandId] ?? match; }); } diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index fc59443462..ceb05ae76c 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -101,4 +101,10 @@ describe('transformToCodexSkillReferences', () => { 'Use $openspec-new-change, $openspec-continue-change, $openspec-bulk-archive-change, and $openspec-onboard.' ); }); + + it('should not transform partial command references', () => { + const input = 'Ignore /opsx:apply2, /opsx-apply-extra, and /opsx:bulk-archive2.'; + + expect(transformToCodexSkillReferences(input)).toBe(input); + }); });