refactor(#56): retire legacy runtime directory helpers into runtime-homes projection (#802)

* refactor(#56): retire legacy runtime directory helpers into runtime-homes projection

Consolidate per-runtime global config-dir resolution onto the single
canonical projection runtime-homes:getGlobalConfigDir. Extend it with the
explicitDir override (CLI --config-dir) and the opencode/kilo
OPENCODE_CONFIG/KILO_CONFIG file-path precedence the installer helpers had,
making it byte-for-behavior equivalent to the old getGlobalDir across all
15 install runtimes.

Delete bin/install.js's getGlobalDir/getOpencodeGlobalDir/getKiloGlobalDir
(and the orphaned local expandTilde), repoint all 9 call-sites, and remove
getGlobalDir from module.exports (net -242 lines in the installer). Migrate
the 5 test importers to the canonical projection; harden default/XDG
assertions against ambient *_CONFIG env vars. getAgentsDir now respects
OPENCODE_CONFIG/KILO_CONFIG consistently with the installer (intentional
convergence). Update CONTEXT.md Installer Module entry.

Completes the installer-refactor chain #58 -> #60 -> #56.

Closes #56

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#56): add changeset for runtime directory helper retirement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-07 12:24:04 -04:00
committed by GitHub
parent f66c4a082c
commit 7e1112c28f
11 changed files with 207 additions and 297 deletions

View File

@@ -0,0 +1,7 @@
---
type: Changed
pr: 802
---
Retire the installer's one-off runtime directory helpers (`getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir`) and consolidate per-runtime global config-dir resolution onto the single canonical projection `runtime-homes:getGlobalConfigDir`, extended with the `--config-dir` override and the opencode/kilo `*_CONFIG` file-path precedence. Behavior-preserving across all 15 install runtimes. (#56)
<!-- docs-exempt: internal behavior-preserving consolidation of duplicated directory-resolution helpers; no user-facing behavior change -->

View File

@@ -104,7 +104,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getGlobalDir(runtime[, explicitDir])` → global path (env-var–aware per runtime); `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.

View File

@@ -30,6 +30,7 @@ const {
} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
const {
resolveAntigravityGlobalDir,
getGlobalConfigDir,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
const {
applyWorktreeBaseRef,
@@ -408,218 +409,6 @@ function getConfigDirFromHome(runtime, isGlobal) {
return "'.claude'";
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
*/
function getOpencodeGlobalDir() {
// 1. Explicit OPENCODE_CONFIG_DIR env var
if (process.env.OPENCODE_CONFIG_DIR) {
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
}
// 2. OPENCODE_CONFIG env var (use its directory)
if (process.env.OPENCODE_CONFIG) {
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
}
// 3. XDG_CONFIG_HOME/opencode
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
}
// 4. Default: ~/.config/opencode (XDG default)
return path.join(os.homedir(), '.config', 'opencode');
}
/**
* Get the global config directory for Kilo
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
*/
function getKiloGlobalDir() {
// 1. Explicit KILO_CONFIG_DIR env var
if (process.env.KILO_CONFIG_DIR) {
return expandTilde(process.env.KILO_CONFIG_DIR);
}
// 2. KILO_CONFIG env var (use its directory)
if (process.env.KILO_CONFIG) {
return path.dirname(expandTilde(process.env.KILO_CONFIG));
}
// 3. XDG_CONFIG_HOME/kilo
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
}
// 4. Default: ~/.config/kilo (XDG default)
return path.join(os.homedir(), '.config', 'kilo');
}
/**
* Get the global config directory for a runtime
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
*/
function getGlobalDir(runtime, explicitDir = null) {
if (runtime === 'opencode') {
// For OpenCode, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getOpencodeGlobalDir();
}
if (runtime === 'kilo') {
// For Kilo, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getKiloGlobalDir();
}
if (runtime === 'gemini') {
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.GEMINI_CONFIG_DIR) {
return expandTilde(process.env.GEMINI_CONFIG_DIR);
}
return path.join(os.homedir(), '.gemini');
}
if (runtime === 'codex') {
// Codex: --config-dir > CODEX_HOME > ~/.codex
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEX_HOME) {
return expandTilde(process.env.CODEX_HOME);
}
return path.join(os.homedir(), '.codex');
}
if (runtime === 'copilot') {
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.COPILOT_CONFIG_DIR) {
return expandTilde(process.env.COPILOT_CONFIG_DIR);
}
return path.join(os.homedir(), '.copilot');
}
if (runtime === 'antigravity') {
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
if (explicitDir) {
return expandTilde(explicitDir);
}
return resolveAntigravityGlobalDir();
}
if (runtime === 'cursor') {
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CURSOR_CONFIG_DIR) {
return expandTilde(process.env.CURSOR_CONFIG_DIR);
}
return path.join(os.homedir(), '.cursor');
}
if (runtime === 'windsurf') {
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.WINDSURF_CONFIG_DIR) {
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
}
return path.join(os.homedir(), '.codeium', 'windsurf');
}
if (runtime === 'augment') {
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.AUGMENT_CONFIG_DIR) {
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
}
return path.join(os.homedir(), '.augment');
}
if (runtime === 'trae') {
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.TRAE_CONFIG_DIR) {
return expandTilde(process.env.TRAE_CONFIG_DIR);
}
return path.join(os.homedir(), '.trae');
}
if (runtime === 'qwen') {
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.QWEN_CONFIG_DIR) {
return expandTilde(process.env.QWEN_CONFIG_DIR);
}
return path.join(os.homedir(), '.qwen');
}
if (runtime === 'hermes') {
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.HERMES_HOME) {
return expandTilde(process.env.HERMES_HOME);
}
return path.join(os.homedir(), '.hermes');
}
if (runtime === 'codebuddy') {
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEBUDDY_CONFIG_DIR) {
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
}
return path.join(os.homedir(), '.codebuddy');
}
if (runtime === 'cline') {
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLINE_CONFIG_DIR) {
return expandTilde(process.env.CLINE_CONFIG_DIR);
}
return path.join(os.homedir(), '.cline');
}
// Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLAUDE_CONFIG_DIR) {
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
}
return path.join(os.homedir(), '.claude');
}
const banner = '\n' +
cyan + ' ██████╗ ███████╗██████╗\n' +
' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -692,16 +481,6 @@ if (hasHelp) {
process.exit(0);
}
/**
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
*/
function expandTilde(filePath) {
if (filePath && filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
/**
* Compute the path prefix used for `@file` references in installed command/skill
* markdown. For global installs into a runtime config dir under $HOME, we
@@ -1774,11 +1553,11 @@ function getCommitAttribution(runtime) {
const resolveConfigPath = runtime === 'opencode'
? resolveOpencodeConfigPath
: resolveKiloConfigPath;
const config = readSettings(resolveConfigPath(getGlobalDir(runtime, null)));
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (runtime === 'gemini') {
// Gemini: check gemini settings.json for attribution config
const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -1788,7 +1567,7 @@ function getCommitAttribution(runtime) {
}
} else if (runtime === 'claude') {
// Claude Code
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -7019,7 +6798,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// Get the target directory based on runtime and install type
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
@@ -7532,7 +7311,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.opencode/
// For global installs, use ~/.config/opencode/
const opencodeConfigDir = configDir || (isGlobal
? getGlobalDir('opencode', explicitConfigDir)
? getGlobalConfigDir('opencode', explicitConfigDir)
: path.join(process.cwd(), '.opencode'));
// Ensure config directory exists
fs.mkdirSync(opencodeConfigDir, { recursive: true });
@@ -7612,7 +7391,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.kilo/
// For global installs, use ~/.config/kilo/
const kiloConfigDir = configDir || (isGlobal
? getGlobalDir('kilo', explicitConfigDir)
? getGlobalConfigDir('kilo', explicitConfigDir)
: path.join(process.cwd(), '.kilo'));
// Ensure config directory exists
fs.mkdirSync(kiloConfigDir, { recursive: true });
@@ -8293,7 +8072,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: isCline
? process.cwd()
: path.join(process.cwd(), dirName);
@@ -10614,7 +10393,7 @@ function promptLocation(runtimes) {
});
const pathExamples = runtimes.map(r => {
const globalPath = getGlobalDir(r, explicitConfigDir);
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
return globalPath.replace(os.homedir(), '~');
}).join(', ');
@@ -11023,7 +10802,6 @@ module.exports = {
GSD_CODEX_MARKER,
CODEX_AGENT_SANDBOX,
getDirName,
getGlobalDir,
getConfigDirFromHome,
resolveKiloConfigPath,
configureKiloPermissions,
@@ -11114,7 +10892,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
console.error('Usage: node install.js --skills-root <runtime>');
process.exit(1);
}
const globalDir = getGlobalDir(runtimeArg, null);
const globalDir = getGlobalConfigDir(runtimeArg, null);
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
// Other runtimes use a flat skills/ root.
const skillsRoot = runtimeArg === 'hermes'

View File

@@ -445,7 +445,7 @@ function cmdEffortSync(cwd: string, raw: boolean, opts?: { dryRun?: boolean; con
}
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string): string };
const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string, explicitDir?: string | null): string };
// Use install-time resolvers: they merge ~/.gsd/defaults.json with project config,
// matching the exact logic used when agents were originally installed.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method

View File

@@ -61,8 +61,15 @@ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}):
/**
* Return the global config base directory for the given runtime.
* Respects the same env-var overrides as bin/install.js getGlobalDir().
*
* @param runtime - The runtime identifier (e.g. 'claude', 'opencode').
* @param explicitDir - If provided and non-empty, returned immediately after
* tilde-expansion, overriding all env-var and default logic. This matches
* the behaviour of bin/install.js getGlobalDir(runtime, explicitDir).
*/
export function getGlobalConfigDir(runtime: string): string {
export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string {
if (explicitDir) return expandTilde(explicitDir);
const home = os.homedir();
const env = process.env as Record<string, string | undefined>;
@@ -128,6 +135,7 @@ export function getGlobalConfigDir(runtime: string): string {
// ── OpenCode (XDG) ───────────────────────────────────────────────────────
case 'opencode': {
if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']);
if (env['OPENCODE_CONFIG']) return path.dirname(expandTilde(env['OPENCODE_CONFIG']));
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'opencode');
return path.join(home, '.config', 'opencode');
}
@@ -135,6 +143,7 @@ export function getGlobalConfigDir(runtime: string): string {
// ── Kilo (XDG) ───────────────────────────────────────────────────────────
case 'kilo': {
if (env['KILO_CONFIG_DIR']) return expandTilde(env['KILO_CONFIG_DIR']);
if (env['KILO_CONFIG']) return path.dirname(expandTilde(env['KILO_CONFIG']));
if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'kilo');
return path.join(home, '.config', 'kilo');
}

View File

@@ -65,7 +65,8 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR',
'CODEX_HOME','COPILOT_CONFIG_DIR','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR',
'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME',
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','KILO_CONFIG_DIR',
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG',
'KILO_CONFIG_DIR','KILO_CONFIG',
'XDG_CONFIG_HOME'];
const saved = {};
for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; }
@@ -105,15 +106,19 @@ describe('bug #3126: runtime-homes env-var overrides', () => {
});
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
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('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
withEnv('KILO_CONFIG', undefined, () => {
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
});
});
});
});
@@ -186,6 +191,110 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
});
});
describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => {
// ── explicitDir override ──────────────────────────────────────────────────
test('explicitDir absolute path is returned as-is (claude)', () => {
assert.strictEqual(getGlobalConfigDir('claude', '/tmp/x'), '/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(getGlobalConfigDir('opencode', '/explicit/wins'), '/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(getGlobalConfigDir('opencode'), '/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(getGlobalConfigDir('opencode'), '/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(getGlobalConfigDir('opencode'), '/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(getGlobalConfigDir('kilo'), '/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(getGlobalConfigDir('kilo'), '/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(getGlobalConfigDir('kilo'), '/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');

View File

@@ -30,20 +30,21 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToCliineMarkdown,
install,
finishInstall,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
describe('Cline runtime directory mapping', () => {
test('getDirName returns .cline for local installs', () => {
assert.strictEqual(getDirName('cline'), '.cline');
});
test('getGlobalDir returns ~/.cline for global installs', () => {
assert.strictEqual(getGlobalDir('cline'), path.join(os.homedir(), '.cline'));
test('getGlobalConfigDir returns ~/.cline for global installs', () => {
assert.strictEqual(getGlobalConfigDir('cline'), path.join(os.homedir(), '.cline'));
});
test('getConfigDirFromHome returns .cline fragment', () => {
@@ -52,7 +53,7 @@ describe('Cline runtime directory mapping', () => {
});
});
describe('getGlobalDir (Cline)', () => {
describe('getGlobalConfigDir (Cline)', () => {
let originalClineConfigDir;
beforeEach(() => {
@@ -69,30 +70,30 @@ describe('getGlobalDir (Cline)', () => {
test('returns ~/.cline with no env var or explicit dir', () => {
delete process.env.CLINE_CONFIG_DIR;
const result = getGlobalDir('cline');
const result = getGlobalConfigDir('cline');
assert.strictEqual(result, path.join(os.homedir(), '.cline'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('cline', '/custom/cline-path');
const result = getGlobalConfigDir('cline', '/custom/cline-path');
assert.strictEqual(result, '/custom/cline-path');
});
test('respects CLINE_CONFIG_DIR env var', () => {
process.env.CLINE_CONFIG_DIR = '~/custom-cline';
const result = getGlobalDir('cline');
const result = getGlobalConfigDir('cline');
assert.strictEqual(result, path.join(os.homedir(), 'custom-cline'));
});
test('explicit dir takes priority over CLINE_CONFIG_DIR', () => {
process.env.CLINE_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('cline', '/explicit/path');
const result = getGlobalConfigDir('cline', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -14,7 +14,6 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToCodebuddyMarkdown,
convertClaudeCommandToCodebuddySkill,
@@ -25,6 +24,8 @@ const {
installRuntimeArtifacts,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
@@ -37,7 +38,7 @@ describe('CodeBuddy runtime directory mapping', () => {
});
test('maps CodeBuddy to ~/.codebuddy for global installs', () => {
assert.strictEqual(getGlobalDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
assert.strictEqual(getGlobalConfigDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
});
test('returns .codebuddy config fragments for local and global installs', () => {
@@ -46,7 +47,7 @@ describe('CodeBuddy runtime directory mapping', () => {
});
});
describe('getGlobalDir (CodeBuddy)', () => {
describe('getGlobalConfigDir (CodeBuddy)', () => {
let originalCodebuddyConfigDir;
beforeEach(() => {
@@ -63,30 +64,30 @@ describe('getGlobalDir (CodeBuddy)', () => {
test('returns ~/.codebuddy with no env var or explicit dir', () => {
delete process.env.CODEBUDDY_CONFIG_DIR;
const result = getGlobalDir('codebuddy');
const result = getGlobalConfigDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), '.codebuddy'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('codebuddy', '/custom/codebuddy-path');
const result = getGlobalConfigDir('codebuddy', '/custom/codebuddy-path');
assert.strictEqual(result, '/custom/codebuddy-path');
});
test('respects CODEBUDDY_CONFIG_DIR env var', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/custom-codebuddy';
const result = getGlobalDir('codebuddy');
const result = getGlobalConfigDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), 'custom-codebuddy'));
});
test('explicit dir takes priority over CODEBUDDY_CONFIG_DIR', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('codebuddy', '/explicit/path');
const result = getGlobalConfigDir('codebuddy', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -28,7 +28,6 @@ const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
claudeToCopilotTools,
convertCopilotToolName,
@@ -48,6 +47,8 @@ const {
buildRuntimePromptText,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
@@ -70,9 +71,9 @@ describe('getDirName (Copilot)', () => {
});
});
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
// ─── getGlobalConfigDir ──────────────────────────────────────────────────────────
describe('getGlobalDir (Copilot)', () => {
describe('getGlobalConfigDir (Copilot)', () => {
let originalCopilotConfigDir;
beforeEach(() => {
@@ -89,30 +90,30 @@ describe('getGlobalDir (Copilot)', () => {
test('returns ~/.copilot with no env var or explicit dir', () => {
delete process.env.COPILOT_CONFIG_DIR;
const result = getGlobalDir('copilot');
const result = getGlobalConfigDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), '.copilot'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('copilot', '/custom/path');
const result = getGlobalConfigDir('copilot', '/custom/path');
assert.strictEqual(result, '/custom/path');
});
test('respects COPILOT_CONFIG_DIR env var', () => {
process.env.COPILOT_CONFIG_DIR = '~/custom-copilot';
const result = getGlobalDir('copilot');
const result = getGlobalConfigDir('copilot');
assert.strictEqual(result, path.join(os.homedir(), 'custom-copilot'));
});
test('explicit dir takes priority over COPILOT_CONFIG_DIR', () => {
process.env.COPILOT_CONFIG_DIR = '~/env-path';
const result = getGlobalDir('copilot', '/explicit/path');
const result = getGlobalConfigDir('copilot', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break existing runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});

View File

@@ -5,7 +5,7 @@
/**
* Installer Module — Sections 1–5.
*
* Covers: getDirName/getGlobalDir/getConfigDirFromHome, per-runtime
* Covers: getDirName/getGlobalConfigDir/getConfigDirFromHome, per-runtime
* install/uninstall spot-checks (hermes/qwen/trae), uninstall skills
* cleanup, Claude-reference leak tests, and Kilo-specific helpers.
*
@@ -34,7 +34,6 @@ const pkg = require('../package.json');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
@@ -46,13 +45,15 @@ const {
configureKiloPermissions,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
const {
RUNTIME_META,
stripAnsi,
walk,
} = require('./helpers/install-shared.cjs');
// ─── Section 1: getDirName / getGlobalDir / getConfigDirFromHome ──────────────
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
describe('getDirName — all runtimes', () => {
for (const runtime of allRuntimes) {
@@ -64,11 +65,14 @@ describe('getDirName — all runtimes', () => {
}
});
describe('getGlobalDir — all runtimes default paths', () => {
describe('getGlobalConfigDir — all runtimes default paths', () => {
// Test the default (no env var, no explicit dir) for each runtime
const ENV_KEYS = [
'HERMES_HOME', 'QWEN_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME',
'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR',
'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR',
'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR',
'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME',
];
let savedEnv = {};
@@ -87,14 +91,14 @@ describe('getGlobalDir — all runtimes default paths', () => {
});
for (const runtime of allRuntimes) {
test(`getGlobalDir('${runtime}') returns expected home-relative path`, () => {
test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => {
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
assert.strictEqual(getGlobalDir(runtime), expected);
assert.strictEqual(getGlobalConfigDir(runtime), expected);
});
}
});
describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
const saved = {};
beforeEach(() => {
saved.HOME = process.env.HOME;
@@ -118,7 +122,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-ide'),
);
assert.strictEqual(
@@ -137,7 +141,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalDir('antigravity'),
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
);
assert.strictEqual(
@@ -150,12 +154,12 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
});
});
describe('getGlobalDir — explicit configDir overrides env for all runtimes', () => {
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
test('explicit dir overrides any env var for hermes', () => {
const savedHome = process.env.HERMES_HOME;
process.env.HERMES_HOME = '~/from-env';
try {
assert.strictEqual(getGlobalDir('hermes', '/explicit/hermes'), '/explicit/hermes');
assert.strictEqual(getGlobalConfigDir('hermes', '/explicit/hermes'), '/explicit/hermes');
} finally {
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
else delete process.env.HERMES_HOME;
@@ -166,7 +170,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
const saved = process.env.KILO_CONFIG_DIR;
process.env.KILO_CONFIG_DIR = '~/from-env';
try {
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
assert.strictEqual(getGlobalConfigDir('kilo', '/explicit/kilo'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;
@@ -174,7 +178,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
});
});
describe('getGlobalDir — HERMES_HOME env var', () => {
describe('getGlobalConfigDir — HERMES_HOME env var', () => {
let saved;
beforeEach(() => { saved = process.env.HERMES_HOME; });
afterEach(() => {
@@ -184,11 +188,11 @@ describe('getGlobalDir — HERMES_HOME env var', () => {
test('respects HERMES_HOME env var (tilde-expanded)', () => {
process.env.HERMES_HOME = '~/custom-hermes';
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
assert.strictEqual(getGlobalConfigDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
});
});
describe('getGlobalDir — Kilo env var priority', () => {
describe('getGlobalConfigDir — Kilo env var priority', () => {
let savedEnv;
beforeEach(() => {
savedEnv = {
@@ -209,23 +213,23 @@ describe('getGlobalDir — Kilo env var priority', () => {
test('respects KILO_CONFIG_DIR', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
});
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR unset', () => {
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
});
test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
});

View File

@@ -5,9 +5,9 @@ const assert = require('node:assert/strict');
const os = require('os');
const path = require('path');
const { getGlobalDir } = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
describe('getGlobalDir (Windsurf)', () => {
describe('getGlobalConfigDir (Windsurf)', () => {
let originalWindsurfConfigDir;
beforeEach(() => {
@@ -24,29 +24,29 @@ describe('getGlobalDir (Windsurf)', () => {
});
test('returns ~/.codeium/windsurf with no env var or explicit dir', () => {
const result = getGlobalDir('windsurf');
const result = getGlobalConfigDir('windsurf');
assert.strictEqual(result, path.join(os.homedir(), '.codeium', 'windsurf'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('windsurf', '/custom/windsurf-path');
const result = getGlobalConfigDir('windsurf', '/custom/windsurf-path');
assert.strictEqual(result, '/custom/windsurf-path');
});
test('respects WINDSURF_CONFIG_DIR env var', () => {
process.env.WINDSURF_CONFIG_DIR = '~/custom-windsurf';
const result = getGlobalDir('windsurf');
const result = getGlobalConfigDir('windsurf');
assert.strictEqual(result, path.join(os.homedir(), 'custom-windsurf'));
});
test('explicit dir takes priority over WINDSURF_CONFIG_DIR', () => {
process.env.WINDSURF_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('windsurf', '/explicit/path');
const result = getGlobalConfigDir('windsurf', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
});
});