ScuttleBot

scuttlebot / cmd / scuttlectl / main.go
Blame History Raw 702 lines
1
// scuttlectl is the CLI for observing and managing a scuttlebot instance.
2
//
3
// Usage:
4
//
5
// scuttlectl [--url URL] [--token TOKEN] [--json] <command> [args]
6
//
7
// Environment variables:
8
//
9
// SCUTTLEBOT_URL API base URL (default: http://localhost:8080)
10
// SCUTTLEBOT_TOKEN API bearer token
11
package main
12
13
import (
14
"encoding/json"
15
"flag"
16
"fmt"
17
"os"
18
"strings"
19
"text/tabwriter"
20
21
"github.com/conflicthq/scuttlebot/cmd/scuttlectl/internal/apiclient"
22
)
23
24
var version = "dev"
25
26
func main() {
27
// Global flags.
28
urlFlag := flag.String("url", envOr("SCUTTLEBOT_URL", "http://localhost:8080"), "scuttlebot API base URL")
29
tokenFlag := flag.String("token", os.Getenv("SCUTTLEBOT_TOKEN"), "API bearer token")
30
jsonFlag := flag.Bool("json", false, "output raw JSON")
31
versionFlag := flag.Bool("version", false, "print version and exit")
32
flag.Usage = usage
33
flag.Parse()
34
35
if *versionFlag {
36
fmt.Println(version)
37
return
38
}
39
40
args := flag.Args()
41
if len(args) == 0 {
42
usage()
43
os.Exit(1)
44
}
45
46
switch args[0] {
47
case "setup":
48
cfgPath := "scuttlebot.yaml"
49
if len(args) > 1 {
50
cfgPath = args[1]
51
}
52
cmdSetup(cfgPath)
53
return
54
}
55
56
if *tokenFlag == "" {
57
fmt.Fprintln(os.Stderr, "error: API token required (set SCUTTLEBOT_TOKEN or use --token)")
58
os.Exit(1)
59
}
60
61
api := apiclient.New(*urlFlag, *tokenFlag)
62
63
switch args[0] {
64
case "status":
65
cmdStatus(api, *jsonFlag)
66
case "agents", "agent":
67
if len(args) < 2 {
68
fmt.Fprintf(os.Stderr, "usage: scuttlectl %s <subcommand>\n", args[0])
69
os.Exit(1)
70
}
71
switch args[1] {
72
case "list":
73
cmdAgentList(api, *jsonFlag)
74
case "get":
75
requireArgs(args, 3, "scuttlectl agent get <nick>")
76
cmdAgentGet(api, args[2], *jsonFlag)
77
case "register":
78
requireArgs(args, 3, "scuttlectl agent register <nick> [--type worker] [--channels #a,#b]")
79
cmdAgentRegister(api, args[2:], *jsonFlag)
80
case "revoke":
81
requireArgs(args, 3, "scuttlectl agent revoke <nick>")
82
cmdAgentRevoke(api, args[2])
83
case "delete":
84
requireArgs(args, 3, "scuttlectl agent delete <nick>")
85
cmdAgentDelete(api, args[2])
86
case "rotate":
87
requireArgs(args, 3, "scuttlectl agent rotate <nick>")
88
cmdAgentRotate(api, args[2], *jsonFlag)
89
default:
90
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", args[1])
91
os.Exit(1)
92
}
93
case "admin":
94
if len(args) < 2 {
95
fmt.Fprintf(os.Stderr, "usage: scuttlectl admin <subcommand>\n")
96
os.Exit(1)
97
}
98
switch args[1] {
99
case "list":
100
cmdAdminList(api, *jsonFlag)
101
case "add":
102
requireArgs(args, 3, "scuttlectl admin add <username>")
103
cmdAdminAdd(api, args[2])
104
case "remove":
105
requireArgs(args, 3, "scuttlectl admin remove <username>")
106
cmdAdminRemove(api, args[2])
107
case "passwd":
108
requireArgs(args, 3, "scuttlectl admin passwd <username>")
109
cmdAdminPasswd(api, args[2])
110
default:
111
fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112
os.Exit(1)
113
}
114
case "api-key", "api-keys":
115
if len(args) < 2 {
116
fmt.Fprintf(os.Stderr, "usage: scuttlectl api-key <list|create|revoke>\n")
117
os.Exit(1)
118
}
119
switch args[1] {
120
case "list":
121
cmdAPIKeyList(api, *jsonFlag)
122
case "create":
123
requireArgs(args, 3, "scuttlectl api-key create --name <name> --scopes <scope1,scope2>")
124
cmdAPIKeyCreate(api, args[2:], *jsonFlag)
125
case "revoke":
126
requireArgs(args, 3, "scuttlectl api-key revoke <id>")
127
cmdAPIKeyRevoke(api, args[2])
128
default:
129
fmt.Fprintf(os.Stderr, "unknown subcommand: api-key %s\n", args[1])
130
os.Exit(1)
131
}
132
case "channels", "channel":
133
if len(args) < 2 {
134
fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
135
os.Exit(1)
136
}
137
switch args[1] {
138
case "list":
139
cmdChannelList(api, *jsonFlag)
140
case "users":
141
requireArgs(args, 3, "scuttlectl channels users <channel>")
142
cmdChannelUsers(api, args[2], *jsonFlag)
143
case "delete", "rm":
144
requireArgs(args, 3, "scuttlectl channels delete <channel>")
145
cmdChannelDelete(api, args[2])
146
default:
147
fmt.Fprintf(os.Stderr, "unknown subcommand: channels %s\n", args[1])
148
os.Exit(1)
149
}
150
case "backend", "backends":
151
if len(args) < 2 {
152
fmt.Fprintf(os.Stderr, "usage: scuttlectl backend <list|get|delete|rename> [args]\n")
153
os.Exit(1)
154
}
155
switch args[1] {
156
case "list":
157
cmdBackendList(api, *jsonFlag)
158
case "get":
159
requireArgs(args, 3, "scuttlectl backend get <name>")
160
cmdBackendGet(api, args[2], *jsonFlag)
161
case "delete", "rm":
162
requireArgs(args, 3, "scuttlectl backend delete <name>")
163
cmdBackendDelete(api, args[2])
164
case "rename":
165
requireArgs(args, 4, "scuttlectl backend rename <old-name> <new-name>")
166
cmdBackendRename(api, args[2], args[3])
167
default:
168
fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
169
os.Exit(1)
170
}
171
default:
172
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
173
usage()
174
os.Exit(1)
175
}
176
}
177
178
func cmdStatus(api *apiclient.Client, asJSON bool) {
179
raw, err := api.Status()
180
die(err)
181
if asJSON {
182
printJSON(raw)
183
return
184
}
185
186
var s struct {
187
Status string `json:"status"`
188
Uptime string `json:"uptime"`
189
Agents int `json:"agents"`
190
Started string `json:"started"`
191
}
192
must(json.Unmarshal(raw, &s))
193
194
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
195
fmt.Fprintf(tw, "status\t%s\n", s.Status)
196
fmt.Fprintf(tw, "uptime\t%s\n", s.Uptime)
197
fmt.Fprintf(tw, "agents\t%d\n", s.Agents)
198
fmt.Fprintf(tw, "started\t%s\n", s.Started)
199
tw.Flush()
200
}
201
202
func cmdAgentList(api *apiclient.Client, asJSON bool) {
203
raw, err := api.ListAgents()
204
die(err)
205
if asJSON {
206
printJSON(raw)
207
return
208
}
209
210
var body struct {
211
Agents []struct {
212
Nick string `json:"nick"`
213
Type string `json:"type"`
214
Channels []string `json:"channels"`
215
Revoked bool `json:"revoked"`
216
} `json:"agents"`
217
}
218
must(json.Unmarshal(raw, &body))
219
220
if len(body.Agents) == 0 {
221
fmt.Println("no agents registered")
222
return
223
}
224
225
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
226
fmt.Fprintln(tw, "NICK\tTYPE\tCHANNELS\tSTATUS")
227
for _, a := range body.Agents {
228
status := "active"
229
if a.Revoked {
230
status = "revoked"
231
}
232
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.Nick, a.Type, strings.Join(a.Channels, ","), status)
233
}
234
tw.Flush()
235
}
236
237
func cmdAgentGet(api *apiclient.Client, nick string, asJSON bool) {
238
raw, err := api.GetAgent(nick)
239
die(err)
240
if asJSON {
241
printJSON(raw)
242
return
243
}
244
245
var a struct {
246
Nick string `json:"nick"`
247
Type string `json:"type"`
248
Channels []string `json:"channels"`
249
Revoked bool `json:"revoked"`
250
}
251
must(json.Unmarshal(raw, &a))
252
253
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
254
status := "active"
255
if a.Revoked {
256
status = "revoked"
257
}
258
fmt.Fprintf(tw, "nick\t%s\n", a.Nick)
259
fmt.Fprintf(tw, "type\t%s\n", a.Type)
260
fmt.Fprintf(tw, "channels\t%s\n", strings.Join(a.Channels, ", "))
261
fmt.Fprintf(tw, "status\t%s\n", status)
262
tw.Flush()
263
}
264
265
func cmdAgentRegister(api *apiclient.Client, args []string, asJSON bool) {
266
nick := args[0]
267
var channels []string
268
269
// Parse optional --type and --channels from remaining args.
270
fs := flag.NewFlagSet("agent register", flag.ExitOnError)
271
typeFlag := fs.String("type", "worker", "agent type (worker, orchestrator, observer)")
272
channelsFlag := fs.String("channels", "", "comma-separated list of channels to join")
273
_ = fs.Parse(args[1:])
274
agentType := *typeFlag
275
if *channelsFlag != "" {
276
for _, ch := range strings.Split(*channelsFlag, ",") {
277
if ch = strings.TrimSpace(ch); ch != "" {
278
channels = append(channels, ch)
279
}
280
}
281
}
282
283
raw, err := api.RegisterAgent(nick, agentType, channels)
284
die(err)
285
if asJSON {
286
printJSON(raw)
287
return
288
}
289
290
var body struct {
291
Credentials struct {
292
Nick string `json:"nick"`
293
Password string `json:"password"`
294
Server string `json:"server"`
295
} `json:"credentials"`
296
Payload struct {
297
Token string `json:"token"`
298
Signature string `json:"signature"`
299
} `json:"payload"`
300
}
301
must(json.Unmarshal(raw, &body))
302
303
fmt.Printf("Agent registered: %s\n\n", nick)
304
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
305
fmt.Fprintln(tw, "CREDENTIAL\tVALUE")
306
fmt.Fprintf(tw, "nick\t%s\n", body.Credentials.Nick)
307
fmt.Fprintf(tw, "password\t%s\n", body.Credentials.Password)
308
fmt.Fprintf(tw, "server\t%s\n", body.Credentials.Server)
309
tw.Flush()
310
fmt.Println("\nStore these credentials — the password will not be shown again.")
311
}
312
313
func cmdAdminList(api *apiclient.Client, asJSON bool) {
314
raw, err := api.ListAdmins()
315
die(err)
316
if asJSON {
317
printJSON(raw)
318
return
319
}
320
321
var body struct {
322
Admins []struct {
323
Username string `json:"username"`
324
Created string `json:"created"`
325
} `json:"admins"`
326
}
327
must(json.Unmarshal(raw, &body))
328
329
if len(body.Admins) == 0 {
330
fmt.Println("no admin accounts")
331
return
332
}
333
334
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
335
fmt.Fprintln(tw, "USERNAME\tCREATED")
336
for _, a := range body.Admins {
337
fmt.Fprintf(tw, "%s\t%s\n", a.Username, a.Created)
338
}
339
tw.Flush()
340
}
341
342
func cmdAdminAdd(api *apiclient.Client, username string) {
343
pass := promptPassword()
344
_, err := api.AddAdmin(username, pass)
345
die(err)
346
fmt.Printf("Admin added: %s\n", username)
347
}
348
349
func cmdAdminRemove(api *apiclient.Client, username string) {
350
die(api.RemoveAdmin(username))
351
fmt.Printf("Admin removed: %s\n", username)
352
}
353
354
func cmdAdminPasswd(api *apiclient.Client, username string) {
355
pass := promptPassword()
356
die(api.SetAdminPassword(username, pass))
357
fmt.Printf("Password updated for: %s\n", username)
358
}
359
360
func promptPassword() string {
361
fmt.Fprint(os.Stderr, "password: ")
362
var pass string
363
_, _ = fmt.Scanln(&pass)
364
return pass
365
}
366
367
func cmdAgentRevoke(api *apiclient.Client, nick string) {
368
die(api.RevokeAgent(nick))
369
fmt.Printf("Agent revoked: %s\n", nick)
370
}
371
372
func cmdAgentDelete(api *apiclient.Client, nick string) {
373
die(api.DeleteAgent(nick))
374
fmt.Printf("Agent deleted: %s\n", nick)
375
}
376
377
func cmdChannelList(api *apiclient.Client, asJSON bool) {
378
raw, err := api.ListChannels()
379
die(err)
380
if asJSON {
381
printJSON(raw)
382
return
383
}
384
var body struct {
385
Channels []string `json:"channels"`
386
}
387
must(json.Unmarshal(raw, &body))
388
if len(body.Channels) == 0 {
389
fmt.Println("no channels")
390
return
391
}
392
for _, ch := range body.Channels {
393
fmt.Println(ch)
394
}
395
}
396
397
func cmdChannelUsers(api *apiclient.Client, channel string, asJSON bool) {
398
raw, err := api.ChannelUsers(channel)
399
die(err)
400
if asJSON {
401
printJSON(raw)
402
return
403
}
404
var body struct {
405
Users []string `json:"users"`
406
}
407
must(json.Unmarshal(raw, &body))
408
if len(body.Users) == 0 {
409
fmt.Printf("no users in %s\n", channel)
410
return
411
}
412
for _, u := range body.Users {
413
fmt.Println(u)
414
}
415
}
416
417
func cmdChannelDelete(api *apiclient.Client, channel string) {
418
die(api.DeleteChannel(channel))
419
fmt.Printf("Channel deleted: #%s\n", strings.TrimPrefix(channel, "#"))
420
}
421
422
func cmdBackendList(api *apiclient.Client, asJSON bool) {
423
raw, err := api.ListLLMBackends()
424
die(err)
425
if asJSON {
426
printJSON(raw)
427
return
428
}
429
var body struct {
430
Backends []struct {
431
Name string `json:"name"`
432
Provider string `json:"provider"`
433
} `json:"backends"`
434
}
435
must(json.Unmarshal(raw, &body))
436
if len(body.Backends) == 0 {
437
fmt.Println("no backends")
438
return
439
}
440
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
441
fmt.Fprintln(tw, "NAME\tPROVIDER")
442
for _, b := range body.Backends {
443
fmt.Fprintf(tw, "%s\t%s\n", b.Name, b.Provider)
444
}
445
tw.Flush()
446
}
447
448
func cmdBackendGet(api *apiclient.Client, name string, asJSON bool) {
449
raw, err := api.GetLLMBackend(name)
450
die(err)
451
if asJSON {
452
printJSON(raw)
453
return
454
}
455
var b struct {
456
Name string `json:"name"`
457
Provider string `json:"provider"`
458
Model string `json:"model"`
459
BaseURL string `json:"base_url,omitempty"`
460
}
461
must(json.Unmarshal(raw, &b))
462
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
463
fmt.Fprintf(tw, "name\t%s\n", b.Name)
464
fmt.Fprintf(tw, "provider\t%s\n", b.Provider)
465
fmt.Fprintf(tw, "model\t%s\n", b.Model)
466
if b.BaseURL != "" {
467
fmt.Fprintf(tw, "base_url\t%s\n", b.BaseURL)
468
}
469
tw.Flush()
470
}
471
472
func cmdBackendDelete(api *apiclient.Client, name string) {
473
die(api.DeleteLLMBackend(name))
474
fmt.Printf("Backend deleted: %s\n", name)
475
}
476
477
func cmdBackendRename(api *apiclient.Client, oldName, newName string) {
478
raw, err := api.GetLLMBackend(oldName)
479
die(err)
480
481
var cfg map[string]any
482
must(json.Unmarshal(raw, &cfg))
483
cfg["name"] = newName
484
485
die(api.CreateLLMBackend(cfg))
486
die(api.DeleteLLMBackend(oldName))
487
fmt.Printf("Backend renamed: %s → %s\n", oldName, newName)
488
}
489
490
func cmdAgentRotate(api *apiclient.Client, nick string, asJSON bool) {
491
raw, err := api.RotateAgent(nick)
492
die(err)
493
if asJSON {
494
printJSON(raw)
495
return
496
}
497
498
var creds struct {
499
Nick string `json:"nick"`
500
Password string `json:"password"`
501
Server string `json:"server"`
502
}
503
must(json.Unmarshal(raw, &creds))
504
505
fmt.Printf("Credentials rotated for: %s\n\n", nick)
506
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
507
fmt.Fprintln(tw, "CREDENTIAL\tVALUE")
508
fmt.Fprintf(tw, "nick\t%s\n", creds.Nick)
509
fmt.Fprintf(tw, "password\t%s\n", creds.Password)
510
fmt.Fprintf(tw, "server\t%s\n", creds.Server)
511
tw.Flush()
512
fmt.Println("\nSto")
513
}
514
515
// --- api-keys ---
516
517
func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
518
raw, err := api.ListAPIKeys()
519
die(err)
520
if asJSON {
521
printJSON(raw)
522
return
523
}
524
525
var keys []struct {
526
ID string `json:"id"`
527
Name string `json:"name"`
528
Scopes []string `json:"scopes"`
529
CreatedAt string `json:"created_at"`
530
LastUsed *string `json:"last_used"`
531
ExpiresAt *string `json:"expires_at"`
532
Active bool `json:"active"`
533
}
534
must(json.Unmarshal(raw, &keys))
535
536
if len(keys) == 0 {
537
fmt.Println("no API keys")
538
return
539
}
540
541
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
542
fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
543
for _, k := range keys {
544
lastUsed := "-"
545
if k.LastUsed != nil {
546
lastUsed = *k.LastUsed
547
}
548
status := "yes"
549
if !k.Active {
550
status = "revoked"
551
}
552
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
553
}
554
tw.Flush()
555
}
556
557
func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
558
fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
559
nameFlag := fs.String("name", "", "key name (required)")
560
scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
561
expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
562
_ = fs.Parse(args)
563
564
if *nameFlag == "" || *scopesFlag == "" {
565
fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
566
os.Exit(1)
567
}
568
569
scopes := strings.Split(*scopesFlag, ",")
570
raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
571
die(err)
572
573
if asJSON {
574
printJSON(raw)
575
return
576
}
577
578
var key struct {
579
ID string `json:"id"`
580
Name string `json:"name"`
581
Token string `json:"token"`
582
}
583
must(json.Unmarshal(raw, &key))
584
585
fmt.Printf("API key created: %s\n\n", key.Name)
586
fmt.Printf(" Token: %s\n\n", key.Token)
587
fmt.Println("Store this token — it will not be shown again.")
588
}
589
590
func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
591
die(api.RevokeAPIKey(id))
592
fmt.Printf("API nel provisioned: %s\n", channel)
593
}
594
595
func cmdTopologyDrop(api *apiclient.Client, channel string) {
596
if !strings.HasPrefix(channel, "#") {
597
channel = "#" + channel
598
}
599
die(api.DropChannel(channel))
600
fmt.Printf("Channel dropped: %s\n", channel)
601
}
602
603
// --- config ---
604
605
func cmdConfigShow(api *apiclient.Client, asJSON bool) {
606
raw, err := api.GetConfig()
607
die(err)
608
printJSON(raw) // always JSON — config is a complex nested object
609
}
610
611
func cmdConfigHistory(api *apiclient.Client, asJSON bool) {
612
raw, err := api.GetConfigHistory()
613
die(err)
614
if asJSON {
615
printJSON(raw)
616
return
617
}
618
var data struct {
619
Entries []struct {
620
Filename string `json:"filename"`
621
At string `json:"at"`
622
} `json:"entries"`
623
}
624
must(json.Unmarshal(raw, &data))
625
if len(data.Entries) == 0 {
626
fmt.Println("no config history")
627
return
628
}
629
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
630
fmt.Fprintln(tw, "SNAPSHOT\tTIME")
631
for _, e := range data.Entries {
632
fmt.Fprintf(tw, "%s\t%s\n", e.Filename, e.At)
633
}
634
tw.Flush()
635
}
636
637
// --- bots ---
638
639
func cmdBotList(api *apiclient.Client, asJSON bool) {
640
raw, err := api.GetSettings()
641
die(err)
642
if asJSON {
643
printJSON(raw)
644
return
645
}
646
var data struct {
647
Policies struct {
648
Behaviors []struct {
649
ID string `json:"id"`
650
Name string `json:"name"`
651
Nick string `json:"nick"`
652
Enabled bool `json:"enabled"`
653
} `json:"behaviors"`
654
} `json:"policies"`
655
}
656
must(json.Unmarshal(raw, &data))
657
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
658
fmt.Fprintln(tw, "BOT\tNICK\tSTATUS")
659
for _, b := range data.Policies.Behaviors {
660
status := "disabled"
661
if b.Enabled {
662
status = "enabled"
663
}
664
fmt.Fprintf(tw, "%s\t%s\t%s\n", b.Name, b.Nick, status)
665
}
666
tw.Flush()
667
}
668
669
fun show system bot status
670
`, version)
671
}
672
673
func printJSON(raw json.RawMessage) {
674
var buf []byte
675
buf, _ = json.MarshalIndent(raw, "", " ")
676
fmt.Println(string(buf))
677
}
678
679
func requireArgs(args []string, n int, usage string) {
680
if len(args) < n {
681
fmt.Fprintf(os.Stderr, "usage: %s\n", usage)
682
os.Exit(1)
683
}
684
}
685
686
func die(err error) {
687
if err != nil {
688
fmt.Fprintln(os.Stderr, "error:", err)
689
os.Exit(1)
690
}
691
}
692
693
func must(err error) {
694
if err != nil {
695
fmt.Fprintln(os.Stderr, "internal error:", err)
696
os.Exit(1)
697
}
698
}
699
700
func envOr(key, def string) string {
701
if v := os.Getenv(key); v != "" {
702

Keyboard Shortcuts

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