From 3187196bd890de6d909a7ee4368aaf47a1ffc542 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 9 Mar 2026 20:01:05 +0700 Subject: [PATCH] refactor(onboard): simplify to DB + keys only, delegate setup to web UI Remove the 700+ line interactive wizard (provider, model, channels, TTS, features) since the web UI setup wizard already handles all of this. The onboard command now only: prompts for Postgres DSN, tests connection, generates gateway token + encryption key, runs migrations, seeds placeholder providers, and saves .env.local with the 3 required secrets. Delete 8 helper files: onboard_auto, onboard_verify, onboard_models, onboard_resolve, onboard_claude_cli, onboard_tts, onboard_feishu, onboard_zalo. --- cmd/onboard.go | 764 ++++++-------------------------------- cmd/onboard_auto.go | 323 ---------------- cmd/onboard_claude_cli.go | 18 - cmd/onboard_feishu.go | 44 --- cmd/onboard_helpers.go | 127 +------ cmd/onboard_managed.go | 293 +-------------- cmd/onboard_models.go | 123 ------ cmd/onboard_resolve.go | 98 ----- cmd/onboard_tts.go | 57 --- cmd/onboard_verify.go | 173 --------- cmd/onboard_zalo.go | 30 -- 11 files changed, 130 insertions(+), 1920 deletions(-) delete mode 100644 cmd/onboard_auto.go delete mode 100644 cmd/onboard_claude_cli.go delete mode 100644 cmd/onboard_feishu.go delete mode 100644 cmd/onboard_models.go delete mode 100644 cmd/onboard_resolve.go delete mode 100644 cmd/onboard_tts.go delete mode 100644 cmd/onboard_verify.go delete mode 100644 cmd/onboard_zalo.go diff --git a/cmd/onboard.go b/cmd/onboard.go index a13dc4d9..9c7c9dbb 100644 --- a/cmd/onboard.go +++ b/cmd/onboard.go @@ -1,595 +1,63 @@ package cmd import ( - "context" "fmt" "os" - "os/exec" "path/filepath" "strconv" "github.com/spf13/cobra" "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/providers" ) func onboardCmd() *cobra.Command { return &cobra.Command{ Use: "onboard", - Short: "Interactive setup wizard — configure provider, model, gateway, channels", + Short: "Quick setup — configure database, generate keys, run migrations", Run: func(cmd *cobra.Command, args []string) { runOnboard() }, } } -type providerInfo struct { - name string - envKey string - modelHint string -} - -var providerMap = map[string]providerInfo{ - "openrouter": {"openrouter", "GOCLAW_OPENROUTER_API_KEY", "anthropic/claude-sonnet-4-5-20250929"}, - "anthropic": {"anthropic", "GOCLAW_ANTHROPIC_API_KEY", "claude-sonnet-4-5-20250929"}, - "openai": {"openai", "GOCLAW_OPENAI_API_KEY", "gpt-4o"}, - "groq": {"groq", "GOCLAW_GROQ_API_KEY", "llama-3.3-70b-versatile"}, - "deepseek": {"deepseek", "GOCLAW_DEEPSEEK_API_KEY", "deepseek-chat"}, - "gemini": {"gemini", "GOCLAW_GEMINI_API_KEY", "gemini-2.0-flash"}, - "mistral": {"mistral", "GOCLAW_MISTRAL_API_KEY", "mistral-large-latest"}, - "xai": {"xai", "GOCLAW_XAI_API_KEY", "grok-3-mini"}, - "minimax": {"minimax", "GOCLAW_MINIMAX_API_KEY", "MiniMax-M2.5"}, - "cohere": {"cohere", "GOCLAW_COHERE_API_KEY", "command-a"}, - "perplexity": {"perplexity", "GOCLAW_PERPLEXITY_API_KEY", "sonar-pro"}, - "claude_cli": {"claude-cli", "", "sonnet"}, - "custom": {"custom", "", ""}, -} - func runOnboard() { - // If env vars provide API keys, skip interactive wizard entirely. - if canAutoOnboard() { - cfgPath := resolveConfigPath() - fmt.Println("Environment variables detected. Running non-interactive setup...") - if runAutoOnboard(cfgPath) { - return - } - fmt.Println("Auto-onboard failed, falling through to interactive wizard...") - fmt.Println() - } - fmt.Println("╔══════════════════════════════════════════════╗") - fmt.Println("║ GoClaw — Setup Wizard ║") + fmt.Println("║ GoClaw — Quick Setup ║") fmt.Println("╚══════════════════════════════════════════════╝") fmt.Println() - // Determine config path cfgPath := resolveConfigPath() + cfg := config.Default() - // Check existing config - var cfg *config.Config - isNewConfig := true + // Load existing config if present (preserve gateway port, etc.) if _, err := os.Stat(cfgPath); err == nil { - fmt.Printf("Found existing config at %s\n", cfgPath) - useExisting, err := promptConfirm("Use existing config as base?", true) - if err != nil { - fmt.Println("Cancelled.") - return - } - if useExisting { - loaded, err := config.Load(cfgPath) - if err != nil { - fmt.Printf("Warning: could not load existing config: %v\n", err) - cfg = config.Default() - } else { - cfg = loaded - isNewConfig = false - } - } else { - cfg = config.Default() - } - } else { - cfg = config.Default() - } - - // --- Declare all wizard variables (pre-filled from existing config) --- - var ( - providerChoice = cfg.Agents.Defaults.Provider - apiKey string - customAPIBase string - customModel string - modelChoice = cfg.Agents.Defaults.Model - orModelChoice = cfg.Agents.Defaults.Model - - portStr = strconv.Itoa(cfg.Gateway.Port) - - selectedChannels []string - telegramToken string - zaloToken string - zaloDMPolicy = "pairing" - feishuAppID string - feishuSecret string - feishuDomain = "lark" - feishuConnMode = "websocket" - - selectedFeatures []string - embProvider string - - ttsProvider = "none" - ttsAPIKey string - ttsGroupID string - ttsAutoMode = "off" - - postgresDSN string - traceVerbose bool - ) - - if isNewConfig { - providerChoice = "openrouter" - selectedFeatures = []string{"memory", "browser"} - } - if portStr == "0" { - portStr = "18790" - } - - // Pre-fill from existing config - if cfg.Channels.Telegram.Enabled { - selectedChannels = append(selectedChannels, "telegram") - telegramToken = cfg.Channels.Telegram.Token - } - if cfg.Channels.Zalo.Enabled { - selectedChannels = append(selectedChannels, "zalo") - zaloToken = cfg.Channels.Zalo.Token - zaloDMPolicy = cfg.Channels.Zalo.DMPolicy - } - if cfg.Channels.Feishu.Enabled { - selectedChannels = append(selectedChannels, "feishu") - feishuAppID = cfg.Channels.Feishu.AppID - feishuSecret = cfg.Channels.Feishu.AppSecret - feishuDomain = cfg.Channels.Feishu.Domain - feishuConnMode = cfg.Channels.Feishu.ConnectionMode - } - if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) { - selectedFeatures = append(selectedFeatures, "memory") - embProvider = cfg.Agents.Defaults.Memory.EmbeddingProvider - } - if cfg.Tools.Browser.Enabled { - selectedFeatures = append(selectedFeatures, "browser") - } - if cfg.Tts.Provider != "" { - ttsProvider = cfg.Tts.Provider - ttsAutoMode = cfg.Tts.Auto - ttsAPIKey = cfg.Tts.OpenAI.APIKey - if ttsAPIKey == "" { - ttsAPIKey = cfg.Tts.ElevenLabs.APIKey - } - if ttsAPIKey == "" { - ttsAPIKey = cfg.Tts.MiniMax.APIKey - } - ttsGroupID = cfg.Tts.MiniMax.GroupID - } - postgresDSN = cfg.Database.PostgresDSN - - // Pre-fill API key from env or config - apiKey = resolveExistingAPIKey(cfg, providerChoice) - if cfg.Providers.OpenAI.APIBase != "" { - customAPIBase = cfg.Providers.OpenAI.APIBase - } - - // ── Step 1: Provider ── - providerOptions := []SelectOption[string]{ - {"OpenRouter (recommended — access to many models)", "openrouter"}, - {"Anthropic (Claude models directly)", "anthropic"}, - {"OpenAI (GPT models)", "openai"}, - {"Groq (fast inference)", "groq"}, - {"DeepSeek (DeepSeek models)", "deepseek"}, - {"Gemini (Google Gemini)", "gemini"}, - {"Mistral (Mistral AI models)", "mistral"}, - {"xAI (Grok models)", "xai"}, - {"MiniMax (MiniMax models)", "minimax"}, - {"Cohere (Command models)", "cohere"}, - {"Perplexity (Sonar search models)", "perplexity"}, - {"Claude CLI (use local claude CLI — no API key needed)", "claude_cli"}, - {"Custom (any OpenAI-compatible endpoint)", "custom"}, - } - - var err error - providerChoice, err = promptSelect("Step 1 · AI Provider — Choose your LLM provider", providerOptions, 0) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // Claude CLI variables - var cliPath, cliModel string - if cfg.Providers.ClaudeCLI.CLIPath != "" { - cliPath = cfg.Providers.ClaudeCLI.CLIPath - cliModel = cfg.Providers.ClaudeCLI.Model - } - - // Provider-specific prompts - switch providerChoice { - case "claude_cli": - if cliPath == "" { - cliPath = "claude" - } - cliPath, err = promptString("Claude CLI Path", "Path to the claude binary", cliPath) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // Verify CLI binary exists - if _, lookErr := exec.LookPath(cliPath); lookErr != nil { - fmt.Printf(" ✗ Claude CLI binary not found at %q\n", cliPath) - fmt.Println(" Install Claude CLI first: https://docs.anthropic.com/en/docs/claude-cli") - return - } - - // Check authentication status - fmt.Print(" Checking Claude CLI authentication... ") - authStatus, authErr := providers.CheckClaudeAuthStatus(context.Background(), cliPath) - if authErr != nil || !authStatus.LoggedIn { - if authErr != nil { - fmt.Println("FAILED") - fmt.Printf(" %v\n", authErr) - } else { - fmt.Println("NOT LOGGED IN") - } - fmt.Println() - runLogin, confirmErr := promptConfirm("Claude CLI requires authentication. Run login now?", true) - if confirmErr != nil { - fmt.Println("Cancelled.") - return - } - if runLogin { - if loginErr := runClaudeAuthLogin(cliPath); loginErr != nil { - fmt.Printf(" Login failed: %v\n", loginErr) - fmt.Println(" You can try manually: claude auth login") - return - } - } else { - fmt.Println(" Skipping login. Run 'claude auth login' before starting the gateway.") - } - } else { - sub := authStatus.SubscriptionType - if sub == "" { - sub = "unknown" - } - fmt.Printf("OK\n") - fmt.Printf(" ✓ Authenticated as %s (subscription: %s)\n", authStatus.Email, sub) - } - fmt.Println() - - if cliModel == "" { - cliModel = "sonnet" - } - cliModel, err = promptString("Default Model", "Model alias: sonnet, opus, haiku", cliModel) - if err != nil { - fmt.Println("Cancelled.") - return - } - - case "custom": - customAPIBase, err = promptString("API Base URL", "OpenAI-compatible endpoint (e.g. Ollama, vLLM, LiteLLM)", customAPIBase) - if err != nil { - fmt.Println("Cancelled.") - return - } - apiKey, err = promptPassword("API Key", "Leave empty if not required") - if err != nil { - fmt.Println("Cancelled.") - return - } - customModel, err = promptString("Model ID", "The model to use on this endpoint", customModel) - if err != nil { - fmt.Println("Cancelled.") - return - } - - case "openrouter": - apiKey, err = promptPassword("OpenRouter API Key", "Get yours at https://openrouter.ai/keys") - if err != nil { - fmt.Println("Cancelled.") - return - } - - // Fetch and select model - fmt.Println(" Fetching OpenRouter models...") - orModelOptions := buildOpenRouterModelOptions() - orModelChoice, err = promptSelect("Choose OpenRouter Model", orModelOptions, 0) - if err != nil { - fmt.Println("Cancelled.") - return - } - - default: - // Standard provider - apiKey, err = promptPassword("API Key", "Your provider API key (check env vars or dashboard)") - if err != nil { - fmt.Println("Cancelled.") - return - } - modelChoice, err = promptString("Default Model", "Model ID to use (leave empty for provider default)", modelChoice) - if err != nil { - fmt.Println("Cancelled.") - return + if loaded, err := config.Load(cfgPath); err == nil { + cfg = loaded } } - // ── Gateway Port ── - portStr, err = promptString("Gateway Port", "WebSocket server port", portStr) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // ── Step 2: Channels ── - selectedChannels, err = promptMultiSelect("Step 2 · Channels (select at least 1)", "Enter numbers to toggle channels", []SelectOption[string]{ - {"Telegram", "telegram"}, - {"Zalo OA", "zalo"}, - {"Feishu / Lark", "feishu"}, - }, selectedChannels) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // Helper closures - hasChannel := func(ch string) bool { - for _, c := range selectedChannels { - if c == ch { - return true - } - } - return false - } - hasFeature := func(f string) bool { - for _, feat := range selectedFeatures { - if feat == f { - return true - } - } - return false - } - - // Channel-specific configs - if hasChannel("telegram") { - telegramToken, err = promptPassword("Telegram Bot Token", "Get from @BotFather on Telegram") - if err != nil { - fmt.Println("Cancelled.") - return - } - if telegramToken == "" { - telegramToken = cfg.Channels.Telegram.Token // keep existing - } - } - - if hasChannel("zalo") { - if err := promptZaloConfig(&zaloToken, &zaloDMPolicy); err != nil { - fmt.Println("Cancelled.") - return - } - } - - if hasChannel("feishu") { - if err := promptFeishuConfig(&feishuAppID, &feishuSecret, &feishuDomain, &feishuConnMode); err != nil { - fmt.Println("Cancelled.") - return - } - } - - // ── Features ── - selectedFeatures, err = promptMultiSelect("Features (recommended: keep both)", "Enter numbers to toggle features", []SelectOption[string]{ - {"Memory (vector search over agent notes)", "memory"}, - {"Browser automation (agent can browse the web)", "browser"}, - }, selectedFeatures) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // Memory embedding provider - if hasFeature("memory") { - embProvider, err = promptSelect("Memory Embedding Provider", []SelectOption[string]{ - {"Auto-detect (use chat provider's API key)", ""}, - {"OpenAI (text-embedding-3-small)", "openai"}, - {"OpenRouter (openai/text-embedding-3-small)", "openrouter"}, - {"Gemini (text-embedding-004)", "gemini"}, - }, 0) - if err != nil { - fmt.Println("Cancelled.") - return - } - } - - // ── TTS ── - if err := promptTTSConfig(&ttsProvider, &ttsAPIKey, &ttsGroupID, &ttsAutoMode); err != nil { - fmt.Println("Cancelled.") - return - } - - // ── Verbose Tracing ── - traceVerbose, err = promptConfirm("Enable verbose tracing? (Logs full LLM input in trace spans)", false) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // ── Step 3: Database ── - postgresDSN, err = promptString("Step 3 · Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", postgresDSN) - if err != nil { - fmt.Println("Cancelled.") - return - } - - // --- Post-form validation --- - var errors []string - - if providerChoice == "custom" { - if customAPIBase == "" { - errors = append(errors, "API base URL is required for custom provider") - } - if customModel == "" { - errors = append(errors, "Model ID is required for custom provider") - } - } else if providerChoice == "claude_cli" { - if cliPath == "" { - errors = append(errors, "Claude CLI path is required") - } - } else if apiKey == "" { - errors = append(errors, fmt.Sprintf("API key is required for %s", providerChoice)) - } - - if _, err := strconv.Atoi(portStr); err != nil { - errors = append(errors, fmt.Sprintf("Invalid gateway port: %s", portStr)) - } - - if len(selectedChannels) == 0 { - errors = append(errors, "At least one channel must be selected (Telegram, Zalo, or Feishu)") - } - - if hasChannel("telegram") && telegramToken == "" { - errors = append(errors, "Telegram bot token is required") - } - if hasChannel("zalo") && zaloToken == "" { - errors = append(errors, "Zalo bot token is required") - } - if hasChannel("feishu") && (feishuAppID == "" || feishuSecret == "") { - errors = append(errors, "Feishu App ID and App Secret are required") - } - + // ── Step 1: Postgres DSN ── + postgresDSN := os.Getenv("GOCLAW_POSTGRES_DSN") if postgresDSN == "" { - errors = append(errors, "Postgres DSN is required (set GOCLAW_POSTGRES_DSN or enter above)") + postgresDSN = cfg.Database.PostgresDSN } - - if len(errors) > 0 { - fmt.Println() - fmt.Println(" Validation errors:") - for _, e := range errors { - fmt.Printf(" • %s\n", e) + if postgresDSN == "" { + var err error + postgresDSN, err = promptString("Postgres DSN", "Connection string (e.g. postgres://user:pass@host:5432/dbname)", "") + if err != nil { + fmt.Println("Cancelled.") + return } - fmt.Println() - fmt.Println(" Please re-run: ./goclaw onboard") + } else { + fmt.Printf(" Using Postgres DSN from environment\n") + } + if postgresDSN == "" { + fmt.Println(" Error: Postgres DSN is required.") return } - // --- Apply collected values to config --- - - // Provider & model - if providerChoice == "claude_cli" { - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Model = cliModel - cfg.Providers.ClaudeCLI.CLIPath = cliPath - cfg.Providers.ClaudeCLI.Model = cliModel - } else if providerChoice == "custom" { - cfg.Agents.Defaults.Provider = "openai" - cfg.Providers.OpenAI.APIBase = customAPIBase - cfg.Providers.OpenAI.APIKey = apiKey - cfg.Agents.Defaults.Model = customModel - } else { - pi := providerMap[providerChoice] - cfg.Agents.Defaults.Provider = pi.name - applyProviderAPIKey(cfg, pi.name, apiKey) - - if providerChoice == "openrouter" { - if orModelChoice == "__custom__" { - cfg.Agents.Defaults.Model = pi.modelHint - } else { - cfg.Agents.Defaults.Model = orModelChoice - } - } else { - if modelChoice == "" { - modelChoice = pi.modelHint - } - cfg.Agents.Defaults.Model = modelChoice - } - } - - // Gateway - cfg.Gateway.Port, _ = strconv.Atoi(portStr) - if cfg.Gateway.Host == "" { - cfg.Gateway.Host = "0.0.0.0" - } - if cfg.Gateway.Token == "" { - cfg.Gateway.Token = onboardGenerateToken(16) - fmt.Printf(" Generated gateway token: %s\n", cfg.Gateway.Token) - } - - // Workspace (use default, no prompt) - cfg.Agents.Defaults.Workspace = "~/.goclaw/workspace" - expandedWS := config.ExpandHome(cfg.Agents.Defaults.Workspace) - if err := os.MkdirAll(expandedWS, 0755); err != nil { - fmt.Printf("Warning: could not create workspace: %v\n", err) - } - - // Channels - cfg.Channels.Telegram.Enabled = hasChannel("telegram") - if cfg.Channels.Telegram.Enabled { - cfg.Channels.Telegram.Token = telegramToken - cfg.Channels.Telegram.DMPolicy = "pairing" - } - - cfg.Channels.Zalo.Enabled = hasChannel("zalo") - if cfg.Channels.Zalo.Enabled { - cfg.Channels.Zalo.Token = zaloToken - cfg.Channels.Zalo.DMPolicy = zaloDMPolicy - } - - cfg.Channels.Feishu.Enabled = hasChannel("feishu") - if cfg.Channels.Feishu.Enabled { - cfg.Channels.Feishu.AppID = feishuAppID - cfg.Channels.Feishu.AppSecret = feishuSecret - cfg.Channels.Feishu.Domain = feishuDomain - cfg.Channels.Feishu.ConnectionMode = feishuConnMode - } - - // Features - if hasFeature("memory") { - enabled := true - if cfg.Agents.Defaults.Memory == nil { - cfg.Agents.Defaults.Memory = &config.MemoryConfig{} - } - cfg.Agents.Defaults.Memory.Enabled = &enabled - cfg.Agents.Defaults.Memory.EmbeddingProvider = embProvider - } else { - disabled := false - cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &disabled} - } - cfg.Tools.Browser.Enabled = hasFeature("browser") - if cfg.Tools.Browser.Enabled { - cfg.Tools.Browser.Headless = true - } - - // TTS - if ttsProvider != "none" { - cfg.Tts.Provider = ttsProvider - cfg.Tts.Auto = ttsAutoMode - switch ttsProvider { - case "openai": - cfg.Tts.OpenAI.APIKey = ttsAPIKey - case "elevenlabs": - cfg.Tts.ElevenLabs.APIKey = ttsAPIKey - case "minimax": - cfg.Tts.MiniMax.APIKey = ttsAPIKey - cfg.Tts.MiniMax.GroupID = ttsGroupID - case "edge": - cfg.Tts.Edge.Enabled = true - } - } - - // Database - cfg.Database.PostgresDSN = postgresDSN - - // Auto-generate encryption key for API keys in DB (if not already set). - if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" { - encKey := onboardGenerateToken(32) - os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey) - fmt.Printf(" Generated encryption key for API keys (AES-256-GCM)\n") - } else { - fmt.Println(" Using existing GOCLAW_ENCRYPTION_KEY from environment") - } - + // ── Step 2: Test connection ── fmt.Print(" Testing Postgres connection... ") if err := testPostgresConnection(postgresDSN); err != nil { fmt.Println("FAILED") @@ -599,135 +67,111 @@ func runOnboard() { } fmt.Println("OK") - runMigrate, err := promptConfirm("Run database migration now?", true) - if err != nil { - fmt.Println("Cancelled.") - return + // ── Step 3: Generate keys ── + gatewayToken := os.Getenv("GOCLAW_GATEWAY_TOKEN") + if gatewayToken == "" { + gatewayToken = cfg.Gateway.Token } - if runMigrate { - fmt.Println(" Running migration...") - m, err := newMigrator(postgresDSN) - if err != nil { - fmt.Printf(" Migration error: %v\n", err) + generatedToken := false + if gatewayToken == "" { + gatewayToken = onboardGenerateToken(16) + generatedToken = true + } + + encryptionKey := os.Getenv("GOCLAW_ENCRYPTION_KEY") + generatedEncKey := false + if encryptionKey == "" { + encryptionKey = onboardGenerateToken(32) + generatedEncKey = true + } + os.Setenv("GOCLAW_ENCRYPTION_KEY", encryptionKey) + + // ── Step 4: Migrations ── + fmt.Print(" Running migrations... ") + m, err := newMigrator(postgresDSN) + if err != nil { + fmt.Printf("FAILED: %v\n", err) + fmt.Println(" You can run it manually later: ./goclaw migrate up") + } else { + if err := m.Up(); err != nil && err.Error() != "no change" { + fmt.Printf("FAILED: %v\n", err) fmt.Println(" You can run it manually later: ./goclaw migrate up") } else { - if err := m.Up(); err != nil && err.Error() != "no change" { - fmt.Printf(" Migration error: %v\n", err) - fmt.Println(" You can run it manually later: ./goclaw migrate up") - } else { - v, _, _ := m.Version() - fmt.Printf(" Migration complete (version: %d)\n", v) - } - m.Close() - } - - fmt.Println(" Seeding default agent and provider...") - if err := seedManagedData(postgresDSN, cfg); err != nil { - fmt.Printf(" Seed warning: %v\n", err) - fmt.Println(" You can seed manually via the API after starting the gateway.") - } else { - fmt.Println(" Default agent and provider seeded.") + v, _, _ := m.Version() + fmt.Printf("OK (version: %d)\n", v) } + m.Close() } - // --- Save config --- - fmt.Println() - fmt.Println("── Saving Config ──") - fmt.Println() - - savedProviders := cfg.Providers - savedGwToken := cfg.Gateway.Token - savedTgToken := cfg.Channels.Telegram.Token - savedZaloToken := cfg.Channels.Zalo.Token - savedFeishuAppSecret := cfg.Channels.Feishu.AppSecret - savedTtsOpenAIKey := cfg.Tts.OpenAI.APIKey - savedTtsElevenLabsKey := cfg.Tts.ElevenLabs.APIKey - savedTtsMiniMaxKey := cfg.Tts.MiniMax.APIKey - - // Clear secrets from providers but keep non-secret configs (like ClaudeCLI) - cfg.Providers = config.ProvidersConfig{ - ClaudeCLI: savedProviders.ClaudeCLI, // no secrets, safe to save in config + // ── Step 5: Seed placeholder providers for UI ── + fmt.Print(" Seeding placeholder providers... ") + if err := seedOnboardPlaceholders(postgresDSN); err != nil { + fmt.Printf("warning: %v\n", err) + } else { + fmt.Println("OK") } - cfg.Gateway.Token = "" - cfg.Channels.Telegram.Token = "" - cfg.Channels.Zalo.Token = "" - cfg.Channels.Feishu.AppSecret = "" - cfg.Tts.OpenAI.APIKey = "" - cfg.Tts.ElevenLabs.APIKey = "" - cfg.Tts.MiniMax.APIKey = "" - saveErr := config.Save(cfgPath, cfg) + // ── Step 6: Save config ── + if cfg.Gateway.Host == "" { + cfg.Gateway.Host = "0.0.0.0" + } + if cfg.Gateway.Port == 0 { + cfg.Gateway.Port = 18790 + } + cfg.Database.PostgresDSN = "" // secrets go in .env.local, not config + cfg.Gateway.Token = "" // secrets go in .env.local, not config - cfg.Providers = savedProviders - cfg.Gateway.Token = savedGwToken - cfg.Channels.Telegram.Token = savedTgToken - cfg.Channels.Zalo.Token = savedZaloToken - cfg.Channels.Feishu.AppSecret = savedFeishuAppSecret - cfg.Tts.OpenAI.APIKey = savedTtsOpenAIKey - cfg.Tts.ElevenLabs.APIKey = savedTtsElevenLabsKey - cfg.Tts.MiniMax.APIKey = savedTtsMiniMaxKey - - if err := saveErr; err != nil { - fmt.Printf("Error saving config: %v\n", err) + if err := config.Save(cfgPath, cfg); err != nil { + fmt.Printf(" Error saving config: %v\n", err) os.Exit(1) } - fmt.Printf("Config saved to %s (no secrets)\n", cfgPath) + // ── Step 7: Save .env.local ── envPath := filepath.Join(filepath.Dir(cfgPath), ".env.local") - pi := providerMap[providerChoice] - onboardWriteEnvFile(envPath, cfg, apiKey, pi.envKey, traceVerbose) - fmt.Printf("Secrets saved to %s\n", envPath) + onboardWriteEnvFile(envPath, postgresDSN, gatewayToken, encryptionKey) + + // ── Summary ── + port := strconv.Itoa(cfg.Gateway.Port) - // Summary fmt.Println() fmt.Println("╔══════════════════════════════════════════════╗") - fmt.Println("║ Setup Complete! ║") + fmt.Println("║ Setup Complete! ║") fmt.Println("╚══════════════════════════════════════════════╝") fmt.Println() - fmt.Printf(" Provider: %s\n", cfg.Agents.Defaults.Provider) - fmt.Printf(" Model: %s\n", cfg.Agents.Defaults.Model) - fmt.Printf(" Gateway: ws://%s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Printf(" Token: %s\n", cfg.Gateway.Token) - fmt.Printf(" Workspace: %s\n", cfg.Agents.Defaults.Workspace) - fmt.Println(" Database: PostgreSQL") - if cfg.Channels.Telegram.Enabled { - fmt.Println(" Telegram: enabled") - } - if cfg.Channels.Zalo.Enabled { - fmt.Println(" Zalo: enabled") - } - if cfg.Channels.Feishu.Enabled { - fmt.Printf(" Feishu: enabled (%s, %s)\n", cfg.Channels.Feishu.Domain, cfg.Channels.Feishu.ConnectionMode) - } - if cfg.Agents.Defaults.Memory != nil && (cfg.Agents.Defaults.Memory.Enabled == nil || *cfg.Agents.Defaults.Memory.Enabled) { - embProv := cfg.Agents.Defaults.Memory.EmbeddingProvider - if embProv == "" { - embProv = "auto-detect" + + if generatedToken || generatedEncKey { + fmt.Println("── Generated Secrets (shown once, saved to .env.local) ──") + fmt.Println() + if generatedToken { + fmt.Printf(" Gateway Token: %s\n", gatewayToken) } - fmt.Printf(" Memory: enabled (embedding: %s)\n", embProv) - } else { - fmt.Println(" Memory: disabled") - } - if cfg.Tools.Browser.RemoteURL != "" { - fmt.Printf(" Browser: enabled (remote: %s)\n", cfg.Tools.Browser.RemoteURL) - } else if cfg.Tools.Browser.Enabled { - fmt.Println(" Browser: enabled (headless)") - } else { - fmt.Println(" Browser: disabled") - } - if cfg.Tts.Provider != "" { - autoMode := cfg.Tts.Auto - if autoMode == "" { - autoMode = "off" + if generatedEncKey { + fmt.Printf(" Encryption Key: %s\n", encryptionKey) } - fmt.Printf(" TTS: %s (auto: %s)\n", cfg.Tts.Provider, autoMode) - } else { - fmt.Println(" TTS: disabled") + fmt.Println() + fmt.Println(" ⚠ These keys are shown only once. They are saved in:") + fmt.Printf(" → %s\n", envPath) + fmt.Println() } + + fmt.Println("── Files ──") fmt.Println() - fmt.Println("To start the gateway:") + fmt.Printf(" Config: %s (gateway host/port, no secrets)\n", cfgPath) + fmt.Printf(" Secrets: %s (GOCLAW_POSTGRES_DSN, GOCLAW_GATEWAY_TOKEN, GOCLAW_ENCRYPTION_KEY)\n", envPath) fmt.Println() - fmt.Printf(" source %s && ./goclaw\n", envPath) + + fmt.Println("── Next Steps ──") + fmt.Println() + fmt.Println(" 1. Start the gateway:") + fmt.Printf(" source %s && ./goclaw\n", envPath) + fmt.Println() + fmt.Println(" 2. Open the dashboard to complete setup:") + fmt.Printf(" http://localhost:%s\n", port) + fmt.Println() + fmt.Println(" The setup wizard will guide you through:") + fmt.Println(" → Provider & API key configuration") + fmt.Println(" → Model selection & verification") + fmt.Println(" → Agent creation") + fmt.Println(" → Channel setup (optional)") fmt.Println() } - diff --git a/cmd/onboard_auto.go b/cmd/onboard_auto.go deleted file mode 100644 index 3a046074..00000000 --- a/cmd/onboard_auto.go +++ /dev/null @@ -1,323 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "time" - - "github.com/nextlevelbuilder/goclaw/internal/config" -) - -// providerPriority defines the order in which providers are auto-detected -// from environment variables. First match wins. -var providerPriority = []string{ - "openrouter", "anthropic", "openai", "groq", "deepseek", - "gemini", "mistral", "xai", "minimax", "cohere", "perplexity", -} - -// canAutoOnboard returns true if any GOCLAW_*_API_KEY env var is set, -// indicating the user wants non-interactive configuration (e.g. Docker). -func canAutoOnboard() bool { - for _, name := range providerPriority { - pi, ok := providerMap[name] - if !ok || pi.envKey == "" { - continue - } - if os.Getenv(pi.envKey) != "" { - return true - } - } - return false -} - -// runAutoOnboard performs non-interactive setup from environment variables. -// Returns true on success, false on fatal error. -func runAutoOnboard(cfgPath string) bool { - fmt.Println("Auto-onboard: environment variables detected, running non-interactive setup...") - - cfg := config.Default() - cfg.ApplyEnvOverrides() - - // 1. Resolve provider: respect GOCLAW_PROVIDER if set, otherwise auto-detect. - provider := cfg.Agents.Defaults.Provider // may be set by GOCLAW_PROVIDER via ApplyEnvOverrides - apiKey := "" - if provider != "" { - apiKey = resolveProviderAPIKey(cfg, provider) - } - if apiKey == "" { - // No explicit provider or no API key for it — auto-detect from available keys - provider, apiKey = detectProvider(cfg) - } - if provider == "" { - fmt.Println("Auto-onboard: no provider API key found in environment") - return false - } - cfg.Agents.Defaults.Provider = provider - - // Use model hint if no model override set via GOCLAW_MODEL - if cfg.Agents.Defaults.Model == "" || cfg.Agents.Defaults.Model == config.Default().Agents.Defaults.Model { - if pi, ok := providerMap[provider]; ok && pi.modelHint != "" { - cfg.Agents.Defaults.Model = pi.modelHint - } - } - - fmt.Printf(" Provider: %s (model: %s)\n", provider, cfg.Agents.Defaults.Model) - - // 2. Auto-enable memory: detect embedding-capable API keys from env. - // Embedding providers: openai, openrouter, gemini (same order as resolveEmbeddingProvider). - embProvider := autoDetectEmbeddingProvider(cfg) - if embProvider != "" { - enabled := true - cfg.Agents.Defaults.Memory = &config.MemoryConfig{ - Enabled: &enabled, - EmbeddingProvider: embProvider, - } - fmt.Printf(" Memory: enabled (embedding: %s)\n", embProvider) - } else { - fmt.Println(" Memory: enabled (FTS-only, no embedding API key)") - enabled := true - cfg.Agents.Defaults.Memory = &config.MemoryConfig{Enabled: &enabled} - } - - // 3. Gateway token - if cfg.Gateway.Token == "" { - cfg.Gateway.Token = onboardGenerateToken(16) - slog.Info("auto-onboard: generated gateway token") - } - - // 3. Database: Postgres setup - if cfg.Database.PostgresDSN != "" { - fmt.Print(" Testing Postgres connection...") - - // Retry loop: database container may still be starting - var pgErr error - for attempt := 1; attempt <= 5; attempt++ { - pgErr = testPostgresConnection(cfg.Database.PostgresDSN) - if pgErr == nil { - break - } - if attempt < 5 { - fmt.Printf(" retry %d/5...", attempt) - time.Sleep(2 * time.Second) - } - } - - if pgErr != nil { - fmt.Println(" FAILED") - fmt.Printf(" Error: %v\n", pgErr) - return false - } - fmt.Println(" OK") - - // Generate encryption key if not set - if os.Getenv("GOCLAW_ENCRYPTION_KEY") == "" { - encKey := onboardGenerateToken(32) - os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey) - slog.Info("auto-onboard: generated encryption key") - } - - // Run migrations (idempotent) - fmt.Print(" Running migrations...") - m, err := newMigrator(cfg.Database.PostgresDSN) - if err != nil { - fmt.Printf(" error: %v\n", err) - fmt.Println(" Continuing without migration (run manually: goclaw migrate up)") - } else { - if err := m.Up(); err != nil && err.Error() != "no change" { - fmt.Printf(" error: %v\n", err) - fmt.Println(" Continuing without migration (run manually: goclaw migrate up)") - } else { - v, _, _ := m.Version() - fmt.Printf(" OK (version: %d)\n", v) - } - m.Close() - } - - // Verify provider connectivity for all configured providers before seeding. - // Only the primary provider's auth failure blocks bootstrap. - fmt.Println(" Verifying provider connectivity...") - if fatalErrors := verifyAllProviders(cfg, provider); len(fatalErrors) > 0 { - slog.Error("auto-onboard: primary provider verification failed", "errors", fatalErrors) - fmt.Printf(" Provider verification FAILED: primary provider %q has invalid API key\n", provider) - return false - } - - // Seed default data (non-fatal if already exists) - fmt.Print(" Seeding default agent/provider...") - if err := seedManagedData(cfg.Database.PostgresDSN, cfg); err != nil { - fmt.Printf(" skipped: %v\n", err) - } else { - fmt.Println(" OK") - } - } - - // 4. Save config (clean, minimal — secrets stripped, unused sections omitted) - savedDSN := cfg.Database.PostgresDSN - if err := saveCleanConfig(cfgPath, cfg); err != nil { - fmt.Printf(" Warning: could not save config: %v\n", err) - } else { - fmt.Printf(" Config saved to %s\n", cfgPath) - } - - // Restore DSN and re-apply env overrides so the runtime config has secrets - cfg.Database.PostgresDSN = savedDSN - cfg.ApplyEnvOverrides() - _ = apiKey // apiKey is already applied via ApplyEnvOverrides - - fmt.Println("Auto-onboard complete.") - return true -} - -// embeddingCapable lists providers that support text embeddings. -// Only these three have embedding provider implementations in resolveEmbeddingProvider. -var embeddingCapable = map[string]bool{ - "openai": true, - "openrouter": true, - "gemini": true, -} - -// autoDetectEmbeddingProvider picks an embedding provider from available API keys. -// Priority: primary provider (GOCLAW_PROVIDER) if embedding-capable, then openai → openrouter → gemini. -func autoDetectEmbeddingProvider(cfg *config.Config) string { - // Prioritize the primary provider if it supports embeddings. - primary := cfg.Agents.Defaults.Provider - if embeddingCapable[primary] && resolveProviderAPIKey(cfg, primary) != "" { - return primary - } - - // Fallback: first available embedding-capable key. - if cfg.Providers.OpenAI.APIKey != "" { - return "openai" - } - if cfg.Providers.OpenRouter.APIKey != "" { - return "openrouter" - } - if cfg.Providers.Gemini.APIKey != "" { - return "gemini" - } - return "" -} - -// detectProvider finds the first provider with an API key in the environment. -func detectProvider(cfg *config.Config) (string, string) { - for _, name := range providerPriority { - key := resolveProviderAPIKey(cfg, name) - if key != "" { - return name, key - } - } - return "", "" -} - -// saveCleanConfig saves a minimal config.json without noise (empty providers, -// disabled channels, stripped secrets). Only includes sections relevant to -// the active configuration so the file serves as clean documentation. -// Channels are stored in the DB (channel_instances table), -// so they are omitted from config.json to avoid dual-connection. -func saveCleanConfig(cfgPath string, cfg *config.Config) error { - // Channels are stored in the DB (channel_instances table), - // so they are omitted from config.json to avoid dual-connection. - channels := make(map[string]interface{}) - - // Build tools section. - tools := map[string]interface{}{ - "web": map[string]interface{}{ - "duckduckgo": map[string]interface{}{ - "enabled": cfg.Tools.Web.DuckDuckGo.Enabled, - "max_results": nonZero(cfg.Tools.Web.DuckDuckGo.MaxResults, 5), - }, - }, - "browser": map[string]interface{}{ - "enabled": cfg.Tools.Browser.Enabled, - "headless": cfg.Tools.Browser.Headless, - "remote_url": cfg.Tools.Browser.RemoteURL, - }, - "execApproval": map[string]interface{}{ - "security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"), - "ask": nonEmpty(cfg.Tools.ExecApproval.Ask, "off"), - }, - } - - // Build agents section. - agents := map[string]interface{}{ - "defaults": map[string]interface{}{ - "workspace": cfg.Agents.Defaults.Workspace, - "restrict_to_workspace": cfg.Agents.Defaults.RestrictToWorkspace, - "provider": cfg.Agents.Defaults.Provider, - "model": cfg.Agents.Defaults.Model, - "max_tokens": cfg.Agents.Defaults.MaxTokens, - "temperature": cfg.Agents.Defaults.Temperature, - "max_tool_iterations": cfg.Agents.Defaults.MaxToolIterations, - "context_window": cfg.Agents.Defaults.ContextWindow, - }, - } - - if cfg.Agents.Defaults.Subagents != nil { - agents["defaults"].(map[string]interface{})["subagents"] = cfg.Agents.Defaults.Subagents - } - - if mc := cfg.Agents.Defaults.Memory; mc != nil { - mem := map[string]interface{}{ - "enabled": mc.Enabled == nil || *mc.Enabled, - } - if mc.EmbeddingProvider != "" { - mem["embedding_provider"] = mc.EmbeddingProvider - } - if mc.EmbeddingModel != "" { - mem["embedding_model"] = mc.EmbeddingModel - } - agents["defaults"].(map[string]interface{})["memory"] = mem - } - - // Build gateway section (no token — secret). - gateway := map[string]interface{}{ - "host": cfg.Gateway.Host, - "port": cfg.Gateway.Port, - "max_message_chars": nonZero(cfg.Gateway.MaxMessageChars, 32000), - "rate_limit_rpm": nonZero(cfg.Gateway.RateLimitRPM, 20), - "inbound_debounce_ms": nonZero(cfg.Gateway.InboundDebounceMs, 1000), - } - - // Build root config map. - root := map[string]interface{}{ - "agents": agents, - "gateway": gateway, - "tools": tools, - } - - if len(channels) > 0 { - root["channels"] = channels - } - - - data, err := json.MarshalIndent(root, "", " ") - if err != nil { - return err - } - - dir := filepath.Dir(cfgPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - return os.WriteFile(cfgPath, data, 0600) -} - -// nonEmpty returns val if non-empty, otherwise fallback. -func nonEmpty(val, fallback string) string { - if val != "" { - return val - } - return fallback -} - -// nonZero returns val if non-zero, otherwise fallback. -func nonZero(val, fallback int) int { - if val != 0 { - return val - } - return fallback -} diff --git a/cmd/onboard_claude_cli.go b/cmd/onboard_claude_cli.go deleted file mode 100644 index f2d740b8..00000000 --- a/cmd/onboard_claude_cli.go +++ /dev/null @@ -1,18 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" -) - -// runClaudeAuthLogin runs `claude auth login` interactively, -// connecting stdin/stdout/stderr to the terminal for the browser OAuth flow. -func runClaudeAuthLogin(cliPath string) error { - fmt.Println(" Opening browser for Claude authentication...") - cmd := exec.Command(cliPath, "auth", "login") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/cmd/onboard_feishu.go b/cmd/onboard_feishu.go deleted file mode 100644 index 448f6e90..00000000 --- a/cmd/onboard_feishu.go +++ /dev/null @@ -1,44 +0,0 @@ -package cmd - -import "fmt" - -// promptFeishuConfig runs the Feishu/Lark configuration prompts sequentially. -func promptFeishuConfig(appID, appSecret, domain, connMode *string) error { - fmt.Println("\n ── Feishu / Lark Configuration ──") - - val, err := promptString("Feishu App ID", "From the Feishu/Lark Open Platform console", *appID) - if err != nil { - return err - } - *appID = val - - val, err = promptPassword("Feishu App Secret", "Keep this secret safe") - if err != nil { - return err - } - if val != "" { - *appSecret = val - } - - // API Domain - d, err := promptSelect("API Domain", []SelectOption[string]{ - {"Lark Global (open.larksuite.com)", "lark"}, - {"Feishu China (open.feishu.cn)", "feishu"}, - }, 0) - if err != nil { - return err - } - *domain = d - - // Connection Mode - cm, err := promptSelect("Connection Mode", []SelectOption[string]{ - {"WebSocket (recommended, no public URL needed)", "websocket"}, - {"Webhook (requires public URL)", "webhook"}, - }, 0) - if err != nil { - return err - } - *connMode = cm - - return nil -} diff --git a/cmd/onboard_helpers.go b/cmd/onboard_helpers.go index 9c01e9bd..6401ef91 100644 --- a/cmd/onboard_helpers.go +++ b/cmd/onboard_helpers.go @@ -6,138 +6,23 @@ import ( "fmt" "os" "strings" - - "github.com/nextlevelbuilder/goclaw/internal/config" ) -// resolveExistingAPIKey tries to find an existing API key from config or env vars. -func resolveExistingAPIKey(cfg *config.Config, provider string) string { - key := resolveProviderAPIKey(cfg, provider) - if key != "" { - return key - } - if pi, ok := providerMap[provider]; ok && pi.envKey != "" { - if envKey := os.Getenv(pi.envKey); envKey != "" { - return envKey - } - } - return "" -} - -// applyProviderAPIKey stores the API key in the correct config field. -func applyProviderAPIKey(cfg *config.Config, provider, key string) { - switch provider { - case "openrouter": - cfg.Providers.OpenRouter.APIKey = key - case "anthropic": - cfg.Providers.Anthropic.APIKey = key - case "openai": - cfg.Providers.OpenAI.APIKey = key - case "groq": - cfg.Providers.Groq.APIKey = key - case "deepseek": - cfg.Providers.DeepSeek.APIKey = key - case "gemini": - cfg.Providers.Gemini.APIKey = key - case "mistral": - cfg.Providers.Mistral.APIKey = key - case "xai": - cfg.Providers.XAI.APIKey = key - case "minimax": - cfg.Providers.MiniMax.APIKey = key - case "cohere": - cfg.Providers.Cohere.APIKey = key - case "perplexity": - cfg.Providers.Perplexity.APIKey = key - } -} - func onboardGenerateToken(bytes int) string { b := make([]byte, bytes) _, _ = rand.Read(b) return hex.EncodeToString(b) } -func onboardWriteEnvFile(path string, cfg *config.Config, primaryKey, primaryEnvKey string, traceVerbose bool) { +// onboardWriteEnvFile writes the minimal .env.local with only the 3 required secrets. +func onboardWriteEnvFile(path, postgresDSN, gatewayToken, encryptionKey string) { var lines []string - lines = append(lines, "# GoClaw — auto-generated by onboard wizard") + lines = append(lines, "# GoClaw — auto-generated by onboard") lines = append(lines, "# Keep this file secret! Add to .gitignore.") lines = append(lines, "") - - if primaryEnvKey != "" && primaryKey != "" { - lines = append(lines, fmt.Sprintf("export %s=%s", primaryEnvKey, primaryKey)) - } - - // Claude CLI config (no API key, just path/model) - if cfg.Providers.ClaudeCLI.CLIPath != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_PATH=%s", cfg.Providers.ClaudeCLI.CLIPath)) - if cfg.Providers.ClaudeCLI.Model != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_CLAUDE_CLI_MODEL=%s", cfg.Providers.ClaudeCLI.Model)) - } - } - - // Add other configured provider keys (skip if same as primary) - addIfSet := func(envKey, value string) { - if value != "" && (primaryEnvKey == "" || envKey != primaryEnvKey) { - lines = append(lines, fmt.Sprintf("export %s=%s", envKey, value)) - } - } - addIfSet("GOCLAW_ANTHROPIC_API_KEY", cfg.Providers.Anthropic.APIKey) - addIfSet("GOCLAW_OPENAI_API_KEY", cfg.Providers.OpenAI.APIKey) - addIfSet("GOCLAW_OPENROUTER_API_KEY", cfg.Providers.OpenRouter.APIKey) - addIfSet("GOCLAW_GROQ_API_KEY", cfg.Providers.Groq.APIKey) - addIfSet("GOCLAW_DEEPSEEK_API_KEY", cfg.Providers.DeepSeek.APIKey) - addIfSet("GOCLAW_GEMINI_API_KEY", cfg.Providers.Gemini.APIKey) - addIfSet("GOCLAW_MISTRAL_API_KEY", cfg.Providers.Mistral.APIKey) - addIfSet("GOCLAW_XAI_API_KEY", cfg.Providers.XAI.APIKey) - addIfSet("GOCLAW_MINIMAX_API_KEY", cfg.Providers.MiniMax.APIKey) - addIfSet("GOCLAW_COHERE_API_KEY", cfg.Providers.Cohere.APIKey) - addIfSet("GOCLAW_PERPLEXITY_API_KEY", cfg.Providers.Perplexity.APIKey) - - if cfg.Gateway.Token != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", cfg.Gateway.Token)) - } - - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_TELEGRAM_TOKEN=%s", cfg.Channels.Telegram.Token)) - } - if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_ZALO_TOKEN=%s", cfg.Channels.Zalo.Token)) - } - if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppSecret != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_ID=%s", cfg.Channels.Feishu.AppID)) - lines = append(lines, fmt.Sprintf("export GOCLAW_FEISHU_APP_SECRET=%s", cfg.Channels.Feishu.AppSecret)) - } - - // Database - if cfg.Database.PostgresDSN != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", cfg.Database.PostgresDSN)) - } - - // Encryption key for API keys in DB - if encKey := os.Getenv("GOCLAW_ENCRYPTION_KEY"); encKey != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encKey)) - } - - // TTS secrets - if cfg.Tts.OpenAI.APIKey != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_OPENAI_API_KEY=%s", cfg.Tts.OpenAI.APIKey)) - } - if cfg.Tts.ElevenLabs.APIKey != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_ELEVENLABS_API_KEY=%s", cfg.Tts.ElevenLabs.APIKey)) - } - if cfg.Tts.MiniMax.APIKey != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_API_KEY=%s", cfg.Tts.MiniMax.APIKey)) - } - if cfg.Tts.MiniMax.GroupID != "" { - lines = append(lines, fmt.Sprintf("export GOCLAW_TTS_MINIMAX_GROUP_ID=%s", cfg.Tts.MiniMax.GroupID)) - } - - // Verbose tracing - if traceVerbose { - lines = append(lines, "export GOCLAW_TRACE_VERBOSE=1") - } - + lines = append(lines, fmt.Sprintf("export GOCLAW_POSTGRES_DSN=%s", postgresDSN)) + lines = append(lines, fmt.Sprintf("export GOCLAW_GATEWAY_TOKEN=%s", gatewayToken)) + lines = append(lines, fmt.Sprintf("export GOCLAW_ENCRYPTION_KEY=%s", encryptionKey)) lines = append(lines, "") content := strings.Join(lines, "\n") diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index e4461cdf..a7e8f35c 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -2,16 +2,11 @@ package cmd import ( "context" - "encoding/json" "fmt" "log/slog" "os" "time" - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" ) @@ -29,259 +24,6 @@ func testPostgresConnection(dsn string) error { return db.PingContext(ctx) } -// seedManagedData inserts providers, default model, and default agent into Postgres -// so the gateway has something to work with on first start. -// All providers with API keys are seeded (not just the default one). -// Idempotent: duplicate entries are skipped on re-run. -func seedManagedData(dsn string, cfg *config.Config) error { - storeCfg := store.StoreConfig{ - PostgresDSN: dsn, - EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), - } - stores, err := pg.NewPGStores(storeCfg) - if err != nil { - return fmt.Errorf("open PG stores: %w", err) - } - - ctx := context.Background() - - // Resolve owner: use first GOCLAW_OWNER_IDS entry if set, otherwise "system". - ownerID := "system" - if len(cfg.Gateway.OwnerIDs) > 0 && cfg.Gateway.OwnerIDs[0] != "" { - ownerID = cfg.Gateway.OwnerIDs[0] - } - - defaultProvider := cfg.Agents.Defaults.Provider - if defaultProvider == "" { - defaultProvider = "openrouter" - } - - // 1. Seed all providers that have API keys. - // Errors are non-fatal per provider (e.g. unique violation on re-run). - var seededCount int - for _, name := range providerPriority { - apiKey := resolveProviderAPIKey(cfg, name) - if apiKey == "" { - continue - } - - providerType := resolveProviderType(name) - - p := &store.LLMProviderData{ - Name: name, - DisplayName: name, - ProviderType: providerType, - APIBase: resolveProviderAPIBase(name), - APIKey: apiKey, - Enabled: true, - } - - if err := stores.Providers.CreateProvider(ctx, p); err != nil { - slog.Debug("seed provider skipped (may already exist)", "name", name, "error", err) - continue - } - seededCount++ - slog.Info("seeded provider", "name", name) - } - - if seededCount > 0 { - fmt.Printf(" Seeded %d provider(s)\n", seededCount) - } - - // 2. Find the default provider's ID from DB (handles both fresh seed and re-run). - allProviders, err := stores.Providers.ListProviders(ctx) - if err != nil { - return fmt.Errorf("list providers: %w", err) - } - - var defaultProviderID uuid.UUID - for _, p := range allProviders { - if p.Name == defaultProvider { - defaultProviderID = p.ID - break - } - } - if defaultProviderID == uuid.Nil { - return fmt.Errorf("default provider %q not found in DB (no API key?)", defaultProvider) - } - - // 3. Resolve default model string (used for agent seed below). - modelID := cfg.Agents.Defaults.Model - if modelID == "" { - modelID = "anthropic/claude-sonnet-4-5-20250929" - } - - // 4. Seed default agent (skip if already exists) - workspace := config.ExpandHome(cfg.Agents.Defaults.Workspace) - agent := &store.AgentData{ - AgentKey: "default", - DisplayName: "Default Agent", - OwnerID: ownerID, - AgentType: store.AgentTypeOpen, - Provider: defaultProvider, - Model: modelID, - Workspace: workspace, - RestrictToWorkspace: true, - IsDefault: true, - Status: store.AgentStatusActive, - SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`), - } - - if err := stores.Agents.Create(ctx, agent); err != nil { - slog.Debug("seed agent skipped (may already exist)", "error", err) - return nil - } - - // 5. Seed context files into agent_context_files (only for predefined agents; - // open agents get per-user files via SeedUserFiles on first chat) - if _, err := bootstrap.SeedToStore(ctx, stores.Agents, agent.ID, agent.AgentType); err != nil { - return fmt.Errorf("seed context files: %w", err) - } - - // 6. Seed channel instances from env vars (if set) - seedChannelInstances(ctx, stores, cfg, agent.ID, ownerID) - - // 7. Seed config_secrets from env vars - seedConfigSecrets(ctx, stores, cfg) - - // 8. Seed placeholder providers for UI discoverability - seedDefaultPlaceholders(ctx, stores) - - return nil -} - -// seedChannelInstances creates channel_instances rows from env-var-provided credentials. -// Idempotent: skips if an instance with the same name already exists. -func seedChannelInstances(ctx context.Context, stores *store.Stores, cfg *config.Config, defaultAgentID uuid.UUID, ownerID string) { - if stores.ChannelInstances == nil { - return - } - - type seed struct { - name string // channel name in the system - channelType string - display string - creds map[string]string - config map[string]interface{} - } - - var seeds []seed - - // Telegram: use legacy name "telegram" for backward compat with existing session keys. - if cfg.Channels.Telegram.Token != "" { - tgConfig := map[string]interface{}{ - "dm_policy": nonEmpty(cfg.Channels.Telegram.DMPolicy, "pairing"), - "group_policy": nonEmpty(cfg.Channels.Telegram.GroupPolicy, "pairing"), - "reaction_level": nonEmpty(cfg.Channels.Telegram.ReactionLevel, "full"), - "history_limit": nonZero(cfg.Channels.Telegram.HistoryLimit, 50), - } - if cfg.Channels.Telegram.RequireMention != nil { - tgConfig["require_mention"] = *cfg.Channels.Telegram.RequireMention - } - if cfg.Channels.Telegram.MediaMaxBytes > 0 { - tgConfig["media_max_bytes"] = cfg.Channels.Telegram.MediaMaxBytes - } - if cfg.Channels.Telegram.LinkPreview != nil { - tgConfig["link_preview"] = *cfg.Channels.Telegram.LinkPreview - } - if len(cfg.Channels.Telegram.AllowFrom) > 0 { - tgConfig["allow_from"] = cfg.Channels.Telegram.AllowFrom - } - - seeds = append(seeds, seed{ - name: "telegram", channelType: "telegram", display: "Telegram Bot", - creds: map[string]string{"token": cfg.Channels.Telegram.Token, "proxy": cfg.Channels.Telegram.Proxy}, - config: tgConfig, - }) - } - - // Other channels: use {type}/default format (no legacy data to preserve). - if cfg.Channels.Discord.Token != "" { - seeds = append(seeds, seed{ - name: "discord/default", channelType: "discord", display: "Discord Bot", - creds: map[string]string{"token": cfg.Channels.Discord.Token}, - config: map[string]interface{}{"dm_policy": cfg.Channels.Discord.DMPolicy, "group_policy": cfg.Channels.Discord.GroupPolicy}, - }) - } - - if cfg.Channels.Feishu.AppID != "" && cfg.Channels.Feishu.AppSecret != "" { - seeds = append(seeds, seed{ - name: "feishu/default", channelType: "feishu", display: "Feishu/Lark Bot", - creds: map[string]string{ - "app_id": cfg.Channels.Feishu.AppID, "app_secret": cfg.Channels.Feishu.AppSecret, - "encrypt_key": cfg.Channels.Feishu.EncryptKey, "verification_token": cfg.Channels.Feishu.VerificationToken, - }, - config: map[string]interface{}{"dm_policy": cfg.Channels.Feishu.DMPolicy, "domain": cfg.Channels.Feishu.Domain}, - }) - } - - if cfg.Channels.Zalo.Token != "" { - seeds = append(seeds, seed{ - name: "zalo_oa/default", channelType: "zalo_oa", display: "Zalo OA", - creds: map[string]string{"token": cfg.Channels.Zalo.Token, "webhook_secret": cfg.Channels.Zalo.WebhookSecret}, - config: map[string]interface{}{"dm_policy": cfg.Channels.Zalo.DMPolicy}, - }) - } - - if cfg.Channels.WhatsApp.BridgeURL != "" { - seeds = append(seeds, seed{ - name: "whatsapp/default", channelType: "whatsapp", display: "WhatsApp", - creds: map[string]string{"bridge_url": cfg.Channels.WhatsApp.BridgeURL}, - config: map[string]interface{}{"dm_policy": cfg.Channels.WhatsApp.DMPolicy, "group_policy": cfg.Channels.WhatsApp.GroupPolicy}, - }) - } - - seeded := 0 - for _, s := range seeds { - credsJSON, _ := json.Marshal(s.creds) - cfgJSON, _ := json.Marshal(s.config) - - inst := &store.ChannelInstanceData{ - Name: s.name, - DisplayName: s.display, - ChannelType: s.channelType, - AgentID: defaultAgentID, - Credentials: credsJSON, - Config: cfgJSON, - Enabled: true, - CreatedBy: ownerID, - } - - if err := stores.ChannelInstances.Create(ctx, inst); err != nil { - slog.Debug("seed channel instance skipped (may already exist)", "name", s.name, "error", err) - continue - } - seeded++ - slog.Info("seeded channel instance", "name", s.name, "type", s.channelType) - } - - if seeded > 0 { - fmt.Printf(" Seeded %d channel instance(s)\n", seeded) - } -} - -// seedConfigSecrets saves non-LLM/non-channel secrets to the config_secrets table. -// These are secrets that don't belong in llm_providers or channel_instances tables. -func seedConfigSecrets(ctx context.Context, stores *store.Stores, cfg *config.Config) { - if stores.ConfigSecrets == nil { - return - } - - secrets := cfg.ExtractDBSecrets() - seeded := 0 - for key, value := range secrets { - if err := stores.ConfigSecrets.Set(ctx, key, value); err != nil { - slog.Debug("seed config secret failed", "key", key, "error", err) - continue - } - seeded++ - } - - if seeded > 0 { - slog.Info("seeded config secrets", "count", seeded) - } -} - // defaultPlaceholderProviders defines disabled placeholder providers seeded for // UI discoverability. Users can later enable and configure them via the dashboard. var defaultPlaceholderProviders = []store.LLMProviderData{ @@ -291,21 +33,28 @@ var defaultPlaceholderProviders = []store.LLMProviderData{ {Name: "alicloud-sub", DisplayName: "AliCloud Sub", ProviderType: store.ProviderBailian, APIBase: "https://coding-intl.dashscope.aliyuncs.com/v1", Enabled: false}, } -// seedDefaultPlaceholders inserts disabled placeholder providers so they -// appear in the UI for easy configuration. Idempotent: UNIQUE(name) constraint -// skips duplicates, and providers whose api_base already exists are skipped -// to avoid overwriting user-configured entries. -func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) { - if stores.Providers == nil { - return +// seedOnboardPlaceholders opens a PG store and seeds disabled placeholder providers +// so they appear in the UI for easy configuration. +func seedOnboardPlaceholders(dsn string) error { + storeCfg := store.StoreConfig{ + PostgresDSN: dsn, + EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"), + } + stores, err := pg.NewPGStores(storeCfg) + if err != nil { + return fmt.Errorf("open PG stores: %w", err) } - // Build a set of existing api_base values to avoid seeding a placeholder - // when a user-configured provider already uses that base URL. + if stores.Providers == nil { + return nil + } + + ctx := context.Background() + + // Build a set of existing api_base values to avoid overwriting user-configured entries. existing, err := stores.Providers.ListProviders(ctx) if err != nil { - slog.Debug("seedDefaultPlaceholders: list providers failed", "error", err) - return + return fmt.Errorf("list providers: %w", err) } existingBases := make(map[string]bool, len(existing)) for _, p := range existing { @@ -316,11 +65,9 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) { seeded := 0 for _, ph := range defaultPlaceholderProviders { - // Skip if a provider with this api_base already exists if ph.APIBase != "" && existingBases[ph.APIBase] { continue } - p := ph // copy if err := stores.Providers.CreateProvider(ctx, &p); err != nil { slog.Debug("seed placeholder skipped (may already exist)", "name", ph.Name, "error", err) @@ -330,7 +77,7 @@ func seedDefaultPlaceholders(ctx context.Context, stores *store.Stores) { } if seeded > 0 { - fmt.Printf(" Seeded %d placeholder provider(s)\n", seeded) + slog.Info("seeded placeholder providers", "count", seeded) } + return nil } - diff --git a/cmd/onboard_models.go b/cmd/onboard_models.go deleted file mode 100644 index 0e5f5bc2..00000000 --- a/cmd/onboard_models.go +++ /dev/null @@ -1,123 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" - "time" -) - -// topOpenRouterProviders is a curated whitelist of the best model providers on OpenRouter. -var topOpenRouterProviders = map[string]bool{ - "anthropic": true, - "openai": true, - "google": true, - "mistralai": true, - "deepseek": true, - "meta-llama": true, - "qwen": true, - "x-ai": true, - "z-ai": true, - "nvidia": true, - "moonshotai": true, - "minimax": true, - "allenai": true, - "cohere": true, - "perplexity": true, - "amazon": true, - "microsoft": true, - "ai21": true, - "nousresearch": true, - "baidu": true, -} - -type openRouterModel struct { - ID string `json:"id"` - Name string `json:"name"` - ContextLength int `json:"context_length"` -} - -func fetchOpenRouterModels() ([]openRouterModel, error) { - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get("https://openrouter.ai/api/v1/models") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var result struct { - Data []openRouterModel `json:"data"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, err - } - - sort.Slice(result.Data, func(i, j int) bool { - return result.Data[i].ID < result.Data[j].ID - }) - - return result.Data, nil -} - -func filterTopProviderModels(models []openRouterModel) []openRouterModel { - var filtered []openRouterModel - for _, m := range models { - parts := strings.SplitN(m.ID, "/", 2) - if len(parts) == 2 && topOpenRouterProviders[parts[0]] { - filtered = append(filtered, m) - } - } - return filtered -} - -func formatCtx(ctxLen int) string { - if ctxLen >= 1_000_000 { - return fmt.Sprintf("%.0fM ctx", float64(ctxLen)/1_000_000) - } - if ctxLen >= 1000 { - return fmt.Sprintf("%dK ctx", ctxLen/1000) - } - return fmt.Sprintf("%d ctx", ctxLen) -} - -// fallbackModels used when API is unreachable. -var fallbackModels = []openRouterModel{ - {ID: "anthropic/claude-sonnet-4-5-20250929", Name: "Claude Sonnet 4.5", ContextLength: 200000}, - {ID: "openai/gpt-4o", Name: "GPT-4o", ContextLength: 128000}, - {ID: "google/gemini-2.0-flash-001", Name: "Gemini 2.0 Flash", ContextLength: 1048576}, - {ID: "deepseek/deepseek-chat-v3-0324", Name: "DeepSeek V3", ContextLength: 131072}, -} - -// buildOpenRouterModelOptions builds select options from OpenRouter models. -// Pre-fetches from API; uses fallback on failure. -func buildOpenRouterModelOptions() []SelectOption[string] { - models, err := fetchOpenRouterModels() - if err != nil || len(models) == 0 { - models = fallbackModels - } else { - models = filterTopProviderModels(models) - if len(models) == 0 { - models = fallbackModels - } - } - - options := make([]SelectOption[string], 0, len(models)+1) - for _, m := range models { - label := fmt.Sprintf("%-50s (%s)", m.ID, formatCtx(m.ContextLength)) - options = append(options, SelectOption[string]{Label: label, Value: m.ID}) - } - options = append(options, SelectOption[string]{Label: "Enter custom model ID...", Value: "__custom__"}) - return options -} diff --git a/cmd/onboard_resolve.go b/cmd/onboard_resolve.go deleted file mode 100644 index df5b61fc..00000000 --- a/cmd/onboard_resolve.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// resolveProviderAPIKey extracts the API key for a provider from the config. -func resolveProviderAPIKey(cfg *config.Config, providerName string) string { - switch providerName { - case "openrouter": - return cfg.Providers.OpenRouter.APIKey - case "anthropic": - return cfg.Providers.Anthropic.APIKey - case "openai": - return cfg.Providers.OpenAI.APIKey - case "groq": - return cfg.Providers.Groq.APIKey - case "deepseek": - return cfg.Providers.DeepSeek.APIKey - case "gemini": - return cfg.Providers.Gemini.APIKey - case "mistral": - return cfg.Providers.Mistral.APIKey - case "xai": - return cfg.Providers.XAI.APIKey - case "minimax": - return cfg.Providers.MiniMax.APIKey - case "cohere": - return cfg.Providers.Cohere.APIKey - case "perplexity": - return cfg.Providers.Perplexity.APIKey - default: - return "" - } -} - -// resolveProviderType maps a provider name to its store.Provider* type constant. -func resolveProviderType(name string) string { - switch name { - case "anthropic": - return store.ProviderAnthropicNative - case "gemini": - return store.ProviderGeminiNative - case "minimax": - return store.ProviderMiniMax - case "openrouter": - return store.ProviderOpenRouter - case "groq": - return store.ProviderGroq - case "deepseek": - return store.ProviderDeepSeek - case "mistral": - return store.ProviderMistral - case "xai": - return store.ProviderXAI - case "cohere": - return store.ProviderCohere - case "perplexity": - return store.ProviderPerplexity - case "yescale": - return store.ProviderYesScale - default: - return store.ProviderOpenAICompat - } -} - -// resolveProviderAPIBase returns the default API base URL for known providers. -func resolveProviderAPIBase(providerName string) string { - switch providerName { - case "openrouter": - return "https://openrouter.ai/api/v1" - case "anthropic": - return "https://api.anthropic.com/v1" - case "openai": - return "https://api.openai.com/v1" - case "groq": - return "https://api.groq.com/openai/v1" - case "deepseek": - return "https://api.deepseek.com/v1" - case "gemini": - return "https://generativelanguage.googleapis.com/v1beta/openai" - case "mistral": - return "https://api.mistral.ai/v1" - case "xai": - return "https://api.x.ai/v1" - case "minimax": - return "https://api.minimax.io/v1" - case "cohere": - return "https://api.cohere.com/v2" - case "perplexity": - return "https://api.perplexity.ai" - case "yescale": - return "https://api.yescale.one/v1" - default: - return "" - } -} diff --git a/cmd/onboard_tts.go b/cmd/onboard_tts.go deleted file mode 100644 index 90db4af1..00000000 --- a/cmd/onboard_tts.go +++ /dev/null @@ -1,57 +0,0 @@ -package cmd - -import "fmt" - -// promptTTSConfig runs the TTS configuration prompts sequentially. -// Returns early on any error (e.g. user pressed Ctrl+C). -func promptTTSConfig(ttsProvider, ttsAPIKey, ttsGroupID, ttsAutoMode *string) error { - // TTS provider - provider, err := promptSelect("TTS Provider (Text-to-Speech for agent replies)", []SelectOption[string]{ - {"None (disabled)", "none"}, - {"OpenAI (gpt-4o-mini-tts, alloy voice)", "openai"}, - {"ElevenLabs (high quality, multilingual)", "elevenlabs"}, - {"MiniMax (speech-02-hd, 300+ voices)", "minimax"}, - {"Edge (free Microsoft Edge TTS, no API key)", "edge"}, - }, 0) - if err != nil { - return err - } - *ttsProvider = provider - - if *ttsProvider == "none" { - return nil - } - - // TTS auto-apply mode - autoMode, err := promptSelect("TTS auto-apply mode", []SelectOption[string]{ - {"Off (agent can use tts tool manually)", "off"}, - {"Always (all replies get audio)", "always"}, - {"Inbound (only when user sends voice/audio)", "inbound"}, - {"Tagged (only when reply has [[tts]] tag)", "tagged"}, - }, 0) - if err != nil { - return err - } - *ttsAutoMode = autoMode - - // API key (not needed for edge) - if *ttsProvider != "edge" { - key, err := promptPassword("TTS API Key", "Leave empty to reuse your chat provider's API key (if same provider)") - if err != nil { - return err - } - *ttsAPIKey = key - } - - // MiniMax Group ID - if *ttsProvider == "minimax" { - groupID, err := promptString("MiniMax Group ID", "Only needed for MiniMax TTS", *ttsGroupID) - if err != nil { - return err - } - *ttsGroupID = groupID - } - - fmt.Println() - return nil -} diff --git a/cmd/onboard_verify.go b/cmd/onboard_verify.go deleted file mode 100644 index e7b1fc7d..00000000 --- a/cmd/onboard_verify.go +++ /dev/null @@ -1,173 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/nextlevelbuilder/goclaw/internal/config" - "github.com/nextlevelbuilder/goclaw/internal/providers" -) - -// providerVerifyError holds the result of a provider connectivity probe. -type providerVerifyError struct { - fatal bool // true = bad credentials, block startup - message string // human-readable description -} - -func (e *providerVerifyError) Error() string { return e.message } - -// newProviderForVerify instantiates a temporary provider for key verification. -// Mirrors the logic in registerProviders (gateway_providers.go) so auth headers, -// base URL overrides, and custom chat paths are handled correctly. -func newProviderForVerify(cfg *config.Config, name string) providers.Provider { - apiKey := resolveProviderAPIKey(cfg, name) - apiBase := resolveProviderAPIBase(name) - - switch name { - case "anthropic": - if cfg.Providers.Anthropic.APIBase != "" { - apiBase = cfg.Providers.Anthropic.APIBase - } - return providers.NewAnthropicProvider(apiKey, providers.WithAnthropicBaseURL(apiBase)) - case "dashscope": - if cfg.Providers.DashScope.APIBase != "" { - apiBase = cfg.Providers.DashScope.APIBase - } - return providers.NewDashScopeProvider(apiKey, apiBase, "") - case "minimax": - return providers.NewOpenAIProvider(name, apiKey, apiBase, "").WithChatPath("/text/chatcompletion_v2") - case "openai": - if cfg.Providers.OpenAI.APIBase != "" { - apiBase = cfg.Providers.OpenAI.APIBase - } - return providers.NewOpenAIProvider(name, apiKey, apiBase, "") - case "bailian": - if cfg.Providers.Bailian.APIBase != "" { - apiBase = cfg.Providers.Bailian.APIBase - } - return providers.NewOpenAIProvider(name, apiKey, apiBase, "") - default: - return providers.NewOpenAIProvider(name, apiKey, apiBase, "") - } -} - -// verifyProviderConnectivity checks whether a provider's API key is valid by -// sending a minimal Chat request through the provider layer. This reuses the -// same auth headers, base URLs, and HTTP client as the real provider calls. -// -// Strategy: provider.Chat() with message "hi", max_tokens=1. -// - 401/403 HTTPError → invalid API key (fatal) -// - Any other error → non-fatal warning (transient/config issue) -// - Success → key is valid -func verifyProviderConnectivity(cfg *config.Config, providerName string) *providerVerifyError { - apiKey := resolveProviderAPIKey(cfg, providerName) - if apiKey == "" { - return nil - } - - apiBase := resolveProviderAPIBase(providerName) - if apiBase == "" { - return nil // custom/unknown provider — skip - } - - prov := newProviderForVerify(cfg, providerName) - - model := "" - if pi, ok := providerMap[providerName]; ok { - model = pi.modelHint - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - _, err := prov.Chat(ctx, providers.ChatRequest{ - Messages: []providers.Message{{Role: "user", Content: "hi"}}, - Model: model, - Options: map[string]interface{}{"max_tokens": 1}, - }) - if err != nil { - var httpErr *providers.HTTPError - if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) { - return &providerVerifyError{ - fatal: true, - message: fmt.Sprintf("%s returned %d — invalid API key", providerName, httpErr.Status), - } - } - // Non-auth errors: transient network issue, bad model, rate limit, etc. - return &providerVerifyError{ - fatal: false, - message: fmt.Sprintf("%s: %s", providerName, friendlyProviderError(err)), - } - } - - return nil -} - -// verifyAllProviders checks connectivity for every provider that has an API key. -// Only the primary provider's auth failure is fatal (blocks bootstrap). -// Secondary provider failures are logged as warnings. -func verifyAllProviders(cfg *config.Config, primaryProvider string) []string { - var fatalErrors []string - - for _, name := range providerPriority { - apiKey := resolveProviderAPIKey(cfg, name) - if apiKey == "" { - continue - } - - verr := verifyProviderConnectivity(cfg, name) - if verr == nil { - slog.Info("provider connectivity verified", "provider", name) - fmt.Printf(" %s: OK\n", name) - continue - } - - if verr.fatal && name == primaryProvider { - slog.Error("primary provider key invalid", "provider", name, "error", verr.message) - fmt.Printf(" %s: FAILED — %s\n", name, verr.message) - fatalErrors = append(fatalErrors, fmt.Sprintf("%s: %s", name, verr.message)) - } else if verr.fatal { - slog.Warn("secondary provider key invalid (continuing)", "provider", name, "error", verr.message) - fmt.Printf(" %s: WARNING — %s (non-primary, skipping)\n", name, verr.message) - } else { - slog.Warn("provider connectivity warning", "provider", name, "warning", verr.message) - fmt.Printf(" %s: WARNING — %s\n", name, verr.message) - } - } - - return fatalErrors -} - -// friendlyProviderError extracts a human-readable message from provider errors. -func friendlyProviderError(err error) string { - msg := err.Error() - - // Try to extract "message" field from embedded JSON error blobs. - if idx := strings.Index(msg, `"message"`); idx >= 0 { - rest := msg[idx:] - if start := strings.Index(rest, `:`); start >= 0 { - rest = strings.TrimLeft(rest[start+1:], " ") - if len(rest) > 0 && rest[0] == '"' { - rest = rest[1:] - if end := strings.Index(rest, `"`); end >= 0 && rest[:end] != "" { - return rest[:end] - } - } - } - } - - // Strip "HTTP NNN: provider: " prefix. - if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx < len(msg)-2 { - suffix := msg[idx+2:] - if strings.HasPrefix(suffix, "{") { - return "request rejected by provider" - } - return suffix - } - - return msg -} diff --git a/cmd/onboard_zalo.go b/cmd/onboard_zalo.go deleted file mode 100644 index d05a165a..00000000 --- a/cmd/onboard_zalo.go +++ /dev/null @@ -1,30 +0,0 @@ -package cmd - -import "fmt" - -// promptZaloConfig runs the Zalo OA Bot configuration prompts sequentially. -func promptZaloConfig(zaloToken, zaloDMPolicy *string) error { - fmt.Println("\n ── Zalo OA Configuration ──") - - val, err := promptPassword("Zalo Bot API Token", "From the Zalo OA developer dashboard") - if err != nil { - return err - } - if val != "" { - *zaloToken = val - } - - // DM Policy - policy, err := promptSelect("Zalo DM Policy — How to handle direct messages", []SelectOption[string]{ - {"Pairing (require approval code)", "pairing"}, - {"Open (anyone can chat)", "open"}, - {"Allowlist (only allow_from IDs)", "allowlist"}, - {"Disabled (reject all DMs)", "disabled"}, - }, 0) - if err != nil { - return err - } - *zaloDMPolicy = policy - - return nil -}