返回 DeepSeek-Reasonix
cli.go
根目录 / internal / cli / cli.go
1 // Package cli implements reasonix's command-line entry: subcommand routing, flag
2 // parsing, assembly from config, and exit codes. The core is config-driven —
3 // providers and tools are resolved from configuration, not hardcoded.
4 package cli
5
6 import (
7 "bufio"
8 "context"
9 "crypto/sha1"
10 "encoding/hex"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "log/slog"
16 "math"
17 "net/url"
18 "os"
19 "os/signal"
20 "path/filepath"
21 "slices"
22 "sort"
23 "strconv"
24 "strings"
25 "syscall"
26 "time"
27 "unicode/utf16"
28
29 "reasonix/internal/ablation"
30 "reasonix/internal/agent"
31 "reasonix/internal/boot"
32 "reasonix/internal/config"
33 "reasonix/internal/control"
34 "reasonix/internal/event"
35 "reasonix/internal/extension/providerext"
36 fileencoding "reasonix/internal/fileutil/encoding"
37 "reasonix/internal/i18n"
38 "reasonix/internal/netclient"
39 "reasonix/internal/notify"
40 "reasonix/internal/plugin"
41 "reasonix/internal/provider"
42 "reasonix/internal/provider/openai"
43 "reasonix/internal/serve"
44 "reasonix/internal/sessiontemp"
45 "reasonix/internal/telemetry"
46 "reasonix/internal/winaclresidue"
47
48 tea "charm.land/bubbletea/v2"
49 "github.com/spf13/pflag"
50 "golang.org/x/term"
51 )
52
53 var (
54 runInteractiveSession = chatREPL
55 cliIsInteractive = isInteractive
56 runWebCommand = runWeb
57 openBrowserURL = openInBrowser
58 )
59
60 // Run is the CLI entry point; it returns a process exit code.
61 // Prefer RunWithBuildInfo when git commit / build time are available from ldflags.
62 func Run(args []string, version string) int {
63 return RunWithBuildInfo(args, BuildInfo{Version: version})
64 }
65
66 // RunWithBuildInfo is the full CLI entry with optional build metadata for
67 // `reasonix version --verbose` / `--json`.
68 func RunWithBuildInfo(args []string, info BuildInfo) int {
69 // Older Windows builds could leave sandbox ACL residue behind after a
70 // crash; sweep it in the background so no tool output waits on icacls.
71 go winaclresidue.SweepStaleMarkers()
72 info = info.withDefaults()
73 version := info.Version
74 // Usage recording is asynchronous so provider/UI paths never wait on disk.
75 // Drain accepted records and fence the projection worker before returning.
76 // An embedded Run may outlive one invocation and remove its CacheDir.
77 defer closeCLIUsageCatalogs()
78 // Pick the UI language up front so even pre-config paths (the first-run
79 // welcome banner) come through localized. Env-only first; if a config
80 // exists and pins a language, that wins.
81 i18n.DetectLanguage("")
82 cmd := ""
83 if len(args) > 0 {
84 cmd = args[0]
85 }
86 if cmd == "--acp" {
87 cmd = "acp"
88 }
89 // -p/--print is one-shot print mode. reasonix has no interactive -p, so a
90 // print flag anywhere in a leading flag run (no explicit subcommand) routes
91 // the whole set to `run --print` — `reasonix --model X -p "task"` works, not
92 // only `reasonix -p ...`.
93 if cmd == "-p" || cmd == "--print" || (isDefaultInteractiveFlag(cmd) && hasLeadingPrintFlag(args)) {
94 args = append([]string{"run", "--print"}, stripLeadingPrintFlag(args)...)
95 cmd = "run"
96 }
97 if len(args) > 0 && isDefaultInteractiveFlag(cmd) {
98 cmd = ""
99 }
100 doctorRepair := isDoctorRepairCommand(args)
101 if shouldMigrateLegacyConfigForCLI(cmd) && !doctorRepair {
102 migrateLegacyConfigForCLI()
103 }
104 if !doctorRepair {
105 if cfg, err := config.Load(); err == nil {
106 if cfg.Language != "" {
107 i18n.DetectLanguage(cfg.Language)
108 }
109 }
110 }
111
112 if len(args) == 0 && cliIsInteractive() {
113 return runInteractiveSession(nil, version)
114 }
115 if len(args) == 0 {
116 configureCLIThemeFromConfigForTTYOutput()
117 usage()
118 return 0
119 }
120 if cmd == "" {
121 return runInteractiveSession(args, version)
122 }
123
124 rest := args[1:]
125 switch cmd {
126 case "run":
127 return runAgent(rest, version)
128 case "chat", "code": // "code" is the v0.x name for the interactive session
129 return runInteractiveSession(rest, version)
130 case "serve":
131 return runServe(rest)
132 case "web":
133 return runWebCommand(rest)
134 case "setup":
135 configureCLIThemeFromConfigForTTYOutput()
136 return setupConfig(rest)
137 case "config":
138 configureCLIThemeFromConfig()
139 return configCommand(rest)
140 case "init":
141 // Project memory (AGENTS.md) is model-generated in-session — `/init` runs
142 // the codebase analysis. This CLI entry just points there (and to `setup`
143 // for config), so `reasonix init` isn't a dead end.
144 configureCLIThemeFromConfig()
145 return initHint()
146 case "acp":
147 configureCLIThemeFromConfig()
148 return acpCommand(rest, version)
149 case "mcp":
150 configureCLIThemeFromConfig()
151 return mcpCommand(rest)
152 case "remote":
153 configureCLIThemeFromConfig()
154 return remoteCommand(rest, version)
155 case "plugin":
156 configureCLIThemeFromConfig()
157 return pluginCommand(rest)
158 case "subagent":
159 configureCLIThemeFromConfigForTTYOutput()
160 return subagentCommand(rest)
161 case "doctor":
162 if !doctorRepair {
163 configureCLIThemeFromConfig()
164 }
165 return doctorCommand(rest, version)
166 case "report":
167 configureCLIThemeFromConfig()
168 return reportCommand(rest)
169 case "session", "sessions", "catalogs":
170 return runSessionOrCatalogCommand(cmd, rest)
171 case "hook", "hooks":
172 configureCLIThemeFromConfig()
173 return hookCommand(rest)
174 case "task":
175 configureCLIThemeFromConfig()
176 return taskCommand(rest)
177 case "review":
178 configureCLIThemeFromConfig()
179 return reviewCommand(rest)
180 case "bot":
181 configureCLIThemeFromConfig()
182 return botCommand(rest, version)
183 case "upgrade", "update":
184 configureCLIThemeFromConfig()
185 return upgradeCommand(rest, version)
186 case "version":
187 // Detailed identity: version --verbose / --json. Top-level --version/-v
188 // stay single-line for script compatibility (Integration D/E).
189 return versionCommand(rest, info, true)
190 case "--version", "-v":
191 return versionCommand(nil, info, false)
192 case "completion":
193 return completionCommand(rest)
194 case "docs-manifest":
195 return docsManifestCommand(rest, version)
196 case "help", "--help", "-h":
197 usage()
198 return 0
199 default:
200 fmt.Fprintf(os.Stderr, i18n.M.UnknownCommandFmt+"\n\n", cmd)
201 usage()
202 return 2
203 }
204 }
205
206 func isDoctorRepairCommand(args []string) bool {
207 return len(args) > 1 && args[0] == "doctor" && args[1] == "repair"
208 }
209
210 func isDefaultInteractiveFlag(arg string) bool {
211 switch arg {
212 case "--model", "--max-steps", "--continue", "-c", "--resume", "-r", "--copy", "--dangerously-skip-permissions", "--yolo", "--permission-mode", "--effort", "--dir", "--add-dir", "--allowed-tools", "--allowedTools", "--profile", "--preset":
213 return true
214 }
215 if name, _, ok := strings.Cut(arg, "="); ok && isDefaultInteractiveFlag(name) {
216 return true
217 }
218 return false
219 }
220
221 func shouldMigrateLegacyConfigForCLI(cmd string) bool {
222 switch cmd {
223 case "", "run", "chat", "code", "serve", "web", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "bot", "upgrade", "update":
224 return true
225 default:
226 return false
227 }
228 }
229
230 func migrateMCPConfigForCLIWorkspace() {
231 if wd, err := os.Getwd(); err == nil {
232 if _, err := config.MigrateMCPToUserConfigOnUpgrade([]string{wd}); err != nil {
233 fmt.Fprintln(os.Stderr, "warning: MCP config migration failed:", err)
234 }
235 }
236 }
237
238 func configureCLIThemeFromConfig() {
239 if cfg, err := config.Load(); err == nil {
240 configureCLIThemeWithStyle(cfg.UITheme(), cfg.UIThemeStyle())
241 cliCursorShape = cfg.UICursorShape()
242 } else {
243 configureCLITheme("auto")
244 cliCursorShape = "bar"
245 }
246 }
247
248 func configureCLIThemeFromConfigForTTYOutput() {
249 if isTTY(os.Stdout) {
250 withTerminalProbe(configureCLIThemeFromConfig)
251 return
252 }
253 configureCLIThemeFromConfig()
254 }
255
256 // setupProfile builds a ready-to-drive Controller from config via boot.Build.
257 // The assembly (model resolution, tool registry, permission gate, two-model
258 // Coordinator) lives in internal/boot, shared with the desktop frontend.
259 // requireKey forces the executor's API key to be present (used by run); chat
260 // passes false so the session UI is reachable before a key is set.
261 func setupProfile(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, workspaceRoot string) (*control.Controller, error) {
262 return setupProfileWithOverrides(ctx, modelName, maxStepsOverride, requireKey, sink, cliBuildOverrides{WorkspaceRoot: workspaceRoot})
263 }
264
265 type cliBuildOverrides struct {
266 Preset string
267 Effort *string
268 EffortModel string
269 PermissionAllow []string
270 AdditionalDirs []string
271 WorkspaceRoot string
272 HeadlessApprovalMode string
273 Stderr io.Writer
274 OnSessionRecovered func(control.SessionRecoveryInfo) error
275 Ablation ablation.Set
276 // InteractiveHost marks human-in-the-loop entries (chat TUI); print mode
277 // and bots stay on core-v1.
278 InteractiveHost bool
279 // SessionTemp carries the previous Controller's private temporary directory
280 // manager across model/profile rebuilds so temporary files survive.
281 SessionTemp *sessiontemp.Manager
282 }
283
284 // sessionTempFromCLIController returns the logical-session private temporary
285 // directory manager for a same-session CLI controller rebuild. Nil keeps fresh
286 // builds on control.New's normal new-manager path.
287 func sessionTempFromCLIController(ctrl control.SessionAPI) *sessiontemp.Manager {
288 prev, ok := ctrl.(*control.Controller)
289 if !ok || prev == nil {
290 return nil
291 }
292 return prev.SessionTemp()
293 }
294
295 func setupProfileWithOverrides(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, overrides cliBuildOverrides) (*control.Controller, error) {
296 migrateMCPConfigForCLIWorkspace()
297 return boot.Build(ctx, cliProfileBuildOptions(modelName, maxStepsOverride, requireKey, sink, overrides))
298 }
299
300 func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, overrides cliBuildOverrides) boot.Options {
301 sessionDir := resolveCLISessionDir()
302 opts := boot.Options{
303 Model: modelName,
304 MaxSteps: maxStepsOverride,
305 MaxStepsKey: "--max-steps",
306 RequireKey: requireKey,
307 Sink: sink,
308 SessionDir: sessionDir,
309 SessionService: cliSessionService(sessionDir),
310 SessionHostID: "local",
311 AgentPreset: overrides.Preset,
312 WorkspaceRoot: overrides.WorkspaceRoot,
313 EffortOverride: overrides.Effort,
314 EffortModel: overrides.EffortModel,
315 PermissionAllow: overrides.PermissionAllow,
316 AdditionalDirs: overrides.AdditionalDirs,
317 HeadlessApprovalMode: overrides.HeadlessApprovalMode,
318 StatsSource: "cli",
319 Stderr: overrides.Stderr,
320 OnSessionRecovered: overrides.OnSessionRecovered,
321 Ablation: overrides.Ablation,
322 SessionTemp: overrides.SessionTemp,
323 }
324 opts.MCPHostProfile = plugin.HostProfileForInteractive(overrides.InteractiveHost)
325 return opts
326 }
327
328 type cliPermissionMode struct {
329 approval string
330 plan bool
331 allow []string
332 }
333
334 func parsePermissionMode(value string) (cliPermissionMode, error) {
335 switch strings.ToLower(strings.TrimSpace(value)) {
336 case "", "default", "workspace-write":
337 return cliPermissionMode{approval: control.ToolApprovalWorkspaceWrite}, nil
338 case "read-only":
339 return cliPermissionMode{approval: control.ToolApprovalReadOnly}, nil
340 case "danger-full-access":
341 return cliPermissionMode{approval: control.ToolApprovalDangerFullAccess}, nil
342 case "ask", "manual":
343 return cliPermissionMode{approval: control.ToolApprovalReadOnly}, nil
344 case "auto", "bypasspermissions", "bypass-permissions", "yolo":
345 return cliPermissionMode{approval: control.ToolApprovalWorkspaceWrite}, nil
346 case "acceptedits", "accept-edits":
347 return cliPermissionMode{approval: control.ToolApprovalWorkspaceWrite}, nil
348 case "dontask", "dont-ask":
349 return cliPermissionMode{approval: control.ToolApprovalReadOnly}, nil
350 case "plan":
351 return cliPermissionMode{approval: control.ToolApprovalAsk, plan: true}, nil
352 default:
353 return cliPermissionMode{}, fmt.Errorf("unknown permission mode %q (want read-only, workspace-write, danger-full-access, or plan)", value)
354 }
355 }
356
357 func resolveRunPermissionMode(value string, auto, modeExplicit bool) (string, error) {
358 if !auto {
359 return value, nil
360 }
361 if modeExplicit {
362 return "", errors.New("--auto/-y cannot be combined with --permission-mode")
363 }
364 return "workspace-write", nil
365 }
366
367 func applyPermissionMode(ctrl *control.Controller, mode cliPermissionMode) {
368 if ctrl == nil {
369 return
370 }
371 ctrl.SetToolApprovalMode(mode.approval)
372 ctrl.SetPlanMode(mode.plan)
373 }
374
375 // resolveCLISessionDir returns the session dir for CLI invocations. When the
376 // current working directory maps to a project session dir, the project dir is
377 // used so /resume shows project history. Falls back to the global session dir.
378 func resolveCLISessionDir() string {
379 cwd, err := os.Getwd()
380 if err != nil {
381 return config.SessionDir()
382 }
383 if projDir := config.ProjectSessionDir(cwd); projDir != "" && projDir != config.SessionDir() {
384 return projDir
385 }
386 return config.SessionDir()
387 }
388
389 // setupQuietProfile is like setupProfile but guarantees plugin subprocess
390 // stderr stays off the terminal. Interactive callers provide the private TUI
391 // diagnostic writer; other callers fall back to io.Discard.
392 func setupQuietProfile(ctx context.Context, modelName string, maxStepsOverride int, requireKey bool, sink event.Sink, overrides cliBuildOverrides) (*control.Controller, error) {
393 if overrides.Stderr == nil {
394 overrides.Stderr = io.Discard
395 }
396 return boot.Build(ctx, cliProfileBuildOptions(modelName, maxStepsOverride, requireKey, sink, overrides))
397 }
398
399 // parseRuntimeProfile validates a retired role flag. Recognized legacy values
400 // all fold to the standard runtime behavior.
401 func parseRuntimeProfile(value string) (string, error) {
402 switch strings.ToLower(strings.TrimSpace(value)) {
403 case "", "balanced", "standard", boot.TokenModeFull:
404 return "standard", nil
405 case "economy", "light", "lite", "eco":
406 return "standard", nil
407 case boot.TokenModeDelivery, "deliver", "quality":
408 return "standard", nil
409 default:
410 return "", fmt.Errorf("unknown retired execution setting %q", value)
411 }
412 }
413
414 // chdirTo honours --dir: it switches the working directory before anything reads
415 // it, so config discovery, the sandbox root, and file tools all resolve from the
416 // chosen project root. Returns 2 (already reported) on failure, 0 otherwise.
417 func chdirTo(dir string) int {
418 if dir == "" {
419 return 0
420 }
421 if err := os.Chdir(dir); err != nil {
422 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
423 return 2
424 }
425 return 0
426 }
427
428 // workspaceRootForDir returns the explicit project root to pin when --dir was
429 // given. It runs after chdirTo has already switched into dir, so the process
430 // working directory is the resolved root. An empty dir means no override (fall
431 // back to git-root detection). A Getwd failure is returned rather than swallowed:
432 // silently reverting to "" would re-trigger git-root/default resolution and break
433 // the explicit --dir guarantee, so the caller must fail loudly instead.
434 func workspaceRootForDir(dir string) (string, error) {
435 if dir == "" {
436 return "", nil
437 }
438 wd, err := os.Getwd()
439 if err != nil {
440 return "", fmt.Errorf("resolve --dir workspace root: %w", err)
441 }
442 return wd, nil
443 }
444
445 func loadResumableSession(path string) (*agent.Session, error) {
446 if agent.IsCleanupPending(path) {
447 return nil, fmt.Errorf("session is pending cleanup")
448 }
449 return agent.LoadSession(path)
450 }
451
452 var newNotificationSender = func() notify.Sender { return notify.NewPlatformSender() }
453
454 // withNotifications adds system notifications to CLI event streams when configured.
455 func withNotifications(sink event.Sink, cfg *config.Config) event.Sink {
456 if cfg == nil || !cfg.Notifications.Enabled {
457 return sink
458 }
459 return notify.NewSink(sink, newNotificationSender(), cfg.Notifications)
460 }
461
462 // registerContinueFlag registers --continue with its -c shorthand. The
463 // shorthand must go through BoolP (pflag shorthand), not BoolVar: BoolVar
464 // registers "c" as a long flag name, which leaves "-c" unparseable
465 // ("unknown shorthand flag: 'c' in -c") while accidentally accepting "--c".
466 func registerContinueFlag(fs *pflag.FlagSet) *bool {
467 return fs.BoolP("continue", "c", false, "resume the most recent saved session")
468 }
469
470 func runAgent(args []string, version string) int {
471 defer closeCLIUsageCatalogs()
472 args, deprecatedMode, err := consumeDeprecatedModeFlags(args, "profile", "preset")
473 if err != nil {
474 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
475 return 2
476 }
477 fs := pflag.NewFlagSet("run", pflag.ContinueOnError)
478 fs.SetInterspersed(true)
479 model := fs.String("model", "", "provider name (default: config default_model)")
480 maxSteps := fs.Int("max-steps", 0, "one-off max tool-call rounds (0 = automatic)")
481 showThinking := fs.Bool("show-thinking", false, "show thinking text instead of the collapsed thinking marker")
482 metricsPath := fs.String("metrics", "", "write a JSON token/cache/cost summary of the run to this path")
483 trajectoryPath := fs.String("trajectory", "", "append a timestamped JSONL trajectory of the run's full event stream (tool calls, reasoning, decisions) to this path")
484 ablateFlag := fs.String("ablate", "", "benchmark arm: comma-separated subsystems to switch off (evidence, planner, subagent, retrieval, compaction; none|all)")
485 dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here")
486 cont := registerContinueFlag(fs)
487 resume := fs.String("resume", "", "resume by session file path, session ID, or machine session ID (takes precedence over --continue)")
488 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)")
489 takeover := fs.Bool("takeover", false, "with --resume/--continue: when a resident serve on this machine holds the session, take it over instead of refusing")
490 effort := fs.String("effort", "", "session reasoning effort override")
491 permissionMode := fs.String("permission-mode", "workspace-write", "permission mode: read-only | workspace-write | danger-full-access")
492 autoApprove := fs.BoolP("auto", "y", false, "deprecated compatibility flag; uses workspace-write")
493 _ = fs.MarkHidden("auto")
494 printOnly := fs.BoolP("print", "p", false, "print only the final response")
495 eventsJSONL := fs.Bool("events-jsonl", false, "emit a redacted structured event stream as JSONL")
496 outputFormat := fs.String("output-format", "text", "output format: text | json | stream-json")
497 var additionalDirs []string
498 fs.StringArrayVar(&additionalDirs, "add-dir", nil, "allow tool access to an additional directory (repeatable)")
499 var allowedToolValues []string
500 fs.StringArrayVar(&allowedToolValues, "allowed-tools", nil, "comma or space-separated permission rules to allow")
501 fs.StringArrayVar(&allowedToolValues, "allowedTools", nil, "alias for --allowed-tools")
502 if code, ok := parseCommandFlags(fs, args); !ok {
503 return code
504 }
505 resolvedPermissionMode, err := resolveRunPermissionMode(*permissionMode, *autoApprove, fs.Changed("permission-mode"))
506 if err != nil {
507 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
508 return 2
509 }
510 *permissionMode = resolvedPermissionMode
511 allowedTools, err := splitAllowedToolRules(allowedToolValues)
512 if err != nil {
513 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
514 return 2
515 }
516 format, err := parseRunOutputFormat(*outputFormat)
517 if err != nil {
518 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
519 return 2
520 }
521 if *eventsJSONL {
522 if fs.Changed("output-format") {
523 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--events-jsonl cannot be combined with --output-format")
524 return 2
525 }
526 format = runOutputEventsJSONL
527 }
528 if err := acceptDeprecatedModeFlag(deprecatedMode); err != nil {
529 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
530 return 2
531 }
532 ablated, err := ablation.Parse(*ablateFlag)
533 if err != nil {
534 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
535 return 2
536 }
537 permissions, err := parsePermissionMode(*permissionMode)
538 if err != nil {
539 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
540 return 2
541 }
542 if permissions.plan {
543 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--permission-mode plan requires an interactive session")
544 return 2
545 }
546 allowedTools = uniqueStrings(append(allowedTools, permissions.allow...))
547 if rc := chdirTo(*dir); rc != 0 {
548 return rc
549 }
550 workspaceRoot, err := workspaceRootForDir(*dir)
551 if err != nil {
552 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
553 return 1
554 }
555 cfg, _ := config.Load()
556 configureCLIThemeFromConfigForTTYOutput()
557
558 prompt := strings.TrimSpace(strings.Join(fs.Args(), " "))
559 if prompt == "" {
560 prompt = readStdin()
561 }
562 if prompt == "" {
563 fmt.Fprintln(os.Stderr, i18n.M.UsageRunHint)
564 return 2
565 }
566 var machineIdentityKey []byte
567 if format == runOutputEventsJSONL {
568 machineIdentityKey, err = loadMachineIdentityKey()
569 if err != nil {
570 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "machine identity is unavailable")
571 return 1
572 }
573 }
574
575 resumeTarget, rc := headlessResumeTarget(*resume, *cont, *copySession)
576 if rc != 0 {
577 return rc
578 }
579 resumePath := resumeTarget.path
580 if *copySession {
581 copied, err := copyResumableSession(*model, resumePath, cfg)
582 if err != nil {
583 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
584 return 1
585 }
586 // Keep structured (json/stream-json) and --print stdout a single
587 // machine-readable payload: the human copy notice goes to stderr there.
588 // Plain text runs keep it on stdout, where callers scrape the copied path.
589 if format == runOutputText && !*printOnly {
590 fmt.Printf("continuing in a session copy: %s\n", copied)
591 } else {
592 fmt.Fprintf(os.Stderr, "continuing in a session copy: %s\n", copied)
593 }
594 resumePath = copied
595 }
596 sessionMode := cliTelemetrySessionMode(*cont, strings.TrimSpace(*resume) != "", *copySession)
597 reporter := startCLITelemetry(cfg, telemetry.Options{
598 Version: version, Interactive: false, CLIMode: "run",
599 PermissionMode: *permissionMode, SessionMode: sessionMode,
600 })
601
602 // Own the session file for the lifetime of this run so a desktop window (or
603 // another CLI) writing the same session is refused up front instead of
604 // silently double-writing. Released after the controller closes.
605 leases := control.NewSessionLeaseKeeper()
606 defer leases.Release()
607 takeoverManager := newCLITakeoverManager(nil, leases)
608 defer func() {
609 if err := takeoverManager.Close(); err != nil {
610 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
611 }
612 }()
613 var resumeSession *agent.Session
614 var takeoverBinding *cliTakeoverBinding
615 if resumePath != "" {
616 var err error
617 resumeSession, err = bindAndLoadCLIResume(leases, resumePath, loadResumableSession)
618 if errors.Is(err, agent.ErrSessionLeaseHeld) && *takeover {
619 takeoverBinding, err = cliTakeoverHeldSession(resumePath, err, leases, takeoverManager)
620 if err != nil {
621 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
622 return 1
623 }
624 resumeSession, err = cliPrepareTakeoverCandidate(takeoverBinding, leases)
625 if err != nil {
626 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
627 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
628 return 1
629 }
630 }
631 if err != nil {
632 if errors.Is(err, agent.ErrSessionLeaseHeld) {
633 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, sessionLeaseResumeRefusal(err))
634 } else {
635 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
636 }
637 return 1
638 }
639 }
640
641 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
642 defer stop()
643 started := time.Now()
644
645 chain, err := buildRunSink(format, *printOnly, *showThinking, *metricsPath, *trajectoryPath, cfg, reporter)
646 if err != nil {
647 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
648 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
649 return 1
650 }
651 takeoverManager.SetInner(chain.sink)
652 chain.sink = takeoverManager
653 sink, resultOutput, metrics := chain.sink, chain.resultOutput, chain.metrics
654 if err := applyResumeModel(model, resumePath, cfg); err != nil {
655 return cliTakeoverFailure(takeoverBinding, leases, takeoverManager, err)
656 }
657 var effortOverride *string
658 if strings.TrimSpace(*effort) != "" {
659 effortOverride = effort
660 }
661 // `reasonix run` is headless: there is no key loop to answer approval or ask
662 // prompts, and the approval timeout defaults to infinite. Installing the
663 // interactive approver/asker here would let an Ask rule, the `ask` tool, or a
664 // sandbox/config approval wedge the run forever. Map the mode onto a
665 // non-blocking headless gate instead — passed into boot.Build so every
666 // headless-only gate it constructs (task/read_only_task, writer-capable
667 // skill sub-agents, the planner runner) gets the same contract as the parent
668 // executor, not just the top-level one. Default/ask fails closed because no
669 // UI can answer; unattended writes require explicit --auto/-y,
670 // legacy permission aliases.
671 overrides := cliBuildOverrides{
672 Preset: deprecatedMode,
673 Effort: effortOverride,
674 PermissionAllow: allowedTools,
675 AdditionalDirs: additionalDirs,
676 WorkspaceRoot: workspaceRoot,
677 HeadlessApprovalMode: permissions.approval,
678 OnSessionRecovered: cliSessionRecoveredHandler(leases),
679 Ablation: ablated,
680 }
681 ctrl, err := setupProfileWithOverrides(ctx, *model, *maxSteps, true, sink, overrides)
682 if err != nil {
683 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
684 if resultOutput != nil && format != runOutputText {
685 if encodeErr := resultOutput.Finalize("", started, err); encodeErr != nil {
686 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, encodeErr)
687 }
688 return 1
689 }
690 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
691 return 1
692 }
693 defer ctrl.Close()
694 takeoverManager.AttachController(ctrl)
695 SetTaskJobKiller(ctrlKillerAdapter{ctrl})
696 ctrl.ApplyHeadlessApprovalMode(permissions.approval)
697
698 // --resume: load a specific session file (non-interactive, meant for
699 // MCP/API callers that manage their own per-project session). Takes
700 // precedence over --continue.
701 // --continue: resume the most recent saved session.
702 if err := commitStartupResume(takeoverBinding, takeoverManager, ctrl, resumeSession, resumeTarget,
703 flagTakeoverApproval(*takeover)); err != nil {
704 return cliTakeoverFailure(takeoverBinding, leases, takeoverManager, err)
705 }
706 ctrl.EnsureSessionPath()
707 // Fresh sessions take the lease too (defensive: the path is brand new); a
708 // resumed path is already held, making this a no-op.
709 if err := rebindCLIControllerAuthority(leases, ctrl); err != nil {
710 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
711 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint)
712 return 1
713 }
714 if takeoverBinding != nil {
715 takeoverManager.Activate(takeoverBinding)
716 }
717 reclaimCLIRecoveryBranches(ctrl.SessionDir())
718
719 runErr := ctrl.Run(ctx, prompt)
720 reporter.RecordRecovery(ctrl.DrainRecoveryMetrics())
721 completion := classifyRunCompletion(runErr)
722 if cfg != nil {
723 notify.SendEvent(newNotificationSender(), cfg.Notifications, event.Event{
724 Kind: event.TurnDone,
725 Err: runErr,
726 Outcome: completion.outcome,
727 })
728 }
729 if metrics != nil {
730 // Snapshot under the sink's lock: a background job can still be emitting
731 // into it while this goroutine assembles the final record.
732 final := metrics.Snapshot()
733 final.DurationMs = time.Since(started).Milliseconds()
734 final.Outcome = completion.class
735 final.Arm = ablated.Arm()
736 if exec := ctrl.Executor(); exec != nil {
737 if audit := exec.CapabilityAudit(); audit != nil {
738 snap := audit.Snapshot()
739 final.MergeCapabilityAudit(&snap)
740 }
741 }
742 if err := writeMetrics(*metricsPath, final); err != nil {
743 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
744 }
745 }
746 if chain.trajectory != nil {
747 if err := chain.trajectory.Close(); err != nil {
748 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
749 }
750 }
751 if resultOutput != nil {
752 sessionID := runOutputSessionID(format, agent.BranchID(ctrl.SessionPath()), machineIdentityKey)
753 if err := resultOutput.Finalize(sessionID, started, runErr); err != nil {
754 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
755 return 1
756 }
757 }
758 if runErr != nil {
759 reportRunFailure(os.Stderr, format, resultOutput != nil, completion, runErr)
760 return completion.exitCode
761 }
762 return completion.exitCode
763 }
764
765 func runServeWithOptions(args []string, opts serveRunOptions) int {
766 if opts.command == "" {
767 opts.command = "serve"
768 }
769 args, deprecatedMode, err := consumeDeprecatedModeFlags(args, "profile", "preset")
770 if err != nil {
771 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
772 return 2
773 }
774 fs := flag.NewFlagSet(opts.command, flag.ContinueOnError)
775 model := fs.String("model", "", "provider name (default: config default_model)")
776 maxSteps := fs.Int("max-steps", 0, "one-off max tool-call rounds (0 = automatic)")
777 addr := fs.String("addr", "127.0.0.1:8787", "listen address")
778 resume := fs.String("resume", "", "resume a saved session file")
779 sessionIDValue := ""
780 sessionID := &sessionIDValue
781 if opts.command == "web" {
782 sessionID = fs.String("session-id", "", "bind a fresh Web session identity (used by /web handoff)")
783 }
784 authHelp := "auth mode: none, token, or password (default: config/none)"
785 if opts.command == "web" {
786 authHelp = "auth mode: none, token, or password (default: generated token)"
787 }
788 auth := fs.String("auth", "", authHelp)
789 token := fs.String("token", "", "pre-shared token for auth=token (auto-generated if empty)")
790 password := fs.String("password", "", "password for auth=password (use --hash-password to store a hash instead)")
791 hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password and exit")
792 behindProxy := fs.Bool("behind-proxy", false, "trust X-Forwarded-For / X-Forwarded-Proto headers from a reverse proxy")
793 portFile := fs.String("port-file", "", "write the actual bound listen address (host:port) to this file after binding")
794 tokenFile := fs.String("token-file", "", "read the auth=token pre-shared token from this file (overrides --token; keeps the secret out of argv)")
795 pidFile := fs.String("pid-file", "", "write the server process id to this file")
796 registerServeCapabilityFlags(fs)
797 openBrowser := fs.Bool("open", opts.openBrowser, "open the Web UI in the default browser")
798 noOpen := fs.Bool("no-open", false, "do not open the Web UI in the default browser")
799 if code, ok := parseCommandFlags(fs, args); !ok {
800 return code
801 }
802 authExplicit := false
803 fs.Visit(func(f *flag.Flag) {
804 if f.Name == "auth" {
805 authExplicit = true
806 }
807 })
808 if *resume != "" && *sessionID != "" {
809 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--resume and --session-id cannot be used together")
810 return 2
811 }
812 if *sessionID != "" {
813 if err := validateWebSessionID(*sessionID); err != nil {
814 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
815 return 2
816 }
817 }
818 if err := acceptDeprecatedModeFlag(deprecatedMode); err != nil {
819 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
820 return 2
821 }
822
823 // --hash-password: generate a bcrypt hash and exit.
824 if *hashPassword {
825 if *password == "" {
826 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--hash-password requires --password")
827 return 1
828 }
829 h, err := serve.HashPassword(*password)
830 if err != nil {
831 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
832 return 1
833 }
834 fmt.Println(h)
835 return 0
836 }
837
838 ctx := context.Background()
839 bc, sessionTag, cfg := newServeBootstrap()
840
841 // Build serve config, merging CLI flags over config file.
842 serveCfg := serveConfigWithCommandDefaults(opts.command, authExplicit, cfg.Serve)
843 // `reasonix web` is a local browser entry point and defaults to a freshly
844 // generated token. `reasonix serve` keeps its existing config-driven default,
845 // and an explicit --auth always wins for both commands.
846 if *auth != "" {
847 serveCfg.AuthMode = *auth
848 }
849 if *token != "" {
850 serveCfg.Token = *token
851 }
852 if *tokenFile != "" {
853 tok, err := readServeTokenFile(*tokenFile)
854 if err != nil {
855 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
856 return 1
857 }
858 serveCfg.Token = tok
859 }
860 if *behindProxy {
861 serveCfg.BehindProxy = true
862 }
863 mode, err := serve.NormalizeAuthMode(serveCfg.AuthMode)
864 if err != nil {
865 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
866 return 1
867 }
868 serveCfg.AuthMode = mode
869 if *password != "" && serveCfg.AuthMode == "password" {
870 // Hash the password at startup so the config never stores plaintext.
871 // If a PasswordHash is already set in config, the CLI password overrides it.
872 h, err := serve.HashPassword(*password)
873 if err != nil {
874 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "failed to hash password:", err)
875 return 1
876 }
877 serveCfg.PasswordHash = h
878 }
879 if serveCfg.AuthMode == "password" && strings.TrimSpace(serveCfg.PasswordHash) == "" {
880 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auth mode password requires --password or serve.password_hash")
881 return 1
882 }
883
884 // Own the active session file for the server's lifetime; the serve
885 // handlers that rebind sessions (/resume, /new, /fork) move the lease
886 // through the same keeper. Released after the controller closes.
887 leases := control.NewSessionLeaseKeeper()
888 defer leases.Release()
889 var resumeSession *agent.Session
890 if *resume != "" {
891 if err := leases.Rebind(*resume); err != nil {
892 if errors.Is(err, agent.ErrSessionLeaseHeld) {
893 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint)
894 } else {
895 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
896 }
897 return 1
898 }
899 var err error
900 resumeSession, err = loadResumableSession(*resume)
901 if err != nil {
902 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
903 return 1
904 }
905 }
906 if err := applyResumeModel(model, *resume, cfg); err != nil {
907 return cliFailure(err)
908 }
909 // Serve always resolves an implicit model from the user-global config,
910 // ignoring project-level default_model overrides. Explicit flags and
911 // resumable session models remain strict and are preserved verbatim.
912 *model = resolveServeModel(*model)
913 // Keep the browser reachable when the selected provider has no saved key.
914 // The loopback-only provider setup surface stores the missing credential and
915 // rebuilds this controller in place before the normal web UI is exposed.
916 ctrl, serveBuildOpts, err := setupCLIMultiSessionProfile(ctx, *model, *maxSteps, deprecatedMode, sessionTag, leases)
917 if err != nil {
918 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
919 return 1
920 }
921 defer ctrl.Close()
922 SetTaskJobKiller(ctrlKillerAdapter{ctrl})
923
924 // Auto-save target: reuse the resumed file, else a fresh one — same as chat.
925 if err := prepareServeSessionPath(ctrl, resumeSession, *resume, *sessionID); err != nil {
926 return cliFailure(err)
927 }
928 ctrl.EnsureSessionPath()
929 // Fresh sessions take the lease too (defensive: the path is brand new); a
930 // resumed path is already held, making this a no-op.
931 if err := rebindCLIControllerAuthority(leases, ctrl); err != nil {
932 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint)
933 return 1
934 }
935
936 srv := newCLIMultiSessionServer(ctrl, bc, sessionTag, serveCfg, leases, serveBuildOpts)
937 defer srv.Close()
938 return runServeFrontend(ctrl, srv, serveCfg, serveFrontendOptions{
939 command: opts.command, address: *addr,
940 portFile: *portFile, tokenFile: *tokenFile, pidFile: *pidFile,
941 openBrowser: *openBrowser && !*noOpen,
942 hasSession: *resume != "" || *sessionID != "",
943 })
944 }
945
946 // chatREPL is an interactive session: a single persistent agent/session and a
947 // prompt loop that keeps conversation context across turns. Exit with
948 // 'exit'/'quit' or Ctrl-D.
949 func chatREPL(args []string, version string) int {
950 args, deprecatedMode, err := consumeDeprecatedModeFlags(args, "profile", "preset")
951 if err != nil {
952 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
953 return 2
954 }
955 fs := pflag.NewFlagSet("reasonix", pflag.ContinueOnError)
956 fs.SetInterspersed(true)
957 model := fs.String("model", "", "provider name (default: config default_model)")
958 maxSteps := fs.Int("max-steps", 0, "one-off max tool-call rounds (0 = automatic)")
959 cont := registerContinueFlag(fs)
960 resume := fs.StringP("resume", "r", "", "resume by session ID/query, or open the picker when no value is given")
961 fs.Lookup("resume").NoOptDefVal = resumePickerSentinel
962 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)")
963 legacyYolo := fs.Bool("dangerously-skip-permissions", false, "deprecated: use --permission-mode danger-full-access")
964 fs.BoolVar(legacyYolo, "yolo", false, "deprecated alias; migrates to workspace-write")
965 _ = fs.MarkHidden("dangerously-skip-permissions")
966 _ = fs.MarkHidden("yolo")
967 dir := fs.String("dir", "", "change to this directory first (project root); config, sandbox and file tools resolve from here")
968 effort := fs.String("effort", "", "session reasoning effort override")
969 permissionMode := fs.String("permission-mode", "workspace-write", "permission mode: read-only | workspace-write | danger-full-access | plan")
970 var additionalDirs []string
971 fs.StringArrayVar(&additionalDirs, "add-dir", nil, "allow tool access to an additional directory (repeatable)")
972 var allowedToolValues []string
973 fs.StringArrayVar(&allowedToolValues, "allowed-tools", nil, "comma or space-separated permission rules to allow")
974 fs.StringArrayVar(&allowedToolValues, "allowedTools", nil, "alias for --allowed-tools")
975 if code, ok := parseCommandFlags(fs, normalizeOptionalResumeArg(args)); !ok {
976 return code
977 }
978 allowedTools, err := splitAllowedToolRules(allowedToolValues)
979 if err != nil {
980 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
981 return 2
982 }
983 if err := acceptDeprecatedModeFlag(deprecatedMode); err != nil {
984 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
985 return 2
986 }
987 permissions, err := parsePermissionMode(*permissionMode)
988 if err != nil {
989 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
990 return 2
991 }
992 allowedTools = uniqueStrings(append(allowedTools, permissions.allow...))
993 if rc := chdirTo(*dir); rc != 0 {
994 return rc
995 }
996 workspaceRoot, err := workspaceRootForDir(*dir)
997 if err != nil {
998 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
999 return 1
1000 }
1001 // Bubble Tea owns the terminal from the resume picker through controller
1002 // shutdown. Start diagnostics before config/controller work so hangs leave a
1003 // non-zero log with milestones (#7435, #7507).
1004 diagnostics := startTUIDiagnostics(config.ReasonixHomeDir())
1005 defer diagnostics.Close()
1006 diagnostics.Milestone("config_load_begin")
1007 cfg, err := config.Load()
1008 if err == nil {
1009 configureCLIThemeWithStyle(cfg.UITheme(), cfg.UIThemeStyle())
1010 cliCursorShape = cfg.UICursorShape()
1011 }
1012 diagnostics.Milestone("config_load_done")
1013
1014 // Decide whether we're starting fresh or resuming. --resume opens an
1015 // interactive picker; --continue / -c jumps straight into the newest.
1016 resumeValue := normalizedResumeFlag(*resume)
1017 resumeTarget, rc := interactiveResumeTarget(resumeValue, *cont, *copySession)
1018 if rc != 0 {
1019 return rc
1020 }
1021 resumePath := resumeTarget.path
1022 if *copySession {
1023 copied, err := copyResumableSession(*model, resumePath, cfg)
1024 if err != nil {
1025 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1026 return 1
1027 }
1028 fmt.Printf("continuing in a session copy: %s\n", copied)
1029 resumePath = copied
1030 }
1031 sessionMode := cliTelemetrySessionMode(*cont, resumeValue != "", *copySession)
1032 reporter := startCLITelemetry(cfg, telemetry.Options{
1033 Version: version, Interactive: isInteractive(), CLIMode: "tui",
1034 PermissionMode: *permissionMode, SessionMode: sessionMode,
1035 })
1036
1037 // Own the active session file for the TUI's lifetime; in-TUI switches
1038 // (/resume, /switch, /new, ...) move the lease with the active path.
1039 // Refusing a held resume target up front is what keeps a desktop window
1040 // and this chat from silently double-writing one transcript.
1041 leases := control.NewSessionLeaseKeeper()
1042 defer leases.Release()
1043 takeoverManager := newCLITakeoverManager(nil, leases)
1044 defer func() {
1045 if err := takeoverManager.Close(); err != nil {
1046 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1047 }
1048 }()
1049 var takeoverBinding *cliTakeoverBinding
1050 var startupResumeSession *agent.Session
1051 if resumePath != "" {
1052 startupResumeSession, err = bindAndLoadCLIResume(leases, resumePath, loadResumableSession)
1053 if errors.Is(err, agent.ErrSessionLeaseHeld) && cliSessionTakeoverCandidate(err) && promptSessionTakeover(err) {
1054 takeoverBinding, err = cliTakeoverHeldSession(resumePath, err, leases, takeoverManager)
1055 if err != nil {
1056 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1057 return 1
1058 }
1059 startupResumeSession, err = cliPrepareTakeoverCandidate(takeoverBinding, leases)
1060 if err != nil {
1061 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
1062 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1063 return 1
1064 }
1065 }
1066 if err != nil {
1067 if errors.Is(err, agent.ErrSessionLeaseHeld) {
1068 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, sessionLeaseResumeRefusal(err))
1069 } else {
1070 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1071 }
1072 return 1
1073 }
1074 }
1075
1076 ctx := context.Background()
1077 if err := applyResumeModel(model, resumePath, cfg); err != nil {
1078 return cliTakeoverFailure(takeoverBinding, leases, takeoverManager, err)
1079 }
1080
1081 // Plumb the controller's typed event stream through a channel so each event
1082 // can become a tea.Msg inside the TUI's update loop. Buffered generously:
1083 // streaming bursts (tool results, long answers) shouldn't backpressure the
1084 // agent goroutine.
1085 eventCh := make(chan event.Event, 1024)
1086
1087 var sink event.Sink = &eventSink{ch: eventCh}
1088 sink = withNotifications(sink, cfg)
1089 sink = reporter.Wrap(sink)
1090 takeoverManager.SetInner(sink)
1091 sink = takeoverManager
1092 var effortOverride *string
1093 if strings.TrimSpace(*effort) != "" {
1094 effortOverride = effort
1095 }
1096 overrides := cliBuildOverrides{
1097 Preset: deprecatedMode,
1098 Effort: effortOverride,
1099 PermissionAllow: allowedTools,
1100 AdditionalDirs: additionalDirs,
1101 WorkspaceRoot: workspaceRoot,
1102 InteractiveHost: true,
1103 Stderr: diagnostics.Writer(),
1104 OnSessionRecovered: cliSessionRecoveredHandler(leases),
1105 }
1106 diagnostics.Milestone("controller_build_begin")
1107 ctrl, err := setupProfileWithOverrides(ctx, *model, *maxSteps, false, sink, overrides)
1108 if err != nil && errors.Is(err, boot.ErrUnknownModel) && isInteractive() && config.SourcePath() == "" {
1109 // True first run whose default model can't resolve: guide setup, then retry.
1110 // With a config present, fall through to the descriptive error — re-running
1111 // the wizard would overwrite the user's config (#2856).
1112 fmt.Fprintln(os.Stderr, i18n.M.ReconfigureOnUnknownModel)
1113 if rc := interactiveSetup(defaultConfigTarget(), defaultEnvTarget()); rc != 0 {
1114 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
1115 return rc
1116 }
1117 ctrl, err = setupProfileWithOverrides(ctx, *model, *maxSteps, false, sink, overrides)
1118 }
1119 if err != nil {
1120 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
1121 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1122 return 1
1123 }
1124 diagnostics.Milestone("controller_build_done")
1125
1126 // Decide where this conversation's auto-save lands. A resume reuses the
1127 // file so closing/reopening keeps appending to the same history; a fresh
1128 // session lands in a new file stamped with the model name.
1129 if err := commitStartupResume(takeoverBinding, takeoverManager, ctrl, startupResumeSession, resumeTarget,
1130 promptTakeoverApproval); err != nil {
1131 return cliTakeoverFailure(takeoverBinding, leases, takeoverManager, err)
1132 }
1133 ctrl.EnsureSessionPath()
1134 // Fresh sessions take the lease too (defensive: the path is brand new); a
1135 // resumed path is already held, making this a no-op.
1136 if err := rebindCLIControllerAuthority(leases, ctrl); err != nil {
1137 _ = cliReturnFailedTakeover(takeoverBinding, leases, takeoverManager)
1138 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, control.SessionInUseMessage(err)+"; "+control.SessionLeaseCloseHint)
1139 return 1
1140 }
1141 reclaimCLIRecoveryBranches(ctrl.SessionDir())
1142
1143 // Keep local recovery available when authentication is incomplete. The
1144 // controller gate ensures input cannot become a model turn until configured.
1145 // resolveModelForCLI transparently falls through a keyless default to the
1146 // next configured provider (issue #6996). Validating the final ref is a
1147 // no-op for that configured fallback and preserves the warning when every
1148 // eligible chat provider is still keyless.
1149 missing := ""
1150 if cfg, loadErr := config.Load(); loadErr == nil {
1151 name, _, err := resolveModelForCLI(*model, cfg)
1152 switch {
1153 case err != nil:
1154 missing = err.Error()
1155 case name != "" && providerext.PluginRefOwner(name) != "":
1156 // Plugin-namespaced refs hold no config credential; boot's merged
1157 // resolver already gated them, and there is no key env to warn about.
1158 case name != "":
1159 if vErr := cfg.Validate(name); vErr != nil {
1160 missing = vErr.Error()
1161 }
1162 }
1163 }
1164
1165 // Initial terminal width — the TUI re-flows on every WindowSizeMsg so
1166 // this is just a starting estimate before the first resize event lands.
1167 termW := 80
1168 if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
1169 termW = w
1170 }
1171
1172 // Route "ask" decisions to the TUI: the controller emits an ApprovalRequest
1173 // event and blocks until the user answers via ctrl.Approve. Sub-agents (the
1174 // task tool) keep their headless gate from setup — no UI to prompt through.
1175 ctrl.EnableInteractiveApproval()
1176 applyPermissionMode(ctrl, permissions)
1177 // Legacy bypass flags migrate conservatively to the workspace preset. Full
1178 // access is only reachable through an explicit canonical preset selection.
1179 if *legacyYolo {
1180 ctrl.SetToolApprovalMode(control.ToolApprovalWorkspaceWrite)
1181 }
1182
1183 m := newChatTUI(ctrl, missing, eventCh, termW)
1184 m.diagnostics = diagnostics
1185 m.updateWatchdogStatusProvider()
1186 m.planMode = permissions.plan
1187 m.leases = leases
1188 m.takeover = takeoverManager
1189 takeoverManager.AttachController(ctrl)
1190 if takeoverBinding != nil {
1191 takeoverManager.Activate(takeoverBinding)
1192 }
1193 if cfg != nil {
1194 m.outputStyle = cfg.Agent.OutputStyle // shown as the active entry in /output-style
1195 m.statuslineCmd = cfg.Statusline.Command // custom status-line command, "" = built-in row
1196 m.showReasoning = cfg.UI.ShowReasoning // /verbose persistence: start with config default
1197 m.showTurnUsage = cfg.UI.ShowTurnUsage // retain usage accounting even when transcript receipts are hidden
1198 m.cfg = cfg
1199 }
1200
1201 // /model support: a pure builder the TUI calls to rebuild on a different
1202 // model (carrying the conversation). It must NOT touch the running model —
1203 // runModelSubcommand performs the swap on the live copy. The same stable sink
1204 // feeds the new controller, so events keep flowing to this TUI.
1205 m.buildController = func(spec controllerBuildSpec, carry []provider.Message, resumePath string, oldCtrl control.SessionAPI) (*control.Controller, error) {
1206 effectiveOverrides := overrides.forSelection(m.cfg, spec)
1207 // Keep the logical-session private temporary directory across model /
1208 // profile switches (Issue #7575).
1209 effectiveOverrides.SessionTemp = sessionTempFromCLIController(oldCtrl)
1210 c, err := setupQuietProfile(ctx, spec.ModelRef, *maxSteps, false, sink, effectiveOverrides)
1211 if err != nil {
1212 return nil, err
1213 }
1214 // Keep the carried conversation in its existing file so the switch doesn't
1215 // orphan a duplicate (#2807).
1216 path := agent.ContinueSessionPath(resumePath, c.SessionDir(), c.Label())
1217 if err := adoptCarriedHistoryPreservingProfileAndGrants(c, carry, path, oldCtrl); err != nil {
1218 c.Close()
1219 return nil, err
1220 }
1221 overrides.Effort = effectiveOverrides.Effort
1222 overrides.EffortModel = spec.ModelRef
1223 c.EnableInteractiveApproval()
1224 c.SetPlanMode(spec.PlanMode)
1225 if spec.ToolApprovalMode != "" {
1226 c.SetToolApprovalMode(spec.ToolApprovalMode)
1227 }
1228 return c, nil
1229 }
1230 // /reload support: rebuild the runtime through boot.Rebuild so tools,
1231 // skills, commands, hooks, MCP servers, and providers are discovered fresh
1232 // while the boot layer migrates the session (history, approval grants,
1233 // goal/recovery state, lifecycle). Same construction inputs as
1234 // buildController so the replacement matches this session's launch wiring;
1235 // the CLI holds no SharedHost, so each rebuild owns its plugin host.
1236 overrides.EffortModel = ctrl.ModelRef()
1237 m.bindRuntimeRebuilder(*maxSteps, sink, false, &overrides, cliProfileBuildOptions)
1238 if effortOverride != nil {
1239 m.effortLevel = *effortOverride
1240 }
1241 if effortOverride == nil {
1242 m.refreshEffortStatus()
1243 }
1244 if authentication, ok := m.ctrl.(interface {
1245 AuthenticationState() control.AuthenticationState
1246 }); ok && !authentication.AuthenticationState().Ready() {
1247 m.openConnectionSetup()
1248 }
1249
1250 if m.nativeScrollback {
1251 prepareNativeScrollback(os.Stdout, m.bottomRows())
1252 }
1253
1254 // Non-Termux terminals use an alt-screen transcript viewport. Termux stays
1255 // in the normal buffer so native touch scrollback and soft-keyboard focus
1256 // keep working; finalized transcript lines are emitted via tea.Println.
1257 diagnostics.Milestone("terminal_takeover_begin")
1258 p := tea.NewProgram(m)
1259 takeoverManager.SetYieldCallback(func() { p.Send(tuiSessionReclaimedMsg{}) })
1260 diagnostics.StartWatchdog(p)
1261 // SSH drop (SIGHUP) or service stop (SIGTERM): persist the conversation
1262 // before the terminal goes away, then unwind through the normal close path
1263 // so resume picks up the interrupted session (#3772).
1264 hangup := make(chan os.Signal, 1)
1265 signal.Notify(hangup, syscall.SIGHUP, syscall.SIGTERM)
1266 go func() {
1267 for range hangup {
1268 p.Send(tuiShutdownMsg{})
1269 }
1270 }()
1271 final, runErr := p.Run()
1272 signal.Stop(hangup)
1273 diagnostics.Milestone("terminal_released")
1274 if err := takeoverManager.Close(); err != nil {
1275 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
1276 if runErr == nil {
1277 runErr = err
1278 }
1279 }
1280 // Close the active controller plus any retired ones from /model switches.
1281 // Retired controllers were stashed rather than closed at switch time
1282 // because Controller.Close() runs SessionEnd hooks and kills plugin
1283 // subprocesses — operations that corrupt bubbletea's terminal raw mode
1284 // when executed while the TUI is alive.
1285 var launchWeb bool
1286 var launchWebPath, launchWebSessionID, launchWebModelRef string
1287 if fm, ok := final.(chatTUI); ok {
1288 reportShutdownFailure(fm.shutdownErr)
1289 launchWeb = fm.launchWebOnExit
1290 for _, oc := range fm.oldControllers {
1291 if c, ok := oc.(*control.Controller); ok {
1292 reporter.RecordRecovery(c.DrainRecoveryMetrics())
1293 }
1294 oc.Close()
1295 }
1296 if fm.ctrl != nil {
1297 launchWebPath = fm.launchWebResumePath
1298 launchWebSessionID = fm.launchWebSessionID
1299 launchWebModelRef = fm.launchWebModelRef
1300 if c, ok := fm.ctrl.(*control.Controller); ok {
1301 reporter.RecordRecovery(c.DrainRecoveryMetrics())
1302 }
1303 fm.ctrl.Close()
1304 } else {
1305 reporter.RecordRecovery(ctrl.DrainRecoveryMetrics())
1306 ctrl.Close()
1307 }
1308 } else {
1309 reporter.RecordRecovery(ctrl.DrainRecoveryMetrics())
1310 ctrl.Close()
1311 }
1312 if runErr != nil {
1313 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, runErr)
1314 return 1
1315 }
1316 if launchWeb {
1317 // The Web runtime resumes a materialized TUI transcript or binds the exact
1318 // reserved identity for a never-used session. Release the TUI lease before
1319 // rebuilding the controller or the handoff would correctly reject its own
1320 // session as already in use. The deferred Release remains as a harmless
1321 // final guard for every other return path.
1322 leases.Release()
1323 return runWebCommand(webHandoffArgs(launchWebPath, launchWebSessionID, launchWebModelRef))
1324 }
1325 return 0
1326 }
1327
1328 // adoptCarriedHistoryPreservingProfileAndGrants resumes c on the carried
1329 // conversation the way buildController's callers expect: the freshly built
1330 // c already has its own leading system message for the target profile (see
1331 // boot/token_profile.go), but AdoptHistory below would otherwise replace the
1332 // whole history — including that message — with carry's outgoing one, so the
1333 // switch splices the new leading message in first. It also carries forward
1334 // oldCtrl's same-session "Allow for this session" tool grants and Plan-mode
1335 // read-only command trust, which a rebuild would otherwise silently drop,
1336 // forcing the user to re-approve things already granted this session.
1337 func adoptCarriedHistoryPreservingProfileAndGrants(c *control.Controller, carry []provider.Message, path string, oldCtrl control.SessionAPI) error {
1338 if fresh := c.History(); len(fresh) > 0 && fresh[0].Role == provider.RoleSystem {
1339 if len(carry) > 0 && carry[0].Role == provider.RoleSystem {
1340 carry[0] = fresh[0]
1341 } else {
1342 carry = append([]provider.Message{fresh[0]}, carry...)
1343 }
1344 }
1345 c.AdoptHistory(carry, path)
1346 if prev, ok := oldCtrl.(*control.Controller); ok {
1347 c.RestoreSessionAuthorizations(prev.SessionAuthorizations())
1348 }
1349 // Persist the adopted history now: the splice above only refreshed the new
1350 // controller's memory and nothing saves again until the next turn ends, so
1351 // quitting right after the switch and resuming would otherwise revive the
1352 // outgoing profile's contract from disk.
1353 if path != "" {
1354 if err := c.Snapshot(); err != nil {
1355 return fmt.Errorf("snapshot after runtime switch: %w", err)
1356 }
1357 }
1358 return nil
1359 }
1360
1361 func prepareNativeScrollback(w io.Writer, rows int) {
1362 // Clear the terminal's scrollback history so a reopened chat starts
1363 // with a clean slate (Termux stays in the normal buffer, so prior
1364 // output would otherwise remain visible above the banner).
1365 fmt.Fprint(w, "\x1B[3J\x1B[2J\x1B[H")
1366 reserveNativeScrollbackFrame(w, rows)
1367 }
1368
1369 func reserveNativeScrollbackFrame(w io.Writer, rows int) {
1370 for range rows {
1371 fmt.Fprintln(w)
1372 }
1373 }
1374
1375 // setupTargets is where the wizard writes: the TOML config and the credential
1376 // store. Keys always go to Reasonix's global .env so they
1377 // never land in a project's own .env; only the config location is project-local
1378 // under --local.
1379 type setupTargets struct {
1380 config string
1381 env string
1382 }
1383
1384 // defaultConfigTarget is the user-global config file, falling back to a
1385 // project-local reasonix.toml only when the user config dir can't be resolved.
1386 func defaultConfigTarget() string {
1387 if p := config.UserConfigPath(); p != "" {
1388 return p
1389 }
1390 return "reasonix.toml"
1391 }
1392
1393 // defaultEnvTarget is the display target for the reasonix-owned global
1394 // Reasonix global .env.
1395 func defaultEnvTarget() string {
1396 return config.CredentialsTargetDescription()
1397 }
1398
1399 // resolveSetupTargets picks where `reasonix setup` writes. Keys always go to the
1400 // global env. The config goes to the user-global dir by default, to ./reasonix.toml
1401 // under --local, or to an explicit path argument when given.
1402 func resolveSetupTargets(args []string) setupTargets {
1403 t := setupTargets{config: defaultConfigTarget(), env: defaultEnvTarget()}
1404 for _, a := range args {
1405 switch a {
1406 case "--local", "-l":
1407 t.config = "reasonix.toml"
1408 default:
1409 t.config = a
1410 }
1411 }
1412 return t
1413 }
1414
1415 // displayPath shortens a home-relative path to ~/… for readable wizard output.
1416 func displayPath(p string) string {
1417 if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(p, home) {
1418 return "~" + p[len(home):]
1419 }
1420 return p
1421 }
1422
1423 // setupConfig runs the configuration wizard (the `reasonix setup` command),
1424 // writing config.toml to the user-global dir (or ./reasonix.toml under --local)
1425 // and API keys to Reasonix's global .env — never a project's own .env.
1426 // Project memory is a separate concern — the in-session `/init` skill generates
1427 // AGENTS.md (see initHint).
1428 func setupConfig(args []string) int {
1429 t := resolveSetupTargets(args)
1430 path := t.config
1431 if _, err := os.Stat(path); err == nil {
1432 // Non-interactive must not clobber an existing config silently. On a TTY,
1433 // setup is a non-destructive configuration manager, so opening an existing
1434 // file no longer needs an overwrite confirmation.
1435 if !isInteractive() {
1436 fmt.Fprintf(os.Stderr, i18n.M.NotOverwritingFmt+"\n", path)
1437 return 1
1438 }
1439 }
1440
1441 // Interactive wizard on a TTY; fall back to the annotated default when piped.
1442 if isInteractive() {
1443 rc := interactiveSetup(t.config, t.env)
1444 if rc == 0 {
1445 fmt.Printf(i18n.M.TryHintFmt+"\n", bold("reasonix"))
1446 }
1447 return rc
1448 }
1449 return writeDefaultConfig(t.config)
1450 }
1451
1452 func confirmReconfigureExistingConfig(path string, in *bufio.Scanner, w io.Writer) bool {
1453 ans := ask(in, w, fmt.Sprintf(i18n.M.ConfirmReconfigureFmt, path), "y/N")
1454 return ans == "y" || ans == "Y"
1455 }
1456
1457 func writeDefaultConfig(path string) int {
1458 unlock, err := config.LockConfigFileEdits(path)
1459 if err != nil {
1460 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
1461 return 1
1462 }
1463 defer unlock()
1464 if _, err := os.Lstat(path); err == nil {
1465 fmt.Fprintf(os.Stderr, i18n.M.NotOverwritingFmt+"\n", path)
1466 return 1
1467 } else if !os.IsNotExist(err) {
1468 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
1469 return 1
1470 }
1471 c := config.Default()
1472 if err := c.SaveTo(path); err != nil {
1473 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
1474 return 1
1475 }
1476 fmt.Printf(i18n.M.WroteFileFmt+"\n", displayPath(path))
1477 fmt.Println(i18n.M.NextHint)
1478 return 0
1479 }
1480
1481 // initHint handles `reasonix init`. Unlike a config scaffold, project memory is
1482 // model-generated by analyzing the codebase, so it lives as the in-session
1483 // `/init` skill rather than a CLI command. This entry just points the user there
1484 // (and to `reasonix setup` for config) so the verb isn't a dead end.
1485 func initHint() int {
1486 fmt.Println(i18n.M.InitHint)
1487 return 0
1488 }
1489
1490 // interactiveSetup opens the staged provider manager. Nothing is written until
1491 // the user explicitly chooses Save and exit; q/Ctrl-C leaves both config and
1492 // credentials untouched.
1493 func interactiveSetup(configPath, envPath string) int {
1494 // Seed from the existing config when reconfiguring, so a re-run to fix a key
1495 // preserves the user's providers / agent settings instead of resetting to
1496 // defaults. First run (no file) falls back to the built-in defaults.
1497 cfg, err := config.LoadForEditReadOnlyStrict(configPath)
1498 if err != nil {
1499 fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
1500 return 1
1501 }
1502 session := newProviderSetupSessionForPath(cfg, configPath)
1503 lang, err := selectLanguage()
1504 if err != nil {
1505 fmt.Fprintln(os.Stderr, "\nsetup cancelled.")
1506 return 1
1507 }
1508 session.setLanguage(lang)
1509 session.applyDeepSeekOfficialDefaultPricing()
1510 session.resetProviderSummaryBaseline()
1511 i18n.DetectLanguage(lang)
1512
1513 // Now that the catalogue matches the user's choice, show the welcome banner
1514 // in their language before any substantive prompt.
1515 fmt.Println()
1516 fmt.Print(boxed([]string{
1517 accent("◆") + " " + fmt.Sprintf(i18n.M.WelcomeTitleFmt, bold("reasonix")),
1518 "",
1519 dim(i18n.M.NoConfigYet),
1520 }))
1521 fmt.Println()
1522
1523 return runProviderSetupManager(session, configPath, envPath)
1524 }
1525
1526 // pickSessionToResume scans the workspace's conversations — legacy transcripts
1527 // and final-format catalog rows alike — takes the 10 most recent, and shows a
1528 // single-choice menu with timestamp + turn count + first user message so the
1529 // user can pick one. Returns the chosen target and a process exit code
1530 // (non-zero when there's nothing to pick or the user cancelled).
1531 func pickSessionToResume() (cliResumeTarget, int) {
1532 sessionDir := resolveCLISessionDir()
1533 reclaimCLIRecoveryBranches(sessionDir)
1534 entries := mergedResumeEntries(sessionDir, resumeListCap)
1535 if len(entries) == 0 {
1536 fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
1537 return cliResumeTarget{}, 1
1538 }
1539 if !isInteractive() {
1540 fmt.Fprintln(os.Stderr, i18n.M.ResumeRequiresTTY)
1541 return cliResumeTarget{}, 1
1542 }
1543 items := make([]menuItem, len(entries))
1544 for i, s := range entries {
1545 when := s.session.ModTime.Local().Format("01-02 15:04")
1546 items[i] = menuItem{
1547 name: when,
1548 desc: sessionSummary(s.session),
1549 }
1550 }
1551 idx, err := selectOne(i18n.M.PickSessionLabel, items)
1552 if err != nil {
1553 return cliResumeTarget{}, 1
1554 }
1555 return entries[idx].target, 0
1556 }
1557
1558 // selectLanguage is the wizard's first prompt: it shows the two UI languages
1559 // in their native form and pre-selects the env-detected one (so a single Enter
1560 // confirms the auto-detection, a single arrow + Enter picks the other). The
1561 // label is bilingual because we don't yet know which catalogue to trust.
1562 func selectLanguage() (string, error) {
1563 detected := i18n.DetectLanguage("")
1564 items := []menuItem{{name: "English"}, {name: "中文 (简体)"}}
1565 tags := []string{"en", "zh"}
1566 if detected == "zh" {
1567 items[0], items[1] = items[1], items[0]
1568 tags[0], tags[1] = tags[1], tags[0]
1569 }
1570 idx, err := selectOne("Language · 语言", items)
1571 if err != nil {
1572 return "", err
1573 }
1574 return tags[idx], nil
1575 }
1576
1577 // familyStaticModels unions the preset model lists of every entry in the family,
1578 // preserving order and dropping duplicates. It is the fallback offered when the
1579 // live /models probe fails, so a family with separate flash/pro preset entries
1580 // still surfaces both rather than only the first member's model.
1581 func familyStaticModels(providers []config.ProviderEntry, idxs []int) []string {
1582 var out []string
1583 seen := map[string]bool{}
1584 for _, i := range idxs {
1585 for _, m := range providers[i].ModelList() {
1586 if m != "" && !seen[m] {
1587 seen[m] = true
1588 out = append(out, m)
1589 }
1590 }
1591 }
1592 return out
1593 }
1594
1595 // fetchOrFallback tries the OpenAI-compatible GET /models endpoint
1596 // (honoring the entry's ModelsURL when set) and returns the live model IDs.
1597 // On any failure — no base URL, no key set yet (the key is collected in a
1598 // later wizard step), network/auth error, or a vendor without /models — it
1599 // silently returns the preset's static model list so the wizard can always
1600 // present something. The fetch has a 10s timeout and is best-effort.
1601 func fetchOrFallback(probe *config.ProviderEntry, famName string, proxy netclient.ProxySpec) []string {
1602 static := probe.ModelList()
1603 if probe.BaseURL == "" {
1604 return static
1605 }
1606 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1607 defer cancel()
1608 models, err := probe.FetchModelsWithProxy(ctx, proxy)
1609 if err != nil || len(models) == 0 {
1610 if len(static) > 0 {
1611 fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.FetchModelsUsingPresetsFmt, famName)))
1612 }
1613 return static
1614 }
1615 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), famName)))
1616 return models
1617 }
1618
1619 // fetchModelListCompat walks the full set of model-list URL candidates a given
1620 // base URL can resolve to (root, /v1, known OpenAI/Anthropic compat suffixes)
1621 // and returns the first successful fetch. This is the wizard-time probe for a
1622 // *user-supplied* custom provider — its baseURL is whatever the user pasted,
1623 // and "whatever they pasted" might be https://x.com (root, probe /v1/models)
1624 // or https://x.com/v1 (versioned, probe /v1/models directly). Previously the
1625 // wizard hardcoded `baseURL + "/models"`, which works for OpenAI-shape URLs
1626 // but silently fails for Anthropic-shape roots and the reverse — so the
1627 // wizard's idea of "what models exist" diverged from the chat client's actual
1628 // endpoint. Returning the empty slice (not an error) on full miss lets the
1629 // wizard fall through to a manual text input without an error message.
1630 func fetchModelListCompat(ctx context.Context, baseURL, apiKey string, proxy netclient.ProxySpec) ([]string, error) {
1631 candidates, err := config.BuildModelFetchURLs(baseURL, "")
1632 if err != nil {
1633 return nil, err
1634 }
1635 var lastErr error
1636 var firstHardErr error
1637 for _, u := range candidates {
1638 models, err := openai.FetchModelsWithOptions(ctx, u, apiKey, openai.FetchModelsOptions{Proxy: proxy})
1639 if err == nil {
1640 return models, nil
1641 }
1642 lastErr = err
1643 if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil {
1644 firstHardErr = err
1645 }
1646 }
1647 if firstHardErr != nil {
1648 return nil, firstHardErr
1649 }
1650 if lastErr != nil {
1651 slog.Debug("model-list probe: all candidates missed", "base_url", baseURL, "err", lastErr)
1652 }
1653 return nil, nil
1654 }
1655
1656 // buildFamilyEntry returns a single ProviderEntry exposing the user's
1657 // selected models under one entry. It preserves the preset's API key env,
1658 // base URL, kind, context window, pricing, and effort — the things that
1659 // vary per vendor but not per model. The Default pointer is reset to the
1660 // first selected model if it would otherwise reference a model the user
1661 // didn't pick (or was empty).
1662 // buildFamilyEntries splits the user's selection back across the family's preset
1663 // members so each model keeps its own entry — and therefore its own pricing,
1664 // context window, and balance URL. A family like DeepSeek ships flash and pro as
1665 // separate presets with different prices; collapsing them into one entry would
1666 // bill pro at flash's rate. Models the live /models list returned that match no
1667 // preset (a new SKU) fall under the probe entry. Member order is preserved;
1668 // within a member, selection order is preserved.
1669 func buildFamilyEntries(probe config.ProviderEntry, members []config.ProviderEntry, selected []string) []config.ProviderEntry {
1670 tmpl := map[string]config.ProviderEntry{probe.Name: probe}
1671 ownerName := map[string]string{}
1672 for _, m := range members {
1673 tmpl[m.Name] = m
1674 for _, id := range m.ModelList() {
1675 ownerName[id] = m.Name
1676 }
1677 }
1678 var order []string
1679 groups := map[string][]string{}
1680 for _, sm := range selected {
1681 name, ok := ownerName[sm]
1682 if !ok {
1683 name = probe.Name
1684 }
1685 if _, seen := groups[name]; !seen {
1686 order = append(order, name)
1687 }
1688 groups[name] = append(groups[name], sm)
1689 }
1690 out := make([]config.ProviderEntry, 0, len(order))
1691 for _, name := range order {
1692 out = append(out, buildFamilyEntry(tmpl[name], groups[name]))
1693 }
1694 return out
1695 }
1696
1697 func buildFamilyEntry(probe config.ProviderEntry, selected []string) config.ProviderEntry {
1698 entry := probe
1699 entry.Models = selected
1700 entry.Model = selected[0]
1701 if entry.Default == "" || !containsString(selected, entry.Default) {
1702 entry.Default = selected[0]
1703 }
1704 return entry
1705 }
1706
1707 func containsString(xs []string, v string) bool {
1708 return slices.Contains(xs, v)
1709 }
1710
1711 // filterStaleCustomEntries drops the wizard's own magic-name entries
1712 // (Name="custom" with Kind="openai" or Name="anthropic" with Kind="anthropic")
1713 // that older versions of the wizard wrote into reasonix.toml. They collide
1714 // with the wizard's "custom" / "anthropic" menu items on re-run, showing up
1715 // as duplicate broken entries. The new wizard writes host-derived slugs
1716 // (e.g. "custom-token-sensenova-cn") so a hit on the magic name is
1717 // unambiguously stale. The returned slice is the dropped set so the caller
1718 // can warn the user to clean up reasonix.toml by hand.
1719 func filterStaleCustomEntries(providers []config.ProviderEntry) (kept, dropped []config.ProviderEntry) {
1720 for _, p := range providers {
1721 if p.Name == "custom" && p.Kind == "openai" {
1722 dropped = append(dropped, p)
1723 continue
1724 }
1725 if p.Name == "anthropic" && p.Kind == "anthropic" {
1726 dropped = append(dropped, p)
1727 continue
1728 }
1729 kept = append(kept, p)
1730 }
1731 return
1732 }
1733
1734 // providerSlug derives a stable, human-readable entry name for a custom
1735 // OpenAI / Anthropic-compatible provider from its base URL, e.g.
1736 // "custom-token-sensenova-cn" or "anthropic-api-anthropic-com". We can't
1737 // reuse the wizard's menu-item labels ("custom" / "anthropic") because
1738 // those would collide with the menu item itself and end up rendered as
1739 // duplicate provider entries on subsequent re-runs of `reasonix setup`.
1740 // The host-based slug also gives users a meaningful name to grep for in
1741 // reasonix.toml. Falls back to a short sha1 of the raw URL when the URL
1742 // doesn't parse, so even malformed input still produces a unique name.
1743 func providerSlug(kind, baseURL string) string {
1744 var host string
1745 if u, err := url.Parse(baseURL); err == nil {
1746 host = u.Host
1747 }
1748 if host == "" {
1749 sum := sha1.Sum([]byte(baseURL))
1750 return kind + "-" + hex.EncodeToString(sum[:4])
1751 }
1752 host = strings.ToLower(strings.TrimPrefix(host, "www."))
1753 var b strings.Builder
1754 prevDash := false
1755 for _, r := range host {
1756 switch {
1757 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
1758 b.WriteRune(r)
1759 prevDash = false
1760 default:
1761 if !prevDash && b.Len() > 0 {
1762 b.WriteRune('-')
1763 prevDash = true
1764 }
1765 }
1766 }
1767 slug := strings.TrimRight(b.String(), "-")
1768 if slug == "" {
1769 sum := sha1.Sum([]byte(baseURL))
1770 return kind + "-" + hex.EncodeToString(sum[:4])
1771 }
1772 return kind + "-" + slug
1773 }
1774
1775 func apiKeyEnvFromProviderName(name string) string {
1776 stem := strings.ToUpper(strings.TrimSpace(name))
1777 stem = strings.Map(func(r rune) rune {
1778 switch {
1779 case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
1780 return r
1781 default:
1782 return '_'
1783 }
1784 }, stem)
1785 stem = strings.Trim(stem, "_")
1786 if stem == "" {
1787 return "CUSTOM_" + fnv1a32Hex(name) + "_API_KEY"
1788 }
1789 if stem[0] >= '0' && stem[0] <= '9' {
1790 stem = "CUSTOM_" + stem
1791 }
1792 return stem + "_API_KEY"
1793 }
1794
1795 type providerKeyEnvRepair struct {
1796 provider string
1797 old string
1798 new string
1799 }
1800
1801 func repairInvalidProviderKeyEnvs(providers []config.ProviderEntry) ([]config.ProviderEntry, []providerKeyEnvRepair) {
1802 providers = append([]config.ProviderEntry(nil), providers...)
1803 var repairs []providerKeyEnvRepair
1804 for i := range providers {
1805 old := strings.TrimSpace(providers[i].APIKeyEnv)
1806 if old == "" || config.IsValidCredentialKey(old) {
1807 continue
1808 }
1809 keyEnv := apiKeyEnvFromProviderName(providers[i].Name)
1810 providers[i].APIKeyEnv = keyEnv
1811 repairs = append(repairs, providerKeyEnvRepair{provider: providers[i].Name, old: old, new: keyEnv})
1812 }
1813 return providers, repairs
1814 }
1815
1816 func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
1817 for {
1818 keyEnv := ask(in, w, label, def)
1819 if config.IsValidCredentialKey(keyEnv) {
1820 return keyEnv
1821 }
1822 fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", keyEnv)
1823 }
1824 }
1825
1826 func fnv1a32Hex(s string) string {
1827 hash := uint32(0x811c9dc5)
1828 for _, unit := range utf16.Encode([]rune(strings.TrimSpace(s))) {
1829 hash ^= uint32(unit)
1830 hash *= 0x01000193
1831 }
1832 return fmt.Sprintf("%08x", hash)
1833 }
1834
1835 // providerFamily is a wizard-only grouping of provider SKUs by vendor; it does
1836 // not exist in config because users editing reasonix.toml deal with SKU names
1837 // directly.
1838 type providerFamily struct {
1839 key string
1840 name string
1841 desc string
1842 }
1843
1844 func familyOf(name string) providerFamily {
1845 switch {
1846 case strings.HasPrefix(name, "deepseek"):
1847 return providerFamily{key: "deepseek", name: "DeepSeek", desc: "fast & cheap, plus a stronger Pro SKU"}
1848 default:
1849 return providerFamily{key: name, name: name}
1850 }
1851 }
1852
1853 type providerPromptResult struct {
1854 entries []config.ProviderEntry
1855 credentials map[string]string
1856 }
1857
1858 func newProviderPromptResult(entries []config.ProviderEntry, key, value string) providerPromptResult {
1859 result := providerPromptResult{entries: entries}
1860 if key != "" && value != "" {
1861 result.credentials = map[string]string{key: value}
1862 }
1863 return result
1864 }
1865
1866 // promptCustomProvider handles the custom provider entry flow.
1867 func promptCustomProvider(proxy netclient.ProxySpec) (providerPromptResult, error) {
1868 methodIdx, err := selectOne(i18n.M.CustomAddMethodLabel, []menuItem{
1869 {name: i18n.M.CustomMethodManual},
1870 {name: i18n.M.CustomMethodURL},
1871 })
1872 if err != nil {
1873 return providerPromptResult{}, err
1874 }
1875 if methodIdx == 0 {
1876 return promptCustomProviderManual()
1877 }
1878 return promptCustomProviderFromURL(proxy)
1879 }
1880
1881 // promptCustomProviderManual handles manual model entry.
1882 func promptCustomProviderManual() (providerPromptResult, error) {
1883 return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
1884 }
1885
1886 // promptCustomProviderManualWith is the shared backend for manual entry.
1887 // Pre-filled values (baseURL, keyEnv, apiKey) are reused as-is when non-empty
1888 // so the URL-fetch flow can fall through to manual entry without re-asking
1889 // the user for information they've already typed. An empty apiKey is allowed
1890 // — the key step happens later in the wizard and Reasonix's global .env is updated then.
1891 func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
1892 fmt.Println()
1893 if baseURL == "" {
1894 baseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "")
1895 if baseURL == "" {
1896 return providerPromptResult{}, fmt.Errorf("base URL is required")
1897 }
1898 }
1899 providerName := providerSlug("custom", baseURL)
1900 modelName := ask(in, os.Stdout, i18n.M.CustomPromptModel, "")
1901 if modelName == "" {
1902 return providerPromptResult{}, fmt.Errorf("model name is required")
1903 }
1904 if keyEnv == "" {
1905 keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
1906 } else if !config.IsValidCredentialKey(keyEnv) {
1907 return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
1908 }
1909 if apiKey == "" {
1910 apiKey = ask(in, os.Stdout, i18n.M.CustomPromptAPIKey, "")
1911 }
1912 entry := config.ProviderEntry{
1913 Name: providerName, Kind: "openai", BaseURL: baseURL,
1914 Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
1915 }
1916 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+modelName)))
1917 return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
1918 }
1919
1920 // promptCustomProviderFromURL tries the OpenAI-compatible GET /models
1921 // endpoint and shows a checkbox of the returned models. If the call fails
1922 // (network error, auth failure, or a vendor without /models) it falls
1923 // through to manual entry, reusing the URL and key the user already typed.
1924 func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResult, error) {
1925 in := bufio.NewScanner(os.Stdin)
1926 fmt.Println()
1927
1928 baseURL := ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "")
1929 if baseURL == "" {
1930 return providerPromptResult{}, fmt.Errorf("base URL is required")
1931 }
1932 providerName := providerSlug("custom", baseURL)
1933 keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
1934 apiKey := ask(in, os.Stdout, i18n.M.CustomPromptAPIKey, "")
1935
1936 fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom")))
1937 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1938 defer cancel()
1939 models, err := fetchModelListCompat(ctx, baseURL, apiKey, proxy)
1940 if err != nil || len(models) == 0 {
1941 if err != nil {
1942 fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.FetchModelsFailedFmt, "custom", err)))
1943 } else {
1944 fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.CustomFetchEmpty))
1945 }
1946 return promptCustomProviderManualWith(in, baseURL, keyEnv, apiKey)
1947 }
1948 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), "custom")))
1949
1950 items := make([]menuItem, len(models))
1951 for i, m := range models {
1952 items[i] = menuItem{name: m}
1953 }
1954 idxs, err := selectMany(fmt.Sprintf(i18n.M.SelectModelsLabel, "custom"), items)
1955 if err != nil || len(idxs) == 0 {
1956 return providerPromptResult{}, fmt.Errorf("no models selected")
1957 }
1958 var selected []string
1959 for _, i := range idxs {
1960 selected = append(selected, models[i])
1961 }
1962 entry := config.ProviderEntry{
1963 Name: providerName, Kind: "openai", BaseURL: baseURL,
1964 Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
1965 }
1966 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+selected[0])))
1967 return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
1968 }
1969
1970 // promptAnthropicProvider handles the Anthropic compatible provider entry flow.
1971 func promptAnthropicProvider(proxy netclient.ProxySpec) (providerPromptResult, error) {
1972 methodIdx, err := selectOne(i18n.M.AnthropicAddMethodLabel, []menuItem{
1973 {name: i18n.M.AnthropicMethodManual},
1974 {name: i18n.M.AnthropicMethodURL},
1975 })
1976 if err != nil {
1977 return providerPromptResult{}, err
1978 }
1979 if methodIdx == 0 {
1980 return promptAnthropicProviderManual()
1981 }
1982 return promptAnthropicProviderFromURL(proxy)
1983 }
1984
1985 // promptAnthropicProviderManual handles manual model entry.
1986 func promptAnthropicProviderManual() (providerPromptResult, error) {
1987 return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
1988 }
1989
1990 // promptAnthropicProviderManualWith is the shared backend for manual entry
1991 // of an Anthropic-compatible custom provider. Pre-filled values (baseURL,
1992 // keyEnv, apiKey) are reused as-is when non-empty so the URL-fetch flow
1993 // can fall through to manual entry without re-asking the user.
1994 func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
1995 fmt.Println()
1996 if baseURL == "" {
1997 baseURL = ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "")
1998 if baseURL == "" {
1999 return providerPromptResult{}, fmt.Errorf("base URL is required")
2000 }
2001 }
2002 modelName := ask(in, os.Stdout, i18n.M.AnthropicPromptModel, "")
2003 if modelName == "" {
2004 return providerPromptResult{}, fmt.Errorf("model name is required")
2005 }
2006 if keyEnv == "" {
2007 keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
2008 } else if !config.IsValidCredentialKey(keyEnv) {
2009 return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
2010 }
2011 if apiKey == "" {
2012 apiKey = ask(in, os.Stdout, i18n.M.AnthropicPromptAPIKey, "")
2013 }
2014 entry := config.ProviderEntry{
2015 Name: providerSlug("anthropic", baseURL), Kind: "anthropic", BaseURL: baseURL,
2016 Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
2017 }
2018 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+modelName)))
2019 return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
2020 }
2021
2022 // promptAnthropicProviderFromURL tries the OpenAI-compatible GET /models
2023 // endpoint (some Anthropic-compatible proxies do expose one). Most don't
2024 // — Anthropic's own API has no public model list — so on any failure the
2025 // flow falls through to manual entry with the URL/key already filled in,
2026 // rather than aborting the wizard.
2027 func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptResult, error) {
2028 in := bufio.NewScanner(os.Stdin)
2029 fmt.Println()
2030
2031 baseURL := ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "")
2032 if baseURL == "" {
2033 return providerPromptResult{}, fmt.Errorf("base URL is required")
2034 }
2035 keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
2036 apiKey := ask(in, os.Stdout, i18n.M.AnthropicPromptAPIKey, "")
2037
2038 fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic")))
2039 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
2040 defer cancel()
2041 models, err := fetchModelListCompat(ctx, baseURL, apiKey, proxy)
2042 if err != nil || len(models) == 0 {
2043 if err != nil {
2044 fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchModelsFailedFmt, "anthropic", err)))
2045 } else {
2046 fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.AnthropicFetchEmpty))
2047 }
2048 return promptAnthropicProviderManualWith(in, baseURL, keyEnv, apiKey)
2049 }
2050 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicFetchModelsSuccessFmt, len(models), "anthropic")))
2051
2052 items := make([]menuItem, len(models))
2053 for i, m := range models {
2054 items[i] = menuItem{name: m}
2055 }
2056 idxs, err := selectMany(fmt.Sprintf(i18n.M.AnthropicSelectModelsLabel, "anthropic"), items)
2057 if err != nil || len(idxs) == 0 {
2058 return providerPromptResult{}, fmt.Errorf("no models selected")
2059 }
2060 var selected []string
2061 for _, i := range idxs {
2062 selected = append(selected, models[i])
2063 }
2064 entry := config.ProviderEntry{
2065 Name: providerSlug("anthropic", baseURL), Kind: "anthropic", BaseURL: baseURL,
2066 Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
2067 }
2068 fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+selected[0])))
2069 return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
2070 }
2071
2072 func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) {
2073 var order []string
2074 members := map[string][]int{}
2075 info := map[string]providerFamily{}
2076 for i, p := range providers {
2077 f := familyOf(p.Name)
2078 if _, seen := members[f.key]; !seen {
2079 order = append(order, f.key)
2080 info[f.key] = f
2081 }
2082 members[f.key] = append(members[f.key], i)
2083 }
2084 return order, members, info
2085 }
2086
2087 // withBuiltinFamilies guarantees the wizard always offers the built-in DeepSeek
2088 // family even when the loaded config replaced the defaults.
2089 // Built-in entries whose exact name already exists in the user's config are
2090 // kept as-is (preserving customizations); missing built-in entries within an
2091 // existing family are appended so the model picker always shows the full
2092 // catalogue rather than only the previously selected subset.
2093 func withBuiltinFamilies(providers []config.ProviderEntry) []config.ProviderEntry {
2094 return withBuiltinFamiliesForLanguage(providers, "")
2095 }
2096
2097 func withBuiltinFamiliesForLanguage(providers []config.ProviderEntry, pricingLanguage string) []config.ProviderEntry {
2098 haveName := map[string]bool{}
2099 for _, p := range providers {
2100 haveName[p.Name] = true
2101 }
2102 defaults := config.Default()
2103 defaults.Language = pricingLanguage
2104 defaults.ApplyDeepSeekOfficialDefaultPricing()
2105 for _, bp := range defaults.Providers {
2106 if !haveName[bp.Name] {
2107 providers = append(providers, bp)
2108 }
2109 }
2110 return providers
2111 }
2112
2113 // providersWithMissingKeys returns the providers the active configuration
2114 // actually references (default/planner/subagent models) whose api_key_env is
2115 // declared but not set. Merely-available providers stay silent; the chat banner
2116 // still warns if users later switch to a model whose key is missing.
2117 // configureKeys dedupes shared envs, so duplicates are fine to leave in.
2118 func providersWithMissingKeys(cfg *config.Config) []config.ProviderEntry {
2119 if cfg == nil {
2120 return nil
2121 }
2122 refs := []string{
2123 cfg.DefaultModel,
2124 cfg.Agent.PlannerModel,
2125 cfg.Agent.SubagentModel,
2126 }
2127 if len(cfg.Agent.SubagentModels) > 0 {
2128 keys := make([]string, 0, len(cfg.Agent.SubagentModels))
2129 for key := range cfg.Agent.SubagentModels {
2130 keys = append(keys, key)
2131 }
2132 sort.Strings(keys)
2133 for _, key := range keys {
2134 refs = append(refs, cfg.Agent.SubagentModels[key])
2135 }
2136 }
2137
2138 var out []config.ProviderEntry
2139 seen := map[string]bool{}
2140 for _, ref := range refs {
2141 ref = strings.TrimSpace(ref)
2142 if ref == "" {
2143 continue
2144 }
2145 p, ok := cfg.ResolveModel(ref)
2146 if !ok || p.APIKeyEnv == "" || os.Getenv(p.APIKeyEnv) != "" || seen[p.APIKeyEnv] {
2147 continue
2148 }
2149 seen[p.APIKeyEnv] = true
2150 out = append(out, *p)
2151 }
2152 return out
2153 }
2154
2155 // configureKeys reconciles each enabled provider's API key with the
2156 // environment. For every distinct api_key_env: if the variable is already set,
2157 // setup asks whether to re-enter it; Enter keeps and re-pins the existing value.
2158 // Otherwise the user is asked once per env var (deduped across providers that
2159 // share one, e.g. both DeepSeek models). Returns KEY=value lines for the
2160 // Reasonix global .env. Re-pinning keeps hand-edited or previously saved values
2161 // aligned with the user's latest setup choice.
2162 func configureKeys(selected []config.ProviderEntry, r io.Reader, w io.Writer) []string {
2163 in := bufio.NewScanner(r)
2164 fmt.Fprintln(w, "\n"+i18n.M.EnterAPIKeysHeader)
2165
2166 seen := map[string]bool{}
2167 var envLines []string
2168 for _, p := range selected {
2169 if p.APIKeyEnv == "" || seen[p.APIKeyEnv] {
2170 continue
2171 }
2172 seen[p.APIKeyEnv] = true
2173
2174 if cur := os.Getenv(p.APIKeyEnv); cur != "" {
2175 reset := ask(in, w, " "+fmt.Sprintf(i18n.M.APIKeyResetPromptFmt, p.APIKeyEnv), "y/N")
2176 if reset == "y" || reset == "Y" {
2177 if key := ask(in, w, " "+p.APIKeyEnv, ""); key != "" {
2178 envLines = append(envLines, p.APIKeyEnv+"="+key)
2179 continue
2180 }
2181 }
2182 fmt.Fprintf(w, " %s %s\n", green("✓"), fmt.Sprintf(i18n.M.APIKeyAlreadySetFmt, p.APIKeyEnv))
2183 envLines = append(envLines, p.APIKeyEnv+"="+cur)
2184 continue
2185 }
2186
2187 if key := ask(in, w, " "+p.APIKeyEnv, ""); key != "" {
2188 envLines = append(envLines, p.APIKeyEnv+"="+key)
2189 }
2190 }
2191 return envLines
2192 }
2193
2194 // ask prints a prompt to w and returns the entered line, or def if input is empty.
2195 func ask(in *bufio.Scanner, w io.Writer, label, def string) string {
2196 if def != "" {
2197 fmt.Fprintf(w, "%s [%s]: ", label, def)
2198 } else {
2199 fmt.Fprintf(w, "%s: ", label)
2200 }
2201 if !in.Scan() {
2202 return def
2203 }
2204 if v := strings.TrimSpace(in.Text()); v != "" {
2205 return v
2206 }
2207 return def
2208 }
2209
2210 // isInteractive reports whether we're attached to a real terminal on both
2211 // stdin and stdout — required for prompting. Redirected or piped I/O is not
2212 // interactive, so wizards never block or auto-default in scripts and CI.
2213 func isInteractive() bool {
2214 return isTTY(os.Stdin) && isTTY(os.Stdout)
2215 }
2216
2217 func isTTY(f *os.File) bool {
2218 return term.IsTerminal(int(f.Fd()))
2219 }
2220
2221 // appendEnv merges KEY=value lines into a .env file. Existing assignments of
2222 // any key that's about to be written are dropped first, then the new values
2223 // are appended — so re-running `reasonix setup` with a corrected key replaces the
2224 // stale one instead of stacking duplicates. The new values are also
2225 // pinned into the current process env so a chat session started right after
2226 // init picks up the fresh keys without a restart.
2227 func appendEnv(path string, lines []string) error {
2228 target := map[string]bool{}
2229 for _, l := range lines {
2230 if k, _, ok := strings.Cut(l, "="); ok {
2231 target[strings.TrimSpace(k)] = true
2232 }
2233 }
2234
2235 var kept []string
2236 if data, err := fileencoding.ReadFileUTF8(path); err == nil {
2237 for raw := range strings.SplitSeq(string(data), "\n") {
2238 trimmed := strings.TrimSpace(raw)
2239 check := strings.TrimPrefix(trimmed, "export ")
2240 if k, _, ok := strings.Cut(check, "="); ok && target[strings.TrimSpace(k)] {
2241 continue
2242 }
2243 kept = append(kept, raw)
2244 }
2245 // strings.Split on a string ending with \n leaves a trailing empty
2246 // element; trim it so we don't grow a blank line on every rewrite.
2247 if n := len(kept); n > 0 && kept[n-1] == "" {
2248 kept = kept[:n-1]
2249 }
2250 } else if !os.IsNotExist(err) {
2251 return err
2252 }
2253
2254 var b strings.Builder
2255 for _, l := range kept {
2256 b.WriteString(l)
2257 b.WriteByte('\n')
2258 }
2259 for _, l := range lines {
2260 b.WriteString(l)
2261 b.WriteByte('\n')
2262 if k, v, ok := strings.Cut(l, "="); ok {
2263 os.Setenv(strings.TrimSpace(k), v)
2264 }
2265 }
2266 if dir := filepath.Dir(path); dir != "" && dir != "." {
2267 if err := os.MkdirAll(dir, 0o755); err != nil {
2268 return err
2269 }
2270 }
2271 return os.WriteFile(path, []byte(b.String()), 0o600)
2272 }
2273
2274 // readStdin reads piped input if present; an interactive terminal yields "".
2275 func readStdin() string {
2276 stat, err := os.Stdin.Stat()
2277 if err != nil || stat.Mode()&os.ModeCharDevice != 0 {
2278 return ""
2279 }
2280 data, _ := io.ReadAll(os.Stdin)
2281 return strings.TrimSpace(string(data))
2282 }
2283
2284 func usage() {
2285 fmt.Print(i18n.M.UsageBody)
2286 }
2287
2288 type ctrlKillerAdapter struct{ ctrl *control.Controller }
2289
2290 func (a ctrlKillerAdapter) Kill(sessionID, id string) bool {
2291 if sessionID != "" && agent.BranchID(a.ctrl.SessionPath()) != sessionID {
2292 return false
2293 }
2294 return a.ctrl.CancelJob(id)
2295 }
2296
2297 func configCommand(args []string) int {
2298 if len(args) == 0 {
2299 configUsage()
2300 return 2
2301 }
2302 switch args[0] {
2303 case "auto-plan":
2304 return configAutoPlanCompatibilityCommand(args[1:])
2305 case "reasoning-language":
2306 return configReasoningLanguageCommand(args[1:])
2307 case "compact-ratio":
2308 return configCompactRatioCommand(args[1:])
2309 case "currency":
2310 return configCurrencyCommand(args[1:])
2311 case "telemetry":
2312 return configTelemetryCommand(args[1:])
2313 default:
2314 configUsage()
2315 return 2
2316 }
2317 }
2318
2319 func configCurrencyCommand(args []string) int {
2320 fs := flag.NewFlagSet("config currency", flag.ContinueOnError)
2321 local := fs.Bool("local", false, "unsupported; pricing currency is user-level only")
2322 if code, ok := parseCommandFlags(fs, args); !ok {
2323 return code
2324 }
2325 if *local {
2326 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "currency is user-level only; --local is not supported")
2327 return 2
2328 }
2329 rest := fs.Args()
2330 if len(rest) > 1 {
2331 configCurrencyUsage()
2332 return 2
2333 }
2334 if len(rest) == 0 {
2335 cfg, err := config.LoadForRootReadOnly(".")
2336 if err != nil {
2337 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2338 return 1
2339 }
2340 fmt.Printf("currency = %q (display: %s)\n", pricingCurrencyDisplay(cfg.DisplayCurrencyPref()), cfg.ResolveDisplayCurrency())
2341 return 0
2342 }
2343 mode, err := parseCLIPricingCurrency(rest[0])
2344 if err != nil {
2345 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2346 return 2
2347 }
2348 path := config.UserConfigPath()
2349 if path == "" {
2350 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve user config path")
2351 return 1
2352 }
2353 unlock := config.LockUserConfigEdits()
2354 defer unlock()
2355 cfg := config.LoadForEdit(path)
2356 if err := cfg.SetDisplayCurrency(mode); err != nil {
2357 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2358 return 2
2359 }
2360 resolved := cfg.ResolveDisplayCurrency()
2361 if err := cfg.SaveTo(path); err != nil {
2362 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2363 return 1
2364 }
2365 fmt.Printf("currency = %q (display: %s, %s)\n", pricingCurrencyDisplay(mode), resolved, displayPath(path))
2366 return 0
2367 }
2368
2369 var (
2370 cleanupCLITelemetry = telemetry.Cleanup
2371 startCLITelemetryReporter = telemetry.Start
2372 persistCLITelemetryConsent = func(mode string) error {
2373 path := config.UserConfigPath()
2374 if strings.TrimSpace(path) == "" {
2375 return errors.New("cannot resolve config path")
2376 }
2377 unlock := config.LockUserConfigEdits()
2378 defer unlock()
2379 cfg, err := config.LoadForEditReadOnlyStrict(path)
2380 if err != nil {
2381 return err
2382 }
2383 if err := cfg.SetCLITelemetryMode(mode); err != nil {
2384 return err
2385 }
2386 return cfg.SaveTo(path)
2387 }
2388 )
2389
2390 func configTelemetryCommand(args []string) int {
2391 fs := flag.NewFlagSet("config telemetry", flag.ContinueOnError)
2392 if code, ok := parseCommandFlags(fs, args); !ok {
2393 return code
2394 }
2395 rest := fs.Args()
2396 if len(rest) > 1 {
2397 configTelemetryUsage()
2398 return 2
2399 }
2400 if len(rest) == 0 {
2401 cfg, err := config.Load()
2402 if err != nil {
2403 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2404 return 1
2405 }
2406 fmt.Printf("cli_metrics = %q\n", cfg.CLITelemetryMode())
2407 return 0
2408 }
2409 path := config.UserConfigPath()
2410 if path == "" {
2411 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path")
2412 return 1
2413 }
2414 unlock := config.LockUserConfigEdits()
2415 defer unlock()
2416 cfg := config.LoadForEdit(path)
2417 if err := cfg.SetCLITelemetryMode(rest[0]); err != nil {
2418 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2419 return 2
2420 }
2421 if err := cfg.SaveTo(path); err != nil {
2422 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2423 return 1
2424 }
2425 if cfg.CLITelemetryMode() == "off" {
2426 if err := cleanupCLITelemetry(config.ReasonixHomeDir()); err != nil {
2427 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "telemetry disabled, but pending metrics could not be deleted:", err)
2428 return 1
2429 }
2430 }
2431 fmt.Printf("cli_metrics = %q (%s)\n", cfg.CLITelemetryMode(), displayPath(path))
2432 return 0
2433 }
2434
2435 // configAutoPlanCompatibilityCommand preserves the released shell interface
2436 // without restoring Automatic Plan Mode. Reading and writing "off" are safe
2437 // no-ops; every attempt to enable the retired feature is rejected.
2438 func configAutoPlanCompatibilityCommand(args []string) int {
2439 fs := flag.NewFlagSet("config auto-plan", flag.ContinueOnError)
2440 local := fs.Bool("local", false, "unsupported; automatic plan mode is retired")
2441 if code, ok := parseCommandFlags(fs, args); !ok {
2442 return code
2443 }
2444 if *local {
2445 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "auto-plan is user-level only; --local is not supported")
2446 return 2
2447 }
2448 rest := fs.Args()
2449 if len(rest) > 1 {
2450 configAutoPlanCompatibilityUsage()
2451 return 2
2452 }
2453 if len(rest) == 0 {
2454 fmt.Println(`auto_plan = "off"`)
2455 return 0
2456 }
2457 cfg := config.Default()
2458 if err := cfg.SetAutoPlan(rest[0]); err != nil {
2459 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2460 return 2
2461 }
2462 fmt.Println(`auto_plan = "off"`)
2463 return 0
2464 }
2465
2466 func configReasoningLanguageCommand(args []string) int {
2467 fs := flag.NewFlagSet("config reasoning-language", flag.ContinueOnError)
2468 local := fs.Bool("local", false, "write ./reasonix.toml instead of the user config")
2469 if code, ok := parseCommandFlags(fs, args); !ok {
2470 return code
2471 }
2472 rest := fs.Args()
2473 if len(rest) > 1 {
2474 configReasoningLanguageUsage()
2475 return 2
2476 }
2477 if len(rest) == 0 {
2478 cfg, err := config.Load()
2479 if err != nil {
2480 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2481 return 1
2482 }
2483 fmt.Printf("reasoning_language = %q\n", cliReasoningLanguageMode(cfg.ReasoningLanguage()))
2484 return 0
2485 }
2486 mode, err := parseCLIReasoningLanguage(rest[0])
2487 if err != nil {
2488 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2489 return 2
2490 }
2491 path := config.UserConfigPath()
2492 if *local {
2493 path = "reasonix.toml"
2494 }
2495 if path == "" {
2496 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path")
2497 return 1
2498 }
2499 unlock, err := config.LockConfigFileEdits(path)
2500 if err != nil {
2501 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2502 return 1
2503 }
2504 defer unlock()
2505 if *local {
2506 if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
2507 lang, err := config.SaveMinimalProjectReasoningLanguage(path, mode)
2508 if err != nil {
2509 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2510 return 1
2511 }
2512 fmt.Printf("reasoning_language = %q (%s)\n", lang, displayPath(path))
2513 return 0
2514 } else if err != nil {
2515 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2516 return 1
2517 }
2518 }
2519 cfg, err := config.LoadForEditReadOnlyStrict(path)
2520 if err != nil {
2521 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2522 return 1
2523 }
2524 if err := cfg.SetReasoningLanguage(mode); err != nil {
2525 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2526 return 2
2527 }
2528 if err := cfg.SaveTo(path); err != nil {
2529 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2530 return 1
2531 }
2532 fmt.Printf("reasoning_language = %q (%s)\n", cfg.ReasoningLanguage(), displayPath(path))
2533 return 0
2534 }
2535
2536 func configCompactRatioCommand(args []string) int {
2537 fs := flag.NewFlagSet("config compact-ratio", flag.ContinueOnError)
2538 local := fs.Bool("local", false, "write ./reasonix.toml instead of the user config")
2539 if err := fs.Parse(args); err != nil {
2540 return 2
2541 }
2542 rest := fs.Args()
2543 if len(rest) > 1 {
2544 configCompactRatioUsage()
2545 return 2
2546 }
2547 if len(rest) == 0 {
2548 cfg, err := config.LoadForRootReadOnly(".")
2549 if err != nil {
2550 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2551 return 1
2552 }
2553 fmt.Printf("compact_ratio = %s (%s)\n", formatCompactRatioPercent(cfg.Agent.CompactRatio), compactRatioSource())
2554 return 0
2555 }
2556 percent, err := strconv.ParseFloat(strings.TrimSpace(rest[0]), 64)
2557 minPercent := config.CompactRatioMin * 100
2558 maxPercent := config.CompactRatioMax * 100
2559 if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < minPercent || percent > maxPercent {
2560 fmt.Fprintf(os.Stderr, "%s compact ratio must be a percentage between %.0f and %.0f\n", i18n.M.ErrorPrefix, minPercent, maxPercent)
2561 return 2
2562 }
2563 ratio := percent / 100
2564 path := config.UserConfigPath()
2565 scope := "user"
2566 if *local {
2567 path = "reasonix.toml"
2568 scope = "project"
2569 }
2570 if path == "" {
2571 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve config path")
2572 return 1
2573 }
2574 unlock, err := config.LockConfigFileEdits(path)
2575 if err != nil {
2576 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2577 return 1
2578 }
2579 defer unlock()
2580 if *local {
2581 if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
2582 saved, err := config.SaveMinimalProjectCompactRatio(path, ratio)
2583 if err != nil {
2584 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2585 return 1
2586 }
2587 fmt.Printf("compact_ratio = %s (%s: %s)\n", formatCompactRatioPercent(saved), scope, displayPath(path))
2588 return 0
2589 } else if err != nil {
2590 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2591 return 1
2592 }
2593 }
2594 cfg, err := config.LoadForEditReadOnlyStrict(path)
2595 if err != nil {
2596 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2597 return 1
2598 }
2599 if err := cfg.SetCompactRatio(ratio); err != nil {
2600 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2601 return 2
2602 }
2603 if err := cfg.SaveTo(path); err != nil {
2604 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
2605 return 1
2606 }
2607 fmt.Printf("compact_ratio = %s (%s: %s)\n", formatCompactRatioPercent(cfg.Agent.CompactRatio), scope, displayPath(path))
2608 return 0
2609 }
2610
2611 func compactRatioSource() string {
2612 if config.ConfigFileDefinesCompactRatio("reasonix.toml") {
2613 return "project: " + displayPath("reasonix.toml")
2614 }
2615 if path := config.UserConfigPath(); path != "" && config.ConfigFileDefinesCompactRatio(path) {
2616 return "user: " + displayPath(path)
2617 }
2618 return "built-in default"
2619 }
2620
2621 func formatCompactRatioPercent(ratio float64) string {
2622 value := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", ratio*100), "0"), ".")
2623 return value + "%"
2624 }
2625
2626 func configUsage() {
2627 fmt.Print(`Usage:
2628 reasonix config reasoning-language [--local] [auto|zh|en]
2629 reasonix config compact-ratio [--local] [30..85]
2630 reasonix config currency [auto|CNY|USD]
2631 reasonix config telemetry [auto|on|off]
2632 `)
2633 }
2634
2635 func configTelemetryUsage() {
2636 fmt.Print(`Usage:
2637 reasonix config telemetry [auto|on|off]
2638 `)
2639 }
2640
2641 func configCompactRatioUsage() {
2642 fmt.Print(`Usage:
2643 reasonix config compact-ratio [--local] [30..85]
2644 `)
2645 }
2646
2647 func startCLITelemetry(cfg *config.Config, opts telemetry.Options) *telemetry.Reporter {
2648 return startCLITelemetryWithIO(cfg, opts, os.Stdin, os.Stdout, os.Stderr)
2649 }
2650
2651 func startCLITelemetryWithIO(cfg *config.Config, opts telemetry.Options, in io.Reader, out, errOut io.Writer) *telemetry.Reporter {
2652 if cfg == nil {
2653 cfg = config.Default()
2654 }
2655 opts.Mode = cfg.CLITelemetryMode()
2656 opts.HomeDir = config.ReasonixHomeDir()
2657 opts.Proxy = cfg.NetworkProxySpec()
2658 opts.Language = cfg.Language
2659
2660 if cfg.CLITelemetryConfigured() || !telemetry.Enabled(opts.Mode, opts.Version, opts.Interactive) {
2661 return startCLITelemetryReporter(opts)
2662 }
2663
2664 fmt.Fprintln(out, i18n.M.CLITelemetryConsentNotice)
2665 scanner := bufio.NewScanner(in)
2666 mode := ""
2667 for mode == "" {
2668 answer := strings.ToLower(strings.TrimSpace(ask(scanner, out, i18n.M.CLITelemetryConsentPrompt, "Y/n")))
2669 switch answer {
2670 case "y", "yes", "y/n":
2671 mode = "auto"
2672 case "n", "no":
2673 mode = "off"
2674 default:
2675 fmt.Fprintln(out, i18n.M.CLITelemetryConsentInvalid)
2676 }
2677 }
2678
2679 if err := persistCLITelemetryConsent(mode); err != nil {
2680 fmt.Fprintf(errOut, i18n.M.CLITelemetryConsentSaveFailedFmt+"\n", err)
2681 return nil
2682 }
2683 cfg.Telemetry.CLIMetrics = mode
2684 opts.Mode = mode
2685 if mode == "off" {
2686 if err := cleanupCLITelemetry(opts.HomeDir); err != nil {
2687 fmt.Fprintf(errOut, i18n.M.CLITelemetryConsentCleanupFailedFmt+"\n", err)
2688 }
2689 return nil
2690 }
2691 return startCLITelemetryReporter(opts)
2692 }
2693
2694 func cliTelemetrySessionMode(cont, resume, copySession bool) string {
2695 switch {
2696 case copySession:
2697 return "copy"
2698 case resume:
2699 return "resume"
2700 case cont:
2701 return "continue"
2702 default:
2703 return "fresh"
2704 }
2705 }
2706
2707 func configAutoPlanCompatibilityUsage() {
2708 fmt.Print(`Usage:
2709 reasonix config auto-plan [off]
2710 `)
2711 }
2712
2713 func configReasoningLanguageUsage() {
2714 fmt.Print(`Usage:
2715 reasonix config reasoning-language [--local] [auto|zh|en]
2716 `)
2717 }
2718
2719 func configCurrencyUsage() {
2720 fmt.Print(`Usage:
2721 reasonix config currency [auto|CNY|USD]
2722 `)
2723 }
2724
2724 lines GO