// Package cli implements reasonix's command-line entry: subcommand routing, flag // parsing, assembly from config, and exit codes. The core is config-driven — // providers and tools are resolved from configuration, not hardcoded. package cli import ( "bufio" "context" "crypto/sha1" "encoding/hex" "errors" "flag" "fmt" "io" "log/slog" "net/url" "os" "os/signal" "path/filepath" "sort" "strings" "syscall" "time" "unicode/utf16" "reasonix/internal/agent" "reasonix/internal/boot" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" fileencoding "reasonix/internal/fileutil/encoding" "reasonix/internal/i18n" "reasonix/internal/notify" "reasonix/internal/provider" "reasonix/internal/provider/openai" "reasonix/internal/sandbox" "reasonix/internal/serve" tea "charm.land/bubbletea/v2" "golang.org/x/term" ) var ( runInteractiveSession = chatREPL cliIsInteractive = isInteractive ) // Run is the CLI entry point; it returns a process exit code. func Run(args []string, version string) int { // This binary routes the hidden Windows sandbox helper subcommand below; // registering that fact is what lets sandbox.Available() report true. sandbox.RegisterHelperDispatch() if len(args) > 0 && args[0] == sandbox.WindowsHelperCommand { return sandbox.RunWindowsSandboxHelper(args[1:], os.Stdin, os.Stdout, os.Stderr) } // Pick the UI language up front so even pre-config paths (the first-run // welcome banner) come through localized. Env-only first; if a config // exists and pins a language, that wins. i18n.DetectLanguage("") cmd := "" if len(args) > 0 { cmd = args[0] } if cmd == "--acp" { cmd = "acp" } if len(args) > 0 && isDefaultInteractiveFlag(cmd) { cmd = "" } if shouldMigrateLegacyConfigForCLI(cmd) { migrateLegacyConfigForCLI() } if cfg, err := config.Load(); err == nil { if cfg.Language != "" { i18n.DetectLanguage(cfg.Language) } } if len(args) == 0 && cliIsInteractive() { return runInteractiveSession(nil) } if len(args) == 0 { configureCLIThemeFromConfigForTTYOutput() usage() return 0 } if cmd == "" { return runInteractiveSession(args) } rest := args[1:] switch cmd { case "run": return runAgent(rest) case "chat", "code": // "code" is the v0.x name for the interactive session return runInteractiveSession(rest) case "serve": return runServe(rest) case "setup": configureCLIThemeFromConfigForTTYOutput() return setupConfig(rest) case "config": configureCLIThemeFromConfigNoProbe() return configCommand(rest) case "init": // Project memory (AGENTS.md) is model-generated in-session — `/init` runs // the codebase analysis. This CLI entry just points there (and to `setup` // for config), so `reasonix init` isn't a dead end. configureCLIThemeFromConfigNoProbe() return initHint() case "acp": configureCLIThemeFromConfigNoProbe() return acpCommand(rest, version) case "mcp": configureCLIThemeFromConfigNoProbe() return mcpCommand(rest) case "plugin": configureCLIThemeFromConfigNoProbe() return pluginCommand(rest) case "subagent": configureCLIThemeFromConfigForTTYOutput() return subagentCommand(rest) case "doctor": configureCLIThemeFromConfigNoProbe() return doctorCommand(rest, version) case "review": configureCLIThemeFromConfigNoProbe() return reviewCommand(rest) case "bot": configureCLIThemeFromConfigNoProbe() return botCommand(rest, version) case "upgrade", "update": configureCLIThemeFromConfigNoProbe() return upgradeCommand(rest, version) case "version", "--version", "-v": fmt.Println("reasonix", version) return 0 case "help", "--help", "-h": usage() return 0 default: fmt.Fprintf(os.Stderr, i18n.M.UnknownCommandFmt+"\n\n", cmd) usage() return 2 } } func isDefaultInteractiveFlag(arg string) bool { switch arg { case "--model", "--max-steps", "--continue", "-c", "--resume", "--copy", "--dangerously-skip-permissions", "--yolo", "--dir": return true } if name, _, ok := strings.Cut(arg, "="); ok && isDefaultInteractiveFlag(name) { return true } return false } func shouldMigrateLegacyConfigForCLI(cmd string) bool { switch cmd { case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "plugin", "subagent", "doctor", "bot", "upgrade", "update": return true default: return false } } func migrateLegacyConfigForCLI() { if _, err := config.MigrateLegacyIfNeeded(); err != nil { fmt.Fprintln(os.Stderr, "warning: config migration failed:", err) } if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil { fmt.Fprintln(os.Stderr, "warning: config upgrade failed:", err) } } func migrateMCPConfigForCLIWorkspace() { if wd, err := os.Getwd(); err == nil { if _, err := config.MigrateMCPToUserConfigOnUpgrade([]string{wd}); err != nil { fmt.Fprintln(os.Stderr, "warning: MCP config migration failed:", err) } } } func configureCLIThemeFromConfig() { if cfg, err := config.Load(); err == nil { configureCLIThemeWithStyle(cfg.UITheme(), cfg.UIThemeStyle()) cliCursorShape = cfg.UICursorShape() } else { configureCLITheme("auto") cliCursorShape = "underline" } } func configureCLIThemeFromConfigForTTYOutput() { if isTTY(os.Stdout) { configureCLIThemeFromConfig() return } configureCLIThemeFromConfigNoProbe() } func configureCLIThemeFromConfigNoProbe() { withoutTerminalProbe(configureCLIThemeFromConfig) } // setupProfile builds a ready-to-drive Controller from config via boot.Build. // The assembly (model resolution, tool registry, permission gate, two-model // Coordinator) lives in internal/boot, shared with the desktop frontend. // requireKey forces the executor's API key to be present (used by run); chat // passes false so the session UI is reachable before a key is set. sink receives // the agent's typed event stream — runAgent passes a TextSink that renders to // stdout, the TUI passes an event-channel sink so events become tea.Msgs. // profile selects economy|balanced|delivery (empty = balanced/full). func setupProfile(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, profile string) (*control.Controller, error) { migrateMCPConfigForCLIWorkspace() return boot.Build(ctx, boot.Options{ Model: modelName, MaxSteps: maxStepsOverride, RequireKey: requireKey, Sink: sink, TokenMode: profile, SessionDir: resolveCLISessionDir(), }) } // resolveCLISessionDir returns the session dir for CLI invocations. When the // current working directory maps to a project session dir, the project dir is // used so /resume shows project history. Falls back to the global session dir. func resolveCLISessionDir() string { cwd, err := os.Getwd() if err != nil { return config.SessionDir() } if projDir := config.ProjectSessionDir(cwd); projDir != "" && projDir != config.SessionDir() { return projDir } return config.SessionDir() } // setupQuietProfile is like setupProfile but suppresses plugin subprocess // stderr. Used during model switch inside a bubbletea session to prevent plugin // logs from corrupting the TUI's terminal raw mode. func setupQuietProfile(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, profile string) (*control.Controller, error) { return boot.Build(ctx, boot.Options{ Model: modelName, MaxSteps: maxStepsOverride, RequireKey: requireKey, Sink: sink, Stderr: io.Discard, TokenMode: profile, }) } func parseRuntimeProfile(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "balanced", boot.TokenModeFull: return boot.TokenModeFull, nil case boot.TokenModeEconomy: return boot.TokenModeEconomy, nil case boot.TokenModeDelivery: return boot.TokenModeDelivery, nil default: return "", fmt.Errorf("unknown runtime profile %q (want economy, balanced, or delivery)", value) } } // chdirTo honours --dir: it switches the working directory before anything reads // it, so config discovery, the sandbox root, and file tools all resolve from the // chosen project root. Returns 2 (already reported) on failure, 0 otherwise. func chdirTo(dir string) int { if dir == "" { return 0 } if err := os.Chdir(dir); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } return 0 } func modelForResumePath(modelName, resumePath string, cfg *config.Config) string { if strings.TrimSpace(modelName) != "" || strings.TrimSpace(resumePath) == "" { return modelName } sessionModel, ok := agent.LoadSessionModel(resumePath) if !ok { return modelName } if cfg == nil { return sessionModel } if _, ok := cfg.ResolveModel(sessionModel); !ok { return modelName } return sessionModel } func loadResumableSession(path string) (*agent.Session, error) { if agent.IsCleanupPending(path) { return nil, fmt.Errorf("session is pending cleanup") } return agent.LoadSession(path) } var newNotificationSender = func() notify.Sender { return notify.NewPlatformSender() } // withNotifications adds system notifications to CLI event streams when configured. func withNotifications(sink event.Sink, cfg *config.Config) event.Sink { if cfg == nil || !cfg.Notifications.Enabled { return sink } return notify.NewSink(sink, newNotificationSender(), cfg.Notifications) } func runAgent(args []string) int { fs := flag.NewFlagSet("run", flag.ContinueOnError) model := fs.String("model", "", "provider name (default: config default_model)") profileFlag := fs.String("profile", "balanced", "runtime profile: economy | balanced | delivery") maxSteps := fs.Int("max-steps", 0, "max tool-call rounds (0 = use config/default)") showThinking := fs.Bool("show-thinking", false, "show thinking text instead of the collapsed thinking marker") metricsPath := fs.String("metrics", "", "write a JSON token/cache/cost summary of the run to this path") dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here") cont := fs.Bool("continue", false, "resume the most recent saved session") fs.BoolVar(cont, "c", false, "shorthand for --continue") resume := fs.String("resume", "", "resume a specific session file (non-interactive; takes precedence over --continue)") copySession := fs.Bool("copy", false, "with --resume/--continue: duplicate the session and continue in the copy (escape hatch when the original is held by another Reasonix process)") if err := fs.Parse(args); err != nil { return 2 } profile, err := parseRuntimeProfile(*profileFlag) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } if rc := chdirTo(*dir); rc != 0 { return rc } cfg, _ := config.Load() configureCLIThemeFromConfigForTTYOutput() prompt := strings.TrimSpace(strings.Join(fs.Args(), " ")) if prompt == "" { prompt = readStdin() } if prompt == "" { fmt.Fprintln(os.Stderr, i18n.M.UsageRunHint) return 2 } // Resolve the resume target up front so --copy and the session lease can be // handled before any heavy assembly. --resume takes precedence over // --continue, matching the Resume call below. resumePath := strings.TrimSpace(*resume) if resumePath == "" && *cont { sessions, err := agent.ListSessions(resolveCLISessionDir()) if err != nil || len(sessions) == 0 { fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume) return 1 } resumePath = sessions[0].Path } if *copySession && resumePath == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--copy requires --resume or --continue") return 2 } if *copySession { copied, err := copySessionForWriting(resumePath) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("continuing in a session copy: %s\n", copied) resumePath = copied } // Own the session file for the lifetime of this run so a desktop window (or // another CLI) writing the same session is refused up front instead of // silently double-writing. Released after the controller closes. leases := control.NewSessionLeaseKeeper() defer leases.Release() var resumeSession *agent.Session if resumePath != "" { if err := leases.Rebind(resumePath); err != nil { if errors.Is(err, agent.ErrSessionLeaseHeld) { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, sessionLeaseResumeRefusal(err)) } else { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) } return 1 } var err error resumeSession, err = loadResumableSession(resumePath) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) defer stop() // Live run: render the agent's event stream to stdout. Markdown post-stream // redraw (cursor moves) is enabled only on a TTY; piped / captured output // keeps the raw stream. var renderer agent.Renderer termW := 80 if isTTY(os.Stdout) { if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { termW = w } renderer = newMarkdownRenderer(termW) } textSink := agent.NewTextSink(os.Stdout, renderer, termW) textSink.SetShowReasoning(*showThinking) var sink event.Sink = textSink var metrics *metricsSink if *metricsPath != "" { metrics = &metricsSink{inner: textSink} sink = metrics } sink = withNotifications(sink, cfg) if resumePath != "" { *model = modelForResumePath(*model, resumePath, cfg) } ctrl, err := setupProfile(ctx, *model, *maxSteps, true, sink, profile) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } defer ctrl.Close() // --resume: load a specific session file (non-interactive, meant for // MCP/API callers that manage their own per-project session). Takes // precedence over --continue. // --continue: resume the most recent saved session. if resumePath != "" { ctrl.Resume(resumeSession, resumePath) } if ctrl.SessionPath() == "" && ctrl.SessionDir() != "" { ctrl.SetSessionPath(agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())) } // Fresh sessions take the lease too (defensive: the path is brand new); a // resumed path is already held, making this a no-op. if err := leases.Rebind(ctrl.SessionPath()); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint) return 1 } runErr := ctrl.Run(ctx, prompt) if cfg != nil { notify.SendEvent(newNotificationSender(), cfg.Notifications, event.Event{Kind: event.TurnDone, Err: runErr}) } if metrics != nil { if exec := ctrl.Executor(); exec != nil { if audit := exec.CapabilityAudit(); audit != nil { snap := audit.Snapshot() metrics.m.MergeCapabilityAuditCounters( snap.Routes, snap.RoutedCandidates, snap.RoutedRequire, snap.RoutedPrefer, snap.RoutedSuggest, snap.Declines, snap.SemanticRoutes, snap.SemanticFallbacks, snap.RequireMissing, snap.RequireRecovered, snap.PreferMissing, snap.PreferRecovered, snap.SkillInvocations, snap.SkillFailures, snap.SkillUnavailable, snap.MCPInspect, snap.MCPCall, snap.MCPCallFailures, snap.ReviewBlocks, snap.SecurityReviewBlocks, snap.RouterPromptTokens, snap.RouterCompletionTokens, snap.RouterCost, snap.RouterLatencyMs, ) } } if err := writeMetrics(*metricsPath, metrics.m); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) } } if runErr != nil { fmt.Fprintln(os.Stderr, "\n"+i18n.M.ErrorPrefix, runErr) return 1 } return 0 } // runServe exposes the controller over HTTP+SSE: events stream to the browser, // commands arrive as JSON POSTs. The Broadcaster is the controller's event sink, // so the same typed stream the chat TUI consumes reaches web clients — the // transport-agnostic controller driven by a second frontend. func runServe(args []string) int { fs := flag.NewFlagSet("serve", flag.ContinueOnError) model := fs.String("model", "", "provider name (default: config default_model)") profileFlag := fs.String("profile", "balanced", "runtime profile: economy | balanced | delivery") maxSteps := fs.Int("max-steps", 0, "max tool-call rounds (0 = use config/default)") addr := fs.String("addr", "127.0.0.1:8787", "listen address") resume := fs.String("resume", "", "resume a saved session file") auth := fs.String("auth", "", "auth mode: none, token, or password (default: none)") token := fs.String("token", "", "pre-shared token for auth=token (auto-generated if empty)") password := fs.String("password", "", "password for auth=password (use --hash-password to store a hash instead)") hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password and exit") behindProxy := fs.Bool("behind-proxy", false, "trust X-Forwarded-For / X-Forwarded-Proto headers from a reverse proxy") if err := fs.Parse(args); err != nil { return 2 } profile, err := parseRuntimeProfile(*profileFlag) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } // --hash-password: generate a bcrypt hash and exit. if *hashPassword { if *password == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--hash-password requires --password") return 1 } h, err := serve.HashPassword(*password) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Println(h) return 0 } ctx := context.Background() bc := serve.NewBroadcaster() cfg, _ := config.Load() // Build serve config, merging CLI flags over config file. serveCfg := cfg.Serve if *auth != "" { serveCfg.AuthMode = *auth } if *token != "" { serveCfg.Token = *token } if *behindProxy { serveCfg.BehindProxy = true } mode, err := serve.NormalizeAuthMode(serveCfg.AuthMode) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } serveCfg.AuthMode = mode if *password != "" && serveCfg.AuthMode == "password" { // Hash the password at startup so the config never stores plaintext. // If a PasswordHash is already set in config, the CLI password overrides it. h, err := serve.HashPassword(*password) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "failed to hash password:", err) return 1 } serveCfg.PasswordHash = h } if serveCfg.AuthMode == "password" && strings.TrimSpace(serveCfg.PasswordHash) == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auth mode password requires --password or serve.password_hash") return 1 } // Own the active session file for the server's lifetime; the serve // handlers that rebind sessions (/resume, /new, /fork) move the lease // through the same keeper. Released after the controller closes. leases := control.NewSessionLeaseKeeper() defer leases.Release() var resumeSession *agent.Session if *resume != "" { if err := leases.Rebind(*resume); err != nil { if errors.Is(err, agent.ErrSessionLeaseHeld) { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint) } else { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) } return 1 } var err error resumeSession, err = loadResumableSession(*resume) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } } *model = modelForResumePath(*model, *resume, cfg) // Serve always uses the user's global default_model, ignoring any // project-level override, so the model choice stays consistent across // projects and matches the user's account-level preference. if *model == "" { if uc := config.UserConfigPath(); uc != "" { if userCfg := config.LoadForEdit(uc); userCfg != nil && userCfg.DefaultModel != "" { *model = userCfg.DefaultModel } } } ctrl, err := setupProfile(ctx, *model, *maxSteps, true, bc, profile) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } defer ctrl.Close() // Auto-save target: reuse the resumed file, else a fresh one — same as chat. if *resume != "" { ctrl.Resume(resumeSession, *resume) } ctrl.EnsureSessionPath() // Fresh sessions take the lease too (defensive: the path is brand new); a // resumed path is already held, making this a no-op. if err := leases.Rebind(ctrl.SessionPath()); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint) return 1 } srv := serve.New(ctrl, bc, serveCfg) srv.SetSessionLeases(leases) fmt.Printf("reasonix serve — %s on http://%s\n", ctrl.Label(), *addr) if srv.AuthMode() == "token" { fmt.Printf(" auth: token\n") fmt.Printf(" share: http://%s/?token=%s\n", *addr, srv.AuthToken()) } else if srv.AuthMode() == "password" { fmt.Printf(" auth: password (login at http://%s/login)\n", *addr) } if warning := serve.PlainHTTPAuthWarning(serveCfg, *addr); warning != "" { fmt.Fprintf(os.Stderr, " %s\n", warning) } // Diagnostic: check whether balance endpoint is reachable if b, err := ctrl.Balance(context.Background()); err != nil { fmt.Fprintf(os.Stderr, " balance: error — %v\n", err) } else if b == nil { fmt.Fprintf(os.Stderr, " balance: not configured (no balance_url for this provider)\n") } else { fmt.Printf(" balance: %s\n", b.Display()) } // Use graceful shutdown so SIGINT/SIGTERM drain active connections. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := srv.RunGraceful(ctx, *addr); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } return 0 } // chatREPL is an interactive session: a single persistent agent/session and a // prompt loop that keeps conversation context across turns. Exit with // 'exit'/'quit' or Ctrl-D. func chatREPL(args []string) int { fs := flag.NewFlagSet("reasonix", flag.ContinueOnError) model := fs.String("model", "", "provider name (default: config default_model)") profileFlag := fs.String("profile", "balanced", "runtime profile: economy | balanced | delivery") maxSteps := fs.Int("max-steps", 0, "max tool-call rounds (0 = use config/default)") cont := fs.Bool("continue", false, "resume the most recent saved session") fs.BoolVar(cont, "c", false, "shorthand for --continue") resume := fs.Bool("resume", false, "list saved sessions and pick one to resume") copySession := fs.Bool("copy", false, "with --resume/--continue: duplicate the selected session and continue in the copy (escape hatch when the original is held by another Reasonix process)") yolo := fs.Bool("dangerously-skip-permissions", false, "YOLO: auto-approve approval-gated tool calls this session; same runtime mode as Ctrl+Y") fs.BoolVar(yolo, "yolo", false, "alias for --dangerously-skip-permissions") dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here") if err := fs.Parse(args); err != nil { return 2 } profile, err := parseRuntimeProfile(*profileFlag) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } if rc := chdirTo(*dir); rc != 0 { return rc } cfg, err := config.Load() if err == nil { configureCLIThemeWithStyle(cfg.UITheme(), cfg.UIThemeStyle()) cliCursorShape = cfg.UICursorShape() } // Decide whether we're starting fresh or resuming. --resume opens an // interactive picker; --continue / -c jumps straight into the newest. var resumePath string switch { case *resume: path, rc := pickSessionToResume() if rc != 0 { return rc } resumePath = path case *cont: sessions, err := agent.ListSessions(resolveCLISessionDir()) if err != nil || len(sessions) == 0 { fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume) return 1 } resumePath = sessions[0].Path } if *copySession && resumePath == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--copy requires --resume or --continue") return 2 } if *copySession { copied, err := copySessionForWriting(resumePath) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("continuing in a session copy: %s\n", copied) resumePath = copied } // Own the active session file for the TUI's lifetime; in-TUI switches // (/resume, /switch, /new, ...) move the lease with the active path. // Refusing a held resume target up front is what keeps a desktop window // and this chat from silently double-writing one transcript. leases := control.NewSessionLeaseKeeper() defer leases.Release() if resumePath != "" { if err := leases.Rebind(resumePath); err != nil { if errors.Is(err, agent.ErrSessionLeaseHeld) { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, sessionLeaseResumeRefusal(err)) } else { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) } return 1 } } ctx := context.Background() *model = modelForResumePath(*model, resumePath, cfg) // Plumb the controller's typed event stream through a channel so each event // can become a tea.Msg inside the TUI's update loop. Buffered generously: // streaming bursts (tool results, long answers) shouldn't backpressure the // agent goroutine. eventCh := make(chan event.Event, 1024) var sink event.Sink = &eventSink{ch: eventCh} sink = withNotifications(sink, cfg) ctrl, err := setupProfile(ctx, *model, *maxSteps, false, sink, profile) if err != nil && errors.Is(err, boot.ErrUnknownModel) && isInteractive() && config.SourcePath() == "" { // True first run whose default model can't resolve: guide setup, then retry. // With a config present, fall through to the descriptive error — re-running // the wizard would overwrite the user's config (#2856). fmt.Fprintln(os.Stderr, i18n.M.ReconfigureOnUnknownModel) if rc := interactiveSetup(defaultConfigTarget(), defaultEnvTarget()); rc != 0 { return rc } ctrl, err = setupProfile(ctx, *model, *maxSteps, false, sink, profile) } if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } // Decide where this conversation's auto-save lands. A resume reuses the // file so closing/reopening keeps appending to the same history; a fresh // session lands in a new file stamped with the model name. if resumePath != "" { loaded, err := agent.LoadSession(resumePath) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } ctrl.Resume(loaded, resumePath) } ctrl.EnsureSessionPath() // Fresh sessions take the lease too (defensive: the path is brand new); a // resumed path is already held, making this a no-op. if err := leases.Rebind(ctrl.SessionPath()); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint) return 1 } // Surface a missing-key warning inside the TUI banner so the first message // failing is at least pre-announced; the user can still enter chat. missing := "" if cfg, loadErr := config.Load(); loadErr == nil { name := *model if name == "" { name = cfg.DefaultModel } if vErr := cfg.Validate(name); vErr != nil { missing = vErr.Error() } } // Initial terminal width — the TUI re-flows on every WindowSizeMsg so // this is just a starting estimate before the first resize event lands. termW := 80 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { termW = w } // Route "ask" decisions to the TUI: the controller emits an ApprovalRequest // event and blocks until the user answers via ctrl.Approve. Sub-agents (the // task tool) keep their headless gate from setup — no UI to prompt through. ctrl.EnableInteractiveApproval() // YOLO: skip every tool approval request for the session (deny rules still // apply; ask questions and plan approvals still wait for the user). if *yolo { ctrl.SetAutoApproveTools(true) } m := newChatTUI(ctrl, missing, eventCh, termW) m.leases = leases if cfg, err := config.Load(); err == nil { m.outputStyle = cfg.Agent.OutputStyle // shown as the active entry in /output-style m.statuslineCmd = cfg.Statusline.Command // custom status-line command, "" = built-in row m.showReasoning = cfg.UI.ShowReasoning // /verbose persistence: start with config default m.cfg = cfg } // /model support: a pure builder the TUI calls to rebuild on a different // model (carrying the conversation). It must NOT touch the running model — // runModelSubcommand performs the swap on the live copy. The same stable sink // feeds the new controller, so events keep flowing to this TUI. m.buildController = func(spec controllerBuildSpec, carry []provider.Message, resumePath string) (*control.Controller, error) { c, err := setupQuietProfile(ctx, spec.ModelRef, *maxSteps, false, sink, spec.RuntimeProfile) if err != nil { return nil, err } // Keep the carried conversation in its existing file so the switch doesn't // orphan a duplicate (#2807). path := agent.ContinueSessionPath(resumePath, c.SessionDir(), c.Label()) c.AdoptHistory(carry, path) c.EnableInteractiveApproval() c.SetPlanMode(spec.PlanMode) if spec.ToolApprovalMode != "" { c.SetToolApprovalMode(spec.ToolApprovalMode) } return c, nil } m.runtimeProfile = profile if cfg, e := config.Load(); e == nil { name := *model if name == "" { name = cfg.DefaultModel } if entry, ok := cfg.ResolveModel(name); ok { m.modelRef = entry.Name + "/" + entry.Model } } m.refreshEffortStatus() if m.nativeScrollback { prepareNativeScrollback(os.Stdout, m.bottomRows()) } // Non-Termux terminals use an alt-screen transcript viewport. Termux stays // in the normal buffer so native touch scrollback and soft-keyboard focus // keep working; finalized transcript lines are emitted via tea.Println. p := tea.NewProgram(m) // SSH drop (SIGHUP) or service stop (SIGTERM): persist the conversation // before the terminal goes away, then unwind through the normal close path // so resume picks up the interrupted session (#3772). hangup := make(chan os.Signal, 1) signal.Notify(hangup, syscall.SIGHUP, syscall.SIGTERM) go func() { for range hangup { p.Send(tuiShutdownMsg{}) } }() final, runErr := p.Run() signal.Stop(hangup) // Close the active controller plus any retired ones from /model switches. // Retired controllers were stashed rather than closed at switch time // because Controller.Close() runs SessionEnd hooks and kills plugin // subprocesses — operations that corrupt bubbletea's terminal raw mode // when executed while the TUI is alive. if fm, ok := final.(chatTUI); ok { for _, oc := range fm.oldControllers { oc.Close() } if fm.ctrl != nil { fm.ctrl.Close() } else { ctrl.Close() } } else { ctrl.Close() } if runErr != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, runErr) return 1 } return 0 } func prepareNativeScrollback(w io.Writer, rows int) { // Clear the terminal's scrollback history so a reopened chat starts // with a clean slate (Termux stays in the normal buffer, so prior // output would otherwise remain visible above the banner). fmt.Fprint(w, "\x1B[3J\x1B[2J\x1B[H") reserveNativeScrollbackFrame(w, rows) } func reserveNativeScrollbackFrame(w io.Writer, rows int) { for i := 0; i < rows; i++ { fmt.Fprintln(w) } } // setupTargets is where the wizard writes: the TOML config and the credential // store. Keys always go to Reasonix's global .env so they // never land in a project's own .env; only the config location is project-local // under --local. type setupTargets struct { config string env string } // defaultConfigTarget is the user-global config file, falling back to a // project-local reasonix.toml only when the user config dir can't be resolved. func defaultConfigTarget() string { if p := config.UserConfigPath(); p != "" { return p } return "reasonix.toml" } // defaultEnvTarget is the display target for the reasonix-owned global // Reasonix global .env. func defaultEnvTarget() string { return config.CredentialsTargetDescription() } // resolveSetupTargets picks where `reasonix setup` writes. Keys always go to the // global env. The config goes to the user-global dir by default, to ./reasonix.toml // under --local, or to an explicit path argument when given. func resolveSetupTargets(args []string) setupTargets { t := setupTargets{config: defaultConfigTarget(), env: defaultEnvTarget()} for _, a := range args { switch a { case "--local", "-l": t.config = "reasonix.toml" default: t.config = a } } return t } // displayPath shortens a home-relative path to ~/… for readable wizard output. func displayPath(p string) string { if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(p, home) { return "~" + p[len(home):] } return p } // setupConfig runs the configuration wizard (the `reasonix setup` command), // writing config.toml to the user-global dir (or ./reasonix.toml under --local) // and API keys to Reasonix's global .env — never a project's own .env. // Project memory is a separate concern — the in-session `/init` skill generates // AGENTS.md (see initHint). func setupConfig(args []string) int { t := resolveSetupTargets(args) path := t.config if _, err := os.Stat(path); err == nil { // Non-interactive must not clobber an existing config silently. On a TTY, // setup is a non-destructive configuration manager, so opening an existing // file no longer needs an overwrite confirmation. if !isInteractive() { fmt.Fprintf(os.Stderr, i18n.M.NotOverwritingFmt+"\n", path) return 1 } } // Interactive wizard on a TTY; fall back to the annotated default when piped. if isInteractive() { rc := interactiveSetup(t.config, t.env) if rc == 0 { fmt.Printf(i18n.M.TryHintFmt+"\n", bold("reasonix")) } return rc } return writeDefaultConfig(t.config) } func confirmReconfigureExistingConfig(path string, in *bufio.Scanner, w io.Writer) bool { ans := ask(in, w, fmt.Sprintf(i18n.M.ConfirmReconfigureFmt, path), "y/N") return ans == "y" || ans == "Y" } func writeDefaultConfig(path string) int { c := config.Default() if err := c.SaveTo(path); err != nil { fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err) return 1 } fmt.Printf(i18n.M.WroteFileFmt+"\n", displayPath(path)) fmt.Println(i18n.M.NextHint) return 0 } // initHint handles `reasonix init`. Unlike a config scaffold, project memory is // model-generated by analyzing the codebase, so it lives as the in-session // `/init` skill rather than a CLI command. This entry just points the user there // (and to `reasonix setup` for config) so the verb isn't a dead end. func initHint() int { fmt.Println(i18n.M.InitHint) return 0 } // interactiveSetup opens the staged provider manager. Nothing is written until // the user explicitly chooses Save and exit; q/Ctrl-C leaves both config and // credentials untouched. func interactiveSetup(configPath, envPath string) int { // Seed from the existing config when reconfiguring, so a re-run to fix a key // preserves the user's providers / agent settings instead of resetting to // defaults. First run (no file) falls back to the built-in defaults. cfg := config.LoadForEdit(configPath) session := newProviderSetupSessionForPath(cfg, configPath) lang, err := selectLanguage() if err != nil { fmt.Fprintln(os.Stderr, "\nsetup cancelled.") return 1 } session.setLanguage(lang) session.applyDeepSeekOfficialDefaultPricing() session.resetProviderSummaryBaseline() i18n.DetectLanguage(lang) // Now that the catalogue matches the user's choice, show the welcome banner // in their language before any substantive prompt. fmt.Println() fmt.Print(boxed([]string{ accent("◆") + " " + fmt.Sprintf(i18n.M.WelcomeTitleFmt, bold("reasonix")), "", dim(i18n.M.NoConfigYet), })) fmt.Println() return runProviderSetupManager(session, configPath, envPath) } // pickSessionToResume scans the session dir, takes the 10 most recent, and // shows a single-choice menu with timestamp + turn count + first user // message so the user can pick one. Returns the chosen path and a process // exit code (non-zero when there's nothing to pick or the user cancelled). func pickSessionToResume() (string, int) { sessions, err := agent.ListSessions(resolveCLISessionDir()) if err != nil || len(sessions) == 0 { fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume) return "", 1 } if !isInteractive() { fmt.Fprintln(os.Stderr, i18n.M.ResumeRequiresTTY) return "", 1 } const cap = 10 if len(sessions) > cap { sessions = sessions[:cap] } items := make([]menuItem, len(sessions)) for i, s := range sessions { when := s.ModTime.Local().Format("01-02 15:04") preview := s.Preview if preview == "" { preview = "(no user message yet)" } items[i] = menuItem{ name: when, desc: fmt.Sprintf("%d turns · %s", s.Turns, preview), } } idx, err := selectOne(i18n.M.PickSessionLabel, items) if err != nil { return "", 1 } return sessions[idx].Path, 0 } // selectLanguage is the wizard's first prompt: it shows the two UI languages // in their native form and pre-selects the env-detected one (so a single Enter // confirms the auto-detection, a single arrow + Enter picks the other). The // label is bilingual because we don't yet know which catalogue to trust. func selectLanguage() (string, error) { detected := i18n.DetectLanguage("") items := []menuItem{{name: "English"}, {name: "中文 (简体)"}} tags := []string{"en", "zh"} if detected == "zh" { items[0], items[1] = items[1], items[0] tags[0], tags[1] = tags[1], tags[0] } idx, err := selectOne("Language · 语言", items) if err != nil { return "", err } return tags[idx], nil } // familyStaticModels unions the preset model lists of every entry in the family, // preserving order and dropping duplicates. It is the fallback offered when the // live /models probe fails, so a family with separate flash/pro preset entries // still surfaces both rather than only the first member's model. func familyStaticModels(providers []config.ProviderEntry, idxs []int) []string { var out []string seen := map[string]bool{} for _, i := range idxs { for _, m := range providers[i].ModelList() { if m != "" && !seen[m] { seen[m] = true out = append(out, m) } } } return out } // fetchOrFallback tries the OpenAI-compatible GET /models endpoint // (honoring the entry's ModelsURL when set) and returns the live model IDs. // On any failure — no base URL, no key set yet (the key is collected in a // later wizard step), network/auth error, or a vendor without /models — it // silently returns the preset's static model list so the wizard can always // present something. The fetch has a 10s timeout and is best-effort. func fetchOrFallback(probe *config.ProviderEntry, famName string) []string { static := probe.ModelList() if probe.BaseURL == "" { return static } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() models, err := probe.FetchModels(ctx) if err != nil || len(models) == 0 { if len(static) > 0 { fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.FetchModelsUsingPresetsFmt, famName))) } return static } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), famName))) return models } // fetchModelListCompat walks the full set of model-list URL candidates a given // base URL can resolve to (root, /v1, known OpenAI/Anthropic compat suffixes) // and returns the first successful fetch. This is the wizard-time probe for a // *user-supplied* custom provider — its baseURL is whatever the user pasted, // and "whatever they pasted" might be https://x.com (root, probe /v1/models) // or https://x.com/v1 (versioned, probe /v1/models directly). Previously the // wizard hardcoded `baseURL + "/models"`, which works for OpenAI-shape URLs // but silently fails for Anthropic-shape roots and the reverse — so the // wizard's idea of "what models exist" diverged from the chat client's actual // endpoint. Returning the empty slice (not an error) on full miss lets the // wizard fall through to a manual text input without an error message. func fetchModelListCompat(ctx context.Context, baseURL, apiKey string) ([]string, error) { candidates, err := config.BuildModelFetchURLs(baseURL, "") if err != nil { return nil, err } var lastErr error var firstHardErr error for _, u := range candidates { models, err := openai.FetchModels(ctx, u, apiKey, nil) if err == nil { return models, nil } lastErr = err if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil { firstHardErr = err } } if firstHardErr != nil { return nil, firstHardErr } if lastErr != nil { slog.Debug("model-list probe: all candidates missed", "base_url", baseURL, "err", lastErr) } return nil, nil } // buildFamilyEntry returns a single ProviderEntry exposing the user's // selected models under one entry. It preserves the preset's API key env, // base URL, kind, context window, pricing, and effort — the things that // vary per vendor but not per model. The Default pointer is reset to the // first selected model if it would otherwise reference a model the user // didn't pick (or was empty). // buildFamilyEntries splits the user's selection back across the family's preset // members so each model keeps its own entry — and therefore its own pricing, // context window, and balance URL. A family like DeepSeek ships flash and pro as // separate presets with different prices; collapsing them into one entry would // bill pro at flash's rate. Models the live /models list returned that match no // preset (a new SKU) fall under the probe entry. Member order is preserved; // within a member, selection order is preserved. func buildFamilyEntries(probe config.ProviderEntry, members []config.ProviderEntry, selected []string) []config.ProviderEntry { tmpl := map[string]config.ProviderEntry{probe.Name: probe} ownerName := map[string]string{} for _, m := range members { tmpl[m.Name] = m for _, id := range m.ModelList() { ownerName[id] = m.Name } } var order []string groups := map[string][]string{} for _, sm := range selected { name, ok := ownerName[sm] if !ok { name = probe.Name } if _, seen := groups[name]; !seen { order = append(order, name) } groups[name] = append(groups[name], sm) } out := make([]config.ProviderEntry, 0, len(order)) for _, name := range order { out = append(out, buildFamilyEntry(tmpl[name], groups[name])) } return out } func buildFamilyEntry(probe config.ProviderEntry, selected []string) config.ProviderEntry { entry := probe entry.Models = selected entry.Model = selected[0] if entry.Default == "" || !containsString(selected, entry.Default) { entry.Default = selected[0] } return entry } func containsString(xs []string, v string) bool { for _, x := range xs { if x == v { return true } } return false } // filterStaleCustomEntries drops the wizard's own magic-name entries // (Name="custom" with Kind="openai" or Name="anthropic" with Kind="anthropic") // that older versions of the wizard wrote into reasonix.toml. They collide // with the wizard's "custom" / "anthropic" menu items on re-run, showing up // as duplicate broken entries. The new wizard writes host-derived slugs // (e.g. "custom-token-sensenova-cn") so a hit on the magic name is // unambiguously stale. The returned slice is the dropped set so the caller // can warn the user to clean up reasonix.toml by hand. func filterStaleCustomEntries(providers []config.ProviderEntry) (kept, dropped []config.ProviderEntry) { for _, p := range providers { if p.Name == "custom" && p.Kind == "openai" { dropped = append(dropped, p) continue } if p.Name == "anthropic" && p.Kind == "anthropic" { dropped = append(dropped, p) continue } kept = append(kept, p) } return } // providerSlug derives a stable, human-readable entry name for a custom // OpenAI / Anthropic-compatible provider from its base URL, e.g. // "custom-token-sensenova-cn" or "anthropic-api-anthropic-com". We can't // reuse the wizard's menu-item labels ("custom" / "anthropic") because // those would collide with the menu item itself and end up rendered as // duplicate provider entries on subsequent re-runs of `reasonix setup`. // The host-based slug also gives users a meaningful name to grep for in // reasonix.toml. Falls back to a short sha1 of the raw URL when the URL // doesn't parse, so even malformed input still produces a unique name. func providerSlug(kind, baseURL string) string { var host string if u, err := url.Parse(baseURL); err == nil { host = u.Host } if host == "" { sum := sha1.Sum([]byte(baseURL)) return kind + "-" + hex.EncodeToString(sum[:4]) } host = strings.ToLower(strings.TrimPrefix(host, "www.")) var b strings.Builder prevDash := false for _, r := range host { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false default: if !prevDash && b.Len() > 0 { b.WriteRune('-') prevDash = true } } } slug := strings.TrimRight(b.String(), "-") if slug == "" { sum := sha1.Sum([]byte(baseURL)) return kind + "-" + hex.EncodeToString(sum[:4]) } return kind + "-" + slug } func apiKeyEnvFromProviderName(name string) string { stem := strings.ToUpper(strings.TrimSpace(name)) stem = strings.Map(func(r rune) rune { switch { case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': return r default: return '_' } }, stem) stem = strings.Trim(stem, "_") if stem == "" { return "CUSTOM_" + fnv1a32Hex(name) + "_API_KEY" } return stem + "_API_KEY" } type providerKeyEnvRepair struct { provider string old string new string } func repairInvalidProviderKeyEnvs(providers []config.ProviderEntry) ([]config.ProviderEntry, []providerKeyEnvRepair) { providers = append([]config.ProviderEntry(nil), providers...) var repairs []providerKeyEnvRepair for i := range providers { old := strings.TrimSpace(providers[i].APIKeyEnv) if old == "" || config.IsValidCredentialKey(old) { continue } keyEnv := apiKeyEnvFromProviderName(providers[i].Name) providers[i].APIKeyEnv = keyEnv repairs = append(repairs, providerKeyEnvRepair{provider: providers[i].Name, old: old, new: keyEnv}) } return providers, repairs } func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string { for { keyEnv := ask(in, w, label, def) if config.IsValidCredentialKey(keyEnv) { return keyEnv } fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", keyEnv) } } func fnv1a32Hex(s string) string { hash := uint32(0x811c9dc5) for _, unit := range utf16.Encode([]rune(strings.TrimSpace(s))) { hash ^= uint32(unit) hash *= 0x01000193 } return fmt.Sprintf("%08x", hash) } // providerFamily is a wizard-only grouping of provider SKUs by vendor; it does // not exist in config because users editing reasonix.toml deal with SKU names // directly. type providerFamily struct { key string name string desc string } func familyOf(name string) providerFamily { switch { case strings.HasPrefix(name, "deepseek"): return providerFamily{key: "deepseek", name: "DeepSeek", desc: "fast & cheap, plus a stronger Pro SKU"} default: return providerFamily{key: name, name: name} } } type providerPromptResult struct { entries []config.ProviderEntry credentials map[string]string } func newProviderPromptResult(entries []config.ProviderEntry, key, value string) providerPromptResult { result := providerPromptResult{entries: entries} if key != "" && value != "" { result.credentials = map[string]string{key: value} } return result } // promptCustomProvider handles the custom provider entry flow. func promptCustomProvider() (providerPromptResult, error) { methodIdx, err := selectOne(i18n.M.CustomAddMethodLabel, []menuItem{ {name: i18n.M.CustomMethodManual}, {name: i18n.M.CustomMethodURL}, }) if err != nil { return providerPromptResult{}, err } if methodIdx == 0 { return promptCustomProviderManual() } return promptCustomProviderFromURL() } // promptCustomProviderManual handles manual model entry. func promptCustomProviderManual() (providerPromptResult, error) { return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "") } // promptCustomProviderManualWith is the shared backend for manual entry. // Pre-filled values (baseURL, keyEnv, apiKey) are reused as-is when non-empty // so the URL-fetch flow can fall through to manual entry without re-asking // the user for information they've already typed. An empty apiKey is allowed // — the key step happens later in the wizard and Reasonix's global .env is updated then. func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) { fmt.Println() if baseURL == "" { baseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "") if baseURL == "" { return providerPromptResult{}, fmt.Errorf("base URL is required") } } providerName := providerSlug("custom", baseURL) modelName := ask(in, os.Stdout, i18n.M.CustomPromptModel, "") if modelName == "" { return providerPromptResult{}, fmt.Errorf("model name is required") } if keyEnv == "" { keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName)) } else if !config.IsValidCredentialKey(keyEnv) { return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv) } if apiKey == "" { apiKey = ask(in, os.Stdout, i18n.M.CustomPromptAPIKey, "") } entry := config.ProviderEntry{ Name: providerName, Kind: "openai", BaseURL: baseURL, Model: modelName, APIKeyEnv: keyEnv, ContextWindow: 128000, } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+modelName))) return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil } // promptCustomProviderFromURL tries the OpenAI-compatible GET /models // endpoint and shows a checkbox of the returned models. If the call fails // (network error, auth failure, or a vendor without /models) it falls // through to manual entry, reusing the URL and key the user already typed. func promptCustomProviderFromURL() (providerPromptResult, error) { in := bufio.NewScanner(os.Stdin) fmt.Println() baseURL := ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "") if baseURL == "" { return providerPromptResult{}, fmt.Errorf("base URL is required") } providerName := providerSlug("custom", baseURL) keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName)) apiKey := ask(in, os.Stdout, i18n.M.CustomPromptAPIKey, "") fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom"))) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() models, err := fetchModelListCompat(ctx, baseURL, apiKey) if err != nil || len(models) == 0 { if err != nil { fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.FetchModelsFailedFmt, "custom", err))) } else { fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.CustomFetchEmpty)) } return promptCustomProviderManualWith(in, baseURL, keyEnv, apiKey) } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), "custom"))) items := make([]menuItem, len(models)) for i, m := range models { items[i] = menuItem{name: m} } idxs, err := selectMany(fmt.Sprintf(i18n.M.SelectModelsLabel, "custom"), items) if err != nil || len(idxs) == 0 { return providerPromptResult{}, fmt.Errorf("no models selected") } var selected []string for _, i := range idxs { selected = append(selected, models[i]) } entry := config.ProviderEntry{ Name: providerName, Kind: "openai", BaseURL: baseURL, Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: 128000, } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+selected[0]))) return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil } // promptAnthropicProvider handles the Anthropic compatible provider entry flow. func promptAnthropicProvider() (providerPromptResult, error) { methodIdx, err := selectOne(i18n.M.AnthropicAddMethodLabel, []menuItem{ {name: i18n.M.AnthropicMethodManual}, {name: i18n.M.AnthropicMethodURL}, }) if err != nil { return providerPromptResult{}, err } if methodIdx == 0 { return promptAnthropicProviderManual() } return promptAnthropicProviderFromURL() } // promptAnthropicProviderManual handles manual model entry. func promptAnthropicProviderManual() (providerPromptResult, error) { return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "") } // promptAnthropicProviderManualWith is the shared backend for manual entry // of an Anthropic-compatible custom provider. Pre-filled values (baseURL, // keyEnv, apiKey) are reused as-is when non-empty so the URL-fetch flow // can fall through to manual entry without re-asking the user. func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) { fmt.Println() if baseURL == "" { baseURL = ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "") if baseURL == "" { return providerPromptResult{}, fmt.Errorf("base URL is required") } } modelName := ask(in, os.Stdout, i18n.M.AnthropicPromptModel, "") if modelName == "" { return providerPromptResult{}, fmt.Errorf("model name is required") } if keyEnv == "" { keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY") } else if !config.IsValidCredentialKey(keyEnv) { return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv) } if apiKey == "" { apiKey = ask(in, os.Stdout, i18n.M.AnthropicPromptAPIKey, "") } entry := config.ProviderEntry{ Name: providerSlug("anthropic", baseURL), Kind: "anthropic", BaseURL: baseURL, Model: modelName, APIKeyEnv: keyEnv, ContextWindow: 128000, } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+modelName))) return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil } // promptAnthropicProviderFromURL tries the OpenAI-compatible GET /models // endpoint (some Anthropic-compatible proxies do expose one). Most don't // — Anthropic's own API has no public model list — so on any failure the // flow falls through to manual entry with the URL/key already filled in, // rather than aborting the wizard. func promptAnthropicProviderFromURL() (providerPromptResult, error) { in := bufio.NewScanner(os.Stdin) fmt.Println() baseURL := ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "") if baseURL == "" { return providerPromptResult{}, fmt.Errorf("base URL is required") } keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY") apiKey := ask(in, os.Stdout, i18n.M.AnthropicPromptAPIKey, "") fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic"))) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() models, err := fetchModelListCompat(ctx, baseURL, apiKey) if err != nil || len(models) == 0 { if err != nil { fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchModelsFailedFmt, "anthropic", err))) } else { fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.AnthropicFetchEmpty)) } return promptAnthropicProviderManualWith(in, baseURL, keyEnv, apiKey) } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicFetchModelsSuccessFmt, len(models), "anthropic"))) items := make([]menuItem, len(models)) for i, m := range models { items[i] = menuItem{name: m} } idxs, err := selectMany(fmt.Sprintf(i18n.M.AnthropicSelectModelsLabel, "anthropic"), items) if err != nil || len(idxs) == 0 { return providerPromptResult{}, fmt.Errorf("no models selected") } var selected []string for _, i := range idxs { selected = append(selected, models[i]) } entry := config.ProviderEntry{ Name: providerSlug("anthropic", baseURL), Kind: "anthropic", BaseURL: baseURL, Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: 128000, } fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+selected[0]))) return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil } func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) { var order []string members := map[string][]int{} info := map[string]providerFamily{} for i, p := range providers { f := familyOf(p.Name) if _, seen := members[f.key]; !seen { order = append(order, f.key) info[f.key] = f } members[f.key] = append(members[f.key], i) } return order, members, info } // withBuiltinFamilies guarantees the wizard always offers the built-in DeepSeek // family even when the loaded config replaced the defaults. // Built-in entries whose exact name already exists in the user's config are // kept as-is (preserving customizations); missing built-in entries within an // existing family are appended so the model picker always shows the full // catalogue rather than only the previously selected subset. func withBuiltinFamilies(providers []config.ProviderEntry) []config.ProviderEntry { return withBuiltinFamiliesForLanguage(providers, "") } func withBuiltinFamiliesForLanguage(providers []config.ProviderEntry, pricingLanguage string) []config.ProviderEntry { haveName := map[string]bool{} for _, p := range providers { haveName[p.Name] = true } defaults := config.Default() defaults.Language = pricingLanguage defaults.ApplyDeepSeekOfficialDefaultPricing() for _, bp := range defaults.Providers { if !haveName[bp.Name] { providers = append(providers, bp) } } return providers } // providersWithMissingKeys returns the providers the active configuration // actually references (default/planner/subagent models) whose api_key_env is // declared but not set. Merely-available providers stay silent; the chat banner // still warns if users later switch to a model whose key is missing. // configureKeys dedupes shared envs, so duplicates are fine to leave in. func providersWithMissingKeys(cfg *config.Config) []config.ProviderEntry { if cfg == nil { return nil } refs := []string{ cfg.DefaultModel, cfg.Agent.PlannerModel, cfg.Agent.SubagentModel, } if !strings.EqualFold(strings.TrimSpace(cfg.Agent.AutoPlan), "off") { refs = append(refs, cfg.Agent.AutoPlanClassifier) } if len(cfg.Agent.SubagentModels) > 0 { keys := make([]string, 0, len(cfg.Agent.SubagentModels)) for key := range cfg.Agent.SubagentModels { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { refs = append(refs, cfg.Agent.SubagentModels[key]) } } var out []config.ProviderEntry seen := map[string]bool{} for _, ref := range refs { ref = strings.TrimSpace(ref) if ref == "" { continue } p, ok := cfg.ResolveModel(ref) if !ok || p.APIKeyEnv == "" || os.Getenv(p.APIKeyEnv) != "" || seen[p.APIKeyEnv] { continue } seen[p.APIKeyEnv] = true out = append(out, *p) } return out } // configureKeys reconciles each enabled provider's API key with the // environment. For every distinct api_key_env: if the variable is already set, // setup asks whether to re-enter it; Enter keeps and re-pins the existing value. // Otherwise the user is asked once per env var (deduped across providers that // share one, e.g. both DeepSeek models). Returns KEY=value lines for the // Reasonix global .env. Re-pinning keeps hand-edited or previously saved values // aligned with the user's latest setup choice. func configureKeys(selected []config.ProviderEntry, r io.Reader, w io.Writer) []string { in := bufio.NewScanner(r) fmt.Fprintln(w, "\n"+i18n.M.EnterAPIKeysHeader) seen := map[string]bool{} var envLines []string for _, p := range selected { if p.APIKeyEnv == "" || seen[p.APIKeyEnv] { continue } seen[p.APIKeyEnv] = true if cur := os.Getenv(p.APIKeyEnv); cur != "" { reset := ask(in, w, " "+fmt.Sprintf(i18n.M.APIKeyResetPromptFmt, p.APIKeyEnv), "y/N") if reset == "y" || reset == "Y" { if key := ask(in, w, " "+p.APIKeyEnv, ""); key != "" { envLines = append(envLines, p.APIKeyEnv+"="+key) continue } } fmt.Fprintf(w, " %s %s\n", green("✓"), fmt.Sprintf(i18n.M.APIKeyAlreadySetFmt, p.APIKeyEnv)) envLines = append(envLines, p.APIKeyEnv+"="+cur) continue } if key := ask(in, w, " "+p.APIKeyEnv, ""); key != "" { envLines = append(envLines, p.APIKeyEnv+"="+key) } } return envLines } // ask prints a prompt to w and returns the entered line, or def if input is empty. func ask(in *bufio.Scanner, w io.Writer, label, def string) string { if def != "" { fmt.Fprintf(w, "%s [%s]: ", label, def) } else { fmt.Fprintf(w, "%s: ", label) } if !in.Scan() { return def } if v := strings.TrimSpace(in.Text()); v != "" { return v } return def } // isInteractive reports whether we're attached to a real terminal on both // stdin and stdout — required for prompting. Redirected or piped I/O is not // interactive, so wizards never block or auto-default in scripts and CI. func isInteractive() bool { return isTTY(os.Stdin) && isTTY(os.Stdout) } func isTTY(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } // appendEnv merges KEY=value lines into a .env file. Existing assignments of // any key that's about to be written are dropped first, then the new values // are appended — so re-running `reasonix setup` with a corrected key replaces the // stale one instead of stacking duplicates. The new values are also // pinned into the current process env so a chat session started right after // init picks up the fresh keys without a restart. func appendEnv(path string, lines []string) error { target := map[string]bool{} for _, l := range lines { if k, _, ok := strings.Cut(l, "="); ok { target[strings.TrimSpace(k)] = true } } var kept []string if data, err := fileencoding.ReadFileUTF8(path); err == nil { for _, raw := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(raw) check := strings.TrimPrefix(trimmed, "export ") if k, _, ok := strings.Cut(check, "="); ok && target[strings.TrimSpace(k)] { continue } kept = append(kept, raw) } // strings.Split on a string ending with \n leaves a trailing empty // element; trim it so we don't grow a blank line on every rewrite. if n := len(kept); n > 0 && kept[n-1] == "" { kept = kept[:n-1] } } else if !os.IsNotExist(err) { return err } var b strings.Builder for _, l := range kept { b.WriteString(l) b.WriteByte('\n') } for _, l := range lines { b.WriteString(l) b.WriteByte('\n') if k, v, ok := strings.Cut(l, "="); ok { os.Setenv(strings.TrimSpace(k), v) } } if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } return os.WriteFile(path, []byte(b.String()), 0o600) } // readStdin reads piped input if present; an interactive terminal yields "". func readStdin() string { stat, err := os.Stdin.Stat() if err != nil || stat.Mode()&os.ModeCharDevice != 0 { return "" } data, _ := io.ReadAll(os.Stdin) return strings.TrimSpace(string(data)) } func usage() { fmt.Print(i18n.M.UsageBody) } func configCommand(args []string) int { if len(args) == 0 { configUsage() return 2 } switch args[0] { case "auto-plan": return configAutoPlanCommand(args[1:]) case "memory-v5": return configMemoryV5Command(args[1:]) case "reasoning-language": return configReasoningLanguageCommand(args[1:]) default: configUsage() return 2 } } func configAutoPlanCommand(args []string) int { fs := flag.NewFlagSet("config auto-plan", flag.ContinueOnError) local := fs.Bool("local", false, "unsupported; auto-plan is user-level only") if err := fs.Parse(args); err != nil { return 2 } if *local { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auto-plan is user-level only; --local is not supported") return 2 } rest := fs.Args() if len(rest) > 1 { configAutoPlanUsage() return 2 } if len(rest) == 0 { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } mode := cfg.Agent.AutoPlan mode = cliAutoPlanMode(mode) fmt.Printf("auto_plan = %q\n", mode) return 0 } path := config.UserConfigPath() if path == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path") return 1 } // Serialize the load-modify-save against other in-process user-config // editors so concurrent writers don't drop each other's fields. unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(path) if err := cfg.SetAutoPlan(rest[0]); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } if err := cfg.SaveTo(path); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("auto_plan = %q (%s)\n", cfg.Agent.AutoPlan, displayPath(path)) return 0 } func configMemoryV5Command(args []string) int { fs := flag.NewFlagSet("config memory-v5", flag.ContinueOnError) local := fs.Bool("local", false, "unsupported; Memory v5 is user-level only") if err := fs.Parse(args); err != nil { return 2 } if *local { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "memory-v5 is user-level only; --local is not supported") return 2 } rest := fs.Args() if len(rest) > 1 { configMemoryV5Usage() return 2 } if len(rest) == 0 || strings.EqualFold(rest[0], "status") { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("memory_compiler.enabled = %v\n", cfg.MemoryCompilerEnabled()) fmt.Printf("memory_compiler.verbosity = %q\n", cfg.MemoryCompilerVerbosity()) return 0 } setting, err := parseCLIMemoryV5Setting(rest[0]) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } path := config.UserConfigPath() if path == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path") return 1 } // Serialize the load-modify-save against other in-process user-config // editors so concurrent writers don't drop each other's fields. unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(path) if err := cfg.SetMemoryCompilerEnabled(setting.enabled); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } if setting.setVerbosity { if err := cfg.SetMemoryCompilerVerbosity(setting.verbosity); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } } if err := cfg.SaveTo(path); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("memory_compiler.enabled = %v\n", cfg.MemoryCompilerEnabled()) fmt.Printf("memory_compiler.verbosity = %q (%s)\n", cfg.MemoryCompilerVerbosity(), displayPath(path)) return 0 } func configReasoningLanguageCommand(args []string) int { fs := flag.NewFlagSet("config reasoning-language", flag.ContinueOnError) local := fs.Bool("local", false, "write ./reasonix.toml instead of the user config") if err := fs.Parse(args); err != nil { return 2 } rest := fs.Args() if len(rest) > 1 { configReasoningLanguageUsage() return 2 } if len(rest) == 0 { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("reasoning_language = %q\n", cliReasoningLanguageMode(cfg.ReasoningLanguage())) return 0 } mode, err := parseCLIReasoningLanguage(rest[0]) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } path := config.UserConfigPath() if *local { path = "reasonix.toml" } if path == "" { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path") return 1 } if *local { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { lang, err := config.SaveMinimalProjectReasoningLanguage(path, mode) if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("reasoning_language = %q (%s)\n", lang, displayPath(path)) return 0 } else if err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } } if !*local { // Non-local writes target the user config; serialize the // load-modify-save against other in-process user-config editors. // --local writes ./reasonix.toml and needs no user-config lock. unlock := config.LockUserConfigEdits() defer unlock() } cfg := config.LoadForEdit(path) if err := cfg.SetReasoningLanguage(mode); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 2 } if err := cfg.SaveTo(path); err != nil { fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) return 1 } fmt.Printf("reasoning_language = %q (%s)\n", cfg.ReasoningLanguage(), displayPath(path)) return 0 } func configUsage() { fmt.Print(`Usage: reasonix config auto-plan [off|on] reasonix config memory-v5 [off|observe|compact|on|status] reasonix config reasoning-language [--local] [auto|zh|en] `) } func configAutoPlanUsage() { fmt.Print(`Usage: reasonix config auto-plan [off|on] `) } func configMemoryV5Usage() { fmt.Print(`Usage: reasonix config memory-v5 [off|observe|compact|on|status] `) } func configReasoningLanguageUsage() { fmt.Print(`Usage: reasonix config reasoning-language [--local] [auto|zh|en] `) }