fix(3799): detect project-local agents in init.* (local-first resolution) (#82)
* fix(3799): detect project-local agents in init.* (local-first resolution) Reverses resolution order in resolveAgentsDir() so project-local <projectDir>/.claude/agents is checked BEFORE the global runtime dir. Claude Code auto-creates ~/.claude/agents at startup (empty); the old global-first check returned that empty dir over a populated local dir. Adds 5 tests to tests/bug-3751-init-local-agents.test.cjs: - Structural: local-first ordering in function body (#3799 RED gate) - Runtime: local+empty-global → agents_dir = local - Runtime: global-only (no local) → agents_dir = global (no regression) - Runtime: both present → local wins (Claude Code parity) - Updated Contract 3 assertion to reflect new local-first ordering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changeset for fix(3799) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/quick-cranes-purr.md
Normal file
5
.changeset/quick-cranes-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3799
|
||||
---
|
||||
project-local agents now detected correctly in init.* queries — resolveAgentsDir() checks `<projectDir>/.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)
|
||||
@@ -109,27 +109,31 @@ export function detectRuntime(config?: { runtime?: unknown }): Runtime {
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `GSD_AGENTS_DIR` — explicit SDK override (wins over runtime selection)
|
||||
* 2. `<getRuntimeConfigDir(runtime)>/agents` — installer-parity default (when the dir exists)
|
||||
* 3. `<projectDir>/.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. `<projectDir>/.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. `<getRuntimeConfigDir(runtime)>/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: <projectDir>/.claude/agents for --local Claude installs (#3751).
|
||||
// Project-local first: <projectDir>/.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <projectDir>/.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 <project>/.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 <projectDir>/.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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user