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