diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index 22e09dd77..328eca609 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -104,7 +104,7 @@ works too, for the tools that surface slash commands at all. When you run `openspec init` (or `openspec update`), OpenSpec writes small files into your project so your AI tool can find the workflow. Depending on your tool and settings, these are **skills**, **commands**, or both. - **Skills** live in places like `.claude/skills/openspec-*/SKILL.md`. They're the emerging cross-tool standard: a folder of instructions your assistant auto-detects. -- **Commands** live in places like `.cursor/commands/opsx-.md` or `.claude/commands/opsx/.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.codex/skills/openspec-*`. +- **Commands** live in places like `.cursor/commands/opsx-.md` or `.claude/commands/opsx/.md` — the layout is the tool's, and it decides how you type the command. They're the older per-tool slash command files. Codex does not get generated command files; use `.agents/skills/openspec-*`. You don't have to care which one your tool uses. You just type the slash command and it works. But knowing these files exist helps when something goes wrong: if your commands vanish, it usually means these files are missing or stale, and `openspec update` regenerates them. diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 57afdbb4b..020f1b8c0 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -47,7 +47,7 @@ Only OpenSpec-managed files that are being replaced: - Cline: `.clinerules/workflows/openspec-*.md` - Roo: `.roo/commands/openspec-*.md` - GitHub Copilot: `.github/prompts/openspec-*.prompt.md` (IDE extensions only; not supported in Copilot CLI) -- Codex: OpenSpec now uses `.codex/skills/openspec-*`; legacy cleanup only targets OpenSpec's allowlisted prompt filenames in `$CODEX_HOME/prompts` or `~/.codex/prompts`, and only removes them after replacement skills exist. +- Codex: OpenSpec now uses the canonical `.agents/skills/openspec-*` path. OpenSpec-managed `SKILL.md` files under the former `.codex/skills` path are reconciled only after replacements exist; custom files and divergent copies stay in place. If an unmarked `.agents` tree already contains OpenSpec skills, OpenSpec preserves its existing Codex (`$openspec-*`) or generic (`/openspec-*`) rendering instead of guessing from the legacy directory. Select `codex` explicitly with `openspec init` to switch ownership. Legacy prompt cleanup still targets only OpenSpec's allowlisted filenames in `$CODEX_HOME/prompts` or `~/.codex/prompts`. - And others (Augment, Continue, Amazon Q, etc.) The migration detects whichever tools you have configured and cleans up their legacy files. @@ -157,7 +157,7 @@ openspec init --force --tools claude The `--force` flag skips prompts and auto-accepts cleanup. -This includes cleanup of OpenSpec-managed Codex prompt files in the global Codex prompt directory. Cleanup only targets OpenSpec's allowlisted legacy Codex prompt filenames, removes them only after replacement `.codex/skills/openspec-*` skills exist, and preserves all other files. +This includes cleanup of OpenSpec-managed Codex prompt files in the global Codex prompt directory. Cleanup only targets OpenSpec's allowlisted legacy Codex prompt filenames, removes them only after replacement `.agents/skills/openspec-*` skills exist, and preserves all other files. --- @@ -411,7 +411,7 @@ OPSX uses the emerging **skills** standard: Skills are recognized across multiple AI coding tools and provide richer metadata. -Codex is skills-only in OPSX. OpenSpec no longer generates Codex custom prompt files; use the generated `.codex/skills/openspec-*` directories instead. +Codex is skills-only in OPSX. OpenSpec no longer generates Codex custom prompt files; use the generated `.agents/skills/openspec-*` directories instead. --- diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 992e1a3c1..78837cb97 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -9,7 +9,7 @@ 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 -Codex is skills-only: OpenSpec installs `.codex/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. +Codex is skills-only: OpenSpec installs `.agents/skills/openspec-*/SKILL.md` for Codex even when delivery is set to `commands`, and it does not generate Codex custom prompt files. Existing OpenSpec-managed skills under the legacy `.codex/skills` path are reconciled after their replacements are written; custom and divergent files are preserved. By default, OpenSpec uses the `core` profile, which includes: - `propose` @@ -72,7 +72,7 @@ to read the hint. | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `.codex/skills/openspec-*`) | +| Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `$openspec-*`) | | Devin Desktop, formerly Windsurf (`devin`) | `.devin/skills/openspec-*/SKILL.md` | `.devin/workflows/opsx-.md`\*\*\*\* | | 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` | @@ -124,7 +124,12 @@ shared root many agent tools read, instead of a tool-specific directory. | Several agents on one repo, all reading `.agents/skills` | `agents` — one skill tree instead of one per tool | | Your tool isn't listed yet but reads `.agents/skills` | `agents` | -Selecting it alongside a tool-specific ID is fine; each writes to its own root. +Selecting it alongside a tool-specific ID is fine; each normally writes to its +own root. Codex is the exception because it uses the same canonical `.agents` +root. If both `codex` and `agents` are selected, OpenSpec keeps one +Codex-led tree. Its handoffs name both `$openspec-*` for Codex and +`/openspec-*` for other agents, so `--tools all` and existing multi-agent +setups keep working without two writers overwriting the same files. OpenSpec also offers it automatically once a project has a `.agents/skills/` directory — a bare `.agents/` is not enough, since tools use that root for rules and subagent definitions too. Note `.agents` is not `.agent`: the singular @@ -145,9 +150,16 @@ Two things to know: Because `.agents/skills/` is shared, it is worth knowing what OpenSpec claims there: it writes, refreshes, and removes only the `openspec-*` skill directories for your -selected workflows. Anything else in that directory is left alone. Treat the -`openspec-*` names as OpenSpec's — edits inside them are replaced on the next -`openspec update`, the same as for every other tool. +selected workflows, plus an `.openspec-target` marker that records whether Codex +or the vendor-neutral target rendered that shared tree. Anything else in that +directory is left alone. Treat the `openspec-*` names and marker as OpenSpec's — +edits inside them are replaced on the next `openspec update`, the same as for +every other tool. + +For pre-marker projects, OpenSpec infers ownership from managed skill references: +`$openspec-*` means Codex and `/openspec-*` means the vendor-neutral target. A +generic canonical tree alongside legacy `.codex/skills` is treated as an older +dual-target install and consolidated into the compatible shared tree. ## Non-Interactive Setup diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7579c1912..b6e65eec8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -173,7 +173,7 @@ You're in CI or a non-interactive shell, and OpenSpec found old files to clean u openspec init --force ``` -For Codex, OpenSpec may detect old managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts`. That cleanup is limited to OpenSpec's allowlisted legacy Codex prompt filenames, and non-interactive `openspec init` removes only the files whose replacement `.codex/skills/openspec-*` skills exist. Non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`. +For Codex, OpenSpec may detect old managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts`. That cleanup is limited to OpenSpec's allowlisted legacy Codex prompt filenames, and non-interactive `openspec init` removes only the files whose replacement `.agents/skills/openspec-*` skills exist. Non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`. ### Commands didn't appear after migrating diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts index cd2f9c6cd..84989ca88 100644 --- a/src/core/available-tools.ts +++ b/src/core/available-tools.ts @@ -8,6 +8,7 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS, type AIToolOption } from './config.js'; +import { reconcileSharedSkillTargets } from './shared-skill-target.js'; import { SKILL_NAMES } from './shared/tool-detection.js'; import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; @@ -16,11 +17,11 @@ import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.j * the tools that are present. * * For tools with `detectionPaths`, checks those specific paths (files or - * directories). Otherwise checks for the tool's `skillsDir` directory at - * the project root. Only tools with a `skillsDir` property are considered. + * directories). Otherwise checks the project's `skillsDir`, or managed skill + * files in the user's home directory for a global skill target. */ export function getAvailableTools(projectPath: string): AIToolOption[] { - return AI_TOOLS.filter((tool) => { + const available = AI_TOOLS.filter((tool) => { if (!toolSupportsSkills(tool)) return false; if (tool.globalSkillsDir) { @@ -51,4 +52,13 @@ export function getAvailableTools(projectPath: string): AIToolOption[] { return false; } }); + const activeProjectTools = new Set( + reconcileSharedSkillTargets( + projectPath, + available.filter((tool) => tool.skillsDir) + ).map((tool) => tool.value) + ); + return available.filter( + (tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value) + ); } diff --git a/src/core/config.ts b/src/core/config.ts index 22f64139f..876dda4f1 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,20 @@ export const OPENSPEC_DIR_NAME = 'openspec'; +export const OPENSPEC_SKILL_NAMES = [ + 'openspec-explore', + 'openspec-new-change', + 'openspec-continue-change', + 'openspec-apply-change', + 'openspec-update-change', + 'openspec-ff-change', + 'openspec-sync-specs', + 'openspec-archive-change', + 'openspec-bulk-archive-change', + 'openspec-verify-change', + 'openspec-onboard', + 'openspec-propose', +] as const; + export const OPENSPEC_MARKERS = { start: '', end: '' @@ -15,6 +30,7 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + legacySkillsDirs?: string[]; // Former roots read for detection and migrated after replacement globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update @@ -28,7 +44,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, { name: 'CodeArts', value: 'codeartsagent', available: true, successLabel: 'CodeArts', skillsDir: '.codeartsdoer' }, - { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' }, + { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.agents', legacySkillsDirs: ['.codex'], detectionPaths: ['.agents/skills', '.codex/skills'] }, { name: 'Devin Desktop (formerly Windsurf)', value: 'devin', available: true, successLabel: 'Devin Desktop', skillsDir: '.devin', detectionPaths: ['.devin', '.windsurf'] }, { name: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' }, { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' }, diff --git a/src/core/init.ts b/src/core/init.ts index 6ec4ea1c9..93e428e2c 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -54,6 +54,7 @@ import { import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; +import { writeSharedSkillTarget } from './shared-skill-target.js'; import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, @@ -614,7 +615,18 @@ export class InitCommand { ): ValidatedInitTool[] { const validatedTools: ValidatedInitTool[] = []; - for (const toolId of toolIds) { + const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents') + ? toolIds.filter((toolId) => toolId !== 'agents') + : toolIds; + if (reconciledToolIds.length !== toolIds.length) { + console.log( + chalk.dim( + 'Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.' + ) + ); + } + + for (const toolId of reconciledToolIds) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool) { const validToolIds = getToolsWithSkillsDir(); @@ -759,9 +771,13 @@ export class InitCommand { FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(projectPath, tool.value); } if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) { removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath); + // Retain an explicit selection even when this delivery mode produces + // no skills, so a divergent legacy sibling cannot reclaim ownership. + writeSharedSkillTarget(projectPath, tool.value); } // Generate commands if delivery includes commands @@ -802,6 +818,20 @@ export class InitCommand { } } + for (const tool of [...createdTools, ...refreshedTools]) { + for (const migration of migrateLegacyToolDirs( + projectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + const kept = keptInPlaceNotice(migration); + if (kept) console.log(chalk.dim(kept)); + } + } + return { createdTools, refreshedTools, diff --git a/src/core/migration.ts b/src/core/migration.ts index 74a92eefa..b4b094d68 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -17,6 +17,9 @@ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { COMMAND_IDS } from './shared/tool-detection.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { isSharedSkillTargetActive } from './shared-skill-target.js'; +import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js'; import path from 'path'; import * as fs from 'fs'; import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; @@ -30,6 +33,8 @@ export interface LegacyToolRoot { * location may still be the live one for somebody. */ needsConsent: boolean; + /** Migrations that need a freshly generated destination run afterward. */ + timing?: 'before-generation' | 'after-generation'; } /** @@ -45,6 +50,9 @@ export const LEGACY_TOOL_ROOTS: Record = { // default — but a pre-rebrand Windsurf build reads ONLY .windsurf/, and // nothing on disk tells that user apart, so the move is offered, not taken. devin: [{ root: '.windsurf', needsConsent: true }], + // Codex now reads the canonical shared .agents root. Generate the current + // replacement first so a divergent legacy file is preserved, not overwritten. + codex: [{ root: '.codex', needsConsent: false, timing: 'after-generation' }], }; export interface LegacyToolMigration { @@ -59,8 +67,8 @@ export interface LegacyToolMigration { commandFiles: number; /** * OpenSpec-managed files left under the legacy root because the copy there - * differs from the one that survives — the user edited it, so it is reported - * rather than dropped. + * differs materially from the one that survives, so it is reported rather + * than dropped. */ keptInPlace: number; /** Whether this move needs the user's consent first */ @@ -69,9 +77,9 @@ export interface LegacyToolMigration { /** * Classifies one OpenSpec-managed file. `move` is the fast path (nothing at - * the destination yet); `drop` means the destination already holds the same - * bytes, so the legacy copy is redundant; `keep` means the two differ, which - * only happens when the user edited one, and an edit is not ours to discard. + * the destination yet); `drop` means the destination already holds equivalent + * generated content, so the legacy copy is redundant; `keep` means the two + * differ materially and the legacy copy is not ours to discard. */ type FileDisposition = 'move' | 'drop' | 'keep' | 'skip'; @@ -79,7 +87,13 @@ function classifyManagedFile(source: string, destination: string): FileDispositi if (isSamePath(source, destination)) return 'skip'; if (!fs.existsSync(destination)) return 'move'; try { - return fs.readFileSync(source, 'utf-8') === fs.readFileSync(destination, 'utf-8') + const sourceContent = fs.readFileSync(source, 'utf-8'); + const destinationContent = fs.readFileSync(destination, 'utf-8'); + const equivalentGeneratedSkills = + path.basename(source) === 'SKILL.md' && + path.basename(destination) === 'SKILL.md' && + isLegacyCodexSkillEquivalentToCurrent(sourceContent, destinationContent); + return sourceContent === destinationContent || equivalentGeneratedSkills ? 'drop' : 'keep'; } catch { @@ -112,8 +126,11 @@ function legacyCommandPath( * Reports the OpenSpec content sitting under each tool's legacy root, without * moving anything. Callers use this to ask before a move that needs consent. */ -export function findLegacyToolMigrations(projectPath: string): LegacyToolMigration[] { - return collectLegacyToolMigrations(projectPath, false); +export function findLegacyToolMigrations( + projectPath: string, + timing: 'before-generation' | 'after-generation' = 'before-generation' +): LegacyToolMigration[] { + return collectLegacyToolMigrations(projectPath, false, undefined, timing); } /** @@ -129,15 +146,17 @@ export function findLegacyToolMigrations(projectPath: string): LegacyToolMigrati */ export function migrateLegacyToolDirs( projectPath: string, - toolIds?: string[] + toolIds?: string[], + timing: 'before-generation' | 'after-generation' = 'before-generation' ): LegacyToolMigration[] { - return collectLegacyToolMigrations(projectPath, true, toolIds); + return collectLegacyToolMigrations(projectPath, true, toolIds, timing); } function collectLegacyToolMigrations( projectPath: string, apply: boolean, - toolIds?: string[] + toolIds?: string[], + timing: 'before-generation' | 'after-generation' = 'before-generation' ): LegacyToolMigration[] { const migrations: LegacyToolMigration[] = []; @@ -146,18 +165,39 @@ function collectLegacyToolMigrations( if (toolIds && !toolIds.includes(tool.value)) continue; for (const legacy of LEGACY_TOOL_ROOTS[tool.value] ?? []) { + const legacyTiming = legacy.timing ?? 'before-generation'; + if (legacyTiming !== timing) continue; if (legacy.root === tool.skillsDir) continue; // Without an explicit tool list, only moves that need no consent run. if (apply && !toolIds && legacy.needsConsent) continue; - if (!fs.existsSync(path.join(projectPath, legacy.root))) continue; + const legacyRootPath = path.join(projectPath, legacy.root); + if (!fs.existsSync(legacyRootPath)) continue; + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, legacyRootPath); + FileSystemUtils.assertProjectArtifactPath( + projectPath, + path.join(projectPath, tool.skillsDir) + ); + } catch { + console.warn( + `Skipping legacy ${legacy.root}/ migration because the directory resolves outside this project.` + ); + continue; + } - const skills = migrateSkillDirs(projectPath, tool.skillsDir, legacy.root, apply); + const skills = migrateSkillDirs( + projectPath, + tool.skillsDir, + legacy.root, + apply, + legacyTiming === 'after-generation' + ); const commands = migrateCommandFiles(projectPath, tool, legacy.root, apply); if (apply) { - removeDirIfEmpty(path.join(projectPath, legacy.root, 'skills')); - removeDirIfEmpty(path.join(projectPath, legacy.root, 'workflows')); - removeDirIfEmpty(path.join(projectPath, legacy.root)); + removeDirIfEmpty(path.join(legacyRootPath, 'skills')); + removeDirIfEmpty(path.join(legacyRootPath, 'workflows')); + removeDirIfEmpty(legacyRootPath); } // Kept-only results are retained deliberately. When every legacy file @@ -185,7 +225,8 @@ function migrateSkillDirs( projectPath: string, currentRoot: string, legacyRoot: string, - apply: boolean + apply: boolean, + requireDestination = false ): { moved: number; kept: number } { const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); if (!fs.existsSync(legacySkillsDir)) return { moved: 0, kept: 0 }; @@ -201,6 +242,13 @@ function migrateSkillDirs( const destination = path.join(currentSkillsDir, dirName); const destinationSkill = path.join(destination, 'SKILL.md'); + if (requireDestination && !fs.existsSync(destinationSkill)) continue; + if (!areProjectArtifacts(projectPath, sourceSkill, destinationSkill)) { + console.warn( + `Skipping legacy ${legacyRoot}/skills/${dirName} migration because it resolves outside this project.` + ); + continue; + } const disposition = classifyManagedFile(sourceSkill, destinationSkill); if (disposition === 'skip') continue; if (disposition === 'keep') { @@ -255,6 +303,12 @@ function migrateCommandFiles( if (!fs.existsSync(source)) continue; const destination = path.join(projectPath, currentPath); + if (!areProjectArtifacts(projectPath, source, destination)) { + console.warn( + `Skipping legacy ${legacyPath} migration because it resolves outside this project.` + ); + continue; + } const disposition = classifyManagedFile(source, destination); if (disposition === 'skip') continue; if (disposition === 'keep') { @@ -353,6 +407,17 @@ function isSamePath(a: string, b: string): boolean { } } +function areProjectArtifacts(projectPath: string, ...artifactPaths: string[]): boolean { + try { + for (const artifactPath of artifactPaths) { + FileSystemUtils.assertProjectArtifactPath(projectPath, artifactPath); + } + return true; + } catch { + return false; + } +} + function removeDirIfEmpty(dirPath: string): void { try { if (fs.readdirSync(dirPath).length === 0) { @@ -371,7 +436,8 @@ interface InstalledWorkflowArtifacts { function scanInstalledWorkflowArtifacts( projectPath: string, - tools: AIToolOption[] + tools: AIToolOption[], + includeLegacySkills = false ): InstalledWorkflowArtifacts { const installed = new Set(); let hasSkills = false; @@ -379,14 +445,29 @@ function scanInstalledWorkflowArtifacts( for (const tool of tools) { if (!toolSupportsSkills(tool)) continue; - const skillsDir = resolveToolSkillsDir(projectPath, tool); - for (const workflowId of ALL_WORKFLOWS) { - const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; - const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - installed.add(workflowId); - hasSkills = true; + const skillsDirs: string[] = []; + if (tool.globalSkillsDir) { + skillsDirs.push(resolveToolSkillsDir(projectPath, tool)); + } else if (isSharedSkillTargetActive(projectPath, tool.value)) { + skillsDirs.push(resolveToolSkillsDir(projectPath, tool)); + if (includeLegacySkills) { + skillsDirs.push( + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectPath, root, 'skills') + ) + ); + } + } + + for (const skillsDir of skillsDirs) { + for (const workflowId of ALL_WORKFLOWS) { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; + const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + installed.add(workflowId); + hasSkills = true; + } } } @@ -460,7 +541,7 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi } // Scan for installed workflows - const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools); + const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools, true); const installedWorkflows = artifacts.workflows; if (installedWorkflows.length === 0) { diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 5f5d26095..b731780df 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -11,6 +11,9 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; +import { readSharedSkillTarget } from './shared-skill-target.js'; +import { FileSystemUtils } from '../utils/file-system.js'; +import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js'; import { hasGlobalSkillTarget, resolveToolSkillsDir, @@ -75,6 +78,44 @@ export function hasToolProfileOrDeliveryDrift( const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); + const sharedTarget = tool.skillsDir + ? readSharedSkillTarget(projectPath, tool.skillsDir) + : undefined; + for (const root of tool.legacySkillsDirs ?? []) { + for (const workflow of knownDesiredWorkflows) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md'); + if (!fs.existsSync(legacySkill)) continue; + + const currentSkill = path.join(skillsDir, dirName, 'SKILL.md'); + if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) { + return true; + } + try { + if ( + FileSystemUtils.canonicalizeExistingPath(legacySkill) === + FileSystemUtils.canonicalizeExistingPath(currentSkill) + ) { + continue; + } + // Equivalent generated copies are actionable: migration can safely + // remove the redundant legacy file even when version, line endings, + // or supported invocation syntax changed. Materially divergent copies + // stay in place without forcing an update on every run. + if ( + isLegacyCodexSkillEquivalentToCurrent( + fs.readFileSync(legacySkill, 'utf-8'), + fs.readFileSync(currentSkill, 'utf-8') + ) + ) { + return true; + } + } catch { + return true; + } + } + } + if (shouldGenerateSkills) { for (const workflow of knownDesiredWorkflows) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; diff --git a/src/core/shared-skill-target.ts b/src/core/shared-skill-target.ts new file mode 100644 index 000000000..e3f214442 --- /dev/null +++ b/src/core/shared-skill-target.ts @@ -0,0 +1,171 @@ +import path from 'path'; +import * as fs from 'fs'; +import { AI_TOOLS, OPENSPEC_SKILL_NAMES, type AIToolOption } from './config.js'; +import { FileSystemUtils } from '../utils/file-system.js'; + +const TARGET_MARKER = '.openspec-target'; + +/** Returns the ownership-marker path for one shared skills root. */ +function markerPath(projectPath: string, skillsDir: string): string { + return path.join(projectPath, skillsDir, 'skills', TARGET_MARKER); +} + +/** Reads a valid-looking marker value without letting linked roots escape. */ +export function readSharedSkillTarget( + projectPath: string, + skillsDir: string +): string | undefined { + try { + const target = markerPath(projectPath, skillsDir); + FileSystemUtils.assertProjectArtifactPath(projectPath, target); + return fs.readFileSync(target, 'utf-8').trim() || undefined; + } catch { + return undefined; + } +} + +/** Whether a tool still has an allowlisted managed skill under an old root. */ +function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean { + return (tool.legacySkillsDirs ?? []).some((root) => { + const skillsDir = path.join(projectPath, root, 'skills'); + return OPENSPEC_SKILL_NAMES.some((skillName) => { + try { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + return fs.existsSync(skillFile); + } catch { + return false; + } + }); + }); +} + +/** + * Infers pre-marker ownership from generated invocation syntax. This preserves + * both existing generic `.agents` trees and Codex trees users moved manually. + */ +function inferSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined { + let foundGenericReference = false; + + for (const skillName of OPENSPEC_SKILL_NAMES) { + const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md'); + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + const content = fs.readFileSync(skillFile, 'utf-8'); + if (content.includes('$openspec-')) return 'codex'; + if (content.includes('/openspec-')) foundGenericReference = true; + } catch { + // Missing, unreadable, or out-of-project files provide no ownership signal. + } + } + + return foundGenericReference ? 'agents' : undefined; +} + +/** Whether the canonical shared root already contains an OpenSpec skill. */ +function hasCurrentSkills(projectPath: string, skillsDir: string): boolean { + return OPENSPEC_SKILL_NAMES.some((skillName) => { + const skillFile = path.join(projectPath, skillsDir, 'skills', skillName, 'SKILL.md'); + try { + FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + return fs.existsSync(skillFile); + } catch { + return false; + } + }); +} + +/** + * A shared skill root can only hold one rendered variant of each skill. + * Keep the writer recorded so later updates do not infer every tool that + * happens to use the same directory. + */ +export function reconcileSharedSkillTargets( + projectPath: string, + tools: AIToolOption[] +): AIToolOption[] { + const byRoot = new Map(); + for (const tool of tools) { + if (!tool.skillsDir) continue; + const group = byRoot.get(tool.skillsDir) ?? []; + group.push(tool); + byRoot.set(tool.skillsDir, group); + } + + const reconciled: AIToolOption[] = []; + for (const group of byRoot.values()) { + if (group.length === 1) { + reconciled.push(group[0]); + continue; + } + + const root = group[0].skillsDir!; + const marked = readSharedSkillTarget(projectPath, root); + const markedTool = group.find((tool) => tool.value === marked); + if (markedTool) { + reconciled.push(markedTool); + continue; + } + + const inferred = inferSharedSkillTarget(projectPath, root); + const legacyCodex = group.find( + (tool) => tool.value === 'codex' && hasLegacySkills(projectPath, tool) + ); + if (inferred === 'agents' && legacyCodex) { + // Before ownership markers existed, selecting both targets produced a + // generic canonical tree plus a Codex-only legacy tree. Codex now emits + // a dual-syntax canonical tree, so it can safely consolidate that state. + reconciled.push(legacyCodex); + continue; + } + const inferredTool = group.find((tool) => tool.value === inferred); + if (inferredTool) { + reconciled.push(inferredTool); + continue; + } + + // An unmarked canonical tree predates Codex's move into `.agents`; keep + // that established agents target instead of overwriting it from `.codex`. + if (hasCurrentSkills(projectPath, root)) { + reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]); + continue; + } + + const legacyTool = group.find((tool) => hasLegacySkills(projectPath, tool)); + if (legacyTool) { + reconciled.push(legacyTool); + continue; + } + + // `.agents` existed as the vendor-neutral target before Codex adopted it. + // Unmarked trees therefore retain that established meaning. + reconciled.push(group.find((tool) => tool.value === 'agents') ?? group[0]); + } + + return reconciled; +} + +/** + * Returns whether a tool is the active writer for its physical skills root. + * Non-shared roots are always active. + */ +export function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool?.skillsDir) return false; + const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir); + if (sharingRoot.length < 2) return true; + return reconcileSharedSkillTargets(projectPath, sharingRoot) + .some((candidate) => candidate.value === toolId); +} + +export function writeSharedSkillTarget(projectPath: string, toolId: string): void { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool?.skillsDir) return; + const sharingRoot = AI_TOOLS.filter((candidate) => candidate.skillsDir === tool.skillsDir); + if (sharingRoot.length < 2) return; + + const target = markerPath(projectPath, tool.skillsDir); + FileSystemUtils.assertProjectArtifactPath(projectPath, target); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${toolId}\n`, 'utf-8'); +} diff --git a/src/core/shared/skill-content-equivalence.ts b/src/core/shared/skill-content-equivalence.ts new file mode 100644 index 000000000..807d22149 --- /dev/null +++ b/src/core/shared/skill-content-equivalence.ts @@ -0,0 +1,64 @@ +import { OPENSPEC_SKILL_NAMES } from '../config.js'; + +const GENERATED_VERSION = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const OPENSPEC_SKILL_NAME_SET = new Set(OPENSPEC_SKILL_NAMES); + +/** + * Normalizes checkout line endings and a valid generated version inside the + * YAML frontmatter. Free-form `generatedBy` text in the instructions remains + * material. + */ +function normalizeGeneratedSkill(content: string): string { + const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0]; + if (!frontmatter) return normalized; + + const versionLine = + /^(\s*generatedBy:\s*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))\s*$/m; + const normalizedFrontmatter = frontmatter.replace( + versionLine, + ( + line: string, + prefix: string, + doubleQuoted: string | undefined, + singleQuoted: string | undefined, + bare: string | undefined + ) => { + const version = doubleQuoted ?? singleQuoted ?? bare; + return version && GENERATED_VERSION.test(version) + ? `${prefix}""` + : line; + } + ); + return normalizedFrontmatter + normalized.slice(frontmatter.length); +} + +/** + * Converts only known generated dual references in current Codex content back + * to the direct syntax used by legacy `.codex` output. + */ +function toLegacyCodexReferences(content: string): string { + return content.replace( + /\$(openspec-[a-z0-9-]+) \(Codex\) or \/\1 \(other agents\)/g, + (match, skillName: string) => + OPENSPEC_SKILL_NAME_SET.has(skillName) ? `$${skillName}` : match + ); +} + +/** + * Returns whether a legacy Codex skill differs from the current canonical + * replacement only by generated version, checkout line endings/BOM, or the + * known Codex/generic dual-reference expansion. + */ +export function isLegacyCodexSkillEquivalentToCurrent( + legacyContent: string, + currentContent: string +): boolean { + const normalizedLegacy = normalizeGeneratedSkill(legacyContent); + const normalizedCurrent = normalizeGeneratedSkill(currentContent); + return ( + normalizedLegacy === normalizedCurrent || + normalizedLegacy === toLegacyCodexReferences(normalizedCurrent) + ); +} diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 894506845..fde90caec 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -6,11 +6,20 @@ import path from 'path'; import * as fs from 'fs'; -import { AI_TOOLS } from '../config.js'; +import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from '../config.js'; import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js'; import { getCommandContents } from './skill-generation.js'; import { getGlobalConfig } from '../global-config.js'; import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; +import { + isSharedSkillTargetActive, + readSharedSkillTarget, + reconcileSharedSkillTargets, +} from '../shared-skill-target.js'; +import { + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, +} from '../command-surface.js'; import { getSkillCapableTools, resolveToolSkillsDir, @@ -20,20 +29,7 @@ import { /** * Names of skill directories created by openspec init. */ -export const SKILL_NAMES = [ - 'openspec-explore', - 'openspec-new-change', - 'openspec-continue-change', - 'openspec-apply-change', - 'openspec-update-change', - 'openspec-ff-change', - 'openspec-sync-specs', - 'openspec-archive-change', - 'openspec-bulk-archive-change', - 'openspec-verify-change', - 'openspec-onboard', - 'openspec-propose', -] as const; +export const SKILL_NAMES = OPENSPEC_SKILL_NAMES; export type SkillName = (typeof SKILL_NAMES)[number]; @@ -104,13 +100,22 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki if (!tool || !toolSupportsSkills(tool)) { return { configured: false, fullyConfigured: false, skillCount: 0 }; } + if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId)) { + return { configured: false, fullyConfigured: false, skillCount: 0 }; + } - const skillsDir = resolveToolSkillsDir(projectRoot, tool); + const skillsDirs = [ + resolveToolSkillsDir(projectRoot, tool), + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectRoot, root, 'skills') + ), + ]; let skillCount = 0; for (const skillName of SKILL_NAMES) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { + if (skillsDirs.some((skillsDir) => + fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')) + )) { skillCount++; } } @@ -223,10 +228,31 @@ export function areCommandFilesUpToDate( */ export function getToolStates(projectRoot: string): Map { const states = new Map(); - const toolIds = getToolsWithSkillsDir(); + const tools = getSkillCapableTools(); + + for (const tool of tools) { + const skillStatus = getToolSkillStatus(projectRoot, tool.value); + const markerConfigured = + Boolean(tool.skillsDir) && + readSharedSkillTarget(projectRoot, tool.skillsDir!) === tool.value; + states.set( + tool.value, + markerConfigured + ? { ...skillStatus, configured: true } + : skillStatus + ); + } - for (const toolId of toolIds) { - states.set(toolId, getToolSkillStatus(projectRoot, toolId)); + const configuredTools = tools.filter( + (tool) => tool.skillsDir && states.get(tool.value)?.configured + ); + const activeSharedTargets = new Set( + reconcileSharedSkillTargets(projectRoot, configuredTools).map((tool) => tool.value) + ); + for (const tool of configuredTools) { + if (!activeSharedTargets.has(tool.value)) { + states.set(tool.value, { configured: false, fullyConfigured: false, skillCount: 0 }); + } } return states; @@ -288,21 +314,34 @@ export function getToolVersionStatus( }; } - const skillsDir = resolveToolSkillsDir(projectRoot, tool); + const skillsDirs = [ + resolveToolSkillsDir(projectRoot, tool), + ...(tool.legacySkillsDirs ?? []).map((root) => + path.join(projectRoot, root, 'skills') + ), + ]; let generatedByVersion: string | null = null; + let foundSkill = false; // 1. Find the first skill file that exists and read its version for (const skillName of SKILL_NAMES) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - generatedByVersion = extractGeneratedByVersion(skillFile); - break; + for (const skillsDir of skillsDirs) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + generatedByVersion = extractGeneratedByVersion(skillFile); + foundSkill = true; + break; + } } + if (foundSkill) break; } const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured; const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId); - const configured = skillConfigured || commandConfigured; + const markerConfigured = + Boolean(tool.skillsDir) && + readSharedSkillTarget(projectRoot, tool.skillsDir!) === toolId; + const configured = skillConfigured || commandConfigured || markerConfigured; // 2. Commands-only installs have no skill file to read a version from, so fall // back to comparing the generated command content. Deliberately skipped when @@ -310,6 +349,15 @@ export function getToolVersionStatus( if (!skillConfigured && commandConfigured && areCommandFilesUpToDate(projectRoot, toolId, options)) { generatedByVersion = currentVersion; } + if (!skillConfigured && !commandConfigured && markerConfigured) { + const delivery = getGlobalConfig().delivery ?? 'both'; + if ( + !shouldGenerateSkillsForTool(toolId, delivery) && + !shouldGenerateCommandsForTool(toolId, delivery) + ) { + generatedByVersion = currentVersion; + } + } const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion); @@ -326,12 +374,25 @@ export function getToolVersionStatus( * Gets all configured tools in the project (configured via skills or commands). */ export function getConfiguredTools(projectRoot: string): string[] { - return AI_TOOLS + const configured = AI_TOOLS .filter((t) => { if (!toolSupportsSkills(t)) return false; - return getToolSkillStatus(projectRoot, t.value).configured || toolHasAnyConfiguredCommand(projectRoot, t.value); - }) - .map((t) => t.value); + return ( + getToolSkillStatus(projectRoot, t.value).configured || + toolHasAnyConfiguredCommand(projectRoot, t.value) || + (Boolean(t.skillsDir) && + readSharedSkillTarget(projectRoot, t.skillsDir!) === t.value) + ); + }); + const activeProjectTools = new Set( + reconcileSharedSkillTargets( + projectRoot, + configured.filter((tool) => tool.skillsDir) + ).map((tool) => tool.value) + ); + return configured + .filter((tool) => tool.globalSkillsDir || activeProjectTools.has(tool.value)) + .map((tool) => tool.value); } /** diff --git a/src/core/update.ts b/src/core/update.ts index dda898fb1..998550121 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -70,6 +70,7 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; +import { writeSharedSkillTarget } from './shared-skill-target.js'; import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); @@ -298,6 +299,7 @@ export class UpdateCommand { FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(resolvedProjectPath, tool.value); removedDeselectedSkillCount += await this.removeUnselectedSkillDirs( skillsRoot, @@ -309,6 +311,9 @@ export class UpdateCommand { // Delete skill directories if delivery is commands-only if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) { removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir); + // Persist the selected owner even when commands-only delivery leaves + // this target with no generated skills. + writeSharedSkillTarget(resolvedProjectPath, tool.value); // A tool with no command adapter now has zero OpenSpec artifacts; // say so like init does, rather than deleting its skills silently // and letting tool detection re-suggest an init that would also @@ -349,6 +354,16 @@ export class UpdateCommand { spinner.succeed(`Updated ${tool.name}`); updatedTools.push(tool.name); + for (const migration of migrateLegacyToolDirs( + resolvedProjectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); + } } catch (error) { spinner.fail(`Failed to update ${tool.name}`); failedTools.push({ @@ -1072,6 +1087,7 @@ export class UpdateCommand { FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } + writeSharedSkillTarget(projectPath, tool.value); } // Create commands when delivery includes commands @@ -1092,6 +1108,16 @@ export class UpdateCommand { spinner.succeed(`Setup complete for ${tool.name}`); newlyConfigured.push(toolId); + for (const migration of migrateLegacyToolDirs( + projectPath, + [tool.value], + 'after-generation' + )) { + if (hasMovableContent(migration)) { + console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`)); + } + this.reportKeptInPlace(migration); + } } catch (error) { spinner.fail(`Failed to set up ${tool.name}`); console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`)); diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index cdcce7918..c4800513c 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -84,6 +84,19 @@ function replaceCommandsWithSkillReferences(text: string, prefix: string): strin }); } +/** + * Keeps Codex's `$` spelling first while making its canonical shared + * `.agents` tree usable by agents that invoke the same skills with `/`. + */ +export function transformToCodexCompatibleSkillReferences(text: string): string { + return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => { + const skillName = COMMAND_TO_SKILL_NAME[commandId]; + return skillName === undefined + ? match + : `$${skillName} (Codex) or /${skillName} (other agents)`; + }); +} + /** * Transforms command references to skill references using the default `/` * invocation prefix. Converts `/opsx:` patterns to @@ -164,7 +177,9 @@ export function getTransformerForTool( invocation: CommandInvocation | undefined ): ((text: string) => string) | undefined { if (delivery === 'skills' || capability !== 'adapter-backed') { - return getSkillReferenceTransformer(toolId); + return toolId === 'codex' + ? transformToCodexCompatibleSkillReferences + : getSkillReferenceTransformer(toolId); } if (toolId === 'devin' && delivery === 'both') { return getSkillReferenceTransformer(toolId); diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 7deb1ddd9..ce1440033 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -114,13 +114,12 @@ describe('available-tools', () => { expect(tools).toEqual([]); }); - it('should only return tools that have a skillsDir property', async () => { + it('should return tools that support project-local or global skills', async () => { await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); const tools = getAvailableTools(testDir); expect(tools.map((t) => t.value)).toContain('claude'); - // The filter's contract: nothing without a skillsDir can ever be returned. - expect(tools.filter((t) => !t.skillsDir)).toEqual([]); + expect(tools.every((tool) => tool.skillsDir || tool.globalSkillsDir)).toBe(true); }); it('should detect the shared agents target from .agents/skills', async () => { @@ -129,6 +128,7 @@ describe('available-tools', () => { const tools = getAvailableTools(testDir); const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('agents'); + expect(toolValues).not.toContain('codex'); }); it('should not detect the shared agents target from a bare .agents directory', async () => { @@ -139,6 +139,139 @@ describe('available-tools', () => { const tools = getAvailableTools(testDir); expect(tools.map((t) => t.value)).not.toContain('agents'); + expect(tools.map((t) => t.value)).not.toContain('codex'); + }); + + it('should detect Codex from its legacy skill directory', async () => { + await fs.mkdir(path.join(testDir, '.codex', 'skills'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + expect(tools[0].skillsDir).toBe('.agents'); + }); + + it('should use the shared-root marker to distinguish Codex from agents', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.agents', 'skills', '.openspec-target'), 'codex\n'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toContain('codex'); + expect(tools.map((tool) => tool.value)).not.toContain('agents'); + }); + + it('should preserve a global tool while reconciling a shared project root', async () => { + const sharedSkills = path.join(testDir, '.agents', 'skills'); + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(sharedSkills, { recursive: true }); + await fs.writeFile(path.join(sharedSkills, '.openspec-target'), 'agents\n'); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).toEqual([ + 'minimax-code', + 'agents', + ]); + }); + + it('should infer an unmarked canonical Codex tree from its invocation syntax', async () => { + const skillFile = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'Next: $openspec-apply-change'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + }); + + it.each(['', 'unknown'])( + 'should preserve generic content when the shared marker is %j', + async (marker) => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + const skillFile = path.join(skillsDir, 'openspec-propose', 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'Next: /openspec-apply-change'); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), `${marker}\n`); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['agents']); + } + ); + + it('should consolidate an unmarked generic tree when legacy Codex skills also exist', async () => { + const agentsSkill = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + const codexSkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(agentsSkill), { recursive: true }); + await fs.mkdir(path.dirname(codexSkill), { recursive: true }); + await fs.writeFile(agentsSkill, 'Next: /openspec-apply-change'); + await fs.writeFile(codexSkill, 'Next: $openspec-apply-change'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + }); + + it('should detect valid legacy Codex skills beside an escaped managed link', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-legacy-outside-')); + try { + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(legacySkills, 'openspec-propose'), { recursive: true }); + await fs.writeFile( + path.join(legacySkills, 'openspec-propose', 'SKILL.md'), + 'Next: $openspec-apply-change' + ); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(legacySkills, 'openspec-explore'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toEqual(['codex']); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not let an unknown legacy skill supersede the shared agents target', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.agents', 'skills', '.openspec-target'), 'agents\n'); + const customSkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-personal', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'user skill'); + + const tools = getAvailableTools(testDir); + expect(tools.map((tool) => tool.value)).toContain('agents'); + expect(tools.map((tool) => tool.value)).not.toContain('codex'); }); it('should return full AIToolOption objects', async () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 18cf2e83f..9002902cd 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -610,14 +610,59 @@ describe('InitCommand', () => { const initCommand = new InitCommand({ tools: 'codex', force: true }); await initCommand.execute(testDir); - const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + const skillFile = path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); + expect( + await fileExists(path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md')) + ).toBe(false); const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); expect(await fileExists(promptFile)).toBe(false); } ); + it('should reconcile Codex and agents to one tree both consumers can invoke', async () => { + const initCommand = new InitCommand({ tools: 'codex,agents', force: true }); + await initCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .flat() + .map(String); + expect(logCalls.some((entry) => entry.includes('Created: Codex'))).toBe(true); + expect(logCalls.some((entry) => entry.includes('Shared .agents skills'))).toBe(false); + expect( + logCalls.some((entry) => entry.includes('writing one tree with Codex and generic')) + ).toBe(true); + }); + + it('should migrate legacy Codex skills only after init writes their replacements', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + const customSkill = path.join(testDir, '.codex', 'skills', 'custom', 'SKILL.md'); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'user skill'); + + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + + expect( + await fileExists(path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md')) + ).toBe(true); + expect( + await fileExists(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).toBe(false); + expect(await fs.readFile(customSkill, 'utf-8')).toBe('user skill'); + }); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); @@ -648,7 +693,7 @@ describe('InitCommand', () => { path.join(testDir, '.cursor', 'commands', 'opsx-propose.md'), path.join(testDir, '.kilocode', 'workflows', 'opsx-propose.md'), path.join(testDir, '.pi', 'prompts', 'opsx-propose.md'), - path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'), + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), ]; for (const proposeFile of proposeFiles) { @@ -686,6 +731,13 @@ describe('InitCommand', () => { expect(await fileExists(codeArtsSkill)).toBe(true); expect(await fileExists(cursorSkill)).toBe(true); expect(await fileExists(devinSkill)).toBe(true); + + const sharedPropose = await fs.readFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(sharedPropose).toContain('$openspec-apply-change'); + expect(sharedPropose).toContain('/openspec-apply-change'); }); it('should skip tool configuration with --tools none option', async () => { @@ -1111,10 +1163,26 @@ describe('InitCommand - profile and detection features', () => { expect(await fileExists(legacyPrompt)).toBe(false); expect(await fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') )).toBe(true); }); + it('should preserve global Codex prompts when only generic agents skills are installed', async () => { + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); + + const initCommand = new InitCommand({ tools: 'agents' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(true); + expect(await fs.readFile( + path.join(testDir, '.agents', 'skills', '.openspec-target'), + 'utf-8' + )).toBe('agents\n'); + }); + it('should preserve legacy Codex prompts without replacement skills during non-interactive init', async () => { const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const legacyPrompt = path.join(promptDir, 'opsx-onboard.md'); @@ -1126,10 +1194,10 @@ describe('InitCommand - profile and detection features', () => { expect(await fileExists(legacyPrompt)).toBe(true); expect(await fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') )).toBe(true); expect(await fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-onboard', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-onboard', 'SKILL.md') )).toBe(false); }); @@ -1403,7 +1471,7 @@ describe('InitCommand - profile and detection features', () => { const initCommand = new InitCommand({ tools: 'codex', force: true }); await initCommand.execute(testDir); - const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'); + const skillFile = path.join(testDir, '.agents', '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:'); @@ -1529,7 +1597,7 @@ describe('InitCommand - profile and detection features', () => { // 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, '.agents', '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); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 6d0d4d46a..7edb5ce91 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -6,7 +6,11 @@ import { promises as fsp } from 'node:fs'; import { AI_TOOLS, type AIToolOption } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js'; import { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-config.js'; -import { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js'; +import { + findLegacyToolMigrations, + migrateIfNeeded, + scanInstalledWorkflows, +} from '../../src/core/migration.js'; const CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined; @@ -92,6 +96,21 @@ describe('migration', () => { expect(config.workflows).toEqual(['explore', 'apply']); }); + it('keeps dry-run legacy results aligned with migration timing', async () => { + await writeSkill(projectDir, 'openspec-explore', '.codex'); + await writeSkill(projectDir, 'openspec-explore', '.agents'); + + expect(findLegacyToolMigrations(projectDir)).toEqual([]); + expect(findLegacyToolMigrations(projectDir, 'after-generation')).toEqual([ + expect.objectContaining({ + toolId: 'codex', + from: '.codex', + to: '.agents', + skillDirs: 1, + }), + ]); + }); + it('migrates to custom commands delivery when only managed commands are detected', async () => { await writeManagedCommand(projectDir, 'explore'); await writeManagedCommand(projectDir, 'archive'); @@ -156,8 +175,7 @@ describe('migration', () => { it('prints the $-prefixed propose reference when migrating a codex-only project', async () => { // Codex is skills-invocable with no slash surface: it invokes skills as - // $, so the migration message must not advertise a /openspec-* or - // /opsx:* form + // Migration hints target the selected tool, so keep Codex's $ form. await writeSkill(projectDir, 'openspec-propose', '.codex'); const message = captureMigrationLogs(projectDir, [requireTool('codex')]).find((entry) => @@ -299,4 +317,16 @@ describe('migration', () => { migrateIfNeeded(projectDir, [ensureClaudeTool()]); expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); + + it('does not count generic shared skills as installed Codex workflows', async () => { + await writeSkill(projectDir, 'openspec-explore', '.agents'); + await fsp.writeFile( + path.join(projectDir, '.agents', 'skills', '.openspec-target'), + 'agents\n', + 'utf-8' + ); + + expect(scanInstalledWorkflows(projectDir, [requireTool('codex')])).toEqual([]); + expect(scanInstalledWorkflows(projectDir, [requireTool('agents')])).toEqual(['explore']); + }); }); diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts index f1da9a539..cfa373aff 100644 --- a/test/core/profile-sync-drift.test.ts +++ b/test/core/profile-sync-drift.test.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { hasProjectConfigDrift, + hasToolProfileOrDeliveryDrift, WORKFLOW_TO_SKILL_DIR, } from '../../src/core/profile-sync-drift.js'; import { CORE_WORKFLOWS } from '../../src/core/profiles.js'; @@ -37,6 +38,18 @@ function setupCoreCommands(projectDir: string): void { } } +function setupCodexCoreSkills(projectDir: string): string { + const skillsDir = path.join(projectDir, '.agents', 'skills'); + for (const workflow of CORE_WORKFLOWS) { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillPath = path.join(skillsDir, skillDirName, 'SKILL.md'); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, `name: ${skillDirName}\n`); + } + fs.writeFileSync(path.join(skillsDir, '.openspec-target'), 'codex\n'); + return skillsDir; +} + describe('profile sync drift detection', () => { let tempDir: string; @@ -107,4 +120,77 @@ describe('profile sync drift detection', () => { const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); expect(hasDrift).toBe(true); }); + + it('does not report legacy Codex drift when both roots resolve to the same files', () => { + setupCodexCoreSkills(tempDir); + fs.symlinkSync( + process.platform === 'win32' ? path.join(tempDir, '.agents') : '.agents', + path.join(tempDir, '.codex'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(false); + }); + + it('reports an equal distinct legacy Codex copy that migration can remove', () => { + const skillsDir = setupCodexCoreSkills(tempDir); + const currentSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.copyFileSync(currentSkill, legacySkill); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(true); + }); + + it('reports generated-only Codex differences that migration can remove', () => { + const skillsDir = setupCodexCoreSkills(tempDir); + const currentSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.writeFileSync( + currentSkill, + '---\nmetadata:\n generatedBy: "1.7.0"\n---\nUse $openspec-apply-change (Codex) or /openspec-apply-change (other agents).\n' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.writeFileSync( + legacySkill, + '\uFEFF---\r\nmetadata:\r\n generatedBy: "0.1.0"\r\n---\r\nUse $openspec-apply-change.\r\n' + ); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(true); + }); + + it('does not repeatedly report a divergent legacy Codex copy', () => { + setupCodexCoreSkills(tempDir); + const legacySkill = path.join( + tempDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(legacySkill), { recursive: true }); + fs.writeFileSync(legacySkill, 'user customization\n'); + + expect( + hasToolProfileOrDeliveryDrift(tempDir, 'codex', CORE_WORKFLOWS, 'skills') + ).toBe(false); + }); }); diff --git a/test/core/shared/skill-content-equivalence.test.ts b/test/core/shared/skill-content-equivalence.test.ts new file mode 100644 index 000000000..fc17838f2 --- /dev/null +++ b/test/core/shared/skill-content-equivalence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { isLegacyCodexSkillEquivalentToCurrent } from '../../../src/core/shared/skill-content-equivalence.js'; + +describe('legacy Codex skill equivalence', () => { + it('accepts generated version, BOM, CRLF, and known dual-reference differences', () => { + const legacy = + '\uFEFF---\r\nmetadata:\r\n generatedBy: "0.1.0"\r\n---\r\nUse $openspec-apply-change.\r\n'; + const current = + '---\nmetadata:\n generatedBy: "1.7.0-beta.1+build.5"\n---\nUse $openspec-apply-change (Codex) or /openspec-apply-change (other agents).\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(true); + }); + + it('preserves custom invocation examples', () => { + const legacy = + '---\nmetadata:\n generatedBy: "1.0.0"\n---\nUse $openspec-personal.\n'; + const current = + '---\nmetadata:\n generatedBy: "1.0.0"\n---\nUse $openspec-personal (Codex) or /openspec-personal (other agents).\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it('preserves non-version generatedBy values', () => { + const legacy = '---\nmetadata:\n generatedBy: "custom-a"\n---\nSame body.\n'; + const current = '---\nmetadata:\n generatedBy: "custom-b"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it.each([ + '1.0.0-preview.', + '1.0.0+build.', + '1.0.0-.', + '1.0.0+.', + '1.0.0-alpha..1', + '01.0.0', + '1.01.0', + '1.0.01', + '1.0.0-01', + ])('preserves malformed generatedBy version %s', (version) => { + const legacy = `---\nmetadata:\n generatedBy: "${version}"\n---\nSame body.\n`; + const current = '---\nmetadata:\n generatedBy: "1.0.0"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); + + it('preserves mismatched generatedBy quotes', () => { + const legacy = `---\nmetadata:\n generatedBy: "1.0.0'\n---\nSame body.\n`; + const current = '---\nmetadata:\n generatedBy: "1.0.0"\n---\nSame body.\n'; + + expect(isLegacyCodexSkillEquivalentToCurrent(legacy, current)).toBe(false); + }); +}); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 9e1f0ffbd..5905c87b7 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -87,6 +87,15 @@ describe('tool-detection', () => { expect(status.skillCount).toBe(1); }); + it('should detect legacy Codex skills before they are migrated', async () => { + const skillDir = path.join(testDir, '.codex', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'legacy content'); + + expect(getToolSkillStatus(testDir, 'codex').configured).toBe(true); + expect(getConfiguredTools(testDir)).toContain('codex'); + }); + it('should detect when all skills exist', async () => { for (const skillName of SKILL_NAMES) { const skillDir = path.join(testDir, '.claude', 'skills', skillName); @@ -152,6 +161,59 @@ describe('tool-detection', () => { expect(states.get('claude')?.configured).toBe(true); expect(states.get('cursor')?.configured).toBe(false); }); + + it('should expose only the marked owner of a shared skill tree as configured', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + const skillDir = path.join(skillsDir, 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content'); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const states = getToolStates(testDir); + expect(states.get('agents')?.configured).toBe(true); + expect(states.get('codex')?.configured).toBe(false); + expect(getToolSkillStatus(testDir, 'agents').configured).toBe(true); + expect(getToolSkillStatus(testDir, 'codex').configured).toBe(false); + expect(getToolVersionStatus(testDir, 'codex', '0.23.0').configured).toBe(false); + }); + + it('should preserve global tool state while reconciling a shared project root', async () => { + const sharedSkills = path.join(testDir, '.agents', 'skills'); + const sharedSkill = path.join(sharedSkills, 'openspec-explore', 'SKILL.md'); + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(sharedSkill), { recursive: true }); + await fs.writeFile(sharedSkill, 'content'); + await fs.writeFile(path.join(sharedSkills, '.openspec-target'), 'agents\n'); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + const states = getToolStates(testDir); + expect(states.get('agents')?.configured).toBe(true); + expect(states.get('codex')?.configured).toBe(false); + expect(states.get('minimax-code')?.configured).toBe(true); + expect(getConfiguredTools(testDir)).toEqual(['minimax-code', 'agents']); + }); + + it('should preserve marker-only ownership when delivery intentionally has no skills', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const states = getToolStates(testDir); + expect(states.get('agents')).toEqual({ + configured: true, + fullyConfigured: false, + skillCount: 0, + }); + expect(states.get('codex')?.configured).toBe(false); + }); }); describe('extractGeneratedByVersion', () => { @@ -545,5 +607,19 @@ metadata: expect(cursorStatus?.generatedByVersion).toBe('0.23.0'); expect(cursorStatus?.needsUpdate).toBe(false); }); + + it('should treat a marker-only target as configured', async () => { + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'agents\n'); + + const statuses = getAllToolVersionStatus(testDir, '0.23.0'); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + toolId: 'agents', + configured: true, + needsUpdate: true, + }); + }); }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ed99a4264..78eecd029 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -38,6 +38,11 @@ function resetMockConfig() { mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' }; } +async function markCodexTarget(skillsDir: string): Promise { + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, '.openspec-target'), 'codex\n'); +} + describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; @@ -428,6 +433,380 @@ metadata: await expect(fs.access(migratedSkill)).resolves.toBeUndefined(); }); + it('should migrate legacy Codex skills after writing replacements and preserve user files', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + + const userSkill = path.join(testDir, '.codex', 'skills', 'my-custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(userSkill), { recursive: true }); + await fs.writeFile(userSkill, 'user skill'); + await fs.writeFile(path.join(testDir, '.codex', 'config.toml'), 'user config'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + + const currentSkill = path.join( + testDir, + '.agents', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + expect(await fs.readFile(currentSkill, 'utf-8')).toContain('$openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect(await fs.readFile(userSkill, 'utf-8')).toBe('user skill'); + expect(await fs.readFile(path.join(testDir, '.codex', 'config.toml'), 'utf-8')).toBe( + 'user config' + ); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('.codex → .agents') + ) + ).toBe(true); + }); + + it('should retry interrupted equivalent Codex cleanup without force', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const canonicalSkills = path.join(testDir, '.agents', 'skills'); + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.cp(canonicalSkills, legacySkills, { recursive: true }); + await fs.rm(path.join(legacySkills, '.openspec-target')); + + for (const entry of await fs.readdir(legacySkills, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('openspec-')) continue; + const skillFile = path.join(legacySkills, entry.name, 'SKILL.md'); + const legacyContent = (await fs.readFile(skillFile, 'utf-8')) + .replace( + /\$openspec-([a-z0-9-]+) \(Codex\) or \/openspec-\1 \(other agents\)/g, + '$openspec-$1' + ) + .replace(/generatedBy:\s*"[^"]+"/, 'generatedBy: "0.1.0"') + .replace(/\n/g, '\r\n'); + await fs.writeFile(skillFile, `\uFEFF${legacyContent}`); + } + + await updateCommand.execute(testDir); + + await expect( + fs.access(path.join(legacySkills, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect(await fs.readFile( + path.join(canonicalSkills, 'openspec-propose', 'SKILL.md'), + 'utf-8' + )).toContain('$openspec-apply-change'); + }); + + it('should preserve and report a divergent legacy Codex skill', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + const legacySkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.appendFile(legacySkill, '\nUser edit\n'); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + + expect(await fs.readFile(legacySkill, 'utf-8')).toContain('User edit'); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('Left 1 file in .codex/') + ) + ).toBe(true); + + consoleSpy.mockClear(); + await updateCommand.execute(testDir); + const secondRunLogs = consoleSpy.mock.calls.flat().map(String); + expect(secondRunLogs.some((entry) => entry.includes('up to date'))).toBe(true); + expect(secondRunLogs.some((entry) => entry.includes('Left 1 file in .codex/'))).toBe(false); + }); + + it('should not restore legacy Codex workflows excluded by the active profile', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'skills', + workflows: ['explore'], + }); + + await updateCommand.execute(testDir); + + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + ) + ).toBe(true); + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') + ) + ).toBe(false); + expect( + await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + ) + ).toBe(true); + + const consoleSpy = vi.spyOn(console, 'log'); + await updateCommand.execute(testDir); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => entry.includes('up to date')) + ).toBe(true); + }); + + it('should keep Codex as the sole writer of its marked shared skill tree', async () => { + await new InitCommand({ tools: 'codex,agents', force: true }).execute(testDir); + const consoleSpy = vi.spyOn(console, 'log'); + + await new UpdateCommand({ force: true }).execute(testDir); + + const proposeSkill = await fs.readFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + expect( + consoleSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('Force updating 1 tool(s): codex') + ) + ).toBe(true); + }); + + it('should keep an explicit agents target despite preserved legacy Codex skills', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + await fs.appendFile( + path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'), + '\nUser edit\n' + ); + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + await updateCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('agents\n'); + expect( + await fs.readFile(path.join(skillsDir, 'openspec-propose', 'SKILL.md'), 'utf-8') + ).toContain('/openspec-apply-change'); + expect( + await fs.readFile( + path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md'), + 'utf-8' + ) + ).toContain('User edit'); + }); + + it('should let an explicit Codex init take ownership of an agents tree', async () => { + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + }); + + it('should consolidate an existing unmarked agents tree with legacy Codex skills', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + await fs.rm(path.join(testDir, '.agents', 'skills', '.openspec-target')); + const legacyPropose = path.join( + testDir, + '.codex', + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.writeFile( + legacyPropose, + (await fs.readFile(legacyPropose, 'utf-8')).replace( + /generatedBy:\s*"[^"]+"/, + 'generatedBy: "0.1.0"' + ) + ); + + await new UpdateCommand({ force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should infer an unmarked canonical Codex tree that was moved manually', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.rm(path.join(skillsDir, '.openspec-target')); + + await new UpdateCommand({ force: true }).execute(testDir); + + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + }); + + it('should preserve agents ownership when it switches to commands-only', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('agents\n'); + await expect( + fs.access(path.join(skillsDir, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should not resurrect divergent legacy Codex skills after agents switches to commands-only', async () => { + await new InitCommand({ tools: 'agents', force: true }).execute(testDir); + const canonicalSkills = path.join(testDir, '.agents', 'skills'); + const legacySkills = path.join(testDir, '.codex', 'skills'); + await fs.cp(canonicalSkills, legacySkills, { recursive: true }); + await fs.writeFile( + path.join(legacySkills, 'openspec-propose', 'SKILL.md'), + 'divergent legacy Codex skill\n' + ); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await updateCommand.execute(testDir); + await updateCommand.execute(testDir); + + expect(await fs.readFile(path.join(canonicalSkills, '.openspec-target'), 'utf-8')).toBe( + 'agents\n' + ); + await expect( + fs.access(path.join(canonicalSkills, 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + expect( + await fs.readFile(path.join(legacySkills, 'openspec-propose', 'SKILL.md'), 'utf-8') + ).toBe('divergent legacy Codex skill\n'); + }); + + it('should migrate legacy Codex skills under commands-only delivery', async () => { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + await fs.rename(path.join(testDir, '.agents'), path.join(testDir, '.codex')); + await fs.rm(path.join(testDir, '.codex', 'skills', '.openspec-target')); + setMockConfig({ featureFlags: {}, profile: 'core', delivery: 'commands' }); + + await updateCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + expect(await fs.readFile(path.join(skillsDir, '.openspec-target'), 'utf-8')).toBe('codex\n'); + const proposeSkill = await fs.readFile( + path.join(skillsDir, 'openspec-propose', 'SKILL.md'), + 'utf-8' + ); + expect(proposeSkill).toContain('$openspec-apply-change'); + expect(proposeSkill).toContain('/openspec-apply-change'); + await expect( + fs.access(path.join(testDir, '.codex', 'skills', 'openspec-propose', 'SKILL.md')) + ).rejects.toThrow(); + }); + + it('should not migrate legacy Codex skills through a symlink outside the project', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-codex-outside-')); + try { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const outsideSkill = path.join( + outsideDir, + 'skills', + 'openspec-propose', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(outsideSkill), { recursive: true }); + await fs.copyFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + outsideSkill + ); + await fs.symlink( + outsideDir, + path.join(testDir, '.codex'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + const warningSpy = vi.spyOn(console, 'warn'); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(outsideSkill, 'utf-8')).resolves.toContain( + 'name: openspec-propose' + ); + expect( + warningSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('resolves outside this project') + ) + ).toBe(true); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not migrate a nested legacy Codex skill symlink outside the project', async () => { + const outsideDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'openspec-codex-skill-outside-') + ); + try { + await new InitCommand({ tools: 'codex', force: true }).execute(testDir); + const outsideSkill = path.join(outsideDir, 'SKILL.md'); + await fs.copyFile( + path.join(testDir, '.agents', 'skills', 'openspec-propose', 'SKILL.md'), + outsideSkill + ); + const legacySkillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(legacySkillsDir, { recursive: true }); + await fs.symlink( + outsideDir, + path.join(legacySkillsDir, 'openspec-propose'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + const warningSpy = vi.spyOn(console, 'warn'); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(outsideSkill, 'utf-8')).resolves.toContain( + 'name: openspec-propose' + ); + expect( + warningSpy.mock.calls.flat().map(String).some((entry) => + entry.includes('resolves outside this project') + ) + ).toBe(true); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + it('should update core profile skill files when tool is configured', async () => { // Set up a configured tool with one skill directory const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1045,6 +1424,37 @@ metadata: }); describe('error handling', () => { + it('should preserve legacy Codex skills and prompts when canonical generation fails', async () => { + const legacySkill = path.join( + testDir, + '.codex', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + const legacyContent = 'legacy Codex skill'; + await fs.mkdir(path.dirname(legacySkill), { recursive: true }); + await fs.writeFile(legacySkill, legacyContent); + + const prompt = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(prompt), { recursive: true }); + await fs.writeFile(prompt, 'legacy prompt'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => { + if (filePath.includes(`${path.sep}.agents${path.sep}`) && filePath.endsWith('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + await expect(new UpdateCommand({ force: true }).execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: Codex' + ); + expect(await fs.readFile(legacySkill, 'utf-8')).toBe(legacyContent); + expect(await FileSystemUtils.fileExists(prompt)).toBe(true); + }); + it('should report tool update failures to automation', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1654,7 +2064,8 @@ ${OPENSPEC_MARKERS.end} delivery: 'commands', }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true, }); @@ -1715,13 +2126,13 @@ ${OPENSPEC_MARKERS.end} expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); expect(await FileSystemUtils.fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') )).toBe(true); expect(await FileSystemUtils.fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') )).toBe(false); expect(await FileSystemUtils.fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-archive-change', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-archive-change', 'SKILL.md') )).toBe(false); }); @@ -1793,7 +2204,8 @@ ${OPENSPEC_MARKERS.end} delivery: 'skills', }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); @@ -1807,7 +2219,7 @@ ${OPENSPEC_MARKERS.end} expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(true); expect(await FileSystemUtils.fileExists( - path.join(testDir, '.codex', 'skills', 'openspec-onboard', 'SKILL.md') + path.join(testDir, '.agents', 'skills', 'openspec-onboard', 'SKILL.md') )).toBe(false); }); @@ -1818,7 +2230,8 @@ ${OPENSPEC_MARKERS.end} delivery: 'commands', }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); @@ -2585,7 +2998,8 @@ More user content after markers. delivery, }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); @@ -2608,7 +3022,8 @@ More user content after markers. delivery: 'both', }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); @@ -2633,7 +3048,8 @@ More user content after markers. delivery: 'skills', }); - const skillsDir = path.join(testDir, '.codex', 'skills'); + const skillsDir = path.join(testDir, '.agents', 'skills'); + await markCodexTarget(skillsDir); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index 4ed4fc4f3..93f01de99 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -226,6 +226,12 @@ describe('getSkillReferenceTransformer', () => { ); expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command'); }); + + it('uses $ for direct Codex invocation hints', () => { + const transformer = getSkillReferenceTransformer('codex'); + expect(transformer('/opsx:propose')).toBe('$openspec-propose'); + expect(transformer('/opsx:unknown-command')).toBe('/opsx:unknown-command'); + }); }); describe('getTransformerForTool', () => { @@ -300,12 +306,17 @@ describe('getTransformerForTool', () => { expect(getTransformerForTool('claude', 'commands', 'adapter-backed', NAMESPACED_SLASH)).toBeUndefined(); }); - it('selects $-prefixed skill references for codex, which registers no slash commands', () => { - // Codex CLI invokes skills as $; the / form is unrecognized. + it('selects shared-tree-safe Codex skill references in every delivery mode', () => { + // Codex needs $, while generic consumers of the same canonical + // .agents tree need /. Keep both explicit so neither target breaks. for (const delivery of ['both', 'commands', 'skills'] as const) { const transformer = getTransformerForTool('codex', delivery, 'skills-invocable', undefined); - expect(transformer?.('/opsx:propose')).toBe('$openspec-propose'); - expect(transformer?.('Run /opsx:apply next')).toBe('Run $openspec-apply-change next'); + expect(transformer?.('/opsx:propose')).toBe( + '$openspec-propose (Codex) or /openspec-propose (other agents)' + ); + expect(transformer?.('Run /opsx:apply next')).toBe( + 'Run $openspec-apply-change (Codex) or /openspec-apply-change (other agents) next' + ); } }); });