diff --git a/.changeset/2644-cursor-deduplicate-menu.md b/.changeset/2644-cursor-deduplicate-menu.md new file mode 100644 index 000000000..d23ed1e03 --- /dev/null +++ b/.changeset/2644-cursor-deduplicate-menu.md @@ -0,0 +1,6 @@ +--- +type: Fixed +pr: 2812 +--- + +**Cursor now shows each GSD workflow once in the slash menu while keeping skills available for contextual model invocation** — Upgrades safely retire manifest-managed legacy `commands/gsd-*.md` duplicates, back up modified managed copies, and preserve unknown user-authored commands. diff --git a/.gitignore b/.gitignore index a2c11747e..9c1928d03 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,8 @@ build/ /gsd-core/bin/lib/installer-migration-authoring.cjs /gsd-core/bin/lib/update-context.cjs /gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +/gsd-core/bin/lib/retired-artifact-cleanup.cjs /gsd-core/bin/lib/runtime-homes.cjs /gsd-core/bin/lib/model-catalog.cjs /gsd-core/bin/lib/configuration.cjs diff --git a/bin/install.js b/bin/install.js index ff781806f..eaee3797c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2612,36 +2612,11 @@ function convertClaudeCommandToCursorSkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; const adapter = getCursorSkillAdapterHeader(skillName); - // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/' - // menu (it defaults to true). Cursor also writes a commands/ surface (#785), - // and surfacing both duplicated every /gsd-* entry. This mirrors the #789 - // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills - // stay model-invocable background knowledge. (user-invocable:false hides from - // '/' while keeping model invocation — distinct from disable-model-invocation.) - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`; -} - -/** - * Convert a Claude Code command to a Cursor 1.6 slash command (#785). - * - * Cursor slash commands live in `.cursor/commands/.md` and are - * plain markdown — no YAML frontmatter, no adapter header. The filename - * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`). - * - * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill - * converter (tool renames, brand substitution, slash-command normalisation), - * then strips the YAML frontmatter block so only the prose body remains. - * - * @param {string} content raw Claude Code command markdown (may have frontmatter) - * @param {string} _commandName the target command name (unused; present for - * API symmetry with other converters so the runtime-artifact-layout stage - * function can call it uniformly) - * @returns {string} plain markdown body, no frontmatter - */ -function convertClaudeCommandToCursorCommand(content, _commandName) { - const converted = convertClaudeToCursorMarkdown(content); - const { body } = extractFrontmatterAndBody(converted); - return body.trimStart(); + // Cursor skills are both slash-invocable and model-invocable. Do not emit the + // unsupported `user-invocable` field: it is ignored by Cursor and previously + // hid the real cause of duplicate entries, the parallel commands/ surface + // retired in #2644. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; } /** @@ -10621,8 +10596,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands). - // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089). + // Descriptor-driven commands/ output report (currently CodeBuddy). + // Cursor retired this parallel surface in #2644 because its skills are + // already slash-menu entries as well as model-invocable context. if (_hostBehaviors(runtime).reportCommandsDir) { const commandsDir = path.join(targetDir, 'commands'); if (fs.existsSync(commandsDir)) { @@ -13326,7 +13302,6 @@ module.exports = { applyRuntimeContentRewritesInPlace, getCodexSkillAdapterHeader, convertClaudeCommandToCursorSkill, - convertClaudeCommandToCursorCommand, convertClaudeAgentToCursorAgent, convertClaudeAgentToCodexAgent, generateCodexAgentToml, diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index 8f3d0476f..845123b96 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -3,7 +3,7 @@ "role": "runtime", "version": "1.9.1", "title": "Cursor", - "description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", + "description": "Cursor IDE — skills-only workflow surface; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -29,14 +29,6 @@ "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -55,14 +47,6 @@ "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -107,7 +91,13 @@ "frontmatterDialect": "cursor", "hooksJsonSurface": true, "skipSharedHooksInstall": true, - "reportCommandsDir": true, + "retiredArtifacts": [ + { + "destSubpath": "commands", + "prefix": "gsd-", + "suffix": ".md" + } + ], "skipUpdateBannerCommand": true, "skipSettingsUi": true, "managedHookEvents": [ diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 967e21105..d3b175ad6 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -422,6 +422,7 @@ "research-provider.cjs", "research-store.cjs", "resolution.cjs", + "retired-artifact-cleanup.cjs", "review-lane-descriptor.cjs", "review-lane-invocation.cjs", "review-lane-runner.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index bc98653aa..6aa63dc14 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -508,6 +508,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | | `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | | `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | +| `retired-artifact-cleanup.cjs` | Manifest-safe cleanup for descriptor-declared retired runtime artifact surfaces; shared by install and profile/surface apply so removed layout kinds converge without deleting modified or unknown user files (#2644) | | `probe-core.cjs` | Generic spec-phase probe resolution model (compiled from `src/probe-core.cts`, gitignored; ADR-550 Decision 7) — the status×verification re-cut (`status: resolved/dismissed/unresolved` × per-probe `verification`), `validateResolution`/`validateRequirement`, `analyzeCoverage(items, resolutions?, validators)` merge/rollup/orphan-reject, the `byVerification` rollup, and the `runProbeCli` I/O scaffold; the shared seam consumed by `edge-probe` (and the prohibition probe #644); exports `VALID_STATUS`, `validateResolution`, `validateRequirement`, `analyzeCoverage`, `runProbeCli` (#550) | | `prohibition-enforcement.cjs` | Deterministic test-tier prohibition PRODUCER/gate (compiled from `src/prohibition-enforcement.cts`, gitignored; #1259, ADR-550 D5d "heavy half") — locates the wired mechanical check (`node-test` or `lint-rule`), confirms it is fail-first, runs it via an injectable runner, builds typed `enforcementEvidence`, and emits the `dispositionForProhibition` verdict; a passing wired check disposes green, a missing/failing/non-fail-first check hard-gates (flagged, non-green) in both interactive and autonomous modes; exports `runProhibitionEnforcement`, `routeProhibitionEnforcement`; CLI surface `gsd_run check prohibition-enforcement ` | | `review-lane-descriptor.cjs` | Declared reviewer-lane contract (compiled from `src/review-lane-descriptor.cts`, gitignored; ADR-2782) — the frozen `REVIEWER_LANES` roster, the lane slug grammar, and two pure parity gates: `checkReviewerLaneParity` (descriptor ↔ roster ↔ registry, plus anti-parity against re-added bespoke workflow legs) and `checkReviewerDocsParity` (declared flags and section titles ↔ `docs/COMMANDS.md`, `docs/FEATURES.md` and their locale mirrors; #2800, closes #2781/#2272); exports `REVIEWER_LANES`, `PARITY_VIOLATION`, `DOCS_PARITY_VIOLATION`, `LANE_SLUG_RE` | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 41c347703..290ce07e0 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -282,7 +282,7 @@ COPILOT_CONFIG_DIR=~/.copilot-alt npx @opengsd/gsd-core@latest --copilot --globa npx @opengsd/gsd-core@latest --cursor --global ``` -Artifacts land in `~/.cursor/`. GSD installs slash commands (`~/.cursor/commands/gsd-*.md`), skills (`~/.cursor/skills/gsd-*/SKILL.md`), agents, and rule references. Each GSD action appears once in Cursor's `/` menu: the command surface is the single `/` entry point, and the skills are installed with `user-invocable: false` so they stay model-invocable background knowledge without duplicating the `/` entries. +Artifacts land in `~/.cursor/`. GSD installs skills (`~/.cursor/skills/gsd-*/SKILL.md`), agents, and rule references. Cursor exposes each skill once in the `/` menu while keeping it available for contextual model invocation. Upgrading removes manifest-managed legacy `~/.cursor/commands/gsd-*.md` copies that previously duplicated those menu entries; unknown user-authored command files are preserved. **Override the install directory:** diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 82a52da04..45dc03a9c 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -505,6 +505,7 @@ Each row corresponds to one migration record in `src/installer-migrations/`. | `2026-07-17-opencode-baseline-commands-dir` | `005-opencode-baseline-commands-dir.cts` | 1.7.0 | global, local | No | Baselines pre-existing files under OpenCode's `commands/` (plural) directory during the first-time scan. #2329 moved OpenCode command materialization to `commands/`, but 000's `RUNTIME_SURFACES.opencode` is a shipped, immutable body that still only names the legacy `command/` alias, so this fix-forward migration widens the scanned surface. OpenCode only; Kilo is unaffected. | | `2026-07-20-pi-extension-cjs-to-js` | `006-pi-extension-cjs-to-js.cts` | 1.7.1 | global, local | Yes | Removes the stale `extensions/gsd.cjs` left by pre-#2470 pi installs. pi's extension auto-discovery (`isExtensionFile()`) accepts only `.ts`/`.js`, so the `.cjs` file was never loaded and `/gsd` never registered; #2470 renamed the installed artifact to `extensions/gsd.js`, orphaning the old path. Locally modified copies are backed up rather than deleted; an unmanifested `gsd.cjs` is preserved as a user file. pi only. | | `2026-07-28-retire-config-root-commonjs-marker` | `007-retire-config-root-commonjs-marker.cts` | 1.8.0 | global, local | Yes | Removes `/package.json` when it is exactly the `{"type":"commonjs"}` marker pre-#2544 installs wrote there. #2544 moved that marker into the directories GSD fills (`hooks/`, and the native plugin dir), so an upgraded install would otherwise keep both and stay pinned to CommonJS at a config root GSD no longer writes. Ownership is proven by exact content match, not the manifest (the marker was never manifest-recorded) — a `package.json` with any other content is left untouched, with no backup-and-remove branch. All runtimes; kimi's root marker lives outside `configDir` and is retired by the installer instead. | +| `2026-07-29-cursor-retire-commands-surface` | `008-cursor-retire-commands-surface.cts` | 1.8.1 | global, local | Yes | Removes manifest-managed `commands/gsd-*.md` files from Cursor installs. Cursor already exposes the corresponding skills in the slash menu and to contextual model invocation, so the command copies produced duplicate entries (#2644). Modified files are backed up; unmanifested files are preserved. | ## Prior Art diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 2cbe3a591..1d3348e09 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -448,7 +448,7 @@ Sources consulted: - https://www.codebuddy.ai/docs/cli/settings - /websites/codebuddy_cn (Context7) -**EoS migration status (#2098):** Migrated onto the declarative adapter (dogfooded in `tests/declarative-reference-codebuddy.test.cjs`). The two remaining `isCodebuddy` branches in `bin/install.js` — a duplicate `commands/` slash-command output report, and a dead legacy agent-converter dispatch arm (unreachable since codebuddy is in `_DESCRIPTOR_AGENTS_RUNTIMES`) — were folded onto the already-generic `runtime.hostBehaviors.reportCommandsDir` (shared with Cursor) and removed outright; `isCodebuddy` no longer appears as a live read anywhere in `bin/install.js`, `src/runtime-artifact-conversion.cts`, `src/shell-command-projection.cts`, or `src/runtime-name-policy.cts`. Two upgrades land: (1) **extended hook events** — codebuddy's `extendedHookEvents` was previously `[]` (none wired); this PR wires all four — `SubagentStop`/`Stop`/`PreCompact`/`SubagentStart` — into `extendedHookEvents` (mirrors qwen/kimi), so an install now registers all four as hooks in `settings.json` alongside the pre-existing base session/tool events (`SessionStart`/`PreToolUse`/`PostToolUse`); cite https://www.codebuddy.ai/docs/cli/hooks. (2) **`dispatch.background`** — the descriptor already declared `true`, exceeding the `declarative-cli` profile baseline of `false`; the negotiation contract (`negotiateHostCapabilities`) now surfaces that value with no downgrade warning, documenting the legitimate deviation. Note: the CodeBuddy CLI has no background-dispatch frontmatter field on sub-agents (`agentMode`/`enabledAutoRun` are IDE-only per https://www.codebuddy.ai/docs/cli/sub-agents) — background dispatch remains a caller-side invocation parameter (`run_in_background: true`), not a field GSD's agent artifacts emit. +**EoS migration status (#2098):** Migrated onto the declarative adapter (dogfooded in `tests/declarative-reference-codebuddy.test.cjs`). The two remaining `isCodebuddy` branches in `bin/install.js` — a duplicate `commands/` slash-command output report, and a dead legacy agent-converter dispatch arm (unreachable since codebuddy is in `_DESCRIPTOR_AGENTS_RUNTIMES`) — were folded onto the generic `runtime.hostBehaviors.reportCommandsDir` and removed outright; `isCodebuddy` no longer appears as a live read anywhere in `bin/install.js`, `src/runtime-artifact-conversion.cts`, `src/shell-command-projection.cts`, or `src/runtime-name-policy.cts`. Cursor also used that report flag historically, but retired its parallel command surface in #2644 because Cursor skills already appear in the slash menu. Two upgrades land: (1) **extended hook events** — codebuddy's `extendedHookEvents` was previously `[]` (none wired); this PR wires all four — `SubagentStop`/`Stop`/`PreCompact`/`SubagentStart` — into `extendedHookEvents` (mirrors qwen/kimi), so an install now registers all four as hooks in `settings.json` alongside the pre-existing base session/tool events (`SessionStart`/`PreToolUse`/`PostToolUse`); cite https://www.codebuddy.ai/docs/cli/hooks. (2) **`dispatch.background`** — the descriptor already declared `true`, exceeding the `declarative-cli` profile baseline of `false`; the negotiation contract (`negotiateHostCapabilities`) now surfaces that value with no downgrade warning, documenting the legitimate deviation. Note: the CodeBuddy CLI has no background-dispatch frontmatter field on sub-agents (`agentMode`/`enabledAutoRun` are IDE-only per https://www.codebuddy.ai/docs/cli/sub-agents) — background dispatch remains a caller-side invocation parameter (`run_in_background: true`), not a field GSD's agent artifacts emit. --- @@ -848,4 +848,3 @@ hosts) adds a belt-and-suspenders `maxDepth: 5` ceiling independent of whatever engine enforces natively — there is no separate extension-side "subagent contribution" registration API beyond the chat participant + Language Model Tools already registered; VS Code's chat engine surfaces them to `#runSubagent` on its own. - diff --git a/docs/reference/skill-mapping-matrix.md b/docs/reference/skill-mapping-matrix.md index 959c63d01..89b8077cb 100644 --- a/docs/reference/skill-mapping-matrix.md +++ b/docs/reference/skill-mapping-matrix.md @@ -28,7 +28,7 @@ For the transform each converter applies, see [ADR-1593 §3 — converter transf | **codex** | `skills/` | `gsd-` | flat | unconfirmed → conservative | `convertClaudeCommandToCodexSkill` | TOML config (`configFormat: toml`). Description truncated to 180 chars (`metadata.short-description`). `sandboxTier: codex-agent-sandbox`. | | **opencode** | `skills/` | `gsd-` | flat | recursive (`**` glob) | `convertClaudeCommandToOpencodeSkill` | XDG config home. Shares the opencode-family converter entry point (`convertClaudeCommandToOpencodeFamilySkill`). Also ships `command` (singular) commands. | | **kilo** | `skills/` | `gsd-` | flat | recursive (`**` glob) | `convertClaudeCommandToKiloSkill` | OpenCode fork; same `**` glob loader. `permissionWriter: kilo`. Also ships `command` commands. | -| **cursor** | `skills/` | `gsd-` | flat | recursive | `convertClaudeCommandToCursorSkill` | Also ships flat `commands/` via `convertClaudeCommandToCursorCommand`. `configFormat: none`. | +| **cursor** | `skills/` | `gsd-` | flat | recursive | `convertClaudeCommandToCursorSkill` | Skills are the single slash-menu and contextual invocation surface; legacy managed `commands/` copies are retired by migration 008 (#2644). `configFormat: none`. | | **copilot** | `skills/` | `gsd-` | flat | unconfirmed → conservative | `convertClaudeCommandToCopilotSkill` | Markdown config. Scope-aware converter (global-home vs workspace-relative). | | **antigravity** | `skills/` | `gsd-` | flat | non-recursive (one-level) | `convertClaudeCommandToAntigravitySkill` | `dot-home-nested` config home. Scope-aware converter. Loader confirmed: *"will not recursive scan"*. Flattened by #1614 — `agy` scans only `skills//SKILL.md`, so nesting hid sub-skills. | | **windsurf** | — *(no skills kind)* | `gsd-` | — | workflows | `convertClaudeCommandToWindsurfWorkflow` | Emits `.windsurf/workflows/gsd-*.md` slash-command workflows. `configFormat: none`. `installSurface: profile-marker-only`. | diff --git a/eslint.config.mjs b/eslint.config.mjs index 72b7a929e..a22dad316 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -118,6 +118,8 @@ export default tseslint.config( 'gsd-core/bin/lib/installer-migration-authoring.cjs', 'gsd-core/bin/lib/update-context.cjs', 'gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs', + 'gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs', + 'gsd-core/bin/lib/retired-artifact-cleanup.cjs', 'gsd-core/bin/lib/runtime-homes.cjs', 'gsd-core/bin/lib/model-catalog.cjs', 'gsd-core/bin/lib/configuration.cjs', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 694c11d09..b6bae0631 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1233,7 +1233,7 @@ const capabilities = { "role": "runtime", "version": "1.9.1", "title": "Cursor", - "description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", + "description": "Cursor IDE — skills-only workflow surface; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -1259,14 +1259,6 @@ const capabilities = { "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -1285,14 +1277,6 @@ const capabilities = { "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -1337,7 +1321,13 @@ const capabilities = { "frontmatterDialect": "cursor", "hooksJsonSurface": true, "skipSharedHooksInstall": true, - "reportCommandsDir": true, + "retiredArtifacts": [ + { + "destSubpath": "commands", + "prefix": "gsd-", + "suffix": ".md" + } + ], "skipUpdateBannerCommand": true, "skipSettingsUi": true, "managedHookEvents": [ @@ -5586,7 +5576,7 @@ const runtimes = { "role": "runtime", "version": "1.9.1", "title": "Cursor", - "description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", + "description": "Cursor IDE — skills-only workflow surface; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -5612,14 +5602,6 @@ const runtimes = { "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -5638,14 +5620,6 @@ const runtimes = { "recursive": true, "converter": "convertClaudeCommandToCursorSkill" }, - { - "kind": "commands", - "destSubpath": "commands", - "prefix": "gsd-", - "nesting": "flat", - "recursive": false, - "converter": "convertClaudeCommandToCursorCommand" - }, { "kind": "agents", "destSubpath": "agents", @@ -5690,7 +5664,13 @@ const runtimes = { "frontmatterDialect": "cursor", "hooksJsonSurface": true, "skipSharedHooksInstall": true, - "reportCommandsDir": true, + "retiredArtifacts": [ + { + "destSubpath": "commands", + "prefix": "gsd-", + "suffix": ".md" + } + ], "skipUpdateBannerCommand": true, "skipSettingsUi": true, "managedHookEvents": [ diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 8f2365335..be6598b9b 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -707,7 +707,6 @@ const VALID_CONVERTER_NAMES = new Set([ 'convertClaudeCommandToCodebuddySkill', 'convertClaudeCommandToCodexSkill', 'convertClaudeCommandToCopilotSkill', - 'convertClaudeCommandToCursorCommand', 'convertClaudeCommandToCursorSkill', 'convertClaudeCommandToKiloSkill', 'convertClaudeCommandToKimiSkill', diff --git a/src/install-engine.cts b/src/install-engine.cts index c53b5b995..213e0a804 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -28,6 +28,7 @@ import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs import runtimeNamePolicy = require('./runtime-name-policy.cjs'); import installProfiles = require('./install-profiles.cjs'); import installerMigrations = require('./installer-migrations.cjs'); +import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs'); import { posixNormalize } from './shell-command-projection.cjs'; import { isPathConfined } from './external-descriptor-trust.cjs'; import { ensureCommonJsMarker } from './commonjs-marker.cjs'; @@ -725,6 +726,11 @@ function installRuntimeArtifacts( resolveAttribution: ResolveAttribution = () => undefined, capabilityRegistry?: any, ): void { + // A removed descriptor kind is no longer visited by the layout loop, so it + // cannot prune its own previous output. Clean manifest-proven retired files + // before materializing the current layout (#2644). + retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir); + // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through // the dedicated combined commands+skills+plugin orchestrator instead of the // generic layout-driven loop below, mirroring the bespoke install path that @@ -1304,6 +1310,12 @@ function installOpencodeFamilyArtifacts( * @param scope */ function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: string): void { + // A retired descriptor kind is absent from the current uninstall plan, just + // as it is absent from the install plan. Sweep manifest-proven output from + // retired kinds before removing the current layout so a direct uninstall + // cannot leave stale runtime surfaces behind (#2644). + retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir); + // Legacy cleanup before layout-driven removal (scope-aware to avoid // removing Claude local commands/gsd/ which is the primary install dir). // Returns saved user artifacts so we can migrate AFTER layout removal diff --git a/src/installer-migrations/008-cursor-retire-commands-surface.cts b/src/installer-migrations/008-cursor-retire-commands-surface.cts new file mode 100644 index 000000000..691165ecc --- /dev/null +++ b/src/installer-migrations/008-cursor-retire-commands-surface.cts @@ -0,0 +1,71 @@ +/** + * Installer migration: retire Cursor's duplicate commands/ surface (#2644). + * + * Cursor discovers GSD skills as slash-menu entries while also keeping them + * model-invocable. Older GSD releases installed the same workflows again as + * commands/gsd-*.md, so every action appeared twice. The skills remain the + * sole workflow surface; this migration removes only old command files proven + * managed by gsd-file-manifest.json. Modified files are backed up first and + * unmanifested files are preserved. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +interface ClassifiedArtifact { + classification: string; +} + +interface MigrationAction { + type: 'remove-managed' | 'backup-and-remove'; + relPath: string; + reason: string; + ownershipEvidence: string; +} + +interface MigrationPlanContext { + configDir: string; + classifyArtifact(relPath: string): ClassifiedArtifact; +} + +const COMMANDS_DIR = 'commands'; +const REASON = + 'Cursor exposes skills directly in the slash menu; the parallel command file duplicated the same GSD action (#2644)'; +const OWNERSHIP_EVIDENCE = + 'pre-#2644 Cursor installs record commands/gsd-*.md in gsd-file-manifest.json'; + +const migration = { + id: '2026-07-29-cursor-retire-commands-surface', + title: 'Retire Cursor duplicate commands surface', + description: + 'Remove manifest-managed Cursor commands/gsd-*.md files now that skills are the single slash-menu and model-invocation surface.', + introducedIn: '1.8.1', + runtimes: ['cursor'], + scopes: ['global', 'local'], + destructive: true, + plan: (ctx: MigrationPlanContext): MigrationAction[] => { + const commandsDir = path.join(ctx.configDir, COMMANDS_DIR); + let entries: fs.Dirent[]; + try { + if (!fs.existsSync(commandsDir) || fs.lstatSync(commandsDir).isSymbolicLink()) return []; + entries = fs.readdirSync(commandsDir, { withFileTypes: true }); + } catch { + return []; + } + + const actions: MigrationAction[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith('gsd-') || !entry.name.endsWith('.md')) continue; + const relPath = path.posix.join(COMMANDS_DIR, entry.name); + const artifact = ctx.classifyArtifact(relPath); + if (artifact.classification === 'managed-pristine') { + actions.push({ type: 'remove-managed', relPath, reason: REASON, ownershipEvidence: OWNERSHIP_EVIDENCE }); + } else if (artifact.classification === 'managed-modified') { + actions.push({ type: 'backup-and-remove', relPath, reason: REASON, ownershipEvidence: OWNERSHIP_EVIDENCE }); + } + } + return actions; + }, +}; + +export = migration; diff --git a/src/retired-artifact-cleanup.cts b/src/retired-artifact-cleanup.cts new file mode 100644 index 000000000..56b0262f9 --- /dev/null +++ b/src/retired-artifact-cleanup.cts @@ -0,0 +1,102 @@ +/** + * Conservative cleanup for artifact surfaces retired by a runtime descriptor. + * + * A descriptor may stop materializing an artifact kind while old installs + * still contain files from that surface. Install and profile/surface apply + * both call this helper so either path converges an existing installation. + * Only files proven managed by the previous install manifest are removed; + * modified and unknown files are preserved for the journaled installer + * migration (or the user) to handle without data loss. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installerMigrations = require('./installer-migrations.cjs'); +import { isPathConfined } from './external-descriptor-trust.cjs'; + +interface RetiredArtifactDescriptor { + destSubpath?: unknown; + prefix?: unknown; + suffix?: unknown; +} + +interface CleanupResult { + removed: string[]; + preserved: string[]; +} + +interface RegistryShape { + runtimes?: Record; +} + +// Generated at build time, so no TypeScript source declaration exists. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const capabilityRegistry = require('./capability-registry.cjs') as unknown as RegistryShape; + +function retiredArtifactsFor(runtime: string): RetiredArtifactDescriptor[] { + const configured = capabilityRegistry.runtimes?.[runtime]?.runtime?.hostBehaviors?.retiredArtifacts; + return Array.isArray(configured) ? configured : []; +} + +function pruneRetiredRuntimeArtifacts(runtime: string, configDir: string): CleanupResult { + const result: CleanupResult = { removed: [], preserved: [] }; + const declarations = retiredArtifactsFor(runtime); + if (declarations.length === 0) return result; + + const manifest = installerMigrations.readInstallManifest(configDir); + for (const declaration of declarations) { + const destSubpath = declaration.destSubpath; + const prefix = declaration.prefix; + const suffix = declaration.suffix; + if ( + typeof destSubpath !== 'string' || destSubpath.length === 0 || + typeof prefix !== 'string' || prefix.length === 0 || + typeof suffix !== 'string' || suffix.length === 0 || + !isPathConfined(destSubpath, configDir) + ) { + continue; + } + + const destDir = path.resolve(configDir, destSubpath); + let entries: fs.Dirent[]; + try { + if (!fs.existsSync(destDir) || fs.lstatSync(destDir).isSymbolicLink()) continue; + entries = fs.readdirSync(destDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(suffix)) continue; + const relPath = path.posix.join(destSubpath.replace(/\\/g, '/'), entry.name); + const artifact = installerMigrations.classifyArtifact(configDir, relPath, manifest); + if (artifact.classification !== 'managed-pristine') { + result.preserved.push(relPath); + continue; + } + try { + fs.unlinkSync(path.join(destDir, entry.name)); + result.removed.push(relPath); + } catch { + result.preserved.push(relPath); + } + } + + try { + if (fs.readdirSync(destDir).length === 0) fs.rmdirSync(destDir); + } catch { + // Non-empty, unreadable, or concurrently changed: preserve the directory. + } + } + return result; +} + +export = { pruneRetiredRuntimeArtifacts, retiredArtifactsFor }; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 95f0c4acd..ae791d085 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -981,36 +981,11 @@ function convertClaudeCommandToCursorSkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; const adapter = getCursorSkillAdapterHeader(skillName); - // #2341: mark user-invocable:false so the skill is NOT shown in Cursor's '/' - // menu (it defaults to true). Cursor also writes a commands/ surface (#785), - // and surfacing both duplicated every /gsd-* entry. This mirrors the #789 - // CodeBuddy de-dup: the commands/ surface is the sole '/' entry point; skills - // stay model-invocable background knowledge. (user-invocable:false hides from - // '/' while keeping model invocation — distinct from disable-model-invocation.) - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n\n${adapter}\n\n${body.trimStart()}`; -} - -/** - * Convert a Claude Code command to a Cursor 1.6 slash command (#785). - * - * Cursor slash commands live in `.cursor/commands/.md` and are - * plain markdown — no YAML frontmatter, no adapter header. The filename - * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`). - * - * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill - * converter (tool renames, brand substitution, slash-command normalisation), - * then strips the YAML frontmatter block so only the prose body remains. - * - * @param {string} content raw Claude Code command markdown (may have frontmatter) - * @param {string} _commandName the target command name (unused; present for - * API symmetry with other converters so the runtime-artifact-layout stage - * function can call it uniformly) - * @returns {string} plain markdown body, no frontmatter - */ -function convertClaudeCommandToCursorCommand(content, _commandName) { - const converted = convertClaudeToCursorMarkdown(content); - const { body } = extractFrontmatterAndBody(converted); - return body.trimStart(); + // Cursor skills are both slash-invocable and model-invocable. Do not emit the + // unsupported `user-invocable` field: it is ignored by Cursor and previously + // hid the real cause of duplicate entries, the parallel commands/ surface + // retired in #2644. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; } // --- Windsurf converters --- @@ -3030,7 +3005,6 @@ export = { buildKimiAgentArtifacts, convertClaudeToCursorMarkdown, convertClaudeCommandToCursorSkill, - convertClaudeCommandToCursorCommand, convertClaudeToWindsurfMarkdown, convertClaudeCommandToWindsurfSkill, convertClaudeCommandToWindsurfWorkflow, diff --git a/src/surface.cts b/src/surface.cts index 1c67adc2b..aebe78abf 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -52,6 +52,8 @@ const { findInstallSourceRoot } = runtimeArtifactLayout; import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs'); const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan; const SURFACE_FILE_NAME = '.gsd-surface.json'; @@ -351,6 +353,9 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map { // ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ───────────────── describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { - test('VALID_CONVERTER_NAMES has exactly 27 entries (17 command/skill/workflow + 10 agent converters)', () => { + test('VALID_CONVERTER_NAMES has exactly 26 entries (16 command/skill/workflow + 10 agent converters)', () => { assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set'); - assert.strictEqual(VALID_CONVERTER_NAMES.size, 27, 'VALID_CONVERTER_NAMES must have exactly 27 entries, got: ' + VALID_CONVERTER_NAMES.size); + assert.strictEqual(VALID_CONVERTER_NAMES.size, 26, 'VALID_CONVERTER_NAMES must have exactly 26 entries, got: ' + VALID_CONVERTER_NAMES.size); }); test('VALID_CONVERTER_NAMES contains all expected converter names', () => { @@ -4057,7 +4057,6 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { 'convertClaudeCommandToCodebuddySkill', 'convertClaudeCommandToCodexSkill', 'convertClaudeCommandToCopilotSkill', - 'convertClaudeCommandToCursorCommand', 'convertClaudeCommandToCursorSkill', 'convertClaudeCommandToKiloSkill', 'convertClaudeCommandToKimiSkill', diff --git a/tests/cursor-conversion.test.cjs b/tests/cursor-conversion.test.cjs index 131cfd2ba..7f357c532 100644 --- a/tests/cursor-conversion.test.cjs +++ b/tests/cursor-conversion.test.cjs @@ -4,9 +4,6 @@ * Ensures Cursor frontmatter names are emitted as plain identifiers * (without surrounding quotes), so Cursor does not treat quotes as * literal parts of skill/subagent names. - * - * Also covers convertClaudeCommandToCursorCommand (#785 — Cursor 1.6 - * slash commands via .cursor/commands/). */ process.env.GSD_TEST_MODE = '1'; @@ -17,7 +14,6 @@ const assert = require('node:assert/strict'); const { convertClaudeCommandToCursorSkill, convertClaudeAgentToCursorAgent, - convertClaudeCommandToCursorCommand, _applyRuntimeRewrites, } = require('../bin/install.js'); @@ -85,74 +81,6 @@ Planner body }); }); -// ─── convertClaudeCommandToCursorCommand (#785) ─────────────────────────────── - -describe('convertClaudeCommandToCursorCommand (#785 — Cursor 1.6 .cursor/commands/)', () => { - test('strips YAML frontmatter — output is plain markdown', () => { - const input = `--- -name: help -description: Show help for GSD commands ---- - -# GSD Help - -Use \`/gsd-help\` to see available commands. -`; - - const result = convertClaudeCommandToCursorCommand(input); - assert.ok(!result.startsWith('---'), 'cursor commands must not have YAML frontmatter'); - assert.ok(!result.includes('name: help'), 'name field must be stripped'); - assert.ok(!result.includes('description:'), 'description field must be stripped'); - assert.ok(result.includes('GSD Help'), 'body content must be preserved'); - }); - - test('applies convertClaudeToCursorMarkdown transforms (Bash → Shell, Claude Code → Cursor)', () => { - const input = `--- -name: quick -description: Quick task ---- - -Use Bash( to run commands. -This runs in Claude Code. -`; - - const result = convertClaudeCommandToCursorCommand(input); - assert.ok(result.includes('Shell('), 'Bash( should be renamed to Shell('); - assert.ok(!result.includes('Claude Code'), 'Claude Code brand reference should be replaced'); - assert.ok(result.includes('Cursor'), 'should reference Cursor instead'); - }); - - test('normalizes gsd: colon slash commands to gsd- hyphen form', () => { - const input = `--- -name: plan-phase -description: Plan a phase ---- - -Next step: /gsd:execute-phase 17 -`; - - const result = convertClaudeCommandToCursorCommand(input); - assert.ok(result.includes('/gsd-execute-phase 17'), 'colon form should become hyphen form'); - assert.ok(!result.includes('/gsd:execute-phase'), 'colon form should be removed'); - }); - - test('handles input with no frontmatter gracefully', () => { - const input = `# No Frontmatter Command - -Some body content. -`; - - const result = convertClaudeCommandToCursorCommand(input); - assert.ok(!result.startsWith('---'), 'output must not start with ---'); - assert.ok(result.includes('No Frontmatter Command'), 'body should be preserved'); - }); - - test('is exported from install.js', () => { - assert.strictEqual(typeof convertClaudeCommandToCursorCommand, 'function', - 'convertClaudeCommandToCursorCommand must be exported from install.js'); - }); -}); - // ─── _applyRuntimeRewrites(cursor) — bare-form regression (#1356) ──────────── // // Prior to this fix the cursor branch only rewrote trailing-slash ~/.claude/ diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs index f3e0209a8..e0ef266a8 100644 --- a/tests/cursor-imperative-reference.test.cjs +++ b/tests/cursor-imperative-reference.test.cjs @@ -108,7 +108,11 @@ test('cursor descriptor declares runtime.hostBehaviors (the folded-in behaviors) assert.equal(hb.frontmatterDialect, 'cursor'); assert.equal(hb.hooksJsonSurface, true); assert.equal(hb.skipSharedHooksInstall, true); - assert.equal(hb.reportCommandsDir, true); + assert.deepEqual(hb.retiredArtifacts, [ + { destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }, + ]); + assert.equal(hb.reportCommandsDir, undefined, + 'Cursor no longer installs a parallel commands/ surface (#2644)'); assert.ok(Array.isArray(hb.managedHookEvents) && hb.managedHookEvents.length >= 6, 'managedHookEvents must list at least 6 events (AC4a)'); }); diff --git a/tests/declarative-reference-codebuddy.test.cjs b/tests/declarative-reference-codebuddy.test.cjs index b27a59c38..e5ba1c395 100644 --- a/tests/declarative-reference-codebuddy.test.cjs +++ b/tests/declarative-reference-codebuddy.test.cjs @@ -12,8 +12,9 @@ * already descriptor-driven except for two residual `isCodebuddy` branches * in bin/install.js: * 1. a duplicate `commands/` slash-command output report (byte-identical - * to the generic `hostBehaviors.reportCommandsDir` block already used - * by Cursor) — folded onto `hostBehaviors.reportCommandsDir` and deleted. + * to the generic `hostBehaviors.reportCommandsDir` block) — folded onto + * `hostBehaviors.reportCommandsDir` and deleted. Cursor retired that + * parallel commands surface later in #2644. * 2. a dead legacy agent-converter dispatch arm in the inline agent-copy * loop, unreachable because codebuddy is a member of * `_DESCRIPTOR_AGENTS_RUNTIMES` (installRuntimeArtifacts already wrote diff --git a/tests/emitted-drift-acks/2812-cursor-single-menu-surface.json b/tests/emitted-drift-acks/2812-cursor-single-menu-surface.json new file mode 100644 index 000000000..3eac2f2a7 --- /dev/null +++ b/tests/emitted-drift-acks/2812-cursor-single-menu-surface.json @@ -0,0 +1,147 @@ +{ + "version": 1, + "paths": { + "commands/gsd-add-tests.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ai-integration-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-audit-fix.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-audit-milestone.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-audit-uat.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-autonomous.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-capture.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-cleanup.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-code-review.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-complete-milestone.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-config.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-debug.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-discuss-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-docs-update.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-eval-review.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-execute-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-explore.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-extract-learnings.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-fast.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-forensics.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-graphify.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-health.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-help.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-import.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-inbox.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ingest-docs.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-manager.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-map-codebase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-mempalace-capture.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-mempalace-recall.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-milestone-summary.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-mvp-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-new-milestone.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-new-project.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-next.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-context.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-ideate.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-manage.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-project.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-review.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ns-workflow.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-onboard.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-pause-work.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-plan-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-plan-review-convergence.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-pr-branch.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-profile-user.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-progress.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-quick.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-resume-work.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-review-backlog.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-review.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-secure-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-settings.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ship.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-sketch.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-spec-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-spike.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-stats.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-surface.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-thread.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ui-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ui-review.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-ultraplan-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-undo.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-update.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-validate-phase.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-verify-work.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-workspace.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "commands/gsd-workstreams.md": "Cursor deliberately retires its duplicate commands surface in favor of skills (#2644).", + "skills/gsd-add-tests/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ai-integration-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-audit-fix/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-audit-milestone/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-audit-uat/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-autonomous/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-capture/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-cleanup/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-code-review/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-complete-milestone/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-config/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-debug/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-discuss-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-docs-update/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-eval-review/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-execute-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-explore/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-extract-learnings/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-fast/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-forensics/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-graphify/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-health/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-help/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-import/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-inbox/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ingest-docs/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-manager/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-map-codebase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-mempalace-capture/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-mempalace-recall/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-milestone-summary/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-mvp-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-new-milestone/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-new-project/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-next/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-context/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-ideate/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-manage/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-project/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-review/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ns-workflow/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-onboard/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-pause-work/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-plan-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-plan-review-convergence/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-pr-branch/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-profile-user/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-progress/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-quick/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-resume-work/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-review-backlog/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-review/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-secure-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-settings/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ship/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-sketch/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-spec-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-spike/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-stats/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-surface/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-thread/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ui-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ui-review/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-ultraplan-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-undo/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-update/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-validate-phase/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-verify-work/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-workspace/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644).", + "skills/gsd-workstreams/SKILL.md": "Cursor skill frontmatter deliberately drops the unsupported user-invocable field while retaining model invocation (#2644)." + } +} diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index f95a7bc3a..6dd3cdd49 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -35,77 +35,6 @@ "agents/gsd-ui-researcher.md", "agents/gsd-user-profiler.md", "agents/gsd-verifier.md", - "commands/gsd-add-tests.md", - "commands/gsd-ai-integration-phase.md", - "commands/gsd-audit-fix.md", - "commands/gsd-audit-milestone.md", - "commands/gsd-audit-uat.md", - "commands/gsd-autonomous.md", - "commands/gsd-capture.md", - "commands/gsd-cleanup.md", - "commands/gsd-code-review.md", - "commands/gsd-complete-milestone.md", - "commands/gsd-config.md", - "commands/gsd-debug.md", - "commands/gsd-discuss-phase.md", - "commands/gsd-docs-update.md", - "commands/gsd-eval-review.md", - "commands/gsd-execute-phase.md", - "commands/gsd-explore.md", - "commands/gsd-extract-learnings.md", - "commands/gsd-fast.md", - "commands/gsd-forensics.md", - "commands/gsd-graphify.md", - "commands/gsd-health.md", - "commands/gsd-help.md", - "commands/gsd-import.md", - "commands/gsd-inbox.md", - "commands/gsd-ingest-docs.md", - "commands/gsd-manager.md", - "commands/gsd-map-codebase.md", - "commands/gsd-mempalace-capture.md", - "commands/gsd-mempalace-recall.md", - "commands/gsd-milestone-summary.md", - "commands/gsd-mvp-phase.md", - "commands/gsd-new-milestone.md", - "commands/gsd-new-project.md", - "commands/gsd-next.md", - "commands/gsd-ns-context.md", - "commands/gsd-ns-ideate.md", - "commands/gsd-ns-manage.md", - "commands/gsd-ns-project.md", - "commands/gsd-ns-review.md", - "commands/gsd-ns-workflow.md", - "commands/gsd-onboard.md", - "commands/gsd-pause-work.md", - "commands/gsd-phase.md", - "commands/gsd-plan-phase.md", - "commands/gsd-plan-review-convergence.md", - "commands/gsd-pr-branch.md", - "commands/gsd-profile-user.md", - "commands/gsd-progress.md", - "commands/gsd-quick.md", - "commands/gsd-resume-work.md", - "commands/gsd-review-backlog.md", - "commands/gsd-review.md", - "commands/gsd-secure-phase.md", - "commands/gsd-settings.md", - "commands/gsd-ship.md", - "commands/gsd-sketch.md", - "commands/gsd-spec-phase.md", - "commands/gsd-spike.md", - "commands/gsd-stats.md", - "commands/gsd-surface.md", - "commands/gsd-thread.md", - "commands/gsd-ui-phase.md", - "commands/gsd-ui-review.md", - "commands/gsd-ultraplan-phase.md", - "commands/gsd-undo.md", - "commands/gsd-update.md", - "commands/gsd-validate-phase.md", - "commands/gsd-verify-work.md", - "commands/gsd-workspace.md", - "commands/gsd-workstreams.md", "gsd-core/.gsd-runtime", "gsd-core/VERSION", "gsd-core/bin/check-latest-version.cjs", diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 33c14fda2..ff7489589 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -23,6 +23,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const crypto = require('node:crypto'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -40,6 +41,8 @@ const { resolveRuntimeArtifactLayout, } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); + const { loadSkillsManifest, resolveProfile, @@ -105,13 +108,10 @@ describe('installRuntimeArtifacts — consumes Runtime Artifact Install Plan Mod }); t.after(restore); - // #1928: gemini (the only runtime whose 'commands' kind used a - // namespaced-by-dir 'commands/gsd' layout) was removed; cursor is the - // stand-in — it has a 'commands' kind but prefixes files 'gsd-' - // (destSubpath basename !== prefix stem), so proof.md → gsd-proof.md. - installer.installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE); + // Augment has a flat commands kind and prefixes proof.md as gsd-proof.md. + installer.installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); - assert.strictEqual(planArgs.layout.runtime, 'cursor'); + assert.strictEqual(planArgs.layout.runtime, 'augment'); assert.strictEqual(planArgs.layout.configDir, configDir); assert.strictEqual(planArgs.layout.scope, 'global'); assert.strictEqual(planArgs.resolvedProfile, RESOLVED_CORE); @@ -288,28 +288,19 @@ describe('installRuntimeArtifacts — hermes nested layout', () => { }); }); -describe('installRuntimeArtifacts — cursor commands layout (#785)', () => { - test('cursor: skills/ AND commands/ both created; commands/gsd-help.md is plain markdown', (t) => { +describe('installRuntimeArtifacts — cursor skills-only layout (#2644)', () => { + test('cursor: skills/ is created and commands/ is not materialized', (t) => { const configDir = createTempDir('gsd-ial-cursor-cmds-'); t.after(() => cleanup(configDir)); installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE); - // Existing skills kind still present const skillsDir = path.join(configDir, 'skills'); assert.ok(fs.existsSync(skillsDir), 'skills/ must exist'); assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); - - // New commands kind (#785) - const commandsDir = path.join(configDir, 'commands'); - assert.ok(fs.existsSync(commandsDir), 'commands/ must exist (#785)'); - assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), - 'commands/gsd-help.md must exist (#785)'); - - // Cursor commands are plain markdown — no YAML frontmatter - const helpContent = fs.readFileSync(path.join(commandsDir, 'gsd-help.md'), 'utf8'); - assert.ok(!helpContent.startsWith('---'), 'cursor commands must not start with YAML frontmatter'); + assert.ok(!fs.existsSync(path.join(configDir, 'commands')), + 'Cursor must not materialize commands/ because skills already populate the slash menu'); }); }); @@ -750,9 +741,9 @@ describe('uninstallRuntimeArtifacts — consumes Runtime Artifact Uninstall Plan }); t.after(restore); - installer.uninstallRuntimeArtifacts('cursor', configDir, 'global'); + installer.uninstallRuntimeArtifacts('augment', configDir, 'global'); - assert.strictEqual(planLayout.runtime, 'cursor'); + assert.strictEqual(planLayout.runtime, 'augment'); assert.strictEqual(planLayout.configDir, configDir); assert.strictEqual(planLayout.scope, 'global'); assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md'))); @@ -2494,17 +2485,9 @@ describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skill }); }); -// ─── #2341: extend the #789 de-dup to Cursor ───────────────────────────────── -// Cursor installs BOTH a skills surface and a commands surface (#785/#803), and -// surfaces both in its '/' menu — so every /gsd-* appeared twice. The #789 fix -// (skills user-invocable:false → model-invocable but out of '/') was scoped to -// CodeBuddy only and never applied to Cursor. Cursor honors the same SKILL.md -// `user-invocable` convention (verified: user-invocable:false hides a skill from -// '/' while keeping it model-invocable, distinct from disable-model-invocation). -// Fix: emit Cursor skills with user-invocable:false so commands are the single -// '/' entry point. -describe('fix-2341 — Cursor skills marked user-invocable:false', () => { - test('convertClaudeCommandToCursorSkill emits user-invocable: false', () => { +// ─── #2644: Cursor skills are the one slash + model surface ────────────────── +describe('fix-2644 — Cursor has one menu entry per GSD workflow', () => { + test('convertClaudeCommandToCursorSkill emits only supported invocation metadata', () => { const src = [ '---', 'name: gsd:help', @@ -2515,33 +2498,93 @@ describe('fix-2341 — Cursor skills marked user-invocable:false', () => { '', ].join('\n'); const out = convertClaudeCommandToCursorSkill(src, 'gsd-help'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(out), - `Cursor SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + assert.ok(!/^user-invocable:/m.test(out), + `Cursor does not support user-invocable; the field must not be emitted. Got:\n${out}`); + assert.ok(!/^disable-model-invocation:/m.test(out), + 'Cursor skill must remain available for contextual model invocation'); }); - test('installed cursor skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { - const configDir = createTempDir('gsd-fix2341-skillhide-'); + test('fresh install keeps the skill slash/model surface and omits commands', (t) => { + const configDir = createTempDir('gsd-fix2644-fresh-'); t.after(() => cleanup(configDir)); installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE); const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); - assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), - 'installed Cursor SKILL.md must set user-invocable: false so it is not a duplicate / entry'); + assert.ok(!/^user-invocable:/m.test(skill)); + assert.ok(!/^disable-model-invocation:/m.test(skill)); + assert.ok(!fs.existsSync(path.join(configDir, 'commands', 'gsd-help.md'))); }); - test('cursor still installs the commands surface (the single / entry point)', (t) => { - const configDir = createTempDir('gsd-fix2341-cmd-'); + test('reinstall removes manifest-proven legacy commands and preserves unknown files', (t) => { + const configDir = createTempDir('gsd-fix2644-upgrade-'); t.after(() => cleanup(configDir)); + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const managedContent = '# old generated help\n'; + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), managedContent); + fs.writeFileSync(path.join(commandsDir, 'gsd-my-custom.md'), '# user command\n'); + fs.writeFileSync(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.8.0', + timestamp: '2026-07-28T00:00:00.000Z', + mode: 'full', + files: { + 'commands/gsd-help.md': crypto.createHash('sha256').update(managedContent).digest('hex'), + }, + })); + installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE); - // The commands surface stays user-invocable — de-dup hides the skill, not the command. - assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd-help.md')), - 'commands/gsd-help.md (the / entry point) must still be installed'); - const cmd = fs.readFileSync(path.join(configDir, 'commands', 'gsd-help.md'), 'utf8'); - assert.ok(!/^user-invocable:\s*false\s*$/m.test(cmd), - 'the command surface must remain user-invocable (only the skill is hidden)'); + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), + 'manifest-proven legacy command must be retired'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-my-custom.md')), + 'unmanifested user command must be preserved'); + }); + + test('profile surface apply also retires a manifest-proven legacy command', (t) => { + const configDir = createTempDir('gsd-fix2644-surface-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const content = '# old generated help\n'; + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), content); + fs.writeFileSync(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.8.0', timestamp: '2026-07-28T00:00:00.000Z', mode: 'core', + files: { 'commands/gsd-help.md': crypto.createHash('sha256').update(content).digest('hex') }, + })); + + const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global'); + applySurface(configDir, layout, MANIFEST); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), + 'profile toggles must not leave or recreate the retired duplicate surface'); + assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'))); + }); + + test('uninstall also retires manifest-proven legacy commands', (t) => { + const configDir = createTempDir('gsd-fix2644-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const managedContent = '# old generated help\n'; + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), managedContent); + fs.writeFileSync(path.join(commandsDir, 'user-command.md'), '# user command\n'); + fs.writeFileSync(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.8.0', timestamp: '2026-07-28T00:00:00.000Z', mode: 'full', + files: { + 'commands/gsd-help.md': crypto.createHash('sha256').update(managedContent).digest('hex'), + }, + })); + + uninstallRuntimeArtifacts('cursor', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), + 'direct uninstall must remove a manifest-proven retired command'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-command.md')), + 'direct uninstall must preserve unknown user commands'); }); }); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index d2ee4669b..1fe5263f8 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -1682,6 +1682,9 @@ test('shipped installer-migration checksums are locked to a committed baseline ( // never manifest-recorded — so the action declares its own classification. '2026-07-28-retire-config-root-commonjs-marker': 'sha256:8f2140cbe8f2dd8f7dfd52a0f6957c5edfe966c52d7e6e4d74ec7366930e0e1d', + // Migration 008: retire Cursor's duplicate commands/ surface (#2644). + '2026-07-29-cursor-retire-commands-surface': + 'sha256:d0b2b812a3f752650f2518b48280f74a5937c80ec8412bac493382dfa3db083f', }; const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs'); @@ -1794,6 +1797,76 @@ test('reconciles a drifted applied-migration checksum into install state on appl }); +// --------------------------------------------------------------------------- +// Cursor duplicate commands-surface retirement (#2644) +// --------------------------------------------------------------------------- + +{ + const cursorCommandsMigration = require('../gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs'); + + test('migration 008 plans only manifest-managed gsd-*.md command files', (t) => { + const configDir = createTempInstall(); + t.after(() => cleanup(configDir)); + writeFile(configDir, 'commands/gsd-help.md', '# help\n'); + writeFile(configDir, 'commands/gsd-custom.md', '# custom\n'); + writeFile(configDir, 'commands/not-gsd.md', '# other\n'); + + const actions = cursorCommandsMigration.plan({ + configDir, + classifyArtifact(relPath) { + if (relPath === 'commands/gsd-help.md') return { classification: 'managed-pristine' }; + return { classification: 'unknown' }; + }, + }); + + assert.deepEqual(actions.map((action) => [action.type, action.relPath]), [ + ['remove-managed', 'commands/gsd-help.md'], + ]); + }); + + test('migration 008 backs up a modified managed command and preserves an unknown neighbor', (t) => { + const configDir = createTempInstall(); + t.after(() => cleanup(configDir)); + writeFile(configDir, 'commands/gsd-help.md', '# locally modified help\n'); + writeFile(configDir, 'commands/gsd-custom.md', '# user command\n'); + writeManifest(configDir, { + 'commands/gsd-help.md': sha256('# original generated help\n'), + }); + + const result = runInstallerMigrations({ + configDir, runtime: 'cursor', scope: 'global', migrations: [cursorCommandsMigration], + now: () => '2026-07-29T00:00:00.000Z', + }); + + assert.equal(result.plan.actions[0].type, 'backup-and-remove'); + assert.ok(!fs.existsSync(path.join(configDir, 'commands', 'gsd-help.md'))); + assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd-custom.md')), + 'unmanifested user command must be preserved'); + const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8')); + assert.equal( + fs.readFileSync(path.join(configDir, journal.actions[0].backupRelPath), 'utf8'), + '# locally modified help\n', + ); + }); + + test('migration 008 is scoped to Cursor for both global and local installs', (t) => { + for (const scope of ['global', 'local']) { + const configDir = createTempInstall(); + t.after(() => cleanup(configDir)); + writeFile(configDir, 'commands/gsd-help.md', '# help\n'); + writeManifest(configDir, { 'commands/gsd-help.md': sha256('# help\n') }); + const result = runInstallerMigrations({ + configDir, runtime: 'cursor', scope, migrations: [cursorCommandsMigration], + now: () => `2026-07-29T00:00:0${scope === 'global' ? '0' : '1'}.000Z`, + }); + assert.ok(!fs.existsSync(path.join(configDir, 'commands', 'gsd-help.md')), + `${scope} Cursor install must retire the duplicate command`); + assert.deepEqual(result.appliedMigrationIds, ['2026-07-29-cursor-retire-commands-surface']); + } + }); +} + + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs — consolidation epic #1969 (B5 #1974) // ──────────────────────────────────────────────────────────────────────── diff --git a/tests/retired-artifact-cleanup.test.cjs b/tests/retired-artifact-cleanup.test.cjs new file mode 100644 index 000000000..c570120ba --- /dev/null +++ b/tests/retired-artifact-cleanup.test.cjs @@ -0,0 +1,155 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const retiredArtifactCleanup = require('../gsd-core/bin/lib/retired-artifact-cleanup.cjs'); +const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +const cursorBehaviors = capabilityRegistry.runtimes.cursor.runtime.hostBehaviors; + +function createFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-retired-cleanup-')); + const configDir = path.join(root, 'config'); + fs.mkdirSync(configDir, { recursive: true }); + t.after(() => { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- isolated temp fixture cleanup + fs.rmSync(root, { recursive: true, force: true }); + }); + return { root, configDir }; +} + +function setDeclarations(t, declarations) { + const original = cursorBehaviors.retiredArtifacts; + cursorBehaviors.retiredArtifacts = declarations; + t.after(() => { + cursorBehaviors.retiredArtifacts = original; + }); +} + +function writeFile(root, relPath, content) { + const filePath = path.join(root, relPath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; +} + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function writeManifest(configDir, files) { + fs.writeFileSync(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.8.0', + timestamp: '2026-07-28T00:00:00.000Z', + mode: 'full', + files, + }), 'utf8'); +} + +test('rejects malformed and escaping retired-artifact declarations', (t) => { + const { root, configDir } = createFixture(t); + const outsideFile = writeFile(root, 'escape/gsd-help.md', '# outside\n'); + const localFile = writeFile(configDir, 'commands/gsd-help.md', '# local\n'); + setDeclarations(t, [ + { destSubpath: null, prefix: 'gsd-', suffix: '.md' }, + { destSubpath: '../escape', prefix: 'gsd-', suffix: '.md' }, + ]); + + const result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + + assert.deepEqual(result, { removed: [], preserved: [] }); + assert.ok(fs.existsSync(outsideFile), 'an escaping destination must never be touched'); + assert.ok(fs.existsSync(localFile), 'a malformed declaration must not affect valid-looking files'); +}); + +test('refuses a symlinked retired destination directory', (t) => { + const { root, configDir } = createFixture(t); + const targetDir = path.join(root, 'outside-commands'); + const targetFile = writeFile(targetDir, 'gsd-help.md', '# outside\n'); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(targetDir, path.join(configDir, 'commands'), linkType); + setDeclarations(t, [{ destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }]); + + const result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + + assert.deepEqual(result, { removed: [], preserved: [] }); + assert.ok(fs.existsSync(targetFile), 'cleanup must not follow the destination symlink'); +}); + +test('preserves and reports a user-modified managed file', (t) => { + const { configDir } = createFixture(t); + const relPath = 'commands/gsd-help.md'; + const originalContent = '# generated\n'; + const modifiedContent = '# user changed this\n'; + const filePath = writeFile(configDir, relPath, modifiedContent); + writeManifest(configDir, { [relPath]: sha256(originalContent) }); + setDeclarations(t, [{ destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }]); + + const result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + + assert.deepEqual(result, { removed: [], preserved: [relPath] }); + assert.equal(fs.readFileSync(filePath, 'utf8'), modifiedContent); + assert.ok(fs.existsSync(path.dirname(filePath)), 'directory must remain while a preserved file exists'); +}); + +test('removes and reports a managed-pristine file and then removes the empty directory', (t) => { + const { configDir } = createFixture(t); + const relPath = 'commands/gsd-help.md'; + const content = '# generated\n'; + const filePath = writeFile(configDir, relPath, content); + const commandsDir = path.dirname(filePath); + writeManifest(configDir, { [relPath]: sha256(content) }); + setDeclarations(t, [{ destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }]); + + const result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + + assert.deepEqual(result, { removed: [relPath], preserved: [] }); + assert.ok(!fs.existsSync(filePath)); + assert.ok(!fs.existsSync(commandsDir), 'an empty retired destination should be removed'); +}); + +test('leaves non-matching files untouched and keeps their directory', (t) => { + const { configDir } = createFixture(t); + const managedRelPath = 'commands/gsd-help.md'; + const managedContent = '# generated\n'; + const managedFile = writeFile(configDir, managedRelPath, managedContent); + const unrelatedFile = writeFile(configDir, 'commands/user-command.md', '# user\n'); + writeManifest(configDir, { [managedRelPath]: sha256(managedContent) }); + setDeclarations(t, [{ destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }]); + + const result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + + assert.deepEqual(result, { removed: [managedRelPath], preserved: [] }); + assert.ok(!fs.existsSync(managedFile)); + assert.ok(fs.existsSync(unrelatedFile)); + assert.ok(fs.existsSync(path.dirname(unrelatedFile)), 'a non-empty destination must remain'); +}); + +test('reports a managed-pristine file as preserved when unlinkSync fails', (t) => { + const { configDir } = createFixture(t); + const relPath = 'commands/gsd-help.md'; + const content = '# generated\n'; + const filePath = writeFile(configDir, relPath, content); + writeManifest(configDir, { [relPath]: sha256(content) }); + setDeclarations(t, [{ destSubpath: 'commands', prefix: 'gsd-', suffix: '.md' }]); + + const originalUnlinkSync = fs.unlinkSync; + fs.unlinkSync = (candidate) => { + if (path.resolve(candidate) === path.resolve(filePath)) throw new Error('injected unlink failure'); + return originalUnlinkSync(candidate); + }; + let result; + try { + result = retiredArtifactCleanup.pruneRetiredRuntimeArtifacts('cursor', configDir); + } finally { + fs.unlinkSync = originalUnlinkSync; + } + + assert.deepEqual(result, { removed: [], preserved: [relPath] }); + assert.ok(fs.existsSync(filePath), 'a failed unlink must leave the file in place'); +}); diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index 9727e3380..27a8b1bad 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -61,14 +61,13 @@ const GOLDEN = { // Old switch: BOTH scopes returned [skills, commands] (no scope branch). // 5b backfill: local == global. // ADR-1235 step 1 (#1763): agents kind added. + // #2644: commands are deliberately retired so Cursor exposes a single skills surface. 'cursor/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, - { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'cursor/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, - { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index d3c44ce7f..4aa19de92 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -65,11 +65,11 @@ describe('resolveRuntimeArtifactLayout — claude global', () => { }); describe('resolveRuntimeArtifactLayout — cursor', () => { - test('returns correct layout for cursor — skills + commands + agents kinds (#785, ADR-1235)', () => { + test('returns correct layout for cursor — skills + agents only (#2644)', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); assert.strictEqual(layout.runtime, 'cursor'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 3); + assert.strictEqual(layout.kinds.length, 2); const skillsKind = layout.kinds.find(k => k.kind === 'skills'); assert.ok(skillsKind, 'must have a skills kind'); @@ -77,11 +77,8 @@ describe('resolveRuntimeArtifactLayout — cursor', () => { assert.strictEqual(skillsKind.prefix, 'gsd-'); assert.strictEqual(typeof skillsKind.stage, 'function'); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'must have a commands kind (#785 Cursor 1.6 slash commands)'); - assert.strictEqual(commandsKind.destSubpath, 'commands'); - assert.strictEqual(commandsKind.prefix, 'gsd-'); - assert.strictEqual(typeof commandsKind.stage, 'function'); + assert.equal(layout.kinds.find(k => k.kind === 'commands'), undefined, + 'Cursor skills are the sole slash-menu surface; commands would duplicate them (#2644)'); const agentsKind = layout.kinds.find(k => k.kind === 'agents'); assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)'); @@ -416,11 +413,11 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => { assert.ok(kindNames.includes('agents'), 'should have agents kind'); }); - test('cursor has both skills and commands kinds (#785)', () => { + test('cursor has a skills kind and no commands kind (#2644)', () => { const layout = resolveRuntimeArtifactLayout('cursor', '/tmp/x'); const kindNames = layout.kinds.map(k => k.kind); assert.ok(kindNames.includes('skills'), 'cursor must have skills kind'); - assert.ok(kindNames.includes('commands'), 'cursor must have commands kind (#785 Cursor 1.6)'); + assert.ok(!kindNames.includes('commands'), 'cursor commands kind would duplicate skill menu entries'); }); test('claude global has only skills kind', () => { @@ -657,39 +654,11 @@ describe('stage — opencode/kilo skills kind (#784)', () => { } }); -describe('stage — cursor commands kind (#785)', () => { - test('cursor commands kind stage returns directory with converted .md files', () => { +describe('stage — cursor retired commands kind (#2644)', () => { + test('cursor layout exposes no commands staging surface', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR); const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'cursor should have a commands kind (#785)'); - - const stagedDir = commandsKind.stage(PROFILE_CORE); - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - - const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); - assert.ok(entries.length >= 1, 'at least one command file should be staged'); - - // Cursor commands are plain markdown — no YAML frontmatter - for (const entry of entries) { - const content = fs.readFileSync(path.join(stagedDir, entry), 'utf8'); - assert.ok(!content.startsWith('---'), `${entry}: cursor commands must not start with YAML frontmatter`); - } - }); - - test('cursor commands stage applies Cursor-specific content transforms', () => { - const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR); - const commandsKind = layout.kinds.find(k => k.kind === 'commands'); - assert.ok(commandsKind, 'cursor should have a commands kind (#785)'); - - const stagedDir = commandsKind.stage(PROFILE_FULL); - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - - // Verify all staged files are .md only (no subdirectory SKILL.md layout) - const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); - for (const entry of entries) { - assert.ok(entry.isFile(), `${entry.name}: cursor commands dir must contain only flat files`); - assert.ok(entry.name.endsWith('.md'), `${entry.name}: must be .md file`); - } + assert.equal(commandsKind, undefined); }); });