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
|
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,32 +115,150 @@ 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 {
|
||||||
// Ensure target directory exists
|
// Ensure target directory exists
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue