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