// Command harness is the CLI entry point: flag parsing, wiring, and the REPL.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"sync/atomic"
"time"
"harness/internal/agent"
"harness/internal/approval"
"harness/internal/claudecode"
"harness/internal/llm"
"harness/internal/metrics"
"harness/internal/skills"
"harness/internal/tools"
"harness/internal/tui"
"harness/internal/ui"
)
// version is what the TUI's welcome block reports. It is a constant rather
// than build-stamped: the harness is built from this tree, and a stamp that
// can disagree with the source is worse than no stamp.
const version = "v0.6.0"
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")
contextWindow := flag.Int("context-window", agent.DefaultContextWindow, "model context window, used to decide when to compact")
compactAt := flag.Float64("compact-at", agent.DefaultCompactAt, "fraction of the window at which to compact")
noCache := flag.Bool("no-cache", false, "disable prompt cache breakpoints")
noStream := flag.Bool("no-stream", false, "wait for each complete response instead of streaming")
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")
resume := flag.String("resume", "", "resume a saved session by id, or \"latest\"")
verbose := flag.Bool("v", false, "print full tool output")
yolo := flag.Bool("yolo", false, "skip every approval prompt. Only against a throwaway clone or inside a container")
noTUI := flag.Bool("no-tui", false, "use the plain line-based REPL instead of the full-screen TUI")
noSkills := flag.Bool("no-skills", false, "skip discovering skills in ~/"+skills.Dirname+" and ./"+skills.Dirname)
noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
flag.Parse()
root, err := filepath.Abs(*workdir)
if err != nil {
return err
}
opts := []llm.Option{
llm.WithModel(*model),
llm.WithBaseURL(*baseURL),
llm.WithAPIKey(*apiKey),
llm.WithAuthToken(*authToken),
}
client := llm.New(opts...)
home, err := os.UserHomeDir()
if err != nil {
// No home directory is not fatal: an explicit credential still works.
home = ""
}
var authNote string
// systemPrefix carries the Claude Code identity block. It is set only when
// the credential was borrowed from Claude Code's own login: an OAuth
// request that omits it is rejected (as a 429, not a 401 — it reads like a
// quota problem and is not one).
var systemPrefix string
if home != "" {
src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
if err != nil {
return err
}
if src != nil {
client = llm.New(append(opts, llm.WithTokenSource(src))...)
// Not expected to ever trigger: resolveClaudeCode already checked
// the endpoint before we agreed to borrow this credential, but
// that check ran against the client we had before this rebuild.
// This is a guard against a future refactor reordering things so
// the two clients quietly diverge, not a condition anyone should
// see fire.
if !client.IsDefaultEndpoint() {
return errors.New("internal: discovered credential on a non-default endpoint")
}
authNote = note
systemPrefix = agent.ClaudeCodeIdentity
}
}
if !client.HasCredentials() {
return errNoCredentials
}
session := time.Now().UTC().Format("20060102T150405Z")
log, err := metrics.Open(filepath.Join(root, *logPath), session)
if err != nil {
return err
}
defer log.Close()
log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
"root": root, "model": client.Model, "base_url": client.BaseURL,
}})
// The TUI takes over the screen, so anything worth saying before the loop
// starts has to go into its scrollback rather than to stderr.
useTUI := !*noTUI && *prompt == "" && interactive()
var banner []string
notice := func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if useTUI {
banner = append(banner, msg)
return
}
fmt.Fprintln(os.Stderr, "harness: "+msg)
}
if !client.IsDefaultEndpoint() {
// Worth saying out loud: the prompt and every file the agent reads go
// to this endpoint, not to Anthropic.
notice("sending requests to %s", client.BaseURL)
}
if authNote != "" {
notice("%s", authNote)
}
var found []skills.Skill
if !*noSkills {
var problems []error
found, problems = skills.Discover(home, root)
for _, p := range problems {
notice("skill skipped: %v", p)
}
}
// Skill directories are readable by the search tools and by nothing else,
// so a skill can read the files it ships with.
var skillDirs []string
for _, s := range found {
skillDirs = append(skillDirs, s.Dir)
}
state := agent.NewState()
reg := tools.NewRegistry(log).Register(
tools.NewRead(root, state, skillDirs...),
tools.NewGlob(root, skillDirs...),
tools.NewGrep(root, skillDirs...),
tools.NewEdit(root, state),
tools.NewWrite(root, state),
tools.NewBash(root),
)
if t := tools.NewSkill(found); t != nil {
reg.Register(t)
}
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
approver := &ui.Approver{In: in, Out: os.Stdout}
policy := approval.New(approver.Prompt)
policy.YOLO = *yolo
if *yolo && !useTUI {
// The TUI says this itself, in the banner.
fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.")
}
a := agent.New(client, reg, state, log, agent.Options{
MaxTurns: *maxTurns,
Effort: *effort,
ContextWindow: *contextWindow,
CompactAt: *compactAt,
NoCache: *noCache,
Stream: !*noStream,
Skills: found,
SystemPrefix: systemPrefix,
})
a.Observe = render.Handle
a.Approve = policy
sessionDir := filepath.Join(root, agent.SessionDir)
if *resume != "" {
prev, err := agent.ResolveSession(sessionDir, *resume)
if err != nil {
return err
}
a.Restore(prev)
session = prev.ID
notice("resumed session %s (%d messages, %d turns)", prev.ID, len(prev.Messages), prev.Turns)
}
if useTUI {
// No signal handler here: Bubble Tea puts the terminal in raw mode, so
// ctrl-c arrives as a key and the model decides what it means.
return tui.Run(context.Background(), tui.Options{
Agent: a,
Policy: policy,
Client: client,
Root: root,
SessionDir: sessionDir,
SessionID: session,
ToolNames: toolNames(reg),
Verbose: *verbose,
YOLO: *yolo,
Skills: found,
Version: version,
Effort: *effort,
ContextWindow: *contextWindow,
Banner: banner,
})
}
ctx, stopSignals := interruptible(context.Background())
defer stopSignals()
if *prompt != "" {
return once(ctx, a, render, sessionDir, session, root, client, *prompt)
}
fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
client.Model, root, strings.Join(toolNames(reg), ", "))
for {
fmt.Print("> ")
if !in.Scan() {
fmt.Println()
return in.Err()
}
line := strings.TrimSpace(in.Text())
switch {
case line == "":
continue
case line == "/exit", line == "/quit":
return nil
case line == "/allowed":
allowed := policy.Remembered()
if len(allowed) == 0 {
fmt.Println("nothing is permanently allowed this session")
}
for _, a := range allowed {
fmt.Println(" " + a)
}
continue
}
if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
render.Error(err)
}
fmt.Println()
}
}
// once runs a single prompt, prints the session totals, and saves the session.
func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
turnCtx, endTurn := beginTurn(ctx)
_, runErr := a.Run(turnCtx, prompt)
endTurn()
render.Flush()
usage, turns := a.State().Usage()
render.Usage(usage, turns, client.Model)
if err := a.Save(sessionDir, id, root, client.Model); err != nil {
fmt.Fprintln(os.Stderr, "harness: saving session:", err)
}
if errors.Is(runErr, context.Canceled) {
// An interrupted turn is not an error: the point is to leave the
// session intact so the next message can correct course.
fmt.Println("interrupted — the session is intact; say what to do differently")
return nil
}
return runErr
}
// interrupt plumbing. The first ctrl-c cancels the turn in progress; a second
// within two seconds, or one with nothing running, exits.
var (
turnCancel atomic.Pointer[context.CancelFunc]
lastSignal atomic.Int64
)
func interruptible(parent context.Context) (context.Context, func()) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
for range ch {
now := time.Now()
prev := lastSignal.Swap(now.UnixNano())
cancel := turnCancel.Load()
if cancel == nil || (prev != 0 && now.Sub(time.Unix(0, prev)) < 2*time.Second) {
fmt.Fprintln(os.Stderr, "\nharness: exiting")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, "\nharness: interrupting this turn (ctrl-c again to exit)")
(*cancel)()
}
}()
return parent, func() { signal.Stop(ch) }
}
// beginTurn makes the running turn cancellable by the signal handler.
func beginTurn(parent context.Context) (context.Context, func()) {
ctx, cancel := context.WithCancel(parent)
turnCancel.Store(&cancel)
return ctx, func() {
turnCancel.Store(nil)
lastSignal.Store(0)
cancel()
}
}
// 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)
log in with Claude Code (borrowed automatically, Anthropic endpoint only)
To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
// resolveClaudeCode returns a token source borrowed from Claude Code's login,
// or nil when one should not be used. The second return is a line for the
// user: a borrowed credential is worth announcing, the same way a non-default
// endpoint is.
//
// Two conditions gate it. The client must have no credential already, because
// we never override what the user typed. And the endpoint must be Anthropic's,
// because a credential we found on disk is not one the user chose to send —
// forwarding it to a -base-url someone else operates would leak it to a third
// party because a file happened to exist.
func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error) {
if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
return nil, "", nil
}
creds, err := claudecode.Load(home)
if errors.Is(err, claudecode.ErrNotFound) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
// home is handed on so a rotated refresh token can be written back. That
// is the only circumstance in which this process writes to Claude Code's
// file, and not writing then is what would break its login.
return claudecode.NewSource(creds, client.HTTP, home).Token, note, nil
}
// interactive reports whether both ends of the terminal are a real one. A
// piped stdin or a redirected stdout means the full-screen UI would be wrong,
// and the line-based REPL still works.
func interactive() bool {
for _, f := range []*os.File{os.Stdin, os.Stdout} {
info, err := f.Stat()
if err != nil || info.Mode()&os.ModeCharDevice == 0 {
return false
}
}
return true
}
func toolNames(reg *tools.Registry) []string {
var names []string
for _, t := range reg.List() {
names = append(names, t.Name())
}
return names
}