From dc2a6278e5926631cb39844d25deab7e6570d016 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:18:37 -0400 Subject: [PATCH] feat(3663): add runtime-artifact-layout.cjs + resolve tests for all 15 runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../bin/lib/runtime-artifact-layout.cjs | 231 ++++++++++++++++++ .../runtime-artifact-layout-resolve.test.cjs | 219 +++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 get-shit-done/bin/lib/runtime-artifact-layout.cjs create mode 100644 tests/runtime-artifact-layout-resolve.test.cjs diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs new file mode 100644 index 000000000..25412b62c --- /dev/null +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -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 }; diff --git a/tests/runtime-artifact-layout-resolve.test.cjs b/tests/runtime-artifact-layout-resolve.test.cjs new file mode 100644 index 000000000..d5eeccbf4 --- /dev/null +++ b/tests/runtime-artifact-layout-resolve.test.cjs @@ -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'); + }); +});