#!/usr/bin/env node /** * CLI entry point for gsd-sdk. * * Usage: gsd-sdk run "" [--project-dir ] [--ws-port ] * [--model ] [--max-budget ] */ import { parseArgs } from 'node:util'; import { execFile } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { resolve, join, isAbsolute } 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'; import { validateWorkstreamName } from './workstream-utils.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; /** For 'auto --init': bootstrap from a PRD before running the autonomous loop. */ init: string | undefined; projectDir: string; wsPort: number | undefined; model: string | undefined; maxBudget: number | undefined; /** Workstream name for multi-workstream projects. Routes .planning/ to .planning/workstreams//. */ ws: string | undefined; help: boolean; version: boolean; /** * When `command === 'query'`, tokens after `query` with only known SDK flags removed. * Extra flags are kept so handlers that share gsd-tools-style argv (e.g. `--pick`) still receive them. */ queryArgv?: string[]; } /** * Parse `gsd-sdk query …` without rejecting unknown flags (query argv is forwarded to the registry). */ function parseCliArgsQueryPermissive(argv: string[]): ParsedCliArgs { let projectDir = process.cwd(); let ws: string | undefined; let wsPort: number | undefined; let model: string | undefined; let maxBudget: number | undefined; let help = false; let version = false; const queryArgv: string[] = []; let i = 1; while (i < argv.length) { const a = argv[i]; if (a === '--project-dir' && argv[i + 1]) { projectDir = argv[i + 1]; i += 2; continue; } if (a === '--ws' && argv[i + 1]) { ws = argv[i + 1]; i += 2; continue; } if (a === '--ws-port' && argv[i + 1]) { wsPort = Number(argv[i + 1]); i += 2; continue; } if (a === '--model' && argv[i + 1]) { model = argv[i + 1]; i += 2; continue; } if (a === '--max-budget' && argv[i + 1]) { maxBudget = Number(argv[i + 1]); i += 2; continue; } if (a === '-h' || a === '--help') { help = true; i += 1; continue; } if (a === '-v' || a === '--version') { version = true; i += 1; continue; } queryArgv.push(a); i += 1; } return { command: 'query', prompt: undefined, initInput: undefined, init: undefined, projectDir, wsPort, model, maxBudget, ws, help, version, queryArgv, }; } /** * Parse CLI arguments into a structured object. * Exported for testing — the main() function uses this internally. */ export function parseCliArgs(argv: string[]): ParsedCliArgs { if (argv[0] === 'query') { return parseCliArgsQueryPermissive(argv); } const { values, positionals } = parseArgs({ args: argv, options: { 'project-dir': { type: 'string', default: process.cwd() }, 'ws-port': { type: 'string' }, ws: { type: 'string' }, model: { type: 'string' }, 'max-budget': { type: 'string' }, init: { 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, init: values.init as string | undefined, 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, ws: values.ws as string | undefined, help: values.help as boolean, version: values.version as boolean, }; } // ─── Usage ─────────────────────────────────────────────────────────────────── export const USAGE = ` Usage: gsd-sdk [args] [options] Commands: run 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 query Registered query handlers only (longest-prefix argv match; see QUERY-HANDLERS.md) Use --pick to extract a specific field from JSON output Options: --init Bootstrap from a PRD before running (auto only) Accepts @path/to/prd.md or "description text" --project-dir Project directory (default: cwd) --ws Route .planning/ to .planning/workstreams// --ws-port Enable WebSocket transport on --model Override LLM model --max-budget 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 { 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 { 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 { 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((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); }); } /** When false, unknown `gsd-sdk query` commands error instead of shelling out to gsd-tools.cjs. */ function queryFallbackToCjsEnabled(): boolean { const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase(); if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false; return true; } async function parseCliQueryJsonOutput(raw: string, projectDir: string): Promise { const trimmed = raw.trim(); if (trimmed === '') return null; let jsonStr = trimmed; if (jsonStr.startsWith('@file:')) { const rel = jsonStr.slice(6).trim(); const { resolvePathUnderProject } = await import('./query/helpers.js'); const filePath = await resolvePathUnderProject(projectDir, rel); jsonStr = await readFile(filePath, 'utf-8'); } return JSON.parse(jsonStr); } /** Map registry-style dotted command tokens to gsd-tools.cjs argv (space-separated subcommands). */ function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] { if (normCmd.includes('.')) { return [...normCmd.split('.'), ...normArgs]; } return [normCmd, ...normArgs]; } function execGsdToolsCjsQuery( projectDir: string, gsdToolsPath: string, normCmd: string, normArgs: string[], ws: string | undefined, ): Promise<{ stdout: string; stderr: string }> { const cjsArgv = dottedCommandToCjsArgv(normCmd, normArgs); const wsSuffix = ws ? ['--ws', ws] : []; const fullArgv = [gsdToolsPath, ...cjsArgv, ...wsSuffix]; return new Promise((resolve, reject) => { execFile( process.execPath, fullArgv, { cwd: projectDir, maxBuffer: 10 * 1024 * 1024, env: { ...process.env } }, (err, stdout, stderr) => { if (err) reject(err); else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' }); }, ); }); } // ─── Main ──────────────────────────────────────────────────────────────────── export async function main(argv: string[] = process.argv.slice(2)): Promise { 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; } // Validate --ws flag if provided if (args.ws !== undefined && !validateWorkstreamName(args.ws)) { console.error(`Error: Invalid workstream name "${args.ws}". Use alphanumeric, hyphens, underscores, or dots only.`); process.exitCode = 1; return; } // ─── Query command ────────────────────────────────────────────────────── if (args.command === 'query') { const { createRegistry } = await import('./query/index.js'); const { extractField, resolveQueryArgv } = await import('./query/registry.js'); const { GSDToolsError } = await import('./gsd-tools.js'); const { GSDError, exitCodeFor, ErrorClassification } = await import('./errors.js'); const queryArgs = args.queryArgv ?? []; // Extract --pick before dispatch const pickIdx = queryArgs.indexOf('--pick'); let pickField: string | undefined; if (pickIdx !== -1) { if (pickIdx + 1 >= queryArgs.length) { console.error('Error: --pick requires a field name'); process.exitCode = 10; return; } pickField = queryArgs[pickIdx + 1]; queryArgs.splice(pickIdx, 2); } if (queryArgs.length === 0 || !queryArgs[0]) { console.error('Error: "gsd-sdk query" requires a command'); process.exitCode = 10; return; } try { const queryCommand = queryArgs[0]; const { normalizeQueryCommand } = await import('./query/normalize-query-command.js'); const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgs.slice(1)); if (!normCmd || !String(normCmd).trim()) { console.error('Error: "gsd-sdk query" requires a command'); process.exitCode = 10; return; } const registry = createRegistry(); const tokens = [normCmd, ...normArgs]; const matched = resolveQueryArgv(tokens, registry); if (!matched) { if (!queryFallbackToCjsEnabled()) { throw new GSDError( `Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations. Set GSD_QUERY_FALLBACK=registered (default) to allow automatic fallback.`, ErrorClassification.Validation, ); } const { resolveGsdToolsPath } = await import('./gsd-tools.js'); const gsdPath = resolveGsdToolsPath(args.projectDir); console.error( `[gsd-sdk] '${tokens.join(' ')}' not in native registry; falling back to gsd-tools.cjs.`, ); console.error('[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.'); const { stdout, stderr } = await execGsdToolsCjsQuery( args.projectDir, gsdPath, normCmd, normArgs, args.ws, ); if (stderr.trim()) console.error(stderr.trimEnd()); let output: unknown = await parseCliQueryJsonOutput(stdout, args.projectDir); if (pickField) { output = extractField(output, pickField); } console.log(JSON.stringify(output, null, 2)); } else { const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws); let output: unknown = result.data; if (pickField) { output = extractField(output, pickField); } console.log(JSON.stringify(output, null, 2)); } } catch (err) { if (err instanceof GSDError) { console.error(`Error: ${err.message}`); process.exitCode = exitCodeFor(err.classification); } else if (err instanceof GSDToolsError) { console.error(`Error: ${err.message}`); process.exitCode = err.exitCode ?? 1; } else { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; } } return; } if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') { console.error('Error: Expected "gsd-sdk run ", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query "'); 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, workstream: args.ws, }); // 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, workstream: args.ws, }); // 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 { // If --init provided, bootstrap project first if (args.init) { const initInput = await resolveInitInput({ ...args, command: 'init', initInput: args.init, }); console.log(`[auto] Bootstrapping project from --init (${initInput.length} chars)`); const tools = gsd.createTools(); const runner = new InitRunner({ projectDir: args.projectDir, tools, eventStream: gsd.eventStream, config: { maxBudgetPerSession: args.maxBudget, orchestratorModel: args.model, }, }); const initResult = await runner.run(initInput); const initStatus = initResult.success ? 'SUCCESS' : 'FAILED'; const stepCount = initResult.steps.length; const passedSteps = initResult.steps.filter(s => s.success).length; const initCost = initResult.totalCostUsd.toFixed(2); const initDuration = (initResult.totalDurationMs / 1000).toFixed(1); console.log(`[init ${initStatus}] ${passedSteps}/${stepCount} steps, $${initCost}, ${initDuration}s`); if (!initResult.success) { for (const step of initResult.steps) { if (!step.success && step.error) { console.error(` ✗ ${step.step}: ${step.error}`); } } process.exitCode = 1; return; } } 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, workstream: args.ws, }); // 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();