feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface (#805)

* feat(#785): write .cursor/commands/ as Cursor 1.6 slash-command surface

Cursor 1.6 (released 2025-09-12) introduced plain-markdown slash commands
in `.cursor/commands/<name>.md` — no frontmatter, invocable via `/` in the
Agent input. GSD previously emitted only `~/.cursor/skills/` for Cursor.

This PR wires a second artifact kind for `cursor` in
`runtime-artifact-layout.cts`: `convertedCommandsKind('commands', 'gsd-',
'convertClaudeCommandToCursorCommand', configDir)`. The new kind applies the
same `convertClaudeToCursorMarkdown` transforms (tool renames, brand
substitution, slash-command normalisation) and then strips YAML frontmatter
so the output is plain prose. Skills output is unchanged.

`stageCommandsForRuntimeFlat` in `install-profiles.cts` stages each source
`.md` as a flat `<stem>.md` in a temp dir; the existing `_copyStaged` commands
path then prefixes and copies to `<configDir>/commands/`.

`.cursor/mcp.json` is explicitly OUT OF SCOPE: GSD ships no MCP server; the
`mcpServers` schema cannot be usefully populated by the installer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#785): address review nit

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 15:07:14 -04:00
committed by GitHub
parent c480e04188
commit 74a818308e
8 changed files with 309 additions and 13 deletions

View File

@@ -363,6 +363,62 @@ function stageSkillsForRuntimeAsSkills(
return stageDir;
}
/**
* Stage converted command files as flat `.md` files.
*
* Analogous to `stageSkillsForRuntimeAsSkills` but for runtimes that use a
* flat commands directory (e.g. Cursor's `.cursor/commands/<name>.md`).
* Each source `.md` is passed through `converter` and written as a single flat
* `${stem}.md` file in the staging directory (no subdirectory, no prefix).
*
* The `_copyStaged` commands branch in install.js will add the prefix when
* copying staged files to the destination directory, so staged files must be
* named with just the stem (e.g. `help.md` not `gsd-help.md`).
*
* The `converter` receives `(content, ${prefix}${stem})` so it can embed the
* full command name (e.g. 'gsd-help') into the document body if needed.
*
* Used by the `convertedCommandsKind` layout descriptor in
* runtime-artifact-layout.cts (#785 — Cursor 1.6 slash commands).
*
* @param srcCommandsDir source commands directory (e.g. commands/gsd/)
* @param resolvedProfile profile filter — '*' for all, Set for subset
* @param converter (content, commandName) → string pure converter
* @param prefix command name prefix (for converter arg), e.g. 'gsd-'
*/
function stageCommandsForRuntimeFlat(
srcCommandsDir: string,
resolvedProfile: ResolvedProfile,
converter: (content: string, commandName: string) => string,
prefix: string,
): string {
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-commands-'));
try {
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
// Pass the full command name (with prefix) to the converter so it can
// reference the installed command name in the body (e.g. for descriptions).
// The staged file itself is named without the prefix; _copyStaged adds it.
const commandName = `${prefix}${stem}`;
const converted = converter(content, commandName);
fs.writeFileSync(path.join(stageDir, `${stem}.md`), converted);
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
}
STAGED_DIRS.add(stageDir);
ensureExitCleanup();
return stageDir;
}
// ---------------------------------------------------------------------------
// Profile marker persistence
// ---------------------------------------------------------------------------
@@ -535,6 +591,7 @@ export = {
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
stageCommandsForRuntimeFlat,
STAGED_DIRS,
readActiveProfile,
writeActiveProfile,

View File

@@ -21,6 +21,7 @@ const {
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
stageCommandsForRuntimeFlat,
} = installProfiles;
// In .cts (CommonJS output) files, `require` is available as a global.
@@ -225,6 +226,40 @@ function skillsKind(
};
}
/**
* Build a converted-commands kind descriptor for runtimes that use a flat
* commands directory with per-file conversion (e.g. Cursor 1.6 slash commands).
*
* Unlike `commandsKind` (which passes raw source files through), this kind
* applies `converterName` from bin/install.js exports to each file during
* staging, writing flat `${prefix}${stem}.md` files to the staged directory.
*
* The staged files are then written by `_copyStaged` (commands branch) which
* handles prefix logic via the existing layout machinery.
*
* @param destSubpath destination subpath within configDir (e.g. 'commands')
* @param prefix filename prefix, e.g. 'gsd-'
* @param converterName name of converter function in bin/install.js exports
* @param configDir runtime config dir (for .gsd-source marker resolution)
*/
function convertedCommandsKind(
destSubpath: string,
prefix: string,
converterName: string,
configDir: string,
): ArtifactKind {
return {
kind: 'commands',
destSubpath,
prefix,
stage: (resolved) => {
const installExports = getInstallExports();
const converter = installExports[converterName] as (content: string, commandName: string) => string;
return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix);
},
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -257,7 +292,14 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
break;
case 'cursor':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)];
// Cursor 1.6+ supports two artifact surfaces:
// 1. skills/gsd-<name>/SKILL.md — rich skills with frontmatter + adapter header
// 2. commands/gsd-<name>.md — plain markdown slash commands (no frontmatter)
// accessed via '/' in the Agent input (#785)
kinds = [
skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir),
convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir),
];
break;
case 'gemini':