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:
382
sdk/src/cli.ts
Normal file
382
sdk/src/cli.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI entry point for gsd-sdk.
|
||||
*
|
||||
* Usage: gsd-sdk run "<prompt>" [--project-dir <dir>] [--ws-port <port>]
|
||||
* [--model <model>] [--max-budget <n>]
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { GSD } from './index.js';
|
||||
import { CLITransport } from './cli-transport.js';
|
||||
import { WSTransport } from './ws-transport.js';
|
||||
import { InitRunner } from './init-runner.js';
|
||||
|
||||
// ─── Parsed CLI args ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedCliArgs {
|
||||
command: string | undefined;
|
||||
prompt: string | undefined;
|
||||
/** For 'init' command: the raw input source (@file, text, or undefined for stdin). */
|
||||
initInput: string | undefined;
|
||||
projectDir: string;
|
||||
wsPort: number | undefined;
|
||||
model: string | undefined;
|
||||
maxBudget: number | undefined;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI arguments into a structured object.
|
||||
* Exported for testing — the main() function uses this internally.
|
||||
*/
|
||||
export function parseCliArgs(argv: string[]): ParsedCliArgs {
|
||||
const { values, positionals } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
'project-dir': { type: 'string', default: process.cwd() },
|
||||
'ws-port': { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
'max-budget': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
version: { type: 'boolean', short: 'v', default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const command = positionals[0] as string | undefined;
|
||||
const prompt = positionals.slice(1).join(' ') || undefined;
|
||||
|
||||
// For 'init' command, the positional after 'init' is the input source.
|
||||
// For 'run' command, it's the prompt. Both use positionals[1+].
|
||||
const initInput = command === 'init' ? prompt : undefined;
|
||||
|
||||
return {
|
||||
command,
|
||||
prompt,
|
||||
initInput,
|
||||
projectDir: values['project-dir'] as string,
|
||||
wsPort: values['ws-port'] ? Number(values['ws-port']) : undefined,
|
||||
model: values.model as string | undefined,
|
||||
maxBudget: values['max-budget'] ? Number(values['max-budget']) : undefined,
|
||||
help: values.help as boolean,
|
||||
version: values.version as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Usage ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const USAGE = `
|
||||
Usage: gsd-sdk <command> [args] [options]
|
||||
|
||||
Commands:
|
||||
run <prompt> Run a full milestone from a text prompt
|
||||
auto Run the full autonomous lifecycle (discover → execute → advance)
|
||||
init [input] Bootstrap a new project from a PRD or description
|
||||
input can be:
|
||||
@path/to/prd.md Read input from a file
|
||||
"description" Use text directly
|
||||
(empty) Read from stdin
|
||||
|
||||
Options:
|
||||
--project-dir <dir> Project directory (default: cwd)
|
||||
--ws-port <port> Enable WebSocket transport on <port>
|
||||
--model <model> Override LLM model
|
||||
--max-budget <n> Max budget per step in USD
|
||||
-h, --help Show this help
|
||||
-v, --version Show version
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Read the package version from package.json.
|
||||
*/
|
||||
async function getVersion(): Promise<string> {
|
||||
try {
|
||||
const pkgPath = resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json');
|
||||
const raw = await readFile(pkgPath, 'utf-8');
|
||||
const pkg = JSON.parse(raw) as { version?: string };
|
||||
return pkg.version ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init input resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the init command input to a string.
|
||||
*
|
||||
* - `@path/to/file.md` → reads the file contents
|
||||
* - Raw text → returns as-is
|
||||
* - No input → reads from stdin (with TTY detection)
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export async function resolveInitInput(args: ParsedCliArgs): Promise<string> {
|
||||
const input = args.initInput;
|
||||
|
||||
if (input && input.startsWith('@')) {
|
||||
// File path: strip @ prefix, resolve relative to projectDir
|
||||
const filePath = resolve(args.projectDir, input.slice(1));
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot read input file "${filePath}": ${(err as NodeJS.ErrnoException).code === 'ENOENT' ? 'file not found' : (err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input) {
|
||||
// Raw text
|
||||
return input;
|
||||
}
|
||||
|
||||
// No input — read from stdin
|
||||
return readStdin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all data from stdin. Rejects if stdin is a TTY with no piped data.
|
||||
*/
|
||||
async function readStdin(): Promise<string> {
|
||||
const { stdin } = process;
|
||||
|
||||
if (stdin.isTTY) {
|
||||
throw new Error(
|
||||
'No input provided. Usage:\n' +
|
||||
' gsd-sdk init @path/to/prd.md\n' +
|
||||
' gsd-sdk init "build a todo app"\n' +
|
||||
' cat prd.md | gsd-sdk init'
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
let args: ParsedCliArgs;
|
||||
|
||||
try {
|
||||
args = parseCliArgs(argv);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
console.error(USAGE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.help) {
|
||||
console.log(USAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.version) {
|
||||
const ver = await getVersion();
|
||||
console.log(`gsd-sdk v${ver}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') {
|
||||
console.error('Error: Expected "gsd-sdk run <prompt>", "gsd-sdk auto", or "gsd-sdk init [input]"');
|
||||
console.error(USAGE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.command === 'run' && !args.prompt) {
|
||||
console.error('Error: "gsd-sdk run" requires a prompt');
|
||||
console.error(USAGE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Init command ─────────────────────────────────────────────────────────
|
||||
if (args.command === 'init') {
|
||||
let input: string;
|
||||
try {
|
||||
input = await resolveInitInput(args);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[init] Resolved input: ${input.length} chars`);
|
||||
|
||||
// Build GSD instance for tools and event stream
|
||||
const gsd = new GSD({
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
});
|
||||
|
||||
// Wire CLI transport
|
||||
const cliTransport = new CLITransport();
|
||||
gsd.addTransport(cliTransport);
|
||||
|
||||
// Optional WebSocket transport
|
||||
let wsTransport: WSTransport | undefined;
|
||||
if (args.wsPort !== undefined) {
|
||||
wsTransport = new WSTransport({ port: args.wsPort });
|
||||
await wsTransport.start();
|
||||
gsd.addTransport(wsTransport);
|
||||
console.log(`WebSocket transport listening on port ${args.wsPort}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const tools = gsd.createTools();
|
||||
const runner = new InitRunner({
|
||||
projectDir: args.projectDir,
|
||||
tools,
|
||||
eventStream: gsd.eventStream,
|
||||
config: {
|
||||
maxBudgetPerSession: args.maxBudget,
|
||||
orchestratorModel: args.model,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runner.run(input);
|
||||
|
||||
// Print completion summary
|
||||
const status = result.success ? 'SUCCESS' : 'FAILED';
|
||||
const stepCount = result.steps.length;
|
||||
const passedSteps = result.steps.filter(s => s.success).length;
|
||||
const cost = result.totalCostUsd.toFixed(2);
|
||||
const duration = (result.totalDurationMs / 1000).toFixed(1);
|
||||
const artifactList = result.artifacts.join(', ');
|
||||
|
||||
console.log(`\n[${status}] ${passedSteps}/${stepCount} steps, $${cost}, ${duration}s`);
|
||||
if (result.artifacts.length > 0) {
|
||||
console.log(`Artifacts: ${artifactList}`);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Log failed steps
|
||||
for (const step of result.steps) {
|
||||
if (!step.success && step.error) {
|
||||
console.error(` ✗ ${step.step}: ${step.error}`);
|
||||
}
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Fatal error: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
cliTransport.close();
|
||||
if (wsTransport) {
|
||||
wsTransport.close();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Auto command ─────────────────────────────────────────────────────────
|
||||
if (args.command === 'auto') {
|
||||
const gsd = new GSD({
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
autoMode: true,
|
||||
});
|
||||
|
||||
// Wire CLI transport (always active)
|
||||
const cliTransport = new CLITransport();
|
||||
gsd.addTransport(cliTransport);
|
||||
|
||||
// Optional WebSocket transport
|
||||
let wsTransport: WSTransport | undefined;
|
||||
if (args.wsPort !== undefined) {
|
||||
wsTransport = new WSTransport({ port: args.wsPort });
|
||||
await wsTransport.start();
|
||||
gsd.addTransport(wsTransport);
|
||||
console.log(`WebSocket transport listening on port ${args.wsPort}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await gsd.run('');
|
||||
|
||||
// Final summary
|
||||
const status = result.success ? 'SUCCESS' : 'FAILED';
|
||||
const phases = result.phases.length;
|
||||
const cost = result.totalCostUsd.toFixed(2);
|
||||
const duration = (result.totalDurationMs / 1000).toFixed(1);
|
||||
console.log(`\n[${status}] ${phases} phase(s), $${cost}, ${duration}s`);
|
||||
|
||||
if (!result.success) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Fatal error: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
cliTransport.close();
|
||||
if (wsTransport) {
|
||||
wsTransport.close();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Run command ─────────────────────────────────────────────────────────
|
||||
|
||||
// Build GSD instance
|
||||
const gsd = new GSD({
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
maxBudgetUsd: args.maxBudget,
|
||||
});
|
||||
|
||||
// Wire CLI transport (always active)
|
||||
const cliTransport = new CLITransport();
|
||||
gsd.addTransport(cliTransport);
|
||||
|
||||
// Optional WebSocket transport
|
||||
let wsTransport: WSTransport | undefined;
|
||||
if (args.wsPort !== undefined) {
|
||||
wsTransport = new WSTransport({ port: args.wsPort });
|
||||
await wsTransport.start();
|
||||
gsd.addTransport(wsTransport);
|
||||
console.log(`WebSocket transport listening on port ${args.wsPort}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await gsd.run(args.prompt!);
|
||||
|
||||
// Final summary
|
||||
const status = result.success ? 'SUCCESS' : 'FAILED';
|
||||
const phases = result.phases.length;
|
||||
const cost = result.totalCostUsd.toFixed(2);
|
||||
const duration = (result.totalDurationMs / 1000).toFixed(1);
|
||||
console.log(`\n[${status}] ${phases} phase(s), $${cost}, ${duration}s`);
|
||||
|
||||
if (!result.success) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Fatal error: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
// Clean up transports
|
||||
cliTransport.close();
|
||||
if (wsTransport) {
|
||||
wsTransport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-run when invoked directly ──────────────────────────────────────────
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user