code.oscarkilo.com/okg

Hash:
05b47fee0e045f369f4e3f00f814b19b3e35f06a
Author:
Igor Naverniouk <[email protected]>
Date:
Thu Jun 4 20:48:15 2026 -0400
Message:
okg: add `okg group create` (auto-resolves owner from caller) POST /groups/add via //okg/who's new CreateGroup method. CLI shape: `okg group create NAME [--full-name TEXT] [--owner USER]`. If --owner is omitted, okg first GETs /login/profile/get to resolve the caller's username and uses that. The lookup happens at command time, not cached, so a username rename doesn't drift the stored config. Also exposes Profile + GetProfile on //okg/who, since group create needs them and future agent commands will too. Profile omits ApiKeys/Logins/AppData/timestamps for now — we'll surface them when there's a use. Tests: - TestCreateGroup pins the POST path + body shape. - TestGetProfile pins the GET path + JSON decode.
diff --git a/README.md b/README.md
index b936a71..259e4ed 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,8 @@ okg pr reopen NUMBER

okg auth login [--key KEY] [--host HOST]

-okg group list [--json]
+okg group list [--json]
+okg group create NAME [--full-name TEXT] [--owner USER]

okg embed [--model NAME] [--dims N] [--full-path]
(reads stdin → vectors on stdout)
diff --git a/group.go b/group.go
index 736a132..1aa487c 100644
--- a/group.go
+++ b/group.go
@@ -11,11 +11,14 @@ import "oscarkilo.com/okg/who"
func runGroup(args []string) error {
if len(args) == 0 {
return fmt.Errorf(
- "usage: okg group SUBCOMMAND ... (try `okg --help`)")
+ "usage: okg group SUBCOMMAND ... " +
+ "(try `okg --help`)")
}
switch args[0] {
case "list":
return runGroupList(args[1:])
+ case "create":
+ return runGroupCreate(args[1:])
default:
return fmt.Errorf(
"unknown group subcommand: %s", args[0])
@@ -29,16 +32,11 @@ func runGroupList(args []string) error {
return err
}

- cfg, err := loadConfig()
+ c, err := newWhoClient()
if err != nil {
return err
}
- if cfg.ApiKey == "" {
- return fmt.Errorf(
- "no API key — run `okg auth login --key sk-...`")
- }

- c := who.NewHTTPClient(cfg.Host, cfg.ApiKey)
groups, err := c.ListGroups()
if err != nil {
return err
@@ -61,3 +59,63 @@ func runGroupList(args []string) error {
}
return tw.Flush()
}
+
+func runGroupCreate(args []string) error {
+ fs := flag.NewFlagSet(
+ "group create", flag.ContinueOnError)
+ fullName := fs.String("full-name", "",
+ "display name (default: NAME)")
+ owner := fs.String("owner", "",
+ "owner username (default: caller)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ positional := fs.Args()
+ if len(positional) != 1 {
+ return fmt.Errorf("usage: okg group create NAME")
+ }
+ name := positional[0]
+
+ c, err := newWhoClient()
+ if err != nil {
+ return err
+ }
+
+ if *owner == "" {
+ me, err := c.GetProfile()
+ if err != nil {
+ return fmt.Errorf("resolve caller: %v", err)
+ }
+ *owner = me.Username
+ }
+
+ displayName := *fullName
+ if displayName == "" {
+ displayName = name
+ }
+
+ if err := c.CreateGroup(who.CreateGroupRequest{
+ Username: name,
+ Name: displayName,
+ OwnerUsername: *owner,
+ }); err != nil {
+ return err
+ }
+ fmt.Printf(
+ "Created group %s (owner: %s)\n", name, *owner)
+ return nil
+}
+
+// newWhoClient builds a //who client from saved config. Shared
+// by every `okg group` subcommand.
+func newWhoClient() (*who.HTTPClient, error) {
+ cfg, err := loadConfig()
+ if err != nil {
+ return nil, err
+ }
+ if cfg.ApiKey == "" {
+ return nil, fmt.Errorf(
+ "no API key — run `okg auth login --key sk-...`")
+ }
+ return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
+}
diff --git a/main.go b/main.go
index 24f98d8..33ef96a 100644
--- a/main.go
+++ b/main.go
@@ -59,6 +59,10 @@ GROUPS
okg group list
--json output raw JSON

+ okg group create NAME
+ --full-name TEXT display name (default: NAME)
+ --owner USER owner username (default: caller)
+
PULL REQUESTS
okg pr list
--state STATE open or closed (default: open)
@@ -123,6 +127,7 @@ EXAMPLES

Groups
okg group list
+ okg group create chat-bots

Pull requests
okg pr list --state open
diff --git a/who/who.go b/who/who.go
index e04fc78..3a6aebf 100644
--- a/who/who.go
+++ b/who/who.go
@@ -24,6 +24,36 @@ func NewHTTPClient(host, apiKey string) *HTTPClient {
return &HTTPClient{Client: rest.NewClient(host, apiKey)}
}

+// ---- Caller identity ----
+
+// Profile is the caller's //who profile. Mirrors the
+// JSON-serialized subset of //who.Profile that GET
+// /login/profile/get returns. Extra fields the server may
+// return (ApiKeys, Logins, AppData, timestamps) are decoded
+// permissively if added later; we just don't expose them here
+// until okg has a use for them.
+type Profile struct {
+ Owid string `json:"owid"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ PortraitUrl string `json:"portrait_url"`
+ OwnerOwid string `json:"owner_owid"`
+ Groups []string `json:"groups"`
+}
+
+// GetProfile returns the caller's own profile, identified by
+// the Bearer key.
+func (c *HTTPClient) GetProfile() (*Profile, error) {
+ var p Profile
+ if err := c.GetJSON("/login/profile/get", &p); err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+// ---- Groups ----
+
// Group describes a //who group (a named entity with members).
// Mirrors the listedGroup shape in //who/server/groups_list.go.
type Group struct {
@@ -46,3 +76,18 @@ func (c *HTTPClient) ListGroups() ([]Group, error) {
}
return res.Groups, nil
}
+
+// CreateGroupRequest is the body for POST /groups/add. Username
+// is the group's //who username (e.g. "team"); Name is the
+// human-facing display name; OwnerUsername is the user who'll
+// own the new group.
+type CreateGroupRequest struct {
+ Username string `json:"username"`
+ Name string `json:"name"`
+ OwnerUsername string `json:"owner_username"`
+}
+
+// CreateGroup creates a new //who group.
+func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
+ return c.PostJSON("/groups/add", req, nil)
+}
diff --git a/who/who_test.go b/who/who_test.go
index b62742a..639ce59 100644
--- a/who/who_test.go
+++ b/who/who_test.go
@@ -70,3 +70,74 @@ func TestListGroupsHTTPError(t *testing.T) {
t.Fatal("want error on 401, got nil")
}
}
+
+func TestGetProfile(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/login/profile/get" {
+ t.Errorf(
+ "path: got %q, want /login/profile/get",
+ r.URL.Path)
+ }
+ w.Header().Set(
+ "Content-Type", "application/json")
+ json.NewEncoder(w).Encode(Profile{
+ Username: "alice",
+ Name: "Alice Liddell",
+ Email: "[email protected]",
+ Groups: []string{"team", "admins"},
+ })
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ p, err := c.GetProfile()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Username != "alice" {
+ t.Errorf(
+ "Username: got %q, want alice", p.Username)
+ }
+ if len(p.Groups) != 2 {
+ t.Errorf(
+ "Groups: got %d, want 2", len(p.Groups))
+ }
+}
+
+func TestCreateGroup(t *testing.T) {
+ var seen CreateGroupRequest
+ srv := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/groups/add" {
+ t.Errorf(
+ "path: got %q, want /groups/add",
+ r.URL.Path)
+ }
+ if r.Method != "POST" {
+ t.Errorf("method: got %q, want POST", r.Method)
+ }
+ json.NewDecoder(r.Body).Decode(&seen)
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "test-key")
+ err := c.CreateGroup(CreateGroupRequest{
+ Username: "team",
+ Name: "Team",
+ OwnerUsername: "alice",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen.Username != "team" {
+ t.Errorf(
+ "Username: got %q, want team", seen.Username)
+ }
+ if seen.OwnerUsername != "alice" {
+ t.Errorf(
+ "OwnerUsername: got %q, want alice",
+ seen.OwnerUsername)
+ }
+}
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
61
61
okg embed [--model NAME] [--dims N] [--full-path]
62
okg embed [--model NAME] [--dims N] [--full-path]
62
(reads stdin → vectors on stdout)
63
(reads stdin → vectors on stdout)
63
okg one [--model NAME] [--system-file FILE] \
64
okg one [--model NAME] [--system-file FILE] \
64
[--prompt-file FILE] [--attach FILE] \
65
[--prompt-file FILE] [--attach FILE] \
65
[--format text|json|jsonindent] [--fast-fail]
66
[--format text|json|jsonindent] [--fast-fail]
66
(reads stdin as a JSON MessagesRequest)
67
(reads stdin as a JSON MessagesRequest)
67
```
68
```
68
69
69
### Coming next
70
### Coming next
70
71
71
- `okg exemplary` — few-shot batch runner (from
72
- `okg exemplary` — few-shot batch runner (from
72
`klex-git/exemplary`).
73
`klex-git/exemplary`).
73
74
74
Once that lands, the standalone `klex-git` binaries are
75
Once that lands, the standalone `klex-git` binaries are
75
deprecated.
76
deprecated.
76
77
77
### Flags
78
### Flags
78
79
79
- `--repo REPO` overrides auto-detected repo name
80
- `--repo REPO` overrides auto-detected repo name
80
(normally parsed from `git remote get-url origin`)
81
(normally parsed from `git remote get-url origin`)
81
- `--json` outputs raw JSON for any command
82
- `--json` outputs raw JSON for any command
82
- `OKG_REPO` env var also overrides repo detection
83
- `OKG_REPO` env var also overrides repo detection
83
84
84
## Repo Detection
85
## Repo Detection
85
86
86
Like `gh`, okg detects the repo from the current directory's
87
Like `gh`, okg detects the repo from the current directory's
87
git remote:
88
git remote:
88
89
89
```
90
```
90
git remote get-url origin
91
git remote get-url origin
91
→ https://code.oscarkilo.com/widget.git
92
→ https://code.oscarkilo.com/widget.git
92
→ repo = "widget"
93
→ repo = "widget"
93
```
94
```
94
95
95
## Dependencies
96
## Dependencies
96
97
97
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
98
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
98
to be folded in once all `klex-git` binaries have moved here).
99
to be folded in once all `klex-git` binaries have moved here).
99
- Otherwise Go standard library only.
100
- 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 ... (try `okg --help`)")
14
"usage: okg group SUBCOMMAND ... " +
15
"(try `okg --help`)")
15
}
16
}
16
switch args[0] {
17
switch args[0] {
17
case "list":
18
case "list":
18
return runGroupList(args[1:])
19
return runGroupList(args[1:])
20
case "create":
21
return runGroupCreate(args[1:])
19
default:
22
default:
20
return fmt.Errorf(
23
return fmt.Errorf(
21
"unknown group subcommand: %s", args[0])
24
"unknown group subcommand: %s", args[0])
22
}
25
}
23
}
26
}
24
27
25
func runGroupList(args []string) error {
28
func runGroupList(args []string) error {
26
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
29
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
27
asJSON := fs.Bool("json", false, "output raw JSON")
30
asJSON := fs.Bool("json", false, "output raw JSON")
28
if err := fs.Parse(args); err != nil {
31
if err := fs.Parse(args); err != nil {
29
return err
32
return err
30
}
33
}
31
34
32
cfg, err := loadConfig()
35
c, err := newWhoClient()
33
if err != nil {
36
if err != nil {
34
return err
37
return err
35
}
38
}
36
if cfg.ApiKey == "" {
37
return fmt.Errorf(
38
"no API key — run `okg auth login --key sk-...`")
39
}
40
39
41
c := who.NewHTTPClient(cfg.Host, cfg.ApiKey)
42
groups, err := c.ListGroups()
40
groups, err := c.ListGroups()
43
if err != nil {
41
if err != nil {
44
return err
42
return err
45
}
43
}
46
44
47
if *asJSON {
45
if *asJSON {
48
buf, err := json.MarshalIndent(groups, "", " ")
46
buf, err := json.MarshalIndent(groups, "", " ")
49
if err != nil {
47
if err != nil {
50
return err
48
return err
51
}
49
}
52
fmt.Println(string(buf))
50
fmt.Println(string(buf))
53
return nil
51
return nil
54
}
52
}
55
53
56
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
54
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
57
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
55
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
58
for _, g := range groups {
56
for _, g := range groups {
59
fmt.Fprintf(tw, "%s\t%s\t%s\n",
57
fmt.Fprintf(tw, "%s\t%s\t%s\n",
60
g.Username, g.Name, g.OwnerUsername)
58
g.Username, g.Name, g.OwnerUsername)
61
}
59
}
62
return tw.Flush()
60
return tw.Flush()
63
}
61
}
62
63
func runGroupCreate(args []string) error {
64
fs := flag.NewFlagSet(
65
"group create", flag.ContinueOnError)
66
fullName := fs.String("full-name", "",
67
"display name (default: NAME)")
68
owner := fs.String("owner", "",
69
"owner username (default: caller)")
70
if err := fs.Parse(args); err != nil {
71
return err
72
}
73
positional := fs.Args()
74
if len(positional) != 1 {
75
return fmt.Errorf("usage: okg group create NAME")
76
}
77
name := positional[0]
78
79
c, err := newWhoClient()
80
if err != nil {
81
return err
82
}
83
84
if *owner == "" {
85
me, err := c.GetProfile()
86
if err != nil {
87
return fmt.Errorf("resolve caller: %v", err)
88
}
89
*owner = me.Username
90
}
91
92
displayName := *fullName
93
if displayName == "" {
94
displayName = name
95
}
96
97
if err := c.CreateGroup(who.CreateGroupRequest{
98
Username: name,
99
Name: displayName,
100
OwnerUsername: *owner,
101
}); err != nil {
102
return err
103
}
104
fmt.Printf(
105
"Created group %s (owner: %s)\n", name, *owner)
106
return nil
107
}
108
109
// newWhoClient builds a //who client from saved config. Shared
110
// by every `okg group` subcommand.
111
func newWhoClient() (*who.HTTPClient, error) {
112
cfg, err := loadConfig()
113
if err != nil {
114
return nil, err
115
}
116
if cfg.ApiKey == "" {
117
return nil, fmt.Errorf(
118
"no API key — run `okg auth login --key sk-...`")
119
}
120
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
121
}
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
63
--full-name TEXT display name (default: NAME)
64
--owner USER owner username (default: caller)
65
62
PULL REQUESTS
66
PULL REQUESTS
63
okg pr list
67
okg pr list
64
--state STATE open or closed (default: open)
68
--state STATE open or closed (default: open)
65
--json output raw JSON
69
--json output raw JSON
66
70
67
okg pr create
71
okg pr create
68
--head BRANCH source branch
72
--head BRANCH source branch
69
--base BRANCH target branch (default: master)
73
--base BRANCH target branch (default: master)
70
--title TITLE PR title
74
--title TITLE PR title
71
--body BODY PR body (optional)
75
--body BODY PR body (optional)
72
--json output raw JSON
76
--json output raw JSON
73
77
74
okg pr view NUMBER
78
okg pr view NUMBER
75
--json output raw JSON
79
--json output raw JSON
76
80
77
okg pr diff NUMBER
81
okg pr diff NUMBER
78
82
79
okg pr comment NUMBER
83
okg pr comment NUMBER
80
--body BODY comment body
84
--body BODY comment body
81
--approve also approve the PR
85
--approve also approve the PR
82
--request-changes also request changes
86
--request-changes also request changes
83
87
84
okg pr merge NUMBER
88
okg pr merge NUMBER
85
--json output raw JSON
89
--json output raw JSON
86
90
87
okg pr close NUMBER
91
okg pr close NUMBER
88
--json output raw JSON
92
--json output raw JSON
89
93
90
okg pr reopen NUMBER
94
okg pr reopen NUMBER
91
--json output raw JSON
95
--json output raw JSON
92
96
93
ARTIFICIAL INTELLIGENCE
97
ARTIFICIAL INTELLIGENCE
94
okg embed
98
okg embed
95
--model NAME embedding model
99
--model NAME embedding model
96
(default: openai:text-embedding-3-small)
100
(default: openai:text-embedding-3-small)
97
--dims N number of dimensions (default: 1536)
101
--dims N number of dimensions (default: 1536)
98
--full-path one vector per prefix of input
102
--full-path one vector per prefix of input
99
(reads stdin; writes vectors to stdout, one per line)
103
(reads stdin; writes vectors to stdout, one per line)
100
104
101
okg one
105
okg one
102
--model NAME override .Model in the request
106
--model NAME override .Model in the request
103
--system-file FILE override .System with contents of FILE
107
--system-file FILE override .System with contents of FILE
104
--prompt-file FILE append FILE as a user prompt
108
--prompt-file FILE append FILE as a user prompt
105
--attach FILE attach an image or PDF to the prompt
109
--attach FILE attach an image or PDF to the prompt
106
--format FORMAT text | json | jsonindent (default: text)
110
--format FORMAT text | json | jsonindent (default: text)
107
--fast-fail preflight attachment MIME (default: on)
111
--fast-fail preflight attachment MIME (default: on)
108
(reads stdin as a JSON MessagesRequest; flags override
112
(reads stdin as a JSON MessagesRequest; flags override
109
its fields)
113
its fields)
110
114
111
GLOBAL FLAGS
115
GLOBAL FLAGS
112
--repo REPO override auto-detected repo name
116
--repo REPO override auto-detected repo name
113
--json output raw JSON (where applicable)
117
--json output raw JSON (where applicable)
114
118
115
EXAMPLES
119
EXAMPLES
116
Setup
120
Setup
117
okg auth login --key sk-...
121
okg auth login --key sk-...
118
cat ~/.klex.key | okg auth login
122
cat ~/.klex.key | okg auth login
119
123
120
Git repos
124
Git repos
121
okg repo list
125
okg repo list
122
okg repo create my-new-repo
126
okg repo create my-new-repo
123
127
124
Groups
128
Groups
125
okg group list
129
okg group list
130
okg group create chat-bots
126
131
127
Pull requests
132
Pull requests
128
okg pr list --state open
133
okg pr list --state open
129
okg pr view 42
134
okg pr view 42
130
okg pr comment 42 --body 'LGTM' --approve
135
okg pr comment 42 --body 'LGTM' --approve
131
136
132
Artificial intelligence
137
Artificial intelligence
133
echo 'hello world' | okg embed --dims 384
138
echo 'hello world' | okg embed --dims 384
134
echo Hello? > /tmp/q.txt && \
139
echo Hello? > /tmp/q.txt && \
135
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
140
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
136
`)
141
`)
137
}
142
}
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 ----
28
29
// Profile is the caller's //who profile. Mirrors the
30
// JSON-serialized subset of //who.Profile that GET
31
// /login/profile/get returns. Extra fields the server may
32
// return (ApiKeys, Logins, AppData, timestamps) are decoded
33
// permissively if added later; we just don't expose them here
34
// until okg has a use for them.
35
type Profile struct {
36
Owid string `json:"owid"`
37
Username string `json:"username"`
38
Name string `json:"name"`
39
Email string `json:"email"`
40
PortraitUrl string `json:"portrait_url"`
41
OwnerOwid string `json:"owner_owid"`
42
Groups []string `json:"groups"`
43
}
44
45
// GetProfile returns the caller's own profile, identified by
46
// the Bearer key.
47
func (c *HTTPClient) GetProfile() (*Profile, error) {
48
var p Profile
49
if err := c.GetJSON("/login/profile/get", &p); err != nil {
50
return nil, err
51
}
52
return &p, nil
53
}
54
55
// ---- Groups ----
56
27
// Group describes a //who group (a named entity with members).
57
// Group describes a //who group (a named entity with members).
28
// Mirrors the listedGroup shape in //who/server/groups_list.go.
58
// Mirrors the listedGroup shape in //who/server/groups_list.go.
29
type Group struct {
59
type Group struct {
30
Owid string `json:"owid"`
60
Owid string `json:"owid"`
31
Username string `json:"username"`
61
Username string `json:"username"`
32
Name string `json:"name"`
62
Name string `json:"name"`
33
OwnerOwid string `json:"owner_owid"`
63
OwnerOwid string `json:"owner_owid"`
34
OwnerUsername string `json:"owner_username"`
64
OwnerUsername string `json:"owner_username"`
35
}
65
}
36
66
37
type listGroupsResponse struct {
67
type listGroupsResponse struct {
38
Groups []Group `json:"groups"`
68
Groups []Group `json:"groups"`
39
}
69
}
40
70
41
// ListGroups returns the groups visible to the caller.
71
// ListGroups returns the groups visible to the caller.
42
func (c *HTTPClient) ListGroups() ([]Group, error) {
72
func (c *HTTPClient) ListGroups() ([]Group, error) {
43
var res listGroupsResponse
73
var res listGroupsResponse
44
if err := c.GetJSON("/groups/list", &res); err != nil {
74
if err := c.GetJSON("/groups/list", &res); err != nil {
45
return nil, err
75
return nil, err
46
}
76
}
47
return res.Groups, nil
77
return res.Groups, nil
48
}
78
}
79
80
// CreateGroupRequest is the body for POST /groups/add. Username
81
// is the group's //who username (e.g. "team"); Name is the
82
// human-facing display name; OwnerUsername is the user who'll
83
// own the new group.
84
type CreateGroupRequest struct {
85
Username string `json:"username"`
86
Name string `json:"name"`
87
OwnerUsername string `json:"owner_username"`
88
}
89
90
// CreateGroup creates a new //who group.
91
func (c *HTTPClient) CreateGroup(req CreateGroupRequest) error {
92
return c.PostJSON("/groups/add", req, nil)
93
}
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
74
func TestGetProfile(t *testing.T) {
75
srv := httptest.NewServer(http.HandlerFunc(
76
func(w http.ResponseWriter, r *http.Request) {
77
if r.URL.Path != "/login/profile/get" {
78
t.Errorf(
79
"path: got %q, want /login/profile/get",
80
r.URL.Path)
81
}
82
w.Header().Set(
83
"Content-Type", "application/json")
84
json.NewEncoder(w).Encode(Profile{
85
Username: "alice",
86
Name: "Alice Liddell",
87
88
Groups: []string{"team", "admins"},
89
})
90
}))
91
defer srv.Close()
92
93
c := NewHTTPClient(srv.URL, "test-key")
94
p, err := c.GetProfile()
95
if err != nil {
96
t.Fatal(err)
97
}
98
if p.Username != "alice" {
99
t.Errorf(
100
"Username: got %q, want alice", p.Username)
101
}
102
if len(p.Groups) != 2 {
103
t.Errorf(
104
"Groups: got %d, want 2", len(p.Groups))
105
}
106
}
107
108
func TestCreateGroup(t *testing.T) {
109
var seen CreateGroupRequest
110
srv := httptest.NewServer(http.HandlerFunc(
111
func(w http.ResponseWriter, r *http.Request) {
112
if r.URL.Path != "/groups/add" {
113
t.Errorf(
114
"path: got %q, want /groups/add",
115
r.URL.Path)
116
}
117
if r.Method != "POST" {
118
t.Errorf("method: got %q, want POST", r.Method)
119
}
120
json.NewDecoder(r.Body).Decode(&seen)
121
w.WriteHeader(200)
122
}))
123
defer srv.Close()
124
125
c := NewHTTPClient(srv.URL, "test-key")
126
err := c.CreateGroup(CreateGroupRequest{
127
Username: "team",
128
Name: "Team",
129
OwnerUsername: "alice",
130
})
131
if err != nil {
132
t.Fatal(err)
133
}
134
if seen.Username != "team" {
135
t.Errorf(
136
"Username: got %q, want team", seen.Username)
137
}
138
if seen.OwnerUsername != "alice" {
139
t.Errorf(
140
"OwnerUsername: got %q, want alice",
141
seen.OwnerUsername)
142
}
143
}