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
62
62
okg embed [--model NAME] [--dims N] [--full-path]
63
okg embed [--model NAME] [--dims N] [--full-path]
63
(reads stdin → vectors on stdout)
64
(reads stdin → vectors on stdout)
64
okg one [--model NAME] [--system-file FILE] \
65
okg one [--model NAME] [--system-file FILE] \
65
[--prompt-file FILE] [--attach FILE] \
66
[--prompt-file FILE] [--attach FILE] \
66
[--format text|json|jsonindent] [--fast-fail]
67
[--format text|json|jsonindent] [--fast-fail]
67
(reads stdin as a JSON MessagesRequest)
68
(reads stdin as a JSON MessagesRequest)
68
```
69
```
69
70
70
### Coming next
71
### Coming next
71
72
72
- `okg exemplary` — few-shot batch runner (from
73
- `okg exemplary` — few-shot batch runner (from
73
`klex-git/exemplary`).
74
`klex-git/exemplary`).
74
75
75
Once that lands, the standalone `klex-git` binaries are
76
Once that lands, the standalone `klex-git` binaries are
76
deprecated.
77
deprecated.
77
78
78
### Flags
79
### Flags
79
80
80
- `--repo REPO` overrides auto-detected repo name
81
- `--repo REPO` overrides auto-detected repo name
81
(normally parsed from `git remote get-url origin`)
82
(normally parsed from `git remote get-url origin`)
82
- `--json` outputs raw JSON for any command
83
- `--json` outputs raw JSON for any command
83
- `OKG_REPO` env var also overrides repo detection
84
- `OKG_REPO` env var also overrides repo detection
84
85
85
## Repo Detection
86
## Repo Detection
86
87
87
Like `gh`, okg detects the repo from the current directory's
88
Like `gh`, okg detects the repo from the current directory's
88
git remote:
89
git remote:
89
90
90
```
91
```
91
git remote get-url origin
92
git remote get-url origin
92
→ https://code.oscarkilo.com/widget.git
93
→ https://code.oscarkilo.com/widget.git
93
→ repo = "widget"
94
→ repo = "widget"
94
```
95
```
95
96
96
## Dependencies
97
## Dependencies
97
98
98
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
99
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
99
to be folded in once all `klex-git` binaries have moved here).
100
to be folded in once all `klex-git` binaries have moved here).
100
- Otherwise Go standard library only.
101
- Otherwise Go standard library only.
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":
23
return runGroupAddMember(args[1:])
22
default:
24
default:
23
return fmt.Errorf(
25
return fmt.Errorf(
24
"unknown group subcommand: %s", args[0])
26
"unknown group subcommand: %s", args[0])
25
}
27
}
26
}
28
}
27
29
28
func runGroupList(args []string) error {
30
func runGroupList(args []string) error {
29
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
31
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
30
asJSON := fs.Bool("json", false, "output raw JSON")
32
asJSON := fs.Bool("json", false, "output raw JSON")
31
if err := fs.Parse(args); err != nil {
33
if err := fs.Parse(args); err != nil {
32
return err
34
return err
33
}
35
}
34
36
35
c, err := newWhoClient()
37
c, err := newWhoClient()
36
if err != nil {
38
if err != nil {
37
return err
39
return err
38
}
40
}
39
41
40
groups, err := c.ListGroups()
42
groups, err := c.ListGroups()
41
if err != nil {
43
if err != nil {
42
return err
44
return err
43
}
45
}
44
46
45
if *asJSON {
47
if *asJSON {
46
buf, err := json.MarshalIndent(groups, "", " ")
48
buf, err := json.MarshalIndent(groups, "", " ")
47
if err != nil {
49
if err != nil {
48
return err
50
return err
49
}
51
}
50
fmt.Println(string(buf))
52
fmt.Println(string(buf))
51
return nil
53
return nil
52
}
54
}
53
55
54
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
56
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
55
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
57
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
56
for _, g := range groups {
58
for _, g := range groups {
57
fmt.Fprintf(tw, "%s\t%s\t%s\n",
59
fmt.Fprintf(tw, "%s\t%s\t%s\n",
58
g.Username, g.Name, g.OwnerUsername)
60
g.Username, g.Name, g.OwnerUsername)
59
}
61
}
60
return tw.Flush()
62
return tw.Flush()
61
}
63
}
62
64
63
func runGroupCreate(args []string) error {
65
func runGroupCreate(args []string) error {
64
fs := flag.NewFlagSet(
66
fs := flag.NewFlagSet(
65
"group create", flag.ContinueOnError)
67
"group create", flag.ContinueOnError)
66
fullName := fs.String("full-name", "",
68
fullName := fs.String("full-name", "",
67
"display name (default: NAME)")
69
"display name (default: NAME)")
68
owner := fs.String("owner", "",
70
owner := fs.String("owner", "",
69
"owner username (default: caller)")
71
"owner username (default: caller)")
70
if err := fs.Parse(args); err != nil {
72
if err := fs.Parse(args); err != nil {
71
return err
73
return err
72
}
74
}
73
positional := fs.Args()
75
positional := fs.Args()
74
if len(positional) != 1 {
76
if len(positional) != 1 {
75
return fmt.Errorf("usage: okg group create NAME")
77
return fmt.Errorf("usage: okg group create NAME")
76
}
78
}
77
name := positional[0]
79
name := positional[0]
78
80
79
c, err := newWhoClient()
81
c, err := newWhoClient()
80
if err != nil {
82
if err != nil {
81
return err
83
return err
82
}
84
}
83
85
84
if *owner == "" {
86
if *owner == "" {
85
me, err := c.GetProfile()
87
me, err := c.GetProfile()
86
if err != nil {
88
if err != nil {
87
return fmt.Errorf("resolve caller: %v", err)
89
return fmt.Errorf("resolve caller: %v", err)
88
}
90
}
89
*owner = me.Username
91
*owner = me.Username
90
}
92
}
91
93
92
displayName := *fullName
94
displayName := *fullName
93
if displayName == "" {
95
if displayName == "" {
94
displayName = name
96
displayName = name
95
}
97
}
96
98
97
if err := c.CreateGroup(who.CreateGroupRequest{
99
if err := c.CreateGroup(who.CreateGroupRequest{
98
Username: name,
100
Username: name,
99
Name: displayName,
101
Name: displayName,
100
OwnerUsername: *owner,
102
OwnerUsername: *owner,
101
}); err != nil {
103
}); err != nil {
102
return err
104
return err
103
}
105
}
104
fmt.Printf(
106
fmt.Printf(
105
"Created group %s (owner: %s)\n", name, *owner)
107
"Created group %s (owner: %s)\n", name, *owner)
106
return nil
108
return nil
107
}
109
}
108
110
111
func runGroupAddMember(args []string) error {
112
fs := flag.NewFlagSet(
113
"group add-member", flag.ContinueOnError)
114
if err := fs.Parse(args); err != nil {
115
return err
116
}
117
positional := fs.Args()
118
if len(positional) < 2 {
119
return fmt.Errorf(
120
"usage: okg group add-member GROUP USER [USER ...]")
121
}
122
group := positional[0]
123
members := positional[1:]
124
125
c, err := newWhoClient()
126
if err != nil {
127
return err
128
}
129
if err := c.JoinGroups(who.JoinGroupsRequest{
130
GroupUsernames: []string{group},
131
MemberUsernames: members,
132
}); err != nil {
133
return err
134
}
135
fmt.Printf(
136
"Added %d member(s) to %s\n", len(members), group)
137
return nil
138
}
139
109
// newWhoClient builds a //who client from saved config. Shared
140
// newWhoClient builds a //who client from saved config. Shared
110
// by every `okg group` subcommand.
141
// by every `okg group` subcommand.
111
func newWhoClient() (*who.HTTPClient, error) {
142
func newWhoClient() (*who.HTTPClient, error) {
112
cfg, err := loadConfig()
143
cfg, err := loadConfig()
113
if err != nil {
144
if err != nil {
114
return nil, err
145
return nil, err
115
}
146
}
116
if cfg.ApiKey == "" {
147
if cfg.ApiKey == "" {
117
return nil, fmt.Errorf(
148
return nil, fmt.Errorf(
118
"no API key — run `okg auth login --key sk-...`")
149
"no API key — run `okg auth login --key sk-...`")
119
}
150
}
120
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
151
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
121
}
152
}
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 ...]
67
66
PULL REQUESTS
68
PULL REQUESTS
67
okg pr list
69
okg pr list
68
--state STATE open or closed (default: open)
70
--state STATE open or closed (default: open)
69
--json output raw JSON
71
--json output raw JSON
70
72
71
okg pr create
73
okg pr create
72
--head BRANCH source branch
74
--head BRANCH source branch
73
--base BRANCH target branch (default: master)
75
--base BRANCH target branch (default: master)
74
--title TITLE PR title
76
--title TITLE PR title
75
--body BODY PR body (optional)
77
--body BODY PR body (optional)
76
--json output raw JSON
78
--json output raw JSON
77
79
78
okg pr view NUMBER
80
okg pr view NUMBER
79
--json output raw JSON
81
--json output raw JSON
80
82
81
okg pr diff NUMBER
83
okg pr diff NUMBER
82
84
83
okg pr comment NUMBER
85
okg pr comment NUMBER
84
--body BODY comment body
86
--body BODY comment body
85
--approve also approve the PR
87
--approve also approve the PR
86
--request-changes also request changes
88
--request-changes also request changes
87
89
88
okg pr merge NUMBER
90
okg pr merge NUMBER
89
--json output raw JSON
91
--json output raw JSON
90
92
91
okg pr close NUMBER
93
okg pr close NUMBER
92
--json output raw JSON
94
--json output raw JSON
93
95
94
okg pr reopen NUMBER
96
okg pr reopen NUMBER
95
--json output raw JSON
97
--json output raw JSON
96
98
97
ARTIFICIAL INTELLIGENCE
99
ARTIFICIAL INTELLIGENCE
98
okg embed
100
okg embed
99
--model NAME embedding model
101
--model NAME embedding model
100
(default: openai:text-embedding-3-small)
102
(default: openai:text-embedding-3-small)
101
--dims N number of dimensions (default: 1536)
103
--dims N number of dimensions (default: 1536)
102
--full-path one vector per prefix of input
104
--full-path one vector per prefix of input
103
(reads stdin; writes vectors to stdout, one per line)
105
(reads stdin; writes vectors to stdout, one per line)
104
106
105
okg one
107
okg one
106
--model NAME override .Model in the request
108
--model NAME override .Model in the request
107
--system-file FILE override .System with contents of FILE
109
--system-file FILE override .System with contents of FILE
108
--prompt-file FILE append FILE as a user prompt
110
--prompt-file FILE append FILE as a user prompt
109
--attach FILE attach an image or PDF to the prompt
111
--attach FILE attach an image or PDF to the prompt
110
--format FORMAT text | json | jsonindent (default: text)
112
--format FORMAT text | json | jsonindent (default: text)
111
--fast-fail preflight attachment MIME (default: on)
113
--fast-fail preflight attachment MIME (default: on)
112
(reads stdin as a JSON MessagesRequest; flags override
114
(reads stdin as a JSON MessagesRequest; flags override
113
its fields)
115
its fields)
114
116
115
GLOBAL FLAGS
117
GLOBAL FLAGS
116
--repo REPO override auto-detected repo name
118
--repo REPO override auto-detected repo name
117
--json output raw JSON (where applicable)
119
--json output raw JSON (where applicable)
118
120
119
EXAMPLES
121
EXAMPLES
120
Setup
122
Setup
121
okg auth login --key sk-...
123
okg auth login --key sk-...
122
cat ~/.klex.key | okg auth login
124
cat ~/.klex.key | okg auth login
123
125
124
Git repos
126
Git repos
125
okg repo list
127
okg repo list
126
okg repo create my-new-repo
128
okg repo create my-new-repo
127
129
128
Groups
130
Groups
129
okg group list
131
okg group list
130
okg group create chat-bots
132
okg group create chat-bots
133
okg group add-member chat-bots claude openclaw
131
134
132
Pull requests
135
Pull requests
133
okg pr list --state open
136
okg pr list --state open
134
okg pr view 42
137
okg pr view 42
135
okg pr comment 42 --body 'LGTM' --approve
138
okg pr comment 42 --body 'LGTM' --approve
136
139
137
Artificial intelligence
140
Artificial intelligence
138
echo 'hello world' | okg embed --dims 384
141
echo 'hello world' | okg embed --dims 384
139
echo Hello? > /tmp/q.txt && \
142
echo Hello? > /tmp/q.txt && \
140
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
143
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
141
`)
144
`)
142
}
145
}
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
95
// JoinGroupsRequest is the body for POST /groups/join. The
96
// server adds every (GroupUsernames[i], MemberUsernames[j])
97
// pair, requiring the caller to be an owner of each group.
98
type JoinGroupsRequest struct {
99
GroupUsernames []string `json:"group_usernames"`
100
MemberUsernames []string `json:"member_usernames"`
101
}
102
103
// JoinGroups adds members to groups (all pairwise combinations).
104
func (c *HTTPClient) JoinGroups(req JoinGroupsRequest) error {
105
return c.PostJSON("/groups/join", req, nil)
106
}
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
145
func TestJoinGroups(t *testing.T) {
146
var seen JoinGroupsRequest
147
srv := httptest.NewServer(http.HandlerFunc(
148
func(w http.ResponseWriter, r *http.Request) {
149
if r.URL.Path != "/groups/join" {
150
t.Errorf(
151
"path: got %q, want /groups/join",
152
r.URL.Path)
153
}
154
if r.Method != "POST" {
155
t.Errorf("method: got %q, want POST", r.Method)
156
}
157
json.NewDecoder(r.Body).Decode(&seen)
158
w.WriteHeader(200)
159
}))
160
defer srv.Close()
161
162
c := NewHTTPClient(srv.URL, "test-key")
163
err := c.JoinGroups(JoinGroupsRequest{
164
GroupUsernames: []string{"team"},
165
MemberUsernames: []string{"alice", "bob"},
166
})
167
if err != nil {
168
t.Fatal(err)
169
}
170
if len(seen.GroupUsernames) != 1 ||
171
seen.GroupUsernames[0] != "team" {
172
t.Errorf(
173
"GroupUsernames: got %v, want [team]",
174
seen.GroupUsernames)
175
}
176
if len(seen.MemberUsernames) != 2 {
177
t.Errorf(
178
"MemberUsernames: got %v, want 2 members",
179
seen.MemberUsernames)
180
}
181
}