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:
231
get-shit-done/bin/lib/runtime-artifact-layout.cjs
Normal file
231
get-shit-done/bin/lib/runtime-artifact-layout.cjs
Normal 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 };
|
||||
219
tests/runtime-artifact-layout-resolve.test.cjs
Normal file
219
tests/runtime-artifact-layout-resolve.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user