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 o
28
(`https://code.oscarkilo.com`); the only setup you need is o
29
yo it
29
yo it
30
`~/.config/okg/config.json`.
30
31
31
```bash
32
```bash
32
# nteractive o aes ci.
33
# nteractive o aes ci.
33
okg auth login
34
okg auth login
35
cat ~/.klex.key | okg auth login # equivalent, ps-safe
34
36
35
# nteractive t key.
37
# nteractive t key.
36
o oi
38
o oi
37
```
39
```
38
40
39
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`
40
or with the `OKG_HOST` env var.
41
42
42
## Commands
43
## Commands
43
44
44
```
45
```
45
okg repo list
46
okg repo list
46
47
47
okg pr list [--state open|closed]
48
okg pr list [--state open|closed]
48
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
49
okg pr create --head BRANCH [--base master] --title TITLE [--body BODY]
49
okg pr view NUMBER
50
okg pr view NUMBER
50
okg pr diff NUMBER
51
okg pr diff NUMBER
51
okg pr comment NUMBER --body BODY [--approve | --request-changes]
52
okg pr comment NUMBER --body BODY [--approve | --request-changes]
52
okg pr merge NUMBER
53
okg pr merge NUMBER
53
okg pr close NUMBER
54
okg pr close NUMBER
54
okg pr reopen NUMBER
55
okg pr reopen NUMBER
55
56
56
okg auth login [-- ] [-- ]
57
okg auth login [-- ] [-- ]
57
58
58
okg embed [--model NAME] [--dims N] [--full-path]
59
okg embed [--model NAME] [--dims N] [--full-path]
59
(reads stdin → vectors on stdout)
60
(reads stdin → vectors on stdout)
60
```
61
```
61
62
62
### Coming next
63
### Coming next
63
64
64
- `okg one` — one LLM inference on one input (from
65
- `okg one` — one LLM inference on one input (from
65
`klex-git/one`).
66
`klex-git/one`).
66
- `okg exemplary` — few-shot batch runner (from
67
- `okg exemplary` — few-shot batch runner (from
67
`klex-git/exemplary`).
68
`klex-git/exemplary`).
68
69
69
Once those land, the standalone `klex-git` binaries are
70
Once those land, the standalone `klex-git` binaries are
70
deprecated.
71
deprecated.
71
72
72
### Flags
73
### Flags
73
74
74
- `--repo REPO` overrides auto-detected repo name
75
- `--repo REPO` overrides auto-detected repo name
75
(normally parsed from `git remote get-url origin`)
76
(normally parsed from `git remote get-url origin`)
76
- `--json` outputs raw JSON for any command
77
- `--json` outputs raw JSON for any command
77
- `OKG_REPO` env var also overrides repo detection
78
- `OKG_REPO` env var also overrides repo detection
78
79
79
## Repo Detection
80
## Repo Detection
80
81
81
Like `gh`, okg detects the repo from the current directory's
82
Like `gh`, okg detects the repo from the current directory's
82
git remote:
83
git remote:
83
84
84
```
85
```
85
git remote get-url origin
86
git remote get-url origin
86
→ https://code.oscarkilo.com/widget.git
87
→ https://code.oscarkilo.com/widget.git
87
→ repo = "widget"
88
→ repo = "widget"
88
```
89
```
89
90
90
## Dependencies
91
## Dependencies
91
92
92
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
93
- `oscarkilo.com/klex-git` for the LLM API client (transitional;
93
to be folded in once all `klex-git` binaries have moved here).
94
to be folded in once all `klex-git` binaries have moved here).
94
- Otherwise Go standard library only.
95
- Otherwise Go standard library only.
1
package main
1
package main
2
2
3
import "bufio"
3
import "bufio"
4
import "fmt"
4
import "fmt"
5
import "io"
5
import "os"
6
import "os"
6
import "strings"
7
import "strings"
7
8
8
func runAuth(args []string) error {
9
func runAuth(args []string) error {
9
if len(args) == 0 {
10
if len(args) == 0 {
10
return fmt.Errorf("usage: okg auth login")
11
return fmt.Errorf("usage: okg auth login")
11
}
12
}
12
switch args[0] {
13
switch args[0] {
13
case "login":
14
case "login":
14
return runAuthLogin(args[1:])
15
return runAuthLogin(args[1:])
15
default:
16
default:
16
return fmt.Errorf(
17
return fmt.Errorf(
17
"unknown auth command: %s", args[0])
18
"unknown auth command: %s", args[0])
18
}
19
}
19
}
20
}
20
21
22
// runAuthLogin saves a host + API key to ~/.config/okg/config.json.
23
// The key can come from --key, from stdin (pipe), or — when stdin
24
// is a TTY — from an interactive prompt. Host defaults to
25
// production unless --host is given (and, in interactive mode, the
26
// prompt offers an override).
21
func runAuthLogin(args []string) error {
27
func runAuthLogin(args []string) error {
22
host := ""
28
host := ""
23
e := ""
29
e := ""
24
for i := 0; i < len(args); i++ {
30
for i := 0; i < len(args); i++ {
25
switch args[i] {
31
switch args[i] {
26
case "--host":
32
case "--host":
27
i++
33
i++
28
if i >= len(args) {
34
if i >= len(args) {
29
return fmt.Errorf(
35
return fmt.Errorf(
30
"--host requires a value")
36
"--host requires a value")
31
}
37
}
32
host = args[i]
38
host = args[i]
33
case "--e":
39
case "--e":
34
i++
40
i++
35
if i >= len(args) {
41
if i >= len(args) {
36
return fmt.Errorf(
42
return fmt.Errorf(
37
"--e requires a value")
43
"--e requires a value")
38
}
44
}
39
e = args[i]
45
e = args[i]
40
default:
46
default:
41
return fmt.Errorf(
47
return fmt.Errorf(
42
"unknown flag: %s", args[i])
48
"unknown flag: %s", args[i])
43
}
49
}
44
}
50
}
45
51
46
ee : ieweadrotdin
52
ee : ieweadrotdin
47
53
48
{
54
{
49
s e "
55
s e "
50
eai
56
eai
51
s stinsip()
57
s stinsip()
58
line, err := bufio.NewReader(os.Stdin).
59
ReadString('\n')
60
if err != nil && err != io.EOF {
61
return fmt.Errorf("reading stdin: %v", err)
62
}
63
key = strings.TrimSpace(line)
64
default:
65
// Interactive: optionally prompt for host, then key.
66
reader := bufio.NewReader(os.Stdin)
52
if host == "" {
67
if host == "" {
53
ost DefaultHost
68
ost DefaultHost
69
line, _ := reader.ReadString('\n')
70
host = strings.TrimSpace(line)
54
}
71
}
72
fmt.Print("API key: ")
73
line, _ := reader.ReadString('\n')
74
key = strings.TrimSpace(line)
55
}
75
}
56
76
57
t "
77
t "
58
= eat
78
= eat
59
79
60
if key == "" {
80
if key == "" {
61
return fmt.Errorf(
81
return fmt.Errorf(
82
"API key required " +
83
"(pass --key, pipe via stdin, or " +
84
"enter at the prompt)")
62
}
85
}
63
86
64
cfg := &Config{Host: host, ApiKey: key}
87
cfg := &Config{Host: host, ApiKey: key}
65
if err := saveConfig(cfg); err != nil {
88
if err := saveConfig(cfg); err != nil {
66
return fmt.Errorf("saving config: %v", err)
89
return fmt.Errorf("saving config: %v", err)
67
}
90
}
68
fmt.Printf("Saved config to %s\n", configPath())
91
fmt.Printf("Saved config to %s\n", configPath())
92
return nil
93
}
69
94
70
// ie di o ma
95
// ie di o ma
71
// e et seei
96
// e et seei
72
i e
97
i e
73
e(
98
e(
74
:= tint
99
:= tint
75
er
100
er
76
se
101
se
77
}
78
err := cl.PostJSON(
79
"/login/profile/edit", payload, nil)
80
if err != nil {
81
return fmt.Errorf(
82
"setting username: %v", err)
83
}
84
fmt.Printf("Authenticated as %s\n", user)
85
}
102
}
86
103
87
return nil
88
}
104
}
1
package main
1
package main
2
2
3
import "encoding/json"
3
import "encoding/json"
4
import "fmt"
4
import "fmt"
5
import "os"
5
import "os"
6
import "path/filepath"
6
import "path/filepath"
7
7
8
// DefaultHost is the production klee host. There is exactly one for
8
// DefaultHost is the production klee host. There is exactly one for
9
// the foreseeable future. It's used by loadConfig (as the host
9
// the foreseeable future. It's used by loadConfig (as the host
10
// fallback) and by `okg auth login` (as the interactive default).
10
// fallback) and by `okg auth login` (as the interactive default).
11
const DefaultHost = "https://code.oscarkilo.com"
11
const DefaultHost = "https://code.oscarkilo.com"
12
12
13
type Config struct {
13
type Config struct {
14
Host string `json:"host"`
14
Host string `json:"host"`
15
ApiKey string `json:"api_key"`
15
ApiKey string `json:"api_key"`
16
}
16
}
17
17
18
func configPath() string {
18
func configPath() string {
19
home, err := os.UserHomeDir()
19
home, err := os.UserHomeDir()
20
if err != nil {
20
if err != nil {
21
return ""
21
return ""
22
}
22
}
23
return filepath.Join(home, ".config", "okg", "config.json")
23
return filepath.Join(home, ".config", "okg", "config.json")
24
}
24
}
25
25
26
func loadConfig() (*Config, error) {
26
func loadConfig() (*Config, error) {
27
c := &Config{}
27
c := &Config{}
28
28
29
// Load from file.
29
// Load from file.
30
path := configPath()
30
path := configPath()
31
if path != "" {
31
if path != "" {
32
data, err := os.ReadFile(path)
32
data, err := os.ReadFile(path)
33
if err == nil {
33
if err == nil {
34
json.Unmarshal(data, c)
34
json.Unmarshal(data, c)
35
}
35
}
36
}
36
}
37
37
38
// nv overrides
38
// nv overrides
39
// localhost so accidental hits to prod fail fast). It is NOT a
40
// user-facing config knob; `okg auth login --host` is.
39
if v := os.Getenv("OKG_HOST"); v != "" {
41
if v := os.Getenv("OKG_HOST"); v != "" {
40
c.Host = v
42
c.Host = v
41
}
43
}
42
if v := os.Getenv("KLEX_API_KEY"); v != "" {
43
c.ApiKey = v
44
}
45
44
46
// Default to prod. Tests set OKG_HOST in TestMain to a localhost
45
// Default to prod. Tests set OKG_HOST in TestMain to a localhost
47
// URL so they can't accidentally reach it.
46
// URL so they can't accidentally reach it.
48
if c.Host == "" {
47
if c.Host == "" {
49
c.Host = DefaultHost
48
c.Host = DefaultHost
50
}
49
}
51
50
52
return c, nil
51
return c, nil
53
}
52
}
54
53
55
func saveConfig(c *Config) error {
54
func saveConfig(c *Config) error {
56
path := configPath()
55
path := configPath()
57
if path == "" {
56
if path == "" {
58
return fmt.Errorf("cannot determine home directory")
57
return fmt.Errorf("cannot determine home directory")
59
}
58
}
60
dir := filepath.Dir(path)
59
dir := filepath.Dir(path)
61
if err := os.MkdirAll(dir, 0700); err != nil {
60
if err := os.MkdirAll(dir, 0700); err != nil {
62
return fmt.Errorf("mkdir %s: %v", dir, err)
61
return fmt.Errorf("mkdir %s: %v", dir, err)
63
}
62
}
64
data, err := json.MarshalIndent(c, "", " ")
63
data, err := json.MarshalIndent(c, "", " ")
65
if err != nil {
64
if err != nil {
66
return err
65
return err
67
}
66
}
68
return os.WriteFile(path, data, 0600)
67
return os.WriteFile(path, data, 0600)
69
}
68
}
1
package main
1
package main
2
2
3
import "flag"
3
import "flag"
4
import "fmt"
4
import "fmt"
5
import "io"
5
import "io"
6
import "os"
6
import "os"
7
7
8
import "oscarkilo.com/klex-git/api"
8
import "oscarkilo.com/klex-git/api"
9
9
10
// runEmbed converts stdin text into embedding vectors, written to
10
// runEmbed converts stdin text into embedding vectors, written to
11
// stdout one vector per line, space-separated floats. Mirrors the
11
// stdout one vector per line, space-separated floats. Mirrors the
12
// (now-deprecated) `klex-git/embed` binary.
12
// (now-deprecated) `klex-git/embed` binary.
13
func runEmbed(args []string) error {
13
func runEmbed(args []string) error {
14
fs := flag.NewFlagSet("embed", flag.ContinueOnError)
14
fs := flag.NewFlagSet("embed", flag.ContinueOnError)
15
model := fs.String("model",
15
model := fs.String("model",
16
"openai:text-embedding-3-small",
16
"openai:text-embedding-3-small",
17
"embedding model name")
17
"embedding model name")
18
dims := fs.Int("dims", 1536,
18
dims := fs.Int("dims", 1536,
19
"number of vector dimensions to return")
19
"number of vector dimensions to return")
20
fullPath := fs.Bool("full-path", false,
20
fullPath := fs.Bool("full-path", false,
21
"return one vector per prefix of input "+
21
"return one vector per prefix of input "+
22
"(otherwise just the final vector)")
22
"(otherwise just the final vector)")
23
if err := fs.Parse(args); err != nil {
23
if err := fs.Parse(args); err != nil {
24
return err
24
return err
25
}
25
}
26
26
27
cfg, err := loadConfig()
27
cfg, err := loadConfig()
28
if err != nil {
28
if err != nil {
29
return err
29
return err
30
}
30
}
31
if cfg.ApiKey == "" {
31
if cfg.ApiKey == "" {
32
return fmt.Errorf(
32
return fmt.Errorf(
33
"no API key — run `okg auth login`")
33
"no API key — run `okg auth login`")
34
}
34
}
35
client := newKlexClient(cfg)
35
client := newKlexClient(cfg)
36
36
37
text, err := io.ReadAll(os.Stdin)
37
text, err := io.ReadAll(os.Stdin)
38
if err != nil {
38
if err != nil {
39
return fmt.Errorf("read stdin: %v", err)
39
return fmt.Errorf("read stdin: %v", err)
40
}
40
}
41
41
42
vectors, err := client.Embed(api.EmbedRequest{
42
vectors, err := client.Embed(api.EmbedRequest{
43
Text: string(text),
43
Text: string(text),
44
Model: *model,
44
Model: *model,
45
Dims: *dims,
45
Dims: *dims,
46
FullPath: *fullPath,
46
FullPath: *fullPath,
47
})
47
})
48
if err != nil {
48
if err != nil {
49
return fmt.Errorf("embed: %v", err)
49
return fmt.Errorf("embed: %v", err)
50
}
50
}
51
51
52
for _, vector := range vectors {
52
for _, vector := range vectors {
53
for i, w := range vector {
53
for i, w := range vector {
54
if i > 0 {
54
if i > 0 {
55
fmt.Print(" ")
55
fmt.Print(" ")
56
}
56
}
57
fmt.Printf("%g", w)
57
fmt.Printf("%g", w)
58
}
58
}
59
fmt.Println()
59
fmt.Println()
60
}
60
}
61
return nil
61
return nil
62
}
62
}
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 "help", "--help", "-h":
23
case "help", "--help", "-h":
24
printUsage()
24
printUsage()
25
return
25
return
26
default:
26
default:
27
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
27
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
28
printUsage()
28
printUsage()
29
os.Exit(1)
29
os.Exit(1)
30
}
30
}
31
31
32
if err != nil {
32
if err != nil {
33
fmt.Fprintf(os.Stderr, "error: %v\n", err)
33
fmt.Fprintf(os.Stderr, "error: %v\n", err)
34
os.Exit(1)
34
os.Exit(1)
35
}
35
}
36
}
36
}
37
37
38
func printUsage() {
38
func printUsage() {
39
fmt.Fprintf(os.Stderr, `NAME
39
fmt.Fprintf(os.Stderr, `NAME
40
okg — Oscar Kilo Goodness
40
okg — Oscar Kilo Goodness
41
41
42
SETUP
42
SETUP
43
okg auth login
43
okg auth login
44
--key KEY API key (also accepted via stdin)
44
--host HOST klee host (default: production)
45
--host HOST klee host (default: production)
45
--user USERNAME login as USERNAME
46
46
47
GIT REPOS
47
GIT REPOS
48
okg repo list
48
okg repo list
49
--json output raw JSON
49
--json output raw JSON
50
50
51
okg repo create NAME
51
okg repo create NAME
52
--reader USER grant read to USER (default: anyone)
52
--reader USER grant read to USER (default: anyone)
53
53
54
PULL REQUESTS
54
PULL REQUESTS
55
okg pr list
55
okg pr list
56
--state STATE open or closed (default: open)
56
--state STATE open or closed (default: open)
57
--json output raw JSON
57
--json output raw JSON
58
58
59
okg pr create
59
okg pr create
60
--head BRANCH source branch
60
--head BRANCH source branch
61
--base BRANCH target branch (default: master)
61
--base BRANCH target branch (default: master)
62
--title TITLE PR title
62
--title TITLE PR title
63
--body BODY PR body (optional)
63
--body BODY PR body (optional)
64
--json output raw JSON
64
--json output raw JSON
65
65
66
okg pr view NUMBER
66
okg pr view NUMBER
67
--json output raw JSON
67
--json output raw JSON
68
68
69
okg pr diff NUMBER
69
okg pr diff NUMBER
70
70
71
okg pr comment NUMBER
71
okg pr comment NUMBER
72
--body BODY comment body
72
--body BODY comment body
73
--approve also approve the PR
73
--approve also approve the PR
74
--request-changes also request changes
74
--request-changes also request changes
75
75
76
okg pr merge NUMBER
76
okg pr merge NUMBER
77
--json output raw JSON
77
--json output raw JSON
78
78
79
okg pr close NUMBER
79
okg pr close NUMBER
80
--json output raw JSON
80
--json output raw JSON
81
81
82
okg pr reopen NUMBER
82
okg pr reopen NUMBER
83
--json output raw JSON
83
--json output raw JSON
84
84
85
ARTIFICIAL INTELLIGENCE
85
ARTIFICIAL INTELLIGENCE
86
okg embed
86
okg embed
87
--model NAME embedding model
87
--model NAME embedding model
88
(default: openai:text-embedding-3-small)
88
(default: openai:text-embedding-3-small)
89
--dims N number of dimensions (default: 1536)
89
--dims N number of dimensions (default: 1536)
90
--full-path one vector per prefix of input
90
--full-path one vector per prefix of input
91
(reads stdin; writes vectors to stdout, one per line)
91
(reads stdin; writes vectors to stdout, one per line)
92
92
93
GLOBAL FLAGS
93
GLOBAL FLAGS
94
--repo REPO override auto-detected repo name
94
--repo REPO override auto-detected repo name
95
--json output raw JSON (where applicable)
95
--json output raw JSON (where applicable)
96
96
97
EXAMPLES
97
EXAMPLES
98
Setup
98
Setup
99
okg auth login
99
okg auth login
100
cat ~/.klex.key | okg auth login
100
101
101
Git repos
102
Git repos
102
okg repo list
103
okg repo list
103
okg repo create my-new-repo
104
okg repo create my-new-repo
104
105
105
Pull requests
106
Pull requests
106
okg pr list --state open
107
okg pr list --state open
107
okg pr view 42
108
okg pr view 42
108
okg pr comment 42 --body 'LGTM' --approve
109
okg pr comment 42 --body 'LGTM' --approve
109
110
110
Artificial intelligence
111
Artificial intelligence
111
echo 'hello world' | okg embed --dims 384
112
echo 'hello world' | okg embed --dims 384
112
`)
113
`)
113
}
114
}
1
package main
1
package main
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 "os"
6
import "os"
7
import "strings"
7
import "strings"
8
import "testing"
8
import "testing"
9
import "time"
9
import "time"
10
10
11
import "oscarkilo.com/okg/klee"
11
import "oscarkilo.com/okg/klee"
12
12
13
// TestMain prevents the entire test binary from accidentally reaching
13
// TestMain prevents the entire test binary from accidentally reaching
14
// the production klee host (which is the default in loadConfig). Set
14
// the production klee host (which is the default in loadConfig). Set
15
// it once here; individual tests that need a specific URL (e.g. a
15
// it once here; individual tests that need a specific URL (e.g. a
16
// mock httptest server) still set OKG_HOST themselves.
16
// mock httptest server) still set OKG_HOST themselves.
17
func TestMain(m *testing.M) {
17
func TestMain(m *testing.M) {
18
os.Setenv("OKG_HOST", "http://localhost:42069")
18
os.Setenv("OKG_HOST", "http://localhost:42069")
19
os.Exit(m.Run())
19
os.Exit(m.Run())
20
}
20
}
21
21
22
func TestRepoRegex(t *testing.T) {
22
func TestRepoRegex(t *testing.T) {
23
check := func(url, want string) {
23
check := func(url, want string) {
24
t.Helper()
24
t.Helper()
25
m := repoRegex.FindStringSubmatch(url)
25
m := repoRegex.FindStringSubmatch(url)
26
if want == "" {
26
if want == "" {
27
if m != nil {
27
if m != nil {
28
t.Errorf(
28
t.Errorf(
29
"%q: want no match, got %q", url, m[1])
29
"%q: want no match, got %q", url, m[1])
30
}
30
}
31
return
31
return
32
}
32
}
33
if m == nil {
33
if m == nil {
34
t.Errorf(
34
t.Errorf(
35
"%q: want %q, got no match", url, want)
35
"%q: want %q, got no match", url, want)
36
return
36
return
37
}
37
}
38
if m[1] != want {
38
if m[1] != want {
39
t.Errorf(
39
t.Errorf(
40
"%q: want %q, got %q", url, want, m[1])
40
"%q: want %q, got %q", url, want, m[1])
41
}
41
}
42
}
42
}
43
check(
43
check(
44
"https://code.oscarkilo.com/widget.git",
44
"https://code.oscarkilo.com/widget.git",
45
"widget")
45
"widget")
46
check(
46
check(
47
"https://code.oscarkilo.com/klee.git",
47
"https://code.oscarkilo.com/klee.git",
48
"klee")
48
"klee")
49
check(
49
check(
50
"https://code.oscarkilo.com/my-repo.git",
50
"https://code.oscarkilo.com/my-repo.git",
51
"my-repo")
51
"my-repo")
52
check(
52
check(
53
"https://code.oscarkilo.com/a123.git",
53
"https://code.oscarkilo.com/a123.git",
54
"a123")
54
"a123")
55
check(
55
check(
56
"https://github.com/foo/bar.git",
56
"https://github.com/foo/bar.git",
57
"")
57
"")
58
check("not-a-url", "")
58
check("not-a-url", "")
59
}
59
}
60
60
61
func TestResolveRepo(t *testing.T) {
61
func TestResolveRepo(t *testing.T) {
62
// Flag takes priority.
62
// Flag takes priority.
63
repo, err := resolveRepo("from-flag")
63
repo, err := resolveRepo("from-flag")
64
if err != nil {
64
if err != nil {
65
t.Fatal(err)
65
t.Fatal(err)
66
}
66
}
67
if repo != "from-flag" {
67
if repo != "from-flag" {
68
t.Errorf("want from-flag, got %q", repo)
68
t.Errorf("want from-flag, got %q", repo)
69
}
69
}
70
70
71
// Env var takes priority over detection.
71
// Env var takes priority over detection.
72
os.Setenv("OKG_REPO", "from-env")
72
os.Setenv("OKG_REPO", "from-env")
73
defer os.Unsetenv("OKG_REPO")
73
defer os.Unsetenv("OKG_REPO")
74
repo, err = resolveRepo("")
74
repo, err = resolveRepo("")
75
if err != nil {
75
if err != nil {
76
t.Fatal(err)
76
t.Fatal(err)
77
}
77
}
78
if repo != "from-env" {
78
if repo != "from-env" {
79
t.Errorf("want from-env, got %q", repo)
79
t.Errorf("want from-env, got %q", repo)
80
}
80
}
81
}
81
}
82
82
83
func TestParsePRFlags(t *testing.T) {
83
func TestParsePRFlags(t *testing.T) {
84
f, rest, err := parsePRFlags([]string{
84
f, rest, err := parsePRFlags([]string{
85
"--repo", "widget", "--json", "42",
85
"--repo", "widget", "--json", "42",
86
})
86
})
87
if err != nil {
87
if err != nil {
88
t.Fatal(err)
88
t.Fatal(err)
89
}
89
}
90
if f.repo != "widget" {
90
if f.repo != "widget" {
91
t.Errorf(
91
t.Errorf(
92
"repo: want widget, got %q", f.repo)
92
"repo: want widget, got %q", f.repo)
93
}
93
}
94
if !f.asJSON {
94
if !f.asJSON {
95
t.Error("asJSON: want true")
95
t.Error("asJSON: want true")
96
}
96
}
97
if len(rest) != 1 || rest[0] != "42" {
97
if len(rest) != 1 || rest[0] != "42" {
98
t.Errorf("rest: want [42], got %v", rest)
98
t.Errorf("rest: want [42], got %v", rest)
99
}
99
}
100
}
100
}
101
101
102
func TestParsePRFlagsEmpty(t *testing.T) {
102
func TestParsePRFlagsEmpty(t *testing.T) {
103
f, rest, err := parsePRFlags(nil)
103
f, rest, err := parsePRFlags(nil)
104
if err != nil {
104
if err != nil {
105
t.Fatal(err)
105
t.Fatal(err)
106
}
106
}
107
if f.repo != "" {
107
if f.repo != "" {
108
t.Errorf(
108
t.Errorf(
109
"repo: want empty, got %q", f.repo)
109
"repo: want empty, got %q", f.repo)
110
}
110
}
111
if f.asJSON {
111
if f.asJSON {
112
t.Error("asJSON: want false")
112
t.Error("asJSON: want false")
113
}
113
}
114
if len(rest) != 0 {
114
if len(rest) != 0 {
115
t.Errorf("rest: want empty, got %v", rest)
115
t.Errorf("rest: want empty, got %v", rest)
116
}
116
}
117
}
117
}
118
118
119
func TestParsePRFlagsMissingValue(t *testing.T) {
119
func TestParsePRFlagsMissingValue(t *testing.T) {
120
_, _, err := parsePRFlags([]string{"--repo"})
120
_, _, err := parsePRFlags([]string{"--repo"})
121
if err == nil {
121
if err == nil {
122
t.Error("want error for --repo without value")
122
t.Error("want error for --repo without value")
123
}
123
}
124
}
124
}
125
125
126
func TestAge(t *testing.T) {
126
func TestAge(t *testing.T) {
127
check := func(d time.Duration, want string) {
127
check := func(d time.Duration, want string) {
128
t.Helper()
128
t.Helper()
129
got := age(time.Now().Add(-d))
129
got := age(time.Now().Add(-d))
130
if got != want {
130
if got != want {
131
t.Errorf(
131
t.Errorf(
132
"age(-%v): want %q, got %q",
132
"age(-%v): want %q, got %q",
133
d, want, got)
133
d, want, got)
134
}
134
}
135
}
135
}
136
check(30*time.Second, "just now")
136
check(30*time.Second, "just now")
137
check(5*time.Minute, "5m")
137
check(5*time.Minute, "5m")
138
check(3*time.Hour, "3h")
138
check(3*time.Hour, "3h")
139
check(48*time.Hour, "2d")
139
check(48*time.Hour, "2d")
140
}
140
}
141
141
142
func TestConfigOverride(t *testing.T) {
142
func TestConfigOverride(t *testing.T) {
143
.Setenv("OKG_HOST", "http://test:1234")
143
.Setenv("OKG_HOST", "http://test:1234")
144
os.Setenv("KLEX_API_KEY", "env-key")
145
defer os.Unsetenv("OKG_HOST")
146
defer os.Unsetenv("KLEX_API_KEY")
147
148
cfg, err := loadConfig()
144
cfg, err := loadConfig()
149
if err != nil {
145
if err != nil {
150
t.Fatal(err)
146
t.Fatal(err)
151
}
147
}
152
if cfg.Host != "http://test:1234" {
148
if cfg.Host != "http://test:1234" {
153
t.Errorf(
149
t.Errorf(
154
"Host: want http://test:1234, got %q",
150
"Host: want http://test:1234, got %q",
155
cfg.Host)
151
cfg.Host)
156
}
152
}
157
if cfg.ApiKey != "env-key" {
158
t.Errorf(
159
"ApiKey: want env-key, got %q",
160
cfg.ApiKey)
161
}
162
}
153
}
163
154
164
func TestConfigDefaultHost(t *testing.T) {
155
func TestConfigDefaultHost(t *testing.T) {
165
// Bypass the TestMain guardrail and isolate from any real
156
// Bypass the TestMain guardrail and isolate from any real
166
// ~/.config/okg/config.json so we can verify the prod default.
157
// ~/.config/okg/config.json so we can verify the prod default.
167
t.Setenv("HOME", t.TempDir())
158
t.Setenv("HOME", t.TempDir())
168
t.Setenv("OKG_HOST", "")
159
t.Setenv("OKG_HOST", "")
169
t.Setenv("KLEX_API_KEY", "")
170
cfg, err := loadConfig()
160
cfg, err := loadConfig()
171
if err != nil {
161
if err != nil {
172
t.Fatal(err)
162
t.Fatal(err)
173
}
163
}
174
if cfg.Host != "https://code.oscarkilo.com" {
164
if cfg.Host != "https://code.oscarkilo.com" {
175
t.Errorf(
165
t.Errorf(
176
"Host: want https://code.oscarkilo.com, got %q",
166
"Host: want https://code.oscarkilo.com, got %q",
177
cfg.Host)
167
cfg.Host)
178
}
168
}
179
}
169
}
180
170
171
// writeTestKey creates an isolated ~/.config/okg/config.json
172
// in a temp dir with the given API key. Used by tests that need
173
// loadConfig to return a key without touching the user's real
174
// config.
175
func writeTestKey(t *testing.T, key string) {
176
t.Helper()
177
t.Setenv("HOME", t.TempDir())
178
if err := saveConfig(&Config{ApiKey: key}); err != nil {
179
t.Fatal(err)
180
}
181
}
182
183
func TestAuthLoginKeyFlag(t *testing.T) {
184
t.Setenv("HOME", t.TempDir())
185
if err := runAuthLogin(
186
[]string{"--key", "sk-flag"},
187
); err != nil {
188
t.Fatal(err)
189
}
190
cfg, err := loadConfig()
191
if err != nil {
192
t.Fatal(err)
193
}
194
if cfg.ApiKey != "sk-flag" {
195
t.Errorf(
196
"ApiKey: want sk-flag, got %q", cfg.ApiKey)
197
}
198
}
199
200
func TestAuthLoginStdin(t *testing.T) {
201
t.Setenv("HOME", t.TempDir())
202
203
r, w, err := os.Pipe()
204
if err != nil {
205
t.Fatal(err)
206
}
207
orig := os.Stdin
208
os.Stdin = r
209
defer func() { os.Stdin = orig }()
210
go func() {
211
w.Write([]byte("sk-piped\n"))
212
w.Close()
213
}()
214
215
if err := runAuthLogin(nil); err != nil {
216
t.Fatal(err)
217
}
218
cfg, err := loadConfig()
219
if err != nil {
220
t.Fatal(err)
221
}
222
if cfg.ApiKey != "sk-piped" {
223
t.Errorf(
224
"ApiKey: want sk-piped, got %q", cfg.ApiKey)
225
}
226
}
227
181
func TestRunRepoCreateArgs(t *testing.T) {
228
func TestRunRepoCreateArgs(t *testing.T) {
182
mock := newMockKleeRepo(t, 204, "")
229
mock := newMockKleeRepo(t, 204, "")
183
defer mock.Close()
230
defer mock.Close()
184
231
185
ee, )
232
ee, )
186
.Setenv("_", )
233
.Setenv("_", )
187
defer os.Unsetenv("OKG_HOST")
188
defer os.Unsetenv("KLEX_API_KEY")
189
234
190
err := runRepoCreate([]string{
235
err := runRepoCreate([]string{
191
"my-repo", "--reader", "igor.agents",
236
"my-repo", "--reader", "igor.agents",
192
})
237
})
193
if err != nil {
238
if err != nil {
194
t.Fatal(err)
239
t.Fatal(err)
195
}
240
}
196
if mock.req.RepoName != "my-repo" {
241
if mock.req.RepoName != "my-repo" {
197
t.Errorf(
242
t.Errorf(
198
"repo_name: want my-repo, got %q",
243
"repo_name: want my-repo, got %q",
199
mock.req.RepoName)
244
mock.req.RepoName)
200
}
245
}
201
if mock.req.ReaderUsername != "igor.agents" {
246
if mock.req.ReaderUsername != "igor.agents" {
202
t.Errorf(
247
t.Errorf(
203
"reader: want igor.agents, got %q",
248
"reader: want igor.agents, got %q",
204
mock.req.ReaderUsername)
249
mock.req.ReaderUsername)
205
}
250
}
206
}
251
}
207
252
208
func TestRunRepoCreateNoReader(t *testing.T) {
253
func TestRunRepoCreateNoReader(t *testing.T) {
209
mock := newMockKleeRepo(t, 204, "")
254
mock := newMockKleeRepo(t, 204, "")
210
defer mock.Close()
255
defer mock.Close()
211
256
212
ee, )
257
ee, )
213
.Setenv("_", )
258
.Setenv("_", )
214
defer os.Unsetenv("OKG_HOST")
215
defer os.Unsetenv("KLEX_API_KEY")
216
259
217
err := runRepoCreate([]string{"my-repo"})
260
err := runRepoCreate([]string{"my-repo"})
218
if err != nil {
261
if err != nil {
219
t.Fatal(err)
262
t.Fatal(err)
220
}
263
}
221
if mock.req.ReaderUsername != "" {
264
if mock.req.ReaderUsername != "" {
222
t.Errorf(
265
t.Errorf(
223
"reader: want empty, got %q",
266
"reader: want empty, got %q",
224
mock.req.ReaderUsername)
267
mock.req.ReaderUsername)
225
}
268
}
226
}
269
}
227
270
228
func TestRunRepoCreateMissingName(t *testing.T) {
271
func TestRunRepoCreateMissingName(t *testing.T) {
229
err := runRepoCreate(nil)
272
err := runRepoCreate(nil)
230
if err == nil {
273
if err == nil {
231
t.Fatal("want error for missing name")
274
t.Fatal("want error for missing name")
232
}
275
}
233
}
276
}
234
277
235
func TestRunRepoCreateReaderMissingValue(
278
func TestRunRepoCreateReaderMissingValue(
236
t *testing.T,
279
t *testing.T,
237
) {
280
) {
238
err := runRepoCreate([]string{
281
err := runRepoCreate([]string{
239
"my-repo", "--reader",
282
"my-repo", "--reader",
240
})
283
})
241
if err == nil {
284
if err == nil {
242
t.Fatal(
285
t.Fatal(
243
"want error for --reader without value")
286
"want error for --reader without value")
244
}
287
}
245
}
288
}
246
289
247
func TestRunRepoCreateUnknownFlag(t *testing.T) {
290
func TestRunRepoCreateUnknownFlag(t *testing.T) {
248
err := runRepoCreate([]string{
291
err := runRepoCreate([]string{
249
"my-repo", "--bogus",
292
"my-repo", "--bogus",
250
})
293
})
251
if err == nil {
294
if err == nil {
252
t.Fatal("want error for unknown flag")
295
t.Fatal("want error for unknown flag")
253
}
296
}
254
}
297
}
255
298
256
func TestRunRepoCreateDuplicateName(
299
func TestRunRepoCreateDuplicateName(
257
t *testing.T,
300
t *testing.T,
258
) {
301
) {
259
err := runRepoCreate([]string{
302
err := runRepoCreate([]string{
260
"my-repo", "extra",
303
"my-repo", "extra",
261
})
304
})
262
if err == nil {
305
if err == nil {
263
t.Fatal(
306
t.Fatal(
264
"want error for extra positional arg")
307
"want error for extra positional arg")
265
}
308
}
266
}
309
}
267
310
268
func TestRunRepoCreateServerError(t *testing.T) {
311
func TestRunRepoCreateServerError(t *testing.T) {
269
mock := newMockKleeRepo(t, 403,
312
mock := newMockKleeRepo(t, 403,
270
"you are not an owner of "+
313
"you are not an owner of "+
271
"klee://code.oscarkilo.com/.new-repo")
314
"klee://code.oscarkilo.com/.new-repo")
272
defer mock.Close()
315
defer mock.Close()
273
316
274
ee, )
317
ee, )
275
.Setenv("_", )
318
.Setenv("_", )
276
defer os.Unsetenv("OKG_HOST")
277
defer os.Unsetenv("KLEX_API_KEY")
278
319
279
err := runRepoCreate([]string{"my-repo"})
320
err := runRepoCreate([]string{"my-repo"})
280
if err == nil {
321
if err == nil {
281
t.Fatal("want error on 403")
322
t.Fatal("want error on 403")
282
}
323
}
283
if !strings.Contains(err.Error(), "403") {
324
if !strings.Contains(err.Error(), "403") {
284
t.Errorf("want 403 in error, got %q", err)
325
t.Errorf("want 403 in error, got %q", err)
285
}
326
}
286
}
327
}
287
328
288
// mockKleeRepo captures the /.add-repo request.
329
// mockKleeRepo captures the /.add-repo request.
289
type mockKleeRepo struct {
330
type mockKleeRepo struct {
290
*httptest.Server
331
*httptest.Server
291
req klee.CreateRepoRequest
332
req klee.CreateRepoRequest
292
}
333
}
293
334
294
func newMockKleeRepo(
335
func newMockKleeRepo(
295
t *testing.T, status int, body string,
336
t *testing.T, status int, body string,
296
) *mockKleeRepo {
337
) *mockKleeRepo {
297
t.Helper()
338
t.Helper()
298
mk := &mockKleeRepo{}
339
mk := &mockKleeRepo{}
299
mk.Server = httptest.NewServer(
340
mk.Server = httptest.NewServer(
300
http.HandlerFunc(func(
341
http.HandlerFunc(func(
301
w http.ResponseWriter, r *http.Request,
342
w http.ResponseWriter, r *http.Request,
302
) {
343
) {
303
json.NewDecoder(r.Body).Decode(&mk.req)
344
json.NewDecoder(r.Body).Decode(&mk.req)
304
w.WriteHeader(status)
345
w.WriteHeader(status)
305
if body != "" {
346
if body != "" {
306
w.Write([]byte(body))
347
w.Write([]byte(body))
307
}
348
}
308
}))
349
}))
309
return mk
350
return mk
310
}
351
}