From d244ec3d917e2e5704a9a0d056d4e708ddf54cbf Mon Sep 17 00:00:00 2001 From: showms Date: Wed, 1 Jul 2026 00:01:39 +0800 Subject: [PATCH 1/6] feat: make Codex skills-only --- docs/commands.md | 1 + docs/how-commands-work.md | 3 +- docs/migration-guide.md | 5 + docs/supported-tools.md | 6 +- docs/troubleshooting.md | 4 +- .../make-codex-skills-only/.openspec.yaml | 2 + .../changes/make-codex-skills-only/design.md | 67 ++++++++ .../make-codex-skills-only/proposal.md | 32 ++++ .../specs/ai-tool-paths/spec.md | 47 ++++++ .../specs/cli-init/spec.md | 50 ++++++ .../specs/cli-update/spec.md | 153 ++++++++++++++++++ .../specs/command-generation/spec.md | 59 +++++++ .../changes/make-codex-skills-only/tasks.md | 48 ++++++ src/core/command-generation/adapters/codex.ts | 44 ----- src/core/command-generation/adapters/index.ts | 1 - src/core/command-generation/registry.ts | 2 - src/core/command-generation/types.ts | 4 +- src/core/command-surface.ts | 32 ++++ src/core/init.ts | 42 +++-- src/core/legacy-cleanup.ts | 145 ++++++++++++++--- src/core/profile-sync-drift.ts | 18 ++- src/core/update.ts | 34 ++-- test/core/command-generation/adapters.test.ts | 58 +------ test/core/command-generation/registry.test.ts | 15 ++ test/core/init.test.ts | 37 +++++ test/core/legacy-cleanup.test.ts | 139 +++++++++++++++- test/core/update.test.ts | 132 ++++++++++++++- 27 files changed, 1020 insertions(+), 160 deletions(-) create mode 100644 openspec/changes/make-codex-skills-only/.openspec.yaml create mode 100644 openspec/changes/make-codex-skills-only/design.md create mode 100644 openspec/changes/make-codex-skills-only/proposal.md create mode 100644 openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md create mode 100644 openspec/changes/make-codex-skills-only/specs/cli-init/spec.md create mode 100644 openspec/changes/make-codex-skills-only/specs/cli-update/spec.md create mode 100644 openspec/changes/make-codex-skills-only/specs/command-generation/spec.md create mode 100644 openspec/changes/make-codex-skills-only/tasks.md delete mode 100644 src/core/command-generation/adapters/codex.ts create mode 100644 src/core/command-surface.ts diff --git a/docs/commands.md b/docs/commands.md index 5d52c056c9..bf3b364df9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -620,6 +620,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | +| Codex | Skill-based invocations from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | | Kimi CLI | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | | Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index e60c9a7618..f5e94a1753 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -79,6 +79,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | +| Codex | skill-style via `.codex/skills/openspec-*` | | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | | Trae | skill-style, e.g. `/openspec-propose` | @@ -91,7 +92,7 @@ When in doubt, type a slash in your AI chat and look at the autocomplete. Your t 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 `.claude/commands/opsx/.md`. They're the older per-tool slash command files. +- **Commands** live in places like `.claude/commands/opsx/.md`. They're the older per-tool slash command files. Codex does not get generated command files; use `.codex/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 d6355740f7..5779824ba8 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -47,6 +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: managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts` are cleaned up by the legacy cleanup flow; Codex now uses `.codex/skills/openspec-*`. - And others (Augment, Continue, Amazon Q, etc.) The migration detects whichever tools you have configured and cleans up their legacy files. @@ -156,6 +157,8 @@ 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. Files outside OpenSpec's managed Codex prompt filename list are preserved. + --- ## Migrating project.md to config.yaml @@ -407,6 +410,8 @@ 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. + --- ## Continuing Existing Changes diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..5c56be7959 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -9,6 +9,8 @@ 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. + By default, OpenSpec uses the `core` profile, which includes: - `propose` - `explore` @@ -29,7 +31,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/.md` | | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-.md`\* | +| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (skills-only; use `.codex/skills/openspec-*`) | | 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` | @@ -53,8 +55,6 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | -\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. - \*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. ## Non-Interactive Setup diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 07b5bb725d..a265f14f6f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,7 +55,7 @@ If `/opsx:propose` (or your tool's equivalent) doesn't appear or doesn't do anyt 5. **Check you initialized this project.** Skills are written per project. If you cloned a repo or switched folders, run `openspec init` (or `openspec update`) there. -6. **Confirm your tool supports command files.** A few tools (Kimi CLI, Trae, ForgeCode, Mistral Vibe) don't get generated `opsx-*` command files; they use skill-based invocations instead. The forms differ per tool: see [Supported Tools](supported-tools.md) and [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). +6. **Confirm your tool supports command files.** Codex and a few other tools (Kimi CLI, Trae, ForgeCode, Mistral Vibe) don't get generated `opsx-*` command files; they use skill-based invocations instead. For Codex, check `.codex/skills/openspec-*`. The forms differ per tool: see [Supported Tools](supported-tools.md) and [How Commands Work](how-commands-work.md#slash-command-syntax-by-tool). ## Working with changes @@ -149,6 +149,8 @@ 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`. Non-interactive `openspec init` removes those managed files during standard cleanup; non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`. The replacement surface is `.codex/skills/openspec-*`. + ### Commands didn't appear after migrating Restart your IDE. Skills are detected at startup. If they still don't appear, run `openspec update` and check the file locations in [Supported Tools](supported-tools.md). diff --git a/openspec/changes/make-codex-skills-only/.openspec.yaml b/openspec/changes/make-codex-skills-only/.openspec.yaml new file mode 100644 index 0000000000..d6b53dee55 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-30 diff --git a/openspec/changes/make-codex-skills-only/design.md b/openspec/changes/make-codex-skills-only/design.md new file mode 100644 index 0000000000..2a031e979f --- /dev/null +++ b/openspec/changes/make-codex-skills-only/design.md @@ -0,0 +1,67 @@ +## Context + +Codex is currently represented as both a skill-capable tool and a command-file target. Its command adapter writes `opsx-.md` files to the global Codex prompt directory resolved from `CODEX_HOME` or the user's default `.codex` home. That means `openspec init` and `openspec update` can mutate files outside the project, and users can believe a project-local setup succeeded while the observable Codex surface depends on stale global prompt files. + +Codex custom prompts are now deprecated in favor of skills, while OpenSpec already generates `.codex/skills/openspec-*/SKILL.md` as the supported workflow surface. This change removes Codex from the generated command adapter surface and treats Codex as a `skills-invocable` tool even when the user's global delivery mode includes commands. + +## Goals / Non-Goals + +**Goals:** + +- Stop generating or refreshing Codex custom prompt files during `openspec init` and `openspec update`. +- Keep Codex usable through `.codex/skills/openspec-*/SKILL.md` for `both`, `skills`, and `commands` delivery settings. +- Remove stale OpenSpec-managed global Codex prompt files from the global Codex prompt directory during safe cleanup flows while keeping repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- Update user-facing documentation and tests so Codex is documented as skills-only. + +**Non-Goals:** + +- Do not remove Codex as a supported AI tool. +- Do not remove command generation for other tools that still support prompt or command files. +- Do not delete arbitrary user-authored Codex prompt files; cleanup is limited to the final OpenSpec-managed prompt patterns in each scope. +- Do not change Codex workspace opener behavior. + +## Decisions + +### Decision: Remove Codex from the command adapter registry + +Codex should no longer have a registered command adapter. This makes the command-generation layer reflect supported behavior: `CommandAdapterRegistry.get('codex')` returns undefined, and command generation callers skip command-file output for Codex. + +Alternative considered: keep the adapter but gate writes in `init` and `update`. That leaves stale API surface and tests that imply Codex custom prompts are supported. Removing the adapter is clearer and matches adapterless skills-only tools. + +### Decision: Treat Codex as skills-invocable regardless of delivery mode + +Global delivery expresses the preferred output surfaces for tools that support both surfaces. For Codex, the only supported command surface is invocable skills. When a selected or configured Codex tool is processed under `commands` delivery, OpenSpec should still generate and preserve Codex skills while skipping Codex command files. + +Alternative considered: let `commands` delivery remove Codex skills because there is no adapter. That would make selecting Codex produce no usable output, which contradicts the proposal and creates a poor migration path. + +This should reuse the shared command-surface capability model from `add-tool-command-surface-capabilities` if that change lands first. If this change lands first, it should introduce only a shared minimal resolver that can later become the broader capability model; it should not add a Codex-only predicate that `init` and `update` special-case forever. + +This follows the adapterless integration boundary for skills-only tools: do not add a fake command adapter or generated command path when the tool's real invocation surface is discovered skills. Codex also has existing managed global prompt files to retire; those global files are handled as legacy cleanup artifacts, not as ordinary delivery-reconciliation command files. + +### Decision: Split global and repo-local Codex cleanup patterns by their final managed names + +Cleanup should resolve the Codex prompt directory with the same `CODEX_HOME` fallback semantics that command generation used, then remove globally managed OpenSpec prompt files matching `opsx-*.md`. Repo-local compatibility cleanup should continue matching `.codex/prompts/openspec-*.md` inside the project tree. This reflects the final managed names left behind by the Codex adapter history: recent global prompt generation used `opsx-*`, while older local compatibility cleanup still needs to catch project-local `openspec-*` artifacts. + +Alternative considered: explicit filename allowlist per workflow ID. That requires maintaining a separate constant synchronized with the workflow list whenever a new workflow is added. The final glob split avoids that double-maintenance cost while still excluding user-authored prompts that do not match the managed `opsx-*` global convention or the project-local `.codex/prompts/openspec-*` compatibility convention. + +### Decision: Global Codex prompts are handled by legacy cleanup detection + +Managed global Codex prompt files should not create a separate cleanup branch in `init` or `update`. `detectLegacyArtifacts(projectPath)` detects globally managed `opsx-*` prompt files as legacy command artifacts, and `cleanupLegacyArtifacts(projectPath, detection)` removes them when that command's normal cleanup flow runs. Non-interactive `openspec init` already auto-cleans OpenSpec-managed artifacts, so it also removes those managed global Codex prompt files. Non-interactive `openspec update` without `--force` keeps its existing broader behavior: it warns about legacy artifacts and does not run cleanup. + +The adapterless command-skip path must not by itself delete files from `$CODEX_HOME/prompts`. Global Codex prompt deletion is only part of legacy artifact cleanup, not ordinary delivery reconciliation. + +Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for globally managed `opsx-*.md` files. `detectLegacyArtifacts()` should keep those managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`. This keeps the local-path registry scoped to repository artifacts while still letting standard legacy cleanup detect and remove the final managed global Codex prompt files. + +### Decision: Keep legacy project-local `.codex/prompts` cleanup as compatibility cleanup + +Existing cleanup already detects `.codex/prompts/openspec-*.md` in the project tree. That should remain for older or manually migrated projects, but it is insufficient for this change because recent Codex prompt generation used the global Codex home. + +Alternative considered: replace project-local detection with global-only detection. Keeping both avoids regressions for users with older project-local artifacts. + +## Risks / Trade-offs + +- [Risk] Users with custom workflows that rely on Codex custom prompts will lose refreshed prompt files. -> Mitigation: document the breaking change and point Codex users to `.codex/skills/openspec-*`. +- [Risk] `delivery=commands` semantics become per-tool rather than purely global. -> Mitigation: document Codex as a `skills-invocable` command-surface tool and test commands-only Codex init/update. +- [Risk] Cleanup touches a global directory. -> Mitigation: remove only globally managed `opsx-*.md` files under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, and honor `CODEX_HOME` in tests. +- [Risk] Registry tests or docs may still assume Codex has a command adapter. -> Mitigation: update adapter, registry, supported-tools, troubleshooting, and migration docs in the same change. +- [Risk] This overlaps with `add-tool-command-surface-capabilities`. -> Mitigation: represent Codex with the same `skills-invocable` concept and rebase whichever change lands second. diff --git a/openspec/changes/make-codex-skills-only/proposal.md b/openspec/changes/make-codex-skills-only/proposal.md new file mode 100644 index 0000000000..ec279d877c --- /dev/null +++ b/openspec/changes/make-codex-skills-only/proposal.md @@ -0,0 +1,32 @@ +## Why + +Codex custom prompts are now a poor fit for OpenSpec's generated command surface: the official Codex docs deprecate custom prompts in favor of skills, while OpenSpec still treats Codex as a prompt-file target under the user's global Codex home. That mismatch creates confusing setup, stale global artifacts, and a command path that is increasingly likely to fail even when `openspec init` appears to succeed. + +## What Changes + +- **BREAKING**: Stop generating new Codex custom prompt files during `openspec init` and `openspec update`. +- Treat Codex as a skills-first integration so OpenSpec installs and refreshes `.codex/skills/openspec-*/SKILL.md` as the supported Codex workflow surface. +- Treat Codex as a `skills-invocable` command-surface tool so Codex remains usable when the global delivery mode is `both`, `skills`, or `commands`, instead of relying on deprecated prompt-file generation. +- Add migration and cleanup behavior for previously managed Codex prompt files, with global cleanup targeting `$CODEX_HOME/prompts` or `~/.codex/prompts` entries named `opsx-*.md` and repo-local compatibility cleanup preserving `.codex/prompts/openspec-*.md` detection in the project tree. +- Update user-facing docs and CLI messaging so Codex guidance reflects skills-based usage rather than global custom prompts. + +## Capabilities + +### New Capabilities +- None. + +### Modified Capabilities +- `ai-tool-paths`: Codex path metadata changes from global prompt generation expectations to skills-first configuration expectations. +- `cli-init`: Codex initialization no longer creates managed custom prompts and instead installs the supported skills-based workflow surface. +- `cli-update`: Codex update behavior no longer refreshes deprecated custom prompts and instead manages skills plus legacy prompt cleanup. +- `command-generation`: Codex is no longer treated as an active generated command-file target in the supported command adapter surface. + +## Impact + +- Affected code: `src/core/config.ts`, `src/core/init.ts`, `src/core/update.ts`, command-surface capability resolution, Codex-related command-generation and migration/cleanup logic, plus Codex-specific tests. +- Affected docs: `docs/supported-tools.md`, `docs/commands.md`, `docs/how-commands-work.md`, and troubleshooting/setup guidance that currently references Codex prompt files. +- User impact: existing Codex users who rely on generated custom prompts will need to use the skills-based Codex workflow surface after updating. + +## Sequencing + +This change should reuse the command-surface capability model from `add-tool-command-surface-capabilities` when that change lands first. If this change lands first, it should introduce only the minimal shared capability path needed for Codex and leave it compatible with the broader capability-aware delivery work. diff --git a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..6eff0a2d09 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Codex skills path is the supported Codex integration path +The system SHALL identify `.codex/skills/` as the supported Codex OpenSpec workflow path. + +#### Scenario: Codex skills path defined +- **WHEN** looking up the `codex` tool +- **THEN** the system SHALL provide `.codex` as the Codex skills base directory +- **AND** generated Codex skills SHALL be written under `/.codex/skills/` + +#### Scenario: Codex command path is not advertised as supported +- **WHEN** displaying AI tool path documentation or command-generation metadata +- **THEN** the system SHALL present Codex as a skills-only OpenSpec integration +- **AND** it SHALL NOT advertise `$CODEX_HOME/prompts/opsx-.md` as a generated Codex command path + +### Requirement: Codex global prompt cleanup path resolution +The system SHALL resolve the legacy Codex prompt cleanup directory using Codex home semantics. + +#### Scenario: CODEX_HOME is set +- **WHEN** cleaning up previously managed Codex prompt files +- **AND** `CODEX_HOME` is set +- **THEN** the system SHALL inspect the `prompts` directory under the resolved `CODEX_HOME` path + +#### Scenario: CODEX_HOME is unset +- **WHEN** cleaning up previously managed Codex prompt files +- **AND** `CODEX_HOME` is not set +- **THEN** the system SHALL inspect the `prompts` directory under the user's default `.codex` home + +#### Scenario: Cross-platform Codex prompt paths +- **WHEN** resolving Codex skills or legacy prompt cleanup paths on Windows, macOS, or Linux +- **THEN** the system SHALL construct paths with platform path utilities +- **AND** it SHALL preserve correct path separators for the current operating system + +### Requirement: Codex-managed legacy prompt cleanup patterns reflect the final managed surfaces +The system SHALL identify managed Codex prompt cleanup targets using the final split patterns for global and repo-local artifacts. + +#### Scenario: Current global opsx prompt names +- **WHEN** detecting managed legacy Codex prompt files in the resolved Codex prompt directory +- **THEN** the system SHALL match files named `opsx-*.md` + +#### Scenario: Repo-local openspec compatibility prompt names +- **WHEN** detecting legacy Codex prompt files in the project tree +- **THEN** the system SHALL match repo-local files named `.codex/prompts/openspec-*.md` + +#### Scenario: Other Codex prompts are unmanaged +- **WHEN** a Codex prompt file does not match the managed pattern for its scope +- **THEN** cleanup SHALL leave that file unchanged diff --git a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md new file mode 100644 index 0000000000..133992c7b2 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Codex initialization uses the skills-invocable command surface +`openspec init` SHALL treat Codex as a skills-invocable tool, not as an adapter-backed command-file tool. + +#### Scenario: Codex command surface resolution +- **WHEN** a user runs `openspec init` and selects Codex +- **THEN** the command SHALL resolve Codex command surface capability as `skills-invocable` +- **AND** it SHALL apply delivery behavior through the shared command-surface capability model when that model is available +- **AND** it SHALL NOT use a Codex-specific delivery predicate that duplicates command-surface capability rules + +### Requirement: Codex initialization uses skills only +`openspec init` SHALL configure Codex through generated OpenSpec skills without generating Codex custom prompt files. + +#### Scenario: Initializing Codex with default delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `both` +- **THEN** the command SHALL create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL NOT create Codex prompt files under `$CODEX_HOME/prompts` or the default Codex prompt directory + +#### Scenario: Initializing Codex with skills delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `skills` +- **THEN** the command SHALL create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL NOT create Codex prompt files + +#### Scenario: Initializing Codex with commands delivery +- **WHEN** a user runs `openspec init` and selects Codex +- **AND** the active delivery mode is `commands` +- **THEN** the command SHALL still create the selected OpenSpec skill files under `.codex/skills/` +- **AND** it SHALL skip Codex command-file generation because Codex is `skills-invocable` + +### Requirement: Codex initialization cleanup removes managed legacy prompts +`openspec init` SHALL remove previously managed Codex prompt files through the standard legacy cleanup flow without deleting user-authored Codex prompts. + +#### Scenario: Cleanup removes managed global Codex prompt files +- **WHEN** initialization cleanup runs +- **AND** the Codex prompt directory contains files matching the managed global Codex prompt glob pattern (`opsx-*.md`) +- **THEN** the command SHALL remove those managed Codex prompt files +- **AND** it SHALL leave other Codex prompt files unchanged + +#### Scenario: Non-interactive initialization removes managed global Codex prompts +- **WHEN** `openspec init` runs without interaction and without `--force` +- **AND** the resolved global Codex prompt directory contains managed `opsx-*.md` Codex prompt files +- **THEN** the command SHALL remove those managed Codex prompt files through standard legacy cleanup +- **AND** it SHALL leave unmanaged Codex prompt files unchanged + +#### Scenario: Cleanup reports Codex skills as the replacement +- **WHEN** initialization cleanup reports removed Codex prompt files +- **THEN** the cleanup summary SHALL indicate that the removed prompt files are replaced by Codex skills diff --git a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md new file mode 100644 index 0000000000..384dbd95c2 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md @@ -0,0 +1,153 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Updates + +The update command SHALL refresh existing slash command files for configured adapter-backed tools without creating new ones, keep legacy command cleanup safe, and treat Codex custom prompts as legacy artifacts that are cleaned up rather than refreshed. + +#### Scenario: Updating slash commands for Antigravity +- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter +- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for CodeBuddy Code +- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) +- **AND** preserve any user customizations outside the OpenSpec managed markers + +#### Scenario: Updating slash commands for Cline +- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Cline-specific Markdown heading frontmatter +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Continue +- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Crush +- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Crush-specific frontmatter with OpenSpec category and tags +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Factory Droid +- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid +- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched +- **AND** skip creating missing files during update + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments + +#### Scenario: Updating slash commands for Windsurf +- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Kilo Code +- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Codex prompt files are not refreshed +- **GIVEN** the global Codex prompt directory contains OpenSpec-managed Codex prompt files +- **WHEN** a user runs `openspec update` +- **THEN** the command SHALL NOT refresh Codex prompt files +- **AND** it SHALL treat those files as legacy cleanup candidates +- **AND** it SHALL preserve unmanaged content by deleting only files matching the managed global Codex prompt glob pattern (`opsx-*.md`) through the cleanup flow + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Gemini CLI +- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` +- **THEN** refresh the body of each file using the shared proposal/apply/archive templates +- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact +- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed + +#### Scenario: Updating slash commands for iFlow CLI +- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update + +## ADDED Requirements + +### Requirement: Codex update uses the skills-invocable command surface +`openspec update` SHALL treat Codex as a skills-invocable tool, not as an adapter-backed command-file tool. + +#### Scenario: Codex command surface resolution +- **WHEN** `openspec update` detects Codex as a configured tool +- **THEN** the command SHALL resolve Codex command surface capability as `skills-invocable` +- **AND** it SHALL apply delivery behavior through the shared command-surface capability model when that model is available +- **AND** it SHALL NOT use a Codex-specific delivery predicate that duplicates command-surface capability rules + +### Requirement: Codex update uses skills only +`openspec update` SHALL refresh Codex through generated OpenSpec skills without generating or refreshing Codex custom prompt files. + +#### Scenario: Updating Codex with default delivery +- **WHEN** a project has Codex OpenSpec skills configured +- **AND** the active delivery mode is `both` +- **THEN** `openspec update` SHALL refresh the selected Codex skill files under `.codex/skills/` +- **AND** it SHALL NOT create or refresh Codex prompt files under `$CODEX_HOME/prompts` or the default Codex prompt directory + +#### Scenario: Updating Codex with commands delivery +- **WHEN** a project has Codex configured +- **AND** the active delivery mode is `commands` +- **THEN** `openspec update` SHALL keep Codex usable by refreshing selected Codex skills +- **AND** it SHALL skip Codex command-file generation because Codex is skills-invocable +- **AND** it SHALL NOT remove Codex skills solely because the global delivery mode is `commands` + +#### Scenario: Updating Codex with skills delivery +- **WHEN** a project has Codex configured +- **AND** the active delivery mode is `skills` +- **THEN** `openspec update` SHALL refresh selected Codex skills +- **AND** it SHALL treat OpenSpec-managed Codex prompt files as legacy cleanup candidates +- **AND** it SHALL NOT delete global Codex prompt files through ordinary delivery reconciliation without accepted or forced cleanup + +### Requirement: Codex update cleanup removes managed legacy prompts +`openspec update` SHALL clean up previously managed Codex prompt files from the resolved global Codex prompt directory. + +#### Scenario: Forced update cleanup removes Codex prompts +- **WHEN** a user runs `openspec update --force` +- **AND** the resolved Codex prompt directory contains files matching the managed global Codex prompt glob pattern (`opsx-*.md`) +- **THEN** the command SHALL remove those managed Codex prompt files +- **AND** it SHALL leave non-OpenSpec Codex prompt files unchanged + +#### Scenario: Interactive update cleanup includes Codex prompts +- **WHEN** a user runs `openspec update` interactively +- **AND** managed Codex prompt files are detected +- **THEN** the cleanup prompt SHALL include those files in the cleanup plan +- **AND** accepting cleanup SHALL remove them + +#### Scenario: Non-interactive update without force does not delete prompts +- **WHEN** a user runs `openspec update` without interaction and without `--force` +- **AND** managed Codex prompt files are detected +- **THEN** the command SHALL warn that legacy cleanup requires `--force` or an interactive run +- **AND** it SHALL NOT delete Codex prompt files diff --git a/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md b/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md new file mode 100644 index 0000000000..e4d0c10db8 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/command-generation/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command relative from project root unless a supported scoped install resolver provides an absolute target for that adapter + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` + +## ADDED Requirements + +### Requirement: Codex is not a command generation target +The command-generation system SHALL exclude Codex from active command adapter lookup and generation. + +#### Scenario: Codex adapter lookup +- **WHEN** callers request a command adapter for `codex` +- **THEN** the registry SHALL return no command adapter +- **AND** command-file generation callers SHALL treat Codex the same as other skills-only tools + +#### Scenario: Generating commands for all registered adapters +- **WHEN** callers enumerate registered command adapters +- **THEN** the returned adapter list SHALL NOT include Codex +- **AND** no generated command path SHALL point to a Codex global prompt directory + +#### Scenario: Codex command adapter module is not exported +- **WHEN** callers import supported command adapters through the command-generation adapter index +- **THEN** Codex SHALL NOT be exported as a supported command adapter + +### Requirement: Skills-only command skip behavior remains valid for Codex +The system SHALL skip Codex command-file generation while still allowing Codex skill generation. + +#### Scenario: Command generation requested for selected Codex tool +- **WHEN** a selected tool is Codex +- **AND** command generation would otherwise be included by delivery mode +- **THEN** the command generation step SHALL skip Codex command files +- **AND** the Codex skill generation step SHALL remain valid diff --git a/openspec/changes/make-codex-skills-only/tasks.md b/openspec/changes/make-codex-skills-only/tasks.md new file mode 100644 index 0000000000..b2fde277ce --- /dev/null +++ b/openspec/changes/make-codex-skills-only/tasks.md @@ -0,0 +1,48 @@ +## 1. Command Adapter Surface + +- [x] 1.1 Remove Codex from command adapter registration so `CommandAdapterRegistry.get('codex')` returns undefined. +- [x] 1.2 Remove Codex command adapter exports and delete or retire Codex adapter-specific tests. +- [x] 1.3 Update command-generation types, comments, and examples that describe Codex as a global command target. +- [x] 1.4 Update registry tests to assert Codex is not included in `getAll()` or `has('codex')`. + +## 2. Codex Skills-Only Delivery + +- [x] 2.1 Reuse the command-surface capability model for Codex by resolving Codex as `skills-invocable`; do not add a Codex-only delivery predicate. +- [x] 2.2 Update `openspec init` generation so Codex skills are created for `both`, `skills`, and `commands` delivery modes. +- [x] 2.3 Update `openspec init` command cleanup so `commands` delivery does not remove Codex OpenSpec skill directories. +- [x] 2.4 Update `openspec update` generation so configured Codex skills are refreshed for `both`, `skills`, and `commands` delivery modes. +- [x] 2.5 Update `openspec update` delivery reconciliation so `commands` delivery does not remove Codex OpenSpec skill directories. +- [x] 2.6 Keep command generation skipped for Codex whenever command generation would otherwise run. +- [x] 2.7 If `add-tool-command-surface-capabilities` has not landed first, stage the smallest shared capability helper needed so Codex and later skills-invocable tools use the same path. + +## 3. Legacy Codex Prompt Cleanup + +- [x] 3.1 Add final Codex prompt cleanup pattern support: globally managed `opsx-*.md` prompt files and repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- [x] 3.2 Resolve the global Codex prompt directory from `CODEX_HOME` when set and the default user `.codex/prompts` directory when unset. +- [x] 3.3 Detect managed global Codex `opsx-*.md` prompt files during legacy cleanup without globbing or deleting unmanaged prompt files. +- [x] 3.4 Remove managed global Codex `opsx-*.md` prompt files through the standard legacy cleanup flow. +- [x] 3.5 Preserve existing project-local `.codex/prompts/openspec-*.md` cleanup compatibility. +- [x] 3.6 Update cleanup summaries to identify removed Codex prompt files as replaced by Codex skills. +- [x] 3.7 Ensure non-interactive `openspec init` without `--force` removes managed global Codex `opsx-*.md` prompt files through standard cleanup. +- [x] 3.8 Ensure non-interactive `openspec update` without `--force` uses the existing legacy-cleanup warning path and leaves legacy files untouched. + +## 4. Documentation and Messaging + +- [x] 4.1 Update `docs/supported-tools.md` to list Codex as skills-only and remove the `$CODEX_HOME/prompts` command path. +- [x] 4.2 Update command and troubleshooting docs so Codex guidance points to `.codex/skills/openspec-*`. +- [x] 4.3 Update installation or migration guidance to mention managed Codex prompt cleanup and the breaking change. +- [x] 4.4 Update CLI success or skipped-command messaging if needed so Codex users understand skills were installed even when commands are skipped. + +## 5. Tests and Validation + +- [x] 5.1 Add `openspec init` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills exist and global prompt files are not created. +- [x] 5.2 Add `openspec update` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills are refreshed and not removed by commands-only delivery. +- [x] 5.3 Add cleanup tests for managed global Codex prompt files under `CODEX_HOME/prompts` using `opsx-*`, and verify global `openspec-*` prompts remain unmanaged. +- [x] 5.4 Add cleanup tests proving unmanaged files in the Codex prompt directory are preserved. +- [x] 5.5 Add non-interactive init cleanup tests proving managed global Codex prompt files are removed without `--force`. +- [x] 5.6 Add non-interactive update cleanup tests proving global Codex prompt files are preserved without `--force`. +- [x] 5.7 Add cross-platform path tests that construct Codex cleanup paths with path utilities rather than hardcoded separators. +- [x] 5.8 Update or remove tests that import the removed Codex adapter directly. +- [x] 5.9 Add command-surface tests proving Codex resolves as `skills-invocable` and does not require a command adapter. +- [x] 5.10 Run targeted test suites for command generation, init, update, legacy cleanup, and docs-related snapshots if present. +- [x] 5.11 Run `openspec validate make-codex-skills-only --strict`. diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts deleted file mode 100644 index 64e73550b9..0000000000 --- a/src/core/command-generation/adapters/codex.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Codex Command Adapter - * - * Formats commands for Codex following its frontmatter specification. - * Codex custom prompts live in the global home directory (~/.codex/prompts/) - * and are not shared through the repository. The CODEX_HOME env var can - * override the default ~/.codex location. - */ - -import os from 'os'; -import path from 'path'; -import type { CommandContent, ToolCommandAdapter } from '../types.js'; - -/** - * Returns the Codex home directory. - * Respects the CODEX_HOME env var, defaulting to ~/.codex. - */ -function getCodexHome(): string { - const envHome = process.env.CODEX_HOME?.trim(); - return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex')); -} - -/** - * Codex adapter for command generation. - * File path: /prompts/opsx-.md (absolute, global) - * Frontmatter: description, argument-hint - */ -export const codexAdapter: ToolCommandAdapter = { - toolId: 'codex', - - getFilePath(commandId: string): string { - return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`); - }, - - formatFile(content: CommandContent): string { - return `--- -description: ${content.description} -argument-hint: command arguments ---- - -${content.body} -`; - }, -}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d6..3fab659128 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -10,7 +10,6 @@ export { auggieAdapter } from './auggie.js'; export { bobAdapter } from './bob.js'; export { claudeAdapter } from './claude.js'; export { clineAdapter } from './cline.js'; -export { codexAdapter } from './codex.js'; export { codebuddyAdapter } from './codebuddy.js'; export { continueAdapter } from './continue.js'; export { costrictAdapter } from './costrict.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707d..7b35e30074 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -12,7 +12,6 @@ import { auggieAdapter } from './adapters/auggie.js'; import { bobAdapter } from './adapters/bob.js'; import { claudeAdapter } from './adapters/claude.js'; import { clineAdapter } from './adapters/cline.js'; -import { codexAdapter } from './adapters/codex.js'; import { codebuddyAdapter } from './adapters/codebuddy.js'; import { continueAdapter } from './adapters/continue.js'; import { costrictAdapter } from './adapters/costrict.js'; @@ -47,7 +46,6 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(bobAdapter); CommandAdapterRegistry.register(claudeAdapter); CommandAdapterRegistry.register(clineAdapter); - CommandAdapterRegistry.register(codexAdapter); CommandAdapterRegistry.register(codebuddyAdapter); CommandAdapterRegistry.register(continueAdapter); CommandAdapterRegistry.register(costrictAdapter); diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 582d8c784f..6cc35ae666 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -36,7 +36,7 @@ export interface ToolCommandAdapter { * Returns the file path for a command. * @param commandId - The command identifier (e.g., 'explore') * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md'). - * May be absolute for tools with global-scoped prompts (e.g., Codex). + * May be absolute for tools with global-scoped command files. */ getFilePath(commandId: string): string; /** @@ -51,7 +51,7 @@ export interface ToolCommandAdapter { * Result of generating a command file. */ export interface GeneratedCommand { - /** File path from project root, or absolute for global-scoped tools */ + /** File path from project root, or absolute for global-scoped command files */ path: string; /** Complete file content (frontmatter + body) */ fileContent: string; diff --git a/src/core/command-surface.ts b/src/core/command-surface.ts new file mode 100644 index 0000000000..2be86dbefd --- /dev/null +++ b/src/core/command-surface.ts @@ -0,0 +1,32 @@ +import { CommandAdapterRegistry } from './command-generation/index.js'; +import type { Delivery } from './global-config.js'; + +export type CommandSurfaceCapability = 'adapter-backed' | 'skills-invocable' | 'none'; + +export function resolveCommandSurfaceCapability(toolId: string): CommandSurfaceCapability { + if (CommandAdapterRegistry.has(toolId)) { + return 'adapter-backed'; + } + + if (toolId === 'codex') { + return 'skills-invocable'; + } + + return 'none'; +} + +export function shouldGenerateSkillsForTool(toolId: string, delivery: Delivery): boolean { + return delivery !== 'commands' || resolveCommandSurfaceCapability(toolId) === 'skills-invocable'; +} + +export function shouldRemoveSkillsForTool(toolId: string, delivery: Delivery): boolean { + return delivery === 'commands' && resolveCommandSurfaceCapability(toolId) !== 'skills-invocable'; +} + +export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery): boolean { + return delivery !== 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed'; +} + +export function shouldReconcileCommandFilesForTool(toolId: string, delivery: Delivery): boolean { + return delivery === 'skills' && resolveCommandSurfaceCapability(toolId) === 'adapter-backed'; +} diff --git a/src/core/init.ts b/src/core/init.ts index 7f5149dd46..2321ff2eea 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -47,6 +47,13 @@ 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 { + resolveCommandSurfaceCapability, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -528,6 +535,7 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }> { @@ -535,6 +543,7 @@ export class InitCommand { const refreshedTools: typeof tools = []; const failedTools: Array<{ name: string; error: Error }> = []; const commandsSkipped: string[] = []; + const skillsInvocableCommandSkips: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; @@ -545,17 +554,19 @@ export class InitCommand { const workflows = getProfileWorkflows(profile, globalConfig.workflows); // Get skill and command templates filtered by profile workflows - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + const deliveryIncludesCommands = delivery !== 'skills'; + const skillTemplates = getSkillTemplates(workflows); + const commandContents = getCommandContents(workflows); // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); try { - // Generate skill files if delivery includes skills + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + + // Generate skill files if the selected delivery and tool capability allow skills if (shouldGenerateSkills) { // Use tool-specific skillsDir const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); @@ -574,7 +585,7 @@ export class InitCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } } - if (!shouldGenerateSkills) { + if (shouldRemoveSkillsForTool(tool.value, delivery)) { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); removedSkillCount += await this.removeSkillDirs(skillsDir); } @@ -589,11 +600,15 @@ export class InitCommand { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } + } + } else if (deliveryIncludesCommands) { + if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { + skillsInvocableCommandSkips.push(tool.value); } else { commandsSkipped.push(tool.value); } } - if (!shouldGenerateCommands) { + if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } @@ -615,6 +630,7 @@ export class InitCommand { refreshedTools, failedTools, commandsSkipped, + skillsInvocableCommandSkips, removedCommandCount, removedSkillCount, }; @@ -656,6 +672,7 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }, @@ -681,8 +698,12 @@ export class InitCommand { const delivery: Delivery = globalConfig.delivery ?? 'both'; const workflows = getProfileWorkflows(profile, globalConfig.workflows); const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0; - const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0; + const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery)) + ? getSkillTemplates(workflows).length + : 0; + const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery)) + ? getCommandContents(workflows).length + : 0; if (skillCount > 0 && commandCount > 0) { console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); } else if (skillCount > 0) { @@ -701,6 +722,9 @@ export class InitCommand { if (results.commandsSkipped.length > 0) { console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`)); } + if (results.skillsInvocableCommandSkips.length > 0) { + console.log(chalk.dim(`Commands skipped for: ${results.skillsInvocableCommandSkips.join(', ')} (uses skills)`)); + } if (results.removedCommandCount > 0) { console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`)); } diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index f3cbb560e1..730c79e670 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -4,6 +4,7 @@ */ import path from 'path'; +import os from 'os'; import { promises as fs } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; @@ -59,6 +60,14 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record = { + 'codex': { + pattern: ['opsx-*.md'], + resolvePromptDir: getCodexPromptDir, + replacementLabel: 'Codex skills', + }, +}; + /** * Pattern types for legacy slash commands */ @@ -68,6 +77,53 @@ export interface LegacySlashCommandPattern { pattern?: string | string[]; // For files type (glob pattern or array of patterns) } +export interface LegacyGlobalPromptPattern { + pattern: string | string[]; + resolvePromptDir: () => string; + replacementLabel?: string; +} + +export function getCodexPromptDir(): string { + const envHome = process.env.CODEX_HOME?.trim(); + const codexHome = envHome ? envHome : path.join(os.homedir(), '.codex'); + return path.join(path.resolve(codexHome), 'prompts'); +} + +function globToRegex(pattern: string): RegExp { + const regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${regexPattern}$`); +} + +function normalizePathForMatch(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function getManagedGlobalLegacyPattern(filePath: string): { toolId: string; pattern: LegacyGlobalPromptPattern } | undefined { + if (!path.isAbsolute(filePath)) { + return undefined; + } + + const resolvedPath = path.resolve(filePath); + + for (const [toolId, pattern] of Object.entries(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { + const promptDir = path.resolve(pattern.resolvePromptDir()); + const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; + if (path.dirname(resolvedPath) !== promptDir) { + continue; + } + + for (const entryPattern of patterns) { + if (globToRegex(entryPattern).test(path.basename(resolvedPath))) { + return { toolId, pattern }; + } + } + } + + return undefined; +} + /** * Result of legacy artifact detection */ @@ -80,6 +136,8 @@ export interface LegacyDetectionResult { slashCommandDirs: string[]; /** Legacy slash command files found (for file-based tools) */ slashCommandFiles: string[]; + /** Managed global command/prompt files found outside the project root */ + globalSlashCommandFiles: string[]; /** Whether openspec/AGENTS.md exists */ hasOpenspecAgents: boolean; /** Whether openspec/project.md exists (preserved, migration hint only) */ @@ -104,6 +162,7 @@ export async function detectLegacyArtifacts( configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -118,7 +177,10 @@ export async function detectLegacyArtifacts( // Detect legacy slash commands const slashResult = await detectLegacySlashCommands(projectPath); result.slashCommandDirs = slashResult.directories; - result.slashCommandFiles = slashResult.files; + result.slashCommandFiles = [...new Set(slashResult.files)]; + + // Detect legacy global slash commands + result.globalSlashCommandFiles = [...new Set(await detectLegacyGlobalPromptFiles())]; // Detect legacy structure files const structureResult = await detectLegacyStructureFiles(projectPath); @@ -131,6 +193,7 @@ export async function detectLegacyArtifacts( result.configFiles.length > 0 || result.slashCommandDirs.length > 0 || result.slashCommandFiles.length > 0 || + result.globalSlashCommandFiles.length > 0 || result.hasOpenspecAgents || result.hasRootAgentsWithMarkers || result.hasProjectMd; @@ -186,14 +249,13 @@ export async function detectLegacySlashCommands( const directories: string[] = []; const files: string[] = []; - for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { + for (const pattern of Object.values(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'directory' && pattern.path) { const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path); if (await FileSystemUtils.directoryExists(dirPath)) { directories.push(pattern.path); } } else if (pattern.type === 'files' && pattern.pattern) { - // For file-based patterns, check for individual files const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; for (const p of patterns) { const foundFiles = await findLegacySlashCommandFiles(projectPath, p); @@ -205,6 +267,33 @@ export async function detectLegacySlashCommands( return { directories, files }; } +/** + * Detects legacy global slash command files. + * + * @returns Object with individual files found + */ +async function detectLegacyGlobalPromptFiles(): Promise { + const foundFiles: string[] = []; + + for (const pattern of Object.values(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { + const promptDir = pattern.resolvePromptDir(); + const filePatterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; + + try { + const entries = await fs.readdir(promptDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && filePatterns.some((filePattern) => globToRegex(filePattern).test(entry.name))) { + foundFiles.push(path.join(promptDir, entry.name)); + } + } + } catch { + // Directory does not exist or cannot be read. + } + } + + return foundFiles; +} + /** * Finds legacy slash command files matching a glob pattern. * @@ -235,14 +324,7 @@ async function findLegacySlashCommandFiles( try { const entries = await fs.readdir(dirPath); - // Convert glob pattern to regex - // openspec-*.md -> /^openspec-.*\.md$/ - // openspec-*.prompt.md -> /^openspec-.*\.prompt\.md$/ - // openspec-*.toml -> /^openspec-.*\.toml$/ - const regexPattern = filePart - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * - .replace(/\*/g, '.*'); // Replace * with .* - const regex = new RegExp(`^${regexPattern}$`); + const regex = globToRegex(filePart); for (const entry of entries) { if (regex.test(entry)) { @@ -410,6 +492,16 @@ export async function cleanupLegacyArtifacts( } } + // Delete managed global slash command files (these are 100% OpenSpec-managed) + for (const filePath of detection.globalSlashCommandFiles) { + try { + await fs.unlink(filePath); + result.deletedFiles.push(filePath); + } catch (error: any) { + result.errors.push(`Failed to delete ${filePath}: ${error.message}`); + } + } + // Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md'); @@ -443,7 +535,11 @@ export function formatCleanupSummary(result: CleanupResult): string { lines.push('Cleaned up legacy files:'); for (const file of result.deletedFiles) { - lines.push(` ✓ Removed ${file}`); + const globalPattern = getManagedGlobalLegacyPattern(file); + const replacement = globalPattern?.pattern.replacementLabel + ? ` (replaced by ${globalPattern.pattern.replacementLabel})` + : ''; + lines.push(` ✓ Removed ${file}${replacement}`); } for (const dir of result.deletedDirs) { @@ -498,6 +594,15 @@ function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: stri removals.push({ path: file, explanation: 'replaced by skills/' }); } + // Managed global slash command files + for (const file of detection.globalSlashCommandFiles) { + const globalPattern = getManagedGlobalLegacyPattern(file); + const explanation = globalPattern + ? `replaced by .${globalPattern.toolId}/skills/` + : 'replaced by skills/'; + removals.push({ path: file, explanation }); + } + // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' }); @@ -604,18 +709,13 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s // Match files to tool IDs using glob patterns for (const file of detection.slashCommandFiles) { // Normalize file path to use forward slashes for consistent matching (Windows compatibility) - const normalizedFile = file.replace(/\\/g, '/'); + const normalizedFile = normalizePathForMatch(file); for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) { if (pattern.type === 'files' && pattern.pattern) { - // Convert glob pattern to regex for matching - // e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; let matched = false; for (const p of patterns) { - const regexPattern = p - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * - .replace(/\*/g, '.*'); // Replace * with .* - const regex = new RegExp(`^${regexPattern}$`); + const regex = globToRegex(p); if (regex.test(normalizedFile)) { tools.add(toolId); matched = true; @@ -627,6 +727,13 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s } } + for (const file of detection.globalSlashCommandFiles) { + const globalPattern = getManagedGlobalLegacyPattern(file); + if (globalPattern) { + tools.add(globalPattern.toolId); + } + } + return Array.from(tools); } diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..270dc6da78 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -5,6 +5,12 @@ import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -98,8 +104,8 @@ export function hasToolProfileOrDeliveryDrift( const desiredWorkflowSet = new Set(knownDesiredWorkflows); const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); const adapter = CommandAdapterRegistry.get(toolId); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; + const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); if (shouldGenerateSkills) { for (const workflow of knownDesiredWorkflows) { @@ -119,7 +125,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else { + } else if (shouldRemoveSkillsForTool(toolId, delivery)) { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); @@ -147,7 +153,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else if (!shouldGenerateCommands && adapter) { + } else if (shouldReconcileCommandFilesForTool(toolId, delivery) && adapter) { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); @@ -226,10 +232,10 @@ export function hasProjectConfigDrift( } const desiredSet = new Set(toKnownWorkflows(desiredWorkflows)); - const includeSkills = delivery !== 'commands'; - const includeCommands = delivery !== 'skills'; for (const toolId of configuredTools) { + const includeSkills = shouldGenerateSkillsForTool(toolId, delivery); + const includeCommands = shouldGenerateCommandsForTool(toolId, delivery); const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands }); if (installed.some((workflow) => !desiredSet.has(workflow))) { return true; diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b1..cee3eb8a0a 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -47,6 +47,13 @@ import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, } from './migration.js'; +import { + resolveCommandSurfaceCapability, + shouldGenerateCommandsForTool, + shouldGenerateSkillsForTool, + shouldReconcileCommandFilesForTool, + shouldRemoveSkillsForTool, +} from './command-surface.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -102,8 +109,6 @@ export class UpdateCommand { const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => (ALL_WORKFLOWS as readonly string[]).includes(workflow) ); - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config const newlyConfiguredTools = await this.handleLegacyCleanup( @@ -169,13 +174,15 @@ export class UpdateCommand { console.log(); // 9. Determine what to generate based on delivery - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const deliveryIncludesCommands = delivery !== 'skills'; + const skillTemplates = getSkillTemplates(desiredWorkflows); + const commandContents = getCommandContents(desiredWorkflows); // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; + const skillsInvocableCommandSkips: string[] = []; let removedCommandCount = 0; let removedSkillCount = 0; let removedDeselectedCommandCount = 0; @@ -189,6 +196,8 @@ export class UpdateCommand { try { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); // Generate skill files if delivery includes skills if (shouldGenerateSkills) { @@ -206,7 +215,7 @@ export class UpdateCommand { } // Delete skill directories if delivery is commands-only - if (!shouldGenerateSkills) { + if (shouldRemoveSkillsForTool(tool.value, delivery)) { removedSkillCount += await this.removeSkillDirs(skillsDir); } @@ -227,10 +236,12 @@ export class UpdateCommand { desiredWorkflows ); } + } else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { + skillsInvocableCommandSkips.push(tool.value); } // Delete command files if delivery is skills-only - if (!shouldGenerateCommands) { + if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); } @@ -253,6 +264,9 @@ export class UpdateCommand { if (failedTools.length > 0) { console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`)); } + if (skillsInvocableCommandSkips.length > 0) { + console.log(chalk.dim(`Commands skipped for: ${skillsInvocableCommandSkips.join(', ')} (uses skills)`)); + } if (removedCommandCount > 0) { console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`)); } @@ -670,10 +684,8 @@ export class UpdateCommand { // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; - const shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const skillTemplates = getSkillTemplates(desiredWorkflows); + const commandContents = getCommandContents(desiredWorkflows); for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -683,6 +695,8 @@ export class UpdateCommand { try { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); + const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); // Create skill files when delivery includes skills if (shouldGenerateSkills) { diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024fb..614ef0ea8e 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -import os from 'os'; import path from 'path'; import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js'; import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js'; @@ -7,7 +6,6 @@ import { auggieAdapter } from '../../../src/core/command-generation/adapters/aug import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js'; import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js'; -import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js'; import { codebuddyAdapter } from '../../../src/core/command-generation/adapters/codebuddy.js'; import { continueAdapter } from '../../../src/core/command-generation/adapters/continue.js'; import { costrictAdapter } from '../../../src/core/command-generation/adapters/costrict.js'; @@ -268,60 +266,6 @@ describe('command-generation/adapters', () => { }); }); - describe('codexAdapter', () => { - it('should have correct toolId', () => { - expect(codexAdapter.toolId).toBe('codex'); - }); - - it('should return an absolute path', () => { - const filePath = codexAdapter.getFilePath('explore'); - expect(path.isAbsolute(filePath)).toBe(true); - }); - - it('should generate path ending with correct structure', () => { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toMatch(/prompts[/\\]opsx-explore\.md$/); - }); - - it('should default to homedir/.codex', () => { - const original = process.env.CODEX_HOME; - delete process.env.CODEX_HOME; - try { - const filePath = codexAdapter.getFilePath('explore'); - const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md'); - expect(filePath).toBe(expected); - } finally { - if (original !== undefined) { - process.env.CODEX_HOME = original; - } - } - }); - - it('should respect CODEX_HOME env var', () => { - const original = process.env.CODEX_HOME; - process.env.CODEX_HOME = '/custom/codex-home'; - try { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join(path.resolve('/custom/codex-home'), 'prompts', 'opsx-explore.md')); - } finally { - if (original !== undefined) { - process.env.CODEX_HOME = original; - } else { - delete process.env.CODEX_HOME; - } - } - }); - - it('should format file with description and argument-hint', () => { - const output = codexAdapter.formatFile(sampleContent); - expect(output).toContain('---\n'); - expect(output).toContain('description: Enter explore mode for thinking'); - expect(output).toContain('argument-hint: command arguments'); - expect(output).toContain('---\n\n'); - expect(output).toContain('This is the command body.'); - }); - }); - describe('codebuddyAdapter', () => { it('should have correct toolId', () => { expect(codebuddyAdapter.toolId).toBe('codebuddy'); @@ -695,7 +639,7 @@ describe('command-generation/adapters', () => { // Verify all adapters produce valid paths const adapters = [ amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter, - codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, + codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51b..8829a021a5 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { resolveCommandSurfaceCapability } from '../../../src/core/command-surface.js'; describe('command-generation/registry', () => { describe('get', () => { @@ -32,6 +33,11 @@ describe('command-generation/registry', () => { expect(adapter).toBeUndefined(); }); + it('should return undefined for Codex', () => { + const adapter = CommandAdapterRegistry.get('codex'); + expect(adapter).toBeUndefined(); + }); + it('should return undefined for empty string', () => { const adapter = CommandAdapterRegistry.get(''); expect(adapter).toBeUndefined(); @@ -52,6 +58,7 @@ describe('command-generation/registry', () => { expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); expect(toolIds).toContain('windsurf'); + expect(toolIds).not.toContain('codex'); }); }); @@ -61,6 +68,7 @@ describe('command-generation/registry', () => { expect(CommandAdapterRegistry.has('cursor')).toBe(true); expect(CommandAdapterRegistry.has('windsurf')).toBe(true); expect(CommandAdapterRegistry.has('junie')).toBe(true); + expect(CommandAdapterRegistry.has('codex')).toBe(false); }); it('should return false for unregistered tools', () => { @@ -105,4 +113,11 @@ describe('command-generation/registry', () => { } }); }); + + describe('command surface capabilities', () => { + it('resolves Codex as skills-invocable without an adapter', () => { + expect(resolveCommandSurfaceCapability('codex')).toBe('skills-invocable'); + expect(CommandAdapterRegistry.get('codex')).toBeUndefined(); + }); + }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed1..ba4f430161 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -36,6 +36,7 @@ describe('InitCommand', () => { configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -192,6 +193,26 @@ describe('InitCommand', () => { ).toBe(true); }); + it.each(['both', 'skills', 'commands'] as const)( + 'should create Codex skills and no global prompts when delivery=%s', + async (delivery) => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery, + }); + + const initCommand = new InitCommand({ tools: 'codex', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + expect(await fileExists(promptFile)).toBe(false); + } + ); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); @@ -500,6 +521,7 @@ describe('InitCommand - profile and detection features', () => { configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`); await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); vi.spyOn(console, 'log').mockImplementation(() => {}); confirmMock.mockReset(); confirmMock.mockResolvedValue(true); @@ -578,6 +600,21 @@ describe('InitCommand - profile and detection features', () => { expect(await directoryExists(newCommandsDir)).toBe(true); }); + it('should remove managed global Codex prompts in non-interactive mode', 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 prompt'); + + const initCommand = new InitCommand({ tools: 'codex' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(false); + expect(await fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + )).toBe(true); + }); + it('should preselect configured tools but not directory-detected tools in extend mode', async () => { // Simulate existing OpenSpec project (extend mode). await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index bfae378055..60ac6efd95 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -8,6 +8,7 @@ import { detectLegacyConfigFiles, detectLegacySlashCommands, detectLegacyStructureFiles, + getCodexPromptDir, hasOpenSpecMarkers, isOnlyOpenSpecContent, removeMarkerBlock, @@ -17,22 +18,28 @@ import { formatProjectMdMigrationHint, getToolsFromLegacyArtifacts, LEGACY_CONFIG_FILES, + LEGACY_GLOBAL_SLASH_COMMAND_PATHS, LEGACY_SLASH_COMMAND_PATHS, } from '../../src/core/legacy-cleanup.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js'; +import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js'; describe('legacy-cleanup', () => { let testDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { + originalEnv = { ...process.env }; testDir = path.join(os.tmpdir(), `openspec-legacy-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); // Create openspec directory structure await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); }); afterEach(async () => { + process.env = originalEnv; await fs.rm(testDir, { recursive: true, force: true }); }); @@ -364,6 +371,20 @@ ${OPENSPEC_MARKERS.end}`); expect(result.files).toContain('.opencode/command/opsx-propose.md'); expect(result.files).toContain('.opencode/command/openspec-new.md'); }); + + it('should not include managed global Codex prompt files in repo-local slash command detection', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); + + const result = await detectLegacySlashCommands(testDir); + + expect(result.files).not.toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.files).not.toContain(path.join(promptDir, 'openspec-proposal.md')); + expect(result.files).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); + }); }); describe('detectLegacyStructureFiles', () => { @@ -462,6 +483,21 @@ ${OPENSPEC_MARKERS.end}`); expect(result.hasOpenspecAgents).toBe(true); expect(result.hasProjectMd).toBe(true); }); + + it('should detect managed global Codex opsx prompt files separately from repo-local slash commands', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); + + const result = await detectLegacyArtifacts(testDir); + + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'openspec-proposal.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); + expect(result.slashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); + }); }); describe('cleanupLegacyArtifacts', () => { @@ -588,6 +624,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['NON_EXISTENT.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -600,6 +637,26 @@ ${OPENSPEC_MARKERS.end}`); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toContain('NON_EXISTENT.md'); }); + + it('should remove managed global Codex opsx prompts and preserve unmanaged prompts', async () => { + const promptDir = getCodexPromptDir(); + const managedPrompt = path.join(promptDir, 'opsx-apply.md'); + const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + const unmanagedPrompt = path.join(promptDir, 'personal.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'managed'); + await fs.writeFile(legacyPrompt, 'managed'); + await fs.writeFile(unmanagedPrompt, 'user'); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain(managedPrompt); + expect(result.deletedFiles).not.toContain(legacyPrompt); + await expect(fs.access(managedPrompt)).rejects.toThrow(); + await expect(fs.access(legacyPrompt)).resolves.not.toThrow(); + await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); + }); }); describe('formatCleanupSummary', () => { @@ -694,6 +751,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -712,6 +770,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -732,6 +791,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLINE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -751,6 +811,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -768,6 +829,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.cursor/commands/openspec-proposal.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -785,6 +847,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -802,6 +865,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -822,6 +886,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -842,6 +907,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md', 'CLINE.md'], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -860,12 +926,32 @@ ${OPENSPEC_MARKERS.end}`); expect(summary).toContain('• CLINE.md'); }); + it('should format managed global slash command files separately from repo-local files', () => { + const globalPrompt = path.join(getCodexPromptDir(), 'opsx-explore.md'); + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [globalPrompt], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const summary = formatDetectionSummary(detection); + expect(summary).toContain('Files to remove'); + expect(summary).toContain(globalPrompt); + }); + it('should return empty string when nothing is detected', () => { const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -931,17 +1017,34 @@ ${OPENSPEC_MARKERS.end}`); }); }); - it('should only include legacy tool IDs that are present in the CommandAdapterRegistry', () => { + it('should only include legacy tool IDs with a command surface capability', () => { const registeredTools = new Set(CommandAdapterRegistry.getAll().map(adapter => adapter.toolId)); - // Verify all legacy map entries correspond to known adapters for (const tool of Object.keys(LEGACY_SLASH_COMMAND_PATHS)) { - expect(registeredTools.has(tool)).toBe(true); + expect(registeredTools.has(tool) || resolveCommandSurfaceCapability(tool) === 'skills-invocable').toBe(true); } // Pi was never a pre-1.0 legacy tool expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('pi'); }); + + it('should use the repo-local compatibility glob pattern for Codex prompt detection', () => { + const codexPatterns = LEGACY_SLASH_COMMAND_PATHS['codex']; + expect(codexPatterns.type).toBe('files'); + const patterns = Array.isArray(codexPatterns.pattern) ? codexPatterns.pattern : [codexPatterns.pattern]; + expect(patterns).toContain('.codex/prompts/openspec-*.md'); + expect(patterns).not.toContain('.codex/prompts/opsx-*.md'); + }); + }); + + describe('LEGACY_GLOBAL_SLASH_COMMAND_PATHS', () => { + it('should define the managed global Codex opsx cleanup pattern separately from project-local paths', () => { + const codexPatterns = LEGACY_GLOBAL_SLASH_COMMAND_PATHS['codex']; + const patterns = Array.isArray(codexPatterns.pattern) ? codexPatterns.pattern : [codexPatterns.pattern]; + expect(patterns).toContain('opsx-*.md'); + expect(patterns).not.toContain('openspec-*.md'); + expect(codexPatterns.resolvePromptDir()).toBe(getCodexPromptDir()); + }); }); describe('getToolsFromLegacyArtifacts', () => { @@ -951,6 +1054,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -968,6 +1072,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.cursor/commands/openspec-proposal.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -985,6 +1090,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec', '.qoder/commands/openspec'], slashCommandFiles: ['.cursor/commands/openspec-apply.md', '.windsurf/workflows/openspec-archive.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1009,6 +1115,7 @@ ${OPENSPEC_MARKERS.end}`); '.cursor/commands/openspec-apply.md', '.cursor/commands/openspec-archive.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1020,12 +1127,31 @@ ${OPENSPEC_MARKERS.end}`); expect(tools).toHaveLength(1); }); + it('should extract codex from managed global legacy prompt files', () => { + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [path.join(getCodexPromptDir(), 'opsx-explore.md')], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const tools = getToolsFromLegacyArtifacts(detection); + expect(tools).toContain('codex'); + expect(tools).toHaveLength(1); + }); + it('should return empty array when no legacy artifacts', () => { const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1042,6 +1168,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.qwen/commands/openspec-proposal.toml'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1059,6 +1186,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.continue/prompts/openspec-apply.prompt'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1076,6 +1204,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.github/prompts/openspec-apply.prompt.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1093,6 +1222,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/opsx-propose.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1110,6 +1240,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/openspec-new.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1130,6 +1261,7 @@ ${OPENSPEC_MARKERS.end}`); '.opencode/command/opsx-propose.md', '.opencode/command/openspec-new.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1149,6 +1281,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..ff8140d6d7 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -41,11 +41,14 @@ function resetMockConfig() { describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { + originalEnv = { ...process.env }; // Create a temporary test directory testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); await fs.mkdir(testDir, { recursive: true }); + process.env.CODEX_HOME = path.join(testDir, 'codex-home'); // Create openspec directory const openspecDir = path.join(testDir, 'openspec'); @@ -61,6 +64,7 @@ describe('UpdateCommand', () => { }); afterEach(async () => { + process.env = originalEnv; // Restore all mocks after each test vi.restoreAllMocks(); @@ -905,6 +909,54 @@ ${OPENSPEC_MARKERS.end} consoleSpy.mockRestore(); }); + it('should remove managed global Codex opsx prompts with --force and preserve unmanaged prompts', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { + recursive: true, + }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'old' + ); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); + const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + const unmanagedPrompt = path.join(promptDir, 'personal-notes.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'managed'); + await fs.writeFile(legacyPrompt, 'managed'); + await fs.writeFile(unmanagedPrompt, 'user'); + + const consoleSpy = vi.spyOn(console, 'log'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Upgrading to the new OpenSpec') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`Removed ${managedPrompt} (replaced by Codex skills)`) + ); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + expect(await FileSystemUtils.fileExists(legacyPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists(unmanagedPrompt)).toBe(true); + + const skillFile = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + expect(await FileSystemUtils.fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('name: openspec-explore'); + + consoleSpy.mockRestore(); + }); + it('should warn but continue with update when legacy files found in non-interactive mode', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1512,6 +1564,80 @@ More user content after markers. )).toBe(false); }); + it.each(['both', 'skills', 'commands'] as const)( + 'should refresh Codex skills and not create global prompts when delivery=%s', + async (delivery) => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery, + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + const skillFile = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + expect(await FileSystemUtils.fileExists(skillFile)).toBe(true); + const skillContent = await fs.readFile(skillFile, 'utf-8'); + expect(skillContent).toContain('name: openspec-explore'); + + const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md'); + expect(await FileSystemUtils.fileExists(promptFile)).toBe(false); + } + ); + + it('should report Codex command generation as skipped because it uses skills', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Updated: Codex') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Commands skipped for: codex (uses skills)') + ); + + consoleSpy.mockRestore(); + }); + + it('should preserve managed global Codex prompts during non-interactive update without force', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, 'legacy prompt'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(legacyPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + }); + it('should remove skills for configured tools without command adapters in commands-only delivery', async () => { setMockConfig({ featureFlags: {}, @@ -1520,8 +1646,10 @@ More user content after markers. }); const { AI_TOOLS } = await import('../../src/core/config.js'); - const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js'); - const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value)); + const { resolveCommandSurfaceCapability } = await import('../../src/core/command-surface.js'); + const adapterlessTool = AI_TOOLS.find((tool) => + tool.skillsDir && resolveCommandSurfaceCapability(tool.value) === 'none' + ); expect(adapterlessTool).toBeDefined(); if (!adapterlessTool?.skillsDir) { return; From 8847835336d90a6695dda15c34d8ee1913058ad5 Mon Sep 17 00:00:00 2001 From: showms Date: Wed, 1 Jul 2026 00:19:06 +0800 Subject: [PATCH 2/6] test: cover codex legacy prompt paths --- test/core/legacy-cleanup.test.ts | 18 ++++++++++++++++++ test/core/update.test.ts | 6 +++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index 60ac6efd95..e92d5e5a44 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -1084,6 +1084,24 @@ ${OPENSPEC_MARKERS.end}`); expect(tools).toHaveLength(1); }); + it('should extract cursor from Windows-style legacy artifact paths', () => { + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: ['.cursor\\commands\\openspec-proposal.md'], + globalSlashCommandFiles: [], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const tools = getToolsFromLegacyArtifacts(detection); + expect(tools).toContain('cursor'); + expect(tools).toHaveLength(1); + }); + it('should extract multiple tools from mixed legacy artifacts', () => { const detection = { configFiles: [], diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ff8140d6d7..1cfab3b84e 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1626,13 +1626,13 @@ More user content after markers. await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); - const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(legacyPrompt, 'legacy prompt'); + await fs.writeFile(managedPrompt, 'legacy prompt'); await updateCommand.execute(testDir); - expect(await FileSystemUtils.fileExists(legacyPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(true); expect(await FileSystemUtils.fileExists( path.join(skillsDir, 'openspec-explore', 'SKILL.md') )).toBe(true); From a29c458f0c9ad05d5a79524f75cba1eec9aaf9eb Mon Sep 17 00:00:00 2001 From: showms Date: Tue, 7 Jul 2026 15:15:17 +0800 Subject: [PATCH 3/6] fix: address review feedback for Codex skills-only migration --- .gitignore | 3 + docs/migration-guide.md | 4 +- docs/troubleshooting.md | 2 +- .../changes/make-codex-skills-only/design.md | 37 +- .../make-codex-skills-only/proposal.md | 2 +- .../specs/ai-tool-paths/spec.md | 5 +- .../specs/cli-init/spec.md | 27 +- .../specs/cli-update/spec.md | 16 +- .../changes/make-codex-skills-only/tasks.md | 13 +- src/core/init.ts | 119 ++++++- src/core/legacy-cleanup.ts | 325 ++++++++++++++++-- src/core/update.ts | 150 ++++++-- test/core/init.test.ts | 47 ++- test/core/legacy-cleanup.test.ts | 78 ++++- test/core/update.test.ts | 62 +++- test/helpers/legacy-codex-prompt.ts | 17 + 16 files changed, 791 insertions(+), 116 deletions(-) create mode 100644 test/helpers/legacy-codex-prompt.ts diff --git a/.gitignore b/.gitignore index 3ed26016aa..65a28896aa 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,6 @@ opencode.json # Bob .bob/ + +# Cursor +.cursor/ diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 5779824ba8..98f8579cbd 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: managed prompt files in `$CODEX_HOME/prompts` or `~/.codex/prompts` are cleaned up by the legacy cleanup flow; Codex now uses `.codex/skills/openspec-*`. +- 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. - 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. Files outside OpenSpec's managed Codex prompt filename list are preserved. +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. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a265f14f6f..8f9aec14e6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -149,7 +149,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`. Non-interactive `openspec init` removes those managed files during standard cleanup; non-interactive `openspec update` leaves all legacy cleanup untouched unless you pass `--force`. The replacement surface is `.codex/skills/openspec-*`. +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`. ### Commands didn't appear after migrating diff --git a/openspec/changes/make-codex-skills-only/design.md b/openspec/changes/make-codex-skills-only/design.md index 2a031e979f..99efa77247 100644 --- a/openspec/changes/make-codex-skills-only/design.md +++ b/openspec/changes/make-codex-skills-only/design.md @@ -10,7 +10,7 @@ Codex custom prompts are now deprecated in favor of skills, while OpenSpec alrea - Stop generating or refreshing Codex custom prompt files during `openspec init` and `openspec update`. - Keep Codex usable through `.codex/skills/openspec-*/SKILL.md` for `both`, `skills`, and `commands` delivery settings. -- Remove stale OpenSpec-managed global Codex prompt files from the global Codex prompt directory during safe cleanup flows while keeping repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- Remove stale OpenSpec-managed global Codex prompt files from the global Codex prompt directory only after replacement Codex skills exist, while keeping repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. - Update user-facing documentation and tests so Codex is documented as skills-only. **Non-Goals:** @@ -38,19 +38,38 @@ This should reuse the shared command-surface capability model from `add-tool-com This follows the adapterless integration boundary for skills-only tools: do not add a fake command adapter or generated command path when the tool's real invocation surface is discovered skills. Codex also has existing managed global prompt files to retire; those global files are handled as legacy cleanup artifacts, not as ordinary delivery-reconciliation command files. -### Decision: Split global and repo-local Codex cleanup patterns by their final managed names +### Decision: Split global and repo-local Codex cleanup by trust level -Cleanup should resolve the Codex prompt directory with the same `CODEX_HOME` fallback semantics that command generation used, then remove globally managed OpenSpec prompt files matching `opsx-*.md`. Repo-local compatibility cleanup should continue matching `.codex/prompts/openspec-*.md` inside the project tree. This reflects the final managed names left behind by the Codex adapter history: recent global prompt generation used `opsx-*`, while older local compatibility cleanup still needs to catch project-local `openspec-*` artifacts. +Cleanup resolves the Codex prompt directory with the same `CODEX_HOME` fallback semantics that command generation used, but the global and repo-local legacy surfaces are not trusted equally. -Alternative considered: explicit filename allowlist per workflow ID. That requires maintaining a separate constant synchronized with the workflow list whenever a new workflow is added. The final glob split avoids that double-maintenance cost while still excluding user-authored prompts that do not match the managed `opsx-*` global convention or the project-local `.codex/prompts/openspec-*` compatibility convention. +Repo-local compatibility cleanup continues matching `.codex/prompts/openspec-*.md` inside the project tree. Those files are repository-scoped compatibility artifacts and can stay in the ordinary legacy cleanup model. -### Decision: Global Codex prompts are handled by legacy cleanup detection +Global Codex prompts live in a user-owned directory outside the repository, so a broad `opsx-*.md` match is too risky. Global cleanup therefore uses an explicit allowlist of the historical OpenSpec-managed Codex prompt filenames plus a legacy-content signature check before treating a file as OpenSpec-managed. Workflow IDs are inferred from those verified filenames. User-authored files like `opsx-review.md`, `opsx-my-flow.md`, or a custom `opsx-explore.md` remain unmanaged and untouched unless they still match the exact legacy OpenSpec Codex prompt content. -Managed global Codex prompt files should not create a separate cleanup branch in `init` or `update`. `detectLegacyArtifacts(projectPath)` detects globally managed `opsx-*` prompt files as legacy command artifacts, and `cleanupLegacyArtifacts(projectPath, detection)` removes them when that command's normal cleanup flow runs. Non-interactive `openspec init` already auto-cleans OpenSpec-managed artifacts, so it also removes those managed global Codex prompt files. Non-interactive `openspec update` without `--force` keeps its existing broader behavior: it warns about legacy artifacts and does not run cleanup. +Alternative considered: keep the final `opsx-*` glob split or rely on filename-only allowlisting. Those options keep implementation small, but they can still classify unrelated user prompts as OpenSpec-managed because the global prompt home is shared with user-authored files. The allowlist plus content signature is the safer boundary for a user-owned global directory. -The adapterless command-skip path must not by itself delete files from `$CODEX_HOME/prompts`. Global Codex prompt deletion is only part of legacy artifact cleanup, not ordinary delivery reconciliation. +### Decision: Global Codex prompt deletion is replacement-gated migration cleanup -Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for globally managed `opsx-*.md` files. `detectLegacyArtifacts()` should keep those managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`. This keeps the local-path registry scoped to repository artifacts while still letting standard legacy cleanup detect and remove the final managed global Codex prompt files. +Managed global Codex prompt files are still detected through legacy artifact detection, but they are not deleted as ordinary "detect then delete" cleanup items. They are migration artifacts: + +- detect the managed global prompt files +- infer the workflow IDs represented by those legacy filenames +- create or confirm replacement `.codex/skills/...` skills for those workflows +- delete only the prompt files whose replacement skills now exist + +This avoids deleting the user's only Codex entry point before OpenSpec has established the replacement skill surface. The adapterless command-skip path must not by itself delete files from `$CODEX_HOME/prompts`, and ordinary delivery reconciliation must not touch them. + +`openspec init` may still auto-clean other OpenSpec-managed legacy artifacts in non-interactive mode, but global Codex prompt deletion is deferred until replacement skills exist. `openspec update --force` or accepted interactive cleanup follows the same replacement-gated rule. + +To keep cleanup previews auditable without falsely implying immediate deletion, CLI messaging should separate immediate repo-local cleanup from deferred global prompts cleanup. The deferred section should list the concrete global prompt paths and their tool IDs, while clearly stating that those prompts are removed only after matching replacement skills exist. + +Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for the allowlisted historical OpenSpec prompt filenames. `detectLegacyArtifacts()` should keep those managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`, and only populate that list when the file contents still match the legacy OpenSpec Codex prompt signature. + +### Decision: Legacy Codex workflow replacement prefers the legacy filenames over the current profile + +When OpenSpec migrates legacy global Codex prompts into skills for an unconfigured Codex tool, the replacement skill set is inferred from the detected prompt filenames where possible. For example, a legacy `opsx-explore.md` maps to `openspec-explore` rather than the full current core profile. + +Alternative considered: reuse the current profile's `desiredWorkflows` for every legacy Codex upgrade. That can silently expand a narrow historical setup into a broader skill set and makes cleanup unsafe because OpenSpec would delete a legacy prompt even when it did not recreate the equivalent workflow. ### Decision: Keep legacy project-local `.codex/prompts` cleanup as compatibility cleanup @@ -62,6 +81,6 @@ Alternative considered: replace project-local detection with global-only detecti - [Risk] Users with custom workflows that rely on Codex custom prompts will lose refreshed prompt files. -> Mitigation: document the breaking change and point Codex users to `.codex/skills/openspec-*`. - [Risk] `delivery=commands` semantics become per-tool rather than purely global. -> Mitigation: document Codex as a `skills-invocable` command-surface tool and test commands-only Codex init/update. -- [Risk] Cleanup touches a global directory. -> Mitigation: remove only globally managed `opsx-*.md` files under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, and honor `CODEX_HOME` in tests. +- [Risk] Cleanup touches a global directory. -> Mitigation: remove only allowlisted and signature-verified OpenSpec-managed legacy prompt files under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, require replacement skills before deletion, and honor `CODEX_HOME` in tests. - [Risk] Registry tests or docs may still assume Codex has a command adapter. -> Mitigation: update adapter, registry, supported-tools, troubleshooting, and migration docs in the same change. - [Risk] This overlaps with `add-tool-command-surface-capabilities`. -> Mitigation: represent Codex with the same `skills-invocable` concept and rebase whichever change lands second. diff --git a/openspec/changes/make-codex-skills-only/proposal.md b/openspec/changes/make-codex-skills-only/proposal.md index ec279d877c..0db4549f6b 100644 --- a/openspec/changes/make-codex-skills-only/proposal.md +++ b/openspec/changes/make-codex-skills-only/proposal.md @@ -7,7 +7,7 @@ Codex custom prompts are now a poor fit for OpenSpec's generated command surface - **BREAKING**: Stop generating new Codex custom prompt files during `openspec init` and `openspec update`. - Treat Codex as a skills-first integration so OpenSpec installs and refreshes `.codex/skills/openspec-*/SKILL.md` as the supported Codex workflow surface. - Treat Codex as a `skills-invocable` command-surface tool so Codex remains usable when the global delivery mode is `both`, `skills`, or `commands`, instead of relying on deprecated prompt-file generation. -- Add migration and cleanup behavior for previously managed Codex prompt files, with global cleanup targeting `$CODEX_HOME/prompts` or `~/.codex/prompts` entries named `opsx-*.md` and repo-local compatibility cleanup preserving `.codex/prompts/openspec-*.md` detection in the project tree. +- Add migration and cleanup behavior for previously managed Codex prompt files, with global cleanup targeting only the known OpenSpec-managed legacy prompt filenames under `$CODEX_HOME/prompts` or `~/.codex/prompts`, deleting them only after replacement Codex skills exist, and repo-local compatibility cleanup preserving `.codex/prompts/openspec-*.md` detection in the project tree. - Update user-facing docs and CLI messaging so Codex guidance reflects skills-based usage rather than global custom prompts. ## Capabilities diff --git a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md index 6eff0a2d09..3a64a4c78e 100644 --- a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md @@ -34,9 +34,10 @@ The system SHALL resolve the legacy Codex prompt cleanup directory using Codex h ### Requirement: Codex-managed legacy prompt cleanup patterns reflect the final managed surfaces The system SHALL identify managed Codex prompt cleanup targets using the final split patterns for global and repo-local artifacts. -#### Scenario: Current global opsx prompt names +#### Scenario: Global legacy Codex prompts use an explicit allowlist and content signature - **WHEN** detecting managed legacy Codex prompt files in the resolved Codex prompt directory -- **THEN** the system SHALL match files named `opsx-*.md` +- **THEN** the system SHALL match only the known OpenSpec-managed legacy Codex prompt filenames whose contents still match the legacy OpenSpec Codex prompt signature +- **AND** it SHALL infer the represented workflow IDs from those filenames #### Scenario: Repo-local openspec compatibility prompt names - **WHEN** detecting legacy Codex prompt files in the project tree diff --git a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md index 133992c7b2..9a507ff2c6 100644 --- a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md @@ -31,20 +31,35 @@ - **AND** it SHALL skip Codex command-file generation because Codex is `skills-invocable` ### Requirement: Codex initialization cleanup removes managed legacy prompts -`openspec init` SHALL remove previously managed Codex prompt files through the standard legacy cleanup flow without deleting user-authored Codex prompts. +`openspec init` SHALL remove previously managed global Codex prompt files only after replacement Codex skills exist, without deleting user-authored Codex prompts. -#### Scenario: Cleanup removes managed global Codex prompt files +#### Scenario: Cleanup removes allowlisted global Codex prompt files after replacement exists - **WHEN** initialization cleanup runs -- **AND** the Codex prompt directory contains files matching the managed global Codex prompt glob pattern (`opsx-*.md`) +- **AND** the Codex prompt directory contains allowlisted and signature-verified managed global Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames - **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave other Codex prompt files unchanged -#### Scenario: Non-interactive initialization removes managed global Codex prompts +#### Scenario: Non-interactive initialization preserves unreplaced global Codex prompts - **WHEN** `openspec init` runs without interaction and without `--force` -- **AND** the resolved global Codex prompt directory contains managed `opsx-*.md` Codex prompt files -- **THEN** the command SHALL remove those managed Codex prompt files through standard legacy cleanup +- **AND** the resolved global Codex prompt directory contains allowlisted and signature-verified managed Codex prompt files +- **AND** replacement Codex skills do not yet exist for at least one detected prompt workflow +- **THEN** the command SHALL preserve the unreplaced Codex prompt files +- **AND** it SHALL continue to leave unmanaged Codex prompt files unchanged + +#### Scenario: Non-interactive initialization removes replaced global Codex prompts +- **WHEN** `openspec init` runs without interaction and without `--force` +- **AND** the resolved global Codex prompt directory contains allowlisted and signature-verified managed Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames +- **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave unmanaged Codex prompt files unchanged +#### Scenario: Initialization preview lists deferred global prompts cleanup separately +- **WHEN** `openspec init` detects managed global Codex prompt files before tool setup +- **THEN** the command SHALL present deferred global prompts cleanup in a separate section from immediate repo-local removals +- **AND** that section SHALL list the concrete prompt paths +- **AND** it SHALL explain that those global prompts are removed only after matching replacement skills are installed + #### Scenario: Cleanup reports Codex skills as the replacement - **WHEN** initialization cleanup reports removed Codex prompt files - **THEN** the cleanup summary SHALL indicate that the removed prompt files are replaced by Codex skills diff --git a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md index 384dbd95c2..b7f6d68316 100644 --- a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md @@ -72,7 +72,7 @@ The update command SHALL refresh existing slash command files for configured ada - **WHEN** a user runs `openspec update` - **THEN** the command SHALL NOT refresh Codex prompt files - **AND** it SHALL treat those files as legacy cleanup candidates -- **AND** it SHALL preserve unmanaged content by deleting only files matching the managed global Codex prompt glob pattern (`opsx-*.md`) through the cleanup flow +- **AND** it SHALL preserve unmanaged content by deleting only allowlisted and signature-verified OpenSpec-managed global Codex prompt files after replacement skills exist #### Scenario: Updating slash commands for GitHub Copilot - **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` @@ -111,6 +111,11 @@ The update command SHALL refresh existing slash command files for configured ada ### Requirement: Codex update uses skills only `openspec update` SHALL refresh Codex through generated OpenSpec skills without generating or refreshing Codex custom prompt files. +#### Scenario: Legacy Codex prompt migration infers workflows from the legacy filenames +- **WHEN** `openspec update` upgrades an unconfigured Codex tool from detected allowlisted and signature-verified global legacy Codex prompt files +- **THEN** it SHALL infer the replacement workflow IDs from the detected prompt filenames where possible +- **AND** it SHALL use that inferred workflow subset for the replacement Codex skills instead of expanding to the current profile's full workflow set + #### Scenario: Updating Codex with default delivery - **WHEN** a project has Codex OpenSpec skills configured - **AND** the active delivery mode is `both` @@ -132,19 +137,22 @@ The update command SHALL refresh existing slash command files for configured ada - **AND** it SHALL NOT delete global Codex prompt files through ordinary delivery reconciliation without accepted or forced cleanup ### Requirement: Codex update cleanup removes managed legacy prompts -`openspec update` SHALL clean up previously managed Codex prompt files from the resolved global Codex prompt directory. +`openspec update` SHALL clean up previously managed Codex prompt files from the resolved global Codex prompt directory only after replacement Codex skills exist. #### Scenario: Forced update cleanup removes Codex prompts - **WHEN** a user runs `openspec update --force` -- **AND** the resolved Codex prompt directory contains files matching the managed global Codex prompt glob pattern (`opsx-*.md`) +- **AND** the resolved Codex prompt directory contains allowlisted and signature-verified managed global Codex prompt files +- **AND** replacement Codex skills exist for the workflows represented by those prompt filenames - **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave non-OpenSpec Codex prompt files unchanged #### Scenario: Interactive update cleanup includes Codex prompts - **WHEN** a user runs `openspec update` interactively +- **THEN** the preview SHALL list immediate repo-local removals separately from deferred global prompts cleanup +- **AND** the deferred section SHALL list the concrete global prompt paths before the user confirms cleanup - **AND** managed Codex prompt files are detected - **THEN** the cleanup prompt SHALL include those files in the cleanup plan -- **AND** accepting cleanup SHALL remove them +- **AND** accepting cleanup SHALL remove only the prompt files whose replacement Codex skills exist #### Scenario: Non-interactive update without force does not delete prompts - **WHEN** a user runs `openspec update` without interaction and without `--force` diff --git a/openspec/changes/make-codex-skills-only/tasks.md b/openspec/changes/make-codex-skills-only/tasks.md index b2fde277ce..8cc3e3f9e2 100644 --- a/openspec/changes/make-codex-skills-only/tasks.md +++ b/openspec/changes/make-codex-skills-only/tasks.md @@ -17,13 +17,14 @@ ## 3. Legacy Codex Prompt Cleanup -- [x] 3.1 Add final Codex prompt cleanup pattern support: globally managed `opsx-*.md` prompt files and repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. +- [x] 3.1 Add final Codex prompt cleanup support: allowlisted globally managed Codex legacy prompt filenames plus repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. - [x] 3.2 Resolve the global Codex prompt directory from `CODEX_HOME` when set and the default user `.codex/prompts` directory when unset. -- [x] 3.3 Detect managed global Codex `opsx-*.md` prompt files during legacy cleanup without globbing or deleting unmanaged prompt files. -- [x] 3.4 Remove managed global Codex `opsx-*.md` prompt files through the standard legacy cleanup flow. +- [x] 3.3 Detect allowlisted and signature-verified global Codex prompt files during legacy cleanup, infer workflow IDs from those filenames, and leave unmanaged prompt files untouched. +- [x] 3.4 Remove managed global Codex prompt files only after replacement Codex skills exist for the represented workflows. - [x] 3.5 Preserve existing project-local `.codex/prompts/openspec-*.md` cleanup compatibility. - [x] 3.6 Update cleanup summaries to identify removed Codex prompt files as replaced by Codex skills. -- [x] 3.7 Ensure non-interactive `openspec init` without `--force` removes managed global Codex `opsx-*.md` prompt files through standard cleanup. +- [x] 3.6a Present deferred global prompts cleanup separately from immediate repo-local removals while listing the affected global prompt files. +- [x] 3.7 Ensure non-interactive `openspec init` without `--force` removes only the managed global Codex prompt files whose replacement skills exist and preserves unreplaced prompts. - [x] 3.8 Ensure non-interactive `openspec update` without `--force` uses the existing legacy-cleanup warning path and leaves legacy files untouched. ## 4. Documentation and Messaging @@ -37,9 +38,9 @@ - [x] 5.1 Add `openspec init` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills exist and global prompt files are not created. - [x] 5.2 Add `openspec update` tests for Codex under `both`, `skills`, and `commands` delivery modes, verifying skills are refreshed and not removed by commands-only delivery. -- [x] 5.3 Add cleanup tests for managed global Codex prompt files under `CODEX_HOME/prompts` using `opsx-*`, and verify global `openspec-*` prompts remain unmanaged. +- [x] 5.3 Add cleanup tests for allowlisted managed global Codex prompt files under `CODEX_HOME/prompts`, and verify custom or non-allowlisted prompts remain unmanaged. - [x] 5.4 Add cleanup tests proving unmanaged files in the Codex prompt directory are preserved. -- [x] 5.5 Add non-interactive init cleanup tests proving managed global Codex prompt files are removed without `--force`. +- [x] 5.5 Add non-interactive init cleanup tests proving managed global Codex prompt files are removed only after replacement skills exist. - [x] 5.6 Add non-interactive update cleanup tests proving global Codex prompt files are preserved without `--force`. - [x] 5.7 Add cross-platform path tests that construct Codex cleanup paths with path utilities rather than hardcoded separators. - [x] 5.8 Update or remove tests that import the removed Codex adapter directly. diff --git a/src/core/init.ts b/src/core/init.ts index 2321ff2eea..409d52b4ed 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -30,7 +30,11 @@ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, + formatDeferredGlobalPromptSummary, formatDetectionSummary, + getLegacyGlobalPromptMatches, + omitGlobalLegacyPromptFiles, + pickGlobalLegacyPromptFiles, type LegacyDetectionResult, } from './legacy-cleanup.js'; import { @@ -46,7 +50,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 { migrateIfNeeded } from './migration.js'; +import { migrateIfNeeded, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js'; import { resolveCommandSurfaceCapability, shouldGenerateCommandsForTool, @@ -94,6 +98,14 @@ type InitCommandOptions = { profile?: string; }; +/** + * Holds the global Codex prompt matches that must wait until replacement skills + * are generated before cleanup can continue. + */ +type DeferredLegacyCleanup = { + detection: LegacyDetectionResult; +}; + // ----------------------------------------------------------------------------- // Init Command Class // ----------------------------------------------------------------------------- @@ -147,7 +159,7 @@ export class InitCommand { } // Check for legacy artifacts and handle cleanup - await this.handleLegacyCleanup(projectPath, extendMode); + const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode); // Detect available tools in the project (task 7.1) const detectedTools = getAvailableTools(projectPath); @@ -183,6 +195,12 @@ export class InitCommand { // Generate skills and commands for each tool const results = await this.generateSkillsAndCommands(projectPath, validatedTools); + // Legacy cleanup was deferred to avoid interfering with skill/command generation; + // now that outputs are written, finalize the cleanup (e.g. remove stale files). + if (deferredLegacyCleanup) { + await this.finalizeDeferredLegacyCleanup(projectPath, deferredLegacyCleanup); + } + // Create config.yaml if needed const configStatus = await this.createConfig(openspecPath, extendMode); @@ -229,18 +247,35 @@ export class InitCommand { // LEGACY CLEANUP // ═══════════════════════════════════════════════════════════ - private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise { + /** + * Cleans repo-local legacy artifacts immediately and defers global Codex prompt + * cleanup until replacement skills have been installed. + */ + private async handleLegacyCleanup(projectPath: string, extendMode: boolean): Promise { // Detect legacy artifacts const detection = await detectLegacyArtifacts(projectPath); if (!detection.hasLegacyArtifacts) { - return; // No legacy artifacts found + return null; // No legacy artifacts found } + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + // Show what was detected - console.log(); - console.log(formatDetectionSummary(detection)); - console.log(); + const immediateSummary = formatDetectionSummary(immediateDetection); + if (immediateSummary) { + console.log(); + console.log(immediateSummary); + console.log(); + } + + // Show which global prompts are deferred — they'll only be removed once + // the corresponding replacement skills are installed during generation. + const deferredSummary = formatDeferredGlobalPromptSummary(detection); + if (deferredSummary) { + console.log(deferredSummary); + console.log(); + } const canPrompt = this.canPromptInteractively(); @@ -248,8 +283,8 @@ export class InitCommand { // --force flag or non-interactive mode: proceed with cleanup automatically. // Legacy slash commands are 100% OpenSpec-managed, and config file cleanup // only removes markers (never deletes files), so auto-cleanup is safe. - await this.performLegacyCleanup(projectPath, detection); - return; + await this.performImmediateLegacyCleanup(projectPath, detection); + return detection.globalSlashCommandFiles.length > 0 ? { detection } : null; } // Interactive mode: prompt for confirmation @@ -265,7 +300,71 @@ export class InitCommand { process.exit(0); } - await this.performLegacyCleanup(projectPath, detection); + await this.performImmediateLegacyCleanup(projectPath, detection); + return detection.globalSlashCommandFiles.length > 0 ? { detection } : null; + } + + /** + * Applies the safe subset of legacy cleanup that does not depend on newly + * generated Codex skills. + */ + private async performImmediateLegacyCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise { + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + if (!immediateDetection.hasLegacyArtifacts) { + return; + } + + await this.performLegacyCleanup(projectPath, immediateDetection); + } + + /** + * Removes only the legacy global Codex prompts whose workflows now have + * replacement skills in the project. + */ + private async finalizeDeferredLegacyCleanup( + projectPath: string, + deferredCleanup: DeferredLegacyCleanup + ): Promise { + const availableCodexWorkflows = await this.getInstalledWorkflowsForTool(projectPath, 'codex'); + const removableMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection) + .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId))); + + if (removableMatches.length > 0) { + await this.performLegacyCleanup( + projectPath, + pickGlobalLegacyPromptFiles( + deferredCleanup.detection, + removableMatches.map((prompt) => prompt.path) + ) + ); + } + + const blockedMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection) + .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path)); + + if (blockedMatches.length > 0) { + console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:')); + for (const prompt of blockedMatches) { + console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`)); + } + console.log(); + } + } + + /** + * Reads the currently installed workflow IDs for a single tool from the + * generated skill layout on disk. + */ + private async getInstalledWorkflowsForTool(projectPath: string, toolId: string): Promise> { + const tool = AI_TOOLS.find((candidate) => candidate.value === toolId); + if (!tool) { + return new Set(); + } + + return new Set(scanInstalledWorkflowsShared(projectPath, [tool])); } private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise { diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 730c79e670..7781b28bb1 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -5,10 +5,12 @@ import path from 'path'; import os from 'os'; -import { promises as fs } from 'fs'; +import { promises as fs, readFileSync } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; import { OPENSPEC_MARKERS } from './config.js'; +import type { WorkflowId } from './profiles.js'; +import { getCommandContents } from './shared/skill-generation.js'; /** * Legacy config file names from the old ToolRegistry. @@ -60,9 +62,59 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record = { + 'opsx-propose.md': ['propose'], + 'opsx-explore.md': ['explore'], + 'opsx-new.md': ['new'], + 'opsx-continue.md': ['continue'], + 'opsx-apply.md': ['apply'], + 'opsx-ff.md': ['ff'], + 'opsx-sync.md': ['sync'], + 'opsx-archive.md': ['archive'], + 'opsx-bulk-archive.md': ['bulk-archive'], + 'opsx-verify.md': ['verify'], + 'opsx-onboard.md': ['onboard'], +}; + +/** + * Pre-computed expected content for each managed global Codex prompt file. + * + * Only single-workflow entries are included — multi-workflow prompts can't be + * deterministically reconstructed from a single command template. Used by + * {@link hasManagedGlobalLegacyPromptSignature} to verify whether an on-disk + * prompt was originally written by OpenSpec. + */ +const LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS: Readonly> = Object.freeze( + Object.fromEntries( + Object.entries(LEGACY_GLOBAL_CODEX_WORKFLOWS) + .map(([fileName, workflowIds]) => { + if (workflowIds.length !== 1) { + return undefined; + } + + const commandContent = getCommandContents(workflowIds)[0]; + if (!commandContent) { + return undefined; + } + + return [fileName, formatLegacyCodexPromptContent(commandContent)] as const; + }) + .filter((entry): entry is readonly [string, string] => entry !== undefined) + ) +); + +/** + * Global legacy prompt locations that live outside the project tree and require + * allowlisted matching instead of broad glob-based cleanup. + */ export const LEGACY_GLOBAL_SLASH_COMMAND_PATHS: Record = { 'codex': { - pattern: ['opsx-*.md'], + managedFileNames: Object.keys(LEGACY_GLOBAL_CODEX_WORKFLOWS), + workflowIdsByFileName: LEGACY_GLOBAL_CODEX_WORKFLOWS, resolvePromptDir: getCodexPromptDir, replacementLabel: 'Codex skills', }, @@ -77,18 +129,37 @@ export interface LegacySlashCommandPattern { pattern?: string | string[]; // For files type (glob pattern or array of patterns) } +/** + * Describes a managed global prompt home and the exact filenames OpenSpec is + * allowed to treat as legacy artifacts there. + */ export interface LegacyGlobalPromptPattern { - pattern: string | string[]; + managedFileNames: readonly string[]; + workflowIdsByFileName?: Readonly>; resolvePromptDir: () => string; replacementLabel?: string; } +/** + * Workflow-aware metadata for a detected global legacy prompt that is safe for + * replacement-gated cleanup. + */ +export interface LegacyGlobalPromptMatch { + path: string; + toolId: string; + managedFileName: string; + workflowIds: readonly WorkflowId[]; + replacementLabel?: string; +} + +// Resolve the Codex global prompts directory, respecting CODEX_HOME if set. export function getCodexPromptDir(): string { const envHome = process.env.CODEX_HOME?.trim(); const codexHome = envHome ? envHome : path.join(os.homedir(), '.codex'); return path.join(path.resolve(codexHome), 'prompts'); } +// Convert a simple glob pattern (only * wildcards) into an anchored RegExp. function globToRegex(pattern: string): RegExp { const regexPattern = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') @@ -96,11 +167,55 @@ function globToRegex(pattern: string): RegExp { return new RegExp(`^${regexPattern}$`); } +// Normalize Windows backslashes to forward slashes for cross-platform path matching. function normalizePathForMatch(filePath: string): string { return filePath.replace(/\\/g, '/'); } -function getManagedGlobalLegacyPattern(filePath: string): { toolId: string; pattern: LegacyGlobalPromptPattern } | undefined { +// Normalize line endings and trim whitespace so that content comparisons +// are not affected by OS-level CRLF vs LF differences. +function normalizePromptContentForMatch(content: string): string { + return content.replace(/\r\n/g, '\n').trim(); +} + +// Reconstruct the Codex prompt file format from a command's description and +// body, matching the front-matter schema Codex uses (description + argument-hint). +function formatLegacyCodexPromptContent(commandContent: { description: string; body: string }): string { + return `--- +description: ${commandContent.description} +argument-hint: command arguments +--- + +${commandContent.body} +`; +} + +// Check whether an on-disk prompt file was originally written by OpenSpec (i.e. +// is "managed") by comparing its normalized content against the expected content +// derived from LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS. +function hasManagedGlobalLegacyPromptSignature(managedFileName: string, content: string): boolean { + const expectedContent = LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS[managedFileName]; + if (!expectedContent) { + return false; + } + + return normalizePromptContentForMatch(content) === normalizePromptContentForMatch(expectedContent); +} + +// Read a global prompt file, returning undefined if it doesn't exist or can't be read. +function readGlobalPromptFileSync(filePath: string): string | undefined { + try { + return readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } +} + +/** + * Classifies a global Codex prompt path as OpenSpec-managed only when it matches + * the explicit legacy allowlist for the resolved prompt home. + */ +function getManagedGlobalLegacyPromptMetadata(filePath: string): LegacyGlobalPromptMatch | undefined { if (!path.isAbsolute(filePath)) { return undefined; } @@ -109,21 +224,46 @@ function getManagedGlobalLegacyPattern(filePath: string): { toolId: string; patt for (const [toolId, pattern] of Object.entries(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { const promptDir = path.resolve(pattern.resolvePromptDir()); - const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; if (path.dirname(resolvedPath) !== promptDir) { continue; } - for (const entryPattern of patterns) { - if (globToRegex(entryPattern).test(path.basename(resolvedPath))) { - return { toolId, pattern }; - } + const managedFileName = path.basename(resolvedPath); + if (pattern.managedFileNames.includes(managedFileName)) { + return { + path: resolvedPath, + toolId, + managedFileName, + workflowIds: pattern.workflowIdsByFileName?.[managedFileName] ?? [], + replacementLabel: pattern.replacementLabel, + }; } } return undefined; } +function getManagedGlobalLegacyPromptMatch( + filePath: string, + fileContent?: string +): LegacyGlobalPromptMatch | undefined { + const match = getManagedGlobalLegacyPromptMetadata(filePath); + if (!match) { + return undefined; + } + + const content = fileContent ?? readGlobalPromptFileSync(match.path); + if (content === undefined) { + return undefined; + } + + if (!hasManagedGlobalLegacyPromptSignature(match.managedFileName, content)) { + return undefined; + } + + return match; +} + /** * Result of legacy artifact detection */ @@ -138,6 +278,8 @@ export interface LegacyDetectionResult { slashCommandFiles: string[]; /** Managed global command/prompt files found outside the project root */ globalSlashCommandFiles: string[]; + /** Details for managed global command/prompt files */ + globalSlashCommandDetails?: LegacyGlobalPromptMatch[]; /** Whether openspec/AGENTS.md exists */ hasOpenspecAgents: boolean; /** Whether openspec/project.md exists (preserved, migration hint only) */ @@ -163,6 +305,7 @@ export async function detectLegacyArtifacts( slashCommandDirs: [], slashCommandFiles: [], globalSlashCommandFiles: [], + globalSlashCommandDetails: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -180,7 +323,8 @@ export async function detectLegacyArtifacts( result.slashCommandFiles = [...new Set(slashResult.files)]; // Detect legacy global slash commands - result.globalSlashCommandFiles = [...new Set(await detectLegacyGlobalPromptFiles())]; + result.globalSlashCommandDetails = await detectLegacyGlobalPromptFiles(); + result.globalSlashCommandFiles = result.globalSlashCommandDetails.map((detail) => detail.path); // Detect legacy structure files const structureResult = await detectLegacyStructureFiles(projectPath); @@ -272,18 +416,26 @@ export async function detectLegacySlashCommands( * * @returns Object with individual files found */ -async function detectLegacyGlobalPromptFiles(): Promise { - const foundFiles: string[] = []; +/** + * Scans the resolved global Codex prompt directories and returns only the + * allowlisted OpenSpec-managed legacy prompt files. + */ +async function detectLegacyGlobalPromptFiles(): Promise { + const foundFiles: LegacyGlobalPromptMatch[] = []; for (const pattern of Object.values(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { const promptDir = pattern.resolvePromptDir(); - const filePatterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern]; try { const entries = await fs.readdir(promptDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isFile() && filePatterns.some((filePattern) => globToRegex(filePattern).test(entry.name))) { - foundFiles.push(path.join(promptDir, entry.name)); + if (entry.isFile() && pattern.managedFileNames.includes(entry.name)) { + const fullPath = path.join(promptDir, entry.name); + const content = await fs.readFile(fullPath, 'utf-8'); + const match = getManagedGlobalLegacyPromptMatch(fullPath, content); + if (match) { + foundFiles.push(match); + } } } } catch { @@ -425,6 +577,8 @@ export function removeMarkerBlock(content: string): string { export interface CleanupResult { /** Files that were deleted entirely */ deletedFiles: string[]; + /** Replacement labels for deleted files when cleanup knows the new surface */ + deletedFileReplacementLabels?: Record; /** Files that had marker blocks removed */ modifiedFiles: string[]; /** Directories that were deleted */ @@ -449,6 +603,7 @@ export async function cleanupLegacyArtifacts( ): Promise { const result: CleanupResult = { deletedFiles: [], + deletedFileReplacementLabels: {}, modifiedFiles: [], deletedDirs: [], projectMdNeedsMigration: detection.hasProjectMd, @@ -493,10 +648,17 @@ export async function cleanupLegacyArtifacts( } // Delete managed global slash command files (these are 100% OpenSpec-managed) + const globalPromptMatchesByPath = new Map( + getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const) + ); for (const filePath of detection.globalSlashCommandFiles) { try { await fs.unlink(filePath); result.deletedFiles.push(filePath); + const promptMatch = globalPromptMatchesByPath.get(filePath); + if (promptMatch?.replacementLabel) { + result.deletedFileReplacementLabels![filePath] = promptMatch.replacementLabel; + } } catch (error: any) { result.errors.push(`Failed to delete ${filePath}: ${error.message}`); } @@ -535,9 +697,10 @@ export function formatCleanupSummary(result: CleanupResult): string { lines.push('Cleaned up legacy files:'); for (const file of result.deletedFiles) { - const globalPattern = getManagedGlobalLegacyPattern(file); - const replacement = globalPattern?.pattern.replacementLabel - ? ` (replaced by ${globalPattern.pattern.replacementLabel})` + const replacementLabel = result.deletedFileReplacementLabels?.[file] + ?? getManagedGlobalLegacyPromptMatch(file)?.replacementLabel; + const replacement = replacementLabel + ? ` (replaced by ${replacementLabel})` : ''; lines.push(` ✓ Removed ${file}${replacement}`); } @@ -595,12 +758,11 @@ function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: stri } // Managed global slash command files - for (const file of detection.globalSlashCommandFiles) { - const globalPattern = getManagedGlobalLegacyPattern(file); - const explanation = globalPattern - ? `replaced by .${globalPattern.toolId}/skills/` + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + const explanation = prompt.toolId + ? `replaced by .${prompt.toolId}/skills/` : 'replaced by skills/'; - removals.push({ path: file, explanation }); + removals.push({ path: prompt.path, explanation }); } // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed) @@ -686,6 +848,27 @@ export function formatDetectionSummary(detection: LegacyDetectionResult): string return lines.join('\n'); } +/** + * Generates a summary for managed global prompt files whose cleanup must wait + * until replacement skills are installed. + */ +export function formatDeferredGlobalPromptSummary(detection: LegacyDetectionResult): string { + const deferredPrompts = getLegacyGlobalPromptMatches(detection); + if (deferredPrompts.length === 0) { + return ''; + } + + const lines: string[] = []; + lines.push(chalk.bold('Deferred global prompts cleanup')); + lines.push(chalk.dim('These global prompts will only be removed after matching replacement skills are installed.')); + for (const prompt of deferredPrompts) { + const toolLabel = prompt.toolId ? `${prompt.toolId}: ` : ''; + lines.push(` • ${toolLabel}${prompt.path}`); + } + + return lines.join('\n'); +} + /** * Extract tool IDs from detected legacy artifacts. * Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs. @@ -727,16 +910,102 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s } } - for (const file of detection.globalSlashCommandFiles) { - const globalPattern = getManagedGlobalLegacyPattern(file); - if (globalPattern) { - tools.add(globalPattern.toolId); - } + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + tools.add(prompt.toolId); } return Array.from(tools); } +/** + * Normalizes global Codex prompt matches so callers can rely on workflow-aware + * metadata even when older detection results only carry file paths. + */ +export function getLegacyGlobalPromptMatches(detection: LegacyDetectionResult): LegacyGlobalPromptMatch[] { + if (detection.globalSlashCommandDetails && detection.globalSlashCommandDetails.length > 0) { + return detection.globalSlashCommandDetails; + } + + return detection.globalSlashCommandFiles + .map((filePath) => getManagedGlobalLegacyPromptMatch(filePath)) + .filter((match): match is LegacyGlobalPromptMatch => match !== undefined); +} + +/** + * Collects workflow IDs inferred from detected legacy global prompts for a + * specific tool. + */ +export function getLegacyWorkflowIdsForTool( + detection: LegacyDetectionResult, + toolId: string +): WorkflowId[] { + const workflows = new Set(); + + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + if (prompt.toolId !== toolId) { + continue; + } + + for (const workflowId of prompt.workflowIds) { + workflows.add(workflowId); + } + } + + return Array.from(workflows); +} + +function hasLegacyArtifacts(detection: LegacyDetectionResult): boolean { + return ( + detection.configFiles.length > 0 || + detection.slashCommandDirs.length > 0 || + detection.slashCommandFiles.length > 0 || + detection.globalSlashCommandFiles.length > 0 || + detection.hasOpenspecAgents || + detection.hasRootAgentsWithMarkers || + detection.hasProjectMd + ); +} + +/** + * Returns a detection snapshot with global Codex prompt cleanup removed so + * callers can safely perform the immediate, non-deferred cleanup pass. + */ +export function omitGlobalLegacyPromptFiles(detection: LegacyDetectionResult): LegacyDetectionResult { + const nextDetection: LegacyDetectionResult = { + ...detection, + globalSlashCommandFiles: [], + globalSlashCommandDetails: [], + }; + nextDetection.hasLegacyArtifacts = hasLegacyArtifacts(nextDetection); + return nextDetection; +} + +/** + * Builds a detection snapshot containing only the selected global Codex prompt + * matches for replacement-gated cleanup. + */ +export function pickGlobalLegacyPromptFiles( + detection: LegacyDetectionResult, + filePaths: readonly string[] +): LegacyDetectionResult { + const selectedPaths = new Set(filePaths.map((filePath) => path.resolve(filePath))); + const details = getLegacyGlobalPromptMatches(detection) + .filter((detail) => selectedPaths.has(path.resolve(detail.path))); + + return { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: details.map((detail) => detail.path), + globalSlashCommandDetails: details, + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: details.length > 0, + }; +} + /** * Generates a migration hint message for project.md. * This is shown when project.md exists and needs manual migration to config.yaml. diff --git a/src/core/update.ts b/src/core/update.ts index cee3eb8a0a..032ec59456 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -28,9 +28,14 @@ import { import { detectLegacyArtifacts, cleanupLegacyArtifacts, + formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, + getLegacyGlobalPromptMatches, + getLegacyWorkflowIdsForTool, getToolsFromLegacyArtifacts, + omitGlobalLegacyPromptFiles, + pickGlobalLegacyPromptFiles, type LegacyDetectionResult, } from './legacy-cleanup.js'; import { isInteractive } from '../utils/interactive.js'; @@ -59,6 +64,15 @@ const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; +/** + * Captures legacy migration side effects so update can refresh newly configured + * tools and honor workflow subsets inferred from legacy Codex prompt filenames. + */ +type LegacyUpgradeResult = { + newlyConfiguredTools: string[]; + workflowOverrides: Partial>; +}; + /** * Options for the update command. */ @@ -111,11 +125,12 @@ export class UpdateCommand { ); // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config - const newlyConfiguredTools = await this.handleLegacyCleanup( + const legacyUpgrade = await this.handleLegacyCleanup( resolvedProjectPath, desiredWorkflows, delivery ); + const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides } = legacyUpgrade; // 5. Find configured tools const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath); @@ -154,7 +169,7 @@ export class UpdateCommand { ]); const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); - if (!this.force && toolsToUpdateSet.size === 0) { + if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) { // All tools are up to date this.displayUpToDateMessage(toolStatuses); @@ -168,6 +183,8 @@ export class UpdateCommand { // 8. Display update plan if (this.force) { console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`); + } else if (toolsToUpdateSet.size === 0) { + console.log('No additional refresh needed after legacy migration.'); } else { this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate); } @@ -175,9 +192,6 @@ export class UpdateCommand { // 9. Determine what to generate based on delivery const deliveryIncludesCommands = delivery !== 'skills'; - const skillTemplates = getSkillTemplates(desiredWorkflows); - const commandContents = getCommandContents(desiredWorkflows); - // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; @@ -198,6 +212,9 @@ export class UpdateCommand { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows; + const skillTemplates = getSkillTemplates(toolWorkflows); + const commandContents = getCommandContents(toolWorkflows); // Generate skill files if delivery includes skills if (shouldGenerateSkills) { @@ -211,7 +228,7 @@ export class UpdateCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } - removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, toolWorkflows); } // Delete skill directories if delivery is commands-only @@ -233,7 +250,7 @@ export class UpdateCommand { removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( resolvedProjectPath, toolId, - desiredWorkflows + toolWorkflows ); } } else if (deliveryIncludesCommands && resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') { @@ -536,26 +553,41 @@ export class UpdateCommand { projectPath: string, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], delivery: Delivery - ): Promise { + ): Promise { // Detect legacy artifacts const detection = await detectLegacyArtifacts(projectPath); if (!detection.hasLegacyArtifacts) { - return []; // No legacy artifacts found + return { newlyConfiguredTools: [], workflowOverrides: {} }; // No legacy artifacts found } // Show what was detected - console.log(); - console.log(formatDetectionSummary(detection)); - console.log(); + const immediateSummary = formatDetectionSummary(omitGlobalLegacyPromptFiles(detection)); + const deferredSummary = formatDeferredGlobalPromptSummary(detection); + if (immediateSummary || deferredSummary) { + console.log(); + if (immediateSummary) { + console.log(immediateSummary); + console.log(); + } + if (deferredSummary) { + console.log(deferredSummary); + console.log(); + } + } const canPrompt = isInteractive(); if (this.force) { - // --force flag: proceed with cleanup automatically - await this.performLegacyCleanup(projectPath, detection); - // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); + const newlyConfiguredTools = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + await this.performApprovedLegacyCleanup(projectPath, detection); + return newlyConfiguredTools; } if (!canPrompt) { @@ -563,7 +595,7 @@ export class UpdateCommand { // (Unlike init, update doesn't abort - user may just want to update skills) console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Interactive mode: prompt for confirmation @@ -574,13 +606,58 @@ export class UpdateCommand { }); if (shouldCleanup) { - await this.performLegacyCleanup(projectPath, detection); - // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); + const newlyConfiguredTools = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + await this.performApprovedLegacyCleanup(projectPath, detection); + return newlyConfiguredTools; } else { console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; + } + } + + /** + * Runs approved legacy cleanup in two phases so global Codex prompts are only + * removed after the matching replacement skills exist. + */ + private async performApprovedLegacyCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise { + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + if (immediateDetection.hasLegacyArtifacts) { + await this.performLegacyCleanup(projectPath, immediateDetection); + } + + const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex'])); + const removableMatches = getLegacyGlobalPromptMatches(detection) + .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId))); + + if (removableMatches.length > 0) { + await this.performLegacyCleanup( + projectPath, + pickGlobalLegacyPromptFiles( + detection, + removableMatches.map((prompt) => prompt.path) + ) + ); + } + + const blockedMatches = getLegacyGlobalPromptMatches(detection) + .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path)); + + if (blockedMatches.length > 0) { + console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:')); + for (const prompt of blockedMatches) { + console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`)); + } + console.log(); } } @@ -604,8 +681,8 @@ export class UpdateCommand { } /** - * Upgrade legacy tools to new skills system. - * Returns array of tool IDs that were newly configured. + * Upgrades unconfigured legacy tools into the skills-based setup and carries + * workflow overrides for migrations that should mirror legacy Codex prompts. */ private async upgradeLegacyTools( projectPath: string, @@ -613,12 +690,12 @@ export class UpdateCommand { canPrompt: boolean, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], delivery: Delivery - ): Promise { + ): Promise { // Get tools that had legacy artifacts const legacyTools = getToolsFromLegacyArtifacts(detection); if (legacyTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Get currently configured tools @@ -629,7 +706,7 @@ export class UpdateCommand { const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t)); if (unconfiguredLegacyTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Get valid tools (those with skillsDir) @@ -637,7 +714,7 @@ export class UpdateCommand { const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t)); if (validUnconfiguredTools.length === 0) { - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } // Show what tools were detected from legacy artifacts @@ -678,14 +755,15 @@ export class UpdateCommand { if (selectedTools.length === 0) { console.log(chalk.dim('Skipping tool setup.')); console.log(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; } } + const inferredCodexWorkflows = getLegacyWorkflowIdsForTool(detection, 'codex'); + // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; - const skillTemplates = getSkillTemplates(desiredWorkflows); - const commandContents = getCommandContents(desiredWorkflows); + const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {}; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -697,6 +775,16 @@ export class UpdateCommand { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); + const toolWorkflows = ( + tool.value === 'codex' && inferredCodexWorkflows.length > 0 + ? inferredCodexWorkflows + : desiredWorkflows + ); + if (tool.value === 'codex' && inferredCodexWorkflows.length > 0) { + workflowOverrides[tool.value] = inferredCodexWorkflows; + } + const skillTemplates = getSkillTemplates(toolWorkflows); + const commandContents = getCommandContents(toolWorkflows); // Create skill files when delivery includes skills if (shouldGenerateSkills) { @@ -736,6 +824,6 @@ export class UpdateCommand { console.log(); } - return newlyConfigured; + return { newlyConfiguredTools: newlyConfigured, workflowOverrides }; } } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index ba4f430161..29343370fb 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -4,6 +4,7 @@ import path from 'path'; import os from 'os'; import { InitCommand } from '../../src/core/init.js'; import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js'; +import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({ confirmMock: vi.fn(), @@ -604,7 +605,7 @@ describe('InitCommand - profile and detection features', () => { 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 prompt'); + await fs.writeFile(legacyPrompt, createLegacyCodexPromptContent('apply')); const initCommand = new InitCommand({ tools: 'codex' }); await initCommand.execute(testDir); @@ -615,6 +616,50 @@ describe('InitCommand - profile and detection features', () => { )).toBe(true); }); + 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'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(legacyPrompt, createLegacyCodexPromptContent('onboard')); + + const initCommand = new InitCommand({ tools: 'codex' }); + await initCommand.execute(testDir); + + expect(await fileExists(legacyPrompt)).toBe(true); + expect(await fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-onboard', 'SKILL.md') + )).toBe(false); + }); + + it('should defer global Codex prompt removal messaging until after interactive tool selection', 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, createLegacyCodexPromptContent('apply')); + + searchableMultiSelectMock.mockResolvedValue(['codex']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + const toolSelectionOrder = searchableMultiSelectMock.mock.invocationCallOrder[0]; + const consoleLogMock = console.log as ReturnType; + const logsBeforeSelection = consoleLogMock.mock.calls + .filter((_, index) => consoleLogMock.mock.invocationCallOrder[index] < toolSelectionOrder) + .flat() + .join('\n'); + + expect(logsBeforeSelection).toContain('Deferred global prompts cleanup'); + expect(logsBeforeSelection).toContain('will only be removed after matching replacement skills are installed'); + expect(logsBeforeSelection).toContain(`codex: ${legacyPrompt}`); + expect(await fileExists(legacyPrompt)).toBe(false); + }); + it('should preselect configured tools but not directory-detected tools in extend mode', async () => { // Simulate existing OpenSpec project (extend mode). await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index e92d5e5a44..cce0f92d6f 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -13,6 +13,7 @@ import { isOnlyOpenSpecContent, removeMarkerBlock, cleanupLegacyArtifacts, + formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, formatProjectMdMigrationHint, @@ -24,6 +25,7 @@ import { import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js'; import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js'; +import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; describe('legacy-cleanup', () => { let testDir: string; @@ -375,7 +377,7 @@ ${OPENSPEC_MARKERS.end}`); it('should not include managed global Codex prompt files in repo-local slash command detection', async () => { const promptDir = getCodexPromptDir(); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), createLegacyCodexPromptContent('explore')); await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); @@ -484,20 +486,35 @@ ${OPENSPEC_MARKERS.end}`); expect(result.hasProjectMd).toBe(true); }); - it('should detect managed global Codex opsx prompt files separately from repo-local slash commands', async () => { + it('should detect allowlisted global Codex prompts separately from repo-local slash commands', async () => { const promptDir = getCodexPromptDir(); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'managed'); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), createLegacyCodexPromptContent('explore')); + await fs.writeFile(path.join(promptDir, 'opsx-review.md'), 'user'); await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); const result = await detectLegacyArtifacts(testDir); expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'opsx-review.md')); expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'openspec-proposal.md')); expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); expect(result.slashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); }); + + it('should ignore allowlisted global Codex filenames when the content is user-authored', async () => { + const promptDir = getCodexPromptDir(); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile( + path.join(promptDir, 'opsx-explore.md'), + '# custom explore prompt\n\nThis is not an OpenSpec generated Codex prompt.\n' + ); + + const result = await detectLegacyArtifacts(testDir); + + expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); + }); }); describe('cleanupLegacyArtifacts', () => { @@ -638,13 +655,15 @@ ${OPENSPEC_MARKERS.end}`); expect(result.errors[0]).toContain('NON_EXISTENT.md'); }); - it('should remove managed global Codex opsx prompts and preserve unmanaged prompts', async () => { + it('should remove allowlisted global Codex prompts and preserve unmanaged prompts', async () => { const promptDir = getCodexPromptDir(); const managedPrompt = path.join(promptDir, 'opsx-apply.md'); + const customOpsxPrompt = path.join(promptDir, 'opsx-review.md'); const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); const unmanagedPrompt = path.join(promptDir, 'personal.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, 'managed'); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('apply')); + await fs.writeFile(customOpsxPrompt, 'user'); await fs.writeFile(legacyPrompt, 'managed'); await fs.writeFile(unmanagedPrompt, 'user'); @@ -653,10 +672,28 @@ ${OPENSPEC_MARKERS.end}`); expect(result.deletedFiles).toContain(managedPrompt); expect(result.deletedFiles).not.toContain(legacyPrompt); + expect(result.deletedFiles).not.toContain(customOpsxPrompt); await expect(fs.access(managedPrompt)).rejects.toThrow(); + await expect(fs.access(customOpsxPrompt)).resolves.not.toThrow(); await expect(fs.access(legacyPrompt)).resolves.not.toThrow(); await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); }); + + it('should preserve allowlisted global Codex filenames when their content is customized', async () => { + const promptDir = getCodexPromptDir(); + const customizedManagedName = path.join(promptDir, 'opsx-apply.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile( + customizedManagedName, + `${createLegacyCodexPromptContent('apply')}\n# user customization\n` + ); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).not.toContain(customizedManagedName); + await expect(fs.access(customizedManagedName)).resolves.not.toThrow(); + }); }); describe('formatCleanupSummary', () => { @@ -926,7 +963,7 @@ ${OPENSPEC_MARKERS.end}`); expect(summary).toContain('• CLINE.md'); }); - it('should format managed global slash command files separately from repo-local files', () => { + it('should format deferred global prompts cleanup separately from repo-local files', () => { const globalPrompt = path.join(getCodexPromptDir(), 'opsx-explore.md'); const detection = { configFiles: [], @@ -934,14 +971,23 @@ ${OPENSPEC_MARKERS.end}`); slashCommandDirs: [], slashCommandFiles: [], globalSlashCommandFiles: [globalPrompt], + globalSlashCommandDetails: [{ + path: globalPrompt, + toolId: 'codex', + managedFileName: 'opsx-explore.md', + workflowIds: ['explore'], + replacementLabel: 'Codex skills', + }], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, hasLegacyArtifacts: true, }; - const summary = formatDetectionSummary(detection); - expect(summary).toContain('Files to remove'); + const summary = formatDeferredGlobalPromptSummary(detection); + expect(summary).toContain('Deferred global prompts cleanup'); + expect(summary).toContain('These global prompts will only be removed after matching replacement skills are installed'); + expect(summary).toContain(`codex: ${globalPrompt}`); expect(summary).toContain(globalPrompt); }); @@ -1038,11 +1084,12 @@ ${OPENSPEC_MARKERS.end}`); }); describe('LEGACY_GLOBAL_SLASH_COMMAND_PATHS', () => { - it('should define the managed global Codex opsx cleanup pattern separately from project-local paths', () => { + it('should define the allowlisted managed global Codex prompt names separately from project-local paths', () => { const codexPatterns = LEGACY_GLOBAL_SLASH_COMMAND_PATHS['codex']; - const patterns = Array.isArray(codexPatterns.pattern) ? codexPatterns.pattern : [codexPatterns.pattern]; - expect(patterns).toContain('opsx-*.md'); - expect(patterns).not.toContain('openspec-*.md'); + expect(codexPatterns.managedFileNames).toContain('opsx-explore.md'); + expect(codexPatterns.managedFileNames).toContain('opsx-apply.md'); + expect(codexPatterns.managedFileNames).not.toContain('opsx-review.md'); + expect(codexPatterns.managedFileNames).not.toContain('openspec-proposal.md'); expect(codexPatterns.resolvePromptDir()).toBe(getCodexPromptDir()); }); }); @@ -1152,6 +1199,13 @@ ${OPENSPEC_MARKERS.end}`); slashCommandDirs: [], slashCommandFiles: [], globalSlashCommandFiles: [path.join(getCodexPromptDir(), 'opsx-explore.md')], + globalSlashCommandDetails: [{ + path: path.join(getCodexPromptDir(), 'opsx-explore.md'), + toolId: 'codex', + managedFileName: 'opsx-explore.md', + workflowIds: ['explore'], + replacementLabel: 'Codex skills', + }], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 1cfab3b84e..b0ea3ff36e 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -8,6 +8,7 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import { randomUUID } from 'crypto'; +import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; // Shared mutable mock config state const mockState = { @@ -930,7 +931,7 @@ ${OPENSPEC_MARKERS.end} const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); const unmanagedPrompt = path.join(promptDir, 'personal-notes.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, 'managed'); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); await fs.writeFile(legacyPrompt, 'managed'); await fs.writeFile(unmanagedPrompt, 'user'); @@ -940,7 +941,10 @@ ${OPENSPEC_MARKERS.end} await forceUpdateCommand.execute(testDir); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Upgrading to the new OpenSpec') + expect.stringContaining('Deferred global prompts cleanup') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`codex: ${managedPrompt}`) ); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining(`Removed ${managedPrompt} (replaced by Codex skills)`) @@ -957,6 +961,58 @@ ${OPENSPEC_MARKERS.end} consoleSpy.mockRestore(); }); + it('should infer Codex replacement workflows from legacy prompt filenames during forced update', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-explore.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-archive-change', 'SKILL.md') + )).toBe(false); + }); + + it('should preserve legacy Codex prompts when a configured Codex tool lacks the replacement workflow', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-onboard.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('onboard')); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(testDir, '.codex', 'skills', 'openspec-onboard', 'SKILL.md') + )).toBe(false); + }); + it('should warn but continue with update when legacy files found in non-interactive mode', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1628,7 +1684,7 @@ More user content after markers. const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const managedPrompt = path.join(promptDir, 'opsx-explore.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, 'legacy prompt'); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); await updateCommand.execute(testDir); diff --git a/test/helpers/legacy-codex-prompt.ts b/test/helpers/legacy-codex-prompt.ts new file mode 100644 index 0000000000..6fa65e3998 --- /dev/null +++ b/test/helpers/legacy-codex-prompt.ts @@ -0,0 +1,17 @@ +import { getCommandContents } from '../../src/core/shared/skill-generation.js'; +import type { WorkflowId } from '../../src/core/profiles.js'; + +export function createLegacyCodexPromptContent(workflowId: WorkflowId): string { + const commandContent = getCommandContents([workflowId])[0]; + if (!commandContent) { + throw new Error(`No legacy Codex prompt template found for workflow "${workflowId}"`); + } + + return `--- +description: ${commandContent.description} +argument-hint: command arguments +--- + +${commandContent.body} +`; +} From 9e415933156fdae07ffe29925cc7693de127121b Mon Sep 17 00:00:00 2001 From: showms Date: Tue, 7 Jul 2026 15:53:27 +0800 Subject: [PATCH 4/6] fix(codex): revalidate managed global prompt paths before cleanup --- .../changes/make-codex-skills-only/design.md | 2 +- src/core/legacy-cleanup.ts | 5 ++++ test/core/legacy-cleanup.test.ts | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/openspec/changes/make-codex-skills-only/design.md b/openspec/changes/make-codex-skills-only/design.md index 99efa77247..9cd3038028 100644 --- a/openspec/changes/make-codex-skills-only/design.md +++ b/openspec/changes/make-codex-skills-only/design.md @@ -44,7 +44,7 @@ Cleanup resolves the Codex prompt directory with the same `CODEX_HOME` fallback Repo-local compatibility cleanup continues matching `.codex/prompts/openspec-*.md` inside the project tree. Those files are repository-scoped compatibility artifacts and can stay in the ordinary legacy cleanup model. -Global Codex prompts live in a user-owned directory outside the repository, so a broad `opsx-*.md` match is too risky. Global cleanup therefore uses an explicit allowlist of the historical OpenSpec-managed Codex prompt filenames plus a legacy-content signature check before treating a file as OpenSpec-managed. Workflow IDs are inferred from those verified filenames. User-authored files like `opsx-review.md`, `opsx-my-flow.md`, or a custom `opsx-explore.md` remain unmanaged and untouched unless they still match the exact legacy OpenSpec Codex prompt content. +Global Codex prompts live in a user-owned directory outside the repository, so even a broad match on the historical `opsx-*.md` prompt filenames is too risky. Global cleanup therefore uses an explicit allowlist of the historical OpenSpec-managed Codex prompt filenames plus a legacy-content signature check before treating a file as OpenSpec-managed. Workflow IDs are inferred from those verified filenames. User-authored files like `opsx-review.md`, `opsx-my-flow.md`, or a custom `opsx-explore.md` remain unmanaged and untouched unless they still match the exact historical OpenSpec Codex prompt content. Alternative considered: keep the final `opsx-*` glob split or rely on filename-only allowlisting. Those options keep implementation small, but they can still classify unrelated user prompts as OpenSpec-managed because the global prompt home is shared with user-authored files. The allowlist plus content signature is the safer boundary for a user-owned global directory. diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 7781b28bb1..05a0096a70 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -652,6 +652,11 @@ export async function cleanupLegacyArtifacts( getLegacyGlobalPromptMatches(detection).map((prompt) => [prompt.path, prompt] as const) ); for (const filePath of detection.globalSlashCommandFiles) { + if (!getManagedGlobalLegacyPromptMetadata(filePath)) { + result.errors.push(`Skipped unmanaged global prompt ${filePath}`); + continue; + } + try { await fs.unlink(filePath); result.deletedFiles.push(filePath); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index cce0f92d6f..fcf69eb20c 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -694,6 +694,35 @@ ${OPENSPEC_MARKERS.end}`); expect(result.deletedFiles).not.toContain(customizedManagedName); await expect(fs.access(customizedManagedName)).resolves.not.toThrow(); }); + + it('should skip unmanaged global prompt paths in stale detection objects', async () => { + const promptDir = getCodexPromptDir(); + const managedPrompt = path.join(promptDir, 'opsx-apply.md'); + const unmanagedPrompt = path.join(promptDir, 'personal.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('apply')); + await fs.writeFile(unmanagedPrompt, 'user'); + + const detection = { + configFiles: [], + configFilesToUpdate: [], + slashCommandDirs: [], + slashCommandFiles: [], + globalSlashCommandFiles: [managedPrompt, unmanagedPrompt], + hasOpenspecAgents: false, + hasProjectMd: false, + hasRootAgentsWithMarkers: false, + hasLegacyArtifacts: true, + }; + + const result = await cleanupLegacyArtifacts(testDir, detection); + + expect(result.deletedFiles).toContain(managedPrompt); + expect(result.deletedFiles).not.toContain(unmanagedPrompt); + expect(result.errors).toContain(`Skipped unmanaged global prompt ${unmanagedPrompt}`); + await expect(fs.access(managedPrompt)).rejects.toThrow(); + await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); + }); }); describe('formatCleanupSummary', () => { From 45ea585deab462caedd6a3e43f421dc77756b5cd Mon Sep 17 00:00:00 2001 From: showms Date: Wed, 8 Jul 2026 09:45:46 +0800 Subject: [PATCH 5/6] resolve conflicts with upstream/main --- test/core/init.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 183345e450..53dbf08647 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -198,7 +198,8 @@ describe('InitCommand', () => { it('should create both skills and commands for Trae with adapter', async () => { saveGlobalConfig({ - configuredTools: [], + featureFlags: {}, + profile: 'core', delivery: 'both', }); @@ -468,7 +469,7 @@ describe('InitCommand', () => { ) { throw new Error('EACCES: permission denied'); } - return originalWriteFile.call(fs, filePath, ...args); + return (originalWriteFile as any)(filePath, ...args); } ); From 752a399357fc36aec541440a15ae7940740a4b1d Mon Sep 17 00:00:00 2001 From: showms Date: Sat, 18 Jul 2026 04:15:34 +0800 Subject: [PATCH 6/6] fix(codex): refine legacy prompt migration --- .../changes/make-codex-skills-only/design.md | 10 +- .../specs/ai-tool-paths/spec.md | 5 +- .../specs/cli-init/spec.md | 6 +- .../specs/cli-update/spec.md | 12 ++- .../changes/make-codex-skills-only/tasks.md | 9 +- src/core/legacy-cleanup.ts | 98 +------------------ src/core/update.ts | 55 +++++++++-- test/core/init.test.ts | 7 +- test/core/legacy-cleanup.test.ts | 33 ++++--- test/core/update.test.ts | 34 ++++++- test/helpers/legacy-codex-prompt.ts | 17 ---- 11 files changed, 131 insertions(+), 155 deletions(-) delete mode 100644 test/helpers/legacy-codex-prompt.ts diff --git a/openspec/changes/make-codex-skills-only/design.md b/openspec/changes/make-codex-skills-only/design.md index 9cd3038028..05d8d16d62 100644 --- a/openspec/changes/make-codex-skills-only/design.md +++ b/openspec/changes/make-codex-skills-only/design.md @@ -44,9 +44,9 @@ Cleanup resolves the Codex prompt directory with the same `CODEX_HOME` fallback Repo-local compatibility cleanup continues matching `.codex/prompts/openspec-*.md` inside the project tree. Those files are repository-scoped compatibility artifacts and can stay in the ordinary legacy cleanup model. -Global Codex prompts live in a user-owned directory outside the repository, so even a broad match on the historical `opsx-*.md` prompt filenames is too risky. Global cleanup therefore uses an explicit allowlist of the historical OpenSpec-managed Codex prompt filenames plus a legacy-content signature check before treating a file as OpenSpec-managed. Workflow IDs are inferred from those verified filenames. User-authored files like `opsx-review.md`, `opsx-my-flow.md`, or a custom `opsx-explore.md` remain unmanaged and untouched unless they still match the exact historical OpenSpec Codex prompt content. +Global Codex prompts live in a user-owned directory outside the repository, so even a broad match on the historical `opsx-*.md` prompt filenames is too risky. Global cleanup therefore requires both the exact resolved Codex prompt directory and an explicit allowlist of the historical OpenSpec-owned Codex filenames. Workflow IDs are inferred from those allowlisted filenames. User-authored files such as `opsx-review.md` or `opsx-my-flow.md` remain unmanaged because they are not in the allowlist. -Alternative considered: keep the final `opsx-*` glob split or rely on filename-only allowlisting. Those options keep implementation small, but they can still classify unrelated user prompts as OpenSpec-managed because the global prompt home is shared with user-authored files. The allowlist plus content signature is the safer boundary for a user-owned global directory. +Alternative considered: compare file contents with the current prompt templates. That would miss prompts generated by older OpenSpec releases after templates changed. Exact directory and filename matching provides a stable migration boundary because the allowlisted names were generated and owned by OpenSpec, while avoiding broad matches against custom `opsx-*` files. ### Decision: Global Codex prompt deletion is replacement-gated migration cleanup @@ -59,11 +59,11 @@ Managed global Codex prompt files are still detected through legacy artifact det This avoids deleting the user's only Codex entry point before OpenSpec has established the replacement skill surface. The adapterless command-skip path must not by itself delete files from `$CODEX_HOME/prompts`, and ordinary delivery reconciliation must not touch them. -`openspec init` may still auto-clean other OpenSpec-managed legacy artifacts in non-interactive mode, but global Codex prompt deletion is deferred until replacement skills exist. `openspec update --force` or accepted interactive cleanup follows the same replacement-gated rule. +`openspec init` may still auto-clean other OpenSpec-managed legacy artifacts in non-interactive mode, but global Codex prompt deletion is deferred until replacement skills exist. `openspec update --force` or accepted interactive cleanup follows the same replacement-gated rule. For configured tools, update refreshes the selected Codex skills before performing the deferred global cleanup so a newly installed replacement skill can retire its prompt in the same run. To keep cleanup previews auditable without falsely implying immediate deletion, CLI messaging should separate immediate repo-local cleanup from deferred global prompts cleanup. The deferred section should list the concrete global prompt paths and their tool IDs, while clearly stating that those prompts are removed only after matching replacement skills exist. -Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for the allowlisted historical OpenSpec prompt filenames. `detectLegacyArtifacts()` should keep those managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`, and only populate that list when the file contents still match the legacy OpenSpec Codex prompt signature. +Implementation note: model project-local and global legacy prompt surfaces separately. Keep project-root slash-command paths in `LEGACY_SLASH_COMMAND_PATHS`, including `.codex/prompts/openspec-*.md` compatibility cleanup, and represent Codex's external prompt home in a separate `LEGACY_GLOBAL_SLASH_COMMAND_PATHS` table that resolves `$CODEX_HOME/prompts` (or `~/.codex/prompts` when unset) for the exact allowlisted historical OpenSpec prompt filenames. The allowlist includes `opsx-update.md`, introduced with the `update` workflow in v1.6.0. `detectLegacyArtifacts()` keeps these managed global prompt files separate from repo-local slash command files via `globalSlashCommandFiles`. ### Decision: Legacy Codex workflow replacement prefers the legacy filenames over the current profile @@ -81,6 +81,6 @@ Alternative considered: replace project-local detection with global-only detecti - [Risk] Users with custom workflows that rely on Codex custom prompts will lose refreshed prompt files. -> Mitigation: document the breaking change and point Codex users to `.codex/skills/openspec-*`. - [Risk] `delivery=commands` semantics become per-tool rather than purely global. -> Mitigation: document Codex as a `skills-invocable` command-surface tool and test commands-only Codex init/update. -- [Risk] Cleanup touches a global directory. -> Mitigation: remove only allowlisted and signature-verified OpenSpec-managed legacy prompt files under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, require replacement skills before deletion, and honor `CODEX_HOME` in tests. +- [Risk] Cleanup touches a global directory. -> Mitigation: remove only exact allowlisted OpenSpec-owned filenames directly under the resolved Codex prompt home, keep repo-local `.codex/prompts/openspec-*.md` cleanup scoped to the project tree, require replacement skills before deletion, and honor `CODEX_HOME` in tests. - [Risk] Registry tests or docs may still assume Codex has a command adapter. -> Mitigation: update adapter, registry, supported-tools, troubleshooting, and migration docs in the same change. - [Risk] This overlaps with `add-tool-command-surface-capabilities`. -> Mitigation: represent Codex with the same `skills-invocable` concept and rebase whichever change lands second. diff --git a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md index 3a64a4c78e..37e9e31781 100644 --- a/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md @@ -34,10 +34,11 @@ The system SHALL resolve the legacy Codex prompt cleanup directory using Codex h ### Requirement: Codex-managed legacy prompt cleanup patterns reflect the final managed surfaces The system SHALL identify managed Codex prompt cleanup targets using the final split patterns for global and repo-local artifacts. -#### Scenario: Global legacy Codex prompts use an explicit allowlist and content signature +#### Scenario: Global legacy Codex prompts use an exact directory and filename allowlist - **WHEN** detecting managed legacy Codex prompt files in the resolved Codex prompt directory -- **THEN** the system SHALL match only the known OpenSpec-managed legacy Codex prompt filenames whose contents still match the legacy OpenSpec Codex prompt signature +- **THEN** the system SHALL match only exact historical OpenSpec-owned filenames directly under that resolved directory - **AND** it SHALL infer the represented workflow IDs from those filenames +- **AND** the allowlist SHALL include `opsx-update.md` mapped to the `update` workflow #### Scenario: Repo-local openspec compatibility prompt names - **WHEN** detecting legacy Codex prompt files in the project tree diff --git a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md index 9a507ff2c6..00d51f71d5 100644 --- a/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md @@ -35,21 +35,21 @@ #### Scenario: Cleanup removes allowlisted global Codex prompt files after replacement exists - **WHEN** initialization cleanup runs -- **AND** the Codex prompt directory contains allowlisted and signature-verified managed global Codex prompt files +- **AND** the Codex prompt directory contains exact allowlisted managed global Codex prompt files - **AND** replacement Codex skills exist for the workflows represented by those prompt filenames - **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave other Codex prompt files unchanged #### Scenario: Non-interactive initialization preserves unreplaced global Codex prompts - **WHEN** `openspec init` runs without interaction and without `--force` -- **AND** the resolved global Codex prompt directory contains allowlisted and signature-verified managed Codex prompt files +- **AND** the resolved global Codex prompt directory contains exact allowlisted managed Codex prompt files - **AND** replacement Codex skills do not yet exist for at least one detected prompt workflow - **THEN** the command SHALL preserve the unreplaced Codex prompt files - **AND** it SHALL continue to leave unmanaged Codex prompt files unchanged #### Scenario: Non-interactive initialization removes replaced global Codex prompts - **WHEN** `openspec init` runs without interaction and without `--force` -- **AND** the resolved global Codex prompt directory contains allowlisted and signature-verified managed Codex prompt files +- **AND** the resolved global Codex prompt directory contains exact allowlisted managed Codex prompt files - **AND** replacement Codex skills exist for the workflows represented by those prompt filenames - **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave unmanaged Codex prompt files unchanged diff --git a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md index b7f6d68316..4eae91011f 100644 --- a/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md +++ b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md @@ -72,7 +72,7 @@ The update command SHALL refresh existing slash command files for configured ada - **WHEN** a user runs `openspec update` - **THEN** the command SHALL NOT refresh Codex prompt files - **AND** it SHALL treat those files as legacy cleanup candidates -- **AND** it SHALL preserve unmanaged content by deleting only allowlisted and signature-verified OpenSpec-managed global Codex prompt files after replacement skills exist +- **AND** it SHALL preserve unmanaged files by deleting only exact allowlisted OpenSpec-owned filenames under the resolved global Codex prompt directory after replacement skills exist #### Scenario: Updating slash commands for GitHub Copilot - **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` @@ -112,7 +112,7 @@ The update command SHALL refresh existing slash command files for configured ada `openspec update` SHALL refresh Codex through generated OpenSpec skills without generating or refreshing Codex custom prompt files. #### Scenario: Legacy Codex prompt migration infers workflows from the legacy filenames -- **WHEN** `openspec update` upgrades an unconfigured Codex tool from detected allowlisted and signature-verified global legacy Codex prompt files +- **WHEN** `openspec update` upgrades an unconfigured Codex tool from detected exact allowlisted global legacy Codex prompt files - **THEN** it SHALL infer the replacement workflow IDs from the detected prompt filenames where possible - **AND** it SHALL use that inferred workflow subset for the replacement Codex skills instead of expanding to the current profile's full workflow set @@ -141,11 +141,17 @@ The update command SHALL refresh existing slash command files for configured ada #### Scenario: Forced update cleanup removes Codex prompts - **WHEN** a user runs `openspec update --force` -- **AND** the resolved Codex prompt directory contains allowlisted and signature-verified managed global Codex prompt files +- **AND** the resolved Codex prompt directory contains exact allowlisted managed global Codex prompt files - **AND** replacement Codex skills exist for the workflows represented by those prompt filenames - **THEN** the command SHALL remove those managed Codex prompt files - **AND** it SHALL leave non-OpenSpec Codex prompt files unchanged +#### Scenario: Configured Codex cleanup completes after skills refresh +- **WHEN** an approved or forced update detects an allowlisted global Codex prompt whose configured project is missing the replacement skill +- **AND** the configured-tool update installs that replacement skill, including under `delivery=commands` +- **THEN** the command SHALL perform deferred global prompt cleanup after the configured-tool update loop +- **AND** it SHALL remove the replaced prompt in the same update run + #### Scenario: Interactive update cleanup includes Codex prompts - **WHEN** a user runs `openspec update` interactively - **THEN** the preview SHALL list immediate repo-local removals separately from deferred global prompts cleanup diff --git a/openspec/changes/make-codex-skills-only/tasks.md b/openspec/changes/make-codex-skills-only/tasks.md index 8cc3e3f9e2..534ddf7072 100644 --- a/openspec/changes/make-codex-skills-only/tasks.md +++ b/openspec/changes/make-codex-skills-only/tasks.md @@ -19,7 +19,7 @@ - [x] 3.1 Add final Codex prompt cleanup support: allowlisted globally managed Codex legacy prompt filenames plus repo-local `.codex/prompts/openspec-*.md` compatibility cleanup. - [x] 3.2 Resolve the global Codex prompt directory from `CODEX_HOME` when set and the default user `.codex/prompts` directory when unset. -- [x] 3.3 Detect allowlisted and signature-verified global Codex prompt files during legacy cleanup, infer workflow IDs from those filenames, and leave unmanaged prompt files untouched. +- [x] 3.3 Detect exact allowlisted global Codex prompt files under the resolved prompt directory, infer workflow IDs from those filenames, and leave non-allowlisted prompt files untouched. - [x] 3.4 Remove managed global Codex prompt files only after replacement Codex skills exist for the represented workflows. - [x] 3.5 Preserve existing project-local `.codex/prompts/openspec-*.md` cleanup compatibility. - [x] 3.6 Update cleanup summaries to identify removed Codex prompt files as replaced by Codex skills. @@ -47,3 +47,10 @@ - [x] 5.9 Add command-surface tests proving Codex resolves as `skills-invocable` and does not require a command adapter. - [x] 5.10 Run targeted test suites for command generation, init, update, legacy cleanup, and docs-related snapshots if present. - [x] 5.11 Run `openspec validate make-codex-skills-only --strict`. + +## 6. Review Follow-up + +- [x] 6.1 Add `opsx-update.md` to the managed global Codex prompt allowlist and map it to the `update` workflow. +- [x] 6.2 Simplify managed global Codex prompt detection to exact directory and filename allowlisting so prompts from older template revisions still migrate. +- [x] 6.3 Defer approved global Codex prompt cleanup until after configured tools refresh, allowing replacement skills and prompt cleanup to complete in one update run. +- [x] 6.4 Update focused tests and change artifacts for the final allowlist and cleanup ordering behavior. diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 05a0096a70..5dcb4d22cd 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -5,12 +5,11 @@ import path from 'path'; import os from 'os'; -import { promises as fs, readFileSync } from 'fs'; +import { promises as fs } from 'fs'; import chalk from 'chalk'; import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js'; import { OPENSPEC_MARKERS } from './config.js'; import type { WorkflowId } from './profiles.js'; -import { getCommandContents } from './shared/skill-generation.js'; /** * Legacy config file names from the old ToolRegistry. @@ -72,6 +71,7 @@ const LEGACY_GLOBAL_CODEX_WORKFLOWS: Record = { 'opsx-new.md': ['new'], 'opsx-continue.md': ['continue'], 'opsx-apply.md': ['apply'], + 'opsx-update.md': ['update'], 'opsx-ff.md': ['ff'], 'opsx-sync.md': ['sync'], 'opsx-archive.md': ['archive'], @@ -80,33 +80,6 @@ const LEGACY_GLOBAL_CODEX_WORKFLOWS: Record = { 'opsx-onboard.md': ['onboard'], }; -/** - * Pre-computed expected content for each managed global Codex prompt file. - * - * Only single-workflow entries are included — multi-workflow prompts can't be - * deterministically reconstructed from a single command template. Used by - * {@link hasManagedGlobalLegacyPromptSignature} to verify whether an on-disk - * prompt was originally written by OpenSpec. - */ -const LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS: Readonly> = Object.freeze( - Object.fromEntries( - Object.entries(LEGACY_GLOBAL_CODEX_WORKFLOWS) - .map(([fileName, workflowIds]) => { - if (workflowIds.length !== 1) { - return undefined; - } - - const commandContent = getCommandContents(workflowIds)[0]; - if (!commandContent) { - return undefined; - } - - return [fileName, formatLegacyCodexPromptContent(commandContent)] as const; - }) - .filter((entry): entry is readonly [string, string] => entry !== undefined) - ) -); - /** * Global legacy prompt locations that live outside the project tree and require * allowlisted matching instead of broad glob-based cleanup. @@ -172,45 +145,6 @@ function normalizePathForMatch(filePath: string): string { return filePath.replace(/\\/g, '/'); } -// Normalize line endings and trim whitespace so that content comparisons -// are not affected by OS-level CRLF vs LF differences. -function normalizePromptContentForMatch(content: string): string { - return content.replace(/\r\n/g, '\n').trim(); -} - -// Reconstruct the Codex prompt file format from a command's description and -// body, matching the front-matter schema Codex uses (description + argument-hint). -function formatLegacyCodexPromptContent(commandContent: { description: string; body: string }): string { - return `--- -description: ${commandContent.description} -argument-hint: command arguments ---- - -${commandContent.body} -`; -} - -// Check whether an on-disk prompt file was originally written by OpenSpec (i.e. -// is "managed") by comparing its normalized content against the expected content -// derived from LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS. -function hasManagedGlobalLegacyPromptSignature(managedFileName: string, content: string): boolean { - const expectedContent = LEGACY_GLOBAL_CODEX_PROMPT_CONTENTS[managedFileName]; - if (!expectedContent) { - return false; - } - - return normalizePromptContentForMatch(content) === normalizePromptContentForMatch(expectedContent); -} - -// Read a global prompt file, returning undefined if it doesn't exist or can't be read. -function readGlobalPromptFileSync(filePath: string): string | undefined { - try { - return readFileSync(filePath, 'utf-8'); - } catch { - return undefined; - } -} - /** * Classifies a global Codex prompt path as OpenSpec-managed only when it matches * the explicit legacy allowlist for the resolved prompt home. @@ -243,27 +177,6 @@ function getManagedGlobalLegacyPromptMetadata(filePath: string): LegacyGlobalPro return undefined; } -function getManagedGlobalLegacyPromptMatch( - filePath: string, - fileContent?: string -): LegacyGlobalPromptMatch | undefined { - const match = getManagedGlobalLegacyPromptMetadata(filePath); - if (!match) { - return undefined; - } - - const content = fileContent ?? readGlobalPromptFileSync(match.path); - if (content === undefined) { - return undefined; - } - - if (!hasManagedGlobalLegacyPromptSignature(match.managedFileName, content)) { - return undefined; - } - - return match; -} - /** * Result of legacy artifact detection */ @@ -431,8 +344,7 @@ async function detectLegacyGlobalPromptFiles(): Promise getManagedGlobalLegacyPromptMatch(filePath)) + .map((filePath) => getManagedGlobalLegacyPromptMetadata(filePath)) .filter((match): match is LegacyGlobalPromptMatch => match !== undefined); } diff --git a/src/core/update.ts b/src/core/update.ts index 83cb52a844..dc656700ca 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -71,6 +71,7 @@ const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; type LegacyUpgradeResult = { newlyConfiguredTools: string[]; workflowOverrides: Partial>; + deferredGlobalCleanup?: LegacyDetectionResult; }; /** @@ -130,12 +131,19 @@ export class UpdateCommand { desiredWorkflows, delivery ); - const { newlyConfiguredTools, workflowOverrides: legacyWorkflowOverrides } = legacyUpgrade; + const { + newlyConfiguredTools, + workflowOverrides: legacyWorkflowOverrides, + deferredGlobalCleanup, + } = legacyUpgrade; // 5. Find configured tools const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath); if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } console.log(chalk.yellow('No configured tools found.')); console.log(chalk.dim('Run "openspec init" to set up tools.')); return; @@ -170,6 +178,9 @@ export class UpdateCommand { const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); if (!this.force && toolsToUpdateSet.size === 0 && newlyConfiguredTools.length === 0) { + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } // All tools are up to date this.displayUpToDateMessage(toolStatuses); @@ -273,6 +284,10 @@ export class UpdateCommand { } } + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } + // 11. Summary console.log(); if (updatedTools.length > 0) { @@ -579,15 +594,21 @@ export class UpdateCommand { const canPrompt = isInteractive(); if (this.force) { - const newlyConfiguredTools = await this.upgradeLegacyTools( + const legacyUpgrade = await this.upgradeLegacyTools( projectPath, detection, canPrompt, desiredWorkflows, delivery ); - await this.performApprovedLegacyCleanup(projectPath, detection); - return newlyConfiguredTools; + await this.performImmediateLegacyCleanup(projectPath, detection); + return { + ...legacyUpgrade, + deferredGlobalCleanup: pickGlobalLegacyPromptFiles( + detection, + detection.globalSlashCommandFiles + ), + }; } if (!canPrompt) { @@ -606,15 +627,21 @@ export class UpdateCommand { }); if (shouldCleanup) { - const newlyConfiguredTools = await this.upgradeLegacyTools( + const legacyUpgrade = await this.upgradeLegacyTools( projectPath, detection, canPrompt, desiredWorkflows, delivery ); - await this.performApprovedLegacyCleanup(projectPath, detection); - return newlyConfiguredTools; + await this.performImmediateLegacyCleanup(projectPath, detection); + return { + ...legacyUpgrade, + deferredGlobalCleanup: pickGlobalLegacyPromptFiles( + detection, + detection.globalSlashCommandFiles + ), + }; } else { console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...')); console.log(); @@ -623,10 +650,9 @@ export class UpdateCommand { } /** - * Runs approved legacy cleanup in two phases so global Codex prompts are only - * removed after the matching replacement skills exist. + * Cleans approved repo-local legacy artifacts before configured tools refresh. */ - private async performApprovedLegacyCleanup( + private async performImmediateLegacyCleanup( projectPath: string, detection: LegacyDetectionResult ): Promise { @@ -634,7 +660,16 @@ export class UpdateCommand { if (immediateDetection.hasLegacyArtifacts) { await this.performLegacyCleanup(projectPath, immediateDetection); } + } + /** + * Cleans approved global Codex prompts after configured tools refresh so newly + * installed replacement skills can retire their prompts in the same run. + */ + private async performDeferredGlobalPromptCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise { const availableCodexWorkflows = new Set(scanInstalledWorkflows(projectPath, ['codex'])); const removableMatches = getLegacyGlobalPromptMatches(detection) .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId))); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 53dbf08647..d43ab79fab 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -4,7 +4,6 @@ import path from 'path'; import os from 'os'; import { InitCommand } from '../../src/core/init.js'; import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js'; -import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({ confirmMock: vi.fn(), @@ -631,7 +630,7 @@ describe('InitCommand - profile and detection features', () => { 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, createLegacyCodexPromptContent('apply')); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); const initCommand = new InitCommand({ tools: 'codex' }); await initCommand.execute(testDir); @@ -646,7 +645,7 @@ describe('InitCommand - profile and detection features', () => { const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const legacyPrompt = path.join(promptDir, 'opsx-onboard.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(legacyPrompt, createLegacyCodexPromptContent('onboard')); + await fs.writeFile(legacyPrompt, 'legacy onboard prompt'); const initCommand = new InitCommand({ tools: 'codex' }); await initCommand.execute(testDir); @@ -664,7 +663,7 @@ describe('InitCommand - profile and detection features', () => { 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, createLegacyCodexPromptContent('apply')); + await fs.writeFile(legacyPrompt, 'legacy apply prompt'); searchableMultiSelectMock.mockResolvedValue(['codex']); diff --git a/test/core/legacy-cleanup.test.ts b/test/core/legacy-cleanup.test.ts index fcf69eb20c..b05ea4215b 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -25,7 +25,6 @@ import { import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import { CommandAdapterRegistry } from '../../src/core/command-generation/registry.js'; import { resolveCommandSurfaceCapability } from '../../src/core/command-surface.js'; -import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; describe('legacy-cleanup', () => { let testDir: string; @@ -377,7 +376,7 @@ ${OPENSPEC_MARKERS.end}`); it('should not include managed global Codex prompt files in repo-local slash command detection', async () => { const promptDir = getCodexPromptDir(); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), createLegacyCodexPromptContent('explore')); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'legacy explore prompt'); await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); @@ -489,7 +488,8 @@ ${OPENSPEC_MARKERS.end}`); it('should detect allowlisted global Codex prompts separately from repo-local slash commands', async () => { const promptDir = getCodexPromptDir(); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), createLegacyCodexPromptContent('explore')); + await fs.writeFile(path.join(promptDir, 'opsx-explore.md'), 'prompt generated by an older OpenSpec version'); + await fs.writeFile(path.join(promptDir, 'opsx-update.md'), 'legacy update prompt'); await fs.writeFile(path.join(promptDir, 'opsx-review.md'), 'user'); await fs.writeFile(path.join(promptDir, 'openspec-proposal.md'), 'managed'); await fs.writeFile(path.join(promptDir, 'my-custom-prompt.md'), 'user'); @@ -497,13 +497,14 @@ ${OPENSPEC_MARKERS.end}`); const result = await detectLegacyArtifacts(testDir); expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-update.md')); expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'opsx-review.md')); expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'openspec-proposal.md')); expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'my-custom-prompt.md')); expect(result.slashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); }); - it('should ignore allowlisted global Codex filenames when the content is user-authored', async () => { + it('should detect exact allowlisted global Codex filenames regardless of template revision', async () => { const promptDir = getCodexPromptDir(); await fs.mkdir(promptDir, { recursive: true }); await fs.writeFile( @@ -513,7 +514,7 @@ ${OPENSPEC_MARKERS.end}`); const result = await detectLegacyArtifacts(testDir); - expect(result.globalSlashCommandFiles).not.toContain(path.join(promptDir, 'opsx-explore.md')); + expect(result.globalSlashCommandFiles).toContain(path.join(promptDir, 'opsx-explore.md')); }); }); @@ -662,7 +663,7 @@ ${OPENSPEC_MARKERS.end}`); const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); const unmanagedPrompt = path.join(promptDir, 'personal.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('apply')); + await fs.writeFile(managedPrompt, 'legacy apply prompt'); await fs.writeFile(customOpsxPrompt, 'user'); await fs.writeFile(legacyPrompt, 'managed'); await fs.writeFile(unmanagedPrompt, 'user'); @@ -679,36 +680,39 @@ ${OPENSPEC_MARKERS.end}`); await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); }); - it('should preserve allowlisted global Codex filenames when their content is customized', async () => { + it('should remove exact allowlisted global Codex filenames when their content differs', async () => { const promptDir = getCodexPromptDir(); const customizedManagedName = path.join(promptDir, 'opsx-apply.md'); await fs.mkdir(promptDir, { recursive: true }); await fs.writeFile( customizedManagedName, - `${createLegacyCodexPromptContent('apply')}\n# user customization\n` + '# customized legacy apply prompt\n' ); const detection = await detectLegacyArtifacts(testDir); const result = await cleanupLegacyArtifacts(testDir, detection); - expect(result.deletedFiles).not.toContain(customizedManagedName); - await expect(fs.access(customizedManagedName)).resolves.not.toThrow(); + expect(result.deletedFiles).toContain(customizedManagedName); + await expect(fs.access(customizedManagedName)).rejects.toThrow(); }); it('should skip unmanaged global prompt paths in stale detection objects', async () => { const promptDir = getCodexPromptDir(); const managedPrompt = path.join(promptDir, 'opsx-apply.md'); const unmanagedPrompt = path.join(promptDir, 'personal.md'); + const outsidePrompt = path.join(testDir, 'other-codex-home', 'prompts', 'opsx-apply.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('apply')); + await fs.mkdir(path.dirname(outsidePrompt), { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy apply prompt'); await fs.writeFile(unmanagedPrompt, 'user'); + await fs.writeFile(outsidePrompt, 'outside configured Codex prompt directory'); const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], - globalSlashCommandFiles: [managedPrompt, unmanagedPrompt], + globalSlashCommandFiles: [managedPrompt, unmanagedPrompt, outsidePrompt], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -719,9 +723,12 @@ ${OPENSPEC_MARKERS.end}`); expect(result.deletedFiles).toContain(managedPrompt); expect(result.deletedFiles).not.toContain(unmanagedPrompt); + expect(result.deletedFiles).not.toContain(outsidePrompt); expect(result.errors).toContain(`Skipped unmanaged global prompt ${unmanagedPrompt}`); + expect(result.errors).toContain(`Skipped unmanaged global prompt ${outsidePrompt}`); await expect(fs.access(managedPrompt)).rejects.toThrow(); await expect(fs.access(unmanagedPrompt)).resolves.not.toThrow(); + await expect(fs.access(outsidePrompt)).resolves.not.toThrow(); }); }); @@ -1117,6 +1124,8 @@ ${OPENSPEC_MARKERS.end}`); const codexPatterns = LEGACY_GLOBAL_SLASH_COMMAND_PATHS['codex']; expect(codexPatterns.managedFileNames).toContain('opsx-explore.md'); expect(codexPatterns.managedFileNames).toContain('opsx-apply.md'); + expect(codexPatterns.managedFileNames).toContain('opsx-update.md'); + expect(codexPatterns.workflowIdsByFileName?.['opsx-update.md']).toEqual(['update']); expect(codexPatterns.managedFileNames).not.toContain('opsx-review.md'); expect(codexPatterns.managedFileNames).not.toContain('openspec-proposal.md'); expect(codexPatterns.resolvePromptDir()).toBe(getCodexPromptDir()); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index d4e0e38480..d5b4029077 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -8,7 +8,6 @@ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import { randomUUID } from 'crypto'; -import { createLegacyCodexPromptContent } from '../helpers/legacy-codex-prompt.js'; // Shared mutable mock config state const mockState = { @@ -932,7 +931,7 @@ ${OPENSPEC_MARKERS.end} const legacyPrompt = path.join(promptDir, 'openspec-proposal.md'); const unmanagedPrompt = path.join(promptDir, 'personal-notes.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); await fs.writeFile(legacyPrompt, 'managed'); await fs.writeFile(unmanagedPrompt, 'user'); @@ -972,7 +971,7 @@ ${OPENSPEC_MARKERS.end} const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const managedPrompt = path.join(promptDir, 'opsx-explore.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); const forceUpdateCommand = new UpdateCommand({ force: true }); await forceUpdateCommand.execute(testDir); @@ -1003,7 +1002,7 @@ ${OPENSPEC_MARKERS.end} const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const managedPrompt = path.join(promptDir, 'opsx-onboard.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('onboard')); + await fs.writeFile(managedPrompt, 'legacy onboard prompt'); const forceUpdateCommand = new UpdateCommand({ force: true }); await forceUpdateCommand.execute(testDir); @@ -1014,6 +1013,31 @@ ${OPENSPEC_MARKERS.end} )).toBe(false); }); + it('should install a missing Codex update skill before removing its prompt in the same forced run', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.codex', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); + const managedPrompt = path.join(promptDir, 'opsx-update.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'prompt generated by OpenSpec v1.6.0'); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-update-change', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists(managedPrompt)).toBe(false); + }); + it('should warn but continue with update when legacy files found in non-interactive mode', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1685,7 +1709,7 @@ More user content after markers. const promptDir = path.join(process.env.CODEX_HOME!, 'prompts'); const managedPrompt = path.join(promptDir, 'opsx-explore.md'); await fs.mkdir(promptDir, { recursive: true }); - await fs.writeFile(managedPrompt, createLegacyCodexPromptContent('explore')); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); await updateCommand.execute(testDir); diff --git a/test/helpers/legacy-codex-prompt.ts b/test/helpers/legacy-codex-prompt.ts deleted file mode 100644 index 6fa65e3998..0000000000 --- a/test/helpers/legacy-codex-prompt.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getCommandContents } from '../../src/core/shared/skill-generation.js'; -import type { WorkflowId } from '../../src/core/profiles.js'; - -export function createLegacyCodexPromptContent(workflowId: WorkflowId): string { - const commandContent = getCommandContents([workflowId])[0]; - if (!commandContent) { - throw new Error(`No legacy Codex prompt template found for workflow "${workflowId}"`); - } - - return `--- -description: ${commandContent.description} -argument-hint: command arguments ---- - -${commandContent.body} -`; -}