From 74a818308eeb1485ca94059d3c7f83f8673b72b6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:07:14 -0400 Subject: [PATCH] feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface (#805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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/.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 `.md` in a temp dir; the existing `_copyStaged` commands path then prefixes and copies to `/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 * refactor(#785): address review nit --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/785-cursor-slash-commands.md | 5 ++ bin/install.js | 40 +++++++++++++ docs/FEATURES.md | 16 ++++-- src/install-profiles.cts | 57 +++++++++++++++++++ src/runtime-artifact-layout.cts | 44 ++++++++++++++- tests/cursor-conversion.test.cjs | 72 ++++++++++++++++++++++++ tests/install-runtime-artifacts.test.cjs | 25 ++++++++ tests/runtime-artifact-layout.test.cjs | 63 +++++++++++++++++++-- 8 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 .changeset/785-cursor-slash-commands.md diff --git a/.changeset/785-cursor-slash-commands.md b/.changeset/785-cursor-slash-commands.md new file mode 100644 index 000000000..62007389f --- /dev/null +++ b/.changeset/785-cursor-slash-commands.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 803 +--- +`gsd install --cursor` now writes `.cursor/commands/gsd-.md` in addition to the existing `.cursor/skills/` surface. Cursor 1.6 introduced plain-markdown slash commands (no frontmatter) in `.cursor/commands/`; they appear in the `/` menu in the Agent input. Each command file is generated from the same source as the skill but with frontmatter stripped and Cursor-specific content transforms applied (`convertClaudeCommandToCursorCommand`). The skills surface is unchanged — both surfaces are written on every install. diff --git a/bin/install.js b/bin/install.js index 1439003aa..6e7b5e61e 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2196,6 +2196,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) { return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\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(); +} + /** * Convert Claude Code agent markdown to Cursor agent format. * Strips frontmatter fields Cursor doesn't support (color, skills), @@ -8603,6 +8626,22 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd-*'); } + + // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) + if (isCursor) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } } } else if (isOpencode || isKilo) { // OpenCode/Kilo: flat structure in command/ directory @@ -10895,6 +10934,7 @@ module.exports = { computePathPrefix, getCodexSkillAdapterHeader, convertClaudeCommandToCursorSkill, + convertClaudeCommandToCursorCommand, convertClaudeAgentToCursorAgent, convertClaudeToGeminiMarkdown, convertSlashCommandsToGeminiMentions, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f879a60bd..839c29642 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1013,12 +1013,16 @@ fix(03-01): correct auth token expiry **Runtime Transformations:** -| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Trae | Cline | Augment | CodeBuddy | Qwen Code | -|--------|------------|----------|--------|-------|-------|---------|-------------|------|-------|---------|-----------|-----------| -| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills | Rules | Skills | Skills | Skills | -| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Rules | Skills | Skills | Skills | -| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | -| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config | +| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Cursor | Trae | Cline | Augment | CodeBuddy | Qwen Code | +|--------|------------|----------|--------|-------|-------|---------|-------------|--------|------|-------|---------|-----------|-----------| +| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills + Slash commands | Skills | Rules | Skills | Skills | Skills | +| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Skills | Rules | Skills | Skills | Skills | +| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | +| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | Config | `.clinerules` | Config | Config | Config | + +**Cursor artifact surfaces:** `gsd install --cursor` writes two artifact kinds: +- `~/.cursor/skills/gsd-/SKILL.md` — rich skills with YAML frontmatter, Cursor tool-name mapping, and adapter context header (existing surface) +- `~/.cursor/commands/gsd-.md` — plain markdown slash commands (no frontmatter) invocable via `/` in the Agent input (Cursor 1.6+, added in #785) **Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported. diff --git a/src/install-profiles.cts b/src/install-profiles.cts index e5ff8d4b2..8a838d3ac 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -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/.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, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 0a0d836ac..fff12cf62 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -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-/SKILL.md — rich skills with frontmatter + adapter header + // 2. commands/gsd-.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': diff --git a/tests/cursor-conversion.test.cjs b/tests/cursor-conversion.test.cjs index c238e2e46..92a961a31 100644 --- a/tests/cursor-conversion.test.cjs +++ b/tests/cursor-conversion.test.cjs @@ -4,6 +4,9 @@ * 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'; @@ -14,6 +17,7 @@ const assert = require('node:assert/strict'); const { convertClaudeCommandToCursorSkill, convertClaudeAgentToCursorAgent, + convertClaudeCommandToCursorCommand, } = require('../bin/install.js'); describe('convertClaudeCommandToCursorSkill', () => { @@ -79,3 +83,71 @@ Planner body assert.ok(!result.includes('name: "gsd-planner"'), 'quoted agent name is not emitted'); }); }); + +// ─── 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'); + }); +}); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 76fd3d566..3b6a1d805 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -131,6 +131,31 @@ describe('installRuntimeArtifacts — gemini commands layout', () => { }); }); +describe('installRuntimeArtifacts — cursor commands layout (#785)', () => { + test('cursor: skills/ AND commands/ both created; commands/gsd-help.md is plain markdown', (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'); + }); +}); + describe('installRuntimeArtifacts — cline no-op', () => { test('cline: no kinds — call succeeds, no dirs created', (t) => { const configDir = createTempDir('gsd-ial-cline-'); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 01c72657e..673f7680a 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -59,15 +59,23 @@ describe('resolveRuntimeArtifactLayout — claude global', () => { }); describe('resolveRuntimeArtifactLayout — cursor', () => { - test('returns correct layout for cursor', () => { + test('returns correct layout for cursor — skills + commands kinds (#785)', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); assert.strictEqual(layout.runtime, 'cursor'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + 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'); }); }); @@ -263,6 +271,13 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => { assert.ok(kindNames.includes('agents'), 'should have agents kind'); }); + test('cursor has both skills and commands kinds (#785)', () => { + 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)'); + }); + test('claude global has only skills kind', () => { const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); assert.strictEqual(layout.kinds.length, 1); @@ -394,3 +409,39 @@ describe('stage — opencode commands kind', () => { } }); }); + +describe('stage — cursor commands kind (#785)', () => { + test('cursor commands kind stage returns directory with converted .md files', () => { + 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`); + } + }); +});