fix: resolve gsd-tools.cjs from repo-local installation before global fallback (#1425)

Probe <projectDir>/.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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES
2026-03-27 09:57:54 -06:00
committed by GitHub
parent 38c18ac68a
commit 1421dc07bc
5 changed files with 62 additions and 9 deletions

View File

@@ -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()', () => {

View File

@@ -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');
}

View File

@@ -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<string | undefined> {
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';

View File

@@ -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';

View File

@@ -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 <projectDir>/.claude/ then ~/.claude/. */
gsdToolsPath?: string;
/** Model to use for execution sessions. */
model?: string;