// 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))...) 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) return claudecode.NewSource(creds).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 }