Files
msd-core/tests/install-runtime-artifacts.test.cjs
Tom Boucher 74a818308e 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>
2026-06-07 15:07:14 -04:00

373 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Installer Module — Sections 6–8 + 12.
*
* Covers: installRuntimeArtifacts parameterised layout loop,
* uninstallRuntimeArtifacts all runtimes, Contract 6 counter-test
* (unknown runtime rejected), and legacy migration tests.
*
* Consolidates (original sources from #3758):
* install-uninstall-layout-loop.test.cjs
*
* Closes #3758
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
installRuntimeArtifacts,
parseRuntimeInput,
allRuntimes,
} = require('../bin/install.js');
const {
resolveRuntimeArtifactLayout,
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const {
loadSkillsManifest,
resolveProfile,
} = require('../gsd-core/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ─── Section 6: installRuntimeArtifacts — parameterised layout loop ──────────
const SKILLS_RUNTIMES_LAYOUT = [
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
];
const ALL_RUNTIMES_LAYOUT = [
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
'cline', 'opencode', 'kilo',
];
function countPrefixedEntries(destDir, prefix) {
if (!fs.existsSync(destDir)) return 0;
return fs.readdirSync(destDir).filter(n => n.startsWith(prefix)).length;
}
function writeSkillEntry(destDir, prefix, stem) {
const entryDir = path.join(destDir, `${prefix}${stem}`);
fs.mkdirSync(entryDir, { recursive: true });
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
}
function writeCommandEntry(destDir, prefix, stem) {
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
}
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
const configDir = createTempDir(`gsd-ial-${runtime}-`);
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, `${runtime} must have skills kind`);
const destDir = path.join(configDir, skillsKind.destSubpath);
assert.ok(fs.existsSync(destDir));
assert.ok(
fs.existsSync(path.join(destDir, `${skillsKind.prefix}help`, 'SKILL.md')),
`${runtime}: ${skillsKind.prefix}help/SKILL.md must exist`
);
if (RESOLVED_CORE.skills !== '*') {
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
assert.strictEqual(prefixedCount, RESOLVED_CORE.skills.size,
`${runtime}: installed skill count must match profile`);
}
});
}
});
describe('installRuntimeArtifacts — hermes nested layout', () => {
test('hermes: skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
const configDir = createTempDir('gsd-ial-hermes-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
const nestedDir = path.join(configDir, 'skills', 'gsd');
assert.ok(fs.existsSync(nestedDir));
assert.ok(fs.existsSync(path.join(nestedDir, 'help', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(nestedDir, 'gsd-help')),
'hermes must NOT have gsd-help prefix');
});
});
describe('installRuntimeArtifacts — gemini commands layout', () => {
test('gemini: commands/gsd/ created, no skills/', (t) => {
const configDir = createTempDir('gsd-ial-gemini-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd')));
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd', 'help.md')));
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
});
});
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-');
t.after(() => cleanup(configDir));
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE));
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
assert.ok(!fs.existsSync(path.join(configDir, 'commands')));
});
});
describe('installRuntimeArtifacts — opencode / kilo flat commands', () => {
for (const runtime of ['opencode', 'kilo']) {
test(`${runtime}: command/gsd-help.md exists`, (t) => {
const configDir = createTempDir(`gsd-ial-${runtime}-`);
t.after(() => cleanup(configDir));
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
const commandDir = path.join(configDir, 'command');
assert.ok(fs.existsSync(commandDir));
assert.ok(fs.existsSync(path.join(commandDir, 'gsd-help.md')));
});
}
});
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => {
for (const runtime of ALL_RUNTIMES_LAYOUT) {
test(`${runtime}: gsd entries removed, foreign preserved`, (t) => {
const configDir = createTempDir(`gsd-ual-${runtime}-`);
t.after(() => cleanup(configDir));
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
if (layout.kinds.length === 0) {
const foreignDir = path.join(configDir, 'foreign-dir');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
assert.doesNotThrow(() => uninstallRuntimeArtifacts(runtime, configDir, 'global'));
assert.ok(fs.existsSync(path.join(foreignDir, 'keep.md')));
return;
}
if (runtime === 'hermes') {
const kind = layout.kinds[0];
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(path.join(destDir, 'help'), { recursive: true });
fs.writeFileSync(path.join(destDir, 'help', 'SKILL.md'), '# help\n');
const siblingDir = path.join(configDir, 'skills', 'user-skill');
fs.mkdirSync(siblingDir, { recursive: true });
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts(runtime, configDir, 'global');
assert.ok(!fs.existsSync(destDir));
assert.ok(fs.existsSync(path.join(siblingDir, 'SKILL.md')));
return;
}
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(destDir, { recursive: true });
if (kind.kind === 'skills') {
writeSkillEntry(destDir, kind.prefix, 'help');
writeSkillEntry(destDir, kind.prefix, 'phase');
const foreignDir = path.join(destDir, 'user-custom-skill');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
} else {
writeCommandEntry(destDir, kind.prefix, 'help');
writeCommandEntry(destDir, kind.prefix, 'phase');
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
}
}
uninstallRuntimeArtifacts(runtime, configDir, 'global');
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
if (kind.kind === 'skills') {
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`)));
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`)));
assert.ok(fs.existsSync(path.join(destDir, 'user-custom-skill', 'SKILL.md')));
} else {
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)));
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)));
assert.ok(fs.existsSync(path.join(destDir, 'user-custom.md')));
}
}
});
}
});
// ─── Section 8: Counter-test — unknown runtime is rejected (Contract 6) ──────
describe('Contract 6: unknown runtime is rejected', () => {
test('resolveRuntimeArtifactLayout throws TypeError for unknown runtime', () => {
assert.throws(
() => resolveRuntimeArtifactLayout('unknown-runtime-xyz', '/tmp/test', 'global'),
(err) => {
assert.ok(err instanceof TypeError, 'must be TypeError');
assert.ok(err.message.includes('Unknown runtime'), `message: ${err.message}`);
return true;
}
);
});
test('parseRuntimeInput returns ["claude"] for unrecognised string (safe default)', () => {
// parseRuntimeInput processes menu numbers, not runtime names directly;
// an unrecognised token falls through to the default ["claude"].
const result = parseRuntimeInput('unknown-xyz');
assert.deepStrictEqual(result, ['claude']);
});
test('allRuntimes does not include any unrecognised value', () => {
// Every entry in allRuntimes must be recognised by resolveRuntimeArtifactLayout
for (const runtime of allRuntimes) {
assert.doesNotThrow(
() => resolveRuntimeArtifactLayout(runtime, '/tmp/test', 'global'),
`${runtime} must be a recognised runtime`
);
}
});
});
// ─── Section 12: Legacy migrations in installRuntimeArtifacts ────────────────
describe('installRuntimeArtifacts — legacy migrations run before layout copy', () => {
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
const configDir = createTempDir('gsd-legacy-install-');
t.after(() => cleanup(configDir));
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# My dev prefs\n');
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(legacyDir));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
});
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
const configDir = createTempDir('gsd-legacy-hermes-install-');
t.after(() => cleanup(configDir));
const legacyFlatHelp = path.join(configDir, 'skills', 'gsd-help');
fs.mkdirSync(legacyFlatHelp, { recursive: true });
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(legacyFlatHelp));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md')));
});
});
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout removal', () => {
test('hermes: both flat and nested layouts removed', (t) => {
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
t.after(() => cleanup(configDir));
const skillsDir = path.join(configDir, 'skills');
const flatHelp = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(flatHelp, { recursive: true });
fs.writeFileSync(path.join(flatHelp, 'SKILL.md'), '# legacy flat\n');
const nestedGsd = path.join(skillsDir, 'gsd');
fs.mkdirSync(path.join(nestedGsd, 'help'), { recursive: true });
fs.writeFileSync(path.join(nestedGsd, 'help', 'SKILL.md'), '# nested help\n');
const userSkill = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkill, { recursive: true });
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts('hermes', configDir, 'global');
assert.ok(!fs.existsSync(flatHelp));
assert.ok(!fs.existsSync(nestedGsd));
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
});
test('claude: legacy commands/gsd/ cleaned AND new skills/ entries removed', (t) => {
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
t.after(() => cleanup(configDir));
const skillsDir = path.join(configDir, 'skills');
const gsdHelp = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(gsdHelp, { recursive: true });
fs.writeFileSync(path.join(gsdHelp, 'SKILL.md'), '# help\n');
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# legacy\n');
const userSkill = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkill, { recursive: true });
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts('claude', configDir, 'global');
assert.ok(!fs.existsSync(gsdHelp));
assert.ok(!fs.existsSync(legacyDir));
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
});
});