feat: GSD SDK — headless CLI with init + auto commands (#1407)
* test: Bootstrapped sdk/ as TypeScript ESM package with full GSD-1 PLAN.… - "sdk/package.json" - "sdk/tsconfig.json" - "sdk/vitest.config.ts" - "sdk/src/types.ts" - "sdk/src/plan-parser.ts" - "sdk/src/plan-parser.test.ts" GSD-Task: S01/T01 * test: Implemented config reader and gsd-tools bridge with 25 unit tests… - "sdk/src/config.ts" - "sdk/src/config.test.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T02 * test: Built prompt-builder, session-runner, and GSD class — 85 total un… - "sdk/src/prompt-builder.ts" - "sdk/src/prompt-builder.test.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S01/T03 * test: Created E2E integration test with fixtures proving full SDK pipel… - "sdk/src/e2e.integration.test.ts" - "sdk/test-fixtures/sample-plan.md" - "sdk/test-fixtures/.planning/config.json" - "sdk/test-fixtures/.planning/STATE.md" - "vitest.config.ts" - "tsconfig.json" GSD-Task: S01/T04 * test: Added PhaseType/GSDEventType enums, 16-variant GSDEvent union, GS… - "sdk/src/types.ts" - "sdk/src/event-stream.ts" - "sdk/src/logger.ts" - "sdk/src/event-stream.test.ts" - "sdk/src/logger.test.ts" GSD-Task: S02/T01 * test: Built ContextEngine for phase-aware context file resolution, getT… - "sdk/src/context-engine.ts" - "sdk/src/tool-scoping.ts" - "sdk/src/phase-prompt.ts" - "sdk/src/context-engine.test.ts" - "sdk/src/tool-scoping.test.ts" - "sdk/src/phase-prompt.test.ts" GSD-Task: S02/T02 * test: Wired event stream into session runner, added onEvent()/addTransp… - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/e2e.integration.test.ts" GSD-Task: S02/T03 * feat: Added PhaseStepType enum, PhaseOpInfo interface, phase lifecycle… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S03/T01 * test: Implemented PhaseRunner state machine with 39 unit tests covering… - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S03/T02 * test: Wired PhaseRunner into GSD.runPhase() public API with full re-exp… - "sdk/src/index.ts" - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/phase-runner.ts" GSD-Task: S03/T03 * test: Expanded runVerifyStep with full gap closure cycle (plan → execut… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S04/T02 * fix: Added 3 integration tests proving phasePlanIndex returns correct t… - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/index.ts" GSD-Task: S04/T03 * test: Add milestone-level types, typed roadmapAnalyze(), GSD.run() orch… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/index.ts" - "sdk/src/milestone-runner.test.ts" GSD-Task: S05/T01 * test: Added CLITransport (structured stdout log lines) and WSTransport… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" - "sdk/src/ws-transport.ts" - "sdk/src/ws-transport.test.ts" - "sdk/src/index.ts" - "sdk/package.json" GSD-Task: S05/T02 * test: Added gsd-sdk CLI entry point with argument parsing, bin field, p… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/package.json" GSD-Task: S05/T03 * feat: Add InitNewProjectInfo type, initNewProject()/configSet() GSDTool… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T01 * chore: Created InitRunner orchestrator with setup → config → PROJECT.md… - "sdk/src/init-runner.ts" - "sdk/src/types.ts" - "sdk/src/index.ts" GSD-Task: S01/T02 * test: Wired InitRunner into CLI main() for full gsd-sdk init dispatch a… - "sdk/src/cli.ts" - "sdk/src/init-runner.test.ts" - "sdk/src/cli.test.ts" GSD-Task: S01/T03 * test: Add PlanCheck step, AI self-discuss, and retryOnce wrapper to Pha… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/session-runner.ts" - "sdk/src/phase-runner.test.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S02/T01 * feat: Rewrite CLITransport with ANSI colors, phase banners, spawn indic… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" GSD-Task: S02/T02 * test: Add `gsd-sdk auto` command with autoMode config override, USAGE t… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S02/T03 * fix: CLI shebang + gsd-tools non-JSON output handling Three bugs found during first real gsd-sdk run: 1. cli.ts shebang was commented out — shell executed JS as bash, triggering ImageMagick's import command instead of Node 2. configSet() called exec() which JSON.parse()d the output, but gsd-tools config-set returns 'key=value' text, not JSON. Added execRaw() method for commands that return plain text. 3. Same JSON parse bug affected commit() (returns git SHA), stateLoad(), verifySummary(), initExecutePhase(), stateBeginPhase(), and phaseComplete(). All switched to execRaw(). Tests updated to match real gsd-tools output format (plain text instead of mocked JSON). 376/376 tests pass.
This commit is contained in:
284
sdk/src/gsd-tools.ts
Normal file
284
sdk/src/gsd-tools.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* GSD Tools Bridge — shells out to `gsd-tools.cjs` for state management.
|
||||
*
|
||||
* All `.planning/` state operations go through gsd-tools.cjs rather than
|
||||
* reimplementing 12K+ lines of logic.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis } from './types.js';
|
||||
|
||||
// ─── Error type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export class GSDToolsError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly command: string,
|
||||
public readonly args: string[],
|
||||
public readonly exitCode: number | null,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GSDToolsError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GSDTools class ──────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class GSDTools {
|
||||
private readonly projectDir: string;
|
||||
private readonly gsdToolsPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(opts: {
|
||||
projectDir: string;
|
||||
gsdToolsPath?: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
this.projectDir = opts.projectDir;
|
||||
this.gsdToolsPath =
|
||||
opts.gsdToolsPath ??
|
||||
join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
||||
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ─── Core exec ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a gsd-tools command and return parsed JSON output.
|
||||
* Appends `--raw` to get machine-readable JSON output.
|
||||
* Handles the `@file:` prefix pattern for large results.
|
||||
*/
|
||||
async exec(command: string, args: string[] = []): Promise<unknown> {
|
||||
const fullArgs = [this.gsdToolsPath, command, ...args, '--raw'];
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'node',
|
||||
fullArgs,
|
||||
{
|
||||
cwd: this.projectDir,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
timeout: this.timeoutMs,
|
||||
env: { ...process.env },
|
||||
},
|
||||
async (error, stdout, stderr) => {
|
||||
const stderrStr = stderr?.toString() ?? '';
|
||||
|
||||
if (error) {
|
||||
// Distinguish timeout from other errors
|
||||
if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`gsd-tools timed out after ${this.timeoutMs}ms: ${command} ${args.join(' ')}`,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
|
||||
command,
|
||||
args,
|
||||
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = stdout?.toString() ?? '';
|
||||
|
||||
try {
|
||||
const parsed = await this.parseOutput(raw);
|
||||
resolve(parsed);
|
||||
} catch (parseErr) {
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`Failed to parse gsd-tools output for "${command}": ${parseErr instanceof Error ? parseErr.message : String(parseErr)}\nRaw output: ${raw.slice(0, 500)}`,
|
||||
command,
|
||||
args,
|
||||
0,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Safety net: kill if child doesn't respond to timeout signal
|
||||
child.on('error', (err) => {
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`Failed to execute gsd-tools: ${err.message}`,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
'',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse gsd-tools output, handling `@file:` prefix.
|
||||
*/
|
||||
private async parseOutput(raw: string): Promise<unknown> {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let jsonStr = trimmed;
|
||||
if (jsonStr.startsWith('@file:')) {
|
||||
const filePath = jsonStr.slice(6).trim();
|
||||
jsonStr = await readFile(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
|
||||
// ─── Raw exec (no JSON parsing) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a gsd-tools command and return raw stdout without JSON parsing.
|
||||
* Use for commands like `config-set` that return plain text, not JSON.
|
||||
*/
|
||||
async execRaw(command: string, args: string[] = []): Promise<string> {
|
||||
const fullArgs = [this.gsdToolsPath, command, ...args, '--raw'];
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'node',
|
||||
fullArgs,
|
||||
{
|
||||
cwd: this.projectDir,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: this.timeoutMs,
|
||||
env: { ...process.env },
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const stderrStr = stderr?.toString() ?? '';
|
||||
if (error) {
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`gsd-tools exited with code ${error.code ?? 'unknown'}: ${command} ${args.join(' ')}${stderrStr ? `\n${stderrStr}` : ''}`,
|
||||
command,
|
||||
args,
|
||||
typeof error.code === 'number' ? error.code : (error as { status?: number }).status ?? 1,
|
||||
stderrStr,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve((stdout?.toString() ?? '').trim());
|
||||
},
|
||||
);
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(
|
||||
new GSDToolsError(
|
||||
`Failed to execute gsd-tools: ${err.message}`,
|
||||
command,
|
||||
args,
|
||||
null,
|
||||
'',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Typed convenience methods ─────────────────────────────────────────
|
||||
|
||||
async stateLoad(): Promise<string> {
|
||||
return this.execRaw('state', ['load']);
|
||||
}
|
||||
|
||||
async roadmapAnalyze(): Promise<RoadmapAnalysis> {
|
||||
return this.exec('roadmap', ['analyze']) as Promise<RoadmapAnalysis>;
|
||||
}
|
||||
|
||||
async phaseComplete(phase: string): Promise<string> {
|
||||
return this.execRaw('phase', ['complete', phase]);
|
||||
}
|
||||
|
||||
async commit(message: string, files?: string[]): Promise<string> {
|
||||
const args = [message];
|
||||
if (files?.length) {
|
||||
args.push('--files', ...files);
|
||||
}
|
||||
return this.execRaw('commit', args);
|
||||
}
|
||||
|
||||
async verifySummary(path: string): Promise<string> {
|
||||
return this.execRaw('verify-summary', [path]);
|
||||
}
|
||||
|
||||
async initExecutePhase(phase: string): Promise<string> {
|
||||
return this.execRaw('state', ['begin-phase', '--phase', phase]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query phase state from gsd-tools.cjs `init phase-op`.
|
||||
* Returns a typed PhaseOpInfo describing what exists on disk for this phase.
|
||||
*/
|
||||
async initPhaseOp(phaseNumber: string): Promise<PhaseOpInfo> {
|
||||
const result = await this.exec('init', ['phase-op', phaseNumber]);
|
||||
return result as PhaseOpInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value from gsd-tools.cjs.
|
||||
*/
|
||||
async configGet(key: string): Promise<string | null> {
|
||||
const result = await this.exec('config', ['get', key]);
|
||||
return result as string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin phase state tracking in gsd-tools.cjs.
|
||||
*/
|
||||
async stateBeginPhase(phaseNumber: string): Promise<string> {
|
||||
return this.execRaw('state', ['begin-phase', '--phase', phaseNumber]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plan index for a phase, grouping plans into dependency waves.
|
||||
* Returns typed PhasePlanIndex with wave assignments and completion status.
|
||||
*/
|
||||
async phasePlanIndex(phaseNumber: string): Promise<PhasePlanIndex> {
|
||||
const result = await this.exec('phase-plan-index', [phaseNumber]);
|
||||
return result as PhasePlanIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query new-project init state from gsd-tools.cjs `init new-project`.
|
||||
* Returns project metadata, model configs, brownfield detection, etc.
|
||||
*/
|
||||
async initNewProject(): Promise<InitNewProjectInfo> {
|
||||
const result = await this.exec('init', ['new-project']);
|
||||
return result as InitNewProjectInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config value via gsd-tools.cjs `config-set`.
|
||||
* Handles type coercion (booleans, numbers, JSON) on the gsd-tools side.
|
||||
* Note: config-set returns `key=value` text, not JSON, so we use execRaw.
|
||||
*/
|
||||
async configSet(key: string, value: string): Promise<string> {
|
||||
return this.execRaw('config-set', [key, value]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user