* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- 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:
@@ -46,27 +46,163 @@ export interface ResolveKimiOpts {
|
||||
existsSync?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ResolveConfigHomeOpts {
|
||||
env?: Record<string, string | undefined>;
|
||||
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/<name>
|
||||
* - generic-agents-root:env[0] → first probe where probeExists exists → probe[0]
|
||||
*/
|
||||
export function resolveConfigHomeFromDescriptor(
|
||||
configHome: ConfigHomeDescriptor,
|
||||
opts: ResolveConfigHomeOpts = {},
|
||||
): string {
|
||||
const env: Record<string, string | undefined> = 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<string, string | undefined> = 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<string, string | undefined> = 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<string, string | undefined>;
|
||||
|
||||
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<string, string | undefined>;
|
||||
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<string, { runtime?: { configHome: ConfigHomeDescriptor } }>;
|
||||
};
|
||||
|
||||
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<string, string | undefined>;
|
||||
return env['CLAUDE_CONFIG_DIR'] ? expandTilde(env['CLAUDE_CONFIG_DIR']) : path.join(os.homedir(), '.claude');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
665
tests/runtime-homes-descriptor-drive.test.cjs
Normal file
665
tests/runtime-homes-descriptor-drive.test.cjs
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user