ScuttleBot

scuttlebot / cmd / scuttlectl / cmd_setup.go
Source Blame History 297 lines
5ac549c… lmata 1 package main
5ac549c… lmata 2
5ac549c… lmata 3 import (
5ac549c… lmata 4 "bufio"
5ac549c… lmata 5 "fmt"
5ac549c… lmata 6 "os"
5ac549c… lmata 7 "strconv"
5ac549c… lmata 8 "strings"
5ac549c… lmata 9
5ac549c… lmata 10 "gopkg.in/yaml.v3"
5ac549c… lmata 11 )
5ac549c… lmata 12
5ac549c… lmata 13 // cmdSetup runs an interactive wizard that writes scuttlebot.yaml.
5ac549c… lmata 14 // It does not require a running server or an API token.
5ac549c… lmata 15 func cmdSetup(path string) {
5ac549c… lmata 16 s := newSetupScanner()
5ac549c… lmata 17
5ac549c… lmata 18 fmt.Println()
5ac549c… lmata 19 fmt.Println(" scuttlebot setup wizard")
5ac549c… lmata 20 fmt.Println(" ─────────────────────────────────────────")
5ac549c… lmata 21 fmt.Println(" Answers in [brackets] are the default — press Enter to accept.")
5ac549c… lmata 22 fmt.Println()
5ac549c… lmata 23
5ac549c… lmata 24 // Check for existing file.
5ac549c… lmata 25 if _, err := os.Stat(path); err == nil {
5ac549c… lmata 26 if !s.confirm(fmt.Sprintf(" %s already exists — overwrite?", path), false) {
5ac549c… lmata 27 fmt.Println(" Aborted.")
5ac549c… lmata 28 os.Exit(0)
5ac549c… lmata 29 }
5ac549c… lmata 30 }
5ac549c… lmata 31
5ac549c… lmata 32 cfg := buildConfig(s)
5ac549c… lmata 33
5ac549c… lmata 34 data, err := yaml.Marshal(cfg)
5ac549c… lmata 35 if err != nil {
5ac549c… lmata 36 fmt.Fprintln(os.Stderr, "error encoding yaml:", err)
5ac549c… lmata 37 os.Exit(1)
5ac549c… lmata 38 }
5ac549c… lmata 39
5ac549c… lmata 40 if err := os.WriteFile(path, data, 0600); err != nil {
5ac549c… lmata 41 fmt.Fprintln(os.Stderr, "error writing config:", err)
5ac549c… lmata 42 os.Exit(1)
5ac549c… lmata 43 }
5ac549c… lmata 44
5ac549c… lmata 45 fmt.Println()
5ac549c… lmata 46 fmt.Printf(" ✓ wrote %s\n", path)
5ac549c… lmata 47 fmt.Println()
5ac549c… lmata 48 fmt.Println(" Next steps:")
5ac549c… lmata 49 fmt.Println(" ./run.sh start # start scuttlebot")
5ac549c… lmata 50 fmt.Println(" ./run.sh token # print API token")
5ac549c… lmata 51 fmt.Println(" open http://localhost:8080/ui/")
5ac549c… lmata 52 fmt.Println()
5ac549c… lmata 53 }
5ac549c… lmata 54
5ac549c… lmata 55 func buildConfig(s *setupScanner) map[string]any {
5ac549c… lmata 56 cfg := map[string]any{}
5ac549c… lmata 57
5ac549c… lmata 58 // ── network ──────────────────────────────────────────────────────────────
5ac549c… lmata 59 printSection("IRC / network")
5ac549c… lmata 60
5ac549c… lmata 61 networkName := s.ask(" IRC network name", "scuttlebot")
0e244d2… lmata 62 serverName := s.ask(" IRC server hostname", "irc.scuttlebot.local")
0e244d2… lmata 63 ircAddr := s.ask(" IRC listen address", "127.0.0.1:6667")
0e244d2… lmata 64 apiAddr := s.ask(" HTTP API listen address", ":8080")
5ac549c… lmata 65
5ac549c… lmata 66 cfg["ergo"] = map[string]any{
5ac549c… lmata 67 "network_name": networkName,
5ac549c… lmata 68 "server_name": serverName,
5ac549c… lmata 69 "irc_addr": ircAddr,
5ac549c… lmata 70 }
5ac549c… lmata 71 cfg["api_addr"] = apiAddr
5ac549c… lmata 72
5ac549c… lmata 73 // ── TLS ──────────────────────────────────────────────────────────────────
5ac549c… lmata 74 printSection("TLS / HTTPS (skip for local/dev)")
5ac549c… lmata 75
5ac549c… lmata 76 if s.confirm(" Enable Let's Encrypt TLS?", false) {
5ac549c… lmata 77 domain := s.ask(" Domain name", "")
0e244d2… lmata 78 email := s.ask(" Email for cert expiry notices", "")
5ac549c… lmata 79 cfg["tls"] = map[string]any{
5ac549c… lmata 80 "domain": domain,
5ac549c… lmata 81 "email": email,
5ac549c… lmata 82 "allow_insecure": true,
5ac549c… lmata 83 }
5ac549c… lmata 84 }
5ac549c… lmata 85
5ac549c… lmata 86 // ── bridge ───────────────────────────────────────────────────────────────
5ac549c… lmata 87 printSection("web chat bridge")
5ac549c… lmata 88
5ac549c… lmata 89 channels := s.ask(" Default channels (comma-separated)", "#general")
5ac549c… lmata 90 chList := splitComma(channels)
5ac549c… lmata 91 cfg["bridge"] = map[string]any{
5ac549c… lmata 92 "enabled": true,
5ac549c… lmata 93 "channels": chList,
5ac549c… lmata 94 "buffer_size": 200,
5ac549c… lmata 95 }
5ac549c… lmata 96
5ac549c… lmata 97 // ── LLM backends ─────────────────────────────────────────────────────────
5ac549c… lmata 98 printSection("LLM backends (for oracle summarisation)")
5ac549c… lmata 99
5ac549c… lmata 100 var backends []map[string]any
5ac549c… lmata 101 for {
5ac549c… lmata 102 if !s.confirm(" Add an LLM backend?", len(backends) == 0) {
5ac549c… lmata 103 break
5ac549c… lmata 104 }
5ac549c… lmata 105 b := buildBackend(s)
5ac549c… lmata 106 backends = append(backends, b)
5ac549c… lmata 107 }
5ac549c… lmata 108 if len(backends) > 0 {
5ac549c… lmata 109 cfg["llm"] = map[string]any{"backends": backends}
5ac549c… lmata 110 }
5ac549c… lmata 111
5ac549c… lmata 112 // ── logging ───────────────────────────────────────────────────────────────
5ac549c… lmata 113 printSection("message logging (scribe bot)")
5ac549c… lmata 114
5ac549c… lmata 115 if s.confirm(" Enable scribe message logging?", true) {
0e244d2… lmata 116 logDir := s.ask(" Log directory", "./data/logs/scribe")
0e244d2… lmata 117 format := s.choice(" Format", []string{"jsonl", "csv", "text"}, "jsonl")
5ac549c… lmata 118 rotatef := s.choice(" Rotation", []string{"none", "daily", "weekly", "monthly", "size"}, "daily")
5ac549c… lmata 119 // Stored as scribe bot policy — just print a note, actual policy is in policies.json
0e244d2… lmata 120 _ = logDir
0e244d2… lmata 121 _ = format
0e244d2… lmata 122 _ = rotatef
5ac549c… lmata 123 fmt.Printf("\n Note: scribe is enabled via the web UI (settings → system behaviors).\n")
5ac549c… lmata 124 fmt.Printf(" Set dir=%s format=%s rotation=%s in oracle's behavior config.\n\n", logDir, format, rotatef)
5ac549c… lmata 125 }
5ac549c… lmata 126
5ac549c… lmata 127 return cfg
5ac549c… lmata 128 }
5ac549c… lmata 129
5ac549c… lmata 130 func buildBackend(s *setupScanner) map[string]any {
5ac549c… lmata 131 backends := []string{
5ac549c… lmata 132 "openai", "anthropic", "gemini", "bedrock", "ollama",
5ac549c… lmata 133 "openrouter", "groq", "together", "fireworks", "mistral",
5ac549c… lmata 134 "deepseek", "xai", "cerebras", "litellm", "lmstudio", "vllm", "localai",
5ac549c… lmata 135 }
5ac549c… lmata 136 backendType := s.choice(" Backend type", backends, "openai")
5ac549c… lmata 137 name := s.ask(" Backend name (identifier)", backendType+"-1")
5ac549c… lmata 138
5ac549c… lmata 139 b := map[string]any{
5ac549c… lmata 140 "name": name,
5ac549c… lmata 141 "backend": backendType,
5ac549c… lmata 142 }
5ac549c… lmata 143
5ac549c… lmata 144 switch backendType {
5ac549c… lmata 145 case "bedrock":
5ac549c… lmata 146 b["region"] = s.ask(" AWS region", "us-east-1")
5ac549c… lmata 147 if s.confirm(" Use static AWS credentials? (No = IAM role auto-detected)", false) {
0e244d2… lmata 148 b["aws_key_id"] = s.ask(" AWS access key ID", "")
5ac549c… lmata 149 b["aws_secret_key"] = s.ask(" AWS secret access key", "")
5ac549c… lmata 150 } else {
5ac549c… lmata 151 fmt.Println(" → credentials will be resolved from env vars or instance/task role")
5ac549c… lmata 152 }
5ac549c… lmata 153 b["model"] = s.ask(" Default model", "anthropic.claude-3-5-sonnet-20241022-v2:0")
5ac549c… lmata 154
5ac549c… lmata 155 case "ollama":
5ac549c… lmata 156 b["base_url"] = s.ask(" Ollama base URL", "http://localhost:11434")
0e244d2… lmata 157 b["model"] = s.ask(" Default model", "llama3.2")
5ac549c… lmata 158
5ac549c… lmata 159 case "anthropic":
5ac549c… lmata 160 b["api_key"] = s.secret(" API key")
0e244d2… lmata 161 b["model"] = s.ask(" Default model", "claude-3-5-sonnet-20241022")
5ac549c… lmata 162
5ac549c… lmata 163 case "gemini":
5ac549c… lmata 164 b["api_key"] = s.secret(" API key")
0e244d2… lmata 165 b["model"] = s.ask(" Default model", "gemini-1.5-flash")
5ac549c… lmata 166
5ac549c… lmata 167 default:
5ac549c… lmata 168 b["api_key"] = s.secret(" API key")
0e244d2… lmata 169 b["model"] = s.ask(" Default model", defaultModelFor(backendType))
5ac549c… lmata 170 }
5ac549c… lmata 171
5ac549c… lmata 172 if s.confirm(" Add model allow/block regex filters?", false) {
5ac549c… lmata 173 allow := s.ask(" Allow patterns (comma-separated regex)", "")
5ac549c… lmata 174 block := s.ask(" Block patterns (comma-separated regex)", "")
5ac549c… lmata 175 if allow != "" {
5ac549c… lmata 176 b["allow"] = splitComma(allow)
5ac549c… lmata 177 }
5ac549c… lmata 178 if block != "" {
5ac549c… lmata 179 b["block"] = splitComma(block)
5ac549c… lmata 180 }
5ac549c… lmata 181 }
5ac549c… lmata 182
5ac549c… lmata 183 if s.confirm(" Mark as default backend?", len([]map[string]any{b}) == 0) {
5ac549c… lmata 184 b["default"] = true
5ac549c… lmata 185 }
5ac549c… lmata 186
5ac549c… lmata 187 return b
5ac549c… lmata 188 }
5ac549c… lmata 189
5ac549c… lmata 190 func defaultModelFor(backend string) string {
5ac549c… lmata 191 defaults := map[string]string{
0e244d2… lmata 192 "openai": "gpt-4o-mini",
0e244d2… lmata 193 "openrouter": "openai/gpt-4o-mini",
0e244d2… lmata 194 "groq": "llama-3.3-70b-versatile",
0e244d2… lmata 195 "together": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
0e244d2… lmata 196 "fireworks": "accounts/fireworks/models/llama-v3p3-70b-instruct",
0e244d2… lmata 197 "mistral": "mistral-large-latest",
0e244d2… lmata 198 "deepseek": "deepseek-chat",
0e244d2… lmata 199 "xai": "grok-2",
0e244d2… lmata 200 "cerebras": "llama3.3-70b",
0e244d2… lmata 201 "litellm": "",
0e244d2… lmata 202 "lmstudio": "",
0e244d2… lmata 203 "vllm": "",
0e244d2… lmata 204 "localai": "",
5ac549c… lmata 205 }
5ac549c… lmata 206 if m, ok := defaults[backend]; ok {
5ac549c… lmata 207 return m
5ac549c… lmata 208 }
5ac549c… lmata 209 return ""
5ac549c… lmata 210 }
5ac549c… lmata 211
5ac549c… lmata 212 func printSection(title string) {
5ac549c… lmata 213 fmt.Printf("\n ── %s\n\n", title)
5ac549c… lmata 214 }
5ac549c… lmata 215
5ac549c… lmata 216 // setupScanner wraps a line reader with prompt helpers.
5ac549c… lmata 217 type setupScanner struct {
5ac549c… lmata 218 scanner *bufio.Scanner
5ac549c… lmata 219 }
5ac549c… lmata 220
5ac549c… lmata 221 func newSetupScanner() *setupScanner {
5ac549c… lmata 222 return &setupScanner{scanner: bufio.NewScanner(os.Stdin)}
5ac549c… lmata 223 }
5ac549c… lmata 224
5ac549c… lmata 225 func (s *setupScanner) readLine() string {
5ac549c… lmata 226 if s.scanner.Scan() {
5ac549c… lmata 227 return strings.TrimSpace(s.scanner.Text())
5ac549c… lmata 228 }
5ac549c… lmata 229 return ""
5ac549c… lmata 230 }
5ac549c… lmata 231
5ac549c… lmata 232 func (s *setupScanner) ask(prompt, def string) string {
5ac549c… lmata 233 if def != "" {
5ac549c… lmata 234 fmt.Printf("%s [%s]: ", prompt, def)
5ac549c… lmata 235 } else {
5ac549c… lmata 236 fmt.Printf("%s: ", prompt)
5ac549c… lmata 237 }
5ac549c… lmata 238 v := s.readLine()
5ac549c… lmata 239 if v == "" {
5ac549c… lmata 240 return def
5ac549c… lmata 241 }
5ac549c… lmata 242 return v
5ac549c… lmata 243 }
5ac549c… lmata 244
5ac549c… lmata 245 func (s *setupScanner) secret(prompt string) string {
5ac549c… lmata 246 fmt.Printf("%s: ", prompt)
5ac549c… lmata 247 return s.readLine()
5ac549c… lmata 248 }
5ac549c… lmata 249
5ac549c… lmata 250 func (s *setupScanner) confirm(prompt string, def bool) bool {
5ac549c… lmata 251 yn := "y/N"
5ac549c… lmata 252 if def {
5ac549c… lmata 253 yn = "Y/n"
5ac549c… lmata 254 }
5ac549c… lmata 255 fmt.Printf("%s [%s]: ", prompt, yn)
5ac549c… lmata 256 v := strings.ToLower(strings.TrimSpace(s.readLine()))
5ac549c… lmata 257 if v == "" {
5ac549c… lmata 258 return def
5ac549c… lmata 259 }
5ac549c… lmata 260 return v == "y" || v == "yes"
5ac549c… lmata 261 }
5ac549c… lmata 262
5ac549c… lmata 263 func (s *setupScanner) choice(prompt string, options []string, def string) string {
5ac549c… lmata 264 fmt.Printf("%s\n", prompt)
5ac549c… lmata 265 for i, o := range options {
5ac549c… lmata 266 marker := " "
5ac549c… lmata 267 if o == def {
5ac549c… lmata 268 marker = "→ "
5ac549c… lmata 269 }
5ac549c… lmata 270 fmt.Printf(" %s%d) %s\n", marker, i+1, o)
5ac549c… lmata 271 }
5ac549c… lmata 272 fmt.Printf(" choice [%s]: ", def)
5ac549c… lmata 273 v := strings.TrimSpace(s.readLine())
5ac549c… lmata 274 if v == "" {
5ac549c… lmata 275 return def
5ac549c… lmata 276 }
5ac549c… lmata 277 // Accept number or name.
5ac549c… lmata 278 if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= len(options) {
5ac549c… lmata 279 return options[n-1]
5ac549c… lmata 280 }
5ac549c… lmata 281 for _, o := range options {
5ac549c… lmata 282 if strings.EqualFold(o, v) {
5ac549c… lmata 283 return o
5ac549c… lmata 284 }
5ac549c… lmata 285 }
5ac549c… lmata 286 return def
5ac549c… lmata 287 }
5ac549c… lmata 288
5ac549c… lmata 289 func splitComma(s string) []string {
5ac549c… lmata 290 var out []string
5ac549c… lmata 291 for _, p := range strings.Split(s, ",") {
5ac549c… lmata 292 if p = strings.TrimSpace(p); p != "" {
5ac549c… lmata 293 out = append(out, p)
5ac549c… lmata 294 }
5ac549c… lmata 295 }
5ac549c… lmata 296 return out
5ac549c… lmata 297 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button