From 6968e04d8a7ce907f3ec8fdb6e194a69cb0d0a44 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 11 Jun 2026 12:08:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1040):=20phase=205b=20=E2=80=94=20drive?= =?UTF-8?q?=20configHome=20from=20the=20runtime=20descriptor=20=E2=80=94?= =?UTF-8?q?=20ADR-857/1016=20(#1043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1040): phase 5b — drive configHome from the runtime descriptor (runtime-homes switch → lookup) getGlobalConfigDir now resolves configHome from registry.runtimes[id].runtime.configHome via a single resolveConfigHomeFromDescriptor(configHome, {env, home, existsSync}) (dot-home / dot-home-nested / xdg / generic-agents-root), replacing the hardcoded 16-runtime switch. Equivalence-preserving: byte-identical config dirs for all 16 runtimes + grok + default + explicitDir (Codex-verified, no divergence). Nuances preserved: xdg env[1] is a FILE path → path.dirname; existsSync injection seam keeps antigravity/kimi probe tests hermetic; grok stays hardcoded (GROK_AGENTS_HOME → ~/.agents, not in the 16); copilot two-env fallback; explicitDir short-circuit. getGlobalSkillsBase unchanged (out of scope). Lazy require of the committed capability-registry.cjs (no circular load). Test env-clearing lists (install.test ENV_KEYS, bug-3126 envKeys) now derived from the registry runtime configHome.env arrays — auto-correct, closes the missing KIMI_CONFIG_DIR gap. New 81-case golden equivalence test. Closes #1040 Co-Authored-By: Claude Opus 4.8 * test(#1040): make windsurf golden path Windows-portable (path.join, not POSIX literal) The dot-home-nested windsurf equivalence case hardcoded '/home/u/.codeium/windsurf' but the resolver builds it via path.join(home,parent,name) → backslashes on Windows. Use path.join for the expected value. Test-only; production resolver unchanged. Defensive scan confirmed it was the only path.join-derived hardcoded literal. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- src/runtime-homes.cts | 299 +++++--- ...6-global-skills-base-runtime-path.test.cjs | 18 +- tests/install.test.cjs | 20 +- tests/runtime-homes-descriptor-drive.test.cjs | 665 ++++++++++++++++++ 4 files changed, 874 insertions(+), 128 deletions(-) create mode 100644 tests/runtime-homes-descriptor-drive.test.cjs diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 9eaf68637..f41dc917f 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -46,27 +46,163 @@ export interface ResolveKimiOpts { existsSync?: (p: string) => boolean; } +export interface ResolveConfigHomeOpts { + env?: Record; + home?: string; + existsSync?: (p: string) => boolean; +} + +// ── Descriptor shapes (mirroring the registry types) ────────────────────── + +interface DotHomeDescriptor { + kind: 'dot-home'; + name: string; + env: string[]; +} + +interface DotHomeNestedDescriptor { + kind: 'dot-home-nested'; + name: string; + parent: string; + env: string[]; + probe?: string[]; +} + +interface XdgDescriptor { + kind: 'xdg'; + name: string; + env: string[]; + skillsHome?: unknown; +} + +interface GenericAgentsRootDescriptor { + kind: 'generic-agents-root'; + name: string; + env: string[]; + probe: string[]; + probeExists: string; +} + +type ConfigHomeDescriptor = + | DotHomeDescriptor + | DotHomeNestedDescriptor + | XdgDescriptor + | GenericAgentsRootDescriptor; + +/** + * Resolve a configHome descriptor to an absolute directory path. + * + * Implements the four descriptor kinds: + * - dot-home: env-override → path.join(home, name) + * - dot-home-nested: env-override → probed subdir of path.join(home, parent) + * - xdg: env[0] → env[1](dirname) → env[2](XDG subdir) → ~/.config/ + * - generic-agents-root:env[0] → first probe where probeExists exists → probe[0] + */ +export function resolveConfigHomeFromDescriptor( + configHome: ConfigHomeDescriptor, + opts: ResolveConfigHomeOpts = {}, +): string { + const env: Record = opts.env ?? process.env; + const home = opts.home ?? os.homedir(); + const existsSyncFn = opts.existsSync ?? fs.existsSync; + + switch (configHome.kind) { + case 'dot-home': { + // First env var that is set wins + for (const varName of configHome.env) { + const val = env[varName]; + if (val) return expandTilde(val); + } + return path.join(home, configHome.name); + } + + case 'dot-home-nested': { + // env override + const nestedEnv0Val = env[configHome.env[0]]; + if (configHome.env[0] && nestedEnv0Val) { + return expandTilde(nestedEnv0Val); + } + const base = path.join(home, configHome.parent); + if (configHome.probe && configHome.probe.length > 0) { + // probe each candidate under base; return first that exists + for (const candidate of configHome.probe) { + const resolved = path.join(base, candidate); + if (existsSyncFn(resolved)) return resolved; + } + // fallback: first probe candidate + return path.join(base, configHome.probe[0]); + } + // no probe (e.g. windsurf): always name under parent + return path.join(base, configHome.name); + } + + case 'xdg': { + // env[0]: direct override dir + const xdgEnv0Val = env[configHome.env[0]]; + if (configHome.env[0] && xdgEnv0Val) { + return expandTilde(xdgEnv0Val); + } + // env[1]: FILE path → dirname + const xdgEnv1Val = env[configHome.env[1]]; + if (configHome.env[1] && xdgEnv1Val) { + return path.dirname(expandTilde(xdgEnv1Val)); + } + // env[2]: XDG_CONFIG_HOME → subdir + const xdgEnv2Val = env[configHome.env[2]]; + if (configHome.env[2] && xdgEnv2Val) { + return path.join(expandTilde(xdgEnv2Val), configHome.name); + } + return path.join(home, '.config', configHome.name); + } + + case 'generic-agents-root': { + // env override + const garEnv0Val = env[configHome.env[0]]; + if (configHome.env[0] && garEnv0Val) { + return expandTilde(garEnv0Val); + } + // probe each candidate; return first where probeExists subpath exists + for (const candidate of configHome.probe) { + const resolved = expandTildeWithHome(candidate, home); + if (existsSyncFn(path.join(resolved, configHome.probeExists))) { + return resolved; + } + } + // fallback: first probe candidate + return expandTildeWithHome(configHome.probe[0], home); + } + } +} + +/** + * Expand ~ using an explicit home directory (for hermetic testing). + */ +function expandTildeWithHome(p: string, home: string): string { + if (!p) return p; + if (p.startsWith('~/') || p === '~') return path.join(home, p.slice(1)); + return p; +} + /** * Resolve Antigravity global config dir across 1.x and 2.x layouts. + * + * Thin wrapper delegating to resolveConfigHomeFromDescriptor with the + * antigravity descriptor shape. Preserved for external callers and tests. */ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}): string { const env: Record = opts.env ?? process.env; const home = opts.home ?? os.homedir(); const existsSyncFn = opts.existsSync ?? fs.existsSync; - - if (env['ANTIGRAVITY_CONFIG_DIR']) return expandTilde(env['ANTIGRAVITY_CONFIG_DIR']); - - const base = path.join(home, '.gemini'); - const candidates = [ - path.join(base, 'antigravity'), - path.join(base, 'antigravity-ide'), - path.join(base, 'antigravity-cli'), - ]; - for (const candidate of candidates) { - if (existsSyncFn(candidate)) return candidate; - } - - return path.join(base, 'antigravity'); + return resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env, home, existsSync: existsSyncFn }, + ); } /** @@ -83,22 +219,24 @@ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}): * KIMI_CONFIG_DIR is a GSD installer write-location override. It is not Kimi's * upstream data-root variable, and arbitrary roots are discoverable by Kimi only * when the user also configures Kimi --skills-dir or extra_skill_dirs. + * + * Thin wrapper delegating to resolveConfigHomeFromDescriptor with the + * kimi descriptor shape. Preserved for external callers and tests. */ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string { const env: Record = opts.env ?? process.env; const home = opts.home ?? os.homedir(); const existsSyncFn = opts.existsSync ?? fs.existsSync; - - if (env['KIMI_CONFIG_DIR']) return expandTilde(env['KIMI_CONFIG_DIR']); - - const recommendedRoot = path.join(home, '.config', 'agents'); - const fallbackRoot = path.join(home, '.agents'); - const candidates = [recommendedRoot, fallbackRoot]; - for (const candidate of candidates) { - if (existsSyncFn(path.join(candidate, 'skills'))) return candidate; - } - - return recommendedRoot; + return resolveConfigHomeFromDescriptor( + { + kind: 'generic-agents-root', + name: 'agents', + env: ['KIMI_CONFIG_DIR'], + probe: ['~/.config/agents', '~/.agents'], + probeExists: 'skills', + }, + { env, home, existsSync: existsSyncFn }, + ); } /** @@ -113,95 +251,30 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string { export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string { if (explicitDir) return expandTilde(explicitDir); - const home = os.homedir(); - const env = process.env as Record; - - switch (runtime) { - // ── Claude Code ────────────────────────────────────────────────────────── - case 'claude': - return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(home, '.claude'); - - // ── Cursor ─────────────────────────────────────────────────────────────── - case 'cursor': - return env['CURSOR_CONFIG_DIR'] ? expandTilde(env['CURSOR_CONFIG_DIR']) : path.join(home, '.cursor'); - - // ── Gemini CLI ─────────────────────────────────────────────────────────── - case 'gemini': - return env['GEMINI_CONFIG_DIR'] ? expandTilde(env['GEMINI_CONFIG_DIR']) : path.join(home, '.gemini'); - - // ── Codex ──────────────────────────────────────────────────────────────── - case 'codex': - return env['CODEX_HOME'] ? expandTilde(env['CODEX_HOME']) : path.join(home, '.codex'); - - // ── Grok Build ─────────────────────────────────────────────────────────── - case 'grok': - return env['GROK_AGENTS_HOME'] ? expandTilde(env['GROK_AGENTS_HOME']) : path.join(home, '.agents'); - - // ── Copilot (VS Code) ──────────────────────────────────────────────────── - case 'copilot': - if (env['COPILOT_CONFIG_DIR']) return expandTilde(env['COPILOT_CONFIG_DIR']); - if (env['COPILOT_HOME']) return expandTilde(env['COPILOT_HOME']); - return path.join(home, '.copilot'); - - // ── Antigravity ────────────────────────────────────────────────────────── - case 'antigravity': - return resolveAntigravityGlobalDir({ env, home }); - - // ── Windsurf ───────────────────────────────────────────────────────────── - case 'windsurf': - return env['WINDSURF_CONFIG_DIR'] - ? expandTilde(env['WINDSURF_CONFIG_DIR']) - : path.join(home, '.codeium', 'windsurf'); - - // ── Augment ────────────────────────────────────────────────────────────── - case 'augment': - return env['AUGMENT_CONFIG_DIR'] ? expandTilde(env['AUGMENT_CONFIG_DIR']) : path.join(home, '.augment'); - - // ── Trae ───────────────────────────────────────────────────────────────── - case 'trae': - return env['TRAE_CONFIG_DIR'] ? expandTilde(env['TRAE_CONFIG_DIR']) : path.join(home, '.trae'); - - // ── Qwen Code ──────────────────────────────────────────────────────────── - case 'qwen': - return env['QWEN_CONFIG_DIR'] ? expandTilde(env['QWEN_CONFIG_DIR']) : path.join(home, '.qwen'); - - // ── Hermes Agent ───────────────────────────────────────────────────────── - case 'hermes': - return env['HERMES_HOME'] ? expandTilde(env['HERMES_HOME']) : path.join(home, '.hermes'); - - // ── CodeBuddy ──────────────────────────────────────────────────────────── - case 'codebuddy': - return env['CODEBUDDY_CONFIG_DIR'] ? expandTilde(env['CODEBUDDY_CONFIG_DIR']) : path.join(home, '.codebuddy'); - - // ── Cline ──────────────────────────────────────────────────────────────── - case 'cline': - return env['CLINE_CONFIG_DIR'] ? expandTilde(env['CLINE_CONFIG_DIR']) : path.join(home, '.cline'); - - // ── Kimi CLI (generic agents user root) ──────────────────────────────── - case 'kimi': { - return resolveKimiGlobalDir({ env, home }); - } - - // ── OpenCode (XDG) ─────────────────────────────────────────────────────── - case 'opencode': { - if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']); - if (env['OPENCODE_CONFIG']) return path.dirname(expandTilde(env['OPENCODE_CONFIG'])); - if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'opencode'); - return path.join(home, '.config', 'opencode'); - } - - // ── Kilo (XDG) ─────────────────────────────────────────────────────────── - case 'kilo': { - if (env['KILO_CONFIG_DIR']) return expandTilde(env['KILO_CONFIG_DIR']); - if (env['KILO_CONFIG']) return path.dirname(expandTilde(env['KILO_CONFIG'])); - if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'kilo'); - return path.join(home, '.config', 'kilo'); - } - - // ── Default (Claude fallback) ───────────────────────────────────────────── - default: - return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(home, '.claude'); + // ── Grok: not in the registry — hardcoded branch ───────────────────────── + if (runtime === 'grok') { + const env = process.env as Record; + return env['GROK_AGENTS_HOME'] ? expandTilde(env['GROK_AGENTS_HOME']) : path.join(os.homedir(), '.agents'); } + + // ── Descriptor-driven: look up in capability-registry ──────────────────── + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runtimes } = require('./capability-registry.cjs') as { + runtimes: Record; + }; + + const runtimeEntry = runtimes[runtime]; + if (runtimeEntry?.runtime?.configHome) { + return resolveConfigHomeFromDescriptor(runtimeEntry.runtime.configHome, { + env: process.env, + home: os.homedir(), + existsSync: fs.existsSync, + }); + } + + // ── Default (unknown runtime → Claude fallback) ─────────────────────────── + const env = process.env as Record; + return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(os.homedir(), '.claude'); } /** diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index dc9637dd1..fe801df04 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -61,13 +61,17 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => { ]; for (const [runtime, expected] of defaults) { test(`${runtime} default configDir`, () => { - // Clear all env vars for this runtime - const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR', - 'CODEX_HOME','COPILOT_CONFIG_DIR','COPILOT_HOME','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR', - 'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME', - 'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG', - 'KILO_CONFIG_DIR','KILO_CONFIG', - 'XDG_CONFIG_HOME']; + // Derive env-var list from the registry so new runtimes are auto-covered. + // GROK_AGENTS_HOME is kept explicitly (grok has no registry entry). + const { runtimes: _reg3126 } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); + const _regEnvKeys3126 = Object.values(_reg3126).flatMap((r) => { + const ch = r.runtime?.configHome; + if (!ch) return []; + const envs = Array.isArray(ch.env) ? ch.env : []; + const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : []; + return [...envs, ...skillsEnvs]; + }); + const envKeys = [...new Set([..._regEnvKeys3126, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])]; const saved = {}; for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; } try { diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 93f634611..b4c97d09d 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -68,14 +68,18 @@ describe('getDirName — all runtimes', () => { }); describe('getGlobalConfigDir — all runtimes default paths', () => { - // Test the default (no env var, no explicit dir) for each runtime - const ENV_KEYS = [ - 'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME', - 'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'COPILOT_HOME', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', - 'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR', - 'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', - 'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME', - ]; + // Derive env-var list from the registry so it stays auto-correct when new + // runtimes are added. GROK_AGENTS_HOME is kept explicitly because grok has + // no registry entry. + const { runtimes: _registryRuntimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + const _registryEnvKeys = Object.values(_registryRuntimes).flatMap((r) => { + const ch = r.runtime?.configHome; + if (!ch) return []; + const envs = Array.isArray(ch.env) ? ch.env : []; + const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : []; + return [...envs, ...skillsEnvs]; + }); + const ENV_KEYS = [...new Set([..._registryEnvKeys, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])]; let savedEnv = {}; beforeEach(() => { diff --git a/tests/runtime-homes-descriptor-drive.test.cjs b/tests/runtime-homes-descriptor-drive.test.cjs new file mode 100644 index 000000000..6f36d31da --- /dev/null +++ b/tests/runtime-homes-descriptor-drive.test.cjs @@ -0,0 +1,665 @@ +'use strict'; + +/** + * Equivalence proof for ADR-857 phase 5b: descriptor-driven getGlobalConfigDir. + * + * For every runtime in the 16-entry capability registry, plus grok and unknown + * runtime, this test asserts that getGlobalConfigDir() produces exactly the + * same path that the old hardcoded switch produced (golden expected values + * captured from the switch BEFORE any edits). All assertions are byte-identical. + * + * The injected opts seam on resolveConfigHomeFromDescriptor is used to control: + * - the env record (avoid ambient env var pollution) + * - the home directory (make tests hermetic) + * - existsSync (control probe-hit / probe-miss scenarios) + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const { cleanup } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +const { + getGlobalConfigDir, + resolveAntigravityGlobalDir, + resolveKimiGlobalDir, + resolveConfigHomeFromDescriptor, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); + +const HOME = os.homedir(); + +// ── Helper: run fn with process.env temporarily mutated ────────────────────── + +function withEnv(overrides, fn) { + const saved = {}; + for (const [k, v] of Object.entries(overrides)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + return fn(); + } finally { + for (const [k] of Object.entries(overrides)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +// All env vars for all runtimes — cleared in each test that calls getGlobalConfigDir directly +const ALL_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME', + 'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'COPILOT_HOME', 'ANTIGRAVITY_CONFIG_DIR', + 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', + 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR', 'KIMI_CONFIG_DIR', + 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', + 'XDG_CONFIG_HOME', +]; + +function clearAllEnvKeys() { + const saved = {}; + for (const k of ALL_ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + return saved; +} + +function restoreEnvKeys(saved) { + for (const k of ALL_ENV_KEYS) { + if (saved[k] !== undefined) process.env[k] = saved[k]; + else delete process.env[k]; + } +} + +// ── STEP 0: golden scenarios captured from old switch BEFORE edits ──────────── + +// GOLDEN DEFAULTS (no env vars set, no existsSync probe hits). +// kimi is NOT included here because it depends on real filesystem probing — +// its probe-miss/hit scenarios are covered separately via injected existsSync. +// antigravity default also depends on probing; the default assumes NO dirs exist. +const GOLDEN_DEFAULTS = { + claude: path.join(HOME, '.claude'), + cursor: path.join(HOME, '.cursor'), + gemini: path.join(HOME, '.gemini'), + codex: path.join(HOME, '.codex'), + grok: path.join(HOME, '.agents'), + copilot: path.join(HOME, '.copilot'), + antigravity: path.join(HOME, '.gemini', 'antigravity'), // probe-miss → first candidate + windsurf: path.join(HOME, '.codeium', 'windsurf'), + augment: path.join(HOME, '.augment'), + trae: path.join(HOME, '.trae'), + qwen: path.join(HOME, '.qwen'), + hermes: path.join(HOME, '.hermes'), + codebuddy: path.join(HOME, '.codebuddy'), + cline: path.join(HOME, '.cline'), + opencode: path.join(HOME, '.config', 'opencode'), + kilo: path.join(HOME, '.config', 'kilo'), +}; + +// ── GOLDEN DEFAULTS ──────────────────────────────────────────────────────────── + +describe('descriptor-driven equivalence: defaults (no env vars, no probe hits)', () => { + // kimi is excluded: its default depends on real filesystem probing (probe-hit/miss + // vary by machine). kimi probe scenarios are covered in the generic-agents-root suite + // with injected existsSync. + // antigravity is excluded: it also depends on real fs probing (probe candidates + // ~/.gemini/antigravity, ~/.gemini/antigravity-ide, ~/.gemini/antigravity-cli); + // a machine that has antigravity-ide or antigravity-cli but not antigravity gets a + // different result. antigravity probe scenarios are covered in the dot-home-nested + // suite with injected existsSync. + for (const [runtime, expected] of Object.entries(GOLDEN_DEFAULTS).filter( + ([r]) => r !== 'antigravity', + )) { + test(`${runtime} default → ${expected}`, () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalConfigDir(runtime), expected); + } finally { + restoreEnvKeys(saved); + } + }); + } + + test('unknown runtime falls back to ~/.claude (CLAUDE_CONFIG_DIR unset)', () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalConfigDir('totally-unknown-runtime-xyz'), path.join(HOME, '.claude')); + } finally { + restoreEnvKeys(saved); + } + }); +}); + +// ── GOLDEN ENV OVERRIDES ────────────────────────────────────────────────────── + +describe('descriptor-driven equivalence: env-var overrides', () => { + const cases = [ + { runtime: 'claude', envKey: 'CLAUDE_CONFIG_DIR', value: '/custom/claude' }, + { runtime: 'cursor', envKey: 'CURSOR_CONFIG_DIR', value: '/custom/cursor' }, + { runtime: 'gemini', envKey: 'GEMINI_CONFIG_DIR', value: '/custom/gemini' }, + { runtime: 'codex', envKey: 'CODEX_HOME', value: '/custom/codex' }, + { runtime: 'grok', envKey: 'GROK_AGENTS_HOME', value: '/custom/grok' }, + { runtime: 'augment', envKey: 'AUGMENT_CONFIG_DIR', value: '/custom/augment' }, + { runtime: 'trae', envKey: 'TRAE_CONFIG_DIR', value: '/custom/trae' }, + { runtime: 'qwen', envKey: 'QWEN_CONFIG_DIR', value: '/custom/qwen' }, + { runtime: 'hermes', envKey: 'HERMES_HOME', value: '/custom/hermes' }, + { runtime: 'codebuddy', envKey: 'CODEBUDDY_CONFIG_DIR', value: '/custom/codebuddy' }, + { runtime: 'cline', envKey: 'CLINE_CONFIG_DIR', value: '/custom/cline' }, + { runtime: 'windsurf', envKey: 'WINDSURF_CONFIG_DIR', value: '/custom/windsurf' }, + { runtime: 'antigravity', envKey: 'ANTIGRAVITY_CONFIG_DIR', value: '/custom/antigravity' }, + { runtime: 'kimi', envKey: 'KIMI_CONFIG_DIR', value: '/custom/kimi' }, + { runtime: 'opencode', envKey: 'OPENCODE_CONFIG_DIR', value: '/custom/opencode' }, + { runtime: 'kilo', envKey: 'KILO_CONFIG_DIR', value: '/custom/kilo' }, + ]; + + for (const { runtime, envKey, value } of cases) { + test(`${runtime}: ${envKey} override → ${value}`, () => { + const saved = clearAllEnvKeys(); + process.env[envKey] = value; + try { + assert.strictEqual(getGlobalConfigDir(runtime), value); + } finally { + restoreEnvKeys(saved); + } + }); + } + + // copilot: COPILOT_CONFIG_DIR takes precedence over COPILOT_HOME + test('copilot: COPILOT_CONFIG_DIR override (first env wins)', () => { + const saved = clearAllEnvKeys(); + process.env['COPILOT_CONFIG_DIR'] = '/custom/copilot-dir'; + process.env['COPILOT_HOME'] = '/should/not/win'; + try { + assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-dir'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('copilot: COPILOT_HOME fallback when COPILOT_CONFIG_DIR absent', () => { + const saved = clearAllEnvKeys(); + process.env['COPILOT_HOME'] = '/custom/copilot-home'; + try { + assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-home'); + } finally { + restoreEnvKeys(saved); + } + }); +}); + +// ── GOLDEN TILDE EXPANSION ───────────────────────────────────────────────────── + +describe('descriptor-driven equivalence: tilde expansion in env overrides', () => { + test('claude: CLAUDE_CONFIG_DIR=~/foo expands to homedir/foo', () => { + withEnv({ CLAUDE_CONFIG_DIR: '~/foo' }, () => { + assert.strictEqual(getGlobalConfigDir('claude'), path.join(HOME, 'foo')); + }); + }); + + test('kimi: KIMI_CONFIG_DIR=~/kimi expands to homedir/kimi', () => { + withEnv({ KIMI_CONFIG_DIR: '~/kimi' }, () => { + assert.strictEqual(getGlobalConfigDir('kimi'), path.join(HOME, 'kimi')); + }); + }); +}); + +// ── GOLDEN XDG SCENARIOS ────────────────────────────────────────────────────── + +describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => { + // opencode + test('opencode: OPENCODE_CONFIG (file-path) → dirname', () => { + const saved = clearAllEnvKeys(); + process.env['OPENCODE_CONFIG'] = '/home/u/cfg/opencode.json'; + try { + assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => { + const saved = clearAllEnvKeys(); + process.env['OPENCODE_CONFIG_DIR'] = '/dir/wins'; + process.env['OPENCODE_CONFIG'] = '/file/loses.json'; + try { + assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => { + const saved = clearAllEnvKeys(); + process.env['OPENCODE_CONFIG'] = '/cfg/opencode.json'; + process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose'; + try { + assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('opencode: XDG_CONFIG_HOME → ~/.config/opencode subdir', () => { + const saved = clearAllEnvKeys(); + process.env['XDG_CONFIG_HOME'] = '/xdg'; + try { + assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode')); + } finally { + restoreEnvKeys(saved); + } + }); + + test('opencode: tilde in OPENCODE_CONFIG → dirname expands tilde', () => { + const saved = clearAllEnvKeys(); + process.env['OPENCODE_CONFIG'] = '~/cfg/opencode.json'; + try { + assert.strictEqual(getGlobalConfigDir('opencode'), path.join(HOME, 'cfg')); + } finally { + restoreEnvKeys(saved); + } + }); + + // kilo + test('kilo: KILO_CONFIG (file-path) → dirname', () => { + const saved = clearAllEnvKeys(); + process.env['KILO_CONFIG'] = '/home/u/cfg/kilo.json'; + try { + assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => { + const saved = clearAllEnvKeys(); + process.env['KILO_CONFIG_DIR'] = '/dir/wins'; + process.env['KILO_CONFIG'] = '/file/loses.json'; + try { + assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => { + const saved = clearAllEnvKeys(); + process.env['KILO_CONFIG'] = '/cfg/kilo.json'; + process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose'; + try { + assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg'); + } finally { + restoreEnvKeys(saved); + } + }); + + test('kilo: XDG_CONFIG_HOME → ~/.config/kilo subdir', () => { + const saved = clearAllEnvKeys(); + process.env['XDG_CONFIG_HOME'] = '/xdg'; + try { + assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo')); + } finally { + restoreEnvKeys(saved); + } + }); +}); + +// ── GOLDEN DOT-HOME-NESTED (antigravity probe) ──────────────────────────────── + +describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/miss', () => { + test('antigravity probe-miss → ~/.gemini/antigravity (first candidate)', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-antigravity-miss-')); + try { + // no candidates exist → fallback to first + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env: {}, home: tmpHome, existsSync: () => false }, + ); + assert.strictEqual(result, path.join(tmpHome, '.gemini', 'antigravity')); + } finally { + cleanup(tmpHome); + } + }); + + test('antigravity probe-hit antigravity → returns ~/.gemini/antigravity', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-antigravity-hit-')); + try { + const hitPath = path.join(tmpHome, '.gemini', 'antigravity'); + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env: {}, home: tmpHome, existsSync: (p) => p === hitPath }, + ); + assert.strictEqual(result, hitPath); + } finally { + cleanup(tmpHome); + } + }); + + test('antigravity probe-hit antigravity-ide → returns ~/.gemini/antigravity-ide', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-antigravity-ide-')); + try { + const hitPath = path.join(tmpHome, '.gemini', 'antigravity-ide'); + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env: {}, home: tmpHome, existsSync: (p) => p === hitPath }, + ); + assert.strictEqual(result, hitPath); + } finally { + cleanup(tmpHome); + } + }); + + test('antigravity probe-hit antigravity-cli (only cli exists) → returns ~/.gemini/antigravity-cli', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-antigravity-cli-')); + try { + const hitPath = path.join(tmpHome, '.gemini', 'antigravity-cli'); + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env: {}, home: tmpHome, existsSync: (p) => p === hitPath }, + ); + assert.strictEqual(result, hitPath); + } finally { + cleanup(tmpHome); + } + }); + + test('antigravity: ANTIGRAVITY_CONFIG_DIR env override wins over any probe', () => { + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'antigravity', + parent: '.gemini', + env: ['ANTIGRAVITY_CONFIG_DIR'], + probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'], + }, + { env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' }, home: '/home/u', existsSync: () => true }, + ); + assert.strictEqual(result, '/custom/ag'); + }); + + test('windsurf (no probe) → ~/.codeium/windsurf regardless of existsSync', () => { + const result = resolveConfigHomeFromDescriptor( + { + kind: 'dot-home-nested', + name: 'windsurf', + parent: '.codeium', + env: ['WINDSURF_CONFIG_DIR'], + }, + { env: {}, home: '/home/u', existsSync: () => true }, + ); + assert.strictEqual(result, path.join('/home/u', '.codeium', 'windsurf')); + }); +}); + +// ── GOLDEN GENERIC-AGENTS-ROOT (kimi probe) ─────────────────────────────────── + +describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss', () => { + test('kimi probe-miss → recommended root ~/.config/agents', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-kimi-miss-')); + try { + const result = resolveConfigHomeFromDescriptor( + { + kind: 'generic-agents-root', + name: 'agents', + env: ['KIMI_CONFIG_DIR'], + probe: ['~/.config/agents', '~/.agents'], + probeExists: 'skills', + }, + { env: {}, home: tmpHome, existsSync: () => false }, + ); + assert.strictEqual(result, path.join(tmpHome, '.config', 'agents')); + } finally { + cleanup(tmpHome); + } + }); + + test('kimi probe-hit on recommended root ~/.config/agents/skills', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-kimi-recommended-')); + try { + const recommended = path.join(tmpHome, '.config', 'agents'); + const result = resolveConfigHomeFromDescriptor( + { + kind: 'generic-agents-root', + name: 'agents', + env: ['KIMI_CONFIG_DIR'], + probe: ['~/.config/agents', '~/.agents'], + probeExists: 'skills', + }, + { + env: {}, + home: tmpHome, + existsSync: (p) => p === path.join(recommended, 'skills'), + }, + ); + assert.strictEqual(result, recommended); + } finally { + cleanup(tmpHome); + } + }); + + test('kimi probe-hit on fallback ~/.agents/skills (recommended does not exist)', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-kimi-fallback-')); + try { + const fallback = path.join(tmpHome, '.agents'); + const result = resolveConfigHomeFromDescriptor( + { + kind: 'generic-agents-root', + name: 'agents', + env: ['KIMI_CONFIG_DIR'], + probe: ['~/.config/agents', '~/.agents'], + probeExists: 'skills', + }, + { + env: {}, + home: tmpHome, + existsSync: (p) => p === path.join(fallback, 'skills'), + }, + ); + assert.strictEqual(result, fallback); + } finally { + cleanup(tmpHome); + } + }); + + test('kimi: KIMI_CONFIG_DIR env override wins over any probe', () => { + const result = resolveConfigHomeFromDescriptor( + { + kind: 'generic-agents-root', + name: 'agents', + env: ['KIMI_CONFIG_DIR'], + probe: ['~/.config/agents', '~/.agents'], + probeExists: 'skills', + }, + { env: { KIMI_CONFIG_DIR: '/custom/kimi' }, home: '/home/u', existsSync: () => true }, + ); + assert.strictEqual(result, '/custom/kimi'); + }); + + // Verify resolveKimiGlobalDir wrapper delegates correctly + test('resolveKimiGlobalDir wrapper: probe-miss → recommended root', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-rkgd-miss-')); + try { + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: () => false }), + path.join(tmpHome, '.config', 'agents'), + ); + } finally { + cleanup(tmpHome); + } + }); + + test('resolveKimiGlobalDir wrapper: fallback probe-hit', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-rkgd-hit-')); + try { + const fallback = path.join(tmpHome, '.agents'); + assert.strictEqual( + resolveKimiGlobalDir({ + env: {}, + home: tmpHome, + existsSync: (p) => p === path.join(fallback, 'skills'), + }), + fallback, + ); + } finally { + cleanup(tmpHome); + } + }); + + // Verify resolveAntigravityGlobalDir wrapper delegates correctly + test('resolveAntigravityGlobalDir wrapper: probe-miss → ~/.gemini/antigravity', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-ragd-miss-')); + try { + assert.strictEqual( + resolveAntigravityGlobalDir({ env: {}, home: tmpHome, existsSync: () => false }), + path.join(tmpHome, '.gemini', 'antigravity'), + ); + } finally { + cleanup(tmpHome); + } + }); + + test('resolveAntigravityGlobalDir wrapper: probe-hit antigravity-ide', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-equiv-ragd-hit-')); + try { + const hitPath = path.join(tmpHome, '.gemini', 'antigravity-ide'); + assert.strictEqual( + resolveAntigravityGlobalDir({ + env: {}, + home: tmpHome, + existsSync: (p) => p === hitPath, + }), + hitPath, + ); + } finally { + cleanup(tmpHome); + } + }); +}); + +// ── GOLDEN EXPLICIT DIR OVERRIDE ────────────────────────────────────────────── + +describe('descriptor-driven equivalence: explicitDir short-circuit', () => { + test('explicitDir absolute path returned as-is (any runtime)', () => { + assert.strictEqual(getGlobalConfigDir('claude', '/tmp/explicit'), '/tmp/explicit'); + assert.strictEqual(getGlobalConfigDir('opencode', '/tmp/explicit'), '/tmp/explicit'); + assert.strictEqual(getGlobalConfigDir('kimi', '/tmp/explicit'), '/tmp/explicit'); + assert.strictEqual(getGlobalConfigDir('grok', '/tmp/explicit'), '/tmp/explicit'); + }); + + test('explicitDir with ~ is expanded', () => { + assert.strictEqual( + getGlobalConfigDir('claude', '~/foo'), + path.join(HOME, 'foo'), + ); + }); + + test('explicitDir wins even when env var is set', () => { + withEnv({ CLAUDE_CONFIG_DIR: '/should/not/win' }, () => { + assert.strictEqual(getGlobalConfigDir('claude', '/explicit/wins'), '/explicit/wins'); + }); + }); +}); + +// ── GOLDEN GROK (not in registry, hardcoded) ────────────────────────────────── + +describe('descriptor-driven equivalence: grok (not in registry)', () => { + test('grok default → ~/.agents', () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalConfigDir('grok'), path.join(HOME, '.agents')); + } finally { + restoreEnvKeys(saved); + } + }); + + test('grok: GROK_AGENTS_HOME override', () => { + withEnv({ GROK_AGENTS_HOME: '/custom/grok-agents' }, () => { + assert.strictEqual(getGlobalConfigDir('grok'), '/custom/grok-agents'); + }); + }); + + test('grok: GROK_AGENTS_HOME tilde expansion', () => { + withEnv({ GROK_AGENTS_HOME: '~/grok' }, () => { + assert.strictEqual(getGlobalConfigDir('grok'), path.join(HOME, 'grok')); + }); + }); +}); + +// ── GOLDEN UNKNOWN RUNTIME (Claude fallback) ────────────────────────────────── + +describe('descriptor-driven equivalence: unknown runtime fallback', () => { + test('unknown runtime → ~/.claude default', () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual(getGlobalConfigDir('no-such-runtime'), path.join(HOME, '.claude')); + } finally { + restoreEnvKeys(saved); + } + }); + + test('unknown runtime → CLAUDE_CONFIG_DIR if set', () => { + withEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-for-unknown' }, () => { + assert.strictEqual(getGlobalConfigDir('no-such-runtime'), '/custom/claude-for-unknown'); + }); + }); +}); + +// ── GOLDEN PARITY: getGlobalConfigDir via process.env for all 16 registry runtimes ── + +describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-vars = golden defaults', () => { + // This is the hardest assertion: it drives getGlobalConfigDir() (which calls + // the registry internally) and compares against GOLDEN_DEFAULTS captured from + // the old switch. Any discrepancy means a regression. + // kimi is excluded because its default depends on real filesystem probing. + // antigravity is excluded because it also depends on real fs probing — a machine + // with ~/.gemini/antigravity-ide or ~/.gemini/antigravity-cli (but not + // ~/.gemini/antigravity) gets a different result. Probe scenarios are covered in + // the dot-home-nested suite with injected existsSync. + // grok is excluded because it is not in the registry (hardcoded branch). + const registryRuntimes = Object.keys(GOLDEN_DEFAULTS).filter( + r => r !== 'grok' && r !== 'antigravity', + ); + + for (const runtime of registryRuntimes) { + test(`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`, () => { + const saved = clearAllEnvKeys(); + try { + assert.strictEqual( + getGlobalConfigDir(runtime), + GOLDEN_DEFAULTS[runtime], + `getGlobalConfigDir('${runtime}') diverged from golden`, + ); + } finally { + restoreEnvKeys(saved); + } + }); + } +});