* 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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
102
src/retired-artifact-cleanup.cts
Normal file
102
src/retired-artifact-cleanup.cts
Normal 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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user