| 1 | package cmd |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | |
| 10 | "parrot/internal/config" |
| 11 | |
| 12 | "github.com/spf13/cobra" |
| 13 | ) |
| 14 | |
| 15 | var configureCmd = &cobra.Command{ |
| 16 | Use: "configure", |
| 17 | Short: "Interactively configure parrot backends and preferences", |
| 18 | Long: "Walk through interactive setup to configure API keys, models, and preferences", |
| 19 | Run: runConfigure, |
| 20 | } |
| 21 | |
| 22 | func init() { |
| 23 | rootCmd.AddCommand(configureCmd) |
| 24 | } |
| 25 | |
| 26 | func runConfigure(cmd *cobra.Command, args []string) { |
| 27 | fmt.Println("🦜 Parrot Configuration Wizard") |
| 28 | fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") |
| 29 | fmt.Println() |
| 30 | |
| 31 | reader := bufio.NewReader(os.Stdin) |
| 32 | |
| 33 | // Load existing config or defaults |
| 34 | cfg, err := config.LoadConfig() |
| 35 | if err != nil { |
| 36 | cfg = config.DefaultConfig() |
| 37 | } |
| 38 | |
| 39 | // 1. Choose config location |
| 40 | configPath := chooseConfigLocation(reader) |
| 41 | |
| 42 | // 2. Configure API backend |
| 43 | fmt.Println("🌐 API Backend Configuration") |
| 44 | fmt.Println("────────────────────────────") |
| 45 | cfg.API.Enabled = askYesNo(reader, "Enable API backend? (recommended)", cfg.API.Enabled) |
| 46 | |
| 47 | if cfg.API.Enabled { |
| 48 | cfg.API.Provider = askChoice(reader, "API Provider", []string{"openai", "anthropic", "custom"}, cfg.API.Provider) |
| 49 | |
| 50 | if cfg.API.Provider == "custom" { |
| 51 | cfg.API.Endpoint = askString(reader, "API Endpoint URL", cfg.API.Endpoint) |
| 52 | } else { |
| 53 | // Set default endpoints |
| 54 | switch cfg.API.Provider { |
| 55 | case "openai": |
| 56 | cfg.API.Endpoint = "https://api.openai.com/v1" |
| 57 | case "anthropic": |
| 58 | cfg.API.Endpoint = "https://api.anthropic.com/v1" |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | cfg.API.APIKey = askString(reader, "API Key", cfg.API.APIKey) |
| 63 | cfg.API.Model = askString(reader, "Model name", cfg.API.Model) |
| 64 | } |
| 65 | |
| 66 | // 3. Configure Local backend |
| 67 | fmt.Println("\n🖥️ Local Backend Configuration") |
| 68 | fmt.Println("────────────────────────────") |
| 69 | cfg.Local.Enabled = askYesNo(reader, "Enable local Ollama backend?", cfg.Local.Enabled) |
| 70 | |
| 71 | if cfg.Local.Enabled { |
| 72 | cfg.Local.Endpoint = askString(reader, "Ollama endpoint", cfg.Local.Endpoint) |
| 73 | availableModels := []string{"phi3.5:3.8b", "llama3.2:3b", "qwen2.5:0.5b", "custom"} |
| 74 | selectedModel := askChoice(reader, "Local model", availableModels, cfg.Local.Model) |
| 75 | |
| 76 | if selectedModel == "custom" { |
| 77 | cfg.Local.Model = askString(reader, "Custom model name", cfg.Local.Model) |
| 78 | } else { |
| 79 | cfg.Local.Model = selectedModel |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // 4. General preferences |
| 84 | fmt.Println("\n⚙️ General Preferences") |
| 85 | fmt.Println("────────────────────────") |
| 86 | personalities := []string{"mild", "sarcastic", "savage"} |
| 87 | if cfg.General.Personality == "" { |
| 88 | cfg.General.Personality = "sarcastic" |
| 89 | } |
| 90 | cfg.General.Personality = askChoice(reader, "Personality level", personalities, cfg.General.Personality) |
| 91 | cfg.General.Debug = askYesNo(reader, "Enable debug mode?", cfg.General.Debug) |
| 92 | cfg.General.FallbackMode = askYesNo(reader, "Use only fallback responses? (disable AI)", cfg.General.FallbackMode) |
| 93 | |
| 94 | // 5. Save configuration |
| 95 | fmt.Println("\n💾 Saving Configuration...") |
| 96 | if err := config.CreateSampleConfig(configPath); err != nil { |
| 97 | fmt.Printf("❌ Error creating config template: %v\n", err) |
| 98 | return |
| 99 | } |
| 100 | |
| 101 | // Load the template and update with user values |
| 102 | if err := saveConfig(cfg, configPath); err != nil { |
| 103 | fmt.Printf("❌ Error saving configuration: %v\n", err) |
| 104 | return |
| 105 | } |
| 106 | |
| 107 | fmt.Printf("✅ Configuration saved to: %s\n", configPath) |
| 108 | |
| 109 | // 6. Next steps |
| 110 | fmt.Println("\n🎯 Next Steps:") |
| 111 | if cfg.API.Enabled && cfg.API.APIKey != "" { |
| 112 | fmt.Println(" • Test API backend: parrot status") |
| 113 | } |
| 114 | if cfg.Local.Enabled { |
| 115 | fmt.Printf(" • Ensure model is available: ollama pull %s\n", cfg.Local.Model) |
| 116 | } |
| 117 | fmt.Println(" • Test parrot: parrot mock \"git push\" \"1\"") |
| 118 | fmt.Println(" • Install shell hooks: parrot install") |
| 119 | } |
| 120 | |
| 121 | func chooseConfigLocation(reader *bufio.Reader) string { |
| 122 | fmt.Println("📁 Configuration Location") |
| 123 | fmt.Println("─────────────────────────") |
| 124 | |
| 125 | paths := config.GetConfigPaths() |
| 126 | fmt.Println("Choose where to save your configuration:") |
| 127 | for i, path := range paths { |
| 128 | fmt.Printf("%d. %s\n", i+1, path) |
| 129 | } |
| 130 | |
| 131 | for { |
| 132 | fmt.Print("Choice [1]: ") |
| 133 | input, _ := reader.ReadString('\n') |
| 134 | input = strings.TrimSpace(input) |
| 135 | |
| 136 | if input == "" { |
| 137 | return paths[0] // Default to first option |
| 138 | } |
| 139 | |
| 140 | choice := 0 |
| 141 | if _, err := fmt.Sscanf(input, "%d", &choice); err == nil && choice >= 1 && choice <= len(paths) { |
| 142 | return paths[choice-1] |
| 143 | } |
| 144 | |
| 145 | fmt.Println("❌ Invalid choice. Please enter a number between 1 and", len(paths)) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func askString(reader *bufio.Reader, prompt, defaultValue string) string { |
| 150 | if defaultValue != "" { |
| 151 | fmt.Printf("%s [%s]: ", prompt, defaultValue) |
| 152 | } else { |
| 153 | fmt.Printf("%s: ", prompt) |
| 154 | } |
| 155 | |
| 156 | input, _ := reader.ReadString('\n') |
| 157 | input = strings.TrimSpace(input) |
| 158 | |
| 159 | if input == "" { |
| 160 | return defaultValue |
| 161 | } |
| 162 | return input |
| 163 | } |
| 164 | |
| 165 | func askYesNo(reader *bufio.Reader, prompt string, defaultValue bool) bool { |
| 166 | defaultStr := "n" |
| 167 | if defaultValue { |
| 168 | defaultStr = "y" |
| 169 | } |
| 170 | |
| 171 | fmt.Printf("%s [%s]: ", prompt, defaultStr) |
| 172 | input, _ := reader.ReadString('\n') |
| 173 | input = strings.ToLower(strings.TrimSpace(input)) |
| 174 | |
| 175 | if input == "" { |
| 176 | return defaultValue |
| 177 | } |
| 178 | |
| 179 | return input == "y" || input == "yes" |
| 180 | } |
| 181 | |
| 182 | func askChoice(reader *bufio.Reader, prompt string, choices []string, defaultValue string) string { |
| 183 | fmt.Printf("%s:\n", prompt) |
| 184 | for i, choice := range choices { |
| 185 | marker := " " |
| 186 | if choice == defaultValue { |
| 187 | marker = "*" |
| 188 | } |
| 189 | fmt.Printf("%s %d. %s\n", marker, i+1, choice) |
| 190 | } |
| 191 | |
| 192 | for { |
| 193 | fmt.Print("Choice: ") |
| 194 | input, _ := reader.ReadString('\n') |
| 195 | input = strings.TrimSpace(input) |
| 196 | |
| 197 | if input == "" && defaultValue != "" { |
| 198 | return defaultValue |
| 199 | } |
| 200 | |
| 201 | choice := 0 |
| 202 | if _, err := fmt.Sscanf(input, "%d", &choice); err == nil && choice >= 1 && choice <= len(choices) { |
| 203 | return choices[choice-1] |
| 204 | } |
| 205 | |
| 206 | // Allow text input too |
| 207 | for _, validChoice := range choices { |
| 208 | if strings.EqualFold(input, validChoice) { |
| 209 | return validChoice |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | fmt.Printf("❌ Invalid choice. Please enter 1-%d or the option name.\n", len(choices)) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func saveConfig(cfg *config.Config, path string) error { |
| 218 | // Ensure directory exists |
| 219 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| 220 | return err |
| 221 | } |
| 222 | |
| 223 | content := generateConfigContent(cfg) |
| 224 | return os.WriteFile(path, []byte(content), 0644) |
| 225 | } |
| 226 | |
| 227 | func generateConfigContent(cfg *config.Config) string { |
| 228 | var content strings.Builder |
| 229 | |
| 230 | content.WriteString("# Parrot Configuration File\n") |
| 231 | content.WriteString("# Generated by: parrot configure\n\n") |
| 232 | |
| 233 | // General section |
| 234 | content.WriteString("[general]\n") |
| 235 | content.WriteString(fmt.Sprintf("personality = \"%s\"\n", cfg.General.Personality)) |
| 236 | content.WriteString(fmt.Sprintf("fallback_mode = %t\n", cfg.General.FallbackMode)) |
| 237 | content.WriteString(fmt.Sprintf("debug = %t\n", cfg.General.Debug)) |
| 238 | content.WriteString("\n") |
| 239 | |
| 240 | // API section |
| 241 | content.WriteString("[api]\n") |
| 242 | content.WriteString(fmt.Sprintf("enabled = %t\n", cfg.API.Enabled)) |
| 243 | content.WriteString(fmt.Sprintf("provider = \"%s\"\n", cfg.API.Provider)) |
| 244 | content.WriteString(fmt.Sprintf("endpoint = \"%s\"\n", cfg.API.Endpoint)) |
| 245 | if cfg.API.APIKey != "" { |
| 246 | content.WriteString(fmt.Sprintf("api_key = \"%s\"\n", cfg.API.APIKey)) |
| 247 | } else { |
| 248 | content.WriteString("# api_key = \"your-api-key-here\"\n") |
| 249 | } |
| 250 | content.WriteString(fmt.Sprintf("model = \"%s\"\n", cfg.API.Model)) |
| 251 | content.WriteString(fmt.Sprintf("timeout = %d\n", cfg.API.Timeout)) |
| 252 | content.WriteString("\n") |
| 253 | |
| 254 | // Local section |
| 255 | content.WriteString("[local]\n") |
| 256 | content.WriteString(fmt.Sprintf("enabled = %t\n", cfg.Local.Enabled)) |
| 257 | content.WriteString(fmt.Sprintf("provider = \"%s\"\n", cfg.Local.Provider)) |
| 258 | content.WriteString(fmt.Sprintf("endpoint = \"%s\"\n", cfg.Local.Endpoint)) |
| 259 | content.WriteString(fmt.Sprintf("model = \"%s\"\n", cfg.Local.Model)) |
| 260 | content.WriteString(fmt.Sprintf("timeout = %d\n", cfg.Local.Timeout)) |
| 261 | |
| 262 | return content.String() |
| 263 | } |