From 1421dc07bc1033f1b69e49d85cb6d625fd696b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 27 Mar 2026 09:57:54 -0600 Subject: [PATCH] fix: resolve gsd-tools.cjs from repo-local installation before global fallback (#1425) Probe /.claude/get-shit-done/bin/gsd-tools.cjs before falling back to ~/.claude/get-shit-done/bin/gsd-tools.cjs, fixing MODULE_NOT_FOUND for repo-local GSD installations. Also adds repo-local agent definition path. Closes #1424 Co-authored-by: Claude Opus 4.6 (1M context) --- sdk/src/gsd-tools.test.ts | 40 ++++++++++++++++++++++++++++++-- sdk/src/gsd-tools.ts | 16 +++++++++++-- sdk/src/index.ts | 12 ++++++---- sdk/src/milestone-runner.test.ts | 1 + sdk/src/types.ts | 2 +- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts index eb3a49032..3134387d1 100644 --- a/sdk/src/gsd-tools.test.ts +++ b/sdk/src/gsd-tools.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { GSDTools, GSDToolsError } from './gsd-tools.js'; +import { GSDTools, GSDToolsError, resolveGsdToolsPath } from './gsd-tools.js'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; describe('GSDTools', () => { let tmpDir: string; @@ -318,6 +318,42 @@ describe('GSDTools', () => { }); }); + // ─── resolveGsdToolsPath() tests ──────────────────────────────────────── + + describe('resolveGsdToolsPath()', () => { + it('returns repo-local path when it exists', async () => { + const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin'); + await mkdir(localBinDir, { recursive: true }); + await writeFile(join(localBinDir, 'gsd-tools.cjs'), '// stub'); + + const result = resolveGsdToolsPath(tmpDir); + expect(result).toBe(join(localBinDir, 'gsd-tools.cjs')); + }); + + it('falls back to global path when repo-local does not exist', () => { + const result = resolveGsdToolsPath(tmpDir); + expect(result).toBe( + join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'), + ); + }); + + it('constructor uses repo-local path when available', async () => { + const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin'); + await mkdir(localBinDir, { recursive: true }); + const scriptPath = join(localBinDir, 'gsd-tools.cjs'); + await writeFile( + scriptPath, + `process.stdout.write(JSON.stringify({ source: "local" }));`, + { mode: 0o755 }, + ); + + // No explicit gsdToolsPath — should auto-resolve to local + const tools = new GSDTools({ projectDir: tmpDir }); + const result = await tools.exec('test', []); + expect(result).toEqual({ source: 'local' }); + }); + }); + // ─── configSet() tests ───────────────────────────────────────────────── describe('configSet()', () => { diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts index 75484af71..90d2f1df1 100644 --- a/sdk/src/gsd-tools.ts +++ b/sdk/src/gsd-tools.ts @@ -7,6 +7,7 @@ import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis } from './types.js'; @@ -42,8 +43,7 @@ export class GSDTools { }) { this.projectDir = opts.projectDir; this.gsdToolsPath = - opts.gsdToolsPath ?? - join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + opts.gsdToolsPath ?? resolveGsdToolsPath(opts.projectDir); this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; } @@ -281,3 +281,15 @@ export class GSDTools { return this.execRaw('config-set', [key, value]); } } + +// ─── Path resolution ──────────────────────────────────────────────────────── + +/** + * Resolve gsd-tools.cjs path with repo-local fallback. + * Probe order: repo-local → global home directory. + */ +export function resolveGsdToolsPath(projectDir: string): string { + const localPath = join(projectDir, '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + if (existsSync(localPath)) return localPath; + return join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'); +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d13353e9b..a8eaa3894 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -27,7 +27,7 @@ import type { GSDOptions, PlanResult, SessionOptions, GSDEvent, TransportHandler import { GSDEventType } from './types.js'; import { parsePlan, parsePlanFile } from './plan-parser.js'; import { loadConfig } from './config.js'; -import { GSDTools } from './gsd-tools.js'; +import { GSDTools, resolveGsdToolsPath } from './gsd-tools.js'; import { runPlanSession } from './session-runner.js'; import { buildExecutorPrompt, parseAgentTools } from './prompt-builder.js'; import { GSDEventStream } from './event-stream.js'; @@ -49,8 +49,7 @@ export class GSD { constructor(options: GSDOptions) { this.projectDir = resolve(options.projectDir); this.gsdToolsPath = - options.gsdToolsPath ?? - join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'); + options.gsdToolsPath ?? resolveGsdToolsPath(this.projectDir); this.defaultModel = options.model; this.defaultMaxBudgetUsd = options.maxBudgetUsd ?? 5.0; this.defaultMaxTurns = options.maxTurns ?? 50; @@ -261,6 +260,11 @@ export class GSD { */ private async loadAgentDefinition(): Promise { const paths = [ + // Repo-local GSD installation + join(this.projectDir, '.claude', 'get-shit-done', 'agents', 'gsd-executor.md'), + // Repo-local agents directory + join(this.projectDir, '.claude', 'agents', 'gsd-executor.md'), + // Global home directory join(homedir(), '.claude', 'agents', 'gsd-executor.md'), join(this.projectDir, 'agents', 'gsd-executor.md'), ]; @@ -282,7 +286,7 @@ export class GSD { export { parsePlan, parsePlanFile } from './plan-parser.js'; export { loadConfig } from './config.js'; export type { GSDConfig } from './config.js'; -export { GSDTools, GSDToolsError } from './gsd-tools.js'; +export { GSDTools, GSDToolsError, resolveGsdToolsPath } from './gsd-tools.js'; export { runPlanSession, runPhaseStepSession } from './session-runner.js'; export { buildExecutorPrompt, parseAgentTools } from './prompt-builder.js'; export * from './types.js'; diff --git a/sdk/src/milestone-runner.test.ts b/sdk/src/milestone-runner.test.ts index 7693cb8ce..1e0a41492 100644 --- a/sdk/src/milestone-runner.test.ts +++ b/sdk/src/milestone-runner.test.ts @@ -71,6 +71,7 @@ vi.mock('./gsd-tools.js', () => ({ GSDToolsError: class extends Error { name = 'GSDToolsError'; }, + resolveGsdToolsPath: vi.fn().mockReturnValue('/mock/gsd-tools.cjs'), })); import { GSD } from './index.js'; diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 47f01f7d0..4894f7c10 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -197,7 +197,7 @@ export interface PlanResult { export interface GSDOptions { /** Root directory of the project. */ projectDir: string; - /** Path to gsd-tools.cjs. Falls back to ~/.claude/get-shit-done/bin/gsd-tools.cjs. */ + /** Path to gsd-tools.cjs. Falls back to /.claude/ then ~/.claude/. */ gsdToolsPath?: string; /** Model to use for execution sessions. */ model?: string;