// Command harness is the CLI entry point: flag parsing, wiring, and the REPL.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"harness/internal/agent"
"harness/internal/llm"
"harness/internal/metrics"
"harness/internal/tools"
"harness/internal/ui"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "harness:", err)
os.Exit(1)
}
}
func run() error {
workdir := flag.String("C", ".", "working root the agent operates in")
model := flag.String("model", "", "model id (default "+llm.DefaultModel+")")
baseURL := flag.String("base-url", "", "API endpoint, for a proxy, gateway, or local server speaking the Messages API (env ANTHROPIC_BASE_URL, default "+llm.DefaultBaseURL+")")
apiKey := flag.String("api-key", "", "credential sent as x-api-key (env ANTHROPIC_API_KEY). Visible to other processes; prefer the env var")
authToken := flag.String("auth-token", "", "credential sent as Authorization: Bearer, for OAuth tokens and most gateways (env ANTHROPIC_AUTH_TOKEN)")
effort := flag.String("effort", "", "output_config.effort: low|medium|high|xhigh|max (default: the API's, high)")
maxTurns := flag.Int("max-turns", agent.DefaultMaxTurns, "turn cap per prompt")
logPath := flag.String("metrics", metrics.DefaultPath, "JSONL event log path, relative to the working root")
prompt := flag.String("p", "", "run one prompt and exit, instead of starting the REPL")
verbose := flag.Bool("v", false, "print full tool output")
flag.Parse()
root, err := filepath.Abs(*workdir)
if err != nil {
return err
}
session := time.Now().UTC().Format("20060102T150405Z")
log, err := metrics.Open(filepath.Join(root, *logPath), session)
if err != nil {
return err
}
defer log.Close()
// Flags win over the environment, which wins over the defaults. Every
// option below ignores an empty value, so an unset flag leaves the
// environment in place.
client := llm.New(
llm.WithModel(*model),
llm.WithBaseURL(*baseURL),
llm.WithAPIKey(*apiKey),
llm.WithAuthToken(*authToken),
)
if !client.HasCredentials() {
return errNoCredentials
}
log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
"root": root, "model": client.Model, "base_url": client.BaseURL,
}})
if !client.IsDefaultEndpoint() {
// Worth saying out loud: the prompt and every file the agent reads go
// to this endpoint, not to Anthropic.
fmt.Fprintf(os.Stderr, "harness: sending requests to %s\n", client.BaseURL)
}
state := agent.NewState()
reg := tools.NewRegistry(log).Register(
tools.NewRead(root, state),
tools.NewGlob(root),
tools.NewGrep(root),
tools.NewEdit(root, state),
tools.NewWrite(root, state),
tools.NewBash(root),
)
render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
a := agent.New(client, reg, state, log, agent.Options{MaxTurns: *maxTurns, Effort: *effort})
a.Observe = render.Handle
ctx := context.Background()
if *prompt != "" {
return once(ctx, a, render, *prompt)
}
fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, or ctrl-d to exit\n\n",
client.Model, root, strings.Join(toolNames(reg), ", "))
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for {
fmt.Print("> ")
if !in.Scan() {
fmt.Println()
return in.Err()
}
line := strings.TrimSpace(in.Text())
if line == "" {
continue
}
if line == "/exit" || line == "/quit" {
return nil
}
if err := once(ctx, a, render, line); err != nil {
render.Error(err)
}
fmt.Println()
}
}
// once runs a single prompt and prints the session totals afterwards.
func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, prompt string) error {
_, err := a.Run(ctx, prompt)
usage, turns := a.State().Usage()
render.Usage(usage, turns)
return err
}
// errNoCredentials spells out every way to supply one, because "unauthorized"
// against a gateway that ignores the header is a confusing failure.
var errNoCredentials = errors.New(`no credentials. Supply one of:
export ANTHROPIC_API_KEY=sk-ant-... (sent as x-api-key)
export ANTHROPIC_AUTH_TOKEN=... (sent as Authorization: Bearer)
-api-key / -auth-token (flags override the environment)
To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
func toolNames(reg *tools.Registry) []string {
var names []string
for _, t := range reg.List() {
names = append(names, t.Name())
}
return names
}