ScuttleBot

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

Keyboard Shortcuts

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