* test(#2755): failing-first coverage for per-runtime kimi hooks root Install/uninstall filesystem-shape rows over a sandbox HOME (no permission tricks) plus resolver unit rows. Covers both uninstall directions, which is where a fix applied only to the install call site would drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2755): resolve the kimi hooks-TOML root per runtime resolveKimiHooksTomlDir took no runtime argument and hardcoded ~/.kimi, but both kimi and kimi-code route through the single hooksSurface=kimi-hooks-toml branch. A --kimi-code install therefore wrote its [[hooks]] block, hook bundle and CommonJS marker into Kimi CLI's config file, and a --kimi-code uninstall stripped Kimi CLI's block. Adds a runtime selector to the resolver -- kimi keeps ~/.kimi + KIMI_SHARE_DIR, kimi-code gets ~/.kimi-code + KIMI_CODE_HOME, per Kimi Code's own upstream data-locations and hooks docs -- and passes the runtime at both the install and uninstall call sites. An omitted or unrecognized runtime still resolves ~/.kimi, so the exported no-arg contract is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2755): use centralized helpers and add a divergence guard Review findings, all fixed in-PR: - The new test block reimplemented runMinimalInstall, createTempDir and toPosixPath. Extends runMinimalInstall with optional root/extraEnv instead (back-compat: every existing caller passes neither) and uses the centralized helpers, per CONTRIBUTING's Use Centralized Test Helpers rule. - Adds a parity assertion between the capability registry and the resolver: a third runtime declaring hooksSurface kimi-hooks-toml would silently inherit ~/.kimi, re-creating this very defect. The guard fires the moment those two surfaces drift. - Adds an installer-level test proving KIMI_SHARE_DIR and KIMI_CODE_HOME do not interfere when both are set, which only the resolver unit covered before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2755): track the kimi-code hooks root in the emitted-artifact gates The remote runner caught a real ripple: moving kimi-code hooks to ~/.kimi-code made 31 emitted paths unattributable and 58 emitted hashes unexplained, because three parallel surfaces keyed on the literal .kimi path. - HOOK_CONFIG_RELATIVE_PATHS excluded only .kimi/config.toml, so kimi-code's config.toml became manifest-visible; it embeds a platform-varying node-runner command and must stay out for both products. - HOOKS_ROOTS, the package.json-marker branch and the synthesized-install-metadata pattern each named .kimi only. - tests/fixtures/install-tree/kimi-code.json still recorded the old paths; regenerated via gen:install-tree. Adds the per-PR drift acknowledgment for the 58 paths whose bytes are unchanged but whose destination moved - a ripple no source diff can show, since no hook script was edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2755): clear production-tree security advisories The remote runner's npm-integrity gate reported 2 high advisories in the production dependency tree. My diff touches neither package.json nor package-lock.json, so these come from the base -- but a red gate is not something to wave off as pre-existing, so it is fixed here rather than deferred. Lockfile-only, semver-in-range, via npm audit fix: fast-uri 3.1.4 -> 3.1.5 (host confusion via backslash authority introducer) ip-address 10.2.0 -> 10.4.0 (three SSRF / trust-boundary bypasses) hono 4.12.31 -> 4.13.0 (moderate; reverting it traded a high for a moderate, so the full remedy is taken) npm audit now reports 0 vulnerabilities at every severity, npm ci installs clean from the updated lockfile, and the build and the kimi behavior both re-verified afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2755): backfill changeset pr numbers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1322 lines
51 KiB
JavaScript
1322 lines
51 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Equivalence proof for ADR-857 phase 5b: descriptor-driven getGlobalConfigDir.
|
||
*
|
||
* For every runtime in the 15-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,
|
||
getGlobalSkillsBase,
|
||
resolveAntigravityGlobalDir,
|
||
resolveKimiGlobalDir,
|
||
resolveKimiHooksTomlDir,
|
||
resolveConfigHomeFromDescriptor,
|
||
resolveSkillsBaseFromDescriptor,
|
||
detectAntigravityDirAmbiguity,
|
||
} = 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', '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'),
|
||
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'),
|
||
zcode: path.join(HOME, '.zcode'),
|
||
};
|
||
|
||
// ── 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 resolves to its golden config dir`, () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir(runtime), expected, `${runtime} default → ${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: '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(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/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);
|
||
}
|
||
});
|
||
|
||
// ── #213/#217 coexistence regression: probeExists disambiguation ──────────
|
||
// Before probeExists on dot-home-nested, first-bare-existing-wins meant a CLI
|
||
// user (antigravity-cli) who also had the IDE's ~/.gemini/antigravity dir
|
||
// present was shadowed to the legacy dir (probed first). probeExists =
|
||
// 'gsd-core/VERSION' makes the dir GSD actually owns win, regardless of order.
|
||
const AG_PROBE = ['antigravity', 'antigravity-ide', 'antigravity-cli'];
|
||
const AG_MARKER = path.join('gsd-core', 'VERSION');
|
||
|
||
function antigravityDescriptor(withMarker) {
|
||
const d = {
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: AG_PROBE,
|
||
};
|
||
if (withMarker) d.probeExists = AG_MARKER;
|
||
return d;
|
||
}
|
||
|
||
test('coexistence: legacy antigravity + antigravity-cli both exist, only cli is GSD-marked → returns antigravity-cli', () => {
|
||
const home = '/home/u';
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const markerPath = path.join(cliDir, AG_MARKER);
|
||
// Both dirs exist on disk; only the cli dir carries gsd-core/VERSION.
|
||
const existsSync = (p) =>
|
||
p === markerPath || p === cliDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, cliDir, 'GSD-marked cli dir must win over bare-existing legacy dir');
|
||
});
|
||
|
||
test('coexistence WITHOUT probeExists still shadows to legacy (documents the pre-fix behavior)', () => {
|
||
const home = '/home/u';
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const existsSync = (p) => p === cliDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(false), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
// No marker → legacy first-bare-existing wins. This is exactly the #217 bug
|
||
// and proves probeExists is the load-bearing fix.
|
||
assert.strictEqual(result, legacyDir);
|
||
});
|
||
|
||
test('coexistence: legacy + ide both exist, only ide is GSD-marked → returns antigravity-ide', () => {
|
||
const home = '/home/u';
|
||
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const markerPath = path.join(ideDir, AG_MARKER);
|
||
const existsSync = (p) => p === markerPath || p === ideDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, ideDir);
|
||
});
|
||
|
||
test('marker on legacy dir: GSD lives in legacy antigravity (a real 1.x install) → returns legacy even when cli dir exists bare', () => {
|
||
const home = '/home/u';
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const markerPath = path.join(legacyDir, AG_MARKER);
|
||
// Legacy carries the marker; cli dir exists but is not GSD's. Legacy wins.
|
||
const existsSync = (p) => p === markerPath || p === legacyDir || p === cliDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, legacyDir);
|
||
});
|
||
|
||
test('no marker anywhere (dirs exist but no GSD installed yet): falls back to bare-existence first match', () => {
|
||
const home = '/home/u';
|
||
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||
// Only ide dir exists, no gsd-core/VERSION anywhere → pass 2 returns ide.
|
||
const existsSync = (p) => p === ideDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, ideDir, 'with no marker, bare-existence pass still resolves the single existing 2.x dir');
|
||
});
|
||
|
||
test('probeExists present but nothing exists → fallback to probe[0] (legacy default preserved)', () => {
|
||
const home = '/home/u';
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync: () => false,
|
||
});
|
||
assert.strictEqual(result, path.join(home, '.gemini', 'antigravity'));
|
||
});
|
||
|
||
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'));
|
||
});
|
||
});
|
||
|
||
// ── #213/#217 thread-4: existing-install ambiguity detector ───────────────────
|
||
describe('detectAntigravityDirAmbiguity (migration/operator-guidance signal)', () => {
|
||
const HOMEU = '/home/u';
|
||
const dir = (name) => path.join(HOMEU, '.gemini', name);
|
||
const markerOf = (name) => path.join(dir(name), 'gsd-core', 'VERSION');
|
||
|
||
test('single dir present → not ambiguous', () => {
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === cli || p === markerOf('antigravity-cli'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, false);
|
||
assert.strictEqual(r.resolved, cli);
|
||
assert.deepStrictEqual(r.presentDirs, [cli]);
|
||
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
|
||
assert.strictEqual(r.envOverridden, false);
|
||
});
|
||
|
||
test('legacy + cli both present, GSD marked in cli → ambiguous, resolves to cli', () => {
|
||
const legacy = dir('antigravity');
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity-cli'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, true, 'two probe dirs present must flag ambiguity');
|
||
assert.strictEqual(r.resolved, cli, 'marker disambiguates resolution to cli');
|
||
assert.deepStrictEqual(r.presentDirs.sort(), [legacy, cli].sort());
|
||
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
|
||
});
|
||
|
||
test('misinstall surface: legacy + cli present but GSD marked ONLY in legacy → ambiguous, resolves to legacy', () => {
|
||
// This is exactly the #217 victim: GSD was written into the legacy/IDE dir,
|
||
// so the marker is in legacy and the resolver keeps it there. The detector
|
||
// flags ambiguity so the installer/update can prompt the operator.
|
||
const legacy = dir('antigravity');
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, true);
|
||
assert.strictEqual(r.resolved, legacy);
|
||
assert.deepStrictEqual(r.gsdMarkedDirs, [legacy]);
|
||
});
|
||
|
||
test('env override short-circuits: envOverridden flag set when ANTIGRAVITY_CONFIG_DIR present', () => {
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' },
|
||
home: HOMEU,
|
||
existsSync: () => true,
|
||
});
|
||
assert.strictEqual(r.envOverridden, true);
|
||
assert.strictEqual(r.resolved, '/custom/ag', 'env override wins over probe entirely');
|
||
});
|
||
});
|
||
|
||
// ── 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(String(getGlobalConfigDir('claude', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('kimi', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('grok', '/tmp/explicit')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('claude', '/explicit/wins')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('grok')).replace(/\\/g, '/'), '/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(String(getGlobalConfigDir('no-such-runtime')).replace(/\\/g, '/'), '/custom/claude-for-unknown');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('descriptor-driven global skills base', () => {
|
||
test('hermes skills base is derived from descriptor artifact layout', () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalSkillsBase('hermes'), path.join(HOME, '.hermes', 'skills', 'gsd'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('kilo skills base is derived from configHome.skillsHome descriptor', () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalSkillsBase('kilo'), path.join(HOME, '.kilo', 'skills'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('synthetic runtime skillsHome descriptor resolves without a runtime-name branch', () => {
|
||
const base = resolveSkillsBaseFromDescriptor(
|
||
{
|
||
kind: 'xdg',
|
||
name: 'futurecli',
|
||
env: ['FUTURE_CONFIG_DIR', 'FUTURE_CONFIG', 'XDG_CONFIG_HOME'],
|
||
skillsHome: {
|
||
kind: 'dot-home',
|
||
name: '.futurecli',
|
||
env: ['FUTURE_SKILLS_HOME'],
|
||
},
|
||
},
|
||
{
|
||
env: { FUTURE_SKILLS_HOME: '/custom/future-skills' },
|
||
home: '/home/u',
|
||
existsSync: () => false,
|
||
},
|
||
);
|
||
|
||
assert.strictEqual(base, path.join('/custom/future-skills', 'skills'));
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN PARITY: getGlobalConfigDir via process.env for every non-probe registry runtime ──
|
||
|
||
describe('descriptor-driven parity: 13 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 its golden default`, () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir(runtime),
|
||
GOLDEN_DEFAULTS[runtime],
|
||
`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`,
|
||
);
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3126-global-skills-base-runtime-path.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3126-global-skills-base-runtime-path (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// allow-test-rule: last three tests read init.cjs source to verify delegation contract to runtime-homes.cjs — structural guard, no behavioral IR exposed (see #3126)
|
||
|
||
// Regression guard for bug #3126.
|
||
//
|
||
// buildAgentSkillsBlock() in init.cjs hardcoded `globalSkillsBase` to
|
||
// `~/.claude/skills` regardless of the active runtime. On a Cursor install,
|
||
// global: skills live under `~/.cursor/skills`, causing every global: lookup
|
||
// to silently fail with:
|
||
// [agent-skills] WARNING: Global skill not found at "~/.cursor/skills/X/SKILL.md" — skipping
|
||
//
|
||
// Fix introduces gsd-core/bin/lib/runtime-homes.cjs with first-class
|
||
// support for every supported runtime, including:
|
||
// - hermes: nested skills/gsd/<skillName>/ layout (#2841)
|
||
// - cline: rules-based, returns null (no skills directory)
|
||
// - CLAUDE_CONFIG_DIR env var for Claude (was missing)
|
||
// - All other runtime-specific env vars
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const {
|
||
getGlobalConfigDir,
|
||
getGlobalSkillsBase,
|
||
getGlobalSkillDir,
|
||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||
|
||
// Helper: run fn with an env var temporarily set
|
||
function withEnv(key, value, fn) {
|
||
const orig = process.env[key];
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
try { return fn(); }
|
||
finally {
|
||
if (orig === undefined) delete process.env[key];
|
||
else process.env[key] = orig;
|
||
}
|
||
}
|
||
|
||
describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
|
||
const defaults = [
|
||
['claude', path.join(os.homedir(), '.claude')],
|
||
['cursor', path.join(os.homedir(), '.cursor')],
|
||
['codex', path.join(os.homedir(), '.codex')],
|
||
['copilot', path.join(os.homedir(), '.copilot')],
|
||
['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')],
|
||
['windsurf', path.join(os.homedir(), '.codeium', 'windsurf')],
|
||
['augment', path.join(os.homedir(), '.augment')],
|
||
['trae', path.join(os.homedir(), '.trae')],
|
||
['qwen', path.join(os.homedir(), '.qwen')],
|
||
['hermes', path.join(os.homedir(), '.hermes')],
|
||
['codebuddy', path.join(os.homedir(), '.codebuddy')],
|
||
['cline', path.join(os.homedir(), '.cline')],
|
||
['opencode', path.join(os.homedir(), '.config', 'opencode')],
|
||
['kilo', path.join(os.homedir(), '.config', 'kilo')],
|
||
];
|
||
for (const [runtime, expected] of defaults) {
|
||
test(`${runtime} default configDir`, () => {
|
||
// 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 {
|
||
assert.strictEqual(getGlobalConfigDir(runtime), expected);
|
||
} finally {
|
||
for (const k of envKeys) {
|
||
if (saved[k] !== undefined) process.env[k] = saved[k];
|
||
}
|
||
}
|
||
});
|
||
}
|
||
test('unknown runtime falls back to ~/.claude', () => {
|
||
withEnv('CLAUDE_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(getGlobalConfigDir('unknown-xyz'), path.join(os.homedir(), '.claude'));
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes env-var overrides', () => {
|
||
test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => {
|
||
withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude');
|
||
});
|
||
});
|
||
test('cursor respects CURSOR_CONFIG_DIR', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor');
|
||
});
|
||
});
|
||
test('opencode respects OPENCODE_CONFIG_DIR', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode');
|
||
});
|
||
});
|
||
});
|
||
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
|
||
});
|
||
});
|
||
});
|
||
});
|
||
test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => {
|
||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||
withEnv('KILO_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('antigravity detects 2.x IDE dir when legacy dir is absent', () => {
|
||
const home = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-home-'));
|
||
try {
|
||
require('node:fs').mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true });
|
||
const savedHome = process.env.HOME;
|
||
const savedUserProfile = process.env.USERPROFILE;
|
||
process.env.HOME = home;
|
||
process.env.USERPROFILE = home;
|
||
withEnv('ANTIGRAVITY_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('antigravity'),
|
||
path.join(home, '.gemini', 'antigravity-ide'),
|
||
);
|
||
});
|
||
if (savedHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = savedHome;
|
||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = savedUserProfile;
|
||
} finally {
|
||
cleanup(home);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes getGlobalSkillsBase', () => {
|
||
test('most runtimes: skills at <configDir>/skills', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillsBase('cursor'),
|
||
path.join(os.homedir(), '.cursor', 'skills'),
|
||
);
|
||
});
|
||
});
|
||
test('hermes: skills at <configDir>/skills/gsd (nested layout #2841)', () => {
|
||
withEnv('HERMES_HOME', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillsBase('hermes'),
|
||
path.join(os.homedir(), '.hermes', 'skills', 'gsd'),
|
||
);
|
||
});
|
||
});
|
||
test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => {
|
||
withEnv('CLINE_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillsBase('cline'),
|
||
path.join(os.homedir(), '.cline', 'skills'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
|
||
test('cursor: <configDir>/skills/<skillName>', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillDir('cursor', 'gsd-executor'),
|
||
path.join(os.homedir(), '.cursor', 'skills', 'gsd-executor'),
|
||
);
|
||
});
|
||
});
|
||
test('hermes: <configDir>/skills/gsd/<skillName>', () => {
|
||
withEnv('HERMES_HOME', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillDir('hermes', 'gsd-executor'),
|
||
path.join(os.homedir(), '.hermes', 'skills', 'gsd', 'gsd-executor'),
|
||
);
|
||
});
|
||
});
|
||
test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => {
|
||
withEnv('CLINE_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillDir('cline', 'gsd-executor'),
|
||
path.join(os.homedir(), '.cline', 'skills', 'gsd-executor'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => {
|
||
// ── explicitDir override ──────────────────────────────────────────────────
|
||
test('explicitDir absolute path is returned as-is (claude)', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x');
|
||
});
|
||
|
||
test('explicitDir with tilde is expanded (opencode)', () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('opencode', '~/foo'),
|
||
path.join(os.homedir(), 'foo'),
|
||
);
|
||
});
|
||
|
||
test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins');
|
||
});
|
||
});
|
||
|
||
// ── opencode: OPENCODE_CONFIG file-path step ──────────────────────────────
|
||
test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => {
|
||
withEnv('OPENCODE_CONFIG', '/file/loses.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins');
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: default ~/.config/opencode when no env vars set', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('opencode'),
|
||
path.join(os.homedir(), '.config', 'opencode'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── kilo: KILO_CONFIG file-path step ─────────────────────────────────────
|
||
test('kilo: KILO_CONFIG → path.dirname(expandTilde(value))', () => {
|
||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
|
||
withEnv('KILO_CONFIG_DIR', '/dir/wins', () => {
|
||
withEnv('KILO_CONFIG', '/file/loses.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins');
|
||
});
|
||
});
|
||
});
|
||
|
||
test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||
withEnv('KILO_CONFIG', '/cfg/kilo.json', () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('kilo: default ~/.config/kilo when no env vars set', () => {
|
||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||
withEnv('KILO_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('kilo'),
|
||
path.join(os.homedir(), '.config', 'kilo'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => {
|
||
test('init.cjs has no hardcoded globalSkillsBase assignment to ~/.claude/skills', () => {
|
||
const fs = require('node:fs');
|
||
const src = fs.readFileSync(
|
||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'),
|
||
'utf8',
|
||
);
|
||
assert.ok(
|
||
!src.includes("const globalSkillsBase = path.join(os.homedir(), '.claude', 'skills')"),
|
||
'init.cjs still assigns globalSkillsBase to hardcoded ~/.claude/skills — fix not applied',
|
||
);
|
||
});
|
||
test('init.cjs requires runtime-homes', () => {
|
||
const fs = require('node:fs');
|
||
const src = fs.readFileSync(
|
||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'),
|
||
'utf8',
|
||
);
|
||
assert.ok(
|
||
src.includes('runtime-homes'),
|
||
'init.cjs does not require runtime-homes.cjs',
|
||
);
|
||
});
|
||
test('init.cjs warning message no longer hardcodes ~/.claude/skills', () => {
|
||
const fs = require('node:fs');
|
||
const src = fs.readFileSync(
|
||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init.cjs'),
|
||
'utf8',
|
||
);
|
||
assert.ok(
|
||
!src.includes("~/.claude/skills/${skillName}/SKILL.md"),
|
||
'init.cjs warning message still hardcodes ~/.claude/skills path',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── resolveKimiHooksTomlDir: per-runtime hooks root (#2755) ──────────────────
|
||
|
||
describe('resolveKimiHooksTomlDir — per-runtime hooks root (#2755)', () => {
|
||
// Pure path computation; the fixture home never needs to exist on disk.
|
||
const FIXTURE_HOME = path.join(os.tmpdir(), 'gsd-2755-home-fixture');
|
||
const at = (...seg) => path.join(FIXTURE_HOME, ...seg);
|
||
|
||
test('a bare call does not throw', () => {
|
||
assert.doesNotThrow(() => resolveKimiHooksTomlDir());
|
||
});
|
||
|
||
test('an omitted runtime still resolves Kimi CLI\'s ~/.kimi (back-compat)', () => {
|
||
// The function is exported; callers and tests outside this diff pass no
|
||
// runtime, and their destination must not move.
|
||
assert.equal(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {} }), at('.kimi'));
|
||
});
|
||
|
||
test('runtime "kimi" resolves ~/.kimi', () => {
|
||
assert.equal(
|
||
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'kimi' }),
|
||
at('.kimi'),
|
||
);
|
||
});
|
||
|
||
test('runtime "kimi-code" resolves ~/.kimi-code', () => {
|
||
assert.equal(
|
||
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'kimi-code' }),
|
||
at('.kimi-code'),
|
||
);
|
||
});
|
||
|
||
test('KIMI_SHARE_DIR overrides the kimi root', () => {
|
||
assert.equal(
|
||
String(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_SHARE_DIR: '/share' }, runtime: 'kimi' })).replace(/\\/g, '/'),
|
||
'/share',
|
||
);
|
||
});
|
||
|
||
test('KIMI_CODE_HOME overrides the kimi-code root', () => {
|
||
assert.equal(
|
||
String(resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_CODE_HOME: '/kcode' }, runtime: 'kimi-code' })).replace(/\\/g, '/'),
|
||
'/kcode',
|
||
);
|
||
});
|
||
|
||
test('KIMI_SHARE_DIR does not hijack the kimi-code root', () => {
|
||
// KIMI_SHARE_DIR is Kimi CLI's own upstream var. Before #2755 it silently
|
||
// redirected kimi-code too — that accident was the issue's suggested
|
||
// workaround, and the fix must make it inert.
|
||
assert.equal(
|
||
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_SHARE_DIR: '/share' }, runtime: 'kimi-code' }),
|
||
at('.kimi-code'),
|
||
);
|
||
});
|
||
|
||
test('KIMI_CODE_HOME does not hijack the kimi root', () => {
|
||
assert.equal(
|
||
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: { KIMI_CODE_HOME: '/kcode' }, runtime: 'kimi' }),
|
||
at('.kimi'),
|
||
);
|
||
});
|
||
|
||
test('an unknown runtime falls back to ~/.kimi', () => {
|
||
assert.equal(
|
||
resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: 'not-a-kimi' }),
|
||
at('.kimi'),
|
||
);
|
||
});
|
||
|
||
test('every kimi-hooks-toml runtime resolves a distinct hooks root', () => {
|
||
// Divergence guard (CLAUDE.md → Generative Fix Divergence). The resolver
|
||
// hardcodes the two Kimi roots while the capability registry independently
|
||
// decides which runtimes use the kimi-hooks-toml surface. If a third one is
|
||
// ever added without teaching the resolver its root, it silently inherits
|
||
// ~/.kimi — which IS the #2755 defect, re-created. This fails the moment
|
||
// those two surfaces drift apart.
|
||
const capsDir = path.join(ROOT, 'capabilities');
|
||
const ids = fs.readdirSync(capsDir).filter((id) => {
|
||
const file = path.join(capsDir, id, 'capability.json');
|
||
if (!fs.existsSync(file)) return false;
|
||
return JSON.parse(fs.readFileSync(file, 'utf8'))?.runtime?.hooksSurface === 'kimi-hooks-toml';
|
||
});
|
||
|
||
assert.deepEqual(ids.sort(), ['kimi', 'kimi-code'],
|
||
'the kimi-hooks-toml runtime set changed — teach resolveKimiHooksTomlDir the new root, then update this list');
|
||
|
||
const roots = ids.map((id) => resolveKimiHooksTomlDir({ home: FIXTURE_HOME, env: {}, runtime: id }));
|
||
assert.equal(new Set(roots).size, roots.length,
|
||
`each kimi-hooks-toml runtime must resolve its own root; got ${JSON.stringify(roots)}`);
|
||
});
|
||
});
|