#!/usr/bin/env bun import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, writeFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { prepareRunConfig, parseClaudeArgs, inlineJsonSchemaArgs, extractStructuredOutput, type ClaudeOptions, } from "../src/run-claude"; describe("prepareRunConfig", () => { test("should prepare config with basic arguments", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs.slice(0, 4)).toEqual([ "-p", "--verbose", "--output-format", "stream-json", ]); }); test("should include promptPath", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.promptPath).toBe("/tmp/test-prompt.txt"); }); test("should include allowed tools in command arguments", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--allowedTools"); expect(prepared.claudeArgs).toContain("Bash,Read"); }); test("should include disallowed tools in command arguments", () => { const options: ClaudeOptions = { disallowedTools: "Bash,Read", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--disallowedTools"); expect(prepared.claudeArgs).toContain("Bash,Read"); }); test("should include max turns in command arguments", () => { const options: ClaudeOptions = { maxTurns: "5", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--max-turns"); expect(prepared.claudeArgs).toContain("5"); }); test("should include mcp config in command arguments", () => { const options: ClaudeOptions = { mcpConfig: "/path/to/mcp-config.json", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--mcp-config"); expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json"); }); test("should include system prompt in command arguments", () => { const options: ClaudeOptions = { systemPrompt: "You are a senior backend engineer.", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--system-prompt"); expect(prepared.claudeArgs).toContain("You are a senior backend engineer."); }); test("should include append system prompt in command arguments", () => { const options: ClaudeOptions = { appendSystemPrompt: "After writing code, be sure to code review yourself.", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--append-system-prompt"); expect(prepared.claudeArgs).toContain( "After writing code, be sure to code review yourself.", ); }); test("should include fallback model in command arguments", () => { const options: ClaudeOptions = { fallbackModel: "claude-sonnet-4-20250514", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--fallback-model"); expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514"); }); test("should use provided prompt path", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/custom/prompt/path.txt", options); expect(prepared.promptPath).toBe("/custom/prompt/path.txt"); }); test("should not include optional arguments when not set", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).not.toContain("--allowedTools"); expect(prepared.claudeArgs).not.toContain("--disallowedTools"); expect(prepared.claudeArgs).not.toContain("--max-turns"); expect(prepared.claudeArgs).not.toContain("--mcp-config"); expect(prepared.claudeArgs).not.toContain("--system-prompt"); expect(prepared.claudeArgs).not.toContain("--append-system-prompt"); expect(prepared.claudeArgs).not.toContain("--fallback-model"); }); test("should preserve order of claude arguments", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", maxTurns: "3", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--max-turns", "3", ]); }); test("should preserve order with all options including fallback model", () => { const options: ClaudeOptions = { allowedTools: "Bash,Read", disallowedTools: "Write", maxTurns: "3", mcpConfig: "/path/to/config.json", systemPrompt: "You are a helpful assistant", appendSystemPrompt: "Be concise", fallbackModel: "claude-sonnet-4-20250514", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--disallowedTools", "Write", "--max-turns", "3", "--mcp-config", "/path/to/config.json", "--system-prompt", "You are a helpful assistant", "--append-system-prompt", "Be concise", "--fallback-model", "claude-sonnet-4-20250514", ]); }); describe("maxTurns validation", () => { test("should accept valid maxTurns value", () => { const options: ClaudeOptions = { maxTurns: "5" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.claudeArgs).toContain("--max-turns"); expect(prepared.claudeArgs).toContain("5"); }); test("should throw error for non-numeric maxTurns", () => { const options: ClaudeOptions = { maxTurns: "abc" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: abc", ); }); test("should throw error for negative maxTurns", () => { const options: ClaudeOptions = { maxTurns: "-1" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: -1", ); }); test("should throw error for zero maxTurns", () => { const options: ClaudeOptions = { maxTurns: "0" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "maxTurns must be a positive number, got: 0", ); }); }); describe("timeoutMinutes validation", () => { test("should accept valid timeoutMinutes value", () => { const options: ClaudeOptions = { timeoutMinutes: "15" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options), ).not.toThrow(); }); test("should throw error for non-numeric timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "abc" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: abc", ); }); test("should throw error for negative timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "-5" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: -5", ); }); test("should throw error for zero timeoutMinutes", () => { const options: ClaudeOptions = { timeoutMinutes: "0" }; expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow( "timeoutMinutes must be a positive number, got: 0", ); }); }); describe("custom environment variables", () => { test("should parse empty claudeEnv correctly", () => { const options: ClaudeOptions = { claudeEnv: "" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({}); }); test("should parse single environment variable", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123" }); }); test("should parse multiple environment variables", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", USER: "testuser", }); }); test("should handle environment variables with spaces around values", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123 \n DEBUG : true ", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should skip empty lines and comments", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should skip lines without colons", () => { const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true", }; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); test("should handle undefined claudeEnv", () => { const options: ClaudeOptions = {}; const prepared = prepareRunConfig("/tmp/test-prompt.txt", options); expect(prepared.env).toEqual({}); }); }); describe("claude_args passthrough", () => { test("empty/undefined claude_args leaves the arg list byte-identical", () => { const baseline = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", }); const withEmpty = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", claudeArgs: "", }); const withWhitespace = prepareRunConfig("/tmp/test-prompt.txt", { allowedTools: "Bash,Read", maxTurns: "3", claudeArgs: " \n ", }); const expected = [ "-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read", "--max-turns", "3", ]; expect(baseline.claudeArgs).toEqual(expected); expect(withEmpty.claudeArgs).toEqual(expected); expect(withWhitespace.claudeArgs).toEqual(expected); expect(baseline.hasJsonSchema).toBe(false); }); test("claude_args tokens are appended after the managed arguments", () => { const prepared = prepareRunConfig("/tmp/test-prompt.txt", { maxTurns: "3", claudeArgs: `--output-format json --json-schema '{"type":"object"}'`, }); expect(prepared.claudeArgs).toEqual([ "-p", "--verbose", "--output-format", "stream-json", "--max-turns", "3", "--output-format", "json", "--json-schema", '{"type":"object"}', ]); expect(prepared.hasJsonSchema).toBe(true); }); test("a single-quoted inline JSON schema survives as one token (incl. $ref)", () => { const schema = '{"$ref":"#/$defs/Verdict","type":"object"}'; const tokens = parseClaudeArgs(`--json-schema '${schema}'`); expect(tokens).toEqual(["--json-schema", schema]); }); test("comment lines are stripped before tokenizing", () => { const tokens = parseClaudeArgs( "# leading comment\n--max-turns 5\n # indented comment\n--verbose", ); expect(tokens).toEqual(["--max-turns", "5", "--verbose"]); }); test("hasJsonSchema is detected for both space and equals forms", () => { expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: `--json-schema '{"type":"object"}'`, }).hasJsonSchema, ).toBe(true); expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: `--json-schema='{"type":"object"}'`, }).hasJsonSchema, ).toBe(true); expect( prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: "--max-turns 3", }).hasJsonSchema, ).toBe(false); }); }); describe("inlineJsonSchemaArgs (issue #3 — file-path schemas)", () => { let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), "json-schema-test-")); }); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); test("passes through args that don't touch --json-schema", () => { const args = ["-p", "--max-turns", "3", "--verbose"]; expect(inlineJsonSchemaArgs(args)).toEqual(args); }); test("leaves an already-inline JSON schema untouched (space form)", () => { const inline = '{"type":"object","properties":{"x":{"type":"string"}}}'; expect(inlineJsonSchemaArgs(["--json-schema", inline])).toEqual([ "--json-schema", inline, ]); }); test("leaves an already-inline JSON schema untouched (equals form)", () => { const inline = '{"type":"object"}'; expect(inlineJsonSchemaArgs([`--json-schema=${inline}`])).toEqual([ `--json-schema=${inline}`, ]); }); test("inlines a file-path schema (space form)", () => { const schema = { type: "object", required: ["decision"] }; const file = join(dir, "space.json"); writeFileSync(file, JSON.stringify(schema, null, 2)); expect(inlineJsonSchemaArgs(["--json-schema", file])).toEqual([ "--json-schema", JSON.stringify(schema), ]); }); test("inlines a file-path schema (equals form)", () => { const schema = { type: "object" }; const file = join(dir, "equals.json"); writeFileSync(file, JSON.stringify(schema)); expect(inlineJsonSchemaArgs([`--json-schema=${file}`])).toEqual([ `--json-schema=${JSON.stringify(schema)}`, ]); }); // The reason the file-path form exists: a schema with $ref/$defs must // round-trip without the shell expanding `$`. Inlining in-process and // handing the result to spawn() as an argv element keeps it intact. test("inlines a file-path schema containing $ref/$defs intact", () => { const schema = { $ref: "#/$defs/Verdict", $defs: { Verdict: { type: "object", additionalProperties: false, required: ["decision"], properties: { decision: { type: "string", enum: ["approve", "reject"] }, }, }, }, }; const file = join(dir, "ref-schema.json"); writeFileSync(file, JSON.stringify(schema, null, 2)); const [flag, value] = inlineJsonSchemaArgs(["--json-schema", file]); expect(flag).toBe("--json-schema"); expect(value).toContain("$ref"); expect(value).toContain("$defs"); expect(JSON.parse(value as string)).toEqual(schema); }); test("throws a clear error when the schema file is missing", () => { const missing = join(dir, "does-not-exist.json"); expect(() => inlineJsonSchemaArgs(["--json-schema", missing])).toThrow( /Failed to read --json-schema file/, ); }); test("throws a clear error when the schema file is not valid JSON", () => { const file = join(dir, "invalid.json"); writeFileSync(file, "{ this is not json "); expect(() => inlineJsonSchemaArgs(["--json-schema", file])).toThrow( /is not valid JSON/, ); }); test("prepareRunConfig inlines a file-path schema end-to-end", () => { const schema = { $ref: "#/$defs/V", $defs: { V: { type: "object" } } }; const file = join(dir, "prepare.json"); writeFileSync(file, JSON.stringify(schema, null, 2)); const prepared = prepareRunConfig("/tmp/test-prompt.txt", { claudeArgs: `--json-schema ${file}`, }); expect(prepared.hasJsonSchema).toBe(true); const idx = prepared.claudeArgs.indexOf("--json-schema"); expect(idx).toBeGreaterThanOrEqual(0); expect(prepared.claudeArgs[idx + 1]).toBe(JSON.stringify(schema)); }); }); describe("extractStructuredOutput", () => { test("returns structured_output from the stream-json result event", () => { const verdict = { decision: "approve", score: 9 }; const output = [ JSON.stringify({ type: "system", subtype: "init" }), JSON.stringify({ type: "assistant", message: { content: [] } }), JSON.stringify({ type: "result", subtype: "success", is_error: false, structured_output: verdict, }), ].join("\n"); expect(extractStructuredOutput(output)).toEqual(verdict); }); test("returns undefined when no structured_output is present", () => { const output = [ JSON.stringify({ type: "system", subtype: "init" }), JSON.stringify({ type: "result", subtype: "success", is_error: false }), ].join("\n"); expect(extractStructuredOutput(output)).toBeUndefined(); }); test("ignores non-JSON lines and trailing whitespace", () => { const verdict = { ok: true }; const output = `Running Claude...\n${JSON.stringify({ type: "result", structured_output: verdict, })}\n\n`; expect(extractStructuredOutput(output)).toEqual(verdict); }); }); });