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