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:
360
sdk/src/gsd-tools.test.ts
Normal file
360
sdk/src/gsd-tools.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { GSDTools, GSDToolsError } from './gsd-tools.js';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
describe('GSDTools', () => {
|
||||
let tmpDir: string;
|
||||
let fixtureDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `gsd-tools-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
fixtureDir = join(tmpDir, 'fixtures');
|
||||
await mkdir(fixtureDir, { recursive: true });
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Helper: create a Node script that outputs something ────────────────
|
||||
|
||||
async function createScript(name: string, code: string): Promise<string> {
|
||||
const scriptPath = join(fixtureDir, name);
|
||||
await writeFile(scriptPath, code, { mode: 0o755 });
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
// ─── exec() tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('exec()', () => {
|
||||
it('parses valid JSON output', async () => {
|
||||
// Create a script that ignores args and outputs JSON
|
||||
const scriptPath = await createScript(
|
||||
'echo-json.cjs',
|
||||
`process.stdout.write(JSON.stringify({ status: "ok", count: 42 }));`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.exec('state', ['load']);
|
||||
|
||||
expect(result).toEqual({ status: 'ok', count: 42 });
|
||||
});
|
||||
|
||||
it('handles @file: prefix by reading referenced file', async () => {
|
||||
// Write a large JSON result to a file
|
||||
const resultFile = join(fixtureDir, 'big-result.json');
|
||||
const bigData = { items: Array.from({ length: 100 }, (_, i) => ({ id: i })) };
|
||||
await writeFile(resultFile, JSON.stringify(bigData));
|
||||
|
||||
// Script outputs @file: prefix
|
||||
const scriptPath = await createScript(
|
||||
'file-ref.cjs',
|
||||
`process.stdout.write('@file:${resultFile.replace(/\\/g, '\\\\')}');`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.exec('state', ['load']);
|
||||
|
||||
expect(result).toEqual(bigData);
|
||||
});
|
||||
|
||||
it('returns null for empty stdout', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'empty-output.cjs',
|
||||
`// outputs nothing`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.exec('state', ['load']);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws GSDToolsError on non-zero exit code', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'fail.cjs',
|
||||
`process.stderr.write('something went wrong\\n'); process.exit(1);`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
|
||||
try {
|
||||
await tools.exec('state', ['load']);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(GSDToolsError);
|
||||
const gsdErr = err as GSDToolsError;
|
||||
expect(gsdErr.command).toBe('state');
|
||||
expect(gsdErr.args).toEqual(['load']);
|
||||
expect(gsdErr.stderr).toContain('something went wrong');
|
||||
expect(gsdErr.exitCode).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws GSDToolsError with context when gsd-tools.cjs not found', async () => {
|
||||
const tools = new GSDTools({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: '/nonexistent/path/gsd-tools.cjs',
|
||||
});
|
||||
|
||||
await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError);
|
||||
});
|
||||
|
||||
it('throws parse error when stdout is non-JSON', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'bad-json.cjs',
|
||||
`process.stdout.write('Not JSON at all');`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
|
||||
try {
|
||||
await tools.exec('state', ['load']);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(GSDToolsError);
|
||||
const gsdErr = err as GSDToolsError;
|
||||
expect(gsdErr.message).toContain('Failed to parse');
|
||||
expect(gsdErr.message).toContain('Not JSON at all');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when @file: points to nonexistent file', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'bad-file-ref.cjs',
|
||||
`process.stdout.write('@file:/tmp/does-not-exist-${Date.now()}.json');`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
|
||||
await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError);
|
||||
});
|
||||
|
||||
it('handles timeout by killing child process', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'hang.cjs',
|
||||
`setTimeout(() => {}, 60000); // hang for 60s`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({
|
||||
projectDir: tmpDir,
|
||||
gsdToolsPath: scriptPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
try {
|
||||
await tools.exec('state', ['load']);
|
||||
expect.fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(GSDToolsError);
|
||||
const gsdErr = err as GSDToolsError;
|
||||
expect(gsdErr.message).toContain('timed out');
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
// ─── Typed method tests ────────────────────────────────────────────────
|
||||
|
||||
describe('typed methods', () => {
|
||||
it('stateLoad() calls exec with correct args', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'state-load.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
// Script receives: state load --raw
|
||||
if (args[0] === 'state' && args[1] === 'load' && args.includes('--raw')) {
|
||||
process.stdout.write('phase=3\\nstatus=executing');
|
||||
} else {
|
||||
process.stderr.write('unexpected args: ' + args.join(' '));
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.stateLoad();
|
||||
|
||||
expect(result).toBe('phase=3\nstatus=executing');
|
||||
});
|
||||
|
||||
it('commit() passes message and optional files', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'commit.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
// commit <msg> --files f1 f2 --raw — returns a git SHA
|
||||
process.stdout.write('f89ae07');
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.commit('test message', ['file1.md', 'file2.md']);
|
||||
|
||||
expect(result).toBe('f89ae07');
|
||||
});
|
||||
|
||||
it('roadmapAnalyze() calls roadmap analyze', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'roadmap.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'roadmap' && args[1] === 'analyze') {
|
||||
process.stdout.write(JSON.stringify({ phases: [] }));
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.roadmapAnalyze();
|
||||
|
||||
expect(result).toEqual({ phases: [] });
|
||||
});
|
||||
|
||||
it('verifySummary() passes path argument', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'verify.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'verify-summary' && args[1] === '/path/to/SUMMARY.md') {
|
||||
process.stdout.write('passed');
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.verifySummary('/path/to/SUMMARY.md');
|
||||
|
||||
expect(result).toBe('passed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration-style test ────────────────────────────────────────────
|
||||
|
||||
describe('integration', () => {
|
||||
it('handles large JSON output (>100KB)', async () => {
|
||||
const largeArray = Array.from({ length: 5000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `item-${i}`,
|
||||
data: 'x'.repeat(20),
|
||||
}));
|
||||
const largeJson = JSON.stringify(largeArray);
|
||||
|
||||
const scriptPath = await createScript(
|
||||
'large-output.cjs',
|
||||
`process.stdout.write(${JSON.stringify(largeJson)});`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.exec('state', ['load']);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as unknown[]).length).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── initNewProject() tests ────────────────────────────────────────────
|
||||
|
||||
describe('initNewProject()', () => {
|
||||
it('calls init new-project and returns typed result', async () => {
|
||||
const mockResult = {
|
||||
researcher_model: 'claude-sonnet-4-6',
|
||||
synthesizer_model: 'claude-sonnet-4-6',
|
||||
roadmapper_model: 'claude-sonnet-4-6',
|
||||
commit_docs: true,
|
||||
project_exists: false,
|
||||
has_codebase_map: false,
|
||||
planning_exists: false,
|
||||
has_existing_code: false,
|
||||
has_package_file: false,
|
||||
is_brownfield: false,
|
||||
needs_codebase_map: false,
|
||||
has_git: true,
|
||||
brave_search_available: false,
|
||||
firecrawl_available: false,
|
||||
exa_search_available: false,
|
||||
project_path: '.planning/PROJECT.md',
|
||||
project_root: '/tmp/test',
|
||||
};
|
||||
|
||||
const scriptPath = await createScript(
|
||||
'init-new-project.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'init' && args[1] === 'new-project' && args.includes('--raw')) {
|
||||
process.stdout.write(JSON.stringify(${JSON.stringify(mockResult)}));
|
||||
} else {
|
||||
process.stderr.write('unexpected args: ' + args.join(' '));
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.initNewProject();
|
||||
|
||||
expect(result.researcher_model).toBe('claude-sonnet-4-6');
|
||||
expect(result.project_exists).toBe(false);
|
||||
expect(result.has_git).toBe(true);
|
||||
expect(result.is_brownfield).toBe(false);
|
||||
expect(result.project_path).toBe('.planning/PROJECT.md');
|
||||
});
|
||||
|
||||
it('propagates errors from gsd-tools', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'init-fail.cjs',
|
||||
`process.stderr.write('init failed\\n'); process.exit(1);`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
|
||||
await expect(tools.initNewProject()).rejects.toThrow(GSDToolsError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── configSet() tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('configSet()', () => {
|
||||
it('calls config-set with key and value args', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'config-set.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'config-set' && args[1] === 'workflow.auto_advance' && args[2] === 'true' && args.includes('--raw')) {
|
||||
process.stdout.write('workflow.auto_advance=true');
|
||||
} else {
|
||||
process.stderr.write('unexpected args: ' + args.join(' '));
|
||||
process.exit(1);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.configSet('workflow.auto_advance', 'true');
|
||||
|
||||
expect(result).toBe('workflow.auto_advance=true');
|
||||
});
|
||||
|
||||
it('passes string values without coercion', async () => {
|
||||
const scriptPath = await createScript(
|
||||
'config-set-str.cjs',
|
||||
`
|
||||
const args = process.argv.slice(2);
|
||||
// config-set mode yolo --raw
|
||||
process.stdout.write(args[1] + '=' + args[2]);
|
||||
`,
|
||||
);
|
||||
|
||||
const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath });
|
||||
const result = await tools.configSet('mode', 'yolo');
|
||||
|
||||
expect(result).toBe('mode=yolo');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user