Files
msd-core/tests/runtime-homes-descriptor-drive.test.cjs
Tom Boucher c6ce4d1d9a fix(#2755): resolve the kimi hooks-TOML root per runtime (#3032)
* 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>
2026-08-03 18:56:42 -04:00

1322 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)}`);
});
});