fix(#3126): replace hardcoded globalSkillsBase with first-class runtime-aware mapping (#3140)

* fix(#3126): replace hardcoded globalSkillsBase with runtime-aware mapping

Root cause: buildAgentSkillsBlock() used path.join(os.homedir(), '.claude',
'skills') for globalSkillsBase regardless of config.runtime. Cursor users
(and every non-Claude runtime) saw their global: skill lookups fail with
a warning pointing to the wrong directory.

Fix: introduces get-shit-done/bin/lib/runtime-homes.cjs — a pure, side-
effect-free module covering all 15 GSD runtimes:

  Runtime      Config base              Skills path
  claude        ~/.claude               ~/.claude/skills/
  cursor        ~/.cursor               ~/.cursor/skills/
  gemini        ~/.gemini               ~/.gemini/skills/
  codex         ~/.codex                ~/.codex/skills/
  copilot       ~/.copilot              ~/.copilot/skills/
  antigravity   ~/.gemini/antigravity   ...antigravity/skills/
  windsurf      ~/.codeium/windsurf     ...windsurf/skills/
  augment       ~/.augment              ~/.augment/skills/
  trae          ~/.trae                 ~/.trae/skills/
  qwen          ~/.qwen                 ~/.qwen/skills/
  hermes        ~/.hermes               ~/.hermes/skills/gsd/ (nested #2841)
  codebuddy     ~/.codebuddy            ~/.codebuddy/skills/
  cline         ~/.cline                null (rules-based, no skills dir)
  opencode      ~/.config/opencode      ...opencode/skills/
  kilo          ~/.config/kilo          ...kilo/skills/

Also adds CLAUDE_CONFIG_DIR env var support (was missing).
Warning messages now show the actual runtime-specific path.
Docs: INVENTORY.md CLI Modules 41→42.

Regression test: 30 assertions across all runtimes.
Suite: 7008/7008. Closes #3126.

* fix(lint+init): allow-test-rule, fix display path duplication (skillName appended twice)
This commit is contained in:
Tom Boucher
2026-05-05 15:02:11 -04:00
committed by GitHub
parent b0be6755e7
commit 375bf3abd6
6 changed files with 400 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3126
---
**`global:` skill resolution now uses the correct runtime home directory** — `buildAgentSkillsBlock()` hardcoded `globalSkillsBase` to `~/.claude/skills` regardless of the active runtime, causing every `global:` skill lookup to silently fail on non-Claude runtimes (Cursor, Gemini, Codex, Windsurf, etc.). Introduces `get-shit-done/bin/lib/runtime-homes.cjs` — a first-class runtime→directory mapping module covering all 15 supported runtimes with their canonical env-var overrides. Notable specifics: Hermes Agent uses a nested `skills/gsd/<skillName>/` layout (#2841); Cline is rules-based and returns `null` (no skills directory); `CLAUDE_CONFIG_DIR` env var was previously missing for Claude. Warning messages now show the actual runtime-specific path. Closes #3126.

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-05-03",
"generated": "2026-05-05",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -273,6 +273,7 @@
"profile-pipeline.cjs",
"roadmap-command-router.cjs",
"roadmap.cjs",
"runtime-homes.cjs",
"schema-detect.cjs",
"secrets.cjs",
"security.cjs",

View File

@@ -346,7 +346,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (41 shipped)
## CLI Modules (42 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -382,6 +382,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
| `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) |
| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) |
| `secrets.cjs` | Secret-config masking convention (`****<last-4>`) for integration keys managed by `/gsd-config --integrations` — keeps plaintext out of `config-set` output |
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers |

View File

@@ -1654,7 +1654,9 @@ function cmdInitRemoveWorkspace(cwd, name, raw) {
function buildAgentSkillsBlock(config, agentType, projectRoot) {
const { validatePath } = require('./security.cjs');
const os = require('os');
const globalSkillsBase = path.join(os.homedir(), '.claude', 'skills');
const { getGlobalSkillDir, getGlobalSkillDisplayPath } = require('./runtime-homes.cjs');
const runtime = (config && config.runtime) || 'claude';
const globalSkillsBase = require('./runtime-homes.cjs').getGlobalSkillsBase(runtime);
if (!config || !config.agent_skills || !agentType) return '';
@@ -1669,7 +1671,7 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
for (const skillPath of skillPaths) {
if (typeof skillPath !== 'string') continue;
// Support global: prefix for skills installed at ~/.claude/skills/ (#1992)
// Support global: prefix for skills installed at the runtime's global skills directory (#1992, #3126)
if (skillPath.startsWith('global:')) {
const skillName = skillPath.slice(7);
// Explicit empty-name guard before regex for clearer error message
@@ -1682,10 +1684,16 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`);
continue;
}
const globalSkillDir = path.join(globalSkillsBase, skillName);
// Cline is rules-based and has no global skills directory
if (globalSkillsBase === null) {
process.stderr.write(`[agent-skills] WARNING: Runtime "${runtime}" does not use a skills directory — "global:${skillName}" is not supported on this runtime\n`);
continue;
}
const globalSkillDir = getGlobalSkillDir(runtime, skillName);
const globalSkillMd = path.join(globalSkillDir, 'SKILL.md');
const displayPath = getGlobalSkillDisplayPath(runtime, skillName);
if (!fs.existsSync(globalSkillMd)) {
process.stderr.write(`[agent-skills] WARNING: Global skill not found at "~/.claude/skills/${skillName}/SKILL.md" — skipping\n`);
process.stderr.write(`[agent-skills] WARNING: Global skill not found at "${displayPath}/SKILL.md" — skipping\n`);
continue;
}
// Symlink escape guard: validatePath resolves symlinks and enforces
@@ -1696,7 +1704,7 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
process.stderr.write(`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`);
continue;
}
validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: `~/.claude/skills/${skillName}` });
validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
continue;
}

View File

@@ -0,0 +1,178 @@
'use strict';
/**
* runtime-homes.cjs — canonical runtime → global config/skills directory mapping.
*
* Single source of truth for resolving the global config base directory and
* the correct global skills directory for every GSD-supported runtime.
*
* Mirrors the logic in bin/install.js getGlobalDir() but as a pure,
* side-effect-free module safe to require() at any point without triggering
* the installer. bin/install.js is the authoritative source — keep in sync.
*
* Runtime-specific notes:
* hermes — GSD skills nest under skills/gsd/<skillName>/ (not the flat
* skills/<skillName>/ layout used by all other runtimes). This
* collapses 86 skill entries into one category in Hermes' system
* prompt (#2841).
* cline — Rules-based; commands are embedded in .clinerules. Cline does
* not use a skills/ directory. getGlobalSkillDir() returns null
* for cline so the caller can emit an appropriate warning.
*/
const os = require('os');
const path = require('path');
/**
* Expand a leading ~ to os.homedir().
* @param {string} p
* @returns {string}
*/
function expandTilde(p) {
if (!p) return p;
if (p.startsWith('~/') || p === '~') return path.join(os.homedir(), p.slice(1));
return p;
}
/**
* Return the global config base directory for the given runtime.
* Respects the same env-var overrides as bin/install.js getGlobalDir().
*
* @param {string} runtime
* @returns {string} Absolute path to the runtime's global config directory
*/
function getGlobalConfigDir(runtime) {
const home = os.homedir();
const env = process.env;
switch (runtime) {
// ── Claude Code ──────────────────────────────────────────────────────────
case 'claude':
return env.CLAUDE_CONFIG_DIR ? expandTilde(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
// ── Cursor ───────────────────────────────────────────────────────────────
case 'cursor':
return env.CURSOR_CONFIG_DIR ? expandTilde(env.CURSOR_CONFIG_DIR) : path.join(home, '.cursor');
// ── Gemini CLI ───────────────────────────────────────────────────────────
case 'gemini':
return env.GEMINI_CONFIG_DIR ? expandTilde(env.GEMINI_CONFIG_DIR) : path.join(home, '.gemini');
// ── Codex ────────────────────────────────────────────────────────────────
case 'codex':
return env.CODEX_HOME ? expandTilde(env.CODEX_HOME) : path.join(home, '.codex');
// ── Copilot (VS Code) ────────────────────────────────────────────────────
case 'copilot':
return env.COPILOT_CONFIG_DIR ? expandTilde(env.COPILOT_CONFIG_DIR) : path.join(home, '.copilot');
// ── Antigravity ──────────────────────────────────────────────────────────
case 'antigravity':
return env.ANTIGRAVITY_CONFIG_DIR
? expandTilde(env.ANTIGRAVITY_CONFIG_DIR)
: path.join(home, '.gemini', 'antigravity');
// ── Windsurf ─────────────────────────────────────────────────────────────
case 'windsurf':
return env.WINDSURF_CONFIG_DIR
? expandTilde(env.WINDSURF_CONFIG_DIR)
: path.join(home, '.codeium', 'windsurf');
// ── Augment ──────────────────────────────────────────────────────────────
case 'augment':
return env.AUGMENT_CONFIG_DIR ? expandTilde(env.AUGMENT_CONFIG_DIR) : path.join(home, '.augment');
// ── Trae ─────────────────────────────────────────────────────────────────
case 'trae':
return env.TRAE_CONFIG_DIR ? expandTilde(env.TRAE_CONFIG_DIR) : path.join(home, '.trae');
// ── Qwen Code ────────────────────────────────────────────────────────────
case 'qwen':
return env.QWEN_CONFIG_DIR ? expandTilde(env.QWEN_CONFIG_DIR) : path.join(home, '.qwen');
// ── Hermes Agent ─────────────────────────────────────────────────────────
// Note: skills use a nested layout (skills/gsd/<skill>/) — see getGlobalSkillDir().
case 'hermes':
return env.HERMES_HOME ? expandTilde(env.HERMES_HOME) : path.join(home, '.hermes');
// ── CodeBuddy ────────────────────────────────────────────────────────────
case 'codebuddy':
return env.CODEBUDDY_CONFIG_DIR ? expandTilde(env.CODEBUDDY_CONFIG_DIR) : path.join(home, '.codebuddy');
// ── Cline ────────────────────────────────────────────────────────────────
// Note: Cline is rules-based (.clinerules) — no skills/ directory.
// getGlobalSkillDir() returns null for cline.
case 'cline':
return env.CLINE_CONFIG_DIR ? expandTilde(env.CLINE_CONFIG_DIR) : path.join(home, '.cline');
// ── OpenCode (XDG) ───────────────────────────────────────────────────────
case 'opencode': {
if (env.OPENCODE_CONFIG_DIR) return expandTilde(env.OPENCODE_CONFIG_DIR);
if (env.XDG_CONFIG_HOME) return path.join(expandTilde(env.XDG_CONFIG_HOME), 'opencode');
return path.join(home, '.config', 'opencode');
}
// ── Kilo (XDG) ───────────────────────────────────────────────────────────
case 'kilo': {
if (env.KILO_CONFIG_DIR) return expandTilde(env.KILO_CONFIG_DIR);
if (env.XDG_CONFIG_HOME) return path.join(expandTilde(env.XDG_CONFIG_HOME), 'kilo');
return path.join(home, '.config', 'kilo');
}
// ── Default (Claude fallback) ─────────────────────────────────────────────
default:
return env.CLAUDE_CONFIG_DIR ? expandTilde(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
}
}
/**
* Return the global skills base directory for the given runtime.
* Most runtimes: <configDir>/skills
* Hermes: <configDir>/skills/gsd (nested category layout — #2841)
* Cline: null (rules-based, no skills directory)
*
* @param {string} runtime
* @returns {string|null}
*/
function getGlobalSkillsBase(runtime) {
if (runtime === 'cline') return null;
const configDir = getGlobalConfigDir(runtime);
if (runtime === 'hermes') return path.join(configDir, 'skills', 'gsd');
return path.join(configDir, 'skills');
}
/**
* Return the full path to a specific skill's directory for the given runtime.
* Returns null for runtimes that don't use a skills directory (cline).
*
* @param {string} runtime
* @param {string} skillName - e.g. 'gsd-executor'
* @returns {string|null}
*/
function getGlobalSkillDir(runtime, skillName) {
const base = getGlobalSkillsBase(runtime);
if (base === null) return null;
return path.join(base, skillName);
}
/**
* Return a human-readable display path for a global skill (for log messages).
*
* @param {string} runtime
* @param {string} skillName
* @returns {string}
*/
function getGlobalSkillDisplayPath(runtime, skillName) {
const dir = getGlobalSkillDir(runtime, skillName);
if (!dir) return `(${runtime} does not use a skills directory)`;
// Replace homedir prefix with ~ for readability
const home = os.homedir();
return dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
}
module.exports = {
getGlobalConfigDir,
getGlobalSkillsBase,
getGlobalSkillDir,
getGlobalSkillDisplayPath,
};

View File

@@ -0,0 +1,200 @@
'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
// 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 get-shit-done/bin/lib/runtime-homes.cjs with first-class
// support for all 15 supported runtimes, 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 ROOT = path.join(__dirname, '..');
const {
getGlobalConfigDir,
getGlobalSkillsBase,
getGlobalSkillDir,
getGlobalSkillDisplayPath,
} = require(path.join(ROOT, 'get-shit-done', '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')],
['gemini', path.join(os.homedir(), '.gemini')],
['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`, () => {
// Clear all env vars for this runtime
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',
'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(getGlobalConfigDir('claude'), '/custom/claude');
});
});
test('cursor respects CURSOR_CONFIG_DIR', () => {
withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => {
assert.strictEqual(getGlobalConfigDir('cursor'), '/custom/cursor');
});
});
test('opencode respects OPENCODE_CONFIG_DIR', () => {
withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/custom/opencode');
});
});
});
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'), '/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'), '/xdg/kilo');
});
});
});
});
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 null (rules-based, no skills directory)', () => {
assert.strictEqual(getGlobalSkillsBase('cline'), null);
});
});
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 null', () => {
assert.strictEqual(getGlobalSkillDir('cline', 'gsd-executor'), null);
});
});
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, 'get-shit-done', '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, 'get-shit-done', '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, 'get-shit-done', 'bin', 'lib', 'init.cjs'),
'utf8',
);
assert.ok(
!src.includes("~/.claude/skills/${skillName}/SKILL.md"),
'init.cjs warning message still hardcodes ~/.claude/skills path',
);
});
});