From ab0fba27fc2af963e6ef135257bc14c762c02d2a Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 10:33:49 +0530 Subject: [PATCH] feat: make picoclaw onboard interactive with provider selection and verification Fixes #566: onboarding now prompts for provider choice (Ollama, OpenRouter, Anthropic, OpenAI, DeepSeek, or manual), asks for API key, and sets the default model automatically. Fixes #568: after writing config, verifies API key validity or Ollama reachability with a 5-second HTTP check and reports results. --- cmd/picoclaw/cmd_onboard.go | 217 +++++++++++++++++++++++++++++++++--- 1 file changed, 203 insertions(+), 14 deletions(-) diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go index 469b73336..6c0717720 100644 --- a/cmd/picoclaw/cmd_onboard.go +++ b/cmd/picoclaw/cmd_onboard.go @@ -4,11 +4,16 @@ package main import ( + "bufio" + "context" "embed" "fmt" "io/fs" + "net/http" "os" "path/filepath" + "strings" + "time" "github.com/sipeed/picoclaw/pkg/config" ) @@ -17,6 +22,72 @@ import ( //go:embed workspace var embeddedFiles embed.FS +// providerChoice holds the details for a user-selected provider +type providerChoice struct { + name string + modelName string + needsAPIKey bool + keyPrompt string + validateURL string + validateFunc func(apiKey string) *http.Request +} + +var providerChoices = []providerChoice{ + { + name: "Ollama", + modelName: "llama3", + }, + { + name: "OpenRouter", + modelName: "openrouter-auto", + needsAPIKey: true, + keyPrompt: "Enter your OpenRouter API key: ", + validateURL: "https://openrouter.ai/api/v1/models", + validateFunc: func(apiKey string) *http.Request { + req, _ := http.NewRequest("GET", "https://openrouter.ai/api/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + return req + }, + }, + { + name: "Anthropic", + modelName: "claude-sonnet-4.6", + needsAPIKey: true, + keyPrompt: "Enter your Anthropic API key: ", + validateURL: "https://api.anthropic.com/v1/models", + validateFunc: func(apiKey string) *http.Request { + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/models", nil) + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + return req + }, + }, + { + name: "OpenAI", + modelName: "gpt-5.2", + needsAPIKey: true, + keyPrompt: "Enter your OpenAI API key: ", + validateURL: "https://api.openai.com/v1/models", + validateFunc: func(apiKey string) *http.Request { + req, _ := http.NewRequest("GET", "https://api.openai.com/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + return req + }, + }, + { + name: "DeepSeek", + modelName: "deepseek-chat", + needsAPIKey: true, + keyPrompt: "Enter your DeepSeek API key: ", + validateURL: "https://api.deepseek.com/v1/models", + validateFunc: func(apiKey string) *http.Request { + req, _ := http.NewRequest("GET", "https://api.deepseek.com/v1/models", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + return req + }, + }, +} + func onboard() { for _, arg := range os.Args[2:] { switch arg { @@ -44,30 +115,148 @@ func onboard() { } } + reader := bufio.NewReader(os.Stdin) + + // 1. Show welcome and provider menu + fmt.Printf("\n%s Welcome to PicoClaw!\n\n", logo) + fmt.Println("Choose your AI provider:") + fmt.Println(" 1. Ollama (local, free — no API key needed) [default]") + fmt.Println(" 2. OpenRouter (100+ models, one API key)") + fmt.Println(" 3. Anthropic (Claude)") + fmt.Println(" 4. OpenAI (GPT)") + fmt.Println(" 5. DeepSeek") + fmt.Println(" 6. Skip — I'll configure manually") + fmt.Println() + fmt.Print("Enter choice [1]: ") + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + input = "1" + } + + // 2. Build config based on selection cfg := config.DefaultConfig() + var apiKey string + var choiceIdx int + + switch input { + case "1": + choiceIdx = 0 + case "2": + choiceIdx = 1 + case "3": + choiceIdx = 2 + case "4": + choiceIdx = 3 + case "5": + choiceIdx = 4 + case "6": + // Skip — use defaults as-is + choiceIdx = -1 + default: + fmt.Printf("Unknown choice %q, using default (Ollama).\n", input) + choiceIdx = 0 + } + + if choiceIdx >= 0 { + choice := providerChoices[choiceIdx] + cfg.Agents.Defaults.Model = choice.modelName + + if choice.needsAPIKey { + fmt.Print(choice.keyPrompt) + apiKey, _ = reader.ReadString('\n') + apiKey = strings.TrimSpace(apiKey) + + // Set the API key on the matching model entry + for i := range cfg.ModelList { + if cfg.ModelList[i].ModelName == choice.modelName { + cfg.ModelList[i].APIKey = apiKey + break + } + } + } + } + + // 3. Save config if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) } + // 4. Copy workspace templates workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", logo) - fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) - fmt.Println("") - fmt.Println(" Recommended:") - fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") - fmt.Println(" - Ollama: https://ollama.com (local, free)") - fmt.Println("") - fmt.Println(" See README.md for 17+ supported providers.") - fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") - fmt.Println("") + // 5. Verification + fmt.Println("\nVerifying setup...") + fmt.Printf(" [\u2713] Config written to %s\n", configPath) + fmt.Printf(" [\u2713] Workspace initialized\n") + + if choiceIdx >= 0 { + choice := providerChoices[choiceIdx] + if choiceIdx == 0 { + // Ollama: check if running + verifyOllama() + } else if choice.needsAPIKey && apiKey != "" { + verifyAPIKey(choice, apiKey) + } + } + + // 6. Next steps + fmt.Printf("\n%s PicoClaw is ready!\n\n", logo) + fmt.Println("Try it: picoclaw agent -m \"Hello!\"") + fmt.Println() fmt.Println("If something isn't working:") - fmt.Println(" picoclaw doctor Check for common problems") - fmt.Println(" picoclaw doctor --fix Auto-fix what it can") + fmt.Println(" picoclaw doctor Diagnose problems") + fmt.Println(" picoclaw doctor --fix Auto-fix what it can") +} + +func verifyOllama() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Println(" [!] Ollama doesn't seem to be running at localhost:11434") + fmt.Println(" Start it with: ollama serve") + return + } + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Printf(" [\u2713] Ollama is running (http://localhost:11434)\n") + } else { + fmt.Printf(" [!] Ollama returned HTTP %d at localhost:11434\n", resp.StatusCode) + fmt.Println(" Start it with: ollama serve") + } +} + +func verifyAPIKey(choice providerChoice, apiKey string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req := choice.validateFunc(apiKey) + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf(" [!] Could not reach %s (network error)\n", choice.name) + fmt.Println(" Double-check your connection and try again later.") + return + } + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Printf(" [\u2713] API key valid\n") + } else if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + fmt.Printf(" [!] API key may be invalid (got HTTP %d)\n", resp.StatusCode) + fmt.Println(" Double-check your key and try again later.") + } else { + // Some APIs return non-200 for list but key might still be valid + fmt.Printf(" [!] %s returned HTTP %d (key may still be valid)\n", choice.name, resp.StatusCode) + } } func copyEmbeddedToTarget(targetDir string) error {