Skip to content

Commit bbeb02c

Browse files
feat: Claude Code adapter improvements for local run readiness
- Fix session resume: use --resume <sessionId> instead of --continue - Add --model support via CLAUDE_CODE_MODEL env var - Add --max-turns protection via CLAUDE_CODE_MAX_TURNS env var - Add --allowedTools support via CLAUDE_CODE_ALLOWED_TOOLS env var - Improve error messages for rate limit, auth, model not found - Sync .env.example with .env, add comments for all new vars - Extend agent type with model, maxTurns, allowedTools fields - Healthcheck: conditional Codex/Claude Code binary check by backend - Add 10 new tests for agent config (128 total, all pass)
1 parent 95f29a1 commit bbeb02c

7 files changed

Lines changed: 297 additions & 41 deletions

File tree

.env.example

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Linear
12
LINEAR_API_KEY=
23
LINEAR_STATUS_ASSIGNED=Todo
34
LINEAR_STATUS_PLANNING=In Progress
@@ -12,14 +13,34 @@ LINEAR_LABEL_REVIEWING=Reviewing
1213
LINEAR_LABEL_TESTING=Testing
1314
LINEAR_AUTO_CREATE_LABELS=1
1415

16+
# GitHub
1517
GITHUB_REPO_OWNER=
1618
GITHUB_REPO_NAME=
1719
GITHUB_BASE_BRANCH=main
1820

21+
# Paths
1922
PIV_WORKSPACE_PATH=
2023
PIV_EXECUTION_PATH=
24+
25+
# Agent backend: "codex" or "claude-code"
26+
AGENT_BACKEND=claude-code
27+
28+
# Claude Code model (only used when AGENT_BACKEND=claude-code)
29+
# CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
30+
31+
# Claude Code max conversation turns (0 = unlimited)
32+
# CLAUDE_CODE_MAX_TURNS=50
33+
34+
# Codex-specific
35+
CODEX_MODEL=
36+
CODEX_MODEL_PLAN=
37+
CODEX_MODEL_IMPLEMENT=
38+
CODEX_MODEL_REVIEW_TEST=
39+
CODEX_SANDBOX=
40+
41+
# Runtime behavior
2142
PIV_DRY_RUN=0
2243
PIV_DEV_MODE=0
2344
PIV_LOG_LEVEL=info
2445
PIV_LOG_PRETTY=1
25-
CODEX_SANDBOX=
46+
PIV_EXIT_WHEN_IDLE=0

src/core/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ function buildEnvBase(cwd: string): ProjectRuntimeConfig {
122122
},
123123
agent: {
124124
backend: normalizeAgentBackend(env.AGENT_BACKEND),
125+
model: normalizeOptionalValue(env.CLAUDE_CODE_MODEL),
126+
maxTurns: parseOptionalPositiveInt(env.CLAUDE_CODE_MAX_TURNS),
127+
allowedTools: parseCommaList(env.CLAUDE_CODE_ALLOWED_TOOLS),
125128
},
126129
dryRun: env.PIV_DRY_RUN === "1",
127130
};
@@ -605,6 +608,17 @@ function validateProjects(projects: ResolvedProjectConfig[]): void {
605608
}
606609
}
607610

611+
function parseCommaList(input: string | undefined): string[] | undefined {
612+
if (!input || !input.trim()) {
613+
return undefined;
614+
}
615+
const items = input
616+
.split(",")
617+
.map((v) => v.trim())
618+
.filter(Boolean);
619+
return items.length > 0 ? items : undefined;
620+
}
621+
608622
function normalizeOptionalValue(input: string | undefined): string | undefined {
609623
if (!input) {
610624
return undefined;

src/core/setup.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { access, readFile, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import readline from "node:readline/promises";
5+
import { findClaudeBinary } from "../utils/claude-path";
56
import type { CommandResult } from "../utils/shell";
67
import { runCommand } from "../utils/shell";
78
import type { LoadedConfig } from "./config";
@@ -289,26 +290,66 @@ export async function collectSetupChecks(
289290
},
290291
);
291292

292-
const codexBinary = config.projects[0]?.codex.binary ?? "codex";
293-
const codex = await safeRun(
294-
commandRunner,
295-
codexBinary,
296-
["--version"],
297-
commandCwd,
293+
const codexBackends = config.projects.filter(
294+
(project) => !project.agent?.backend || project.agent.backend === "codex",
298295
);
299-
checks.push(
300-
codex.code === 0
301-
? {
302-
name: "Codex binary",
303-
status: "pass",
304-
message: `${codexBinary} is available`,
305-
}
306-
: {
307-
name: "Codex binary",
308-
status: "fail",
309-
message: commandFailureMessage(codex),
310-
},
296+
if (codexBackends.length > 0) {
297+
const codexBinary = config.projects[0]?.codex.binary ?? "codex";
298+
const codex = await safeRun(
299+
commandRunner,
300+
codexBinary,
301+
["--version"],
302+
commandCwd,
303+
);
304+
checks.push(
305+
codex.code === 0
306+
? {
307+
name: "Codex binary",
308+
status: "pass",
309+
message: `${codexBinary} is available`,
310+
}
311+
: {
312+
name: "Codex binary",
313+
status: "fail",
314+
message: commandFailureMessage(codex),
315+
},
316+
);
317+
}
318+
319+
const claudeCodeBackends = config.projects.filter(
320+
(project) => project.agent?.backend === "claude-code",
311321
);
322+
if (claudeCodeBackends.length > 0) {
323+
const claudePath = findClaudeBinary();
324+
if (claudePath) {
325+
const claude = await safeRun(
326+
commandRunner,
327+
claudePath,
328+
["--version"],
329+
commandCwd,
330+
);
331+
checks.push(
332+
claude.code === 0
333+
? {
334+
name: "Claude Code binary",
335+
status: "pass",
336+
message: `${claudePath} is available`,
337+
}
338+
: {
339+
name: "Claude Code binary",
340+
status: "fail",
341+
message: commandFailureMessage(claude),
342+
},
343+
);
344+
} else {
345+
checks.push({
346+
name: "Claude Code binary",
347+
status: "fail",
348+
message:
349+
"claude binary not found. Install with: npm install -g @anthropic-ai/claude-code",
350+
});
351+
}
352+
}
312353

313354
checks.push(await checkTrackedConfigSecrets(cwd, config, readText));
314355

src/core/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export interface ProjectRuntimeConfig {
8484
};
8585
agent?: {
8686
backend?: "codex" | "claude-code";
87+
model?: string;
88+
maxTurns?: number;
89+
allowedTools?: string[];
8790
};
8891
skills: {
8992
root: string;

src/services/claude-code-adapter.ts

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentAdapter, AgentResult } from "../core/agent-adapter";
22
import type { ResolvedProjectConfig } from "../core/types";
33
import { getClaudeBinaryPath } from "../utils/claude-path";
4+
import { logger, normalizeError } from "../utils/logger";
45
import { assertCommandOk, runCommand } from "../utils/shell";
56

67
export class ClaudeCodeAdapter implements AgentAdapter {
@@ -14,16 +15,44 @@ export class ClaudeCodeAdapter implements AgentAdapter {
1415
return this.runClaude(prompt);
1516
}
1617

17-
async resume(_sessionId: string, prompt: string): Promise<AgentResult> {
18-
return this.runClaudeContinue(prompt);
18+
async resume(sessionId: string, prompt: string): Promise<AgentResult> {
19+
return this.runClaudeResume(sessionId, prompt);
1920
}
2021

2122
async runReview(prompt: string): Promise<AgentResult> {
2223
return this.runClaude(prompt);
2324
}
2425

26+
private buildModelArgs(): string[] {
27+
const model = this.config.agent?.model;
28+
if (!model) return [];
29+
return ["--model", model];
30+
}
31+
32+
private buildMaxTurnsArgs(): string[] {
33+
const maxTurns = this.config.agent?.maxTurns;
34+
if (!maxTurns || maxTurns <= 0) return [];
35+
return ["--max-turns", String(maxTurns)];
36+
}
37+
38+
private buildAllowedToolsArgs(): string[] {
39+
const tools = this.config.agent?.allowedTools;
40+
if (!tools || tools.length === 0) return [];
41+
return ["--allowedTools", ...tools];
42+
}
43+
44+
private buildCommonArgs(): string[] {
45+
return [
46+
"--output-format",
47+
"json",
48+
...this.buildModelArgs(),
49+
...this.buildMaxTurnsArgs(),
50+
...this.buildAllowedToolsArgs(),
51+
];
52+
}
53+
2554
private async runClaude(prompt: string): Promise<AgentResult> {
26-
const args = ["-p", prompt, "--output-format", "json"];
55+
const args = ["-p", prompt, ...this.buildCommonArgs()];
2756

2857
const result = await runCommand(this.claudePath, args, {
2958
cwd: this.config.executionPath,
@@ -32,7 +61,10 @@ export class ClaudeCodeAdapter implements AgentAdapter {
3261
stdinMode: "ignore",
3362
});
3463

35-
assertCommandOk(this.claudePath, args, result);
64+
if (result.code !== 0) {
65+
throw mapClaudeError(this.claudePath, args, result);
66+
}
67+
3668
const finalMessage = extractFinalMessage(result.stdout);
3769
const sessionId = extractSessionId(result.stdout);
3870
const usage = extractUsage(result.stdout);
@@ -45,8 +77,22 @@ export class ClaudeCodeAdapter implements AgentAdapter {
4577
};
4678
}
4779

48-
private async runClaudeContinue(prompt: string): Promise<AgentResult> {
49-
const args = ["--continue", prompt, "--output-format", "json"];
80+
private async runClaudeResume(
81+
sessionId: string,
82+
prompt: string,
83+
): Promise<AgentResult> {
84+
const args = [
85+
"--resume",
86+
sessionId,
87+
"-p",
88+
prompt,
89+
...this.buildCommonArgs(),
90+
];
91+
92+
logger.debug(
93+
{ sessionId, claudePath: this.claudePath },
94+
"Resuming Claude Code session",
95+
);
5096

5197
const result = await runCommand(this.claudePath, args, {
5298
cwd: this.config.executionPath,
@@ -55,13 +101,16 @@ export class ClaudeCodeAdapter implements AgentAdapter {
55101
stdinMode: "ignore",
56102
});
57103

58-
assertCommandOk(this.claudePath, args, result);
104+
if (result.code !== 0) {
105+
throw mapClaudeError(this.claudePath, args, result);
106+
}
107+
59108
const finalMessage = extractFinalMessage(result.stdout);
60-
const sessionId = extractSessionId(result.stdout);
109+
const resumedSessionId = extractSessionId(result.stdout) ?? sessionId;
61110
const usage = extractUsage(result.stdout);
62111

63112
return {
64-
sessionId,
113+
sessionId: resumedSessionId,
65114
finalMessage,
66115
stdout: result.stdout,
67116
usage,
@@ -126,3 +175,42 @@ export function extractUsage(
126175
} catch {}
127176
return undefined;
128177
}
178+
179+
function mapClaudeError(
180+
command: string,
181+
args: string[],
182+
result: { code: number; stdout: string; stderr: string },
183+
): Error {
184+
const output = result.stderr || result.stdout;
185+
const base = `${command} ${args.join(" ")} failed with exit code ${result.code}`;
186+
187+
if (output.includes("rate limit") || output.includes("429")) {
188+
return new Error(
189+
`${base}\nClaude API rate limit hit. Wait a moment and retry, or set CLAUDE_CODE_MODEL to a model with higher limits.`,
190+
);
191+
}
192+
193+
if (
194+
output.includes("authentication") ||
195+
output.includes("API key") ||
196+
output.includes("ANTHROPIC_API_KEY")
197+
) {
198+
return new Error(
199+
`${base}\nClaude Code authentication failed. Run 'claude' interactively once to log in, or set ANTHROPIC_API_KEY in your environment.`,
200+
);
201+
}
202+
203+
if (output.includes("model") && output.includes("not found")) {
204+
return new Error(
205+
`${base}\nThe specified model was not found. Check CLAUDE_CODE_MODEL in your .env file.`,
206+
);
207+
}
208+
209+
if (result.code === 127) {
210+
return new Error(
211+
`${base}\nClaude Code binary not found. Install with: npm install -g @anthropic-ai/claude-code`,
212+
);
213+
}
214+
215+
return new Error(`${base}\n${output}`);
216+
}

tests/claude-code.test.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { describe, expect, it } from "bun:test";
22
import type { ResolvedProjectConfig } from "../src/core/types";
33
import {
4-
ClaudeCodeAdapter,
54
extractSessionId,
65
extractUsage,
76
} from "../src/services/claude-code-adapter";
87

9-
const config: ResolvedProjectConfig = {
8+
const baseConfig: ResolvedProjectConfig = {
109
id: "default",
1110
name: "Default",
1211
workspacePath: "/tmp/work",
@@ -76,12 +75,4 @@ describe("claude code adapter", () => {
7675
const json = `{"result":"ok"}`;
7776
expect(extractUsage(json)).toBeUndefined();
7877
});
79-
80-
it("creates adapter instance", () => {
81-
const adapter = new ClaudeCodeAdapter(config);
82-
expect(adapter).toBeDefined();
83-
expect(typeof adapter.runPlan).toBe("function");
84-
expect(typeof adapter.resume).toBe("function");
85-
expect(typeof adapter.runReview).toBe("function");
86-
});
8778
});

0 commit comments

Comments
 (0)