feat(3663): add runtime-artifact-layout.cjs + resolve tests for all 15 runtimes

- New module: get-shit-done/bin/lib/runtime-artifact-layout.cjs
  - resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout
  - 15-runtime table (grok intentionally excluded, throws TypeError)
  - findInstallSourceRoot / findAgentsSourceRoot walk-up-only (no .gsd-source marker in Phase 1)
  - Loads bin/install.js converters via GSD_TEST_MODE guard
- Tests: 16 fixtures covering all runtimes + both claude scopes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-17 00:18:37 -04:00
parent 808b40d319
commit dc2a6278e5
2 changed files with 450 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
'use strict';
/**
* Runtime artifact layout module — resolves the artifact directory shapes
* (commands, agents, skills) for each supported runtime.
*
* grok is intentionally absent: it is in runtime-homes.cjs but not wired
* here. The TypeError on unknown runtime is the loud-fail signal that a
* runtime was added to the homes list without a layout entry.
*/
const path = require('path');
const fs = require('fs');
const {
stageSkillsForProfile,
stageAgentsForProfile,
stageSkillsForRuntimeAsSkills,
} = require('./install-profiles.cjs');
// Load converters from bin/install.js in test-safe way (GSD_TEST_MODE skips main logic)
process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1';
const {
convertClaudeCommandToClaudeSkill,
convertClaudeCommandToCursorSkill,
convertClaudeCommandToCodexSkill,
convertClaudeCommandToCopilotSkill,
convertClaudeCommandToAntigravitySkill,
convertClaudeCommandToWindsurfSkill,
convertClaudeCommandToAugmentSkill,
convertClaudeCommandToTraeSkill,
convertClaudeCommandToCodebuddySkill,
} = require('../../../bin/install.js');
/**
* @typedef {'commands'|'agents'|'skills'} ArtifactKindName
* @typedef {Object} ArtifactKind
* @property {ArtifactKindName} kind
* @property {string} destSubpath
* @property {string} prefix
* @property {(resolvedProfile: Object) => string} stage
* @typedef {Object} Layout
* @property {string} runtime
* @property {string} configDir
* @property {ArtifactKind[]} kinds
*/
// ---------------------------------------------------------------------------
// Source root finders
// ---------------------------------------------------------------------------
/**
* Walk up from __dirname to find commands/gsd. Walk-up-only for Phase 1;
* the .gsd-source marker check (which requires runtimeConfigDir) is deferred.
*
* @param {string} [overrideRoot] optional override for testability
* @returns {string}
*/
function findInstallSourceRoot(overrideRoot) {
if (overrideRoot) return overrideRoot;
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'commands', 'gsd');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.join(__dirname, '..', '..', '..', 'commands', 'gsd');
}
/**
* Walk up from __dirname to find agents/. Walk-up-only for Phase 1.
*
* @param {string} [overrideRoot] optional override for testability
* @returns {string|null}
*/
function findAgentsSourceRoot(overrideRoot) {
if (overrideRoot) return overrideRoot;
let dir = __dirname;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, 'agents');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// ---------------------------------------------------------------------------
// Allowlisted runtimes
// ---------------------------------------------------------------------------
const ALLOWED_RUNTIMES = new Set([
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
'cline', 'opencode', 'kilo',
]);
// ---------------------------------------------------------------------------
// Layout table builders
// ---------------------------------------------------------------------------
function commandsKind(destSubpath, prefix, srcOverride) {
return {
kind: 'commands',
destSubpath,
prefix,
stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(srcOverride), resolved),
};
}
function agentsKind(destSubpath, prefix, srcOverride) {
return {
kind: 'agents',
destSubpath,
prefix,
stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(srcOverride), resolved),
};
}
function skillsKind(destSubpath, prefix, converter, srcOverride) {
return {
kind: 'skills',
destSubpath,
prefix,
stage: (resolved) => stageSkillsForRuntimeAsSkills(findInstallSourceRoot(srcOverride), resolved, converter, prefix),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolve the artifact layout for a given runtime and config directory.
*
* @param {string} runtime
* @param {string} configDir
* @param {'local'|'global'} [scope]
* @returns {Layout}
*/
function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') {
if (typeof configDir !== 'string' || configDir === '') {
throw new TypeError('configDir must be a non-empty string');
}
if (scope !== 'local' && scope !== 'global') {
throw new TypeError('scope must be "local" or "global"');
}
if (!ALLOWED_RUNTIMES.has(runtime)) {
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
let kinds;
switch (runtime) {
case 'claude':
if (scope === 'local') {
kinds = [
commandsKind('commands/gsd', 'gsd-'),
agentsKind('agents', 'gsd-'),
];
} else {
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)];
}
break;
case 'cursor':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCursorSkill)];
break;
case 'gemini':
kinds = [commandsKind('commands/gsd', 'gsd-')];
break;
case 'codex':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodexSkill)];
break;
case 'copilot':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCopilotSkill)];
break;
case 'antigravity':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAntigravitySkill)];
break;
case 'windsurf':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToWindsurfSkill)];
break;
case 'augment':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAugmentSkill)];
break;
case 'trae':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToTraeSkill)];
break;
case 'qwen':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)];
break;
case 'hermes':
kinds = [skillsKind('skills/gsd', '', convertClaudeCommandToClaudeSkill)];
break;
case 'codebuddy':
kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodebuddySkill)];
break;
case 'cline':
kinds = [];
break;
case 'opencode':
kinds = [commandsKind('command', 'gsd-')];
break;
case 'kilo':
kinds = [commandsKind('command', 'gsd-')];
break;
default:
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
return { runtime, configDir, kinds };
}
module.exports = { resolveRuntimeArtifactLayout };

View File

@@ -0,0 +1,219 @@
'use strict';
/**
* Tests for resolveRuntimeArtifactLayout — structural shape per runtime.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
const FAKE_DIR = '/tmp/fake-config-dir';
describe('resolveRuntimeArtifactLayout — claude local', () => {
test('returns correct layout for claude scope=local', () => {
const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local');
assert.strictEqual(layout.runtime, 'claude');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds[1].kind, 'agents');
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — claude global', () => {
test('returns correct layout for claude scope=global', () => {
const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global');
assert.strictEqual(layout.runtime, 'claude');
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');
});
});
describe('resolveRuntimeArtifactLayout — cursor', () => {
test('returns correct layout for cursor', () => {
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');
});
});
describe('resolveRuntimeArtifactLayout — gemini', () => {
test('returns correct layout for gemini', () => {
const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR);
assert.strictEqual(layout.runtime, 'gemini');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — codex', () => {
test('returns correct layout for codex', () => {
const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR);
assert.strictEqual(layout.runtime, 'codex');
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');
});
});
describe('resolveRuntimeArtifactLayout — copilot', () => {
test('returns correct layout for copilot', () => {
const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR);
assert.strictEqual(layout.runtime, 'copilot');
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');
});
});
describe('resolveRuntimeArtifactLayout — antigravity', () => {
test('returns correct layout for antigravity', () => {
const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR);
assert.strictEqual(layout.runtime, 'antigravity');
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');
});
});
describe('resolveRuntimeArtifactLayout — windsurf', () => {
test('returns correct layout for windsurf', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR);
assert.strictEqual(layout.runtime, 'windsurf');
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');
});
});
describe('resolveRuntimeArtifactLayout — augment', () => {
test('returns correct layout for augment', () => {
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR);
assert.strictEqual(layout.runtime, 'augment');
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');
});
});
describe('resolveRuntimeArtifactLayout — trae', () => {
test('returns correct layout for trae', () => {
const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR);
assert.strictEqual(layout.runtime, 'trae');
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');
});
});
describe('resolveRuntimeArtifactLayout — qwen', () => {
test('returns correct layout for qwen', () => {
const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR);
assert.strictEqual(layout.runtime, 'qwen');
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');
});
});
describe('resolveRuntimeArtifactLayout — hermes', () => {
test('returns correct layout for hermes', () => {
const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR);
assert.strictEqual(layout.runtime, 'hermes');
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/gsd');
assert.strictEqual(layout.kinds[0].prefix, '');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — codebuddy', () => {
test('returns correct layout for codebuddy', () => {
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR);
assert.strictEqual(layout.runtime, 'codebuddy');
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');
});
});
describe('resolveRuntimeArtifactLayout — cline', () => {
test('returns correct layout for cline', () => {
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR);
assert.strictEqual(layout.runtime, 'cline');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 0);
});
});
describe('resolveRuntimeArtifactLayout — opencode', () => {
test('returns correct layout for opencode', () => {
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR);
assert.strictEqual(layout.runtime, 'opencode');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — kilo', () => {
test('returns correct layout for kilo', () => {
const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR);
assert.strictEqual(layout.runtime, 'kilo');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
});
});