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

@@ -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');
});
});

View File

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

View File

@@ -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`);
}
});
});