diff --git a/.gitignore b/.gitignore index 0455fee22c..1fb5c4a26a 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ opencode.json # Trae .trae/ + +# Cursor +.cursor/ diff --git a/docs/commands.md b/docs/commands.md index 1e4e035183..14c95ab55a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -671,6 +671,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | +| Codex | Skill-based invocations from `.codex/skills/openspec-*` (no generated `opsx-*` prompt files) | | Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi Code | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) | | Trae | `/opsx-propose`, `/opsx-apply` | diff --git a/docs/how-commands-work.md b/docs/how-commands-work.md index cd277c4bd4..175c5a701a 100644 --- a/docs/how-commands-work.md +++ b/docs/how-commands-work.md @@ -80,6 +80,7 @@ The intent is identical everywhere. The punctuation differs. Use the form that m | Windsurf | `/opsx-propose`, `/opsx-apply` | | GitHub Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | | CodeArts | skill-style, e.g. `/openspec-propose` | +| Codex | skill-style via `.codex/skills/openspec-*` | | Oh My Pi | `/opsx-propose`, `/opsx-apply` | | Kimi CLI | skill-style, e.g. `/skill:openspec-propose` | | Trae | `/opsx-propose`, `/opsx-apply` | @@ -93,7 +94,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..98f8579cbd 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: 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. @@ -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. 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. + --- ## 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 5781f67bec..efc052688e 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` @@ -30,7 +32,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-.md` | | CodeArts (`codeartsagent`) | `.codeartsdoer/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/.md` | -| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$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` | @@ -57,8 +59,6 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/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. \*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d84a52ceb0..4d901e0717 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 (CodeArts, Kimi CLI, 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 (CodeArts, Kimi CLI, 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`. 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 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..05d8d16d62 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/design.md @@ -0,0 +1,86 @@ +## 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 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:** + +- 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 by trust level + +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. + +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 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: 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 + +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. 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 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 + +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 + +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 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/proposal.md b/openspec/changes/make-codex-skills-only/proposal.md new file mode 100644 index 0000000000..0db4549f6b --- /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 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 + +### 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..37e9e31781 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/ai-tool-paths/spec.md @@ -0,0 +1,49 @@ +## 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: 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 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 +- **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..00d51f71d5 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-init/spec.md @@ -0,0 +1,65 @@ +## 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 global Codex prompt files only after replacement Codex skills exist, without deleting user-authored Codex prompts. + +#### Scenario: Cleanup removes allowlisted global Codex prompt files after replacement exists +- **WHEN** initialization cleanup runs +- **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 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 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 + +#### 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 new file mode 100644 index 0000000000..4eae91011f --- /dev/null +++ b/openspec/changes/make-codex-skills-only/specs/cli-update/spec.md @@ -0,0 +1,167 @@ +## 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 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` +- **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: Legacy Codex prompt migration infers workflows from the legacy filenames +- **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 + +#### 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 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 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 +- **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 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` +- **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..534ddf7072 --- /dev/null +++ b/openspec/changes/make-codex-skills-only/tasks.md @@ -0,0 +1,56 @@ +## 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 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 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. +- [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 + +- [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 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 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. +- [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/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 89d0fe5201..ad0c8fb867 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 d02ad6b3e3..7fe470c22a 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'; @@ -50,7 +49,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 2d17aefdf2..f8fb71338b 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,14 @@ 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, migrateLegacySkillDirs } from './migration.js'; +import { migrateIfNeeded, migrateLegacySkillDirs, scanInstalledWorkflows as scanInstalledWorkflowsShared } 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'); @@ -88,6 +99,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 // ----------------------------------------------------------------------------- @@ -141,7 +160,7 @@ export class InitCommand { } // Check for legacy artifacts and handle cleanup - await this.handleLegacyCleanup(projectPath, extendMode); + const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode); // Migrate OpenSpec-managed skills left in renamed tool directories // (e.g. .kimi -> .kimi-code) before detection so they stay recognized. @@ -181,6 +200,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); @@ -227,18 +252,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(); @@ -246,8 +288,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 @@ -263,7 +305,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 { @@ -533,6 +639,7 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }> { @@ -540,6 +647,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; @@ -550,17 +658,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'); @@ -579,7 +689,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); } @@ -594,11 +704,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); } @@ -620,6 +734,7 @@ export class InitCommand { refreshedTools, failedTools, commandsSkipped, + skillsInvocableCommandSkips, removedCommandCount, removedSkillCount, }; @@ -661,6 +776,7 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + skillsInvocableCommandSkips: string[]; removedCommandCount: number; removedSkillCount: number; }, @@ -686,8 +802,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) { @@ -706,6 +826,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 74b04813cb..1318a6b0f2 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -4,10 +4,12 @@ */ 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'; import { OPENSPEC_MARKERS } from './config.js'; +import type { WorkflowId } from './profiles.js'; /** * Legacy config file names from the old ToolRegistry. @@ -59,6 +61,38 @@ 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-update.md': ['update'], + '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'], +}; + +/** + * 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': { + managedFileNames: Object.keys(LEGACY_GLOBAL_CODEX_WORKFLOWS), + workflowIdsByFileName: LEGACY_GLOBAL_CODEX_WORKFLOWS, + resolvePromptDir: getCodexPromptDir, + replacementLabel: 'Codex skills', + }, +}; + /** * Pattern types for legacy slash commands */ @@ -68,6 +102,81 @@ 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 { + 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, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${regexPattern}$`); +} + +// Normalize Windows backslashes to forward slashes for cross-platform path matching. +function normalizePathForMatch(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +/** + * 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; + } + + const resolvedPath = path.resolve(filePath); + + for (const [toolId, pattern] of Object.entries(LEGACY_GLOBAL_SLASH_COMMAND_PATHS)) { + const promptDir = path.resolve(pattern.resolvePromptDir()); + if (path.dirname(resolvedPath) !== promptDir) { + continue; + } + + const managedFileName = path.basename(resolvedPath); + if (pattern.managedFileNames.includes(managedFileName)) { + return { + path: resolvedPath, + toolId, + managedFileName, + workflowIds: pattern.workflowIdsByFileName?.[managedFileName] ?? [], + replacementLabel: pattern.replacementLabel, + }; + } + } + + return undefined; +} + /** * Result of legacy artifact detection */ @@ -80,6 +189,10 @@ 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[]; + /** 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) */ @@ -104,6 +217,8 @@ export async function detectLegacyArtifacts( configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], + globalSlashCommandDetails: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -118,7 +233,11 @@ 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.globalSlashCommandDetails = await detectLegacyGlobalPromptFiles(); + result.globalSlashCommandFiles = result.globalSlashCommandDetails.map((detail) => detail.path); // Detect legacy structure files const structureResult = await detectLegacyStructureFiles(projectPath); @@ -131,6 +250,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 +306,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 +324,40 @@ export async function detectLegacySlashCommands( return { directories, files }; } +/** + * Detects legacy global slash command files. + * + * @returns Object with individual files found + */ +/** + * 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(); + + try { + const entries = await fs.readdir(promptDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && pattern.managedFileNames.includes(entry.name)) { + const fullPath = path.join(promptDir, entry.name); + const match = getManagedGlobalLegacyPromptMetadata(fullPath); + if (match) { + foundFiles.push(match); + } + } + } + } catch { + // Directory does not exist or cannot be read. + } + } + + return foundFiles; +} + /** * Finds legacy slash command files matching a glob pattern. * @@ -235,14 +388,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)) { @@ -343,6 +489,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 */ @@ -367,6 +515,7 @@ export async function cleanupLegacyArtifacts( ): Promise { const result: CleanupResult = { deletedFiles: [], + deletedFileReplacementLabels: {}, modifiedFiles: [], deletedDirs: [], projectMdNeedsMigration: detection.hasProjectMd, @@ -410,6 +559,28 @@ 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) { + if (!getManagedGlobalLegacyPromptMetadata(filePath)) { + result.errors.push(`Skipped unmanaged global prompt ${filePath}`); + continue; + } + + 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}`); + } + } + // 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 +614,12 @@ export function formatCleanupSummary(result: CleanupResult): string { lines.push('Cleaned up legacy files:'); for (const file of result.deletedFiles) { - lines.push(` ✓ Removed ${file}`); + const replacementLabel = result.deletedFileReplacementLabels?.[file] + ?? getManagedGlobalLegacyPromptMetadata(file)?.replacementLabel; + const replacement = replacementLabel + ? ` (replaced by ${replacementLabel})` + : ''; + lines.push(` ✓ Removed ${file}${replacement}`); } for (const dir of result.deletedDirs) { @@ -498,6 +674,14 @@ function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: stri removals.push({ path: file, explanation: 'replaced by skills/' }); } + // Managed global slash command files + for (const prompt of getLegacyGlobalPromptMatches(detection)) { + const explanation = prompt.toolId + ? `replaced by .${prompt.toolId}/skills/` + : 'replaced by skills/'; + removals.push({ path: prompt.path, explanation }); + } + // openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed) if (detection.hasOpenspecAgents) { removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' }); @@ -581,6 +765,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. @@ -604,18 +809,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,9 +827,102 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s } } + 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) => getManagedGlobalLegacyPromptMetadata(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/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 488d16cfdc..a876d6ce72 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]; @@ -99,8 +105,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) { @@ -120,7 +126,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); @@ -148,7 +154,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); @@ -227,10 +233,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 e75f4471ee..f93d78f6a8 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'; @@ -48,10 +53,27 @@ import { migrateIfNeeded as migrateIfNeededShared, migrateLegacySkillDirs, } 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'); +/** + * 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>; + deferredGlobalCleanup?: LegacyDetectionResult; +}; + /** * Options for the update command. */ @@ -109,20 +131,26 @@ 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( + const legacyUpgrade = await this.handleLegacyCleanup( resolvedProjectPath, desiredWorkflows, delivery ); + 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; @@ -156,7 +184,10 @@ 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) { + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } // All tools are up to date this.displayUpToDateMessage(toolStatuses); @@ -171,19 +202,20 @@ 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); } console.log(); // 9. Determine what to generate based on delivery - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; - + const deliveryIncludesCommands = delivery !== 'skills'; // 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; @@ -197,6 +229,11 @@ export class UpdateCommand { try { 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) { @@ -210,11 +247,11 @@ 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 - if (!shouldGenerateSkills) { + if (shouldRemoveSkillsForTool(tool.value, delivery)) { removedSkillCount += await this.removeSkillDirs(skillsDir); } @@ -232,13 +269,15 @@ export class UpdateCommand { removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( resolvedProjectPath, toolId, - desiredWorkflows + toolWorkflows ); } + } 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 +292,10 @@ export class UpdateCommand { } } + if (deferredGlobalCleanup) { + await this.performDeferredGlobalPromptCleanup(resolvedProjectPath, deferredGlobalCleanup); + } + // 11. Summary console.log(); if (updatedTools.length > 0) { @@ -261,6 +304,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)`)); } @@ -545,26 +591,47 @@ 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 legacyUpgrade = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + await this.performImmediateLegacyCleanup(projectPath, detection); + return { + ...legacyUpgrade, + deferredGlobalCleanup: pickGlobalLegacyPromptFiles( + detection, + detection.globalSlashCommandFiles + ), + }; } if (!canPrompt) { @@ -572,7 +639,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 @@ -583,13 +650,72 @@ 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 legacyUpgrade = await this.upgradeLegacyTools( + projectPath, + detection, + canPrompt, + desiredWorkflows, + delivery + ); + 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(); - return []; + return { newlyConfiguredTools: [], workflowOverrides: {} }; + } + } + + /** + * Cleans approved repo-local legacy artifacts before configured tools refresh. + */ + private async performImmediateLegacyCleanup( + projectPath: string, + detection: LegacyDetectionResult + ): Promise { + const immediateDetection = omitGlobalLegacyPromptFiles(detection); + 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))); + + 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(); } } @@ -613,8 +739,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, @@ -622,12 +748,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 @@ -638,7 +764,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) @@ -646,7 +772,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 @@ -687,16 +813,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 shouldGenerateSkills = delivery !== 'commands'; - const shouldGenerateCommands = delivery !== 'skills'; - const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; - const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + const workflowOverrides: LegacyUpgradeResult['workflowOverrides'] = {}; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -706,6 +831,18 @@ export class UpdateCommand { try { 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) { @@ -745,6 +882,6 @@ export class UpdateCommand { console.log(); } - return newlyConfigured; + return { newlyConfiguredTools: newlyConfigured, workflowOverrides }; } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 4678d7bbfc..f758305704 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'; @@ -272,60 +270,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'); @@ -975,7 +919,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, ohMyPiAdapter, opencodeAdapter, piAdapter, qoderAdapter, qwenAdapter, roocodeAdapter, traeAdapter, zcodeAdapter diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 363d2ff272..ce41d96f6e 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', () => { @@ -44,6 +45,11 @@ describe('command-generation/registry', () => { expect(CommandAdapterRegistry.get('kimi')).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(); @@ -64,6 +70,7 @@ describe('command-generation/registry', () => { expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); expect(toolIds).toContain('windsurf'); + expect(toolIds).not.toContain('codex'); }); it('should include the ZCode adapter', () => { @@ -81,6 +88,7 @@ describe('command-generation/registry', () => { expect(CommandAdapterRegistry.has('windsurf')).toBe(true); expect(CommandAdapterRegistry.has('junie')).toBe(true); expect(CommandAdapterRegistry.has('zcode')).toBe(true); + expect(CommandAdapterRegistry.has('codex')).toBe(false); }); it('should return false for unregistered tools', () => { @@ -129,4 +137,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 e5dc9bb746..069ba396bb 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(() => { }); @@ -299,7 +300,8 @@ describe('InitCommand', () => { it('should create both skills and commands for Trae with adapter', async () => { saveGlobalConfig({ - configuredTools: [], + featureFlags: {}, + profile: 'core', delivery: 'both', }); @@ -320,6 +322,26 @@ describe('InitCommand', () => { expect(commandContent).toContain('description:'); }); + 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 }); @@ -551,7 +573,7 @@ describe('InitCommand', () => { ) { throw new Error('EACCES: permission denied'); } - return originalWriteFile.call(fs, filePath, ...args); + return (originalWriteFile as any)(filePath, ...args); } ); @@ -630,6 +652,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); @@ -708,6 +731,65 @@ 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 apply 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 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, 'legacy onboard prompt'); + + 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, 'legacy apply prompt'); + + 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 0f6ebc86ea..c048edf91c 100644 --- a/test/core/legacy-cleanup.test.ts +++ b/test/core/legacy-cleanup.test.ts @@ -8,31 +8,39 @@ import { detectLegacyConfigFiles, detectLegacySlashCommands, detectLegacyStructureFiles, + getCodexPromptDir, hasOpenSpecMarkers, isOnlyOpenSpecContent, removeMarkerBlock, cleanupLegacyArtifacts, + formatDeferredGlobalPromptSummary, formatCleanupSummary, formatDetectionSummary, 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 }); }); @@ -382,6 +390,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'), 'legacy explore prompt'); + 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', () => { @@ -480,6 +502,38 @@ ${OPENSPEC_MARKERS.end}`); expect(result.hasOpenspecAgents).toBe(true); expect(result.hasProjectMd).toBe(true); }); + + 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'), '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'); + + 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 detect exact allowlisted global Codex filenames regardless of template revision', 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).toContain(path.join(promptDir, 'opsx-explore.md')); + }); }); describe('cleanupLegacyArtifacts', () => { @@ -606,6 +660,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['NON_EXISTENT.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -618,6 +673,81 @@ ${OPENSPEC_MARKERS.end}`); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toContain('NON_EXISTENT.md'); }); + + 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, 'legacy apply prompt'); + await fs.writeFile(customOpsxPrompt, 'user'); + 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); + 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 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, + '# customized legacy apply prompt\n' + ); + + const detection = await detectLegacyArtifacts(testDir); + const result = await cleanupLegacyArtifacts(testDir, detection); + + 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.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, outsidePrompt], + 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.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(); + }); }); describe('formatCleanupSummary', () => { @@ -712,6 +842,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -730,6 +861,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -750,6 +882,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLINE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -769,6 +902,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -786,6 +920,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.cursor/commands/openspec-proposal.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -803,6 +938,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -820,6 +956,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -840,6 +977,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md'], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: true, hasRootAgentsWithMarkers: false, @@ -860,6 +998,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: ['CLAUDE.md', 'CLINE.md'], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: true, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -878,12 +1017,41 @@ ${OPENSPEC_MARKERS.end}`); expect(summary).toContain('• CLINE.md'); }); + it('should format deferred global prompts cleanup separately from repo-local files', () => { + const globalPrompt = path.join(getCodexPromptDir(), 'opsx-explore.md'); + const detection = { + configFiles: [], + configFilesToUpdate: [], + 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 = 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); + }); + it('should return empty string when nothing is detected', () => { const detection = { configFiles: [], configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -949,17 +1117,37 @@ ${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 allowlisted managed global Codex prompt names separately from project-local paths', () => { + 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()); + }); }); describe('getToolsFromLegacyArtifacts', () => { @@ -969,6 +1157,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: ['.claude/commands/openspec'], slashCommandFiles: [], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -986,6 +1175,25 @@ ${OPENSPEC_MARKERS.end}`); 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 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, @@ -1003,6 +1211,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, @@ -1027,6 +1236,7 @@ ${OPENSPEC_MARKERS.end}`); '.cursor/commands/openspec-apply.md', '.cursor/commands/openspec-archive.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1038,12 +1248,38 @@ ${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')], + 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, + 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, @@ -1060,6 +1296,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.qwen/commands/openspec-proposal.toml'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1077,6 +1314,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.continue/prompts/openspec-apply.prompt'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1094,6 +1332,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.github/prompts/openspec-apply.prompt.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1111,6 +1350,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/opsx-propose.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1128,6 +1368,7 @@ ${OPENSPEC_MARKERS.end}`); configFilesToUpdate: [], slashCommandDirs: [], slashCommandFiles: ['.opencode/command/openspec-new.md'], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1148,6 +1389,7 @@ ${OPENSPEC_MARKERS.end}`); '.opencode/command/opsx-propose.md', '.opencode/command/openspec-new.md', ], + globalSlashCommandFiles: [], hasOpenspecAgents: false, hasProjectMd: false, hasRootAgentsWithMarkers: false, @@ -1167,6 +1409,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 1e6d4b3230..dc42d887bd 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(); @@ -1033,6 +1037,134 @@ ${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, 'legacy explore prompt'); + 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('Deferred global prompts cleanup') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining(`codex: ${managedPrompt}`) + ); + 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 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, 'legacy explore prompt'); + + 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, 'legacy onboard prompt'); + + 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 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'); @@ -1693,6 +1825,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 managedPrompt = path.join(promptDir, 'opsx-explore.md'); + await fs.mkdir(promptDir, { recursive: true }); + await fs.writeFile(managedPrompt, 'legacy explore prompt'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists(managedPrompt)).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: {}, @@ -1701,8 +1907,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;