diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2475d48 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +GITHUB_TOKEN=your_token +OPENROUTER_API_KEY=your_key +CFORGE_MAX_BUDGET=5 diff --git a/README.md b/README.md index b280921..b8e21ad 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ npm install npm run build export GITHUB_TOKEN=your_token export OPENROUTER_API_KEY=your_key +export CFORGE_MAX_BUDGET=5 # max USD per Claude Code session (default: 5) cforge-dev chat ``` diff --git a/src/infrastructure/claude/ClaudeCodeRunner.ts b/src/infrastructure/claude/ClaudeCodeRunner.ts index c17fabb..3cf2010 100644 --- a/src/infrastructure/claude/ClaudeCodeRunner.ts +++ b/src/infrastructure/claude/ClaudeCodeRunner.ts @@ -2,7 +2,7 @@ import { execSync, spawn } from "child_process"; import { CodeRunner, RunOptions, RunResult } from "../../domain/interfaces/CodeRunner"; const DEFAULT_MODEL = "claude-sonnet-4-6"; -const DEFAULT_BUDGET = 1.0; +const DEFAULT_BUDGET = 5; const DEFAULT_TOOLS = ["Bash", "Edit", "Read", "Write", "Glob", "Grep"]; interface ClaudeJsonResponse { @@ -18,7 +18,8 @@ interface ClaudeJsonResponse { export class ClaudeCodeRunner implements CodeRunner { async run(prompt: string, workingDir: string, options: RunOptions): Promise { const model = options.model ?? DEFAULT_MODEL; - const budget = options.maxBudgetUsd ?? DEFAULT_BUDGET; + const envBudget = process.env.CFORGE_MAX_BUDGET ? Number(process.env.CFORGE_MAX_BUDGET) : DEFAULT_BUDGET; + const budget = options.maxBudgetUsd ?? envBudget; const tools = options.allowedTools ?? DEFAULT_TOOLS; // Check if claude CLI exists diff --git a/tests/infrastructure/ClaudeCodeRunner.test.ts b/tests/infrastructure/ClaudeCodeRunner.test.ts index b19da1e..b9e6483 100644 --- a/tests/infrastructure/ClaudeCodeRunner.test.ts +++ b/tests/infrastructure/ClaudeCodeRunner.test.ts @@ -229,7 +229,7 @@ describe("ClaudeCodeRunner", () => { ); }); - it("should pass correct default flags", async () => { + it("should pass correct default flags with env-based budget", async () => { mockSpawnSuccess(successJson); const runner = new ClaudeCodeRunner(); await runner.run("prompt", "/tmp/work", {}); @@ -241,12 +241,68 @@ describe("ClaudeCodeRunner", () => { "--model", "claude-sonnet-4-6", "--dangerously-skip-permissions", "--allowed-tools", "Bash,Edit,Read,Write,Glob,Grep", - "--max-budget-usd", "1", + "--max-budget-usd", "5", "--output-format", "json", ]); expect(opts.cwd).toBe("/tmp/work"); }); + it("should read CFORGE_MAX_BUDGET from env with default of 5", async () => { + mockSpawnSuccess(successJson); + const original = process.env.CFORGE_MAX_BUDGET; + try { + delete process.env.CFORGE_MAX_BUDGET; + const runner = new ClaudeCodeRunner(); + await runner.run("prompt", "/tmp/test", {}); + + const args: string[] = (spawn as jest.Mock).mock.calls[0][1]; + const idx = args.indexOf("--max-budget-usd"); + expect(args[idx + 1]).toBe("5"); + } finally { + if (original !== undefined) process.env.CFORGE_MAX_BUDGET = original; + } + }); + + it("should use CFORGE_MAX_BUDGET env var when set", async () => { + mockSpawnSuccess(successJson); + const original = process.env.CFORGE_MAX_BUDGET; + try { + process.env.CFORGE_MAX_BUDGET = "10"; + const runner = new ClaudeCodeRunner(); + await runner.run("prompt", "/tmp/test", {}); + + const args: string[] = (spawn as jest.Mock).mock.calls[0][1]; + const idx = args.indexOf("--max-budget-usd"); + expect(args[idx + 1]).toBe("10"); + } finally { + if (original !== undefined) { + process.env.CFORGE_MAX_BUDGET = original; + } else { + delete process.env.CFORGE_MAX_BUDGET; + } + } + }); + + it("should let RunOptions.maxBudgetUsd override env var", async () => { + mockSpawnSuccess(successJson); + const original = process.env.CFORGE_MAX_BUDGET; + try { + process.env.CFORGE_MAX_BUDGET = "10"; + const runner = new ClaudeCodeRunner(); + await runner.run("prompt", "/tmp/test", { maxBudgetUsd: 20 }); + + const args: string[] = (spawn as jest.Mock).mock.calls[0][1]; + const idx = args.indexOf("--max-budget-usd"); + expect(args[idx + 1]).toBe("20"); + } finally { + if (original !== undefined) { + process.env.CFORGE_MAX_BUDGET = original; + } else { + delete process.env.CFORGE_MAX_BUDGET; + } + } + }); + it("should respect custom model and budget options", async () => { mockSpawnSuccess(successJson); const runner = new ClaudeCodeRunner();