fix(#2644): deduplicate Cursor slash menu (#2812)

* fix(#2644): deduplicate Cursor slash menu

* fix: preserve installer executable mode

* chore: add changeset for PR #2812

* test(#2644): acknowledge Cursor emission changes

* test(#2644): drop spent emitted drift acknowledgments

* fix(#2644): remove retired Cursor command converter

---------

Co-authored-by: clezcoding <clezcoding@users.noreply.github.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
𝚌𝚕𝚎𝚣𝚌𝚘𝚍𝚒𝚗𝚐
2026-08-03 18:05:45 +02:00
committed by GitHub
parent 067a4d1c6c
commit 88f6d9bd1b
29 changed files with 727 additions and 360 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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<string, {
runtime?: {
hostBehaviors?: {
retiredArtifacts?: RetiredArtifactDescriptor[];
};
};
}>;
}
// 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 };

View File

@@ -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/<name>.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,

View File

@@ -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<st
}
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
// Profile toggles must converge retired surfaces too. Once a kind disappears
// from artifactLayout there is no normal sync pass left to prune it (#2644).
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(layout.runtime, layout.configDir);
// #1575: agents kind now mirrors createRuntimeArtifactInstallPlan — build
// agentCtx (pathPrefix + attribution) and pass it to kind.stage() so
// stageAgentsForRuntimeWithConverter applies the full inline-loop pipeline