ScuttleBot

feat: scuttlectl topology, config, and bot commands (#104) Add topology subcommand: list (show types + static channels), provision (create channel via ChanServ), drop (remove channel). Add config subcommand: show (dump config JSON), history (list snapshots). Add bot subcommand: list (show system bot status from settings). All backed by existing API endpoints — these are CLI wrappers.

lmata 2026-04-05 15:21 trunk
Commit d11e165f13fc3165edc5f9510de4d3ab3a6fa384f917a6eba940ae056e961760
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,41 @@
137137
// RemoveAdmin sends DELETE /v1/admins/{username}.
138138
func (c *Client) RemoveAdmin(username string) error {
139139
_, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140140
return err
141141
}
142
+
143
+// GetTopology returns GET /v1/topology.
144
+func (c *Client) GetTopology() (json.RawMessage, error) {
145
+ return c.get("/v1/topology")
146
+}
147
+
148
+// ProvisionChannel sends POST /v1/channels.
149
+func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) {
150
+ return c.post("/v1/channels", map[string]string{"name": name})
151
+}
152
+
153
+// DropChannel sends DELETE /v1/topology/channels/{channel}.
154
+func (c *Client) DropChannel(channel string) error {
155
+ _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#"))
156
+ return err
157
+}
158
+
159
+// GetConfig returns GET /v1/config.
160
+func (c *Client) GetConfig() (json.RawMessage, error) {
161
+ return c.get("/v1/config")
162
+}
163
+
164
+// GetConfigHistory returns GET /v1/config/history.
165
+func (c *Client) GetConfigHistory() (json.RawMessage, error) {
166
+ return c.get("/v1/config/history")
167
+}
168
+
169
+// GetSettings returns GET /v1/settings.
170
+func (c *Client) GetSettings() (json.RawMessage, error) {
171
+ return c.get("/v1/settings")
172
+}
142173
143174
// SetAdminPassword sends PUT /v1/admins/{username}/password.
144175
func (c *Client) SetAdminPassword(username, password string) error {
145176
_, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146177
return err
147178
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,41 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
143 // SetAdminPassword sends PUT /v1/admins/{username}/password.
144 func (c *Client) SetAdminPassword(username, password string) error {
145 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146 return err
147
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,41 @@
137 // RemoveAdmin sends DELETE /v1/admins/{username}.
138 func (c *Client) RemoveAdmin(username string) error {
139 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
140 return err
141 }
142
143 // GetTopology returns GET /v1/topology.
144 func (c *Client) GetTopology() (json.RawMessage, error) {
145 return c.get("/v1/topology")
146 }
147
148 // ProvisionChannel sends POST /v1/channels.
149 func (c *Client) ProvisionChannel(name string) (json.RawMessage, error) {
150 return c.post("/v1/channels", map[string]string{"name": name})
151 }
152
153 // DropChannel sends DELETE /v1/topology/channels/{channel}.
154 func (c *Client) DropChannel(channel string) error {
155 _, err := c.doNoBody("DELETE", "/v1/topology/channels/"+strings.TrimPrefix(channel, "#"))
156 return err
157 }
158
159 // GetConfig returns GET /v1/config.
160 func (c *Client) GetConfig() (json.RawMessage, error) {
161 return c.get("/v1/config")
162 }
163
164 // GetConfigHistory returns GET /v1/config/history.
165 func (c *Client) GetConfigHistory() (json.RawMessage, error) {
166 return c.get("/v1/config/history")
167 }
168
169 // GetSettings returns GET /v1/settings.
170 func (c *Client) GetSettings() (json.RawMessage, error) {
171 return c.get("/v1/settings")
172 }
173
174 // SetAdminPassword sends PUT /v1/admins/{username}/password.
175 func (c *Client) SetAdminPassword(username, password string) error {
176 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
177 return err
178
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -148,10 +148,54 @@
148148
cmdBackendRename(api, args[2], args[3])
149149
default:
150150
fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
151151
os.Exit(1)
152152
}
153
+ case "topology", "topo":
154
+ if len(args) < 2 {
155
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl topology <list|provision|drop>\n")
156
+ os.Exit(1)
157
+ }
158
+ switch args[1] {
159
+ case "list", "show":
160
+ cmdTopologyList(api, *jsonFlag)
161
+ case "provision", "create":
162
+ requireArgs(args, 3, "scuttlectl topology provision #channel")
163
+ cmdTopologyProvision(api, args[2], *jsonFlag)
164
+ case "drop", "rm":
165
+ requireArgs(args, 3, "scuttlectl topology drop #channel")
166
+ cmdTopologyDrop(api, args[2])
167
+ default:
168
+ fmt.Fprintf(os.Stderr, "unknown subcommand: topology %s\n", args[1])
169
+ os.Exit(1)
170
+ }
171
+ case "config":
172
+ if len(args) < 2 {
173
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl config <show|history>\n")
174
+ os.Exit(1)
175
+ }
176
+ switch args[1] {
177
+ case "show", "get":
178
+ cmdConfigShow(api, *jsonFlag)
179
+ case "history":
180
+ cmdConfigHistory(api, *jsonFlag)
181
+ default:
182
+ fmt.Fprintf(os.Stderr, "unknown subcommand: config %s\n", args[1])
183
+ os.Exit(1)
184
+ }
185
+ case "bot", "bots":
186
+ if len(args) < 2 {
187
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl bot <list>\n")
188
+ os.Exit(1)
189
+ }
190
+ switch args[1] {
191
+ case "list":
192
+ cmdBotList(api, *jsonFlag)
193
+ default:
194
+ fmt.Fprintf(os.Stderr, "unknown subcommand: bot %s\n", args[1])
195
+ os.Exit(1)
196
+ }
153197
default:
154198
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
155199
usage()
156200
os.Exit(1)
157201
}
@@ -491,10 +535,141 @@
491535
fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492536
fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493537
tw.Flush()
494538
fmt.Println("\nStore this password — it will not be shown again.")
495539
}
540
+
541
+// --- topology ---
542
+
543
+func cmdTopologyList(api *apiclient.Client, asJSON bool) {
544
+ raw, err := api.GetTopology()
545
+ die(err)
546
+ if asJSON {
547
+ printJSON(raw)
548
+ return
549
+ }
550
+ var data struct {
551
+ StaticChannels []string `json:"static_channels"`
552
+ Types []struct {
553
+ Name string `json:"name"`
554
+ Prefix string `json:"prefix"`
555
+ Autojoin []string `json:"autojoin"`
556
+ Ephemeral bool `json:"ephemeral"`
557
+ TTL int64 `json:"ttl_seconds"`
558
+ } `json:"types"`
559
+ }
560
+ must(json.Unmarshal(raw, &data))
561
+
562
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
563
+ fmt.Fprintln(tw, "STATIC CHANNELS")
564
+ for _, ch := range data.StaticChannels {
565
+ fmt.Fprintf(tw, " %s\n", ch)
566
+ }
567
+ if len(data.Types) > 0 {
568
+ fmt.Fprintln(tw, "\nCHANNEL TYPES")
569
+ fmt.Fprintln(tw, " NAME\tPREFIX\tAUTOJOIN\tEPHEMERAL\tTTL")
570
+ for _, t := range data.Types {
571
+ ttl := "—"
572
+ if t.TTL > 0 {
573
+ ttl = fmt.Sprintf("%dh", t.TTL/3600)
574
+ }
575
+ eph := "no"
576
+ if t.Ephemeral {
577
+ eph = "yes"
578
+ }
579
+ fmt.Fprintf(tw, " %s\t#%s*\t%s\t%s\t%s\n", t.Name, t.Prefix, strings.Join(t.Autojoin, ","), eph, ttl)
580
+ }
581
+ }
582
+ tw.Flush()
583
+}
584
+
585
+func cmdTopologyProvision(api *apiclient.Client, channel string, asJSON bool) {
586
+ if !strings.HasPrefix(channel, "#") {
587
+ channel = "#" + channel
588
+ }
589
+ raw, err := api.ProvisionChannel(channel)
590
+ die(err)
591
+ if asJSON {
592
+ printJSON(raw)
593
+ return
594
+ }
595
+ fmt.Printf("Channel provisioned: %s\n", channel)
596
+}
597
+
598
+func cmdTopologyDrop(api *apiclient.Client, channel string) {
599
+ if !strings.HasPrefix(channel, "#") {
600
+ channel = "#" + channel
601
+ }
602
+ die(api.DropChannel(channel))
603
+ fmt.Printf("Channel dropped: %s\n", channel)
604
+}
605
+
606
+// --- config ---
607
+
608
+func cmdConfigShow(api *apiclient.Client, asJSON bool) {
609
+ raw, err := api.GetConfig()
610
+ die(err)
611
+ printJSON(raw) // always JSON — config is a complex nested object
612
+}
613
+
614
+func cmdConfigHistory(api *apiclient.Client, asJSON bool) {
615
+ raw, err := api.GetConfigHistory()
616
+ die(err)
617
+ if asJSON {
618
+ printJSON(raw)
619
+ return
620
+ }
621
+ var data struct {
622
+ Entries []struct {
623
+ Filename string `json:"filename"`
624
+ At string `json:"at"`
625
+ } `json:"entries"`
626
+ }
627
+ must(json.Unmarshal(raw, &data))
628
+ if len(data.Entries) == 0 {
629
+ fmt.Println("no config history")
630
+ return
631
+ }
632
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
633
+ fmt.Fprintln(tw, "SNAPSHOT\tTIME")
634
+ for _, e := range data.Entries {
635
+ fmt.Fprintf(tw, "%s\t%s\n", e.Filename, e.At)
636
+ }
637
+ tw.Flush()
638
+}
639
+
640
+// --- bots ---
641
+
642
+func cmdBotList(api *apiclient.Client, asJSON bool) {
643
+ raw, err := api.GetSettings()
644
+ die(err)
645
+ if asJSON {
646
+ printJSON(raw)
647
+ return
648
+ }
649
+ var data struct {
650
+ Policies struct {
651
+ Behaviors []struct {
652
+ ID string `json:"id"`
653
+ Name string `json:"name"`
654
+ Nick string `json:"nick"`
655
+ Enabled bool `json:"enabled"`
656
+ } `json:"behaviors"`
657
+ } `json:"policies"`
658
+ }
659
+ must(json.Unmarshal(raw, &data))
660
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
661
+ fmt.Fprintln(tw, "BOT\tNICK\tSTATUS")
662
+ for _, b := range data.Policies.Behaviors {
663
+ status := "disabled"
664
+ if b.Enabled {
665
+ status = "enabled"
666
+ }
667
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", b.Name, b.Nick, status)
668
+ }
669
+ tw.Flush()
670
+}
496671
497672
func usage() {
498673
fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499674
500675
Usage:
@@ -526,10 +701,16 @@
526701
backend rename <old> <new> rename a backend
527702
admin list list admin accounts
528703
admin add <username> add admin (prompts for password)
529704
admin remove <username> remove admin
530705
admin passwd <username> change admin password (prompts)
706
+ topology list show topology (static channels, types)
707
+ topology provision #channel provision a new channel via ChanServ
708
+ topology drop #channel drop a channel
709
+ config show dump current config (JSON)
710
+ config history show config change history
711
+ bot list show system bot status
531712
`, version)
532713
}
533714
534715
func printJSON(raw json.RawMessage) {
535716
var buf []byte
536717
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -148,10 +148,54 @@
148 cmdBackendRename(api, args[2], args[3])
149 default:
150 fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
151 os.Exit(1)
152 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153 default:
154 fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
155 usage()
156 os.Exit(1)
157 }
@@ -491,10 +535,141 @@
491 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493 tw.Flush()
494 fmt.Println("\nStore this password — it will not be shown again.")
495 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
497 func usage() {
498 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499
500 Usage:
@@ -526,10 +701,16 @@
526 backend rename <old> <new> rename a backend
527 admin list list admin accounts
528 admin add <username> add admin (prompts for password)
529 admin remove <username> remove admin
530 admin passwd <username> change admin password (prompts)
 
 
 
 
 
 
531 `, version)
532 }
533
534 func printJSON(raw json.RawMessage) {
535 var buf []byte
536
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -148,10 +148,54 @@
148 cmdBackendRename(api, args[2], args[3])
149 default:
150 fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
151 os.Exit(1)
152 }
153 case "topology", "topo":
154 if len(args) < 2 {
155 fmt.Fprintf(os.Stderr, "usage: scuttlectl topology <list|provision|drop>\n")
156 os.Exit(1)
157 }
158 switch args[1] {
159 case "list", "show":
160 cmdTopologyList(api, *jsonFlag)
161 case "provision", "create":
162 requireArgs(args, 3, "scuttlectl topology provision #channel")
163 cmdTopologyProvision(api, args[2], *jsonFlag)
164 case "drop", "rm":
165 requireArgs(args, 3, "scuttlectl topology drop #channel")
166 cmdTopologyDrop(api, args[2])
167 default:
168 fmt.Fprintf(os.Stderr, "unknown subcommand: topology %s\n", args[1])
169 os.Exit(1)
170 }
171 case "config":
172 if len(args) < 2 {
173 fmt.Fprintf(os.Stderr, "usage: scuttlectl config <show|history>\n")
174 os.Exit(1)
175 }
176 switch args[1] {
177 case "show", "get":
178 cmdConfigShow(api, *jsonFlag)
179 case "history":
180 cmdConfigHistory(api, *jsonFlag)
181 default:
182 fmt.Fprintf(os.Stderr, "unknown subcommand: config %s\n", args[1])
183 os.Exit(1)
184 }
185 case "bot", "bots":
186 if len(args) < 2 {
187 fmt.Fprintf(os.Stderr, "usage: scuttlectl bot <list>\n")
188 os.Exit(1)
189 }
190 switch args[1] {
191 case "list":
192 cmdBotList(api, *jsonFlag)
193 default:
194 fmt.Fprintf(os.Stderr, "unknown subcommand: bot %s\n", args[1])
195 os.Exit(1)
196 }
197 default:
198 fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
199 usage()
200 os.Exit(1)
201 }
@@ -491,10 +535,141 @@
535 fmt.Fprintf(tw, "password\t%s\n", creds.Password)
536 fmt.Fprintf(tw, "server\t%s\n", creds.Server)
537 tw.Flush()
538 fmt.Println("\nStore this password — it will not be shown again.")
539 }
540
541 // --- topology ---
542
543 func cmdTopologyList(api *apiclient.Client, asJSON bool) {
544 raw, err := api.GetTopology()
545 die(err)
546 if asJSON {
547 printJSON(raw)
548 return
549 }
550 var data struct {
551 StaticChannels []string `json:"static_channels"`
552 Types []struct {
553 Name string `json:"name"`
554 Prefix string `json:"prefix"`
555 Autojoin []string `json:"autojoin"`
556 Ephemeral bool `json:"ephemeral"`
557 TTL int64 `json:"ttl_seconds"`
558 } `json:"types"`
559 }
560 must(json.Unmarshal(raw, &data))
561
562 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
563 fmt.Fprintln(tw, "STATIC CHANNELS")
564 for _, ch := range data.StaticChannels {
565 fmt.Fprintf(tw, " %s\n", ch)
566 }
567 if len(data.Types) > 0 {
568 fmt.Fprintln(tw, "\nCHANNEL TYPES")
569 fmt.Fprintln(tw, " NAME\tPREFIX\tAUTOJOIN\tEPHEMERAL\tTTL")
570 for _, t := range data.Types {
571 ttl := "—"
572 if t.TTL > 0 {
573 ttl = fmt.Sprintf("%dh", t.TTL/3600)
574 }
575 eph := "no"
576 if t.Ephemeral {
577 eph = "yes"
578 }
579 fmt.Fprintf(tw, " %s\t#%s*\t%s\t%s\t%s\n", t.Name, t.Prefix, strings.Join(t.Autojoin, ","), eph, ttl)
580 }
581 }
582 tw.Flush()
583 }
584
585 func cmdTopologyProvision(api *apiclient.Client, channel string, asJSON bool) {
586 if !strings.HasPrefix(channel, "#") {
587 channel = "#" + channel
588 }
589 raw, err := api.ProvisionChannel(channel)
590 die(err)
591 if asJSON {
592 printJSON(raw)
593 return
594 }
595 fmt.Printf("Channel provisioned: %s\n", channel)
596 }
597
598 func cmdTopologyDrop(api *apiclient.Client, channel string) {
599 if !strings.HasPrefix(channel, "#") {
600 channel = "#" + channel
601 }
602 die(api.DropChannel(channel))
603 fmt.Printf("Channel dropped: %s\n", channel)
604 }
605
606 // --- config ---
607
608 func cmdConfigShow(api *apiclient.Client, asJSON bool) {
609 raw, err := api.GetConfig()
610 die(err)
611 printJSON(raw) // always JSON — config is a complex nested object
612 }
613
614 func cmdConfigHistory(api *apiclient.Client, asJSON bool) {
615 raw, err := api.GetConfigHistory()
616 die(err)
617 if asJSON {
618 printJSON(raw)
619 return
620 }
621 var data struct {
622 Entries []struct {
623 Filename string `json:"filename"`
624 At string `json:"at"`
625 } `json:"entries"`
626 }
627 must(json.Unmarshal(raw, &data))
628 if len(data.Entries) == 0 {
629 fmt.Println("no config history")
630 return
631 }
632 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
633 fmt.Fprintln(tw, "SNAPSHOT\tTIME")
634 for _, e := range data.Entries {
635 fmt.Fprintf(tw, "%s\t%s\n", e.Filename, e.At)
636 }
637 tw.Flush()
638 }
639
640 // --- bots ---
641
642 func cmdBotList(api *apiclient.Client, asJSON bool) {
643 raw, err := api.GetSettings()
644 die(err)
645 if asJSON {
646 printJSON(raw)
647 return
648 }
649 var data struct {
650 Policies struct {
651 Behaviors []struct {
652 ID string `json:"id"`
653 Name string `json:"name"`
654 Nick string `json:"nick"`
655 Enabled bool `json:"enabled"`
656 } `json:"behaviors"`
657 } `json:"policies"`
658 }
659 must(json.Unmarshal(raw, &data))
660 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
661 fmt.Fprintln(tw, "BOT\tNICK\tSTATUS")
662 for _, b := range data.Policies.Behaviors {
663 status := "disabled"
664 if b.Enabled {
665 status = "enabled"
666 }
667 fmt.Fprintf(tw, "%s\t%s\t%s\n", b.Name, b.Nick, status)
668 }
669 tw.Flush()
670 }
671
672 func usage() {
673 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
674
675 Usage:
@@ -526,10 +701,16 @@
701 backend rename <old> <new> rename a backend
702 admin list list admin accounts
703 admin add <username> add admin (prompts for password)
704 admin remove <username> remove admin
705 admin passwd <username> change admin password (prompts)
706 topology list show topology (static channels, types)
707 topology provision #channel provision a new channel via ChanServ
708 topology drop #channel drop a channel
709 config show dump current config (JSON)
710 config history show config change history
711 bot list show system bot status
712 `, version)
713 }
714
715 func printJSON(raw json.RawMessage) {
716 var buf []byte
717

Keyboard Shortcuts

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