a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
197 lines
6.4 KiB
Go
197 lines
6.4 KiB
Go
// Package claudecli is the Claude Code CLI llm.Provider.
|
|
//
|
|
// It is pure Go — available in every build, no `-tags llama` needed.
|
|
// Inference is delegated to the user's locally installed `claude`
|
|
// binary, which reuses the user's Claude Code subscription instead of
|
|
// requiring an Anthropic API key. Each Complete call spawns one
|
|
// `claude -p` subprocess: the conversation is flattened to text, the
|
|
// system prompt is forwarded via --append-system-prompt, and the
|
|
// prompt text is fed on stdin so very large contexts don't trip
|
|
// ARG_MAX.
|
|
//
|
|
// Structured output (the expand / rerank / verify shapes and the
|
|
// agent tool-call shape) is obtained by appending a JSON-Schema
|
|
// instruction to the system prompt and parsing the first valid JSON
|
|
// object out of the response — the CLI has no native structured-
|
|
// output mechanism. That schema-rider + JSON-extraction logic is
|
|
// shared with the `codex` provider; it lives in llm.AppendSchema-
|
|
// Instruction / llm.ExtractJSON. The agent tool-loop itself uses the
|
|
// *emulated* protocol: tool calls and results travel as plain text
|
|
// turns, so a single llm.Message shape works across all providers.
|
|
package claudecli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/llm"
|
|
)
|
|
|
|
// defaultTimeout caps one Complete call when the user hasn't set
|
|
// claudecli.timeout_seconds in config. Claude Code CLI startup plus
|
|
// one model round-trip is comfortably under 120s for the small
|
|
// prompts the assist/agent loop emits.
|
|
const defaultTimeout = 120 * time.Second
|
|
|
|
// Provider implements llm.Provider against the `claude` CLI.
|
|
type Provider struct {
|
|
binary string
|
|
model string
|
|
extra []string
|
|
timeout time.Duration
|
|
}
|
|
|
|
var _ llm.Provider = (*Provider)(nil)
|
|
|
|
// New constructs the Claude CLI provider. It verifies the binary is
|
|
// reachable on $PATH (or as an absolute path) so misconfiguration
|
|
// surfaces at startup, not on the first Complete call.
|
|
func New(cfg llm.ClaudeCLIConfig) (llm.Provider, error) {
|
|
bin := strings.TrimSpace(cfg.Binary)
|
|
if bin == "" {
|
|
bin = "claude"
|
|
}
|
|
resolved, err := exec.LookPath(bin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claudecli: binary %q not found on PATH: %w", bin, err)
|
|
}
|
|
timeout := defaultTimeout
|
|
if cfg.TimeoutSeconds > 0 {
|
|
timeout = time.Duration(cfg.TimeoutSeconds) * time.Second
|
|
}
|
|
return &Provider{
|
|
binary: resolved,
|
|
model: strings.TrimSpace(cfg.Model),
|
|
extra: append([]string(nil), cfg.Args...),
|
|
timeout: timeout,
|
|
}, nil
|
|
}
|
|
|
|
// Name implements llm.Provider.
|
|
func (p *Provider) Name() string { return "claudecli" }
|
|
|
|
// Close is a no-op — every Complete spawns a fresh subprocess; there
|
|
// is no long-lived connection or model handle to release.
|
|
func (p *Provider) Close() error { return nil }
|
|
|
|
// Complete implements llm.Provider. It runs one `claude -p`
|
|
// subprocess: the system messages are joined and forwarded via
|
|
// --append-system-prompt, every other message is flattened into a
|
|
// chat-style prompt that is piped on stdin, and stdout is captured
|
|
// as the model's text. For structured shapes the schema is injected
|
|
// into the system prompt and the first balanced JSON object is
|
|
// extracted from the response.
|
|
func (p *Provider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) {
|
|
system, prompt := flatten(req.Messages)
|
|
structured := req.Shape != llm.ShapeFreeform
|
|
if structured {
|
|
system = llm.AppendSchemaInstruction(system, req.Shape, req.Tools)
|
|
}
|
|
|
|
args := []string{"--print", "--output-format", "text"}
|
|
if p.model != "" {
|
|
args = append(args, "--model", p.model)
|
|
}
|
|
// --max-turns pins the agent loop inside Claude Code to a single
|
|
// turn — every llm.Provider caller assumes one single-shot
|
|
// response. The per-response token cap (req.MaxTokens) is
|
|
// best-effort: the CLI exposes no equivalent flag, so we lean on
|
|
// the model's own behaviour given a short system prompt.
|
|
args = append(args, "--max-turns", "1")
|
|
if system != "" {
|
|
args = append(args, "--append-system-prompt", system)
|
|
}
|
|
args = append(args, p.extra...)
|
|
|
|
runCtx := ctx
|
|
if p.timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
runCtx, cancel = context.WithTimeout(ctx, p.timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
cmd := exec.CommandContext(runCtx, p.binary, args...)
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Distinguish a context-timeout from an exec failure so the
|
|
// agent loop can log something meaningful.
|
|
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
|
return llm.CompletionResponse{}, fmt.Errorf("claudecli: timed out after %s: %s", p.timeout, llm.Snippet(stderr.Bytes()))
|
|
}
|
|
if msg := llm.Snippet(stderr.Bytes()); msg != "" {
|
|
return llm.CompletionResponse{}, fmt.Errorf("claudecli: %w: %s", err, msg)
|
|
}
|
|
return llm.CompletionResponse{}, fmt.Errorf("claudecli: %w", err)
|
|
}
|
|
|
|
text := strings.TrimSpace(stdout.String())
|
|
if text == "" {
|
|
return llm.CompletionResponse{}, errors.New("claudecli: empty response from CLI")
|
|
}
|
|
if structured {
|
|
extracted, ok := llm.ExtractJSON(text)
|
|
if !ok {
|
|
return llm.CompletionResponse{}, fmt.Errorf("claudecli: response carried no JSON: %s", llm.Snippet([]byte(text)))
|
|
}
|
|
text = extracted
|
|
}
|
|
return llm.CompletionResponse{Text: text}, nil
|
|
}
|
|
|
|
// flatten splits the conversation into a system block (every
|
|
// RoleSystem message joined with a blank line) and a chat-style
|
|
// prompt (every other message rendered as "User:" / "Assistant:" /
|
|
// "Tool result:" turns). The CLI takes the system part via
|
|
// --append-system-prompt and reads the prompt part from stdin. Using
|
|
// stdin avoids the ARG_MAX ceiling on long contexts.
|
|
func flatten(in []llm.Message) (system, prompt string) {
|
|
var sys []string
|
|
var b strings.Builder
|
|
turns := 0
|
|
for _, m := range in {
|
|
switch m.Role {
|
|
case llm.RoleSystem:
|
|
if s := strings.TrimSpace(m.Content); s != "" {
|
|
sys = append(sys, s)
|
|
}
|
|
case llm.RoleAssistant:
|
|
if turns > 0 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString("Assistant: ")
|
|
b.WriteString(m.Content)
|
|
turns++
|
|
case llm.RoleTool:
|
|
if turns > 0 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString(renderToolResult(m))
|
|
turns++
|
|
default:
|
|
if turns > 0 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString("User: ")
|
|
b.WriteString(m.Content)
|
|
turns++
|
|
}
|
|
}
|
|
return strings.Join(sys, "\n\n"), b.String()
|
|
}
|
|
|
|
func renderToolResult(m llm.Message) string {
|
|
if m.ToolName != "" {
|
|
return "Tool result (" + m.ToolName + "):\n" + m.Content
|
|
}
|
|
return "Tool result:\n" + m.Content
|
|
}
|