Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions cmd/agentscript/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"),
Expand Down
58 changes: 56 additions & 2 deletions internal/agentscript/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}
Expand Down
69 changes: 69 additions & 0 deletions pkg/claude/claudecode.go
Original file line number Diff line number Diff line change
@@ -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 <prompt> --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
}
2 changes: 2 additions & 0 deletions pkg/scriptmem/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading