diff --git a/docs/cli.md b/docs/cli.md index 9e85c5aa7..33cfe1a2c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -140,6 +140,7 @@ openspec/ └── config.yaml # Project configuration .claude/skills/ # Claude Code skills (if claude selected) +.agents/skills/ # Codex skills (if codex selected) .cursor/skills/ # Cursor skills (if cursor selected) .cursor/commands/ # Cursor OPSX commands (if delivery includes commands) ... (other tool configs) diff --git a/docs/commands.md b/docs/commands.md index 8b0d81839..e0b5cc7f2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -683,7 +683,7 @@ The AI tool doesn't recognize OpenSpec commands. **Solutions:** - Ensure OpenSpec is initialized: `openspec init` - Regenerate skills: `openspec update` -- Check that `.claude/skills/` directory exists (for Claude Code) +- Check the selected tool's skill directory from [Supported Tools](supported-tools.md), such as `.claude/skills/` for Claude Code or `.agents/skills/` for Codex - Restart your AI tool to pick up new skills ### Artifacts not generating properly diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..d67d281c7 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -29,7 +29,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/.md` | | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-.md`\* | +| Codex (`codex`) | `.agents/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/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` | | CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-.md` | @@ -54,6 +54,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | \* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. +Legacy OpenSpec Codex skills under `.codex/skills/openspec-*` are detected during setup/update and migrated to `.agents/skills`. Non-OpenSpec content under `.codex/skills` is left untouched. \*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. diff --git a/openspec/changes/migrate-codex-skills-to-agents/.openspec.yaml b/openspec/changes/migrate-codex-skills-to-agents/.openspec.yaml new file mode 100644 index 000000000..db47328a1 --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/migrate-codex-skills-to-agents/design.md b/openspec/changes/migrate-codex-skills-to-agents/design.md new file mode 100644 index 000000000..773f1ce0c --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/design.md @@ -0,0 +1,70 @@ +## Context + +The active Codex skills-first PR fixes deprecated prompt generation, but it still keeps Codex skills under `.codex/skills`. Current OpenAI Codex docs use `.agents/skills` for repository skills, so OpenSpec needs a narrow path migration on top of skills-first behavior. + +Current OpenSpec path behavior is driven by `AI_TOOLS[].skillsDir`, then init/update append `skills`. Detection can be customized with `detectionPaths`, so the migration can reuse the existing configuration path rather than adding a separate Codex-only scanner. + +## Goals / Non-Goals + +**Goals:** + +- Write Codex OpenSpec skills to `.agents/skills`. +- Detect legacy Codex OpenSpec installs under `.codex/skills`. +- Migrate managed legacy Codex skills by regenerating them under `.agents/skills`. +- Remove only OpenSpec-managed legacy `.codex/skills/openspec-*` directories after successful regeneration. +- Apply the same Codex skill migration and managed legacy cleanup to workspace-local Codex skills. +- Update Codex docs and user output to consistently say `.agents/skills`. +- Keep path handling cross-platform. + +**Non-Goals:** + +- Rework Codex custom prompt deprecation beyond what the skills-first PR already covers. +- Rewrite unsupported Codex runtime tool names beyond existing or parallel Codex-safe transforms. +- Add a generic `.agents` installer option for every tool. +- Delete unmanaged `.codex` content. + +## Decisions + +### 1. Codex Current Skill Root Is `.agents` + +Set the Codex tool entry to `skillsDir: '.agents'`. Generated skill paths continue to be built by joining project root, `skillsDir`, `skills`, skill directory, and `SKILL.md`. + +Rationale: this keeps the general skill-generation pipeline intact and moves only Codex's configured root. + +### 2. Legacy `.codex` Is Detection And Cleanup Metadata + +Add explicit legacy metadata for Codex, such as `legacySkillsDirs: ['.codex']` and `detectionPaths: ['.agents/skills', '.codex/skills']`. + +Rationale: existing projects should still appear configured for Codex so `openspec update` can migrate them. Detection should not depend on broad globbing. + +### 3. Cleanup Is Limited To Managed OpenSpec Skill Directories + +After a successful `.agents/skills` write, remove only legacy `.codex/skills/` directories. This applies to repo-local init/update and workspace-local skill setup/update. The known list should come from existing skill/workflow constants. + +Rationale: users may have unrelated Codex files or custom skills under `.codex`. The cleanup must not delete anything that is not a known OpenSpec generated skill directory. + +Alternative considered: leave `.codex/skills` untouched and only print a warning. Rejected because users asked for cleanup, and stale OpenSpec skills can keep confusing agents and contributors. + +Alternative considered: delete all of `.codex/skills`. Rejected because it can remove user-authored skills. + +### 4. Documentation Uses `.agents/skills` For Codex + +Codex rows, examples, and setup guidance should describe `.agents/skills`. Any `.codex/skills` mention should be framed only as a legacy migration note. + +Rationale: documentation is part of the migration. If docs keep `.codex/skills`, contributors will recreate the drift. + +## Risks / Trade-offs + +- **Risk: removing user edits inside generated OpenSpec skill directories** - Mitigation: remove only known `openspec-*` legacy directories and only after the replacement is written. This matches the generated artifact contract. +- **Risk: duplicate skills during failed migration** - Mitigation: perform cleanup after successful `.agents/skills` generation, not before. +- **Risk: duplicate workspace-local Codex skills** - Mitigation: workspace setup/update should use the same managed legacy cleanup rule as repo-local init/update. +- **Risk: Windows path regressions** - Mitigation: use `path.join()` or existing file-system helpers and add path tests. +- **Risk: PR overlap with #1143** - Mitigation: keep this change path-focused and avoid re-solving prompt deprecation. + +## Migration Plan + +1. Update Codex `AI_TOOLS` metadata to use `.agents`, detect `.agents/skills` and `.codex/skills`, and store legacy skill roots. +2. Update init/update to remove managed legacy Codex skill directories after successful Codex skill generation. +3. Update workspace skill setup/update to remove managed legacy Codex skill directories after successful workspace-local Codex skill generation. +4. Update docs and output to reference `.agents/skills` for Codex. +5. Add tests for current generation, legacy detection, migration cleanup, unmanaged content preservation, workspace migration cleanup, and cross-platform paths. diff --git a/openspec/changes/migrate-codex-skills-to-agents/proposal.md b/openspec/changes/migrate-codex-skills-to-agents/proposal.md new file mode 100644 index 000000000..81d7aa841 --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/proposal.md @@ -0,0 +1,32 @@ +## Why + +OpenAI's current Codex Skills convention discovers repository skills from `.agents/skills`, but OpenSpec's Codex integration and docs still have legacy `.codex/skills` assumptions in places. This creates the exact gap left by the skills-first Codex PR: prompts may be deprecated, but Codex skills still land in the wrong repo-local directory. + +## What Changes + +- Set Codex skill generation to `.agents/skills/openspec-*/SKILL.md`. +- Detect legacy Codex OpenSpec skills in `.codex/skills` so existing projects are still recognized as configured for Codex. +- On init/update refresh, migrate legacy Codex OpenSpec skills by regenerating them under `.agents/skills`. +- Clean up only OpenSpec-managed legacy `.codex/skills/openspec-*` directories after the `.agents/skills` replacement succeeds. +- Leave unmanaged or non-OpenSpec `.codex/skills` content untouched. +- Update Codex-facing docs and success output so Codex skill paths consistently point to `.agents/skills`. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `ai-tool-paths`: Codex uses `.agents` as its current skill root and treats `.codex` as a legacy detection and cleanup path. +- `cli-init`: Codex setup writes skills to `.agents/skills`, detects legacy `.codex/skills`, and removes managed legacy Codex skill directories after successful regeneration. +- `cli-update`: Codex refresh migrates existing `.codex/skills` installs to `.agents/skills`, preserves unmanaged legacy content, and reports the migration. +- `workspace-links`: Codex workspace skills install to `.agents/skills`, migrate managed legacy `.codex/skills/openspec-*` directories, preserve unmanaged legacy content, and report the migration. + +## Impact + +- Affected code: `src/core/config.ts`, tool detection, init/update skill generation and cleanup, docs, and tests. +- Affected docs: supported-tools tables and any Codex-specific setup/update guidance. +- No new dependencies. +- This is intended as a small follow-up to the active Codex skills-first work; it does not need to re-solve custom prompt deprecation or unsupported runtime tool-name rewrites. diff --git a/openspec/changes/migrate-codex-skills-to-agents/specs/ai-tool-paths/spec.md b/openspec/changes/migrate-codex-skills-to-agents/specs/ai-tool-paths/spec.md new file mode 100644 index 000000000..6b738903d --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/specs/ai-tool-paths/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Codex legacy skill path metadata +The system SHALL keep explicit legacy Codex skill path metadata so existing `.codex/skills` installations can be detected and migrated. + +#### Scenario: Detecting current Codex skills +- **WHEN** tool detection checks a project containing `.agents/skills` +- **THEN** Codex SHALL be considered configured + +#### Scenario: Detecting legacy Codex skills +- **WHEN** tool detection checks a project containing `.codex/skills` +- **THEN** Codex SHALL be considered configured +- **AND** Codex skill generation SHALL still target `.agents/skills` + +#### Scenario: Building Codex paths across platforms +- **WHEN** constructing current or legacy Codex skill paths +- **THEN** the system SHALL use platform-aware path handling + +## MODIFIED Requirements + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **WHEN** looking up the `windsurf` tool +- **THEN** `skillsDir` SHALL be `.windsurf` + +#### Scenario: Kimi CLI paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi` + +#### Scenario: Codex paths defined + +- **WHEN** looking up the `codex` tool +- **THEN** `skillsDir` SHALL be `.agents` +- **AND** legacy Codex skill detection SHALL include `.codex/skills` + +#### Scenario: Tools without skillsDir + +- **WHEN** a tool has no `skillsDir` defined +- **THEN** skill generation SHALL error with message indicating the tool is not supported diff --git a/openspec/changes/migrate-codex-skills-to-agents/specs/cli-init/spec.md b/openspec/changes/migrate-codex-skills-to-agents/specs/cli-init/spec.md new file mode 100644 index 000000000..967cd6db0 --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/specs/cli-init/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: AI Tool Configuration + +The command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience. + +#### Scenario: Prompting for AI tool selection + +- **WHEN** run interactively +- **THEN** display animated welcome screen with OpenSpec logo +- **AND** present a searchable multi-select that shows all available tools +- **AND** mark already configured tools with "(configured ✓)" indicator +- **AND** pre-select configured tools for easy refresh +- **AND** sort configured tools to appear first in the list +- **AND** allow filtering by typing to search + +#### Scenario: Selecting tools to configure + +- **WHEN** user selects tools and confirms +- **THEN** generate skills in each selected tool's configured skill path +- **AND** generate slash commands only for selected tools with a registered command adapter +- **AND** create `openspec/config.yaml` with default schema setting + +#### Scenario: Selecting Codex + +- **WHEN** user selects Codex during initialization +- **THEN** generate Codex OpenSpec skills in `.agents/skills` +- **AND** treat `.codex/skills` only as a legacy detection and migration path +- **AND** remove OpenSpec-managed legacy `.codex/skills/openspec-*` directories after the `.agents/skills` replacement succeeds + +### Requirement: Skill Generation + +The command SHALL generate Agent Skills for selected AI tools. + +#### Scenario: Generating skills for a tool + +- **WHEN** a tool is selected during initialization +- **THEN** create 9 skill directories under the selected tool's configured skill path: + - `openspec-explore/SKILL.md` + - `openspec-new-change/SKILL.md` + - `openspec-continue-change/SKILL.md` + - `openspec-apply-change/SKILL.md` + - `openspec-ff-change/SKILL.md` + - `openspec-verify-change/SKILL.md` + - `openspec-sync-specs/SKILL.md` + - `openspec-archive-change/SKILL.md` + - `openspec-bulk-archive-change/SKILL.md` +- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description +- **AND** each SKILL.md SHALL contain the skill instructions + +#### Scenario: Generating Codex skills + +- **WHEN** Codex is selected during initialization +- **THEN** create Codex skill directories under `.agents/skills` +- **AND** do not create Codex OpenSpec skills under `.codex/skills` + +#### Scenario: Preserving unmanaged legacy Codex content + +- **WHEN** Codex initialization encounters `.codex/skills` content outside known OpenSpec-managed skill directories +- **THEN** the system SHALL leave that content untouched + +#### Scenario: Constructing skill paths across platforms + +- **WHEN** skill paths are generated for any selected tool +- **THEN** the system SHALL construct paths using platform-aware path handling diff --git a/openspec/changes/migrate-codex-skills-to-agents/specs/cli-update/spec.md b/openspec/changes/migrate-codex-skills-to-agents/specs/cli-update/spec.md new file mode 100644 index 000000000..dbd13f339 --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/specs/cli-update/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Codex legacy skill migration +The update command SHALL migrate legacy Codex OpenSpec skills from `.codex/skills` to `.agents/skills`. + +#### Scenario: Updating current Codex skills +- **WHEN** a project has Codex OpenSpec skills under `.agents/skills` +- **AND** the user runs `openspec update` +- **THEN** OpenSpec SHALL refresh Codex OpenSpec skills under `.agents/skills` + +#### Scenario: Migrating legacy Codex skills +- **WHEN** a project has Codex OpenSpec skills under `.codex/skills` +- **AND** the user runs `openspec update` +- **THEN** OpenSpec SHALL generate refreshed Codex OpenSpec skills under `.agents/skills` +- **AND** remove OpenSpec-managed legacy `.codex/skills/openspec-*` directories after the replacement succeeds +- **AND** report that legacy Codex skills were migrated + +#### Scenario: Preserving unmanaged legacy Codex skills +- **WHEN** a project has non-OpenSpec or unmanaged content under `.codex/skills` +- **AND** the user runs `openspec update` +- **THEN** OpenSpec SHALL leave that unmanaged content untouched + +#### Scenario: Failed Codex migration +- **WHEN** writing Codex OpenSpec skills to `.agents/skills` fails +- **THEN** OpenSpec SHALL leave legacy `.codex/skills` content untouched +- **AND** surface the write failure with the affected path + +#### Scenario: Constructing migration paths across platforms +- **WHEN** the update command migrates Codex OpenSpec skills +- **THEN** the system SHALL construct `.agents/skills` and `.codex/skills` paths using platform-aware path handling diff --git a/openspec/changes/migrate-codex-skills-to-agents/specs/workspace-links/spec.md b/openspec/changes/migrate-codex-skills-to-agents/specs/workspace-links/spec.md new file mode 100644 index 000000000..79bb88106 --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/specs/workspace-links/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Workspace setup installs agent skills +OpenSpec SHALL let users install OpenSpec agent skills into a workspace during workspace setup. + +#### Scenario: Installing Codex workspace skills +- **WHEN** workspace setup installs Codex agent skills +- **THEN** OpenSpec SHALL generate workspace-local Codex OpenSpec skills under `.agents/skills` +- **AND** it SHALL NOT generate Codex OpenSpec skills under `.codex/skills` +- **AND** it SHALL treat `.codex/skills` only as a legacy detection and migration path + +#### Scenario: Cleaning managed legacy Codex skills during workspace setup +- **WHEN** workspace setup installs Codex agent skills +- **AND** the workspace root contains OpenSpec-managed legacy `.codex/skills/openspec-*` directories +- **THEN** OpenSpec SHALL remove those managed legacy directories only after the `.agents/skills` replacement succeeds +- **AND** it SHALL leave unmanaged `.codex/skills` content untouched + +### Requirement: Workspace update manages agent skills +OpenSpec SHALL provide a workspace update flow for refreshing agent skills after setup. + +#### Scenario: Updating current Codex workspace skills +- **WHEN** workspace update refreshes Codex agent skills +- **AND** the workspace has Codex OpenSpec skills under `.agents/skills` +- **THEN** OpenSpec SHALL refresh Codex OpenSpec skills under `.agents/skills` + +#### Scenario: Migrating legacy Codex workspace skills +- **WHEN** workspace update refreshes Codex agent skills +- **AND** the workspace root contains OpenSpec-managed legacy `.codex/skills/openspec-*` directories +- **THEN** OpenSpec SHALL generate refreshed Codex OpenSpec skills under `.agents/skills` +- **AND** remove OpenSpec-managed legacy `.codex/skills/openspec-*` directories after the replacement succeeds +- **AND** report that legacy workspace Codex skills were migrated + +#### Scenario: Preserving unmanaged legacy Codex workspace skills +- **WHEN** workspace update refreshes Codex agent skills +- **AND** the workspace root contains non-OpenSpec or unmanaged content under `.codex/skills` +- **THEN** OpenSpec SHALL leave that unmanaged content untouched + +#### Scenario: Failed workspace Codex migration +- **WHEN** workspace update fails to write Codex OpenSpec skills to `.agents/skills` +- **THEN** OpenSpec SHALL leave legacy `.codex/skills` content untouched +- **AND** surface the write failure with the affected path diff --git a/openspec/changes/migrate-codex-skills-to-agents/tasks.md b/openspec/changes/migrate-codex-skills-to-agents/tasks.md new file mode 100644 index 000000000..ad8e8210a --- /dev/null +++ b/openspec/changes/migrate-codex-skills-to-agents/tasks.md @@ -0,0 +1,36 @@ +## 1. Path Metadata + +- [x] 1.1 Set Codex `skillsDir` to `.agents` in the supported tool configuration. +- [x] 1.2 Add explicit legacy Codex skill metadata for `.codex`. +- [x] 1.3 Ensure tool detection recognizes both `.agents/skills` and `.codex/skills` as Codex configuration signals. +- [x] 1.4 Verify all Codex skill paths are built with `path.join()` or existing cross-platform file-system helpers. + +## 2. Migration And Cleanup + +- [x] 2.1 Add a helper that lists known OpenSpec-managed Codex skill directories from existing workflow or skill constants. +- [x] 2.2 Update init so Codex writes skills to `.agents/skills`. +- [x] 2.3 Update init so managed legacy `.codex/skills/openspec-*` directories are removed only after successful `.agents/skills` generation. +- [x] 2.4 Update update so legacy `.codex/skills` installs are refreshed under `.agents/skills`. +- [x] 2.5 Update update so unmanaged `.codex/skills` content is preserved. +- [x] 2.6 Report legacy Codex skill migration or cleanup in init/update output. +- [x] 2.7 Update workspace setup so Codex workspace skills write to `.agents/skills` and managed legacy `.codex/skills/openspec-*` directories are removed only after successful generation. +- [x] 2.8 Update workspace update so legacy Codex workspace skills are refreshed under `.agents/skills`, managed legacy `.codex/skills/openspec-*` directories are removed after successful generation, and unmanaged `.codex/skills` content is preserved. +- [x] 2.9 Report legacy Codex workspace skill migration or cleanup in workspace setup/update output, including JSON output. + +## 3. Documentation + +- [x] 3.1 Update supported-tool docs so Codex skills point to `.agents/skills/openspec-*/SKILL.md`. +- [x] 3.2 Remove or reframe `.codex/skills` references so they appear only as legacy migration notes. +- [x] 3.3 Update Codex setup, update, and troubleshooting guidance to use `.agents/skills`. +- [x] 3.4 Keep non-Codex tool path docs unchanged. + +## 4. Tests + +- [x] 4.1 Add tests that `AI_TOOLS` returns Codex `skillsDir: '.agents'` with legacy `.codex/skills` detection. +- [x] 4.2 Add init tests proving Codex generates `.agents/skills` and not `.codex/skills`. +- [x] 4.3 Add init/update tests proving managed legacy `.codex/skills/openspec-*` directories are removed after successful migration. +- [x] 4.4 Add tests proving unmanaged `.codex/skills` content remains untouched. +- [x] 4.5 Add Windows-style path assertions for Codex current and legacy skill paths. +- [x] 4.6 Run `pnpm run build`, `pnpm run lint`, and focused Vitest suites for config, available-tools, tool detection, init, update, workspace skills, and docs-related snapshots if present. +- [x] 4.7 Add workspace setup/update tests proving managed legacy `.codex/skills/openspec-*` directories are removed after successful workspace Codex migration. +- [x] 4.8 Add workspace setup/update tests proving unmanaged legacy `.codex/skills` content remains untouched and failed workspace Codex migration leaves legacy content untouched. diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 5262f6efa..2a83bd836 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -345,6 +345,12 @@ function printWorkspaceSkillReportHuman(report: WorkspaceSkillInstallationReport console.log(` Removed: ${report.removed.map(formatWorkspaceSkillRemovedResult).join(', ')}`); } + if (report.migrated_legacy_codex_skill_count > 0) { + console.log( + ` Migrated: removed ${report.migrated_legacy_codex_skill_count} legacy Codex workspace skill directories from .codex/skills` + ); + } + if (report.skipped.length > 0) { for (const skipped of report.skipped) { const prefix = skipped.name ? `${skipped.name}: ` : ''; diff --git a/src/core/codex-skill-migration.ts b/src/core/codex-skill-migration.ts new file mode 100644 index 000000000..700be06ad --- /dev/null +++ b/src/core/codex-skill-migration.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; + +import { FileSystemUtils } from '../utils/file-system.js'; +import { AI_TOOLS } from './config.js'; +import { + getOpenSpecManagedSkillDirNames, + getToolLegacySkillDirectories, +} from './shared/index.js'; + +const CODEX_TOOL_ID = 'codex'; + +function getCodexTool() { + return AI_TOOLS.find((tool) => tool.value === CODEX_TOOL_ID); +} + +export function getManagedLegacyCodexSkillDirectories(projectPath: string): string[] { + const codexTool = getCodexTool(); + if (!codexTool) return []; + + return getToolLegacySkillDirectories(projectPath, codexTool).flatMap((skillsDir) => + getOpenSpecManagedSkillDirNames().map((dirName) => + FileSystemUtils.joinPath(skillsDir, dirName) + ) + ); +} + +export function hasManagedLegacyCodexSkills(projectPath: string): boolean { + return getManagedLegacyCodexSkillDirectories(projectPath).some((skillDir) => + fs.existsSync(skillDir) + ); +} + +export async function removeManagedLegacyCodexSkills(projectPath: string): Promise { + let removed = 0; + + for (const skillDir of getManagedLegacyCodexSkillDirectories(projectPath)) { + if (!fs.existsSync(skillDir)) continue; + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; + } + + return removed; +} diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26..8b293f01a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,6 +15,7 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + legacySkillsDirs?: string[]; // Previous skill roots used only for detection and migration detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection } @@ -25,7 +26,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' }, { name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' }, { name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' }, - { 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: 'ForgeCode', value: 'forgecode', available: true, successLabel: 'ForgeCode', skillsDir: '.forge' }, { name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' }, { name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' }, diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..b18d9ec5b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -45,6 +45,7 @@ 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 { migrateIfNeeded } from './migration.js'; +import { removeManagedLegacyCodexSkills } from './codex-skill-migration.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -501,6 +502,7 @@ export class InitCommand { commandsSkipped: string[]; removedCommandCount: number; removedSkillCount: number; + migratedLegacyCodexSkillCount: number; }> { const createdTools: typeof tools = []; const refreshedTools: typeof tools = []; @@ -508,6 +510,7 @@ export class InitCommand { const commandsSkipped: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; + let migratedLegacyCodexSkillCount = 0; // Read global config for profile and delivery settings (use --profile override if set) const globalConfig = getGlobalConfig(); @@ -544,6 +547,10 @@ export class InitCommand { // Write the skill file await FileSystemUtils.writeFile(skillFile, skillContent); } + + if (tool.value === 'codex') { + migratedLegacyCodexSkillCount += await removeManagedLegacyCodexSkills(projectPath); + } } if (!shouldGenerateSkills) { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); @@ -588,6 +595,7 @@ export class InitCommand { commandsSkipped, removedCommandCount, removedSkillCount, + migratedLegacyCodexSkillCount, }; } @@ -633,6 +641,7 @@ export class InitCommand { commandsSkipped: string[]; removedCommandCount: number; removedSkillCount: number; + migratedLegacyCodexSkillCount: number; }, configStatus: 'created' | 'exists' | 'skipped' ): void { @@ -682,6 +691,9 @@ export class InitCommand { if (results.removedSkillCount > 0) { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } + if (results.migratedLegacyCodexSkillCount > 0) { + console.log(chalk.dim(`Migrated: removed ${results.migratedLegacyCodexSkillCount} legacy Codex skill directories from .codex/skills`)); + } // Config status if (configStatus === 'created') { diff --git a/src/core/migration.ts b/src/core/migration.ts index 48aaa41ee..70508f123 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,6 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; +import { getToolSkillDirectories } from './shared/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -29,12 +30,13 @@ function scanInstalledWorkflowArtifacts( for (const tool of tools) { if (!tool.skillsDir) continue; - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); 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)) { + const skillExists = getToolSkillDirectories(projectPath, tool).some((skillsDir) => + fs.existsSync(path.join(skillsDir, skillDirName, 'SKILL.md')) + ); + if (skillExists) { installed.add(workflowId); hasSkills = true; } diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9f..ad766c2dd 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -4,7 +4,12 @@ import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; -import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { + COMMAND_IDS, + getConfiguredTools, + getToolCurrentSkillDirectory, + getToolLegacySkillDirectories, +} from './shared/index.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -32,6 +37,15 @@ function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { ); } +function hasManagedLegacySkillDirs(projectPath: string, tool: { legacySkillsDirs?: string[] }): boolean { + return getToolLegacySkillDirectories(projectPath, tool).some((skillsDir) => + ALL_WORKFLOWS.some((workflow) => { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + return fs.existsSync(path.join(skillsDir, dirName)); + }) + ); +} + /** * Checks whether a tool has at least one generated OpenSpec command file. */ @@ -96,7 +110,8 @@ export function hasToolProfileOrDeliveryDrift( const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set(knownDesiredWorkflows); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = getToolCurrentSkillDirectory(projectPath, tool); + if (!skillsDir) return false; const adapter = CommandAdapterRegistry.get(toolId); const shouldGenerateSkills = delivery !== 'commands'; const shouldGenerateCommands = delivery !== 'skills'; @@ -119,6 +134,10 @@ export function hasToolProfileOrDeliveryDrift( return true; } } + + if (hasManagedLegacySkillDirs(projectPath, tool)) { + return true; + } } else { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; @@ -184,14 +203,20 @@ function getInstalledWorkflowsForTool( if (!tool?.skillsDir) return []; const installed = new Set(); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillDirectories = [getToolCurrentSkillDirectory(projectPath, tool)].filter( + (skillsDir): skillsDir is string => skillsDir !== null + ); if (options.includeSkills) { - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - installed.add(workflow); + skillDirectories.push(...getToolLegacySkillDirectories(projectPath, tool)); + + for (const skillsDir of skillDirectories) { + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + installed.add(workflow); + } } } } diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696..33a4add1d 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -11,6 +11,10 @@ export { type CommandId, type ToolSkillStatus, type ToolVersionStatus, + getOpenSpecManagedSkillDirNames, + getToolCurrentSkillDirectory, + getToolLegacySkillDirectories, + getToolSkillDirectories, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a..ce8237d26 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -4,9 +4,9 @@ * Shared utilities for detecting tool configurations and version status. */ -import path from 'path'; import * as fs from 'fs'; -import { AI_TOOLS } from '../config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { AI_TOOLS, type AIToolOption } from '../config.js'; /** * Names of skill directories created by openspec init. @@ -46,6 +46,47 @@ export const COMMAND_IDS = [ export type CommandId = (typeof COMMAND_IDS)[number]; +type SkillPathTool = Pick; + +/** + * Names of OpenSpec-managed skill directories. + */ +export function getOpenSpecManagedSkillDirNames(): string[] { + return [...SKILL_NAMES]; +} + +export function getToolCurrentSkillDirectory( + projectRoot: string, + tool: SkillPathTool +): string | null { + if (!tool.skillsDir) return null; + return FileSystemUtils.joinPath(projectRoot, tool.skillsDir, 'skills'); +} + +export function getToolLegacySkillDirectories( + projectRoot: string, + tool: SkillPathTool +): string[] { + return (tool.legacySkillsDirs ?? []).map((legacySkillsDir) => + FileSystemUtils.joinPath(projectRoot, legacySkillsDir, 'skills') + ); +} + +export function getToolSkillDirectories( + projectRoot: string, + tool: SkillPathTool, + options: { includeLegacy?: boolean } = {} +): string[] { + const currentSkillDirectory = getToolCurrentSkillDirectory(projectRoot, tool); + const directories = currentSkillDirectory ? [currentSkillDirectory] : []; + + if (options.includeLegacy !== false) { + directories.push(...getToolLegacySkillDirectories(projectRoot, tool)); + } + + return directories; +} + /** * Status of skill configuration for a tool. */ @@ -90,12 +131,14 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki return { configured: false, fullyConfigured: false, skillCount: 0 }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillDirectories = getToolSkillDirectories(projectRoot, tool); let skillCount = 0; for (const skillName of SKILL_NAMES) { - const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); - if (fs.existsSync(skillFile)) { + const isConfigured = skillDirectories.some((skillsDir) => + fs.existsSync(FileSystemUtils.joinPath(skillsDir, skillName, 'SKILL.md')) + ); + if (isConfigured) { skillCount++; } } @@ -173,14 +216,22 @@ export function getToolVersionStatus( }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillDirectories = getToolSkillDirectories(projectRoot, tool); let generatedByVersion: string | null = null; + let foundSkillFile = false; // 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); + for (const skillsDir of skillDirectories) { + for (const skillName of SKILL_NAMES) { + const skillFile = FileSystemUtils.joinPath(skillsDir, skillName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + generatedByVersion = extractGeneratedByVersion(skillFile); + foundSkillFile = true; + break; + } + } + + if (foundSkillFile) { break; } } diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..007c878ab 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -47,6 +47,7 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; +import { removeManagedLegacyCodexSkills } from './codex-skill-migration.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -180,6 +181,7 @@ export class UpdateCommand { let removedSkillCount = 0; let removedDeselectedCommandCount = 0; let removedDeselectedSkillCount = 0; + let migratedLegacyCodexSkillCount = 0; for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -203,6 +205,10 @@ export class UpdateCommand { } removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + + if (tool.value === 'codex') { + migratedLegacyCodexSkillCount += await removeManagedLegacyCodexSkills(resolvedProjectPath); + } } // Delete skill directories if delivery is commands-only @@ -265,6 +271,9 @@ export class UpdateCommand { if (removedDeselectedSkillCount > 0) { console.log(chalk.dim(`Removed: ${removedDeselectedSkillCount} skill directories (deselected workflows)`)); } + if (migratedLegacyCodexSkillCount > 0) { + console.log(chalk.dim(`Migrated: removed ${migratedLegacyCodexSkillCount} legacy Codex skill directories from .codex/skills`)); + } // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { @@ -674,6 +683,7 @@ export class UpdateCommand { const shouldGenerateCommands = delivery !== 'skills'; const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + let migratedLegacyCodexSkillCount = 0; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -695,6 +705,10 @@ export class UpdateCommand { const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } + + if (tool.value === 'codex') { + migratedLegacyCodexSkillCount += await removeManagedLegacyCodexSkills(projectPath); + } } // Create commands when delivery includes commands @@ -718,6 +732,10 @@ export class UpdateCommand { } } + if (migratedLegacyCodexSkillCount > 0) { + console.log(chalk.dim(`Migrated: removed ${migratedLegacyCodexSkillCount} legacy Codex skill directories from .codex/skills`)); + } + if (newlyConfigured.length > 0) { console.log(); } diff --git a/src/core/workspace/skills.ts b/src/core/workspace/skills.ts index 9caea04a9..f67a76b00 100644 --- a/src/core/workspace/skills.ts +++ b/src/core/workspace/skills.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { FileSystemUtils } from '../../utils/file-system.js'; import { transformToHyphenCommands } from '../../utils/command-references.js'; import { AI_TOOLS, type AIToolOption } from '../config.js'; +import { removeManagedLegacyCodexSkills } from '../codex-skill-migration.js'; import { getGlobalConfig, type Delivery, type Profile } from '../global-config.js'; import { getProfileWorkflows } from '../profiles.js'; import { @@ -54,6 +55,7 @@ export interface WorkspaceSkillInstallationReport { added: WorkspaceSkillAgentResult[]; refreshed: WorkspaceSkillAgentResult[]; removed: WorkspaceSkillRemovedResult[]; + migrated_legacy_codex_skill_count: number; skipped: WorkspaceSkillSkippedResult[]; failed: WorkspaceSkillFailedResult[]; } @@ -147,6 +149,7 @@ function makeBaseWorkspaceSkillReport( added: [], refreshed: [], removed: [], + migrated_legacy_codex_skill_count: 0, skipped: [], failed: [], }; @@ -362,6 +365,11 @@ export async function generateWorkspaceAgentSkills( await FileSystemUtils.writeFile(skillFile, skillContent); } + if (tool.value === 'codex') { + report.migrated_legacy_codex_skill_count += + await removeManagedLegacyCodexSkills(workspaceRoot); + } + const result = makeAgentResult(workspaceRoot, tool, profileContext.workflowIds); if (wasConfigured) { report.refreshed.push(result); @@ -474,6 +482,11 @@ export async function updateWorkspaceAgentSkills( await FileSystemUtils.writeFile(skillFile, skillContent); } + if (tool.value === 'codex') { + report.migrated_legacy_codex_skill_count += + await removeManagedLegacyCodexSkills(workspaceRoot); + } + const removed = await removeManagedWorkflowSkillDirs( workspaceRoot, tool, diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 7e3bffeab..862822589 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -245,7 +245,7 @@ describe('workspace command', () => { }) ); expect(readWorkspaceState(setup.workspace.root).workspace_skills).toBeUndefined(); - expect(fs.existsSync(path.join(setup.workspace.root, '.codex'))).toBe(false); + expect(fs.existsSync(path.join(setup.workspace.root, '.agents'))).toBe(false); }); it('installs profile-selected workspace skills in the workspace root only', async () => { @@ -304,12 +304,12 @@ describe('workspace command', () => { }) ); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-archive-change', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-archive-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); expect(fs.existsSync(path.join(codexHome, 'prompts'))).toBe(false); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); - expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); + expect(fs.existsSync(path.join(api, '.agents'))).toBe(false); expect(readWorkspaceState(workspaceRoot).workspace_skills).toEqual( expect.objectContaining({ @@ -357,12 +357,29 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('profile-sync', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; - const customSkillDir = path.join(workspaceRoot, '.codex', 'skills', 'custom-note'); + const customSkillDir = path.join(workspaceRoot, '.agents', 'skills', 'custom-note'); + const managedLegacySkillDir = path.join( + workspaceRoot, + '.codex', + 'skills', + 'openspec-apply-change' + ); + const unmanagedLegacySkillPath = path.join( + workspaceRoot, + '.codex', + 'skills', + 'team-skill', + 'SKILL.md' + ); fs.mkdirSync(customSkillDir, { recursive: true }); fs.writeFileSync(path.join(customSkillDir, 'README.md'), 'user-owned\n'); + fs.mkdirSync(managedLegacySkillDir, { recursive: true }); + fs.writeFileSync(path.join(managedLegacySkillDir, 'SKILL.md'), 'legacy managed\n'); + fs.mkdirSync(path.dirname(unmanagedLegacySkillPath), { recursive: true }); + fs.writeFileSync(unmanagedLegacySkillPath, 'legacy unmanaged\n'); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-verify-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-verify-change', 'SKILL.md'))).toBe(true); writeGlobalConfig({ profile: 'core', @@ -410,18 +427,21 @@ describe('workspace command', () => { workflow_ids: ['verify'], }), ], + migrated_legacy_codex_skill_count: 1, failed: [], }) ); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-explore', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-archive-change', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-verify-change'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-explore', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-archive-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-verify-change'))).toBe(false); expect(fs.existsSync(path.join(customSkillDir, 'README.md'))).toBe(true); + expect(fs.existsSync(managedLegacySkillDir)).toBe(false); + expect(fs.existsSync(unmanagedLegacySkillPath)).toBe(true); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'prompts'))).toBe(false); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); - expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); + expect(fs.existsSync(path.join(api, '.agents'))).toBe(false); expect(readWorkspaceState(workspaceRoot).workspace_skills).toEqual( expect.objectContaining({ selected_agents: ['codex'], @@ -453,8 +473,8 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('update-redirect', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); writeGlobalConfig({ profile: 'core', @@ -469,10 +489,10 @@ describe('workspace command', () => { expect(update.stdout).toContain('Workspace update complete'); expect(update.stdout).toContain('update-redirect'); expect(update.stdout).not.toContain('not in the managed local workspace views list'); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); - expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); + expect(fs.existsSync(path.join(api, '.agents'))).toBe(false); }); it('updates the workspace passed to openspec update even when another workspace is known', async () => { @@ -500,8 +520,8 @@ describe('workspace command', () => { expect(update.stdout).toContain('Workspace update complete'); expect(update.stdout).toContain('target-first'); expect(update.stdout).not.toContain('Multiple OpenSpec workspaces are known'); - expect(fs.existsSync(path.join(first.workspace.root, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(second.workspace.root, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(first.workspace.root, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(second.workspace.root, '.agents', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); }); it('supports named and flag-selected workspace updates with explicit agent changes', async () => { @@ -513,7 +533,7 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('agent-change', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; - const userSkillDir = path.join(workspaceRoot, '.codex', 'skills', 'user-skill'); + const userSkillDir = path.join(workspaceRoot, '.agents', 'skills', 'user-skill'); fs.mkdirSync(userSkillDir, { recursive: true }); fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), 'user-owned\n'); @@ -548,7 +568,7 @@ describe('workspace command', () => { expect(removePayload.workspace_skills.refreshed).toEqual([ expect.objectContaining({ tool_id: 'claude', workflow_ids: ['apply'] }), ]); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.agents', 'skills', 'openspec-apply-change'))).toBe(false); expect(fs.existsSync(path.join(userSkillDir, 'SKILL.md'))).toBe(true); expect(readWorkspaceState(workspaceRoot).workspace_skills?.selected_agents).toEqual(['claude']); }); @@ -562,7 +582,7 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('unmanaged-collision', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; - const collidingSkillDir = path.join(workspaceRoot, '.codex', 'skills', 'openspec-verify-change'); + const collidingSkillDir = path.join(workspaceRoot, '.agents', 'skills', 'openspec-verify-change'); fs.writeFileSync(path.join(collidingSkillDir, 'SKILL.md'), 'name: user-owned-verify\n'); const update = await runCLI( @@ -585,7 +605,7 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('failed-update-state', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; - const blockingSkillPath = path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose'); + const blockingSkillPath = path.join(workspaceRoot, '.agents', 'skills', 'openspec-propose'); fs.writeFileSync(blockingSkillPath, 'blocks generated skill directory\n'); writeGlobalConfig({ @@ -659,7 +679,7 @@ ${WORKSPACE_GUIDANCE_END_MARKER} expect(agentsContent).not.toContain('Use `changes/` for workspace-level planning'); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); expect(readWorkspaceState(setup.workspace.root).workspace_skills).toBeUndefined(); - expect(fs.existsSync(path.join(setup.workspace.root, '.codex'))).toBe(false); + expect(fs.existsSync(path.join(setup.workspace.root, '.agents'))).toBe(false); }); it('rejects invalid workspace setup tool IDs with structured JSON status', async () => { diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d758070..ad7b6a47b 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -4,6 +4,7 @@ import path from 'path'; import os from 'os'; import { randomUUID } from 'crypto'; import { getAvailableTools } from '../../src/core/available-tools.js'; +import { AI_TOOLS } from '../../src/core/config.js'; describe('available-tools', () => { let testDir: string; @@ -55,8 +56,7 @@ describe('available-tools', () => { }); it('should only return tools that have a skillsDir property', async () => { - // .agents value has no skillsDir in AI_TOOLS config - // Create directories for both a valid and the agents case + // The AGENTS.md-compatible assistant entry has no skillsDir in AI_TOOLS config. await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); const tools = getAvailableTools(testDir); @@ -65,6 +65,34 @@ describe('available-tools', () => { expect(toolValues).not.toContain('agents'); }); + it('should configure Codex with current and legacy skill metadata', () => { + const codex = AI_TOOLS.find((tool) => tool.value === 'codex'); + + expect(codex).toMatchObject({ + skillsDir: '.agents', + legacySkillsDirs: ['.codex'], + detectionPaths: ['.agents/skills', '.codex/skills'], + }); + }); + + it('should detect Codex from current .agents skills', async () => { + await fs.mkdir(path.join(testDir, '.agents', 'skills'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const codex = tools.find((tool) => tool.value === 'codex'); + + expect(codex?.skillsDir).toBe('.agents'); + }); + + it('should detect Codex from legacy .codex skills', async () => { + await fs.mkdir(path.join(testDir, '.codex', 'skills'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const codex = tools.find((tool) => tool.value === 'codex'); + + expect(codex?.skillsDir).toBe('.agents'); + }); + it('should return full AIToolOption objects', async () => { await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..19695c909 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -192,6 +192,48 @@ describe('InitCommand', () => { ).toBe(true); }); + it('should create Codex skills in .agents and not .codex', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const currentSkill = path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'); + const legacySkill = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + + expect(await fileExists(currentSkill)).toBe(true); + expect(await fileExists(legacySkill)).toBe(false); + }); + + it('should remove managed legacy Codex skills after .agents generation', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const managedLegacySkill = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + const unmanagedLegacySkill = path.join(testDir, '.codex', 'skills', 'custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(managedLegacySkill), { recursive: true }); + await fs.writeFile(managedLegacySkill, 'legacy managed'); + await fs.mkdir(path.dirname(unmanagedLegacySkill), { recursive: true }); + await fs.writeFile(unmanagedLegacySkill, 'user-owned'); + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + expect(await fileExists(path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md'))).toBe(true); + expect(await fileExists(managedLegacySkill)).toBe(false); + expect(await fileExists(unmanagedLegacySkill)).toBe(true); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('legacy Codex skill directories'))).toBe(true); + }); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 409206e94..83ce9a04f 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -10,6 +10,7 @@ import { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-con import { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js'; const CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined; +const CODEX_TOOL = AI_TOOLS.find((tool) => tool.value === 'codex') as AIToolOption | undefined; function ensureClaudeTool(): AIToolOption { if (!CLAUDE_TOOL) { @@ -18,12 +19,25 @@ function ensureClaudeTool(): AIToolOption { return CLAUDE_TOOL; } +function ensureCodexTool(): AIToolOption { + if (!CODEX_TOOL) { + throw new Error('Codex tool definition not found'); + } + return CODEX_TOOL; +} + async function writeSkill(projectPath: string, dirName: string): Promise { const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md'); await fsp.mkdir(path.dirname(skillFile), { recursive: true }); await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); } +async function writeLegacyCodexSkill(projectPath: string, dirName: string): Promise { + const skillFile = path.join(projectPath, '.codex', 'skills', dirName, 'SKILL.md'); + await fsp.mkdir(path.dirname(skillFile), { recursive: true }); + await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); +} + async function writeManagedCommand(projectPath: string, workflowId: string): Promise { const adapter = CommandAdapterRegistry.get('claude'); if (!adapter) { @@ -73,6 +87,19 @@ describe('migration', () => { expect(config.workflows).toEqual(['explore', 'apply']); }); + it('migrates from legacy Codex skills when no current .agents skills exist', async () => { + process.env.CODEX_HOME = path.join(projectDir, 'codex-home'); + await writeLegacyCodexSkill(projectDir, 'openspec-explore'); + await writeLegacyCodexSkill(projectDir, 'openspec-apply-change'); + + migrateIfNeeded(projectDir, [ensureCodexTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('skills'); + expect(config.workflows).toEqual(['explore', 'apply']); + }); + it('migrates to custom commands delivery when only managed commands are detected', async () => { await writeManagedCommand(projectDir, 'explore'); await writeManagedCommand(projectDir, 'archive'); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 5a66ff3cd..e5c3c9585 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -5,6 +5,8 @@ import os from 'os'; import { randomUUID } from 'crypto'; import { SKILL_NAMES, + getToolCurrentSkillDirectory, + getToolLegacySkillDirectories, getToolsWithSkillsDir, getToolSkillStatus, getToolStates, @@ -47,12 +49,37 @@ describe('tool-detection', () => { it('should return tools that have skillsDir configured', () => { const tools = getToolsWithSkillsDir(); expect(tools).toContain('claude'); + expect(tools).toContain('codex'); expect(tools).toContain('cursor'); expect(tools).toContain('windsurf'); expect(tools.length).toBeGreaterThan(0); }); }); + describe('Codex skill paths', () => { + it('should build current and legacy Codex skill paths with POSIX path style', () => { + const codex = { skillsDir: '.agents', legacySkillsDirs: ['.codex'] }; + + expect(getToolCurrentSkillDirectory('/repos/platform', codex)).toBe( + '/repos/platform/.agents/skills' + ); + expect(getToolLegacySkillDirectories('/repos/platform', codex)).toEqual([ + '/repos/platform/.codex/skills', + ]); + }); + + it('should build current and legacy Codex skill paths with Windows path style', () => { + const codex = { skillsDir: '.agents', legacySkillsDirs: ['.codex'] }; + + expect(getToolCurrentSkillDirectory('D:\\repos\\platform', codex)).toBe( + 'D:\\repos\\platform\\.agents\\skills' + ); + expect(getToolLegacySkillDirectories('D:\\repos\\platform', codex)).toEqual([ + 'D:\\repos\\platform\\.codex\\skills', + ]); + }); + }); + describe('getToolSkillStatus', () => { it('should return not configured for unknown tool', () => { const status = getToolSkillStatus(testDir, 'unknown-tool'); @@ -79,6 +106,26 @@ describe('tool-detection', () => { expect(status.skillCount).toBe(1); }); + it('should detect Codex skills from current .agents path', async () => { + const skillDir = path.join(testDir, '.agents', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content'); + + const status = getToolSkillStatus(testDir, 'codex'); + expect(status.configured).toBe(true); + expect(status.skillCount).toBe(1); + }); + + it('should detect Codex skills from legacy .codex path', async () => { + const skillDir = path.join(testDir, '.codex', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content'); + + const status = getToolSkillStatus(testDir, 'codex'); + expect(status.configured).toBe(true); + expect(status.skillCount).toBe(1); + }); + it('should detect when all skills exist', async () => { for (const skillName of SKILL_NAMES) { const skillDir = path.join(testDir, '.claude', 'skills', skillName); @@ -267,6 +314,21 @@ Content here expect(status.toolId).toBe('claude'); expect(status.toolName).toBe('Claude Code'); }); + + it('should read Codex version status from legacy .codex skills', async () => { + const skillDir = path.join(testDir, '.codex', 'skills', 'openspec-explore'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, 'SKILL.md'), `--- +metadata: + generatedBy: "0.22.0" +--- +`); + + const status = getToolVersionStatus(testDir, 'codex', '0.23.0'); + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe('0.22.0'); + expect(status.needsUpdate).toBe(true); + }); }); describe('getConfiguredTools', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..cc02684c2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -190,6 +190,95 @@ Old instructions content expect(exists).toBe(false); } }); + + it('should update current Codex skills under .agents', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.agents', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + const updatedSkill = await fs.readFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(updatedSkill).toContain('name: openspec-explore'); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(false); + }); + + it('should migrate legacy Codex skills to .agents and preserve unmanaged .codex content', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const managedLegacySkill = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + const unmanagedLegacySkill = path.join(testDir, '.codex', 'skills', 'custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(managedLegacySkill), { recursive: true }); + await fs.writeFile(managedLegacySkill, `--- +metadata: + generatedBy: "0.1.0" +--- +legacy +`); + await fs.mkdir(path.dirname(unmanagedLegacySkill), { recursive: true }); + await fs.writeFile(unmanagedLegacySkill, 'user-owned'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists(managedLegacySkill)).toBe(false); + expect(await FileSystemUtils.fileExists(unmanagedLegacySkill)).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('legacy Codex skill directories') + ); + + consoleSpy.mockRestore(); + }); + + it('should leave legacy Codex skills when .agents generation fails', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const managedLegacySkill = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + await fs.mkdir(path.dirname(managedLegacySkill), { recursive: true }); + await fs.writeFile(managedLegacySkill, 'legacy'); + + const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils); + const writeSpy = vi + .spyOn(FileSystemUtils, 'writeFile') + .mockImplementation(async (filePath, content) => { + if (filePath.includes('.agents') && filePath.includes('SKILL.md')) { + throw new Error('EACCES: permission denied'); + } + return originalWriteFile(filePath, content); + }); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedLegacySkill)).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(false); + + writeSpy.mockRestore(); + }); }); describe('command updates', () => { @@ -1157,6 +1246,39 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should report legacy Codex skill cleanup during legacy tool upgrade', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + await fs.mkdir(path.join(testDir, '.codex', 'prompts'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.codex', 'prompts', 'openspec-proposal.md'), + 'legacy prompt' + ); + + const legacySkillDir = path.join(testDir, '.codex', 'skills', 'openspec-explore'); + await fs.mkdir(legacySkillDir, { recursive: true }); + await fs.writeFile(path.join(legacySkillDir, 'README.md'), 'legacy managed skill dir'); + + const consoleSpy = vi.spyOn(console, 'log'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Migrated: removed 1 legacy Codex skill directories') + ); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.agents', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.directoryExists(legacySkillDir)).toBe(false); + + consoleSpy.mockRestore(); + }); + it('should upgrade multiple legacy tools with --force', async () => { // Create legacy command directories for Claude and Cursor await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true }); diff --git a/test/core/workspace/skills.test.ts b/test/core/workspace/skills.test.ts index c776ff851..f402b0fb3 100644 --- a/test/core/workspace/skills.test.ts +++ b/test/core/workspace/skills.test.ts @@ -5,10 +5,12 @@ import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + generateWorkspaceAgentSkills, getWorkspaceSkillDirectory, getWorkspaceSkillToolIds, hasWorkspaceSkillProfileDrift, parseWorkspaceSkillToolsValue, + updateWorkspaceAgentSkills, } from '../../../src/core/workspace/skills.js'; import { CORE_WORKFLOWS } from '../../../src/core/profiles.js'; @@ -30,6 +32,29 @@ function withDefaultGlobalConfig(callback: () => T): T { } } +async function withDefaultGlobalConfigAsync(callback: () => Promise): Promise { + const previousConfigHome = process.env.XDG_CONFIG_HOME; + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-skills-')); + + process.env.XDG_CONFIG_HOME = configHome; + + try { + return await callback(); + } finally { + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome; + } + fs.rmSync(configHome, { recursive: true, force: true }); + } +} + +function writeText(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + describe('workspace skill helpers', () => { it('parses workspace --tools values using the skill-capable tool set', () => { expect(parseWorkspaceSkillToolsValue('all')).toEqual(getWorkspaceSkillToolIds()); @@ -45,10 +70,10 @@ describe('workspace skill helpers', () => { it('builds workspace-root skill paths with the workspace path style', () => { expect(getWorkspaceSkillDirectory('/repos/platform-workspace', 'codex')).toBe( - '/repos/platform-workspace/.codex/skills' + '/repos/platform-workspace/.agents/skills' ); expect(getWorkspaceSkillDirectory('D:\\repos\\platform-workspace', 'codex')).toBe( - 'D:\\repos\\platform-workspace\\.codex\\skills' + 'D:\\repos\\platform-workspace\\.agents\\skills' ); }); @@ -66,4 +91,132 @@ describe('workspace skill helpers', () => { ).toBe(false); }); }); + + it('removes managed legacy Codex workspace skills after setup generation', async () => { + await withDefaultGlobalConfigAsync(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-root-')); + + try { + const managedLegacySkill = path.join( + workspaceRoot, + '.codex', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + const unmanagedLegacySkill = path.join( + workspaceRoot, + '.codex', + 'skills', + 'custom-skill', + 'SKILL.md' + ); + writeText(managedLegacySkill, 'legacy managed\n'); + writeText(unmanagedLegacySkill, 'legacy unmanaged\n'); + + const report = await generateWorkspaceAgentSkills(workspaceRoot, ['codex']); + + expect(report.failed).toEqual([]); + expect(report.migrated_legacy_codex_skill_count).toBe(1); + expect(report.refreshed).toEqual([ + expect.objectContaining({ + tool_id: 'codex', + }), + ]); + expect( + fs.existsSync( + path.join(workspaceRoot, '.agents', 'skills', 'openspec-apply-change', 'SKILL.md') + ) + ).toBe(true); + expect(fs.existsSync(path.dirname(managedLegacySkill))).toBe(false); + expect(fs.existsSync(unmanagedLegacySkill)).toBe(true); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + }); + + it('migrates managed legacy Codex workspace skills during update and preserves unmanaged legacy content', async () => { + await withDefaultGlobalConfigAsync(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-root-')); + + try { + const managedLegacySkill = path.join( + workspaceRoot, + '.codex', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + const unmanagedLegacySkill = path.join( + workspaceRoot, + '.codex', + 'skills', + 'team-skill', + 'SKILL.md' + ); + writeText(managedLegacySkill, 'legacy managed\n'); + writeText(unmanagedLegacySkill, 'legacy unmanaged\n'); + + const report = await updateWorkspaceAgentSkills( + workspaceRoot, + ['codex'], + { selected_agents: ['codex'] } + ); + + expect(report.failed).toEqual([]); + expect(report.migrated_legacy_codex_skill_count).toBe(1); + expect(report.refreshed).toEqual([ + expect.objectContaining({ + tool_id: 'codex', + }), + ]); + expect(fs.existsSync(path.dirname(managedLegacySkill))).toBe(false); + expect(fs.existsSync(unmanagedLegacySkill)).toBe(true); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + }); + + it('leaves legacy Codex workspace skills untouched when .agents generation fails', async () => { + await withDefaultGlobalConfigAsync(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-root-')); + + try { + const managedLegacySkill = path.join( + workspaceRoot, + '.codex', + 'skills', + 'openspec-apply-change', + 'SKILL.md' + ); + const blockingGeneratedSkillDir = path.join( + workspaceRoot, + '.agents', + 'skills', + 'openspec-apply-change' + ); + writeText(managedLegacySkill, 'legacy managed\n'); + writeText(blockingGeneratedSkillDir, 'blocks generated skill directory\n'); + + const report = await updateWorkspaceAgentSkills( + workspaceRoot, + ['codex'], + { selected_agents: ['codex'] } + ); + + expect(report.migrated_legacy_codex_skill_count).toBe(0); + expect(report.failed).toEqual([ + expect.objectContaining({ + tool_id: 'codex', + error: expect.stringContaining('openspec-apply-change'), + }), + ]); + expect(fs.existsSync(managedLegacySkill)).toBe(true); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + }); });