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:
mrbeandev 2026-02-17 18:11:52 +05:30
parent a961a2df87
commit 117398c2b9
3 changed files with 1406 additions and 0 deletions

View file

@ -121,6 +121,9 @@ func copyDirectory(src, dst string) error {
func main() {
if len(os.Args) < 2 {
if maybeRunZeroConfigWizard() {
return
}
printHelp()
os.Exit(1)
}

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
View 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")
}
}