ScuttleBot

feat: scuttlectl api-key commands — list, create, revoke (#126) Add api-key subcommand to scuttlectl: scuttlectl api-key list scuttlectl api-key create --name <name> --scopes <s1,s2> [--expires 720h] scuttlectl api-key revoke <id> Creates keys via POST /v1/api-keys, lists via GET, revokes via DELETE. Token shown once on creation (like GitHub PATs).

lmata 2026-04-05 14:16 trunk
Commit 3d0d46ec6554b9c83d8d91a96c038be160b8a273e883fefb08bca961c039784f
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,30 @@
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
+// ListAPIKeys returns GET /v1/api-keys.
144
+func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145
+ return c.get("/v1/api-keys")
146
+}
147
+
148
+// CreateAPIKey sends POST /v1/api-keys.
149
+func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150
+ body := map[string]any{"name": name, "scopes": scopes}
151
+ if expiresIn != "" {
152
+ body["expires_in"] = expiresIn
153
+ }
154
+ return c.post("/v1/api-keys", body)
155
+}
156
+
157
+// RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158
+func (c *Client) RevokeAPIKey(id string) error {
159
+ _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160
+ return err
161
+}
142162
143163
// SetAdminPassword sends PUT /v1/admins/{username}/password.
144164
func (c *Client) SetAdminPassword(username, password string) error {
145165
_, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
146166
return err
147167
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -137,10 +137,30 @@
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,30 @@
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 // ListAPIKeys returns GET /v1/api-keys.
144 func (c *Client) ListAPIKeys() (json.RawMessage, error) {
145 return c.get("/v1/api-keys")
146 }
147
148 // CreateAPIKey sends POST /v1/api-keys.
149 func (c *Client) CreateAPIKey(name string, scopes []string, expiresIn string) (json.RawMessage, error) {
150 body := map[string]any{"name": name, "scopes": scopes}
151 if expiresIn != "" {
152 body["expires_in"] = expiresIn
153 }
154 return c.post("/v1/api-keys", body)
155 }
156
157 // RevokeAPIKey sends DELETE /v1/api-keys/{id}.
158 func (c *Client) RevokeAPIKey(id string) error {
159 _, err := c.doNoBody("DELETE", "/v1/api-keys/"+id)
160 return err
161 }
162
163 // SetAdminPassword sends PUT /v1/admins/{username}/password.
164 func (c *Client) SetAdminPassword(username, password string) error {
165 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
166 return err
167
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
108108
requireArgs(args, 3, "scuttlectl admin passwd <username>")
109109
cmdAdminPasswd(api, args[2])
110110
default:
111111
fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112112
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)
113131
}
114132
case "channels", "channel":
115133
if len(args) < 2 {
116134
fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117135
os.Exit(1)
@@ -491,10 +509,88 @@
491509
fmt.Fprintf(tw, "password\t%s\n", creds.Password)
492510
fmt.Fprintf(tw, "server\t%s\n", creds.Server)
493511
tw.Flush()
494512
fmt.Println("\nStore this password — it will not be shown again.")
495513
}
514
+
515
+func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516
+ raw, err := api.ListAPIKeys()
517
+ die(err)
518
+ if asJSON {
519
+ printJSON(raw)
520
+ return
521
+ }
522
+
523
+ var keys []struct {
524
+ ID string `json:"id"`
525
+ Name string `json:"name"`
526
+ Scopes []string `json:"scopes"`
527
+ CreatedAt string `json:"created_at"`
528
+ LastUsed *string `json:"last_used"`
529
+ ExpiresAt *string `json:"expires_at"`
530
+ Active bool `json:"active"`
531
+ }
532
+ must(json.Unmarshal(raw, &keys))
533
+
534
+ if len(keys) == 0 {
535
+ fmt.Println("no API keys")
536
+ return
537
+ }
538
+
539
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540
+ fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541
+ for _, k := range keys {
542
+ lastUsed := "-"
543
+ if k.LastUsed != nil {
544
+ lastUsed = *k.LastUsed
545
+ }
546
+ status := "yes"
547
+ if !k.Active {
548
+ status = "revoked"
549
+ }
550
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551
+ }
552
+ tw.Flush()
553
+}
554
+
555
+func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556
+ fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557
+ nameFlag := fs.String("name", "", "key name (required)")
558
+ scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559
+ expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560
+ _ = fs.Parse(args)
561
+
562
+ if *nameFlag == "" || *scopesFlag == "" {
563
+ fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564
+ os.Exit(1)
565
+ }
566
+
567
+ scopes := strings.Split(*scopesFlag, ",")
568
+ raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569
+ die(err)
570
+
571
+ if asJSON {
572
+ printJSON(raw)
573
+ return
574
+ }
575
+
576
+ var key struct {
577
+ ID string `json:"id"`
578
+ Name string `json:"name"`
579
+ Token string `json:"token"`
580
+ }
581
+ must(json.Unmarshal(raw, &key))
582
+
583
+ fmt.Printf("API key created: %s\n\n", key.Name)
584
+ fmt.Printf(" Token: %s\n\n", key.Token)
585
+ fmt.Println("Store this token — it will not be shown again.")
586
+}
587
+
588
+func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589
+ die(api.RevokeAPIKey(id))
590
+ fmt.Printf("API key revoked: %s\n", id)
591
+}
496592
497593
func usage() {
498594
fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
499595
500596
Usage:
@@ -526,10 +622,13 @@
526622
backend rename <old> <new> rename a backend
527623
admin list list admin accounts
528624
admin add <username> add admin (prompts for password)
529625
admin remove <username> remove admin
530626
admin passwd <username> change admin password (prompts)
627
+ api-key list list API keys
628
+ api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629
+ api-key revoke <id> revoke an API key
531630
`, version)
532631
}
533632
534633
func printJSON(raw json.RawMessage) {
535634
var buf []byte
536635
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -108,10 +108,28 @@
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 "channels", "channel":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117 os.Exit(1)
@@ -491,10 +509,88 @@
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 +622,13 @@
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
@@ -108,10 +108,28 @@
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)
@@ -491,10 +509,88 @@
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("\nStore this password — it will not be shown again.")
513 }
514
515 func cmdAPIKeyList(api *apiclient.Client, asJSON bool) {
516 raw, err := api.ListAPIKeys()
517 die(err)
518 if asJSON {
519 printJSON(raw)
520 return
521 }
522
523 var keys []struct {
524 ID string `json:"id"`
525 Name string `json:"name"`
526 Scopes []string `json:"scopes"`
527 CreatedAt string `json:"created_at"`
528 LastUsed *string `json:"last_used"`
529 ExpiresAt *string `json:"expires_at"`
530 Active bool `json:"active"`
531 }
532 must(json.Unmarshal(raw, &keys))
533
534 if len(keys) == 0 {
535 fmt.Println("no API keys")
536 return
537 }
538
539 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
540 fmt.Fprintln(tw, "ID\tNAME\tSCOPES\tACTIVE\tLAST USED")
541 for _, k := range keys {
542 lastUsed := "-"
543 if k.LastUsed != nil {
544 lastUsed = *k.LastUsed
545 }
546 status := "yes"
547 if !k.Active {
548 status = "revoked"
549 }
550 fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, k.Name, strings.Join(k.Scopes, ","), status, lastUsed)
551 }
552 tw.Flush()
553 }
554
555 func cmdAPIKeyCreate(api *apiclient.Client, args []string, asJSON bool) {
556 fs := flag.NewFlagSet("api-key create", flag.ExitOnError)
557 nameFlag := fs.String("name", "", "key name (required)")
558 scopesFlag := fs.String("scopes", "", "comma-separated scopes (required)")
559 expiresFlag := fs.String("expires", "", "expiry duration (e.g. 720h for 30 days)")
560 _ = fs.Parse(args)
561
562 if *nameFlag == "" || *scopesFlag == "" {
563 fmt.Fprintln(os.Stderr, "usage: scuttlectl api-key create --name <name> --scopes <scope1,scope2> [--expires 720h]")
564 os.Exit(1)
565 }
566
567 scopes := strings.Split(*scopesFlag, ",")
568 raw, err := api.CreateAPIKey(*nameFlag, scopes, *expiresFlag)
569 die(err)
570
571 if asJSON {
572 printJSON(raw)
573 return
574 }
575
576 var key struct {
577 ID string `json:"id"`
578 Name string `json:"name"`
579 Token string `json:"token"`
580 }
581 must(json.Unmarshal(raw, &key))
582
583 fmt.Printf("API key created: %s\n\n", key.Name)
584 fmt.Printf(" Token: %s\n\n", key.Token)
585 fmt.Println("Store this token — it will not be shown again.")
586 }
587
588 func cmdAPIKeyRevoke(api *apiclient.Client, id string) {
589 die(api.RevokeAPIKey(id))
590 fmt.Printf("API key revoked: %s\n", id)
591 }
592
593 func usage() {
594 fmt.Fprintf(os.Stderr, `scuttlectl %s — scuttlebot management CLI
595
596 Usage:
@@ -526,10 +622,13 @@
622 backend rename <old> <new> rename a backend
623 admin list list admin accounts
624 admin add <username> add admin (prompts for password)
625 admin remove <username> remove admin
626 admin passwd <username> change admin password (prompts)
627 api-key list list API keys
628 api-key create --name <name> --scopes <s1,s2> [--expires 720h]
629 api-key revoke <id> revoke an API key
630 `, version)
631 }
632
633 func printJSON(raw json.RawMessage) {
634 var buf []byte
635

Keyboard Shortcuts

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