Vectrum
Sign In
442 lines · 15.6 KB · Go
// 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/config"
	"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
	}

	// Saved preferences sit under the flags and over the built-in defaults. A
	// config that cannot be read is reported and then ignored: the harness
	// still starts, and the wizard can write a good one over it.
	cfg, _, cfgErr := config.Load()

	// Held as a slice because the Claude Code fallback below may have to
	// rebuild the client with one more option, and replaying the same list is
	// how the rebuilt client is guaranteed to be the one that passed the
	// endpoint check.
	opts := []llm.Option{
		llm.WithModel(config.Or(*model, cfg.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
	// ccSource is kept so its write-back failure hook can be wired once the
	// places a warning can go — the notice channel, the event log — exist.
	var ccSource *claudecode.Source
	if home != "" {
		src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
		if err != nil {
			return err
		}
		if src != nil {
			ccSource = src
			client = llm.New(append(opts, llm.WithTokenSource(src.Token))...)
			// 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
	}

	effortSetting := config.Or(*effort, cfg.Effort)
	// An accept-all default is a decision about what the agent may do
	// unattended, so -yolo can turn it on but a config can only be the default
	// for a session where the flag said nothing.
	acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll

	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 cfgErr != nil {
		notice("%v — using defaults; /setup will write a fresh one", cfgErr)
	}

	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)
	}
	if ccSource != nil {
		// A rotation we could not store means the credential on disk is dead
		// and its replacement dies with this process — the user's next `claude`
		// will ask them to log in again, for a reason nothing else would
		// explain. It goes to the event log unconditionally, because that
		// survives the session; stderr only outside the TUI, which owns the
		// screen once it starts.
		ccSource.OnWriteBackFailure = func(err error) {
			log.Log(metrics.Event{Type: "credential_writeback_failed", Extra: map[string]any{
				"error": err.Error(), "file": "~/" + claudecode.Filename,
			}})
			if !useTUI {
				fmt.Fprintf(os.Stderr, "harness: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err)
				fmt.Fprintln(os.Stderr, "harness: run `claude` when convenient — its stored login is now out of date.")
			}
		}
	}

	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 = acceptAll
	if acceptAll && !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:        effortSetting,
		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:       acceptAll,
			Skills:     found,

			Version:       version,
			Effort:        effortSetting,
			ContextWindow: *contextWindow,

			Banner: banner,

			// A config that does not say the setup was finished is what
			// "first run" means, and it is the only thing that opens the
			// wizard unasked. A flag-only invocation is still a first run:
			// the flags say what this run does, not what the next one should
			// default to. A config we could not read is not a first run
			// either — the wizard's answers would be saved over settings that
			// are probably still in there.
			Config:   cfg,
			FirstRun: !cfg.Done() && cfgErr == nil,
			// Applying the answers is the TUI's own job; persisting them is
			// the only part that belongs out here.
			SaveConfig: config.Save,
		})
	}

	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) (*claudecode.Source, 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.
	//
	// The refresh client is deliberately not the harness's: that one allows
	// ten minutes for a model to answer, which is the wrong budget for a token
	// exchange running under a lock. NewSource supplies a short-timeout client.
	return claudecode.NewSource(creds, nil, home), 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
}