From 24a9baaf71838d24223610620d34c50005562226 Mon Sep 17 00:00:00 2001 From: vinodhalaharvi-claude Date: Mon, 25 May 2026 13:36:38 +0000 Subject: [PATCH] runtime: default LLM verbs to Claude Code, keep Gemini selectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory runtime's LLM verbs (summarize, ask, analyze, and the search fallback) were hardwired to Gemini (geminiCall → r.gemini, requiring GEMINI_API_KEY). They now default to Claude Code — the `claude` CLI run as a subprocess, authenticated by the CLI's own local login, no API key — consistent with the rest of the stack (worker, loom). Gemini stays fully available, selectable per the user's 'keep GEMINI_API_KEY' ask. New (pkg/claude/claudecode.go): - ClaudeCodeClient: runs `claude -p --output-format json` and returns the result. Same Chat(ctx, prompt) (string, error) shape as ClaudeClient, so it's a drop-in. No API key. Runtime (internal/agentscript/runtime.go): - New llmChatter seam { Chat(ctx, prompt) (string,error) }; Runtime gains an `llm` field used by verb completions. - RuntimeConfig.LLMBackend selects the default: "claude-code" (default, no key) | "gemini" (needs GeminiAPIKey) | "claude" (Anthropic API, needs ClaudeAPIKey). Empty ⇒ claude-code. Falls back to Claude Code if a named backend's client isn't available, so verbs always work. - geminiCall now routes through r.llm (the historical name kept so every calling verb is unchanged); the raw Gemini client is used when LLMBackend==gemini (via a geminiChatAdapter mapping GenerateContent → Chat) or as a last-resort fallback. So 'read >=> summarize' runs on Claude Code with NO key; '-llm=gemini' + GEMINI_API_KEY still routes to Gemini. CLI (cmd/agentscript): - New -llm flag (claude-code | gemini | claude), default claude-code. - GEMINI_API_KEY only required for -llm=gemini (the old hard requirement for -n/-i is relaxed — the default backend needs no key). - Reads ANTHROPIC_API_KEY (or legacy CLAUDE_API_KEY) for the claude verb / claude backend. scriptmem.MemoryConfig gains LLMBackend, forwarded to the runtime, so a front end (loom) can pick the memory backend's LLM too; defaults to claude-code. Verified: 'read "/tmp/notes.txt" >=> summarize' (no keys) routes to Claude Code (errors only because this sandbox lacks the `claude` binary — proving the route); '-llm=gemini' correctly demands GEMINI_API_KEY. The file-read half needs nothing. Backward compatible: existing Gemini setups work via -llm=gemini. CI: vet, gofmt, staticcheck, go test -race ./pkg/..., go build ./... pass. --- cmd/agentscript/main.go | 11 ++++-- internal/agentscript/runtime.go | 58 ++++++++++++++++++++++++++- pkg/claude/claudecode.go | 69 +++++++++++++++++++++++++++++++++ pkg/scriptmem/memory.go | 2 + 4 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 pkg/claude/claudecode.go diff --git a/cmd/agentscript/main.go b/cmd/agentscript/main.go index 92b8d4d..a577b5d 100644 --- a/cmd/agentscript/main.go +++ b/cmd/agentscript/main.go @@ -18,6 +18,7 @@ func main() { natural := flag.Bool("n", false, "Natural language mode (translates input to DSL)") script := flag.String("e", "", "Execute DSL script directly") file := flag.String("f", "", "Execute DSL script from file") + llmBackend := flag.String("llm", "claude-code", "default LLM backend: claude-code | gemini | claude") flag.Parse() ctx := context.Background() @@ -32,17 +33,19 @@ func main() { } } - // Only require API key for modes that need it - if (*natural || *interactive) && geminiKey == "" { - fmt.Fprintln(os.Stderr, "Error: GEMINI_API_KEY environment variable required for natural language / interactive mode") + // A key is only required when the selected backend needs one. + // claude-code (the default) uses the local `claude` CLI — no key. + if *llmBackend == "gemini" && geminiKey == "" { + fmt.Fprintln(os.Stderr, "Error: GEMINI_API_KEY required for -llm=gemini") os.Exit(1) } // Create runtime rt, err := agentscript.NewRuntime(ctx, agentscript.RuntimeConfig{ GeminiAPIKey: geminiKey, - ClaudeAPIKey: os.Getenv("CLAUDE_API_KEY"), + ClaudeAPIKey: coalesce(os.Getenv("ANTHROPIC_API_KEY"), os.Getenv("CLAUDE_API_KEY")), SearchAPIKey: coalesce(os.Getenv("SEARCH_API_KEY"), os.Getenv("SERPAPI_KEY")), + LLMBackend: *llmBackend, GoogleCredsFile: googleCreds, GoogleTokenFile: os.Getenv("GOOGLE_TOKEN_FILE"), GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), diff --git a/internal/agentscript/runtime.go b/internal/agentscript/runtime.go index 2536785..22831c8 100644 --- a/internal/agentscript/runtime.go +++ b/internal/agentscript/runtime.go @@ -37,6 +37,7 @@ type Runtime struct { google *google.GoogleClient github *aggithub.GitHubClient claude *claude.ClaudeClient + llm llmChatter mcp *mcp.MCPClient hf *huggingface.HuggingFaceClient crypto *agcrypto.CryptoClient @@ -67,6 +68,12 @@ type RuntimeConfig struct { GitHubClientID string GitHubClientSecret string GitHubTokenFile string + // LLMBackend selects the default LLM for verbs like summarize, ask, + // analyze, and the search fallback. Values: "claude-code" (default — + // the `claude` CLI subprocess, no API key), "gemini" (requires + // GeminiAPIKey), "claude" (Anthropic API, requires ClaudeAPIKey). + // Empty defaults to claude-code. + LLMBackend string } // NewRuntime creates a new Runtime instance @@ -81,6 +88,28 @@ func NewRuntime(ctx context.Context, cfg RuntimeConfig) (*Runtime, error) { claudeClient = claude.NewClaudeClient(cfg.ClaudeAPIKey) } + // Default LLM for verb completions. Claude Code (the `claude` CLI, no + // API key) is the default, keeping the runtime consistent with the + // rest of the stack. "gemini"/"claude" select the API-key clients. + var defaultLLM llmChatter + switch cfg.LLMBackend { + case "gemini": + if geminiClient != nil { + defaultLLM = geminiChatAdapter{geminiClient} + } + case "claude": + if claudeClient != nil { + defaultLLM = claudeClient + } + case "", "claude-code", "claudecode": + defaultLLM = claude.NewClaudeCodeClient("", cfg.Model) + } + // Fallback: if a backend was named but its client wasn't available, + // fall back to Claude Code so verbs still work without a key. + if defaultLLM == nil { + defaultLLM = claude.NewClaudeCodeClient("", cfg.Model) + } + var googleClient *google.GoogleClient if cfg.GoogleCredsFile != "" { tokenFile := cfg.GoogleTokenFile @@ -115,6 +144,7 @@ func NewRuntime(ctx context.Context, cfg RuntimeConfig) (*Runtime, error) { google: googleClient, github: githubClient, claude: claudeClient, + llm: defaultLLM, mcp: mcp.NewMCPClient(), hf: huggingface.NewHuggingFaceClient(cfg.Verbose), crypto: agcrypto.NewCryptoClient(cfg.Verbose), @@ -534,10 +564,34 @@ func (r *Runtime) executeCommand(ctx context.Context, cmd *Command, input string return result, nil } -// geminiCall makes a call to the Gemini API +// llmChatter is the minimal LLM seam: a single-prompt completion. Both +// the Gemini client (via an adapter), the Anthropic API client, and the +// Claude Code CLI client satisfy it. +type llmChatter interface { + Chat(ctx context.Context, prompt string) (string, error) +} + +// geminiChatAdapter adapts the Gemini client's GenerateContent to the +// llmChatter Chat shape, so "gemini" can be selected as the default LLM. +type geminiChatAdapter struct{ c *gemini.GeminiClient } + +func (a geminiChatAdapter) Chat(ctx context.Context, prompt string) (string, error) { + return a.c.GenerateContent(ctx, prompt) +} + +// geminiCall is the historical name for "run this prompt through the +// default LLM". It now routes to r.llm (Claude Code by default), keeping +// every verb that called it (summarize, ask, analyze, search fallback) +// on the configured backend with no per-verb change. The real Gemini +// client is still used when LLMBackend=="gemini" (r.llm is then gemini). +// If no default LLM is set for some reason, it falls back to the raw +// Gemini client so existing GEMINI_API_KEY setups keep working. func (r *Runtime) geminiCall(ctx context.Context, prompt string) (string, error) { + if r.llm != nil { + return r.llm.Chat(ctx, prompt) + } if r.gemini == nil { - return "", fmt.Errorf("GEMINI_API_KEY not set - required for this command") + return "", fmt.Errorf("no LLM configured (set up Claude Code, or GEMINI_API_KEY)") } return r.gemini.GenerateContent(ctx, prompt) } diff --git a/pkg/claude/claudecode.go b/pkg/claude/claudecode.go new file mode 100644 index 0000000..06d1c0a --- /dev/null +++ b/pkg/claude/claudecode.go @@ -0,0 +1,69 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// ClaudeCodeClient runs the Claude Code CLI (`claude -p`) as a subprocess +// and returns its output. Authentication relies on the CLI's own local +// login — no API key is read by this client. It exposes the same +// Chat(ctx, prompt) (string, error) shape as ClaudeClient, so it is a +// drop-in for the runtime's LLM calls. +// +// This is the default LLM backend for the in-memory runtime: it keeps the +// whole stack on Claude Code (consistent with the worker and loom) and +// needs no key, while the Gemini and Anthropic-API clients remain +// available for callers that prefer them. +type ClaudeCodeClient struct { + binary string + model string +} + +// NewClaudeCodeClient returns a Claude Code client. binary defaults to +// "claude"; model passes through to `claude --model` when non-empty +// (e.g. "sonnet", "opus"). +func NewClaudeCodeClient(binary, model string) *ClaudeCodeClient { + if binary == "" { + binary = "claude" + } + return &ClaudeCodeClient{binary: binary, model: model} +} + +// claudeCodeJSON is the subset of `claude -p --output-format json` we read. +type claudeCodeJSON struct { + Result string `json:"result"` + // The CLI emits more (cost, usage, etc.); we only need the result. +} + +// Chat runs `claude -p --output-format json` and returns the +// result text. It is a single completion, not an interactive agent loop. +func (c *ClaudeCodeClient) Chat(ctx context.Context, prompt string) (string, error) { + args := []string{"-p", prompt, "--output-format", "json"} + if c.model != "" { + args = append(args, "--model", c.model) + } + cmd := exec.CommandContext(ctx, c.binary, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("claude code: %w (stderr: %s)", err, strings.TrimSpace(stderr.String())) + } + + var parsed claudeCodeJSON + if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil { + // Fall back to raw stdout if the CLI didn't emit JSON as expected. + raw := strings.TrimSpace(stdout.String()) + if raw == "" { + return "", fmt.Errorf("claude code: empty output (%w)", err) + } + return raw, nil + } + return strings.TrimSpace(parsed.Result), nil +} diff --git a/pkg/scriptmem/memory.go b/pkg/scriptmem/memory.go index 32373ac..5418a11 100644 --- a/pkg/scriptmem/memory.go +++ b/pkg/scriptmem/memory.go @@ -27,6 +27,7 @@ import ( // always has). type MemoryConfig struct { GeminiAPIKey string + LLMBackend string // default LLM: claude-code (default) | gemini | claude ClaudeAPIKey string SearchAPIKey string Model string @@ -58,6 +59,7 @@ func RunMemory(ctx context.Context, cfg MemoryConfig, r resolved.AST) (string, e rt, err := agentscript.NewRuntime(ctx, agentscript.RuntimeConfig{ GeminiAPIKey: cfg.GeminiAPIKey, + LLMBackend: cfg.LLMBackend, ClaudeAPIKey: cfg.ClaudeAPIKey, SearchAPIKey: cfg.SearchAPIKey, Model: cfg.Model,