feat: interactive CLI wizard for zero-config onboarding (#350)
Implement an interactive TUI setup wizard that launches automatically when no config file is found. Guides users through provider, model, and channel setup in ~1 minute. Features: - Auto-detect environment (Ollama, network, NPU hardware) - Arrow-key interactive menus with vim j/k support - Masked API key input via readline - Dynamic model list fetching from provider APIs: OpenRouter, OpenAI, Anthropic, Gemini, Groq, Zhipu, Ollama/vLLM - Searchable model selector (type-to-filter) for large lists (>20 models) - Smart defaults based on detected environment - Progress bar and info boxes for guided experience - Graceful fallback for non-interactive terminals - Generates valid config.json and offers to auto-start the agent Closes #350
This commit is contained in:
parent
a961a2df87
commit
117398c2b9
3 changed files with 1406 additions and 0 deletions
|
|
@ -121,6 +121,9 @@ func copyDirectory(src, dst string) error {
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
if maybeRunZeroConfigWizard() {
|
||||||
|
return
|
||||||
|
}
|
||||||
printHelp()
|
printHelp()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1270
cmd/picoclaw/wizard.go
Normal file
1270
cmd/picoclaw/wizard.go
Normal file
File diff suppressed because it is too large
Load diff
133
cmd/picoclaw/wizard_test.go
Normal file
133
cmd/picoclaw/wizard_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultConfigSearchPaths(t *testing.T) {
|
||||||
|
home := "/tmp/home-test"
|
||||||
|
paths := defaultConfigSearchPaths(home)
|
||||||
|
if len(paths) < 4 {
|
||||||
|
t.Fatalf("expected multiple config search paths, got %d", len(paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
hasHomeConfig := false
|
||||||
|
for _, p := range paths {
|
||||||
|
if p == filepath.Join(home, ".picoclaw", "config.json") {
|
||||||
|
hasHomeConfig = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasHomeConfig {
|
||||||
|
t.Fatal("expected home config path in defaultConfigSearchPaths")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasConfigInPaths(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
missing := filepath.Join(dir, "missing.json")
|
||||||
|
if hasConfigInPaths([]string{missing}) {
|
||||||
|
t.Fatal("expected false when config does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"ok":true}`), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write temp config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasConfigInPaths([]string{missing, cfgPath}) {
|
||||||
|
t.Fatal("expected true when one path exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSV(t *testing.T) {
|
||||||
|
items := parseCSV(" 123, ,abc, xyz ")
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("expected 3 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0] != "123" || items[1] != "abc" || items[2] != "xyz" {
|
||||||
|
t.Fatalf("unexpected parse result: %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisibleLen(t *testing.T) {
|
||||||
|
if got := visibleLen("hello"); got != 5 {
|
||||||
|
t.Fatalf("expected 5, got %d", got)
|
||||||
|
}
|
||||||
|
// ANSI escape codes should not count
|
||||||
|
if got := visibleLen("\033[31mred\033[0m"); got != 3 {
|
||||||
|
t.Fatalf("expected 3 for colored string, got %d", got)
|
||||||
|
}
|
||||||
|
if got := visibleLen(""); got != 0 {
|
||||||
|
t.Fatalf("expected 0 for empty string, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectEnvironment(t *testing.T) {
|
||||||
|
env := detectEnvironment()
|
||||||
|
// Just verify it doesn't panic and returns a struct
|
||||||
|
_ = env.OllamaFound
|
||||||
|
_ = env.NetworkOnline
|
||||||
|
_ = env.NPUDetected
|
||||||
|
_ = env.NPUDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOpenAIModels(t *testing.T) {
|
||||||
|
data := []byte(`{"data":[{"id":"gpt-4o"},{"id":"gpt-4o-mini"},{"id":"gpt-3.5-turbo"}]}`)
|
||||||
|
models, err := parseOpenAIModels(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) != 3 {
|
||||||
|
t.Fatalf("expected 3 models, got %d", len(models))
|
||||||
|
}
|
||||||
|
// Should be sorted
|
||||||
|
if models[0] != "gpt-3.5-turbo" {
|
||||||
|
t.Fatalf("expected sorted first model gpt-3.5-turbo, got %s", models[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOpenAIModelsEmpty(t *testing.T) {
|
||||||
|
data := []byte(`{"data":[]}`)
|
||||||
|
models, err := parseOpenAIModels(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) != 0 {
|
||||||
|
t.Fatalf("expected 0 models, got %d", len(models))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGeminiModels(t *testing.T) {
|
||||||
|
data := []byte(`{"models":[{"name":"models/gemini-2.5-flash"},{"name":"models/gemini-pro"}]}`)
|
||||||
|
models, err := parseGeminiModels(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Fatalf("expected 2 models, got %d", len(models))
|
||||||
|
}
|
||||||
|
// Should strip "models/" prefix and be sorted
|
||||||
|
if models[0] != "gemini-2.5-flash" {
|
||||||
|
t.Fatalf("expected gemini-2.5-flash, got %s", models[0])
|
||||||
|
}
|
||||||
|
if models[1] != "gemini-pro" {
|
||||||
|
t.Fatalf("expected gemini-pro, got %s", models[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOpenAIModelsInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseOpenAIModels([]byte(`not json`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGeminiModelsInvalidJSON(t *testing.T) {
|
||||||
|
_, err := parseGeminiModels([]byte(`{broken`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue