feat: add Claude Code agent for Anthropic model users (#502)
* feat: add Claude Code agent for Anthropic model users Re-adds Claude Code support (removed in #478) so users with Anthropic API keys or Claude Code OAuth tokens can use their Claude subscriptions directly. When an Anthropic model is selected and Claude Code credentials are available, the system auto-selects the Claude agent instead of OpenCode. The harness mirrors opentoad's security model: native Bash blocked via --disallowedTools, MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON streaming, provider error detection, cache/cost tracking, browser skill, and todo progress tracking. Key changes: - action/agents/claude.ts: full Claude Code harness - action/utils/agent.ts: auto-select Claude for anthropic/* models - action/utils/providerErrors.ts: extracted shared provider error detection - action/utils/skills.ts: extracted shared skill installation (agent-aware) - action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars - action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist - CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused toolId variable, fix apiKeys test env cleanup The apiKeys test cleanup stripped *_API_KEY vars but missed CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip provider prefix from PULLFROG_MODEL in Claude agent the env override path was returning the raw value (e.g. "anthropic/claude-sonnet-4-5") without stripping the provider prefix, causing the Claude CLI to receive an invalid model ID. Made-with: Cursor * fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows remove unused cliPath from Claude agent RunParams, and pass CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates so users with Claude Pro/Team subscriptions can use their membership. Made-with: Cursor * fix: block Bash subagent in Claude Code disallowedTools Made-with: Cursor * chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free) Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
9f566d20e4
commit
0055aef618
+36
-1
@@ -1,6 +1,41 @@
|
||||
import type { Agent } from "../agents/index.ts";
|
||||
import { agents } from "../agents/index.ts";
|
||||
import { getModelProvider } from "../models.ts";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export function resolveAgent(): Agent {
|
||||
function hasEnvVar(name: string): boolean {
|
||||
const val = process.env[name];
|
||||
return typeof val === "string" && val.length > 0;
|
||||
}
|
||||
|
||||
function hasClaudeCodeAuth(): boolean {
|
||||
return hasEnvVar("CLAUDE_CODE_OAUTH_TOKEN") || hasEnvVar("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
export function resolveAgent(ctx?: { model?: string | undefined }): Agent {
|
||||
// 1. explicit env var override (escape hatch)
|
||||
const envAgent = process.env.PULLFROG_AGENT?.trim();
|
||||
if (envAgent) {
|
||||
if (envAgent in agents) {
|
||||
log.info(`» agent: ${envAgent} (override via PULLFROG_AGENT)`);
|
||||
return agents[envAgent as keyof typeof agents];
|
||||
}
|
||||
log.warning(`» unknown PULLFROG_AGENT="${envAgent}" — falling through to auto-select`);
|
||||
}
|
||||
|
||||
// 2. if model is Anthropic and Claude Code credentials are available, use Claude Code
|
||||
if (ctx?.model) {
|
||||
try {
|
||||
const provider = getModelProvider(ctx.model);
|
||||
if (provider === "anthropic" && hasClaudeCodeAuth()) {
|
||||
log.info(`» agent: claude (auto-selected for ${ctx.model})`);
|
||||
return agents.claude;
|
||||
}
|
||||
} catch {
|
||||
// invalid model slug format — fall through
|
||||
}
|
||||
}
|
||||
|
||||
// 3. default: OpenCode (universal, supports all providers)
|
||||
return agents.opentoad;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const savedEnv = { ...process.env };
|
||||
beforeEach(() => {
|
||||
// strip all known provider keys so tests start clean
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.endsWith("_API_KEY")) delete process.env[key];
|
||||
if (key.endsWith("_API_KEY") || key === "CLAUDE_CODE_OAUTH_TOKEN") delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ const testEnvAllowList = new Set([
|
||||
"GITHUB_PRIVATE_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
"PULLFROG_MODEL",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const PROVIDER_ERROR_PATTERNS = [
|
||||
{ pattern: "429", label: "rate limited (429)" },
|
||||
{ pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" },
|
||||
{ pattern: "quota", label: "quota error" },
|
||||
{ pattern: "status: 500", label: "provider 500 error" },
|
||||
{ pattern: "INTERNAL", label: "provider internal error" },
|
||||
{ pattern: "status: 503", label: "provider unavailable (503)" },
|
||||
{ pattern: "UNAVAILABLE", label: "provider unavailable" },
|
||||
{ pattern: "rate limit", label: "rate limited" },
|
||||
{ pattern: "limit: 0", label: "zero quota" },
|
||||
];
|
||||
|
||||
export function detectProviderError(text: string): string | null {
|
||||
for (const entry of PROVIDER_ERROR_PATTERNS) {
|
||||
if (text.includes(entry.pattern)) return entry.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
export function addSkill(params: {
|
||||
ref: string;
|
||||
skill: string;
|
||||
env: Record<string, string>;
|
||||
agent: string;
|
||||
}): void {
|
||||
const result = spawnSync(
|
||||
"npx",
|
||||
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
|
||||
{
|
||||
env: { ...process.env, ...params.env },
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
if (result.status === 0) {
|
||||
log.info(`installed ${params.skill} skill (${params.agent})`);
|
||||
} else {
|
||||
log.info(`${params.skill} skill install failed: ${(result.stderr?.toString() || "").trim()}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user