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:
parent
11e001b27c
commit
ab0fba27fc
1 changed files with 203 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue