feat(#1049): phase 5d — drive artifactLayout from the runtime descriptor (retire the 128-LOC switch) (#1053)

resolveRuntimeArtifactLayout now builds Layout from
registry.runtimes[id].runtime.artifactLayout[scope] — a loop dispatching each
ArtifactKind through the SAME 5 builders (commandsKind/agentsKind/skillsKind/
convertedCommandsKind/kimiAgentsKind, unchanged) by (kind, converter, nesting) —
replacing the hardcoded switch(runtime). Equivalence-preserving for all 16 runtimes
× {global, local} (Codex-verified, no divergence). -43 LOC; bin/install.js + the
converters + the install loop untouched. getInstallExports()[converterName]
resolution, configDir threading, scope default, unknown-runtime guard all preserved.

Driving the local scope surfaced a 5a gap: the old switch had no scope branch for 13
runtimes (cursor/gemini/codex/copilot/antigravity/windsurf/augment/trae/qwen/hermes/
codebuddy/opencode/kilo) → local == global for them, but 5a authored local:[].
Backfilled local=global for those 13 (descriptor-faithful; a fall-through shim would
wrongly give cline/kimi local=global). claude/cline/kimi scope-gating untouched.

validateArtifactKindEntry tightened: destSubpath/prefix/nesting/converter required
(ConverterName enum still open — 5e). New 39-case deep-equal golden equivalence test.

Closes #1049

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 14:08:46 -04:00
committed by GitHub
parent cec7e704d6
commit 58ed55683e
18 changed files with 946 additions and 171 deletions

View File

@@ -25,7 +25,16 @@
"converter": "convertClaudeCommandToAntigravitySkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -31,7 +31,24 @@
"converter": "convertClaudeCommandToAugmentSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -31,7 +31,24 @@
"converter": "convertClaudeCommandToCodebuddySkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddyCommand"
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -23,7 +23,16 @@
"converter": "convertClaudeCommandToCodexSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodexSkill"
}
]
},
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",

View File

@@ -23,7 +23,16 @@
"converter": "convertClaudeCommandToCopilotSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",

View File

@@ -31,7 +31,24 @@
"converter": "convertClaudeCommandToCursorCommand"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToCursorSkill"
},
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",

View File

@@ -23,7 +23,16 @@
"converter": null
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -23,7 +23,16 @@
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills/gsd",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -36,7 +36,24 @@
"converter": "convertClaudeCommandToKiloSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToKiloSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",

View File

@@ -31,7 +31,24 @@
"converter": "convertClaudeCommandToOpencodeSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToOpencodeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",

View File

@@ -23,7 +23,16 @@
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",

View File

@@ -23,7 +23,16 @@
"converter": "convertClaudeCommandToTraeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",

View File

@@ -24,7 +24,16 @@
"converter": "convertClaudeCommandToWindsurfSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",

View File

@@ -40,7 +40,16 @@ const capabilities = {
"converter": "convertClaudeCommandToAntigravitySkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -111,7 +120,24 @@ const capabilities = {
"converter": "convertClaudeCommandToAugmentSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -243,7 +269,24 @@ const capabilities = {
"converter": "convertClaudeCommandToCodebuddySkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddyCommand"
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -279,7 +322,16 @@ const capabilities = {
"converter": "convertClaudeCommandToCodexSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodexSkill"
}
]
},
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
@@ -316,7 +368,16 @@ const capabilities = {
"converter": "convertClaudeCommandToCopilotSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
@@ -359,7 +420,24 @@ const capabilities = {
"converter": "convertClaudeCommandToCursorCommand"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToCursorSkill"
},
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
@@ -395,7 +473,16 @@ const capabilities = {
"converter": null
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -461,7 +548,16 @@ const capabilities = {
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills/gsd",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -540,7 +636,24 @@ const capabilities = {
"converter": "convertClaudeCommandToKiloSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToKiloSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -633,7 +746,24 @@ const capabilities = {
"converter": "convertClaudeCommandToOpencodeSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToOpencodeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -668,7 +798,16 @@ const capabilities = {
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -704,7 +843,16 @@ const capabilities = {
"converter": "convertClaudeCommandToTraeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -825,7 +973,16 @@ const capabilities = {
"converter": "convertClaudeCommandToWindsurfSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -1038,7 +1195,16 @@ const runtimes = {
"converter": "convertClaudeCommandToAntigravitySkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1082,7 +1248,24 @@ const runtimes = {
"converter": "convertClaudeCommandToAugmentSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1214,7 +1397,24 @@ const runtimes = {
"converter": "convertClaudeCommandToCodebuddySkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddyCommand"
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1250,7 +1450,16 @@ const runtimes = {
"converter": "convertClaudeCommandToCodexSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodexSkill"
}
]
},
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
@@ -1287,7 +1496,16 @@ const runtimes = {
"converter": "convertClaudeCommandToCopilotSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
@@ -1330,7 +1548,24 @@ const runtimes = {
"converter": "convertClaudeCommandToCursorCommand"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToCursorSkill"
},
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
@@ -1366,7 +1601,16 @@ const runtimes = {
"converter": null
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1402,7 +1646,16 @@ const runtimes = {
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills/gsd",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1453,7 +1706,24 @@ const runtimes = {
"converter": "convertClaudeCommandToKiloSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToKiloSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -1546,7 +1816,24 @@ const runtimes = {
"converter": "convertClaudeCommandToOpencodeSkill"
}
],
"local": []
"local": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToOpencodeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -1581,7 +1868,16 @@ const runtimes = {
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
@@ -1617,7 +1913,16 @@ const runtimes = {
"converter": "convertClaudeCommandToTraeSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
@@ -1653,7 +1958,16 @@ const runtimes = {
"converter": "convertClaudeCommandToWindsurfSkill"
}
],
"local": []
"local": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfSkill"
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",

View File

@@ -581,21 +581,21 @@ function validateArtifactKindEntry(capId, entry, prefix) {
errors.push(ctx + '.destSubpath must be a non-empty string');
}
// nesting — optional; if present must be in closed vocab
if (entry.nesting !== undefined) {
if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) {
errors.push(
ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') +
' (got: ' + JSON.stringify(entry.nesting) + ')',
);
}
// nesting — required; must be in closed vocab (ADR-857 §5d: now drives install)
if (entry.nesting === undefined || entry.nesting === null) {
errors.push(ctx + '.nesting is required and must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', '));
} else if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) {
errors.push(
ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') +
' (got: ' + JSON.stringify(entry.nesting) + ')',
);
}
// prefix — optional; if present must be a string
if (entry.prefix !== undefined) {
if (typeof entry.prefix !== 'string') {
errors.push(ctx + '.prefix must be a string if present (got: ' + typeof entry.prefix + ')');
}
// prefix — required; must be a string (may be empty string '')
if (entry.prefix === undefined || entry.prefix === null) {
errors.push(ctx + '.prefix is required (must be a string, may be empty)');
} else if (typeof entry.prefix !== 'string') {
errors.push(ctx + '.prefix must be a string (got: ' + typeof entry.prefix + ')');
}
// recursive — optional; if present must be a boolean
@@ -605,11 +605,11 @@ function validateArtifactKindEntry(capId, entry, prefix) {
}
}
// converter — optional; if present must be a string or null (closed ConverterName enum in phase 5e)
if (entry.converter !== undefined) {
if (entry.converter !== null && typeof entry.converter !== 'string') {
errors.push(ctx + '.converter must be a string or null if present (got: ' + typeof entry.converter + ')');
}
// converter — required; must be a string or null (closed ConverterName enum in phase 5e)
if (!Object.prototype.hasOwnProperty.call(entry, 'converter')) {
errors.push(ctx + '.converter is required (must be a string or null)');
} else if (entry.converter !== null && typeof entry.converter !== 'string') {
errors.push(ctx + '.converter must be a string or null (got: ' + typeof entry.converter + ')');
}
return errors;

View File

@@ -79,6 +79,7 @@
},
"runtime-artifact-layout": {
"files": [
"runtime-artifact-layout-descriptor-drive.test.cjs",
"runtime-artifact-layout-install-profiles.test.cjs",
"runtime-artifact-layout-surface.test.cjs",
"runtime-artifact-layout.test.cjs"

View File

@@ -352,8 +352,72 @@ function convertedCommandsKind(
// windsurf — docs.devin.ai/desktop/cascade/skills
// codebuddy — codebuddy.ai/docs/cli/skills
// ---------------------------------------------------------------------------
// Descriptor-driven dispatch helpers (ADR-857 phase 5d)
// ---------------------------------------------------------------------------
interface ArtifactKindDescriptor {
kind: string;
destSubpath: string;
prefix: string;
nesting: 'flat' | 'nested';
recursive: boolean;
converter: string | null;
}
interface ArtifactLayoutDescriptor {
global: ArtifactKindDescriptor[];
local: ArtifactKindDescriptor[];
}
/** Lazy registry accessor — mirrors pattern from 5b/5c (runtime-homes.cts). */
function getRegistry(): { runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }> } {
return _require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { artifactLayout?: ArtifactLayoutDescriptor } }>;
};
}
/**
* Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
* matching builder function. Mirrors the hand-built calls in the old switch.
*/
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string): ArtifactKind {
const { kind, destSubpath, prefix, nesting, converter } = entry;
const nested = nesting === 'nested';
switch (kind) {
case 'commands':
if (converter == null) {
return commandsKind(destSubpath, prefix, configDir);
}
return convertedCommandsKind(destSubpath, prefix, converter, configDir);
case 'agents':
return agentsKind(destSubpath, prefix, configDir);
case 'skills':
if (converter == null) {
throw new TypeError(
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
);
}
return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested);
case 'kimi-agents':
return kimiAgentsKind(destSubpath, prefix, configDir);
default:
throw new TypeError(
`resolveRuntimeArtifactLayout: unknown kind '${kind}' in descriptor for runtime '${runtime}'`,
);
}
}
/**
* Resolve the artifact layout for a given runtime and config directory.
*
* ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
* instead of a hardcoded switch statement.
*/
function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global'): Layout {
if (typeof configDir !== 'string' || configDir === '') {
@@ -366,122 +430,15 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
let kinds: ArtifactKind[];
switch (runtime) {
case 'claude':
if (scope === 'local') {
kinds = [
commandsKind('commands/gsd', 'gsd-', configDir),
agentsKind('agents', 'gsd-', configDir),
];
} else {
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)];
}
break;
case 'cursor':
// Cursor 1.6+ supports two artifact surfaces:
// 1. skills/gsd-<name>/SKILL.md — rich skills with frontmatter + adapter header
// 2. commands/gsd-<name>.md — plain markdown slash commands (no frontmatter)
// accessed via '/' in the Agent input (#785)
kinds = [
skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir),
convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir),
];
break;
case 'gemini':
kinds = [commandsKind('commands/gsd', 'gsd-', configDir)];
break;
case 'codex':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', 'codex', configDir)];
break;
case 'copilot':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', 'copilot', configDir)];
break;
case 'antigravity':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir, true /* #69 nested */)];
break;
case 'windsurf':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', 'windsurf', configDir)];
break;
case 'augment':
kinds = [
commandsKind('commands', 'gsd-', configDir),
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir, true /* #69 nested */),
];
break;
case 'trae':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir, true /* #69 nested */)];
break;
case 'qwen':
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir, true /* #69 nested */)];
break;
case 'hermes':
// #947: restore canonical gsd- prefix — skills land at skills/gsd/gsd-<stem>/SKILL.md
// and dispatch as /gsd-<stem>, consistent with every other runtime.
// The skills/gsd/ category bucket (introduced by #2841) is retained.
// Prior bare-stem layout (prefix='') used by #3664 is reversed here.
kinds = [skillsKind('skills/gsd', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir, true /* #69 nested */)];
break;
case 'codebuddy':
// CodeBuddy (Tencent) reads two user-level surfaces (codebuddy.ai/docs/cli):
// 1. commands/gsd-<name>.md — slash commands shown in the '/' menu (#789)
// 2. skills/gsd-<name>/SKILL.md — model-invocable skills, emitted with
// user-invocable:false so they stay OUT of '/' (the commands surface is
// the sole '/' entry point) — avoids a duplicated /gsd-* per workflow.
// Subagents (~/.codebuddy/agents/) are already emitted by the generic agents
// block in bin/install.js; MCP is excluded (gsd ships no MCP server).
kinds = [
convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCodebuddyCommand', configDir),
skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir),
];
break;
case 'cline':
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : [];
break;
case 'kimi':
kinds = scope === 'global'
? [
skillsKind('skills', 'gsd-', 'convertClaudeCommandToKimiSkill', 'kimi', configDir),
kimiAgentsKind('agents', 'gsd', configDir),
]
: [];
break;
case 'opencode':
// OpenCode reads flat slash commands from command/ and on-demand skills
// from skills/<name>/SKILL.md (https://opencode.ai/docs/skills). Emit both.
kinds = [
commandsKind('command', 'gsd-', configDir),
skillsKind('skills', 'gsd-', 'convertClaudeCommandToOpencodeSkill', 'opencode', configDir),
];
break;
case 'kilo':
// Kilo derives from OpenCode and shares the skills/<name>/SKILL.md layout
// (https://kilo.ai/docs/customize/skills). Emit flat commands + skills.
kinds = [
commandsKind('command', 'gsd-', configDir),
skillsKind('skills', 'gsd-', 'convertClaudeCommandToKiloSkill', 'kilo', configDir),
];
break;
default:
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
const desc = getRegistry().runtimes[runtime]?.runtime?.artifactLayout;
if (!desc) {
// Runtime is in ALLOWED_RUNTIMES but has no descriptor — reproduce old default: throw.
throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`);
}
const entries: ArtifactKindDescriptor[] = desc[scope] ?? [];
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir));
return { runtime, configDir, scope, kinds };
}

View File

@@ -0,0 +1,346 @@
'use strict';
/**
* Equivalence proof for ADR-857 phase 5d: descriptor-driven resolveRuntimeArtifactLayout.
*
* For every runtime in the 16-entry capability registry × {global, local} scopes,
* this test asserts that:
* 1. kind.kind, kind.destSubpath, kind.prefix are byte-identical to the STEP-0
* golden captured from the old switch() before any edits.
* 2. typeof kind.stage === 'function' for every kind.
* 3. layout.runtime === runtime, layout.configDir === configDir, layout.scope === scope.
*
* SCOPE-FALL-THROUGH NOTE:
* The old switch() had no scope branches for 13 runtimes (cursor, gemini, codex,
* copilot, antigravity, windsurf, augment, trae, qwen, hermes, codebuddy, opencode,
* kilo), meaning scope='local' returned the same kinds as scope='global'. The 5a
* descriptors incorrectly set local:[] for those runtimes, causing 31 local-install
* test regressions. The 5b backfill sets local == global for these 13, restoring
* the old switch's scope-agnostic behaviour.
*
* For runtimes that had explicit scope branches in the old switch
* (claude: distinct local=commands+agents; cline: local=[]; kimi: local=[]),
* the STEP-0 golden matches the descriptor exactly and is left unchanged.
*
* Unknown runtime case:
* ALLOWED_RUNTIMES guard throws TypeError BEFORE the descriptor lookup,
* so unknown runtimes still throw:
* TypeError: Unknown runtime: 'grok' — add to runtime-artifact-layout.cjs table
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { resolveRuntimeArtifactLayout } = require(
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'),
);
const FAKE_DIR = '/tmp/fake-config-dir-dd';
// ── STEP-0 golden (captured from switch BEFORE edits) ────────────────────────
// Format: { kind, destSubpath, prefix } for each entry in kinds[].
// 'function' means we assert typeof kind.stage === 'function'.
const GOLDEN = {
// ── claude ──────────────────────────────────────────────────────────────────
'claude/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'claude/local': [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── cursor ───────────────────────────────────────────────────────────────────
// Old switch: BOTH scopes returned [skills, commands] (no scope branch).
// 5b backfill: local == global.
'cursor/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
],
'cursor/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
],
// ── gemini ───────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'gemini/global': [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
],
'gemini/local': [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
],
// ── codex ────────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'codex/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'codex/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── copilot ──────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'copilot/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'copilot/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── antigravity ──────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'antigravity/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'antigravity/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── windsurf ─────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'windsurf/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'windsurf/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── augment ──────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'augment/global': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'augment/local': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── trae ─────────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'trae/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'trae/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── qwen ─────────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'qwen/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'qwen/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── hermes ───────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'hermes/global': [
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
],
'hermes/local': [
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
],
// ── codebuddy ────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'codebuddy/global': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'codebuddy/local': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── cline ────────────────────────────────────────────────────────────────────
// Old switch: scope='global' → [skills]; scope='local' → []. Matches descriptor.
'cline/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'cline/local': [],
// ── kimi ─────────────────────────────────────────────────────────────────────
// Old switch: scope='global' → [skills, kimi-agents]; scope='local' → []. Matches descriptor.
'kimi/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'kimi-agents', destSubpath: 'agents', prefix: 'gsd' },
],
'kimi/local': [],
// ── opencode ─────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'opencode/global': [
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'opencode/local': [
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
// ── kilo ─────────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
'kilo/global': [
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
'kilo/local': [
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
],
};
// ── Parametrized tests ────────────────────────────────────────────────────────
const RUNTIMES = [
'claude', 'cursor', 'gemini', 'codex', 'copilot',
'antigravity', 'windsurf', 'augment', 'trae', 'qwen',
'hermes', 'codebuddy', 'cline', 'kimi', 'opencode', 'kilo',
];
for (const runtime of RUNTIMES) {
for (const scope of ['global', 'local']) {
const key = `${runtime}/${scope}`;
const expected = GOLDEN[key];
assert.ok(
expected !== undefined,
`GOLDEN missing entry for ${key} — update the golden table`,
);
describe(`resolveRuntimeArtifactLayout — ${runtime} ${scope} (descriptor-driven)`, () => {
test(`kinds array matches STEP-0 golden for ${runtime}/${scope}`, () => {
const layout = resolveRuntimeArtifactLayout(runtime, FAKE_DIR, /** @type {'local'|'global'} */ (scope));
// Structural fields
assert.strictEqual(layout.runtime, runtime, 'layout.runtime');
assert.strictEqual(layout.configDir, FAKE_DIR, 'layout.configDir');
assert.strictEqual(layout.scope, scope, 'layout.scope');
// kinds length matches golden
assert.strictEqual(
layout.kinds.length,
expected.length,
`kinds.length for ${key}: expected ${expected.length}, got ${layout.kinds.length}`,
);
// Per-kind field checks
for (let i = 0; i < expected.length; i++) {
const actual = layout.kinds[i];
const exp = expected[i];
assert.strictEqual(
actual.kind,
exp.kind,
`kinds[${i}].kind for ${key}`,
);
assert.strictEqual(
actual.destSubpath,
exp.destSubpath,
`kinds[${i}].destSubpath for ${key}`,
);
assert.strictEqual(
actual.prefix,
exp.prefix,
`kinds[${i}].prefix for ${key}`,
);
assert.strictEqual(
typeof actual.stage,
'function',
`kinds[${i}].stage must be a function for ${key}`,
);
}
});
});
}
}
// ── Unknown runtime ───────────────────────────────────────────────────────────
// ALLOWED_RUNTIMES guard fires BEFORE descriptor lookup — reproduces old behaviour.
describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', () => {
test('throws TypeError for grok (not in ALLOWED_RUNTIMES)', () => {
assert.throws(
() => resolveRuntimeArtifactLayout('grok', FAKE_DIR, 'global'),
(err) => {
assert.ok(err instanceof TypeError, 'must be TypeError');
assert.ok(
err.message.includes("Unknown runtime: 'grok'"),
`message must include "Unknown runtime: 'grok'" — got: ${err.message}`,
);
return true;
},
);
});
test('throws TypeError for an arbitrary unknown string', () => {
assert.throws(
() => resolveRuntimeArtifactLayout('notaruntime', FAKE_DIR, 'global'),
(err) => {
assert.ok(err instanceof TypeError);
assert.ok(err.message.includes("Unknown runtime: 'notaruntime'"));
return true;
},
);
});
});
// ── Scope default ─────────────────────────────────────────────────────────────
// resolveRuntimeArtifactLayout(runtime, configDir) with no scope arg → 'global'.
describe('resolveRuntimeArtifactLayout — scope defaults to global (descriptor-driven)', () => {
test('omitting scope yields global layout for claude', () => {
const withDefault = resolveRuntimeArtifactLayout('claude', FAKE_DIR);
const withExplicit = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global');
assert.strictEqual(withDefault.scope, 'global', 'default scope must be "global"');
assert.strictEqual(withDefault.kinds.length, withExplicit.kinds.length);
for (let i = 0; i < withDefault.kinds.length; i++) {
assert.strictEqual(withDefault.kinds[i].kind, withExplicit.kinds[i].kind);
assert.strictEqual(withDefault.kinds[i].destSubpath, withExplicit.kinds[i].destSubpath);
assert.strictEqual(withDefault.kinds[i].prefix, withExplicit.kinds[i].prefix);
}
});
test('omitting scope yields global layout for kimi (2 kinds)', () => {
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR);
assert.strictEqual(layout.scope, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[1].kind, 'kimi-agents');
});
});
// ── Non-vacuous check: verify at least one multi-kind runtime ─────────────────
describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (descriptor-driven)', () => {
test('augment global returns 2 kinds (commands + skills)', () => {
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[1].kind, 'skills');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
});
test('kimi global returns skills then kimi-agents', () => {
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[1].kind, 'kimi-agents');
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
assert.strictEqual(layout.kinds[1].prefix, 'gsd');
});
test('codebuddy global returns commands then skills', () => {
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[1].kind, 'skills');
});
});