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.
This commit is contained in:
Rahul Bansal 2026-02-21 10:33:49 +05:30
parent 11e001b27c
commit ab0fba27fc

View file

@ -4,11 +4,16 @@
package main package main
import ( import (
"bufio"
"context"
"embed" "embed"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -17,6 +22,72 @@ import (
//go:embed workspace //go:embed workspace
var embeddedFiles embed.FS 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() { func onboard() {
for _, arg := range os.Args[2:] { for _, arg := range os.Args[2:] {
switch arg { 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() 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 { if err := config.SaveConfig(configPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err) fmt.Printf("Error saving config: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// 4. Copy workspace templates
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace) createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo) // 5. Verification
fmt.Println("\nNext steps:") fmt.Println("\nVerifying setup...")
fmt.Println(" 1. Add your API key to", configPath) fmt.Printf(" [\u2713] Config written to %s\n", configPath)
fmt.Println("") fmt.Printf(" [\u2713] Workspace initialized\n")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") if choiceIdx >= 0 {
fmt.Println(" - Ollama: https://ollama.com (local, free)") choice := providerChoices[choiceIdx]
fmt.Println("") if choiceIdx == 0 {
fmt.Println(" See README.md for 17+ supported providers.") // Ollama: check if running
fmt.Println("") verifyOllama()
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") } else if choice.needsAPIKey && apiKey != "" {
fmt.Println("") 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("If something isn't working:")
fmt.Println(" picoclaw doctor Check for common problems") fmt.Println(" picoclaw doctor Diagnose problems")
fmt.Println(" picoclaw doctor --fix Auto-fix what it can") 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 { func copyEmbeddedToTarget(targetDir string) error {