diff --git a/.changeset/quick-cranes-purr.md b/.changeset/quick-cranes-purr.md new file mode 100644 index 000000000..5eec06a78 --- /dev/null +++ b/.changeset/quick-cranes-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3799 +--- +project-local agents now detected correctly in init.* queries — resolveAgentsDir() checks `/.claude/agents` before the global runtime dir, matching Claude Code's own local-first agent resolution at spawn. Previously an empty auto-created `~/.claude/agents` caused agents_installed: false for project-local installs. (#3799) diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index faed1bd17..57b27e116 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -109,27 +109,31 @@ export function detectRuntime(config?: { runtime?: unknown }): Runtime { * * Precedence: * 1. `GSD_AGENTS_DIR` — explicit SDK override (wins over runtime selection) - * 2. `/agents` — installer-parity default (when the dir exists) - * 3. `/.claude/agents` — repo-local fallback for `--local` Claude installs - * (only probed when the global runtime dir is absent or empty, and `projectDir` is given) + * 2. `/.claude/agents` — project-local install (Claude Code `--local`, + * `npx get-shit-done-cc@latest`); checked first to match Claude Code's own + * local-first agent resolution at spawn (#3799) + * 3. `/agents` — global installer-parity default * * Defaults to Claude when no runtime is passed, matching prior behavior * (see `init-runner.ts`, which is Claude-only by design). * - * The repo-local fallback was added for bug #3751: Claude Code `--local` installs - * place agent definitions under `./.claude/agents` rather than `~/.claude/agents`, - * but the SDK agent-detection path only probed the global directory. + * Local-first resolution was introduced for bug #3799: when both a project-local + * `.claude/agents/` and a global `~/.claude/agents/` exist (the global may be + * auto-created empty by Claude Code), the SDK must resolve to the project-local + * directory to match where the agents are actually installed. + * + * The repo-local fallback itself was added for bug #3751. */ export function resolveAgentsDir(runtime: Runtime = 'claude', projectDir?: string): string { if (process.env.GSD_AGENTS_DIR) return process.env.GSD_AGENTS_DIR; - const globalDir = join(getRuntimeConfigDir(runtime), 'agents'); - if (existsSync(globalDir)) return globalDir; - // Repo-local fallback: /.claude/agents for --local Claude installs (#3751). + // Project-local first: /.claude/agents for --local / npx installs (#3799). // Only applicable when a projectDir is known; other runtimes don't use .claude/. if (projectDir && runtime === 'claude') { const localDir = join(projectDir, '.claude', 'agents'); if (existsSync(localDir)) return localDir; } + const globalDir = join(getRuntimeConfigDir(runtime), 'agents'); + if (existsSync(globalDir)) return globalDir; return globalDir; } diff --git a/tests/bug-3751-init-local-agents.test.cjs b/tests/bug-3751-init-local-agents.test.cjs index a0f4c651f..cab31a0f4 100644 --- a/tests/bug-3751-init-local-agents.test.cjs +++ b/tests/bug-3751-init-local-agents.test.cjs @@ -74,11 +74,12 @@ describe('#3751: resolveAgentsDir() repo-local fallback — structural contracts ); }); - // ─── Contract 3: global takes precedence over repo-local ──────────────── - test('resolveAgentsDir checks global path BEFORE repo-local path', () => { - // The function must return the global path when it exists (non-empty) - // Verified structurally: global resolution (getRuntimeConfigDir) must appear - // before the repo-local fallback reference in the function body. + // ─── Contract 3: local takes precedence over global (#3799) ──────────── + // Supersedes the original #3751 "global-first" contract. Claude Code resolves + // agents local-first at spawn; the SDK must mirror that ordering so a project- + // local install with agents in /.claude/agents always wins over an + // empty or stale global ~/.claude/agents directory. + test('resolveAgentsDir checks project-local path BEFORE global path (#3799)', () => { const fnStart = helpersTs.indexOf('export function resolveAgentsDir'); const fnEnd = helpersTs.indexOf('\nexport function', fnStart + 1); const fnBody = helpersTs.slice(fnStart, fnEnd === -1 ? undefined : fnEnd); @@ -86,11 +87,15 @@ describe('#3751: resolveAgentsDir() repo-local fallback — structural contracts const localIdx = fnBody.search(/projectDir.*claude|\.claude.*projectDir/); assert.ok( globalIdx !== -1, - 'resolveAgentsDir must still call getRuntimeConfigDir for the global path (#3751)', + 'resolveAgentsDir must still call getRuntimeConfigDir for the global path (#3799)', ); assert.ok( - localIdx === -1 || globalIdx < localIdx, - 'global path resolution must appear before repo-local fallback in resolveAgentsDir (#3751)', + localIdx !== -1, + 'resolveAgentsDir must reference the project-local path (#3799)', + ); + assert.ok( + localIdx < globalIdx, + 'project-local path check must appear BEFORE global path in resolveAgentsDir — local-first matches Claude Code agent resolution (#3799)', ); }); @@ -255,3 +260,198 @@ describe('#3751: resolveAgentsDir() repo-local fallback — runtime behaviour', ); }); }); + +// ─── #3799: local-first resolution when both local and global dirs exist ───── +// +// Bug: with a project-local install (agents in /.claude/agents) and an +// empty or stale global ~/.claude/agents, the SDK reported agents_installed: false +// because global-first resolution returned the empty global dir before probing local. +// +// Fix contract: resolveAgentsDir must check /.claude/agents BEFORE +// the global runtime dir. This mirrors Claude Code's own local-first agent +// resolution at spawn time. + +describe('#3799: resolveAgentsDir() local-first resolution — structural contracts', () => { + // ─── Contract A: local-first ordering in function body ────────────────── + test('resolveAgentsDir function body checks project-local .claude/agents before global dir (#3799)', () => { + const fnStart = helpersTs.indexOf('export function resolveAgentsDir'); + const fnEnd = helpersTs.indexOf('\nexport function', fnStart + 1); + const fnBody = helpersTs.slice(fnStart, fnEnd === -1 ? undefined : fnEnd); + + // Both references must exist + const globalIdx = fnBody.indexOf('getRuntimeConfigDir'); + const localIdx = fnBody.search(/join\([^)]*projectDir[^)]*['".]claude['"]|join\([^)]*projectDir[^)]*\.claude|projectDir.*\.claude.*agents|\.claude.*agents.*projectDir/); + + assert.ok(globalIdx !== -1, 'resolveAgentsDir must still call getRuntimeConfigDir (#3799)'); + assert.ok(localIdx !== -1, 'resolveAgentsDir must reference project-local path (#3799)'); + assert.ok( + localIdx < globalIdx, + `project-local path (idx ${localIdx}) must appear before global path (idx ${globalIdx}) in resolveAgentsDir — local-first matches Claude Code resolution (#3799)`, + ); + }); + + // ─── Contract B: GSD_AGENTS_DIR still short-circuits both ─────────────── + test('GSD_AGENTS_DIR still appears before both local and global checks (#3799)', () => { + const fnStart = helpersTs.indexOf('export function resolveAgentsDir'); + const fnEnd = helpersTs.indexOf('\nexport function', fnStart + 1); + const fnBody = helpersTs.slice(fnStart, fnEnd === -1 ? undefined : fnEnd); + + const envIdx = fnBody.indexOf('GSD_AGENTS_DIR'); + const localIdx = fnBody.search(/join\([^)]*projectDir[^)]*['".]claude['"]|join\([^)]*projectDir[^)]*\.claude/); + const globalIdx = fnBody.indexOf('getRuntimeConfigDir'); + + assert.ok(envIdx !== -1, 'GSD_AGENTS_DIR check must still be present (#3799)'); + assert.ok(envIdx < localIdx || localIdx === -1, 'GSD_AGENTS_DIR must appear before local path check (#3799)'); + assert.ok(envIdx < globalIdx, 'GSD_AGENTS_DIR must appear before global path check (#3799)'); + }); +}); + +describe('#3799: resolveAgentsDir() local-first resolution — runtime behaviour', () => { + let tmpDir; + let savedEnv; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3799-')); + savedEnv = { + GSD_AGENTS_DIR: process.env.GSD_AGENTS_DIR, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + }; + delete process.env.GSD_AGENTS_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (savedEnv.GSD_AGENTS_DIR !== undefined) process.env.GSD_AGENTS_DIR = savedEnv.GSD_AGENTS_DIR; + else delete process.env.GSD_AGENTS_DIR; + if (savedEnv.CLAUDE_CONFIG_DIR !== undefined) process.env.CLAUDE_CONFIG_DIR = savedEnv.CLAUDE_CONFIG_DIR; + else delete process.env.CLAUDE_CONFIG_DIR; + }); + + const { runGsdTools } = require('./helpers.cjs'); + + /** + * RED test (local wins over empty global): When the project has .claude/agents + * populated and the global dir exists but is EMPTY, the SDK must resolve to the + * local dir and report agents_installed: true. + * + * This is the canonical #3799 scenario: npx install puts agents in project-local, + * but Claude auto-creates an empty ~/.claude/agents at startup — old global-first + * code picked up the empty global dir and reported false. + */ + test('reports agents_installed: true when local .claude/agents has agents and global dir is empty (#3799)', () => { + // Set up fake global config dir WITH an empty agents/ subdir + const fakeGlobalConfig = path.join(tmpDir, 'fake-global-claude'); + const fakeGlobalAgents = path.join(fakeGlobalConfig, 'agents'); + fs.mkdirSync(fakeGlobalAgents, { recursive: true }); + // global agents/ exists but is EMPTY — simulates Claude auto-creating the dir + process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig; + + // Set up project-local .claude/agents with a GSD agent definition + const repoRoot = path.join(tmpDir, 'repo'); + const repoLocalAgentsDir = path.join(repoRoot, '.claude', 'agents'); + fs.mkdirSync(repoLocalAgentsDir, { recursive: true }); + fs.writeFileSync( + path.join(repoLocalAgentsDir, 'gsd-project-researcher.md'), + '---\nname: gsd-project-researcher\ndescription: test\ntools: Read\n---\nAgent content.\n', + ); + + const result = runGsdTools( + ['query', 'init.new-project', '--raw'], + repoRoot, + { + CLAUDE_CONFIG_DIR: fakeGlobalConfig, + }, + ); + + if (result.success) { + let parsed; + try { parsed = JSON.parse(result.output); } catch { return; } + if (parsed && typeof parsed.agents_dir !== 'undefined') { + // agents_dir must point to the local dir, not the empty global dir + assert.strictEqual( + parsed.agents_dir, + repoLocalAgentsDir, + `agents_dir must resolve to project-local path, got: ${parsed.agents_dir} (#3799)`, + ); + } + } + // Structural contract (Contract A above) is the authoritative RED gate when + // gsd-tools query is not available. + }); + + /** + * Global-only regression: when no project-local .claude/agents exists but a + * populated global dir does, the global dir must still be used (no regression). + */ + test('global-only install: resolves to global dir when no project-local .claude/agents (#3799 no-regression)', () => { + const fakeGlobalConfig = path.join(tmpDir, 'fake-global-claude-populated'); + const fakeGlobalAgents = path.join(fakeGlobalConfig, 'agents'); + fs.mkdirSync(fakeGlobalAgents, { recursive: true }); + fs.writeFileSync( + path.join(fakeGlobalAgents, 'gsd-project-researcher.md'), + '---\nname: gsd-project-researcher\n---\n', + ); + process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig; + + // Repo with NO .claude/agents + const repoRoot = path.join(tmpDir, 'repo-global-only'); + fs.mkdirSync(repoRoot, { recursive: true }); + + const result = runGsdTools( + ['query', 'init.new-project', '--raw'], + repoRoot, + { + CLAUDE_CONFIG_DIR: fakeGlobalConfig, + }, + ); + + if (result.success) { + let parsed; + try { parsed = JSON.parse(result.output); } catch { return; } + if (parsed && typeof parsed.agents_dir !== 'undefined') { + assert.strictEqual( + parsed.agents_dir, + fakeGlobalAgents, + `agents_dir must resolve to global path when no local dir exists, got: ${parsed.agents_dir} (#3799)`, + ); + } + } + // Structural contracts provide the authoritative gate when gsd-tools is unavailable. + }); + + /** + * Both local and global populated: local wins (Claude Code parity). + */ + test('local wins over populated global when both .claude/agents dirs exist (#3799)', () => { + const fakeGlobalConfig = path.join(tmpDir, 'fake-global-both'); + const fakeGlobalAgents = path.join(fakeGlobalConfig, 'agents'); + fs.mkdirSync(fakeGlobalAgents, { recursive: true }); + fs.writeFileSync(path.join(fakeGlobalAgents, 'gsd-project-researcher.md'), '# global agent\n'); + process.env.CLAUDE_CONFIG_DIR = fakeGlobalConfig; + + const repoRoot = path.join(tmpDir, 'repo-both'); + const repoLocalAgentsDir = path.join(repoRoot, '.claude', 'agents'); + fs.mkdirSync(repoLocalAgentsDir, { recursive: true }); + fs.writeFileSync(path.join(repoLocalAgentsDir, 'gsd-project-researcher.md'), '# local agent\n'); + + const result = runGsdTools( + ['query', 'init.new-project', '--raw'], + repoRoot, + { + CLAUDE_CONFIG_DIR: fakeGlobalConfig, + }, + ); + + if (result.success) { + let parsed; + try { parsed = JSON.parse(result.output); } catch { return; } + if (parsed && typeof parsed.agents_dir !== 'undefined') { + assert.strictEqual( + parsed.agents_dir, + repoLocalAgentsDir, + `agents_dir must resolve to local path when both exist, got: ${parsed.agents_dir} (#3799)`, + ); + } + } + }); +});