diff --git a/.changeset/56-retire-legacy-runtime-directory-helpers.md b/.changeset/56-retire-legacy-runtime-directory-helpers.md new file mode 100644 index 000000000..4737ef30f --- /dev/null +++ b/.changeset/56-retire-legacy-runtime-directory-helpers.md @@ -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) + + diff --git a/CONTEXT.md b/CONTEXT.md index 449508464..baea885cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` 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//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` 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. diff --git a/bin/install.js b/bin/install.js index 4f67b71e0..d3e660915 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 '); 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' diff --git a/src/commands.cts b/src/commands.cts index 4ce1f87be..0d72e81bc 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -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 diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index be373e44f..a27c96887 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -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; @@ -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'); } diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index e1088b624..383751d30 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -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'); diff --git a/tests/cline-install.test.cjs b/tests/cline-install.test.cjs index b13fd0610..adbde38d9 100644 --- a/tests/cline-install.test.cjs +++ b/tests/cline-install.test.cjs @@ -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')); }); }); diff --git a/tests/codebuddy-install.test.cjs b/tests/codebuddy-install.test.cjs index b21781d04..83c00505f 100644 --- a/tests/codebuddy-install.test.cjs +++ b/tests/codebuddy-install.test.cjs @@ -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')); }); }); diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 86aff0d9a..404a6baee 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -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')); }); }); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 3ac6e72c8..3007523f0 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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')); }); }); diff --git a/tests/windsurf-install.test.cjs b/tests/windsurf-install.test.cjs index 35ef1ab0a..4b6aefdfc 100644 --- a/tests/windsurf-install.test.cjs +++ b/tests/windsurf-install.test.cjs @@ -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')); }); });