code.oscarkilo.com/okg

Hash:
0556fcde41ae8167b74a395033cb1adf07abcbba
Author:
Igor Naverniouk <[email protected]>
Date:
Thu Jun 4 21:08:21 2026 -0400
Message:
okg: add `okg group delete` CLI shape: `okg group delete NAME`. /groups/delete takes an owid, so the CLI resolves NAME → owid via /groups/list before the call (same trick as remove-member). //okg/who gains a matching DeleteGroupRequest + DeleteGroup method. Server returns 200 on success; the CLI prints "Deleted group NAME". Test: TestDeleteGroup pins the POST body shape. That completes the per-group lifecycle: list, create, members, add-member, remove-member, delete. Next up: authz commands so chat:// permissions can be set.
diff --git a/README.md b/README.md
index 1aa15a3..3639d4d 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ okg group create NAME [--full-name TEXT] [--owner USER]
okg group add-member GROUP USER [USER ...]
okg group remove-member GROUP USER [USER ...]
okg group members GROUP [--json]
+okg group delete NAME

okg embed [--model NAME] [--dims N] [--full-path]
(reads stdin → vectors on stdout)
diff --git a/group.go b/group.go
index 12a8fd5..ca1932b 100644
--- a/group.go
+++ b/group.go
@@ -25,6 +25,8 @@ func runGroup(args []string) error {
return runGroupRemoveMember(args[1:])
case "members":
return runGroupMembers(args[1:])
+ case "delete":
+ return runGroupDelete(args[1:])
default:
return fmt.Errorf(
"unknown group subcommand: %s", args[0])
@@ -246,6 +248,36 @@ func runGroupMembers(args []string) error {
return nil
}

+func runGroupDelete(args []string) error {
+ fs := flag.NewFlagSet(
+ "group delete", flag.ContinueOnError)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ positional := fs.Args()
+ if len(positional) != 1 {
+ return fmt.Errorf("usage: okg group delete NAME")
+ }
+ groupName := positional[0]
+
+ c, err := newWhoClient()
+ if err != nil {
+ return err
+ }
+ // /groups/delete takes an owid; resolve via /groups/list.
+ groupOwid, err := resolveGroupOwid(c, groupName)
+ if err != nil {
+ return err
+ }
+ if err := c.DeleteGroup(who.DeleteGroupRequest{
+ Owid: groupOwid,
+ }); err != nil {
+ return err
+ }
+ fmt.Printf("Deleted group %s\n", groupName)
+ return nil
+}
+
// resolveGroupOwid translates a group username to its owid via
// /groups/list. Returns an error if the group isn't visible to
// the caller.
diff --git a/main.go b/main.go
index 5936fb6..a92f14e 100644
--- a/main.go
+++ b/main.go
@@ -70,6 +70,8 @@ GROUPS
okg group members GROUP
--json full DAG (Up/Down/Usernames) as raw JSON

+ okg group delete NAME
+
PULL REQUESTS
okg pr list
--state STATE open or closed (default: open)
@@ -138,6 +140,7 @@ EXAMPLES
okg group add-member chat-bots claude openclaw
okg group remove-member chat-bots openclaw
okg group members chat-bots
+ okg group delete chat-bots

Pull requests
okg pr list --state open
diff --git a/who/who.go b/who/who.go
index f15c81c..3198a99 100644
--- a/who/who.go
+++ b/who/who.go
@@ -143,3 +143,15 @@ type LeaveGroupRequest struct {
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
return c.PostJSON("/groups/leave", req, nil)
}
+
+// DeleteGroupRequest is the body for POST /groups/delete. Owid
+// is the group's obfuscated wid.
+type DeleteGroupRequest struct {
+ Owid string `json:"owid"`
+}
+
+// DeleteGroup destroys a group. The caller must own it (or be a
+// //who admin).
+func (c *HTTPClient) DeleteGroup(req DeleteGroupRequest) error {
+ return c.PostJSON("/groups/delete", req, nil)
+}
diff --git a/who/who_test.go b/who/who_test.go
index ad6c1c8..65ae002 100644
--- a/who/who_test.go
+++ b/who/who_test.go
@@ -212,6 +212,32 @@ func TestLeaveGroup(t *testing.T) {
}
}

+func TestDeleteGroup(t *testing.T) {
+ var seen DeleteGroupRequest
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/groups/delete" {
+ t.Errorf(
+ "path: got %q, want /groups/delete",
+ r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&seen)
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ err := c.DeleteGroup(DeleteGroupRequest{
+ Owid: "team-owid",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.Owid != "team-owid" {
+ t.Errorf("Owid: got %q, want team-owid", seen.Owid)
+ }
+}
+
func TestJoinGroups(t *testing.T) {
var seen JoinGroupsRequest
srv := httptest.NewServer(http.HandlerFunc(
a/README.md
b/README.md
1
# okg — Oscar Kilo Goodness
1
# okg — Oscar Kilo Goodness
2
2
3
A command-line tool that bundles Oscar Kilo's externally usable
3
A command-line tool that bundles Oscar Kilo's externally usable
4
services into one binary. Today: git ops against
4
services into one binary. Today: git ops against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
5
[klee](https://code.oscarkilo.com), and LLM embeddings against
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
6
[klex](https://oscarkilo.com/klex). More LLM utilities (`one`,
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
7
`exemplary`) coming as the legacy `klex-git` binaries fold in.
8
8
9
Designed for both humans and AI agents (Claude, OpenClaw) to
9
Designed for both humans and AI agents (Claude, OpenClaw) to
10
use directly from the command line.
10
use directly from the command line.
11
11
12
## Install
12
## Install
13
13
14
```bash
14
```bash
15
go install oscarkilo.com/okg@latest
15
go install oscarkilo.com/okg@latest
16
```
16
```
17
17
18
Or build from source:
18
Or build from source:
19
19
20
```bash
20
```bash
21
git clone https://code.oscarkilo.com/okg
21
git clone https://code.oscarkilo.com/okg
22
cd okg && go build .
22
cd okg && go build .
23
```
23
```
24
24
25
## Setup
25
## Setup
26
26
27
By default okg talks to the production klee host
27
By default okg talks to the production klee host
28
(`https://code.oscarkilo.com`); the only setup you need is your
28
(`https://code.oscarkilo.com`); the only setup you need is your
29
API key. `okg auth login` saves it to
29
API key. `okg auth login` saves it to
30
`~/.config/okg/config.json`.
30
`~/.config/okg/config.json`.
31
31
32
```bash
32
```bash
33
# Non-interactive (for agents and scripts).
33
# Non-interactive (for agents and scripts).
34
okg auth login --key sk-...
34
okg auth login --key sk-...
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
36
36
37
# Interactive (prompts for host then key).
37
# Interactive (prompts for host then key).
38
okg auth login
38
okg auth login
39
```
39
```
40
40
41
Override the host for dev/local work with `--host` on `auth login`.
41
Override the host for dev/local work with `--host` on `auth login`.
42
42
43
## Commands
43
## Commands
44
44
45
```
45
```
46
okg repo list
46
okg repo list
47
47
48
okg pr list [--state open|closed]
48
okg pr list [--state open|closed]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
50
okg pr view NUMBER
50
okg pr view NUMBER
51
okg pr diff NUMBER
51
okg pr diff NUMBER
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
53
okg pr merge NUMBER
53
okg pr merge NUMBER
54
okg pr close NUMBER
54
okg pr close NUMBER
55
okg pr reopen NUMBER
55
okg pr reopen NUMBER
56
56
57
okg auth login [--key KEY] [--host HOST]
57
okg auth login [--key KEY] [--host HOST]
58
58
59
okg group list [--json]
59
okg group list [--json]
60
okg group create NAME [--full-name TEXT] [--owner USER]
60
okg group create NAME [--full-name TEXT] [--owner USER]
61
okg group add-member GROUP USER [USER ...]
61
okg group add-member GROUP USER [USER ...]
62
okg group remove-member GROUP USER [USER ...]
62
okg group remove-member GROUP USER [USER ...]
63
okg group members GROUP [--json]
63
okg group members GROUP [--json]
64
okg group delete NAME
64
65
65
okg embed [--model NAME] [--dims N] [--full-path]
66
okg embed [--model NAME] [--dims N] [--full-path]
66
(reads stdin → vectors on stdout)
67
(reads stdin → vectors on stdout)
67
okg one [--model NAME] [--system-file FILE] \
68
okg one [--model NAME] [--system-file FILE] \
68
[--prompt-file FILE] [--attach FILE] \
69
[--prompt-file FILE] [--attach FILE] \
69
[--format text|json|jsonindent] [--fast-fail]
70
[--format text|json|jsonindent] [--fast-fail]
70
(reads stdin as a JSON MessagesRequest)
71
(reads stdin as a JSON MessagesRequest)
71
```
72
```
72
73
73
### Coming next
74
### Coming next
74
75
75
- `okg exemplary` — few-shot batch runner (from
76
- `okg exemplary` — few-shot batch runner (from
76
`klex-git/exemplary`).
77
`klex-git/exemplary`).
77
78
78
Once that lands, the standalone `klex-git` binaries are
79
Once that lands, the standalone `klex-git` binaries are
79
deprecated.
80
deprecated.
80
81
81
### Flags
82
### Flags
82
83
83
- `--repo REPO` overrides auto-detected repo name
84
- `--repo REPO` overrides auto-detected repo name
84
(normally parsed from `git remote get-url origin`)
85
(normally parsed from `git remote get-url origin`)
85
- `--json` outputs raw JSON for any command
86
- `--json` outputs raw JSON for any command
86
- `OKG_REPO` env var also overrides repo detection
87
- `OKG_REPO` env var also overrides repo detection
87
88
88
## Repo Detection
89
## Repo Detection
89
90
90
Like `gh`, okg detects the repo from the current directory's
91
Like `gh`, okg detects the repo from the current directory's
91
git remote:
92
git remote:
92
93
93
```
94
```
94
git remote get-url origin
95
git remote get-url origin
95
→ https://code.oscarkilo.com/widget.git
96
→ https://code.oscarkilo.com/widget.git
96
→ repo = "widget"
97
→ repo = "widget"
97
```
98
```
98
99
99
## Dependencies
100
## Dependencies
100
101
101
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
102
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
102
to be folded in once all `klex-git` binaries have moved here).
103
to be folded in once all `klex-git` binaries have moved here).
103
- Otherwise Go standard library only.
104
- Otherwise Go standard library only.
a/group.go
b/group.go
1
package main
1
package main
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "flag"
4
import "flag"
5
import "fmt"
5
import "fmt"
6
import "os"
6
import "os"
7
import "text/tabwriter"
7
import "text/tabwriter"
8
8
9
import "oscarkilo.com/okg/who"
9
import "oscarkilo.com/okg/who"
10
10
11
func runGroup(args []string) error {
11
func runGroup(args []string) error {
12
if len(args) == 0 {
12
if len(args) == 0 {
13
return fmt.Errorf(
13
return fmt.Errorf(
14
"usage: okg group SUBCOMMAND ... " +
14
"usage: okg group SUBCOMMAND ... " +
15
"(try `okg --help`)")
15
"(try `okg --help`)")
16
}
16
}
17
switch args[0] {
17
switch args[0] {
18
case "list":
18
case "list":
19
return runGroupList(args[1:])
19
return runGroupList(args[1:])
20
case "create":
20
case "create":
21
return runGroupCreate(args[1:])
21
return runGroupCreate(args[1:])
22
case "add-member":
22
case "add-member":
23
return runGroupAddMember(args[1:])
23
return runGroupAddMember(args[1:])
24
case "remove-member":
24
case "remove-member":
25
return runGroupRemoveMember(args[1:])
25
return runGroupRemoveMember(args[1:])
26
case "members":
26
case "members":
27
return runGroupMembers(args[1:])
27
return runGroupMembers(args[1:])
28
case "delete":
29
return runGroupDelete(args[1:])
28
default:
30
default:
29
return fmt.Errorf(
31
return fmt.Errorf(
30
"unknown group subcommand: %s", args[0])
32
"unknown group subcommand: %s", args[0])
31
}
33
}
32
}
34
}
33
35
34
func runGroupList(args []string) error {
36
func runGroupList(args []string) error {
35
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
37
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
36
asJSON := fs.Bool("json", false, "output raw JSON")
38
asJSON := fs.Bool("json", false, "output raw JSON")
37
if err := fs.Parse(args); err != nil {
39
if err := fs.Parse(args); err != nil {
38
return err
40
return err
39
}
41
}
40
42
41
c, err := newWhoClient()
43
c, err := newWhoClient()
42
if err != nil {
44
if err != nil {
43
return err
45
return err
44
}
46
}
45
47
46
groups, err := c.ListGroups()
48
groups, err := c.ListGroups()
47
if err != nil {
49
if err != nil {
48
return err
50
return err
49
}
51
}
50
52
51
if *asJSON {
53
if *asJSON {
52
buf, err := json.MarshalIndent(groups, "", " ")
54
buf, err := json.MarshalIndent(groups, "", " ")
53
if err != nil {
55
if err != nil {
54
return err
56
return err
55
}
57
}
56
fmt.Println(string(buf))
58
fmt.Println(string(buf))
57
return nil
59
return nil
58
}
60
}
59
61
60
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
62
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
61
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
63
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
62
for _, g := range groups {
64
for _, g := range groups {
63
fmt.Fprintf(tw, "%s\t%s\t%s\n",
65
fmt.Fprintf(tw, "%s\t%s\t%s\n",
64
g.Username, g.Name, g.OwnerUsername)
66
g.Username, g.Name, g.OwnerUsername)
65
}
67
}
66
return tw.Flush()
68
return tw.Flush()
67
}
69
}
68
70
69
func runGroupCreate(args []string) error {
71
func runGroupCreate(args []string) error {
70
fs := flag.NewFlagSet(
72
fs := flag.NewFlagSet(
71
"group create", flag.ContinueOnError)
73
"group create", flag.ContinueOnError)
72
fullName := fs.String("full-name", "",
74
fullName := fs.String("full-name", "",
73
"display name (default: NAME)")
75
"display name (default: NAME)")
74
owner := fs.String("owner", "",
76
owner := fs.String("owner", "",
75
"owner username (default: caller)")
77
"owner username (default: caller)")
76
if err := fs.Parse(args); err != nil {
78
if err := fs.Parse(args); err != nil {
77
return err
79
return err
78
}
80
}
79
positional := fs.Args()
81
positional := fs.Args()
80
if len(positional) != 1 {
82
if len(positional) != 1 {
81
return fmt.Errorf("usage: okg group create NAME")
83
return fmt.Errorf("usage: okg group create NAME")
82
}
84
}
83
name := positional[0]
85
name := positional[0]
84
86
85
c, err := newWhoClient()
87
c, err := newWhoClient()
86
if err != nil {
88
if err != nil {
87
return err
89
return err
88
}
90
}
89
91
90
if *owner == "" {
92
if *owner == "" {
91
me, err := c.GetProfile()
93
me, err := c.GetProfile()
92
if err != nil {
94
if err != nil {
93
return fmt.Errorf("resolve caller: %v", err)
95
return fmt.Errorf("resolve caller: %v", err)
94
}
96
}
95
*owner = me.Username
97
*owner = me.Username
96
}
98
}
97
99
98
displayName := *fullName
100
displayName := *fullName
99
if displayName == "" {
101
if displayName == "" {
100
displayName = name
102
displayName = name
101
}
103
}
102
104
103
if err := c.CreateGroup(who.CreateGroupRequest{
105
if err := c.CreateGroup(who.CreateGroupRequest{
104
Username: name,
106
Username: name,
105
Name: displayName,
107
Name: displayName,
106
OwnerUsername: *owner,
108
OwnerUsername: *owner,
107
}); err != nil {
109
}); err != nil {
108
return err
110
return err
109
}
111
}
110
fmt.Printf(
112
fmt.Printf(
111
"Created group %s (owner: %s)\n", name, *owner)
113
"Created group %s (owner: %s)\n", name, *owner)
112
return nil
114
return nil
113
}
115
}
114
116
115
func runGroupAddMember(args []string) error {
117
func runGroupAddMember(args []string) error {
116
fs := flag.NewFlagSet(
118
fs := flag.NewFlagSet(
117
"group add-member", flag.ContinueOnError)
119
"group add-member", flag.ContinueOnError)
118
if err := fs.Parse(args); err != nil {
120
if err := fs.Parse(args); err != nil {
119
return err
121
return err
120
}
122
}
121
positional := fs.Args()
123
positional := fs.Args()
122
if len(positional) < 2 {
124
if len(positional) < 2 {
123
return fmt.Errorf(
125
return fmt.Errorf(
124
"usage: okg group add-member GROUP USER [USER ...]")
126
"usage: okg group add-member GROUP USER [USER ...]")
125
}
127
}
126
group := positional[0]
128
group := positional[0]
127
members := positional[1:]
129
members := positional[1:]
128
130
129
c, err := newWhoClient()
131
c, err := newWhoClient()
130
if err != nil {
132
if err != nil {
131
return err
133
return err
132
}
134
}
133
if err := c.JoinGroups(who.JoinGroupsRequest{
135
if err := c.JoinGroups(who.JoinGroupsRequest{
134
GroupUsernames: []string{group},
136
GroupUsernames: []string{group},
135
MemberUsernames: members,
137
MemberUsernames: members,
136
}); err != nil {
138
}); err != nil {
137
return err
139
return err
138
}
140
}
139
fmt.Printf(
141
fmt.Printf(
140
"Added %d member(s) to %s\n", len(members), group)
142
"Added %d member(s) to %s\n", len(members), group)
141
return nil
143
return nil
142
}
144
}
143
145
144
func runGroupRemoveMember(args []string) error {
146
func runGroupRemoveMember(args []string) error {
145
fs := flag.NewFlagSet(
147
fs := flag.NewFlagSet(
146
"group remove-member", flag.ContinueOnError)
148
"group remove-member", flag.ContinueOnError)
147
if err := fs.Parse(args); err != nil {
149
if err := fs.Parse(args); err != nil {
148
return err
150
return err
149
}
151
}
150
positional := fs.Args()
152
positional := fs.Args()
151
if len(positional) < 2 {
153
if len(positional) < 2 {
152
return fmt.Errorf(
154
return fmt.Errorf(
153
"usage: okg group remove-member " +
155
"usage: okg group remove-member " +
154
"GROUP USER [USER ...]")
156
"GROUP USER [USER ...]")
155
}
157
}
156
groupName := positional[0]
158
groupName := positional[0]
157
members := positional[1:]
159
members := positional[1:]
158
160
159
c, err := newWhoClient()
161
c, err := newWhoClient()
160
if err != nil {
162
if err != nil {
161
return err
163
return err
162
}
164
}
163
165
164
// /groups/leave wants owids, not usernames. Resolve the
166
// /groups/leave wants owids, not usernames. Resolve the
165
// group's owid via ListGroups, then the members' owids via
167
// group's owid via ListGroups, then the members' owids via
166
// GroupMembers. Two round-trips on top of the actual leave
168
// GroupMembers. Two round-trips on top of the actual leave
167
// calls; shared across all members in this invocation.
169
// calls; shared across all members in this invocation.
168
groupOwid, err := resolveGroupOwid(c, groupName)
170
groupOwid, err := resolveGroupOwid(c, groupName)
169
if err != nil {
171
if err != nil {
170
return err
172
return err
171
}
173
}
172
ms, err := c.GroupMembers(groupOwid)
174
ms, err := c.GroupMembers(groupOwid)
173
if err != nil {
175
if err != nil {
174
return err
176
return err
175
}
177
}
176
username2owid := make(map[string]string)
178
username2owid := make(map[string]string)
177
for owid, name := range ms.Usernames {
179
for owid, name := range ms.Usernames {
178
username2owid[name] = owid
180
username2owid[name] = owid
179
}
181
}
180
182
181
for _, m := range members {
183
for _, m := range members {
182
memberOwid, ok := username2owid[m]
184
memberOwid, ok := username2owid[m]
183
if !ok {
185
if !ok {
184
return fmt.Errorf(
186
return fmt.Errorf(
185
"user %q is not a member of %s", m, groupName)
187
"user %q is not a member of %s", m, groupName)
186
}
188
}
187
if err := c.LeaveGroup(who.LeaveGroupRequest{
189
if err := c.LeaveGroup(who.LeaveGroupRequest{
188
GroupOwid: groupOwid,
190
GroupOwid: groupOwid,
189
MemberOwid: memberOwid,
191
MemberOwid: memberOwid,
190
}); err != nil {
192
}); err != nil {
191
return fmt.Errorf(
193
return fmt.Errorf(
192
"remove %s from %s: %v", m, groupName, err)
194
"remove %s from %s: %v", m, groupName, err)
193
}
195
}
194
}
196
}
195
fmt.Printf(
197
fmt.Printf(
196
"Removed %d member(s) from %s\n",
198
"Removed %d member(s) from %s\n",
197
len(members), groupName)
199
len(members), groupName)
198
return nil
200
return nil
199
}
201
}
200
202
201
func runGroupMembers(args []string) error {
203
func runGroupMembers(args []string) error {
202
fs := flag.NewFlagSet(
204
fs := flag.NewFlagSet(
203
"group members", flag.ContinueOnError)
205
"group members", flag.ContinueOnError)
204
asJSON := fs.Bool("json", false,
206
asJSON := fs.Bool("json", false,
205
"output full DAG (Up/Down/Usernames) as raw JSON")
207
"output full DAG (Up/Down/Usernames) as raw JSON")
206
if err := fs.Parse(args); err != nil {
208
if err := fs.Parse(args); err != nil {
207
return err
209
return err
208
}
210
}
209
positional := fs.Args()
211
positional := fs.Args()
210
if len(positional) != 1 {
212
if len(positional) != 1 {
211
return fmt.Errorf("usage: okg group members GROUP")
213
return fmt.Errorf("usage: okg group members GROUP")
212
}
214
}
213
groupName := positional[0]
215
groupName := positional[0]
214
216
215
c, err := newWhoClient()
217
c, err := newWhoClient()
216
if err != nil {
218
if err != nil {
217
return err
219
return err
218
}
220
}
219
groupOwid, err := resolveGroupOwid(c, groupName)
221
groupOwid, err := resolveGroupOwid(c, groupName)
220
if err != nil {
222
if err != nil {
221
return err
223
return err
222
}
224
}
223
ms, err := c.GroupMembers(groupOwid)
225
ms, err := c.GroupMembers(groupOwid)
224
if err != nil {
226
if err != nil {
225
return err
227
return err
226
}
228
}
227
229
228
if *asJSON {
230
if *asJSON {
229
buf, err := json.MarshalIndent(ms, "", " ")
231
buf, err := json.MarshalIndent(ms, "", " ")
230
if err != nil {
232
if err != nil {
231
return err
233
return err
232
}
234
}
233
fmt.Println(string(buf))
235
fmt.Println(string(buf))
234
return nil
236
return nil
235
}
237
}
236
238
237
// Direct members are the first level of Down. Print one
239
// Direct members are the first level of Down. Print one
238
// username per line; deeper nesting (group-of-groups) is
240
// username per line; deeper nesting (group-of-groups) is
239
// available via --json.
241
// available via --json.
240
if len(ms.Down) == 0 {
242
if len(ms.Down) == 0 {
241
return nil
243
return nil
242
}
244
}
243
for _, owid := range ms.Down[0] {
245
for _, owid := range ms.Down[0] {
244
fmt.Println(ms.Usernames[owid])
246
fmt.Println(ms.Usernames[owid])
245
}
247
}
246
return nil
248
return nil
247
}
249
}
248
250
251
func runGroupDelete(args []string) error {
252
fs := flag.NewFlagSet(
253
"group delete", flag.ContinueOnError)
254
if err := fs.Parse(args); err != nil {
255
return err
256
}
257
positional := fs.Args()
258
if len(positional) != 1 {
259
return fmt.Errorf("usage: okg group delete NAME")
260
}
261
groupName := positional[0]
262
263
c, err := newWhoClient()
264
if err != nil {
265
return err
266
}
267
// /groups/delete takes an owid; resolve via /groups/list.
268
groupOwid, err := resolveGroupOwid(c, groupName)
269
if err != nil {
270
return err
271
}
272
if err := c.DeleteGroup(who.DeleteGroupRequest{
273
Owid: groupOwid,
274
}); err != nil {
275
return err
276
}
277
fmt.Printf("Deleted group %s\n", groupName)
278
return nil
279
}
280
249
// resolveGroupOwid translates a group username to its owid via
281
// resolveGroupOwid translates a group username to its owid via
250
// /groups/list. Returns an error if the group isn't visible to
282
// /groups/list. Returns an error if the group isn't visible to
251
// the caller.
283
// the caller.
252
func resolveGroupOwid(
284
func resolveGroupOwid(
253
c *who.HTTPClient, name string,
285
c *who.HTTPClient, name string,
254
) (string, error) {
286
) (string, error) {
255
groups, err := c.ListGroups()
287
groups, err := c.ListGroups()
256
if err != nil {
288
if err != nil {
257
return "", err
289
return "", err
258
}
290
}
259
for _, g := range groups {
291
for _, g := range groups {
260
if g.Username == name {
292
if g.Username == name {
261
return g.Owid, nil
293
return g.Owid, nil
262
}
294
}
263
}
295
}
264
return "", fmt.Errorf(
296
return "", fmt.Errorf(
265
"group %q not found (or not visible to caller)", name)
297
"group %q not found (or not visible to caller)", name)
266
}
298
}
267
299
268
// newWhoClient builds a //who client from saved config. Shared
300
// newWhoClient builds a //who client from saved config. Shared
269
// by every `okg group` subcommand.
301
// by every `okg group` subcommand.
270
func newWhoClient() (*who.HTTPClient, error) {
302
func newWhoClient() (*who.HTTPClient, error) {
271
cfg, err := loadConfig()
303
cfg, err := loadConfig()
272
if err != nil {
304
if err != nil {
273
return nil, err
305
return nil, err
274
}
306
}
275
if cfg.ApiKey == "" {
307
if cfg.ApiKey == "" {
276
return nil, fmt.Errorf(
308
return nil, fmt.Errorf(
277
"no API key — run `okg auth login --key sk-...`")
309
"no API key — run `okg auth login --key sk-...`")
278
}
310
}
279
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
311
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
280
}
312
}
a/main.go
b/main.go
1
package main
1
package main
2
2
3
import "fmt"
3
import "fmt"
4
import "os"
4
import "os"
5
5
6
func main() {
6
func main() {
7
args := os.Args[1:]
7
args := os.Args[1:]
8
if len(args) == 0 {
8
if len(args) == 0 {
9
printUsage()
9
printUsage()
10
os.Exit(1)
10
os.Exit(1)
11
}
11
}
12
12
13
var err error
13
var err error
14
switch args[0] {
14
switch args[0] {
15
case "pr":
15
case "pr":
16
err = runPR(args[1:])
16
err = runPR(args[1:])
17
case "repo":
17
case "repo":
18
err = runRepo(args[1:])
18
err = runRepo(args[1:])
19
case "auth":
19
case "auth":
20
err = runAuth(args[1:])
20
err = runAuth(args[1:])
21
case "embed":
21
case "embed":
22
err = runEmbed(args[1:])
22
err = runEmbed(args[1:])
23
case "one":
23
case "one":
24
err = runOne(args[1:])
24
err = runOne(args[1:])
25
case "group":
25
case "group":
26
err = runGroup(args[1:])
26
err = runGroup(args[1:])
27
case "help", "--help", "-h":
27
case "help", "--help", "-h":
28
printUsage()
28
printUsage()
29
return
29
return
30
default:
30
default:
31
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
31
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
32
printUsage()
32
printUsage()
33
os.Exit(1)
33
os.Exit(1)
34
}
34
}
35
35
36
if err != nil {
36
if err != nil {
37
fmt.Fprintf(os.Stderr, "error: %v\n", err)
37
fmt.Fprintf(os.Stderr, "error: %v\n", err)
38
os.Exit(1)
38
os.Exit(1)
39
}
39
}
40
}
40
}
41
41
42
func printUsage() {
42
func printUsage() {
43
fmt.Fprintf(os.Stderr, `NAME
43
fmt.Fprintf(os.Stderr, `NAME
44
okg — Oscar Kilo Goodness
44
okg — Oscar Kilo Goodness
45
45
46
SETUP
46
SETUP
47
okg auth login
47
okg auth login
48
--key KEY API key (also accepted via stdin)
48
--key KEY API key (also accepted via stdin)
49
--host HOST klee host (default: production)
49
--host HOST klee host (default: production)
50
50
51
GIT REPOS
51
GIT REPOS
52
okg repo list
52
okg repo list
53
--json output raw JSON
53
--json output raw JSON
54
54
55
okg repo create NAME
55
okg repo create NAME
56
--reader USER grant read to USER (default: anyone)
56
--reader USER grant read to USER (default: anyone)
57
57
58
GROUPS
58
GROUPS
59
okg group list
59
okg group list
60
--json output raw JSON
60
--json output raw JSON
61
61
62
okg group create NAME
62
okg group create NAME
63
--full-name TEXT display name (default: NAME)
63
--full-name TEXT display name (default: NAME)
64
--owner USER owner username (default: caller)
64
--owner USER owner username (default: caller)
65
65
66
okg group add-member GROUP USER [USER ...]
66
okg group add-member GROUP USER [USER ...]
67
67
68
okg group remove-member GROUP USER [USER ...]
68
okg group remove-member GROUP USER [USER ...]
69
69
70
okg group members GROUP
70
okg group members GROUP
71
--json full DAG (Up/Down/Usernames) as raw JSON
71
--json full DAG (Up/Down/Usernames) as raw JSON
72
72
73
okg group delete NAME
74
73
PULL REQUESTS
75
PULL REQUESTS
74
okg pr list
76
okg pr list
75
--state STATE open or closed (default: open)
77
--state STATE open or closed (default: open)
76
--json output raw JSON
78
--json output raw JSON
77
79
78
okg pr create
80
okg pr create
79
--head BRANCH source branch
81
--head BRANCH source branch
80
--base BRANCH target branch (default: master)
82
--base BRANCH target branch (default: master)
81
--title TITLE PR title
83
--title TITLE PR title
82
--body BODY PR body (optional)
84
--body BODY PR body (optional)
83
--json output raw JSON
85
--json output raw JSON
84
86
85
okg pr view NUMBER
87
okg pr view NUMBER
86
--json output raw JSON
88
--json output raw JSON
87
89
88
okg pr diff NUMBER
90
okg pr diff NUMBER
89
91
90
okg pr comment NUMBER
92
okg pr comment NUMBER
91
--body BODY comment body
93
--body BODY comment body
92
--approve also approve the PR
94
--approve also approve the PR
93
--request-changes also request changes
95
--request-changes also request changes
94
96
95
okg pr merge NUMBER
97
okg pr merge NUMBER
96
--json output raw JSON
98
--json output raw JSON
97
99
98
okg pr close NUMBER
100
okg pr close NUMBER
99
--json output raw JSON
101
--json output raw JSON
100
102
101
okg pr reopen NUMBER
103
okg pr reopen NUMBER
102
--json output raw JSON
104
--json output raw JSON
103
105
104
ARTIFICIAL INTELLIGENCE
106
ARTIFICIAL INTELLIGENCE
105
okg embed
107
okg embed
106
--model NAME embedding model
108
--model NAME embedding model
107
(default: openai:text-embedding-3-small)
109
(default: openai:text-embedding-3-small)
108
--dims N number of dimensions (default: 1536)
110
--dims N number of dimensions (default: 1536)
109
--full-path one vector per prefix of input
111
--full-path one vector per prefix of input
110
(reads stdin; writes vectors to stdout, one per line)
112
(reads stdin; writes vectors to stdout, one per line)
111
113
112
okg one
114
okg one
113
--model NAME override .Model in the request
115
--model NAME override .Model in the request
114
--system-file FILE override .System with contents of FILE
116
--system-file FILE override .System with contents of FILE
115
--prompt-file FILE append FILE as a user prompt
117
--prompt-file FILE append FILE as a user prompt
116
--attach FILE attach an image or PDF to the prompt
118
--attach FILE attach an image or PDF to the prompt
117
--format FORMAT text | json | jsonindent (default: text)
119
--format FORMAT text | json | jsonindent (default: text)
118
--fast-fail preflight attachment MIME (default: on)
120
--fast-fail preflight attachment MIME (default: on)
119
(reads stdin as a JSON MessagesRequest; flags override
121
(reads stdin as a JSON MessagesRequest; flags override
120
its fields)
122
its fields)
121
123
122
GLOBAL FLAGS
124
GLOBAL FLAGS
123
--repo REPO override auto-detected repo name
125
--repo REPO override auto-detected repo name
124
--json output raw JSON (where applicable)
126
--json output raw JSON (where applicable)
125
127
126
EXAMPLES
128
EXAMPLES
127
Setup
129
Setup
128
okg auth login --key sk-...
130
okg auth login --key sk-...
129
cat ~/.klex.key | okg auth login
131
cat ~/.klex.key | okg auth login
130
132
131
Git repos
133
Git repos
132
okg repo list
134
okg repo list
133
okg repo create my-new-repo
135
okg repo create my-new-repo
134
136
135
Groups
137
Groups
136
okg group list
138
okg group list
137
okg group create chat-bots
139
okg group create chat-bots
138
okg group add-member chat-bots claude openclaw
140
okg group add-member chat-bots claude openclaw
139
okg group remove-member chat-bots openclaw
141
okg group remove-member chat-bots openclaw
140
okg group members chat-bots
142
okg group members chat-bots
143
okg group delete chat-bots
141
144
142
Pull requests
145
Pull requests
143
okg pr list --state open
146
okg pr list --state open
144
okg pr view 42
147
okg pr view 42
145
okg pr comment 42 --body 'LGTM' --approve
148
okg pr comment 42 --body 'LGTM' --approve
146
149
147
Artificial intelligence
150
Artificial intelligence
148
echo 'hello world' | okg embed --dims 384
151
echo 'hello world' | okg embed --dims 384
149
echo Hello? > /tmp/q.txt && \
152
echo Hello? > /tmp/q.txt && \
150
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
153
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
151
`)
154
`)
152
}
155
}
a/who/who.go
b/who/who.go
1
// Package who is the public HTTP client for OscarKilo's //who
1
// Package who is the public HTTP client for OscarKilo's //who
2
// service. It is the world-public side of //who, used by
2
// service. It is the world-public side of //who, used by
3
// `okg` and any other tool that talks to oscarkilo.com over
3
// `okg` and any other tool that talks to oscarkilo.com over
4
// the network.
4
// the network.
5
//
5
//
6
// //clients/who (intra-cluster, has the in-process MockClient,
6
// //clients/who (intra-cluster, has the in-process MockClient,
7
// the /internal/* calls, the dev/prod factory) will eventually
7
// the /internal/* calls, the dev/prod factory) will eventually
8
// import this package via `import . "oscarkilo.com/okg/who"`
8
// import this package via `import . "oscarkilo.com/okg/who"`
9
// so the two share types and request paths. To make that dot
9
// so the two share types and request paths. To make that dot
10
// import work, the struct here is named HTTPClient — leaving
10
// import work, the struct here is named HTTPClient — leaving
11
// the unqualified `Client` name free for //clients/who's
11
// the unqualified `Client` name free for //clients/who's
12
// existing interface.
12
// existing interface.
13
package who
13
package who
14
14
15
import "oscarkilo.com/okg/internal/rest"
15
import "oscarkilo.com/okg/internal/rest"
16
16
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
17
// HTTPClient talks to a //who server over HTTPS. Embeds the
18
// shared rest helpers; who-specific methods live below.
18
// shared rest helpers; who-specific methods live below.
19
type HTTPClient struct {
19
type HTTPClient struct {
20
*rest.Client
20
*rest.Client
21
}
21
}
22
22
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
23
func NewHTTPClient(host, apiKey string) *HTTPClient {
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
24
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
25
}
25
}
26
26
27
// ---- Caller identity ----
27
// ---- Caller identity ----
28
28
29
// Profile is the caller's //who profile. Mirrors the
29
// Profile is the caller's //who profile. Mirrors the
30
// JSON-serialized subset of //who.Profile that GET
30
// JSON-serialized subset of //who.Profile that GET
31
// /login/profile/get returns. Extra fields the server may
31
// /login/profile/get returns. Extra fields the server may
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
33
// permissively if added later; we just don't expose them here
33
// permissively if added later; we just don't expose them here
34
// until okg has a use for them.
34
// until okg has a use for them.
35
type Profile struct {
35
type Profile struct {
36
Owid string `json:"owid"`
36
Owid string `json:"owid"`
37
Username string `json:"username"`
37
Username string `json:"username"`
38
Name string `json:"name"`
38
Name string `json:"name"`
39
Email string `json:"email"`
39
Email string `json:"email"`
40
PortraitUrl string `json:"portrait_url"`
40
PortraitUrl string `json:"portrait_url"`
41
OwnerOwid string `json:"owner_owid"`
41
OwnerOwid string `json:"owner_owid"`
42
Groups []string `json:"groups"`
42
Groups []string `json:"groups"`
43
}
43
}
44
44
45
// GetProfile returns the caller's own profile, identified by
45
// GetProfile returns the caller's own profile, identified by
46
// the Bearer key.
46
// the Bearer key.
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
48
var p Profile
48
var p Profile
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
50
return nil, err
50
return nil, err
51
}
51
}
52
return &p, nil
52
return &p, nil
53
}
53
}
54
54
55
// ---- Groups ----
55
// ---- Groups ----
56
56
57
// Group describes a //who group (a named entity with members).
57
// Group describes a //who group (a named entity with members).
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
59
type Group struct {
59
type Group struct {
60
Owid string `json:"owid"`
60
Owid string `json:"owid"`
61
Username string `json:"username"`
61
Username string `json:"username"`
62
Name string `json:"name"`
62
Name string `json:"name"`
63
OwnerOwid string `json:"owner_owid"`
63
OwnerOwid string `json:"owner_owid"`
64
OwnerUsername string `json:"owner_username"`
64
OwnerUsername string `json:"owner_username"`
65
}
65
}
66
66
67
type listGroupsResponse struct {
67
type listGroupsResponse struct {
68
Groups []Group `json:"groups"`
68
Groups []Group `json:"groups"`
69
}
69
}
70
70
71
// ListGroups returns the groups visible to the caller.
71
// ListGroups returns the groups visible to the caller.
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
73
var res listGroupsResponse
73
var res listGroupsResponse
74
if err := c.GetJSON("/groups/list", &res); err != nil {
74
if err := c.GetJSON("/groups/list", &res); err != nil {
75
return nil, err
75
return nil, err
76
}
76
}
77
return res.Groups, nil
77
return res.Groups, nil
78
}
78
}
79
79
80
// CreateGroupRequest is the body for POST /groups/add. Username
80
// CreateGroupRequest is the body for POST /groups/add. Username
81
// is the group's //who username (e.g. "team"); Name is the
81
// is the group's //who username (e.g. "team"); Name is the
82
// human-facing display name; OwnerUsername is the user who'll
82
// human-facing display name; OwnerUsername is the user who'll
83
// own the new group.
83
// own the new group.
84
type CreateGroupRequest struct {
84
type CreateGroupRequest struct {
85
Username string `json:"username"`
85
Username string `json:"username"`
86
Name string `json:"name"`
86
Name string `json:"name"`
87
OwnerUsername string `json:"owner_username"`
87
OwnerUsername string `json:"owner_username"`
88
}
88
}
89
89
90
// CreateGroup creates a new //who group.
90
// CreateGroup creates a new //who group.
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
92
return c.PostJSON("/groups/add", req, nil)
92
return c.PostJSON("/groups/add", req, nil)
93
}
93
}
94
94
95
// JoinGroupsRequest is the body for POST /groups/join. The
95
// JoinGroupsRequest is the body for POST /groups/join. The
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
97
// pair, requiring the caller to be an owner of each group.
97
// pair, requiring the caller to be an owner of each group.
98
type JoinGroupsRequest struct {
98
type JoinGroupsRequest struct {
99
GroupUsernames []string `json:"group_usernames"`
99
GroupUsernames []string `json:"group_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
101
}
101
}
102
102
103
// JoinGroups adds members to groups (all pairwise combinations).
103
// JoinGroups adds members to groups (all pairwise combinations).
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
105
return c.PostJSON("/groups/join", req, nil)
105
return c.PostJSON("/groups/join", req, nil)
106
}
106
}
107
107
108
// GroupMembersResponse is the response from POST
108
// GroupMembersResponse is the response from POST
109
// /groups/members. Up lists the groups that contain the queried
109
// /groups/members. Up lists the groups that contain the queried
110
// entity (each level as a slice of owids). Down lists its
110
// entity (each level as a slice of owids). Down lists its
111
// members the same way. Usernames maps every owid mentioned to
111
// members the same way. Usernames maps every owid mentioned to
112
// its //who username.
112
// its //who username.
113
type GroupMembersResponse struct {
113
type GroupMembersResponse struct {
114
Up [][]string `json:"up"`
114
Up [][]string `json:"up"`
115
Down [][]string `json:"down"`
115
Down [][]string `json:"down"`
116
Usernames map[string]string `json:"usernames"`
116
Usernames map[string]string `json:"usernames"`
117
}
117
}
118
118
119
// GroupMembers returns membership DAG navigation for the given
119
// GroupMembers returns membership DAG navigation for the given
120
// entity owid. For a group, Down enumerates its members.
120
// entity owid. For a group, Down enumerates its members.
121
func (c *HTTPClient) GroupMembers(
121
func (c *HTTPClient) GroupMembers(
122
ownerOwid string,
122
ownerOwid string,
123
) (*GroupMembersResponse, error) {
123
) (*GroupMembersResponse, error) {
124
var res GroupMembersResponse
124
var res GroupMembersResponse
125
err := c.PostJSON("/groups/members",
125
err := c.PostJSON("/groups/members",
126
map[string]string{"owid": ownerOwid}, &res)
126
map[string]string{"owid": ownerOwid}, &res)
127
if err != nil {
127
if err != nil {
128
return nil, err
128
return nil, err
129
}
129
}
130
return &res, nil
130
return &res, nil
131
}
131
}
132
132
133
// LeaveGroupRequest is the body for POST /groups/leave. Both
133
// LeaveGroupRequest is the body for POST /groups/leave. Both
134
// IDs are owids (obfuscated wids); to translate from usernames,
134
// IDs are owids (obfuscated wids); to translate from usernames,
135
// call ListGroups + GroupMembers first.
135
// call ListGroups + GroupMembers first.
136
type LeaveGroupRequest struct {
136
type LeaveGroupRequest struct {
137
GroupOwid string `json:"group_owid"`
137
GroupOwid string `json:"group_owid"`
138
MemberOwid string `json:"member_owid"`
138
MemberOwid string `json:"member_owid"`
139
}
139
}
140
140
141
// LeaveGroup removes a member from a group. The caller must own
141
// LeaveGroup removes a member from a group. The caller must own
142
// the group.
142
// the group.
143
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
143
func (c *HTTPClient) LeaveGroup(req LeaveGroupRequest) error {
144
return c.PostJSON("/groups/leave", req, nil)
144
return c.PostJSON("/groups/leave", req, nil)
145
}
145
}
146
147
// DeleteGroupRequest is the body for POST /groups/delete. Owid
148
// is the group's obfuscated wid.
149
type DeleteGroupRequest struct {
150
Owid string `json:"owid"`
151
}
152
153
// DeleteGroup destroys a group. The caller must own it (or be a
154
// //who admin).
155
func (c *HTTPClient) DeleteGroup(req DeleteGroupRequest) error {
156
return c.PostJSON("/groups/delete", req, nil)
157
}
a/who/who_test.go
b/who/who_test.go
1
package who
1
package who
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "net/http"
4
import "net/http"
5
import "net/http/httptest"
5
import "net/http/httptest"
6
import "testing"
6
import "testing"
7
7
8
func TestListGroups(t *testing.T) {
8
func TestListGroups(t *testing.T) {
9
srv := httptest.NewServer(http.HandlerFunc(
9
srv := httptest.NewServer(http.HandlerFunc(
10
func(w http.ResponseWriter, r *http.Request) {
10
func(w http.ResponseWriter, r *http.Request) {
11
if r.URL.Path != "/groups/list" {
11
if r.URL.Path != "/groups/list" {
12
t.Errorf("path: got %q, want /groups/list",
12
t.Errorf("path: got %q, want /groups/list",
13
r.URL.Path)
13
r.URL.Path)
14
}
14
}
15
if r.Header.Get("Authorization") !=
15
if r.Header.Get("Authorization") !=
16
"Bearer test-key" {
16
"Bearer test-key" {
17
t.Errorf("Authorization: got %q",
17
t.Errorf("Authorization: got %q",
18
r.Header.Get("Authorization"))
18
r.Header.Get("Authorization"))
19
}
19
}
20
w.Header().Set(
20
w.Header().Set(
21
"Content-Type", "application/json")
21
"Content-Type", "application/json")
22
json.NewEncoder(w).Encode(listGroupsResponse{
22
json.NewEncoder(w).Encode(listGroupsResponse{
23
Groups: []Group{
23
Groups: []Group{
24
{
24
{
25
Username: "team",
25
Username: "team",
26
Name: "Team",
26
Name: "Team",
27
OwnerUsername: "alice",
27
OwnerUsername: "alice",
28
},
28
},
29
{
29
{
30
Username: "admins",
30
Username: "admins",
31
Name: "Admins",
31
Name: "Admins",
32
OwnerUsername: "alice",
32
OwnerUsername: "alice",
33
},
33
},
34
},
34
},
35
})
35
})
36
}))
36
}))
37
defer srv.Close()
37
defer srv.Close()
38
38
39
c := NewHTTPClient(srv.URL, "test-key")
39
c := NewHTTPClient(srv.URL, "test-key")
40
groups, err := c.ListGroups()
40
groups, err := c.ListGroups()
41
if err != nil {
41
if err != nil {
42
t.Fatal(err)
42
t.Fatal(err)
43
}
43
}
44
if len(groups) != 2 {
44
if len(groups) != 2 {
45
t.Fatalf("len: got %d, want 2", len(groups))
45
t.Fatalf("len: got %d, want 2", len(groups))
46
}
46
}
47
if groups[0].Username != "team" {
47
if groups[0].Username != "team" {
48
t.Errorf(
48
t.Errorf(
49
"groups[0].Username: got %q, want team",
49
"groups[0].Username: got %q, want team",
50
groups[0].Username)
50
groups[0].Username)
51
}
51
}
52
if groups[1].OwnerUsername != "alice" {
52
if groups[1].OwnerUsername != "alice" {
53
t.Errorf(
53
t.Errorf(
54
"groups[1].OwnerUsername: got %q, want alice",
54
"groups[1].OwnerUsername: got %q, want alice",
55
groups[1].OwnerUsername)
55
groups[1].OwnerUsername)
56
}
56
}
57
}
57
}
58
58
59
func TestListGroupsHTTPError(t *testing.T) {
59
func TestListGroupsHTTPError(t *testing.T) {
60
srv := httptest.NewServer(http.HandlerFunc(
60
srv := httptest.NewServer(http.HandlerFunc(
61
func(w http.ResponseWriter, r *http.Request) {
61
func(w http.ResponseWriter, r *http.Request) {
62
w.WriteHeader(401)
62
w.WriteHeader(401)
63
w.Write([]byte("not authenticated"))
63
w.Write([]byte("not authenticated"))
64
}))
64
}))
65
defer srv.Close()
65
defer srv.Close()
66
66
67
c := NewHTTPClient(srv.URL, "bad-key")
67
c := NewHTTPClient(srv.URL, "bad-key")
68
_, err := c.ListGroups()
68
_, err := c.ListGroups()
69
if err == nil {
69
if err == nil {
70
t.Fatal("want error on 401, got nil")
70
t.Fatal("want error on 401, got nil")
71
}
71
}
72
}
72
}
73
73
74
func TestGetProfile(t *testing.T) {
74
func TestGetProfile(t *testing.T) {
75
srv := httptest.NewServer(http.HandlerFunc(
75
srv := httptest.NewServer(http.HandlerFunc(
76
func(w http.ResponseWriter, r *http.Request) {
76
func(w http.ResponseWriter, r *http.Request) {
77
if r.URL.Path != "/login/profile/get" {
77
if r.URL.Path != "/login/profile/get" {
78
t.Errorf(
78
t.Errorf(
79
"path: got %q, want /login/profile/get",
79
"path: got %q, want /login/profile/get",
80
r.URL.Path)
80
r.URL.Path)
81
}
81
}
82
w.Header().Set(
82
w.Header().Set(
83
"Content-Type", "application/json")
83
"Content-Type", "application/json")
84
json.NewEncoder(w).Encode(Profile{
84
json.NewEncoder(w).Encode(Profile{
85
Username: "alice",
85
Username: "alice",
86
Name: "Alice Liddell",
86
Name: "Alice Liddell",
87
87
88
Groups: []string{"team", "admins"},
88
Groups: []string{"team", "admins"},
89
})
89
})
90
}))
90
}))
91
defer srv.Close()
91
defer srv.Close()
92
92
93
c := NewHTTPClient(srv.URL, "test-key")
93
c := NewHTTPClient(srv.URL, "test-key")
94
p, err := c.GetProfile()
94
p, err := c.GetProfile()
95
if err != nil {
95
if err != nil {
96
t.Fatal(err)
96
t.Fatal(err)
97
}
97
}
98
if p.Username != "alice" {
98
if p.Username != "alice" {
99
t.Errorf(
99
t.Errorf(
100
"Username: got %q, want alice", p.Username)
100
"Username: got %q, want alice", p.Username)
101
}
101
}
102
if len(p.Groups) != 2 {
102
if len(p.Groups) != 2 {
103
t.Errorf(
103
t.Errorf(
104
"Groups: got %d, want 2", len(p.Groups))
104
"Groups: got %d, want 2", len(p.Groups))
105
}
105
}
106
}
106
}
107
107
108
func TestCreateGroup(t *testing.T) {
108
func TestCreateGroup(t *testing.T) {
109
var seen CreateGroupRequest
109
var seen CreateGroupRequest
110
srv := httptest.NewServer(http.HandlerFunc(
110
srv := httptest.NewServer(http.HandlerFunc(
111
func(w http.ResponseWriter, r *http.Request) {
111
func(w http.ResponseWriter, r *http.Request) {
112
if r.URL.Path != "/groups/add" {
112
if r.URL.Path != "/groups/add" {
113
t.Errorf(
113
t.Errorf(
114
"path: got %q, want /groups/add",
114
"path: got %q, want /groups/add",
115
r.URL.Path)
115
r.URL.Path)
116
}
116
}
117
if r.Method != "POST" {
117
if r.Method != "POST" {
118
t.Errorf("method: got %q, want POST", r.Method)
118
t.Errorf("method: got %q, want POST", r.Method)
119
}
119
}
120
json.NewDecoder(r.Body).Decode(&seen)
120
json.NewDecoder(r.Body).Decode(&seen)
121
w.WriteHeader(200)
121
w.WriteHeader(200)
122
}))
122
}))
123
defer srv.Close()
123
defer srv.Close()
124
124
125
c := NewHTTPClient(srv.URL, "test-key")
125
c := NewHTTPClient(srv.URL, "test-key")
126
err := c.CreateGroup(CreateGroupRequest{
126
err := c.CreateGroup(CreateGroupRequest{
127
Username: "team",
127
Username: "team",
128
Name: "Team",
128
Name: "Team",
129
OwnerUsername: "alice",
129
OwnerUsername: "alice",
130
})
130
})
131
if err != nil {
131
if err != nil {
132
t.Fatal(err)
132
t.Fatal(err)
133
}
133
}
134
if seen.Username != "team" {
134
if seen.Username != "team" {
135
t.Errorf(
135
t.Errorf(
136
"Username: got %q, want team", seen.Username)
136
"Username: got %q, want team", seen.Username)
137
}
137
}
138
if seen.OwnerUsername != "alice" {
138
if seen.OwnerUsername != "alice" {
139
t.Errorf(
139
t.Errorf(
140
"OwnerUsername: got %q, want alice",
140
"OwnerUsername: got %q, want alice",
141
seen.OwnerUsername)
141
seen.OwnerUsername)
142
}
142
}
143
}
143
}
144
144
145
func TestGroupMembers(t *testing.T) {
145
func TestGroupMembers(t *testing.T) {
146
srv := httptest.NewServer(http.HandlerFunc(
146
srv := httptest.NewServer(http.HandlerFunc(
147
func(w http.ResponseWriter, r *http.Request) {
147
func(w http.ResponseWriter, r *http.Request) {
148
if r.URL.Path != "/groups/members" {
148
if r.URL.Path != "/groups/members" {
149
t.Errorf(
149
t.Errorf(
150
"path: got %q, want /groups/members",
150
"path: got %q, want /groups/members",
151
r.URL.Path)
151
r.URL.Path)
152
}
152
}
153
var body map[string]string
153
var body map[string]string
154
json.NewDecoder(r.Body).Decode(&body)
154
json.NewDecoder(r.Body).Decode(&body)
155
if body["owid"] != "team-owid" {
155
if body["owid"] != "team-owid" {
156
t.Errorf(
156
t.Errorf(
157
"owid: got %q, want team-owid", body["owid"])
157
"owid: got %q, want team-owid", body["owid"])
158
}
158
}
159
json.NewEncoder(w).Encode(GroupMembersResponse{
159
json.NewEncoder(w).Encode(GroupMembersResponse{
160
Down: [][]string{
160
Down: [][]string{
161
{"alice-owid", "bob-owid"},
161
{"alice-owid", "bob-owid"},
162
},
162
},
163
Usernames: map[string]string{
163
Usernames: map[string]string{
164
"alice-owid": "alice",
164
"alice-owid": "alice",
165
"bob-owid": "bob",
165
"bob-owid": "bob",
166
},
166
},
167
})
167
})
168
}))
168
}))
169
defer srv.Close()
169
defer srv.Close()
170
170
171
c := NewHTTPClient(srv.URL, "test-key")
171
c := NewHTTPClient(srv.URL, "test-key")
172
res, err := c.GroupMembers("team-owid")
172
res, err := c.GroupMembers("team-owid")
173
if err != nil {
173
if err != nil {
174
t.Fatal(err)
174
t.Fatal(err)
175
}
175
}
176
if res.Usernames["alice-owid"] != "alice" {
176
if res.Usernames["alice-owid"] != "alice" {
177
t.Errorf(
177
t.Errorf(
178
"Usernames[alice-owid]: got %q",
178
"Usernames[alice-owid]: got %q",
179
res.Usernames["alice-owid"])
179
res.Usernames["alice-owid"])
180
}
180
}
181
}
181
}
182
182
183
func TestLeaveGroup(t *testing.T) {
183
func TestLeaveGroup(t *testing.T) {
184
var seen LeaveGroupRequest
184
var seen LeaveGroupRequest
185
srv := httptest.NewServer(http.HandlerFunc(
185
srv := httptest.NewServer(http.HandlerFunc(
186
func(w http.ResponseWriter, r *http.Request) {
186
func(w http.ResponseWriter, r *http.Request) {
187
if r.URL.Path != "/groups/leave" {
187
if r.URL.Path != "/groups/leave" {
188
t.Errorf(
188
t.Errorf(
189
"path: got %q, want /groups/leave",
189
"path: got %q, want /groups/leave",
190
r.URL.Path)
190
r.URL.Path)
191
}
191
}
192
json.NewDecoder(r.Body).Decode(&seen)
192
json.NewDecoder(r.Body).Decode(&seen)
193
w.WriteHeader(204)
193
w.WriteHeader(204)
194
}))
194
}))
195
defer srv.Close()
195
defer srv.Close()
196
196
197
c := NewHTTPClient(srv.URL, "test-key")
197
c := NewHTTPClient(srv.URL, "test-key")
198
err := c.LeaveGroup(LeaveGroupRequest{
198
err := c.LeaveGroup(LeaveGroupRequest{
199
GroupOwid: "team-owid",
199
GroupOwid: "team-owid",
200
MemberOwid: "alice-owid",
200
MemberOwid: "alice-owid",
201
})
201
})
202
if err != nil {
202
if err != nil {
203
t.Fatal(err)
203
t.Fatal(err)
204
}
204
}
205
if seen.GroupOwid != "team-owid" {
205
if seen.GroupOwid != "team-owid" {
206
t.Errorf(
206
t.Errorf(
207
"GroupOwid: got %q", seen.GroupOwid)
207
"GroupOwid: got %q", seen.GroupOwid)
208
}
208
}
209
if seen.MemberOwid != "alice-owid" {
209
if seen.MemberOwid != "alice-owid" {
210
t.Errorf(
210
t.Errorf(
211
"MemberOwid: got %q", seen.MemberOwid)
211
"MemberOwid: got %q", seen.MemberOwid)
212
}
212
}
213
}
213
}
214
214
215
func TestDeleteGroup(t *testing.T) {
216
var seen DeleteGroupRequest
217
srv := httptest.NewServer(http.HandlerFunc(
218
func(w http.ResponseWriter, r *http.Request) {
219
if r.URL.Path != "/groups/delete" {
220
t.Errorf(
221
"path: got %q, want /groups/delete",
222
r.URL.Path)
223
}
224
json.NewDecoder(r.Body).Decode(&seen)
225
w.WriteHeader(200)
226
}))
227
defer srv.Close()
228
229
c := NewHTTPClient(srv.URL, "test-key")
230
err := c.DeleteGroup(DeleteGroupRequest{
231
Owid: "team-owid",
232
})
233
if err != nil {
234
t.Fatal(err)
235
}
236
if seen.Owid != "team-owid" {
237
t.Errorf("Owid: got %q, want team-owid", seen.Owid)
238
}
239
}
240
215
func TestJoinGroups(t *testing.T) {
241
func TestJoinGroups(t *testing.T) {
216
var seen JoinGroupsRequest
242
var seen JoinGroupsRequest
217
srv := httptest.NewServer(http.HandlerFunc(
243
srv := httptest.NewServer(http.HandlerFunc(
218
func(w http.ResponseWriter, r *http.Request) {
244
func(w http.ResponseWriter, r *http.Request) {
219
if r.URL.Path != "/groups/join" {
245
if r.URL.Path != "/groups/join" {
220
t.Errorf(
246
t.Errorf(
221
"path: got %q, want /groups/join",
247
"path: got %q, want /groups/join",
222
r.URL.Path)
248
r.URL.Path)
223
}
249
}
224
if r.Method != "POST" {
250
if r.Method != "POST" {
225
t.Errorf("method: got %q, want POST", r.Method)
251
t.Errorf("method: got %q, want POST", r.Method)
226
}
252
}
227
json.NewDecoder(r.Body).Decode(&seen)
253
json.NewDecoder(r.Body).Decode(&seen)
228
w.WriteHeader(200)
254
w.WriteHeader(200)
229
}))
255
}))
230
defer srv.Close()
256
defer srv.Close()
231
257
232
c := NewHTTPClient(srv.URL, "test-key")
258
c := NewHTTPClient(srv.URL, "test-key")
233
err := c.JoinGroups(JoinGroupsRequest{
259
err := c.JoinGroups(JoinGroupsRequest{
234
GroupUsernames: []string{"team"},
260
GroupUsernames: []string{"team"},
235
MemberUsernames: []string{"alice", "bob"},
261
MemberUsernames: []string{"alice", "bob"},
236
})
262
})
237
if err != nil {
263
if err != nil {
238
t.Fatal(err)
264
t.Fatal(err)
239
}
265
}
240
if len(seen.GroupUsernames) != 1 ||
266
if len(seen.GroupUsernames) != 1 ||
241
seen.GroupUsernames[0] != "team" {
267
seen.GroupUsernames[0] != "team" {
242
t.Errorf(
268
t.Errorf(
243
"GroupUsernames: got %v, want [team]",
269
"GroupUsernames: got %v, want [team]",
244
seen.GroupUsernames)
270
seen.GroupUsernames)
245
}
271
}
246
if len(seen.MemberUsernames) != 2 {
272
if len(seen.MemberUsernames) != 2 {
247
t.Errorf(
273
t.Errorf(
248
"MemberUsernames: got %v, want 2 members",
274
"MemberUsernames: got %v, want 2 members",
249
seen.MemberUsernames)
275
seen.MemberUsernames)
250
}
276
}
251
}
277
}