code.oscarkilo.com/okg

Hash:
5d8118d5f2b6dfe18e99dd6e8e37ef9ff8b12716
Author:
Igor Naverniouk <[email protected]>
Date:
Sat Jun 6 13:43:08 2026 -0400
Message:
okg: drop all env vars; thread *Config explicitly to handlers Replace env-var-based dev/test knobs with explicit Go-side plumbing. Production reads only the config file; tests construct a *Config locally and call handlers with it. Removed env vars: OKG_HOST — set host via `okg auth login --host` OKG_CODE_HOST — set via `okg auth login --code-host` OKG_REPO — pass `--repo` or cd into a klee checkout KLEX_URL — Klex URL is now cfg.Host + "/klex" Signature shape: - loadConfig(path) and saveConfig(path, cfg) take a path arg. - defaultConfigPath() (was configPath) wraps os.UserHomeDir. - main() resolves path once, loads cfg once, dispatches. - Every runX handler takes (*Config, []string). - runAuth/runAuthLogin take (path, args) — they write. - newWhoClient/newChatClient/newKleeClient/newKlexClient take *Config; no more internal loadConfig() calls. Tests no longer touch process state: - TestMain deleted (no global to seed). - Every test constructs its own *Config and passes it in. - Auth-login tests pass a tempdir path; saveConfig writes there, loadConfig reads back. - t.Parallel() on every test except TestAuthLoginStdin (mutates os.Stdin). - TestConfigOKGHostOverride and the env branch of the old TestResolveRepo deleted (env vars gone). - New TestConfigMigrateOldHost covers the one-shot Host-was-klee-URL migration kept in loadConfig. The Host/CodeHost migration block stays in loadConfig so saved configs from before the split still work without a re-auth.
diff --git a/auth.go b/auth.go
index 3c8837a..219c95c 100644
--- a/auth.go
+++ b/auth.go
@@ -6,25 +6,25 @@ import "io"
import "os"
import "strings"

-func runAuth(args []string) error {
+func runAuth(path string, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: okg auth login")
}
switch args[0] {
case "login":
- return runAuthLogin(args[1:])
+ return runAuthLogin(path, args[1:])
default:
return fmt.Errorf(
"unknown auth command: %s", args[0])
}
}

-// runAuthLogin saves a host + API key to ~/.config/okg/config.json.
-// The key can come from --key, from stdin (pipe), or — when stdin
-// is a TTY — from an interactive prompt. Host defaults to
-// production unless --host is given (and, in interactive mode, the
-// prompt offers an override).
-func runAuthLogin(args []string) error {
+// runAuthLogin saves a host + API key to path. The key can come
+// from --key, from stdin (pipe), or — when stdin is a TTY —
+// from an interactive prompt. Host defaults to production unless
+// --host is given (and, in interactive mode, the prompt offers
+// an override).
+func runAuthLogin(path string, args []string) error {
host := ""
codeHost := ""
key := ""
@@ -100,10 +100,10 @@ func runAuthLogin(args []string) error {
cfg := &Config{
Host: host, CodeHost: codeHost, ApiKey: key,
}
- if err := saveConfig(cfg); err != nil {
+ if err := saveConfig(path, cfg); err != nil {
return fmt.Errorf("saving config: %v", err)
}
- fmt.Printf("Saved config to %s\n", configPath())
+ fmt.Printf("Saved config to %s\n", path)
return nil
}

diff --git a/authz.go b/authz.go
index 351ecf4..376927e 100644
--- a/authz.go
+++ b/authz.go
@@ -8,7 +8,7 @@ import "text/tabwriter"

import "oscarkilo.com/okg/who"

-func runAuthz(args []string) error {
+func runAuthz(cfg *Config, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"usage: okg authz SUBCOMMAND ... " +
@@ -16,25 +16,25 @@ func runAuthz(args []string) error {
}
switch args[0] {
case "list":
- return runAuthzList(args[1:])
+ return runAuthzList(cfg, args[1:])
case "set":
- return runAuthzSet(args[1:])
+ return runAuthzSet(cfg, args[1:])
case "delete":
- return runAuthzDelete(args[1:])
+ return runAuthzDelete(cfg, args[1:])
default:
return fmt.Errorf(
"unknown authz subcommand: %s", args[0])
}
}

-func runAuthzList(args []string) error {
+func runAuthzList(cfg *Config, args []string) error {
fs := flag.NewFlagSet("authz list", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "output raw JSON")
if err := fs.Parse(args); err != nil {
return err
}

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -83,7 +83,7 @@ func rights(e who.AuthzEntry) string {
}
}

-func runAuthzSet(args []string) error {
+func runAuthzSet(cfg *Config, args []string) error {
fs := flag.NewFlagSet("authz set", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
@@ -97,7 +97,7 @@ func runAuthzSet(args []string) error {
owner := positional[1]
reader := positional[2]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -114,7 +114,7 @@ func runAuthzSet(args []string) error {
return nil
}

-func runAuthzDelete(args []string) error {
+func runAuthzDelete(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"authz delete", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
@@ -127,7 +127,7 @@ func runAuthzDelete(args []string) error {
}
uri := positional[0]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
diff --git a/chat.go b/chat.go
index 80ff8ba..21ab97e 100644
--- a/chat.go
+++ b/chat.go
@@ -6,7 +6,7 @@ import "fmt"

import "oscarkilo.com/okg/chat"

-func runChat(args []string) error {
+func runChat(cfg *Config, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"usage: okg chat SUBCOMMAND ... " +
@@ -14,16 +14,16 @@ func runChat(args []string) error {
}
switch args[0] {
case "send":
- return runChatSend(args[1:])
+ return runChatSend(cfg, args[1:])
case "fetch":
- return runChatFetch(args[1:])
+ return runChatFetch(cfg, args[1:])
default:
return fmt.Errorf(
"unknown chat subcommand: %s", args[0])
}
}

-func runChatSend(args []string) error {
+func runChatSend(cfg *Config, args []string) error {
fs := flag.NewFlagSet("chat send", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
@@ -35,7 +35,7 @@ func runChatSend(args []string) error {
to := positional[0]
text := positional[1]

- c, err := newChatClient()
+ c, err := newChatClient(cfg)
if err != nil {
return err
}
@@ -50,7 +50,7 @@ func runChatSend(args []string) error {
return nil
}

-func runChatFetch(args []string) error {
+func runChatFetch(cfg *Config, args []string) error {
fs := flag.NewFlagSet("chat fetch", flag.ContinueOnError)
to := fs.String("to", "",
"filter by destination group (default: all visible)")
@@ -59,7 +59,7 @@ func runChatFetch(args []string) error {
return err
}

- c, err := newChatClient()
+ c, err := newChatClient(cfg)
if err != nil {
return err
}
@@ -86,12 +86,8 @@ func runChatFetch(args []string) error {
return nil
}

-// newChatClient builds a //chat client from saved config.
-func newChatClient() (*chat.HTTPClient, error) {
- cfg, err := loadConfig()
- if err != nil {
- return nil, err
- }
+// newChatClient builds a //chat client.
+func newChatClient(cfg *Config) (*chat.HTTPClient, error) {
if cfg.ApiKey == "" {
return nil, fmt.Errorf(
"no API key — run `okg auth login --key sk-...`")
diff --git a/client.go b/client.go
index 7e54425..68e8740 100644
--- a/client.go
+++ b/client.go
@@ -1,7 +1,6 @@
package main

import "fmt"
-import "os"
import "os/exec"
import "regexp"
import "strings"
@@ -32,15 +31,12 @@ func detectRepo() (string, error) {
return m[1], nil
}

-// resolveRepo returns the repo name from --repo
-// flag, OKG_REPO env var, or git remote detection.
+// resolveRepo returns the repo name from --repo flag or
+// git remote detection.
func resolveRepo(flag_repo string) (string, error) {
if flag_repo != "" {
return flag_repo, nil
}
- if v := os.Getenv("OKG_REPO"); v != "" {
- return v, nil
- }
return detectRepo()
}

diff --git a/config.go b/config.go
index 28f8242..597826d 100644
--- a/config.go
+++ b/config.go
@@ -20,7 +20,10 @@ type Config struct {
ApiKey string `json:"api_key"`
}

-func configPath() string {
+// defaultConfigPath returns the canonical config location,
+// ~/.config/okg/config.json. Empty if the home dir is
+// unresolvable.
+func defaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
@@ -28,11 +31,11 @@ func configPath() string {
return filepath.Join(home, ".config", "okg", "config.json")
}

-func loadConfig() (*Config, error) {
+// loadConfig reads the config file at path. Missing-file is
+// not an error; the returned Config has defaults filled in.
+// Pass "" to get pure defaults.
+func loadConfig(path string) (*Config, error) {
c := &Config{}
-
- // Load from file.
- path := configPath()
if path != "" {
data, err := os.ReadFile(path)
if err == nil {
@@ -40,16 +43,6 @@ func loadConfig() (*Config, error) {
}
}

- // Env overrides. Both exist for tests (TestMain points them
- // at localhost so accidental hits to prod fail fast). Not
- // user-facing config knobs; `okg auth login` is.
- if v := os.Getenv("OKG_HOST"); v != "" {
- c.Host = v
- }
- if v := os.Getenv("OKG_CODE_HOST"); v != "" {
- c.CodeHost = v
- }
-
// Migrate old configs whose Host field stored the klee URL.
// Treat that value as CodeHost and reset Host to default.
if c.CodeHost == "" && c.Host == DefaultCodeHost {
@@ -67,10 +60,9 @@ func loadConfig() (*Config, error) {
return c, nil
}

-func saveConfig(c *Config) error {
- path := configPath()
+func saveConfig(path string, c *Config) error {
if path == "" {
- return fmt.Errorf("cannot determine home directory")
+ return fmt.Errorf("config path is empty")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
diff --git a/embed.go b/embed.go
index 0fc86ce..3cf98f6 100644
--- a/embed.go
+++ b/embed.go
@@ -10,7 +10,7 @@ import "oscarkilo.com/klex-git/api"
// runEmbed converts stdin text into embedding vectors, written to
// stdout one vector per line, space-separated floats. Mirrors the
// (now-deprecated) `klex-git/embed` binary.
-func runEmbed(args []string) error {
+func runEmbed(cfg *Config, args []string) error {
fs := flag.NewFlagSet("embed", flag.ContinueOnError)
model := fs.String("model",
"openai:text-embedding-3-small",
@@ -24,10 +24,6 @@ func runEmbed(args []string) error {
return err
}

- cfg, err := loadConfig()
- if err != nil {
- return err
- }
if cfg.ApiKey == "" {
return fmt.Errorf(
"no API key — run `okg auth login --key sk-...`")
diff --git a/exemplary.go b/exemplary.go
index 6a8e6bc..eb3c948 100644
--- a/exemplary.go
+++ b/exemplary.go
@@ -18,7 +18,7 @@ import "oscarkilo.com/klex-git/api"
// are written back as <case>.out files.
//
// Mirrors the (now-deprecated) `klex-git/exemplary` binary.
-func runExemplary(args []string) error {
+func runExemplary(cfg *Config, args []string) error {
fs := flag.NewFlagSet("exemplary", flag.ContinueOnError)
dir := fs.String("dir", ".",
"directory to scan for cases and write outputs to")
@@ -32,10 +32,6 @@ func runExemplary(args []string) error {
return err
}

- cfg, err := loadConfig()
- if err != nil {
- return err
- }
if cfg.ApiKey == "" {
return fmt.Errorf(
"no API key — run `okg auth login --key sk-...`")
diff --git a/group.go b/group.go
index ad91285..0f285d6 100644
--- a/group.go
+++ b/group.go
@@ -8,7 +8,7 @@ import "text/tabwriter"

import "oscarkilo.com/okg/who"

-func runGroup(args []string) error {
+func runGroup(cfg *Config, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"usage: okg group SUBCOMMAND ... " +
@@ -16,31 +16,31 @@ func runGroup(args []string) error {
}
switch args[0] {
case "list":
- return runGroupList(args[1:])
+ return runGroupList(cfg, args[1:])
case "create":
- return runGroupCreate(args[1:])
+ return runGroupCreate(cfg, args[1:])
case "add-member":
- return runGroupAddMember(args[1:])
+ return runGroupAddMember(cfg, args[1:])
case "remove-member":
- return runGroupRemoveMember(args[1:])
+ return runGroupRemoveMember(cfg, args[1:])
case "members":
- return runGroupMembers(args[1:])
+ return runGroupMembers(cfg, args[1:])
case "delete":
- return runGroupDelete(args[1:])
+ return runGroupDelete(cfg, args[1:])
default:
return fmt.Errorf(
"unknown group subcommand: %s", args[0])
}
}

-func runGroupList(args []string) error {
+func runGroupList(cfg *Config, args []string) error {
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
asJSON := fs.Bool("json", false, "output raw JSON")
if err := fs.Parse(args); err != nil {
return err
}

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -68,7 +68,7 @@ func runGroupList(args []string) error {
return tw.Flush()
}

-func runGroupCreate(args []string) error {
+func runGroupCreate(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"group create", flag.ContinueOnError)
fullName := fs.String("full-name", "",
@@ -84,7 +84,7 @@ func runGroupCreate(args []string) error {
}
name := positional[0]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -114,7 +114,7 @@ func runGroupCreate(args []string) error {
return nil
}

-func runGroupAddMember(args []string) error {
+func runGroupAddMember(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"group add-member", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
@@ -128,7 +128,7 @@ func runGroupAddMember(args []string) error {
group := positional[0]
members := positional[1:]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -143,7 +143,7 @@ func runGroupAddMember(args []string) error {
return nil
}

-func runGroupRemoveMember(args []string) error {
+func runGroupRemoveMember(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"group remove-member", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
@@ -158,7 +158,7 @@ func runGroupRemoveMember(args []string) error {
groupName := positional[0]
members := positional[1:]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -200,7 +200,7 @@ func runGroupRemoveMember(args []string) error {
return nil
}

-func runGroupMembers(args []string) error {
+func runGroupMembers(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"group members", flag.ContinueOnError)
asJSON := fs.Bool("json", false,
@@ -214,7 +214,7 @@ func runGroupMembers(args []string) error {
}
groupName := positional[0]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -248,7 +248,7 @@ func runGroupMembers(args []string) error {
return nil
}

-func runGroupDelete(args []string) error {
+func runGroupDelete(cfg *Config, args []string) error {
fs := flag.NewFlagSet(
"group delete", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
@@ -260,7 +260,7 @@ func runGroupDelete(args []string) error {
}
groupName := positional[0]

- c, err := newWhoClient()
+ c, err := newWhoClient(cfg)
if err != nil {
return err
}
@@ -297,13 +297,9 @@ func resolveGroupOwid(
"group %q not found (or not visible to caller)", name)
}

-// newWhoClient builds a //who client from saved config. Shared
-// by every `okg group` and `okg authz` subcommand.
-func newWhoClient() (*who.HTTPClient, error) {
- cfg, err := loadConfig()
- if err != nil {
- return nil, err
- }
+// newWhoClient builds a //who client. Shared by every
+// `okg group` and `okg authz` subcommand.
+func newWhoClient(cfg *Config) (*who.HTTPClient, error) {
if cfg.ApiKey == "" {
return nil, fmt.Errorf(
"no API key — run `okg auth login --key sk-...`")
diff --git a/klex.go b/klex.go
index e3c79f5..c50ed81 100644
--- a/klex.go
+++ b/klex.go
@@ -1,17 +1,10 @@
package main

-import "os"
-
import "oscarkilo.com/klex-git/api"

-// KlexDefaultUrl is the production Klex LLM endpoint. Override
-// via the KLEX_URL env var for dev/tests.
-const KlexDefaultUrl = "https://oscarkilo.com/klex"
-
+// newKlexClient builds a Klex LLM client. The endpoint is the
+// public host's /klex path; tests redirect by overriding
+// cfg.Host.
func newKlexClient(cfg *Config) *api.Client {
- url := KlexDefaultUrl
- if v := os.Getenv("KLEX_URL"); v != "" {
- url = v
- }
- return api.NewClient(url, cfg.ApiKey)
+ return api.NewClient(cfg.Host+"/klex", cfg.ApiKey)
}
diff --git a/main.go b/main.go
index 1f5d91c..caaf5ad 100644
--- a/main.go
+++ b/main.go
@@ -3,20 +3,6 @@ package main
import "fmt"
import "os"

-// commands maps each top-level subcommand to its handler.
-// printUsage's sections need to stay in sync with this list.
-var commands = map[string]func([]string) error{
- "pr": runPR,
- "repo": runRepo,
- "auth": runAuth,
- "embed": runEmbed,
- "one": runOne,
- "exemplary": runExemplary,
- "group": runGroup,
- "authz": runAuthz,
- "chat": runChat,
-}
-
func main() {
args := os.Args[1:]
if len(args) == 0 {
@@ -28,14 +14,41 @@ func main() {
printUsage()
return
}
- fn, ok := commands[args[0]]
- if !ok {
+
+ cfgPath := defaultConfigPath()
+ cfg, err := loadConfig(cfgPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+
+ rest := args[1:]
+ switch args[0] {
+ case "pr":
+ err = runPR(cfg, rest)
+ case "repo":
+ err = runRepo(cfg, rest)
+ case "auth":
+ err = runAuth(cfgPath, rest)
+ case "embed":
+ err = runEmbed(cfg, rest)
+ case "one":
+ err = runOne(cfg, rest)
+ case "exemplary":
+ err = runExemplary(cfg, rest)
+ case "group":
+ err = runGroup(cfg, rest)
+ case "authz":
+ err = runAuthz(cfg, rest)
+ case "chat":
+ err = runChat(cfg, rest)
+ default:
fmt.Fprintf(
os.Stderr, "unknown command: %s\n", args[0])
printUsage()
os.Exit(1)
}
- if err := fn(args[1:]); err != nil {
+ if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
diff --git a/okg_test.go b/okg_test.go
index 2a099d6..ea89e7c 100644
--- a/okg_test.go
+++ b/okg_test.go
@@ -4,23 +4,15 @@ import "encoding/json"
import "net/http"
import "net/http/httptest"
import "os"
+import "path/filepath"
import "strings"
import "testing"
import "time"

import "oscarkilo.com/okg/klee"

-// TestMain points both host fields at localhost so the suite
-// can't accidentally reach prod. Individual tests that need a
-// specific URL (e.g. a mock httptest server for klee) override
-// OKG_CODE_HOST themselves.
-func TestMain(m *testing.M) {
- os.Setenv("OKG_HOST", "http://localhost:42069")
- os.Setenv("OKG_CODE_HOST", "http://localhost:42069")
- os.Exit(m.Run())
-}
-
func TestRepoRegex(t *testing.T) {
+ t.Parallel()
check := func(url, want string) {
t.Helper()
m := repoRegex.FindStringSubmatch(url)
@@ -59,8 +51,8 @@ func TestRepoRegex(t *testing.T) {
check("not-a-url", "")
}

-func TestResolveRepo(t *testing.T) {
- // Flag takes priority.
+func TestResolveRepoFlag(t *testing.T) {
+ t.Parallel()
repo, err := resolveRepo("from-flag")
if err != nil {
t.Fatal(err)
@@ -68,20 +60,10 @@ func TestResolveRepo(t *testing.T) {
if repo != "from-flag" {
t.Errorf("want from-flag, got %q", repo)
}
-
- // Env var takes priority over detection.
- os.Setenv("OKG_REPO", "from-env")
- defer os.Unsetenv("OKG_REPO")
- repo, err = resolveRepo("")
- if err != nil {
- t.Fatal(err)
- }
- if repo != "from-env" {
- t.Errorf("want from-env, got %q", repo)
- }
}

func TestParsePRFlags(t *testing.T) {
+ t.Parallel()
f, rest, err := parsePRFlags([]string{
"--repo", "widget", "--json", "42",
})
@@ -101,6 +83,7 @@ func TestParsePRFlags(t *testing.T) {
}

func TestParsePRFlagsEmpty(t *testing.T) {
+ t.Parallel()
f, rest, err := parsePRFlags(nil)
if err != nil {
t.Fatal(err)
@@ -118,6 +101,7 @@ func TestParsePRFlagsEmpty(t *testing.T) {
}

func TestParsePRFlagsMissingValue(t *testing.T) {
+ t.Parallel()
_, _, err := parsePRFlags([]string{"--repo"})
if err == nil {
t.Error("want error for --repo without value")
@@ -125,6 +109,7 @@ func TestParsePRFlagsMissingValue(t *testing.T) {
}

func TestAge(t *testing.T) {
+ t.Parallel()
check := func(d time.Duration, want string) {
t.Helper()
got := age(time.Now().Add(-d))
@@ -140,61 +125,79 @@ func TestAge(t *testing.T) {
check(48*time.Hour, "2d")
}

-func TestConfigOKGHostOverride(t *testing.T) {
- t.Setenv("OKG_HOST", "http://test:1234")
- cfg, err := loadConfig()
+func TestConfigDefaultsEmptyPath(t *testing.T) {
+ t.Parallel()
+ cfg, err := loadConfig("")
if err != nil {
t.Fatal(err)
}
- if cfg.Host != "http://test:1234" {
+ if cfg.Host != DefaultHost {
t.Errorf(
- "Host: want http://test:1234, got %q",
- cfg.Host)
+ "Host: want %q, got %q",
+ DefaultHost, cfg.Host)
+ }
+ if cfg.CodeHost != DefaultCodeHost {
+ t.Errorf(
+ "CodeHost: want %q, got %q",
+ DefaultCodeHost, cfg.CodeHost)
}
}

-func TestConfigDefaultHost(t *testing.T) {
- // Bypass the TestMain guardrail and isolate from any real
- // ~/.config/okg/config.json so we can verify the prod default.
- t.Setenv("HOME", t.TempDir())
- t.Setenv("OKG_HOST", "")
- t.Setenv("OKG_CODE_HOST", "")
- cfg, err := loadConfig()
+func TestConfigDefaultsMissingFile(t *testing.T) {
+ t.Parallel()
+ path := filepath.Join(t.TempDir(), "config.json")
+ cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
- if cfg.Host != "https://oscarkilo.com" {
+ if cfg.Host != DefaultHost {
t.Errorf(
- "Host: want https://oscarkilo.com, got %q",
- cfg.Host)
+ "Host: want %q, got %q",
+ DefaultHost, cfg.Host)
}
- if cfg.CodeHost != "https://code.oscarkilo.com" {
+ if cfg.CodeHost != DefaultCodeHost {
t.Errorf(
- "CodeHost: want https://code.oscarkilo.com, got %q",
- cfg.CodeHost)
+ "CodeHost: want %q, got %q",
+ DefaultCodeHost, cfg.CodeHost)
}
}

-// writeTestKey creates an isolated ~/.config/okg/config.json
-// in a temp dir with the given API key. Used by tests that need
-// loadConfig to return a key without touching the user's real
-// config.
-func writeTestKey(t *testing.T, key string) {
- t.Helper()
- t.Setenv("HOME", t.TempDir())
- if err := saveConfig(&Config{ApiKey: key}); err != nil {
+// TestConfigMigrateOldHost covers configs saved before the
+// Host/CodeHost split: Host held the klee URL. The migration
+// moves it to CodeHost.
+func TestConfigMigrateOldHost(t *testing.T) {
+ t.Parallel()
+ path := filepath.Join(t.TempDir(), "config.json")
+ old := []byte(
+ `{"host": "https://code.oscarkilo.com", ` +
+ `"api_key": "k"}`)
+ if err := os.WriteFile(path, old, 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
t.Fatal(err)
}
+ if cfg.Host != DefaultHost {
+ t.Errorf(
+ "Host: want %q, got %q",
+ DefaultHost, cfg.Host)
+ }
+ if cfg.CodeHost != DefaultCodeHost {
+ t.Errorf(
+ "CodeHost: want %q, got %q",
+ DefaultCodeHost, cfg.CodeHost)
+ }
}

func TestAuthLoginKeyFlag(t *testing.T) {
- t.Setenv("HOME", t.TempDir())
- if err := runAuthLogin(
- []string{"--key", "sk-flag"},
- ); err != nil {
+ t.Parallel()
+ path := filepath.Join(t.TempDir(), "config.json")
+ err := runAuthLogin(path, []string{"--key", "sk-flag"})
+ if err != nil {
t.Fatal(err)
}
- cfg, err := loadConfig()
+ cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
@@ -204,9 +207,9 @@ func TestAuthLoginKeyFlag(t *testing.T) {
}
}

+// TestAuthLoginStdin mutates os.Stdin and so cannot run in
+// parallel with other tests that might read stdin.
func TestAuthLoginStdin(t *testing.T) {
- t.Setenv("HOME", t.TempDir())
-
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
@@ -219,10 +222,11 @@ func TestAuthLoginStdin(t *testing.T) {
w.Close()
}()

- if err := runAuthLogin(nil); err != nil {
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := runAuthLogin(path, nil); err != nil {
t.Fatal(err)
}
- cfg, err := loadConfig()
+ cfg, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
@@ -233,13 +237,15 @@ func TestAuthLoginStdin(t *testing.T) {
}

func TestRunRepoCreateArgs(t *testing.T) {
+ t.Parallel()
mock := newMockKleeRepo(t, 204, "")
defer mock.Close()

- writeTestKey(t, "test-key")
- t.Setenv("OKG_CODE_HOST", mock.URL)
-
- err := runRepoCreate([]string{
+ cfg := &Config{
+ CodeHost: mock.URL,
+ ApiKey: "test-key",
+ }
+ err := runRepoCreate(cfg, []string{
"my-repo", "--reader", "igor.agents",
})
if err != nil {
@@ -258,13 +264,15 @@ func TestRunRepoCreateArgs(t *testing.T) {
}

func TestRunRepoCreateNoReader(t *testing.T) {
+ t.Parallel()
mock := newMockKleeRepo(t, 204, "")
defer mock.Close()

- writeTestKey(t, "test-key")
- t.Setenv("OKG_CODE_HOST", mock.URL)
-
- err := runRepoCreate([]string{"my-repo"})
+ cfg := &Config{
+ CodeHost: mock.URL,
+ ApiKey: "test-key",
+ }
+ err := runRepoCreate(cfg, []string{"my-repo"})
if err != nil {
t.Fatal(err)
}
@@ -276,16 +284,16 @@ func TestRunRepoCreateNoReader(t *testing.T) {
}

func TestRunRepoCreateMissingName(t *testing.T) {
- err := runRepoCreate(nil)
+ t.Parallel()
+ err := runRepoCreate(&Config{}, nil)
if err == nil {
t.Fatal("want error for missing name")
}
}

-func TestRunRepoCreateReaderMissingValue(
- t *testing.T,
-) {
- err := runRepoCreate([]string{
+func TestRunRepoCreateReaderMissingValue(t *testing.T) {
+ t.Parallel()
+ err := runRepoCreate(&Config{}, []string{
"my-repo", "--reader",
})
if err == nil {
@@ -295,7 +303,8 @@ func TestRunRepoCreateReaderMissingValue(
}

func TestRunRepoCreateUnknownFlag(t *testing.T) {
- err := runRepoCreate([]string{
+ t.Parallel()
+ err := runRepoCreate(&Config{}, []string{
"my-repo", "--bogus",
})
if err == nil {
@@ -303,10 +312,9 @@ func TestRunRepoCreateUnknownFlag(t *testing.T) {
}
}

-func TestRunRepoCreateDuplicateName(
- t *testing.T,
-) {
- err := runRepoCreate([]string{
+func TestRunRepoCreateDuplicateName(t *testing.T) {
+ t.Parallel()
+ err := runRepoCreate(&Config{}, []string{
"my-repo", "extra",
})
if err == nil {
@@ -316,15 +324,17 @@ func TestRunRepoCreateDuplicateName(
}

func TestRunRepoCreateServerError(t *testing.T) {
+ t.Parallel()
mock := newMockKleeRepo(t, 403,
"you are not an owner of "+
"klee://code.oscarkilo.com/.new-repo")
defer mock.Close()

- writeTestKey(t, "test-key")
- t.Setenv("OKG_CODE_HOST", mock.URL)
-
- err := runRepoCreate([]string{"my-repo"})
+ cfg := &Config{
+ CodeHost: mock.URL,
+ ApiKey: "test-key",
+ }
+ err := runRepoCreate(cfg, []string{"my-repo"})
if err == nil {
t.Fatal("want error on 403")
}
diff --git a/one.go b/one.go
index c19a421..e2fb487 100644
--- a/one.go
+++ b/one.go
@@ -14,7 +14,7 @@ import "oscarkilo.com/klex-git/api"
//
// Reads stdin as an api.MessagesRequest JSON; empty stdin is
// allowed (treated as {}). Flags override individual fields.
-func runOne(args []string) error {
+func runOne(cfg *Config, args []string) error {
fs := flag.NewFlagSet("one", flag.ContinueOnError)
model := fs.String("model", "",
"override .Model, if non-empty")
@@ -35,10 +35,6 @@ func runOne(args []string) error {
return err
}

- cfg, err := loadConfig()
- if err != nil {
- return err
- }
if cfg.ApiKey == "" {
return fmt.Errorf(
"no API key — run `okg auth login --key sk-...`")
diff --git a/pr.go b/pr.go
index 188b645..818ceed 100644
--- a/pr.go
+++ b/pr.go
@@ -10,7 +10,7 @@ import "time"

import "oscarkilo.com/okg/klee"

-func runPR(args []string) error {
+func runPR(cfg *Config, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"usage: okg pr <list|create|view|diff" +
@@ -18,21 +18,21 @@ func runPR(args []string) error {
}
switch args[0] {
case "list":
- return runPRList(args[1:])
+ return runPRList(cfg, args[1:])
case "create":
- return runPRCreate(args[1:])
+ return runPRCreate(cfg, args[1:])
case "view":
- return runPRView(args[1:])
+ return runPRView(cfg, args[1:])
case "diff":
- return runPRDiff(args[1:])
+ return runPRDiff(cfg, args[1:])
case "comment":
- return runPRComment(args[1:])
+ return runPRComment(cfg, args[1:])
case "merge":
- return runPRMerge(args[1:])
+ return runPRMerge(cfg, args[1:])
case "close":
- return runPRClose(args[1:])
+ return runPRClose(cfg, args[1:])
case "reopen":
- return runPRReopen(args[1:])
+ return runPRReopen(cfg, args[1:])
default:
return fmt.Errorf(
"unknown pr command: %s", args[0])
@@ -70,12 +70,8 @@ func parsePRFlags(args []string) (
}

func setupClient(
- flag_repo string,
+ cfg *Config, flag_repo string,
) (*klee.Client, string, error) {
- cfg, err := loadConfig()
- if err != nil {
- return nil, "", err
- }
repo, err := resolveRepo(flag_repo)
if err != nil {
return nil, "", err
@@ -106,7 +102,7 @@ func age(t time.Time) string {

// --- pr list ---

-func runPRList(args []string) error {
+func runPRList(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -124,7 +120,7 @@ func runPRList(args []string) error {
}
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -152,7 +148,7 @@ func runPRList(args []string) error {

// --- pr view ---

-func runPRView(args []string) error {
+func runPRView(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -167,7 +163,7 @@ func runPRView(args []string) error {
"invalid PR number: %s", rest[0])
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -223,7 +219,7 @@ func runPRView(args []string) error {

// --- pr diff ---

-func runPRDiff(args []string) error {
+func runPRDiff(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -238,7 +234,7 @@ func runPRDiff(args []string) error {
"invalid PR number: %s", rest[0])
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -258,7 +254,7 @@ func runPRDiff(args []string) error {

// --- pr create ---

-func runPRCreate(args []string) error {
+func runPRCreate(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -311,7 +307,7 @@ func runPRCreate(args []string) error {
return fmt.Errorf("--title is required")
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -339,7 +335,7 @@ func runPRCreate(args []string) error {

// --- pr comment ---

-func runPRComment(args []string) error {
+func runPRComment(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -379,7 +375,7 @@ func runPRComment(args []string) error {
return fmt.Errorf("--body is required")
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -407,7 +403,7 @@ func runPRComment(args []string) error {

// --- pr merge ---

-func runPRMerge(args []string) error {
+func runPRMerge(cfg *Config, args []string) error {
f, rest, err := parsePRFlags(args)
if err != nil {
return err
@@ -422,7 +418,7 @@ func runPRMerge(args []string) error {
"invalid PR number: %s", rest[0])
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
@@ -443,18 +439,18 @@ func runPRMerge(args []string) error {

// --- pr close ---

-func runPRClose(args []string) error {
- return runPRStateChange("closed", args)
+func runPRClose(cfg *Config, args []string) error {
+ return runPRStateChange(cfg, "closed", args)
}

// --- pr reopen ---

-func runPRReopen(args []string) error {
- return runPRStateChange("open", args)
+func runPRReopen(cfg *Config, args []string) error {
+ return runPRStateChange(cfg, "open", args)
}

func runPRStateChange(
- new_state string, args []string,
+ cfg *Config, new_state string, args []string,
) error {
f, rest, err := parsePRFlags(args)
if err != nil {
@@ -470,7 +466,7 @@ func runPRStateChange(
"invalid PR number: %s", rest[0])
}

- cl, repo, err := setupClient(f.repo)
+ cl, repo, err := setupClient(cfg, f.repo)
if err != nil {
return err
}
diff --git a/repo.go b/repo.go
index 1bb3e97..922d82e 100644
--- a/repo.go
+++ b/repo.go
@@ -7,23 +7,23 @@ import "text/tabwriter"

import "oscarkilo.com/okg/klee"

-func runRepo(args []string) error {
+func runRepo(cfg *Config, args []string) error {
if len(args) == 0 {
return fmt.Errorf(
"usage: okg repo <list|create>")
}
switch args[0] {
case "list":
- return runRepoList(args[1:])
+ return runRepoList(cfg, args[1:])
case "create":
- return runRepoCreate(args[1:])
+ return runRepoCreate(cfg, args[1:])
default:
return fmt.Errorf(
"unknown repo command: %s", args[0])
}
}

-func runRepoCreate(args []string) error {
+func runRepoCreate(cfg *Config, args []string) error {
reader := ""
name := ""
for i := 0; i < len(args); i++ {
@@ -53,13 +53,9 @@ func runRepoCreate(args []string) error {
"[--reader USER]")
}

- cfg, err := loadConfig()
- if err != nil {
- return err
- }
cl := newKleeClient(cfg)

- err = cl.CreateRepo(klee.CreateRepoRequest{
+ err := cl.CreateRepo(klee.CreateRepoRequest{
RepoName: name,
ReaderUsername: reader,
})
@@ -74,7 +70,7 @@ func runRepoCreate(args []string) error {
return nil
}

-func runRepoList(args []string) error {
+func runRepoList(cfg *Config, args []string) error {
as_json := false
for _, a := range args {
if a == "--json" {
@@ -82,10 +78,6 @@ func runRepoList(args []string) error {
}
}

- cfg, err := loadConfig()
- if err != nil {
- return err
- }
cl := newKleeClient(cfg)

res, err := cl.ListRepos()
a/auth.go
b/auth.go
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 "io"
6
import "os"
6
import "os"
7
import "strings"
7
import "strings"
8
8
9
func runAuth(args []string) error {
9
func runAuth(path string, args []string) error {
10
if len(args) == 0 {
10
if len(args) == 0 {
11
return fmt.Errorf("usage: okg auth login")
11
return fmt.Errorf("usage: okg auth login")
12
}
12
}
13
switch args[0] {
13
switch args[0] {
14
case "login":
14
case "login":
15
return runAuthLogin(args[1:])
15
return runAuthLogin(path, args[1:])
16
default:
16
default:
17
return fmt.Errorf(
17
return fmt.Errorf(
18
"unknown auth command: %s", args[0])
18
"unknown auth command: %s", args[0])
19
}
19
}
20
}
20
}
21
21
22
// runAuthLogin saves a host + API key to ~/.config/okg/config.json.
22
// runAuthLogin saves a host + API key to path. The key can come
23
// The key can come from --key, from stdin (pipe), or — when stdin
23
// from --key, from stdin (pipe), or — when stdin is a TTY —
24
// is a TTY — from an interactive prompt. Host defaults to
24
// from an interactive prompt. Host defaults to production unless
25
// production unless --host is given (and, in interactive mode, the
25
// --host is given (and, in interactive mode, the prompt offers
26
// prompt offers an override).
26
// an override).
27
func runAuthLogin(args []string) error {
27
func runAuthLogin(path string, args []string) error {
28
host := ""
28
host := ""
29
codeHost := ""
29
codeHost := ""
30
key := ""
30
key := ""
31
for i := 0; i < len(args); i++ {
31
for i := 0; i < len(args); i++ {
32
switch args[i] {
32
switch args[i] {
33
case "--host":
33
case "--host":
34
i++
34
i++
35
if i >= len(args) {
35
if i >= len(args) {
36
return fmt.Errorf(
36
return fmt.Errorf(
37
"--host requires a value")
37
"--host requires a value")
38
}
38
}
39
host = args[i]
39
host = args[i]
40
case "--code-host":
40
case "--code-host":
41
i++
41
i++
42
if i >= len(args) {
42
if i >= len(args) {
43
return fmt.Errorf(
43
return fmt.Errorf(
44
"--code-host requires a value")
44
"--code-host requires a value")
45
}
45
}
46
codeHost = args[i]
46
codeHost = args[i]
47
case "--key":
47
case "--key":
48
i++
48
i++
49
if i >= len(args) {
49
if i >= len(args) {
50
return fmt.Errorf(
50
return fmt.Errorf(
51
"--key requires a value")
51
"--key requires a value")
52
}
52
}
53
key = args[i]
53
key = args[i]
54
default:
54
default:
55
return fmt.Errorf(
55
return fmt.Errorf(
56
"unknown flag: %s", args[i])
56
"unknown flag: %s", args[i])
57
}
57
}
58
}
58
}
59
59
60
// Resolve API key: --key wins; otherwise read from stdin
60
// Resolve API key: --key wins; otherwise read from stdin
61
// (piped) or prompt interactively.
61
// (piped) or prompt interactively.
62
switch {
62
switch {
63
case key != "":
63
case key != "":
64
// Use as-is.
64
// Use as-is.
65
case stdinIsPiped():
65
case stdinIsPiped():
66
line, err := bufio.NewReader(os.Stdin).
66
line, err := bufio.NewReader(os.Stdin).
67
ReadString('\n')
67
ReadString('\n')
68
if err != nil && err != io.EOF {
68
if err != nil && err != io.EOF {
69
return fmt.Errorf("reading stdin: %v", err)
69
return fmt.Errorf("reading stdin: %v", err)
70
}
70
}
71
key = strings.TrimSpace(line)
71
key = strings.TrimSpace(line)
72
default:
72
default:
73
// Interactive: prompt for the public host, then the key.
73
// Interactive: prompt for the public host, then the key.
74
// CodeHost stays at its default unless --code-host was
74
// CodeHost stays at its default unless --code-host was
75
// given; uncommon enough to keep off the prompt.
75
// given; uncommon enough to keep off the prompt.
76
reader := bufio.NewReader(os.Stdin)
76
reader := bufio.NewReader(os.Stdin)
77
if host == "" {
77
if host == "" {
78
fmt.Printf("Host (default %s): ", DefaultHost)
78
fmt.Printf("Host (default %s): ", DefaultHost)
79
line, _ := reader.ReadString('\n')
79
line, _ := reader.ReadString('\n')
80
host = strings.TrimSpace(line)
80
host = strings.TrimSpace(line)
81
}
81
}
82
fmt.Print("API key: ")
82
fmt.Print("API key: ")
83
line, _ := reader.ReadString('\n')
83
line, _ := reader.ReadString('\n')
84
key = strings.TrimSpace(line)
84
key = strings.TrimSpace(line)
85
}
85
}
86
86
87
if host == "" {
87
if host == "" {
88
host = DefaultHost
88
host = DefaultHost
89
}
89
}
90
if codeHost == "" {
90
if codeHost == "" {
91
codeHost = DefaultCodeHost
91
codeHost = DefaultCodeHost
92
}
92
}
93
if key == "" {
93
if key == "" {
94
return fmt.Errorf(
94
return fmt.Errorf(
95
"API key required " +
95
"API key required " +
96
"(pass --key, pipe via stdin, or " +
96
"(pass --key, pipe via stdin, or " +
97
"enter at the prompt)")
97
"enter at the prompt)")
98
}
98
}
99
99
100
cfg := &Config{
100
cfg := &Config{
101
Host: host, CodeHost: codeHost, ApiKey: key,
101
Host: host, CodeHost: codeHost, ApiKey: key,
102
}
102
}
103
if err := saveConfig(cfg); err != nil {
103
if err := saveConfig(path, cfg); err != nil {
104
return fmt.Errorf("saving config: %v", err)
104
return fmt.Errorf("saving config: %v", err)
105
}
105
}
106
fmt.Printf("Saved config to %s\n", configPath())
106
fmt.Printf("Saved config to %s\n", path)
107
return nil
107
return nil
108
}
108
}
109
109
110
// stdinIsPiped reports whether stdin is not a terminal — i.e.
110
// stdinIsPiped reports whether stdin is not a terminal — i.e.
111
// a pipe or a redirected file. Used to decide between reading
111
// a pipe or a redirected file. Used to decide between reading
112
// stdin directly and prompting interactively.
112
// stdin directly and prompting interactively.
113
func stdinIsPiped() bool {
113
func stdinIsPiped() bool {
114
fi, err := os.Stdin.Stat()
114
fi, err := os.Stdin.Stat()
115
if err != nil {
115
if err != nil {
116
return false
116
return false
117
}
117
}
118
return (fi.Mode() & os.ModeCharDevice) == 0
118
return (fi.Mode() & os.ModeCharDevice) == 0
119
}
119
}
a/authz.go
b/authz.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 runAuthz(args []string) error {
11
func runAuthz(cfg *Config, args []string) error {
12
if len(args) == 0 {
12
if len(args) == 0 {
13
return fmt.Errorf(
13
return fmt.Errorf(
14
"usage: okg authz SUBCOMMAND ... " +
14
"usage: okg authz 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 runAuthzList(args[1:])
19
return runAuthzList(cfg, args[1:])
20
case "set":
20
case "set":
21
return runAuthzSet(args[1:])
21
return runAuthzSet(cfg, args[1:])
22
case "delete":
22
case "delete":
23
return runAuthzDelete(args[1:])
23
return runAuthzDelete(cfg, args[1:])
24
default:
24
default:
25
return fmt.Errorf(
25
return fmt.Errorf(
26
"unknown authz subcommand: %s", args[0])
26
"unknown authz subcommand: %s", args[0])
27
}
27
}
28
}
28
}
29
29
30
func runAuthzList(args []string) error {
30
func runAuthzList(cfg *Config, args []string) error {
31
fs := flag.NewFlagSet("authz list", flag.ContinueOnError)
31
fs := flag.NewFlagSet("authz list", flag.ContinueOnError)
32
asJSON := fs.Bool("json", false, "output raw JSON")
32
asJSON := fs.Bool("json", false, "output raw JSON")
33
if err := fs.Parse(args); err != nil {
33
if err := fs.Parse(args); err != nil {
34
return err
34
return err
35
}
35
}
36
36
37
c, err := newWhoClient()
37
c, err := newWhoClient(cfg)
38
if err != nil {
38
if err != nil {
39
return err
39
return err
40
}
40
}
41
uris, err := c.ListAuthz()
41
uris, err := c.ListAuthz()
42
if err != nil {
42
if err != nil {
43
return err
43
return err
44
}
44
}
45
45
46
if *asJSON {
46
if *asJSON {
47
buf, err := json.MarshalIndent(uris, "", " ")
47
buf, err := json.MarshalIndent(uris, "", " ")
48
if err != nil {
48
if err != nil {
49
return err
49
return err
50
}
50
}
51
fmt.Println(string(buf))
51
fmt.Println(string(buf))
52
return nil
52
return nil
53
}
53
}
54
54
55
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
55
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
56
fmt.Fprintln(tw, "URI\tOWNER\tREADER\tYOU")
56
fmt.Fprintln(tw, "URI\tOWNER\tREADER\tYOU")
57
for _, e := range uris {
57
for _, e := range uris {
58
owner, reader := "", ""
58
owner, reader := "", ""
59
if e.Owner != nil {
59
if e.Owner != nil {
60
owner = e.Owner.Username
60
owner = e.Owner.Username
61
}
61
}
62
if e.Reader != nil {
62
if e.Reader != nil {
63
reader = e.Reader.Username
63
reader = e.Reader.Username
64
}
64
}
65
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
65
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
66
e.Uri, owner, reader, rights(e))
66
e.Uri, owner, reader, rights(e))
67
}
67
}
68
return tw.Flush()
68
return tw.Flush()
69
}
69
}
70
70
71
// rights is the human-friendly summary of the caller's
71
// rights is the human-friendly summary of the caller's
72
// effective rights on an authz entry.
72
// effective rights on an authz entry.
73
func rights(e who.AuthzEntry) string {
73
func rights(e who.AuthzEntry) string {
74
switch {
74
switch {
75
case e.IsOwner && e.IsReader:
75
case e.IsOwner && e.IsReader:
76
return "owner+reader"
76
return "owner+reader"
77
case e.IsOwner:
77
case e.IsOwner:
78
return "owner"
78
return "owner"
79
case e.IsReader:
79
case e.IsReader:
80
return "reader"
80
return "reader"
81
default:
81
default:
82
return "-"
82
return "-"
83
}
83
}
84
}
84
}
85
85
86
func runAuthzSet(args []string) error {
86
func runAuthzSet(cfg *Config, args []string) error {
87
fs := flag.NewFlagSet("authz set", flag.ContinueOnError)
87
fs := flag.NewFlagSet("authz set", flag.ContinueOnError)
88
if err := fs.Parse(args); err != nil {
88
if err := fs.Parse(args); err != nil {
89
return err
89
return err
90
}
90
}
91
positional := fs.Args()
91
positional := fs.Args()
92
if len(positional) != 3 {
92
if len(positional) != 3 {
93
return fmt.Errorf(
93
return fmt.Errorf(
94
"usage: okg authz set URI OWNER READER")
94
"usage: okg authz set URI OWNER READER")
95
}
95
}
96
uri := positional[0]
96
uri := positional[0]
97
owner := positional[1]
97
owner := positional[1]
98
reader := positional[2]
98
reader := positional[2]
99
99
100
c, err := newWhoClient()
100
c, err := newWhoClient(cfg)
101
if err != nil {
101
if err != nil {
102
return err
102
return err
103
}
103
}
104
if err := c.SetAuthz(who.AuthzSetRequest{
104
if err := c.SetAuthz(who.AuthzSetRequest{
105
Uri: uri,
105
Uri: uri,
106
OwnerUsername: owner,
106
OwnerUsername: owner,
107
ReaderUsername: reader,
107
ReaderUsername: reader,
108
}); err != nil {
108
}); err != nil {
109
return err
109
return err
110
}
110
}
111
fmt.Printf(
111
fmt.Printf(
112
"Set authz on %s (owner=%s, reader=%s)\n",
112
"Set authz on %s (owner=%s, reader=%s)\n",
113
uri, owner, reader)
113
uri, owner, reader)
114
return nil
114
return nil
115
}
115
}
116
116
117
func runAuthzDelete(args []string) error {
117
func runAuthzDelete(cfg *Config, args []string) error {
118
fs := flag.NewFlagSet(
118
fs := flag.NewFlagSet(
119
"authz delete", flag.ContinueOnError)
119
"authz delete", flag.ContinueOnError)
120
if err := fs.Parse(args); err != nil {
120
if err := fs.Parse(args); err != nil {
121
return err
121
return err
122
}
122
}
123
positional := fs.Args()
123
positional := fs.Args()
124
if len(positional) != 1 {
124
if len(positional) != 1 {
125
return fmt.Errorf(
125
return fmt.Errorf(
126
"usage: okg authz delete URI")
126
"usage: okg authz delete URI")
127
}
127
}
128
uri := positional[0]
128
uri := positional[0]
129
129
130
c, err := newWhoClient()
130
c, err := newWhoClient(cfg)
131
if err != nil {
131
if err != nil {
132
return err
132
return err
133
}
133
}
134
if err := c.DeleteAuthz(who.AuthzDeleteRequest{
134
if err := c.DeleteAuthz(who.AuthzDeleteRequest{
135
Uri: uri,
135
Uri: uri,
136
}); err != nil {
136
}); err != nil {
137
return err
137
return err
138
}
138
}
139
fmt.Printf("Deleted authz on %s\n", uri)
139
fmt.Printf("Deleted authz on %s\n", uri)
140
return nil
140
return nil
141
}
141
}
a/chat.go
b/chat.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
6
7
import "oscarkilo.com/okg/chat"
7
import "oscarkilo.com/okg/chat"
8
8
9
func runChat(args []string) error {
9
func runChat(cfg *Config, args []string) error {
10
if len(args) == 0 {
10
if len(args) == 0 {
11
return fmt.Errorf(
11
return fmt.Errorf(
12
"usage: okg chat SUBCOMMAND ... " +
12
"usage: okg chat SUBCOMMAND ... " +
13
"(try `okg --help`)")
13
"(try `okg --help`)")
14
}
14
}
15
switch args[0] {
15
switch args[0] {
16
case "send":
16
case "send":
17
return runChatSend(args[1:])
17
return runChatSend(cfg, args[1:])
18
case "fetch":
18
case "fetch":
19
return runChatFetch(args[1:])
19
return runChatFetch(cfg, args[1:])
20
default:
20
default:
21
return fmt.Errorf(
21
return fmt.Errorf(
22
"unknown chat subcommand: %s", args[0])
22
"unknown chat subcommand: %s", args[0])
23
}
23
}
24
}
24
}
25
25
26
func runChatSend(args []string) error {
26
func runChatSend(cfg *Config, args []string) error {
27
fs := flag.NewFlagSet("chat send", flag.ContinueOnError)
27
fs := flag.NewFlagSet("chat send", flag.ContinueOnError)
28
if err := fs.Parse(args); err != nil {
28
if err := fs.Parse(args); err != nil {
29
return err
29
return err
30
}
30
}
31
positional := fs.Args()
31
positional := fs.Args()
32
if len(positional) != 2 {
32
if len(positional) != 2 {
33
return fmt.Errorf("usage: okg chat send TO TEXT")
33
return fmt.Errorf("usage: okg chat send TO TEXT")
34
}
34
}
35
to := positional[0]
35
to := positional[0]
36
text := positional[1]
36
text := positional[1]
37
37
38
c, err := newChatClient()
38
c, err := newChatClient(cfg)
39
if err != nil {
39
if err != nil {
40
return err
40
return err
41
}
41
}
42
msg, err := c.Send(chat.SendRequest{To: to, Text: text})
42
msg, err := c.Send(chat.SendRequest{To: to, Text: text})
43
if err != nil {
43
if err != nil {
44
return err
44
return err
45
}
45
}
46
fmt.Printf(
46
fmt.Printf(
47
"Sent (from=%s, to=%s, at=%s)\n",
47
"Sent (from=%s, to=%s, at=%s)\n",
48
msg.From, msg.To,
48
msg.From, msg.To,
49
msg.CreatedAt.Format("2006-01-02 15:04:05"))
49
msg.CreatedAt.Format("2006-01-02 15:04:05"))
50
return nil
50
return nil
51
}
51
}
52
52
53
func runChatFetch(args []string) error {
53
func runChatFetch(cfg *Config, args []string) error {
54
fs := flag.NewFlagSet("chat fetch", flag.ContinueOnError)
54
fs := flag.NewFlagSet("chat fetch", flag.ContinueOnError)
55
to := fs.String("to", "",
55
to := fs.String("to", "",
56
"filter by destination group (default: all visible)")
56
"filter by destination group (default: all visible)")
57
asJSON := fs.Bool("json", false, "output raw JSON")
57
asJSON := fs.Bool("json", false, "output raw JSON")
58
if err := fs.Parse(args); err != nil {
58
if err := fs.Parse(args); err != nil {
59
return err
59
return err
60
}
60
}
61
61
62
c, err := newChatClient()
62
c, err := newChatClient(cfg)
63
if err != nil {
63
if err != nil {
64
return err
64
return err
65
}
65
}
66
msgs, err := c.Search(chat.SearchRequest{To: *to})
66
msgs, err := c.Search(chat.SearchRequest{To: *to})
67
if err != nil {
67
if err != nil {
68
return err
68
return err
69
}
69
}
70
70
71
if *asJSON {
71
if *asJSON {
72
buf, err := json.MarshalIndent(msgs, "", " ")
72
buf, err := json.MarshalIndent(msgs, "", " ")
73
if err != nil {
73
if err != nil {
74
return err
74
return err
75
}
75
}
76
fmt.Println(string(buf))
76
fmt.Println(string(buf))
77
return nil
77
return nil
78
}
78
}
79
79
80
for _, m := range msgs {
80
for _, m := range msgs {
81
fmt.Printf(
81
fmt.Printf(
82
"[%s] %s → %s: %s\n",
82
"[%s] %s → %s: %s\n",
83
m.CreatedAt.Format("2006-01-02 15:04:05"),
83
m.CreatedAt.Format("2006-01-02 15:04:05"),
84
m.From, m.To, m.Text)
84
m.From, m.To, m.Text)
85
}
85
}
86
return nil
86
return nil
87
}
87
}
88
88
89
// newChatClient builds a //chat client from saved config.
89
// newChatClient builds a //chat client.
90
func newChatClient() (*chat.HTTPClient, error) {
90
func newChatClient(cfg *Config) (*chat.HTTPClient, error) {
91
cfg, err := loadConfig()
92
if err != nil {
93
return nil, err
94
}
95
if cfg.ApiKey == "" {
91
if cfg.ApiKey == "" {
96
return nil, fmt.Errorf(
92
return nil, fmt.Errorf(
97
"no API key — run `okg auth login --key sk-...`")
93
"no API key — run `okg auth login --key sk-...`")
98
}
94
}
99
return chat.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
95
return chat.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
100
}
96
}
a/client.go
b/client.go
1
package main
1
package main
2
2
3
import "fmt"
3
import "fmt"
4
import "os"
5
import "os/exec"
4
import "os/exec"
6
import "regexp"
5
import "regexp"
7
import "strings"
6
import "strings"
8
7
9
import "oscarkilo.com/okg/klee"
8
import "oscarkilo.com/okg/klee"
10
9
11
var repoRegex = regexp.MustCompile(
10
var repoRegex = regexp.MustCompile(
12
`code\.oscarkilo\.com/` +
11
`code\.oscarkilo\.com/` +
13
`([a-z][-a-z0-9]*?)(?:\.git)?$`)
12
`([a-z][-a-z0-9]*?)(?:\.git)?$`)
14
13
15
// detectRepo parses the git remote URL for the
14
// detectRepo parses the git remote URL for the
16
// klee repo name.
15
// klee repo name.
17
func detectRepo() (string, error) {
16
func detectRepo() (string, error) {
18
cmd := exec.Command(
17
cmd := exec.Command(
19
"git", "remote", "get-url", "origin")
18
"git", "remote", "get-url", "origin")
20
out, err := cmd.Output()
19
out, err := cmd.Output()
21
if err != nil {
20
if err != nil {
22
return "", fmt.Errorf(
21
return "", fmt.Errorf(
23
"not a git repo or no remote 'origin': %v",
22
"not a git repo or no remote 'origin': %v",
24
err)
23
err)
25
}
24
}
26
url := strings.TrimSpace(string(out))
25
url := strings.TrimSpace(string(out))
27
m := repoRegex.FindStringSubmatch(url)
26
m := repoRegex.FindStringSubmatch(url)
28
if m == nil {
27
if m == nil {
29
return "", fmt.Errorf(
28
return "", fmt.Errorf(
30
"remote URL %q is not a klee repo", url)
29
"remote URL %q is not a klee repo", url)
31
}
30
}
32
return m[1], nil
31
return m[1], nil
33
}
32
}
34
33
35
// resolveRepo returns the repo name from --repo
34
// resolveRepo returns the repo name from --repo flag or
36
// flag, OKG_REPO env var, or git remote detection.
35
// git remote detection.
37
func resolveRepo(flag_repo string) (string, error) {
36
func resolveRepo(flag_repo string) (string, error) {
38
if flag_repo != "" {
37
if flag_repo != "" {
39
return flag_repo, nil
38
return flag_repo, nil
40
}
39
}
41
if v := os.Getenv("OKG_REPO"); v != "" {
42
return v, nil
43
}
44
return detectRepo()
40
return detectRepo()
45
}
41
}
46
42
47
func newKleeClient(cfg *Config) *klee.Client {
43
func newKleeClient(cfg *Config) *klee.Client {
48
return klee.NewClient(cfg.CodeHost, cfg.ApiKey)
44
return klee.NewClient(cfg.CodeHost, cfg.ApiKey)
49
}
45
}
a/config.go
b/config.go
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 public OscarKilo host (where //who, //chat,
8
// DefaultHost is the public OscarKilo host (where //who, //chat,
9
// and the LLM endpoints live behind fe).
9
// and the LLM endpoints live behind fe).
10
const DefaultHost = "https://oscarkilo.com"
10
const DefaultHost = "https://oscarkilo.com"
11
11
12
// DefaultCodeHost is the //klee git server's host. Klee is
12
// DefaultCodeHost is the //klee git server's host. Klee is
13
// served from a different name than the rest of the public
13
// served from a different name than the rest of the public
14
// surface, so it gets its own config field.
14
// surface, so it gets its own config field.
15
const DefaultCodeHost = "https://code.oscarkilo.com"
15
const DefaultCodeHost = "https://code.oscarkilo.com"
16
16
17
type Config struct {
17
type Config struct {
18
Host string `json:"host"`
18
Host string `json:"host"`
19
CodeHost string `json:"code_host"`
19
CodeHost string `json:"code_host"`
20
ApiKey string `json:"api_key"`
20
ApiKey string `json:"api_key"`
21
}
21
}
22
22
23
func configPath() string {
23
// defaultConfigPath returns the canonical config location,
24
// ~/.config/okg/config.json. Empty if the home dir is
25
// unresolvable.
26
func defaultConfigPath() string {
24
home, err := os.UserHomeDir()
27
home, err := os.UserHomeDir()
25
if err != nil {
28
if err != nil {
26
return ""
29
return ""
27
}
30
}
28
return filepath.Join(home, ".config", "okg", "config.json")
31
return filepath.Join(home, ".config", "okg", "config.json")
29
}
32
}
30
33
31
func loadConfig() (*Config, error) {
34
// loadConfig reads the config file at path. Missing-file is
35
// not an error; the returned Config has defaults filled in.
36
// Pass "" to get pure defaults.
37
func loadConfig(path string) (*Config, error) {
32
c := &Config{}
38
c := &Config{}
33
34
// Load from file.
35
path := configPath()
36
if path != "" {
39
if path != "" {
37
data, err := os.ReadFile(path)
40
data, err := os.ReadFile(path)
38
if err == nil {
41
if err == nil {
39
json.Unmarshal(data, c)
42
json.Unmarshal(data, c)
40
}
43
}
41
}
44
}
42
45
43
// Env overrides. Both exist for tests (TestMain points them
44
// at localhost so accidental hits to prod fail fast). Not
45
// user-facing config knobs; `okg auth login` is.
46
if v := os.Getenv("OKG_HOST"); v != "" {
47
c.Host = v
48
}
49
if v := os.Getenv("OKG_CODE_HOST"); v != "" {
50
c.CodeHost = v
51
}
52
53
// Migrate old configs whose Host field stored the klee URL.
46
// Migrate old configs whose Host field stored the klee URL.
54
// Treat that value as CodeHost and reset Host to default.
47
// Treat that value as CodeHost and reset Host to default.
55
if c.CodeHost == "" && c.Host == DefaultCodeHost {
48
if c.CodeHost == "" && c.Host == DefaultCodeHost {
56
c.CodeHost = c.Host
49
c.CodeHost = c.Host
57
c.Host = ""
50
c.Host = ""
58
}
51
}
59
52
60
if c.Host == "" {
53
if c.Host == "" {
61
c.Host = DefaultHost
54
c.Host = DefaultHost
62
}
55
}
63
if c.CodeHost == "" {
56
if c.CodeHost == "" {
64
c.CodeHost = DefaultCodeHost
57
c.CodeHost = DefaultCodeHost
65
}
58
}
66
59
67
return c, nil
60
return c, nil
68
}
61
}
69
62
70
func saveConfig(c *Config) error {
63
func saveConfig(path string, c *Config) error {
71
path := configPath()
72
if path == "" {
64
if path == "" {
73
return fmt.Errorf("cannot determine home directory")
65
return fmt.Errorf("config path is empty")
74
}
66
}
75
dir := filepath.Dir(path)
67
dir := filepath.Dir(path)
76
if err := os.MkdirAll(dir, 0700); err != nil {
68
if err := os.MkdirAll(dir, 0700); err != nil {
77
return fmt.Errorf("mkdir %s: %v", dir, err)
69
return fmt.Errorf("mkdir %s: %v", dir, err)
78
}
70
}
79
data, err := json.MarshalIndent(c, "", " ")
71
data, err := json.MarshalIndent(c, "", " ")
80
if err != nil {
72
if err != nil {
81
return err
73
return err
82
}
74
}
83
return os.WriteFile(path, data, 0600)
75
return os.WriteFile(path, data, 0600)
84
}
76
}
a/embed.go
b/embed.go
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(cfg *Config, 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()
28
if err != nil {
29
return err
30
}
31
if cfg.ApiKey == "" {
27
if cfg.ApiKey == "" {
32
return fmt.Errorf(
28
return fmt.Errorf(
33
"no API key — run `okg auth login --key sk-...`")
29
"no API key — run `okg auth login --key sk-...`")
34
}
30
}
35
client := newKlexClient(cfg)
31
client := newKlexClient(cfg)
36
32
37
text, err := io.ReadAll(os.Stdin)
33
text, err := io.ReadAll(os.Stdin)
38
if err != nil {
34
if err != nil {
39
return fmt.Errorf("read stdin: %v", err)
35
return fmt.Errorf("read stdin: %v", err)
40
}
36
}
41
37
42
vectors, err := client.Embed(api.EmbedRequest{
38
vectors, err := client.Embed(api.EmbedRequest{
43
Text: string(text),
39
Text: string(text),
44
Model: *model,
40
Model: *model,
45
Dims: *dims,
41
Dims: *dims,
46
FullPath: *fullPath,
42
FullPath: *fullPath,
47
})
43
})
48
if err != nil {
44
if err != nil {
49
return fmt.Errorf("embed: %v", err)
45
return fmt.Errorf("embed: %v", err)
50
}
46
}
51
47
52
for _, vector := range vectors {
48
for _, vector := range vectors {
53
for i, w := range vector {
49
for i, w := range vector {
54
if i > 0 {
50
if i > 0 {
55
fmt.Print(" ")
51
fmt.Print(" ")
56
}
52
}
57
fmt.Printf("%g", w)
53
fmt.Printf("%g", w)
58
}
54
}
59
fmt.Println()
55
fmt.Println()
60
}
56
}
61
return nil
57
return nil
62
}
58
}
a/exemplary.go
b/exemplary.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 "log"
6
import "log"
7
import "os"
7
import "os"
8
import "path"
8
import "path"
9
import "sort"
9
import "sort"
10
import "strings"
10
import "strings"
11
11
12
import "oscarkilo.com/klex-git/api"
12
import "oscarkilo.com/klex-git/api"
13
13
14
// runExemplary runs few-shot LLM inference on a directory full
14
// runExemplary runs few-shot LLM inference on a directory full
15
// of "<case>.<ext>" input files. Files ending in .out are
15
// of "<case>.<ext>" input files. Files ending in .out are
16
// treated as examples (paired with their non-.out siblings);
16
// treated as examples (paired with their non-.out siblings);
17
// the remaining inputs are sent to the LLM and the responses
17
// the remaining inputs are sent to the LLM and the responses
18
// are written back as <case>.out files.
18
// are written back as <case>.out files.
19
//
19
//
20
// Mirrors the (now-deprecated) `klex-git/exemplary` binary.
20
// Mirrors the (now-deprecated) `klex-git/exemplary` binary.
21
func runExemplary(args []string) error {
21
func runExemplary(cfg *Config, args []string) error {
22
fs := flag.NewFlagSet("exemplary", flag.ContinueOnError)
22
fs := flag.NewFlagSet("exemplary", flag.ContinueOnError)
23
dir := fs.String("dir", ".",
23
dir := fs.String("dir", ".",
24
"directory to scan for cases and write outputs to")
24
"directory to scan for cases and write outputs to")
25
model := fs.String("model", "Gemini 3 Pro",
25
model := fs.String("model", "Gemini 3 Pro",
26
"LLM model name")
26
"LLM model name")
27
dryRun := fs.Bool("dry-run", false,
27
dryRun := fs.Bool("dry-run", false,
28
"scan + build requests but don't send them")
28
"scan + build requests but don't send them")
29
debug := fs.Bool("debug", false,
29
debug := fs.Bool("debug", false,
30
"log each request body to stderr before sending")
30
"log each request body to stderr before sending")
31
if err := fs.Parse(args); err != nil {
31
if err := fs.Parse(args); err != nil {
32
return err
32
return err
33
}
33
}
34
34
35
cfg, err := loadConfig()
36
if err != nil {
37
return err
38
}
39
if cfg.ApiKey == "" {
35
if cfg.ApiKey == "" {
40
return fmt.Errorf(
36
return fmt.Errorf(
41
"no API key — run `okg auth login --key sk-...`")
37
"no API key — run `okg auth login --key sk-...`")
42
}
38
}
43
client := newKlexClient(cfg)
39
client := newKlexClient(cfg)
44
40
45
// Build a request that starts with the system prompt and
41
// Build a request that starts with the system prompt and
46
// grows by one user message per case (plus an assistant
42
// grows by one user message per case (plus an assistant
47
// message for examples). Inputs (cases without .out) get a
43
// message for examples). Inputs (cases without .out) get a
48
// snapshot of the request taken just before their user
44
// snapshot of the request taken just before their user
49
// message would otherwise be added to the running prefix.
45
// message would otherwise be added to the running prefix.
50
sysBytes, err := os.ReadFile(
46
sysBytes, err := os.ReadFile(
51
path.Join(*dir, "system_prompt.txt"))
47
path.Join(*dir, "system_prompt.txt"))
52
if err != nil {
48
if err != nil {
53
return fmt.Errorf("read system_prompt.txt: %v", err)
49
return fmt.Errorf("read system_prompt.txt: %v", err)
54
}
50
}
55
req := api.MessagesRequest{
51
req := api.MessagesRequest{
56
Model: *model,
52
Model: *model,
57
System: string(sysBytes),
53
System: string(sysBytes),
58
}
54
}
59
55
60
cases, err := scanForCases(*dir)
56
cases, err := scanForCases(*dir)
61
if err != nil {
57
if err != nil {
62
return err
58
return err
63
}
59
}
64
60
65
for i, c := range cases {
61
for i, c := range cases {
66
user := api.ChatMessage{Role: "user"}
62
user := api.ChatMessage{Role: "user"}
67
text := "Case name: " + c.Name + "\n\n"
63
text := "Case name: " + c.Name + "\n\n"
68
for _, suffix := range c.Before {
64
for _, suffix := range c.Before {
69
switch suffix {
65
switch suffix {
70
case "txt":
66
case "txt":
71
b, err := os.ReadFile(
67
b, err := os.ReadFile(
72
path.Join(*dir, c.Name+".txt"))
68
path.Join(*dir, c.Name+".txt"))
73
if err != nil {
69
if err != nil {
74
return fmt.Errorf(
70
return fmt.Errorf(
75
"read %s.txt: %v", c.Name, err)
71
"read %s.txt: %v", c.Name, err)
76
}
72
}
77
text += string(b)
73
text += string(b)
78
case "json":
74
case "json":
79
b, err := os.ReadFile(
75
b, err := os.ReadFile(
80
path.Join(*dir, c.Name+".json"))
76
path.Join(*dir, c.Name+".json"))
81
if err != nil {
77
if err != nil {
82
return fmt.Errorf(
78
return fmt.Errorf(
83
"read %s.json: %v", c.Name, err)
79
"read %s.json: %v", c.Name, err)
84
}
80
}
85
text += "\n\n```json\n" + string(b) + "\n```\n"
81
text += "\n\n```json\n" + string(b) + "\n```\n"
86
case "jpg", "jpeg", "png", "webp":
82
case "jpg", "jpeg", "png", "webp":
87
b, err := os.ReadFile(
83
b, err := os.ReadFile(
88
path.Join(*dir, c.Name+"."+suffix))
84
path.Join(*dir, c.Name+"."+suffix))
89
if err != nil {
85
if err != nil {
90
return fmt.Errorf(
86
return fmt.Errorf(
91
"read %s.%s: %v", c.Name, suffix, err)
87
"read %s.%s: %v", c.Name, suffix, err)
92
}
88
}
93
user.Content = append(user.Content,
89
user.Content = append(user.Content,
94
api.NewDocumentBlock(b))
90
api.NewDocumentBlock(b))
95
default:
91
default:
96
return fmt.Errorf(
92
return fmt.Errorf(
97
"unsupported suffix %s in case %s",
93
"unsupported suffix %s in case %s",
98
suffix, c.Name)
94
suffix, c.Name)
99
}
95
}
100
}
96
}
101
user.Content = append(user.Content, api.ContentBlock{
97
user.Content = append(user.Content, api.ContentBlock{
102
Type: "text",
98
Type: "text",
103
Text: text,
99
Text: text,
104
})
100
})
105
req.Messages = append(req.Messages, user)
101
req.Messages = append(req.Messages, user)
106
if c.After != "" {
102
if c.After != "" {
107
out, err := os.ReadFile(
103
out, err := os.ReadFile(
108
path.Join(*dir, c.Name+".out"))
104
path.Join(*dir, c.Name+".out"))
109
if err != nil {
105
if err != nil {
110
return fmt.Errorf(
106
return fmt.Errorf(
111
"read %s.out: %v", c.Name, err)
107
"read %s.out: %v", c.Name, err)
112
}
108
}
113
req.Messages = append(req.Messages,
109
req.Messages = append(req.Messages,
114
api.ChatMessage{
110
api.ChatMessage{
115
Role: "assistant",
111
Role: "assistant",
116
Content: []api.ContentBlock{
112
Content: []api.ContentBlock{
117
{Type: "text", Text: string(out)},
113
{Type: "text", Text: string(out)},
118
},
114
},
119
})
115
})
120
} else {
116
} else {
121
copy, err := copyRequest(req)
117
copy, err := copyRequest(req)
122
if err != nil {
118
if err != nil {
123
return err
119
return err
124
}
120
}
125
cases[i].Request = copy
121
cases[i].Request = copy
126
req.Messages = req.Messages[:len(req.Messages)-1]
122
req.Messages = req.Messages[:len(req.Messages)-1]
127
}
123
}
128
}
124
}
129
125
130
if *dryRun {
126
if *dryRun {
131
log.Printf("dry run; not sending requests")
127
log.Printf("dry run; not sending requests")
132
return nil
128
return nil
133
}
129
}
134
130
135
// TODO: parallelize.
131
// TODO: parallelize.
136
for _, c := range cases {
132
for _, c := range cases {
137
if c.Request == nil {
133
if c.Request == nil {
138
continue
134
continue
139
}
135
}
140
if *debug {
136
if *debug {
141
log.Printf("Case %s: sending request:", c.Name)
137
log.Printf("Case %s: sending request:", c.Name)
142
enc := json.NewEncoder(os.Stderr)
138
enc := json.NewEncoder(os.Stderr)
143
enc.SetIndent("", " ")
139
enc.SetIndent("", " ")
144
enc.Encode(c.Request)
140
enc.Encode(c.Request)
145
}
141
}
146
res, err := client.Messages(*c.Request)
142
res, err := client.Messages(*c.Request)
147
if err != nil {
143
if err != nil {
148
log.Printf(
144
log.Printf(
149
"Case %s: request failed: %v", c.Name, err)
145
"Case %s: request failed: %v", c.Name, err)
150
continue
146
continue
151
}
147
}
152
if len(res.Content) != 1 {
148
if len(res.Content) != 1 {
153
log.Printf(
149
log.Printf(
154
"Case %s: empty response", c.Name)
150
"Case %s: empty response", c.Name)
155
continue
151
continue
156
}
152
}
157
c0 := res.Content[0]
153
c0 := res.Content[0]
158
if c0.Type != "text" {
154
if c0.Type != "text" {
159
log.Printf(
155
log.Printf(
160
"Case %s: Content[0].Type = %s",
156
"Case %s: Content[0].Type = %s",
161
c.Name, c0.Type)
157
c.Name, c0.Type)
162
continue
158
continue
163
}
159
}
164
out := path.Join(*dir, c.Name+".out")
160
out := path.Join(*dir, c.Name+".out")
165
err = os.WriteFile(
161
err = os.WriteFile(
166
out, append([]byte(c0.Text), '\n'), 0644)
162
out, append([]byte(c0.Text), '\n'), 0644)
167
if err != nil {
163
if err != nil {
168
log.Printf(
164
log.Printf(
169
"Case %s: failed to write %s.out: %v",
165
"Case %s: failed to write %s.out: %v",
170
c.Name, c.Name, err)
166
c.Name, c.Name, err)
171
continue
167
continue
172
}
168
}
173
log.Printf("Case %s: wrote %s.out", c.Name, c.Name)
169
log.Printf("Case %s: wrote %s.out", c.Name, c.Name)
174
}
170
}
175
return nil
171
return nil
176
}
172
}
177
173
178
// Case is one input or one input-plus-example file group in
174
// Case is one input or one input-plus-example file group in
179
// the directory exemplary scans.
175
// the directory exemplary scans.
180
type Case struct {
176
type Case struct {
181
Name string
177
Name string
182
Before []string // file extensions present: txt, json, jpg, ...
178
Before []string // file extensions present: txt, json, jpg, ...
183
After string // "out" if a paired .out file exists
179
After string // "out" if a paired .out file exists
184
Request *api.MessagesRequest
180
Request *api.MessagesRequest
185
}
181
}
186
182
187
// scanForCases walks dir and groups files into Cases. Files
183
// scanForCases walks dir and groups files into Cases. Files
188
// named system_prompt.txt are skipped (consumed separately as
184
// named system_prompt.txt are skipped (consumed separately as
189
// the request's System field). Cases with an .out file sort
185
// the request's System field). Cases with an .out file sort
190
// before cases without, so the example-prefix building loop
186
// before cases without, so the example-prefix building loop
191
// can stop at the first non-example.
187
// can stop at the first non-example.
192
func scanForCases(dir string) ([]Case, error) {
188
func scanForCases(dir string) ([]Case, error) {
193
entries, err := os.ReadDir(dir)
189
entries, err := os.ReadDir(dir)
194
if err != nil {
190
if err != nil {
195
return nil, fmt.Errorf("read dir %s: %v", dir, err)
191
return nil, fmt.Errorf("read dir %s: %v", dir, err)
196
}
192
}
197
193
198
before := make(map[string][]string)
194
before := make(map[string][]string)
199
after := make(map[string]string)
195
after := make(map[string]string)
200
for _, entry := range entries {
196
for _, entry := range entries {
201
if entry.IsDir() {
197
if entry.IsDir() {
202
continue
198
continue
203
}
199
}
204
if entry.Name() == "system_prompt.txt" {
200
if entry.Name() == "system_prompt.txt" {
205
continue
201
continue
206
}
202
}
207
chunks := strings.Split(entry.Name(), ".")
203
chunks := strings.Split(entry.Name(), ".")
208
if len(chunks) < 2 {
204
if len(chunks) < 2 {
209
continue
205
continue
210
}
206
}
211
name := strings.Join(
207
name := strings.Join(
212
chunks[0:len(chunks)-1], ".")
208
chunks[0:len(chunks)-1], ".")
213
suffix := chunks[len(chunks)-1]
209
suffix := chunks[len(chunks)-1]
214
switch suffix {
210
switch suffix {
215
case "txt", "json", "jpg", "jpeg", "png", "webp":
211
case "txt", "json", "jpg", "jpeg", "png", "webp":
216
before[name] = append(before[name], suffix)
212
before[name] = append(before[name], suffix)
217
case "out":
213
case "out":
218
after[name] = suffix
214
after[name] = suffix
219
}
215
}
220
}
216
}
221
217
222
var cases []Case
218
var cases []Case
223
for name, b := range before {
219
for name, b := range before {
224
sort.Slice(b, func(i, j int) bool {
220
sort.Slice(b, func(i, j int) bool {
225
if b[i] == "txt" && b[j] != "txt" {
221
if b[i] == "txt" && b[j] != "txt" {
226
return true
222
return true
227
}
223
}
228
if b[i] != "txt" && b[j] == "txt" {
224
if b[i] != "txt" && b[j] == "txt" {
229
return false
225
return false
230
}
226
}
231
return b[i] < b[j]
227
return b[i] < b[j]
232
})
228
})
233
cases = append(cases, Case{
229
cases = append(cases, Case{
234
Name: name,
230
Name: name,
235
Before: b,
231
Before: b,
236
After: after[name],
232
After: after[name],
237
})
233
})
238
}
234
}
239
235
240
sort.Slice(cases, func(i, j int) bool {
236
sort.Slice(cases, func(i, j int) bool {
241
a, b := cases[i], cases[j]
237
a, b := cases[i], cases[j]
242
if a.After != "" && b.After == "" {
238
if a.After != "" && b.After == "" {
243
return true
239
return true
244
}
240
}
245
if a.After == "" && b.After != "" {
241
if a.After == "" && b.After != "" {
246
return false
242
return false
247
}
243
}
248
return a.Name < b.Name
244
return a.Name < b.Name
249
})
245
})
250
246
251
numExamples := 0
247
numExamples := 0
252
for ; numExamples < len(cases); numExamples++ {
248
for ; numExamples < len(cases); numExamples++ {
253
if cases[numExamples].After == "" {
249
if cases[numExamples].After == "" {
254
break
250
break
255
}
251
}
256
}
252
}
257
log.Printf(
253
log.Printf(
258
"%s:\n num_examples = %d\n num_inputs = %d",
254
"%s:\n num_examples = %d\n num_inputs = %d",
259
dir, numExamples, len(cases)-numExamples)
255
dir, numExamples, len(cases)-numExamples)
260
256
261
return cases, nil
257
return cases, nil
262
}
258
}
263
259
264
// copyRequest deep-copies via JSON marshal/unmarshal so each
260
// copyRequest deep-copies via JSON marshal/unmarshal so each
265
// snapshot taken inside the build loop is independent of later
261
// snapshot taken inside the build loop is independent of later
266
// mutations.
262
// mutations.
267
func copyRequest(
263
func copyRequest(
268
req api.MessagesRequest,
264
req api.MessagesRequest,
269
) (*api.MessagesRequest, error) {
265
) (*api.MessagesRequest, error) {
270
buf, err := json.Marshal(req)
266
buf, err := json.Marshal(req)
271
if err != nil {
267
if err != nil {
272
return nil, fmt.Errorf("copy request: %v", err)
268
return nil, fmt.Errorf("copy request: %v", err)
273
}
269
}
274
out := &api.MessagesRequest{}
270
out := &api.MessagesRequest{}
275
if err := json.Unmarshal(buf, out); err != nil {
271
if err := json.Unmarshal(buf, out); err != nil {
276
return nil, fmt.Errorf("copy request: %v", err)
272
return nil, fmt.Errorf("copy request: %v", err)
277
}
273
}
278
return out, nil
274
return out, nil
279
}
275
}
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(cfg *Config, 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(cfg, args[1:])
20
case "create":
20
case "create":
21
return runGroupCreate(args[1:])
21
return runGroupCreate(cfg, args[1:])
22
case "add-member":
22
case "add-member":
23
return runGroupAddMember(args[1:])
23
return runGroupAddMember(cfg, args[1:])
24
case "remove-member":
24
case "remove-member":
25
return runGroupRemoveMember(args[1:])
25
return runGroupRemoveMember(cfg, args[1:])
26
case "members":
26
case "members":
27
return runGroupMembers(args[1:])
27
return runGroupMembers(cfg, args[1:])
28
case "delete":
28
case "delete":
29
return runGroupDelete(args[1:])
29
return runGroupDelete(cfg, args[1:])
30
default:
30
default:
31
return fmt.Errorf(
31
return fmt.Errorf(
32
"unknown group subcommand: %s", args[0])
32
"unknown group subcommand: %s", args[0])
33
}
33
}
34
}
34
}
35
35
36
func runGroupList(args []string) error {
36
func runGroupList(cfg *Config, args []string) error {
37
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
37
fs := flag.NewFlagSet("group list", flag.ContinueOnError)
38
asJSON := fs.Bool("json", false, "output raw JSON")
38
asJSON := fs.Bool("json", false, "output raw JSON")
39
if err := fs.Parse(args); err != nil {
39
if err := fs.Parse(args); err != nil {
40
return err
40
return err
41
}
41
}
42
42
43
c, err := newWhoClient()
43
c, err := newWhoClient(cfg)
44
if err != nil {
44
if err != nil {
45
return err
45
return err
46
}
46
}
47
47
48
groups, err := c.ListGroups()
48
groups, err := c.ListGroups()
49
if err != nil {
49
if err != nil {
50
return err
50
return err
51
}
51
}
52
52
53
if *asJSON {
53
if *asJSON {
54
buf, err := json.MarshalIndent(groups, "", " ")
54
buf, err := json.MarshalIndent(groups, "", " ")
55
if err != nil {
55
if err != nil {
56
return err
56
return err
57
}
57
}
58
fmt.Println(string(buf))
58
fmt.Println(string(buf))
59
return nil
59
return nil
60
}
60
}
61
61
62
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
62
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
63
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
63
fmt.Fprintln(tw, "USERNAME\tFULL NAME\tOWNER")
64
for _, g := range groups {
64
for _, g := range groups {
65
fmt.Fprintf(tw, "%s\t%s\t%s\n",
65
fmt.Fprintf(tw, "%s\t%s\t%s\n",
66
g.Username, g.Name, g.OwnerUsername)
66
g.Username, g.Name, g.OwnerUsername)
67
}
67
}
68
return tw.Flush()
68
return tw.Flush()
69
}
69
}
70
70
71
func runGroupCreate(args []string) error {
71
func runGroupCreate(cfg *Config, args []string) error {
72
fs := flag.NewFlagSet(
72
fs := flag.NewFlagSet(
73
"group create", flag.ContinueOnError)
73
"group create", flag.ContinueOnError)
74
fullName := fs.String("full-name", "",
74
fullName := fs.String("full-name", "",
75
"display name (default: NAME)")
75
"display name (default: NAME)")
76
owner := fs.String("owner", "",
76
owner := fs.String("owner", "",
77
"owner username (default: caller)")
77
"owner username (default: caller)")
78
if err := fs.Parse(args); err != nil {
78
if err := fs.Parse(args); err != nil {
79
return err
79
return err
80
}
80
}
81
positional := fs.Args()
81
positional := fs.Args()
82
if len(positional) != 1 {
82
if len(positional) != 1 {
83
return fmt.Errorf("usage: okg group create NAME")
83
return fmt.Errorf("usage: okg group create NAME")
84
}
84
}
85
name := positional[0]
85
name := positional[0]
86
86
87
c, err := newWhoClient()
87
c, err := newWhoClient(cfg)
88
if err != nil {
88
if err != nil {
89
return err
89
return err
90
}
90
}
91
91
92
if *owner == "" {
92
if *owner == "" {
93
me, err := c.GetProfile()
93
me, err := c.GetProfile()
94
if err != nil {
94
if err != nil {
95
return fmt.Errorf("resolve caller: %v", err)
95
return fmt.Errorf("resolve caller: %v", err)
96
}
96
}
97
*owner = me.Username
97
*owner = me.Username
98
}
98
}
99
99
100
displayName := *fullName
100
displayName := *fullName
101
if displayName == "" {
101
if displayName == "" {
102
displayName = name
102
displayName = name
103
}
103
}
104
104
105
if err := c.CreateGroup(who.CreateGroupRequest{
105
if err := c.CreateGroup(who.CreateGroupRequest{
106
Username: name,
106
Username: name,
107
Name: displayName,
107
Name: displayName,
108
OwnerUsername: *owner,
108
OwnerUsername: *owner,
109
}); err != nil {
109
}); err != nil {
110
return err
110
return err
111
}
111
}
112
fmt.Printf(
112
fmt.Printf(
113
"Created group %s (owner: %s)\n", name, *owner)
113
"Created group %s (owner: %s)\n", name, *owner)
114
return nil
114
return nil
115
}
115
}
116
116
117
func runGroupAddMember(args []string) error {
117
func runGroupAddMember(cfg *Config, args []string) error {
118
fs := flag.NewFlagSet(
118
fs := flag.NewFlagSet(
119
"group add-member", flag.ContinueOnError)
119
"group add-member", flag.ContinueOnError)
120
if err := fs.Parse(args); err != nil {
120
if err := fs.Parse(args); err != nil {
121
return err
121
return err
122
}
122
}
123
positional := fs.Args()
123
positional := fs.Args()
124
if len(positional) < 2 {
124
if len(positional) < 2 {
125
return fmt.Errorf(
125
return fmt.Errorf(
126
"usage: okg group add-member GROUP USER [USER ...]")
126
"usage: okg group add-member GROUP USER [USER ...]")
127
}
127
}
128
group := positional[0]
128
group := positional[0]
129
members := positional[1:]
129
members := positional[1:]
130
130
131
c, err := newWhoClient()
131
c, err := newWhoClient(cfg)
132
if err != nil {
132
if err != nil {
133
return err
133
return err
134
}
134
}
135
if err := c.JoinGroups(who.JoinGroupsRequest{
135
if err := c.JoinGroups(who.JoinGroupsRequest{
136
GroupUsernames: []string{group},
136
GroupUsernames: []string{group},
137
MemberUsernames: members,
137
MemberUsernames: members,
138
}); err != nil {
138
}); err != nil {
139
return err
139
return err
140
}
140
}
141
fmt.Printf(
141
fmt.Printf(
142
"Added %d member(s) to %s\n", len(members), group)
142
"Added %d member(s) to %s\n", len(members), group)
143
return nil
143
return nil
144
}
144
}
145
145
146
func runGroupRemoveMember(args []string) error {
146
func runGroupRemoveMember(cfg *Config, args []string) error {
147
fs := flag.NewFlagSet(
147
fs := flag.NewFlagSet(
148
"group remove-member", flag.ContinueOnError)
148
"group remove-member", flag.ContinueOnError)
149
if err := fs.Parse(args); err != nil {
149
if err := fs.Parse(args); err != nil {
150
return err
150
return err
151
}
151
}
152
positional := fs.Args()
152
positional := fs.Args()
153
if len(positional) < 2 {
153
if len(positional) < 2 {
154
return fmt.Errorf(
154
return fmt.Errorf(
155
"usage: okg group remove-member " +
155
"usage: okg group remove-member " +
156
"GROUP USER [USER ...]")
156
"GROUP USER [USER ...]")
157
}
157
}
158
groupName := positional[0]
158
groupName := positional[0]
159
members := positional[1:]
159
members := positional[1:]
160
160
161
c, err := newWhoClient()
161
c, err := newWhoClient(cfg)
162
if err != nil {
162
if err != nil {
163
return err
163
return err
164
}
164
}
165
165
166
// /groups/leave wants owids, not usernames. Resolve the
166
// /groups/leave wants owids, not usernames. Resolve the
167
// group's owid via ListGroups, then the members' owids via
167
// group's owid via ListGroups, then the members' owids via
168
// GroupMembers. Two round-trips on top of the actual leave
168
// GroupMembers. Two round-trips on top of the actual leave
169
// calls; shared across all members in this invocation.
169
// calls; shared across all members in this invocation.
170
groupOwid, err := resolveGroupOwid(c, groupName)
170
groupOwid, err := resolveGroupOwid(c, groupName)
171
if err != nil {
171
if err != nil {
172
return err
172
return err
173
}
173
}
174
ms, err := c.GroupMembers(groupOwid)
174
ms, err := c.GroupMembers(groupOwid)
175
if err != nil {
175
if err != nil {
176
return err
176
return err
177
}
177
}
178
username2owid := make(map[string]string)
178
username2owid := make(map[string]string)
179
for owid, name := range ms.Usernames {
179
for owid, name := range ms.Usernames {
180
username2owid[name] = owid
180
username2owid[name] = owid
181
}
181
}
182
182
183
for _, m := range members {
183
for _, m := range members {
184
memberOwid, ok := username2owid[m]
184
memberOwid, ok := username2owid[m]
185
if !ok {
185
if !ok {
186
return fmt.Errorf(
186
return fmt.Errorf(
187
"user %q is not a member of %s", m, groupName)
187
"user %q is not a member of %s", m, groupName)
188
}
188
}
189
if err := c.LeaveGroup(who.LeaveGroupRequest{
189
if err := c.LeaveGroup(who.LeaveGroupRequest{
190
GroupOwid: groupOwid,
190
GroupOwid: groupOwid,
191
MemberOwid: memberOwid,
191
MemberOwid: memberOwid,
192
}); err != nil {
192
}); err != nil {
193
return fmt.Errorf(
193
return fmt.Errorf(
194
"remove %s from %s: %v", m, groupName, err)
194
"remove %s from %s: %v", m, groupName, err)
195
}
195
}
196
}
196
}
197
fmt.Printf(
197
fmt.Printf(
198
"Removed %d member(s) from %s\n",
198
"Removed %d member(s) from %s\n",
199
len(members), groupName)
199
len(members), groupName)
200
return nil
200
return nil
201
}
201
}
202
202
203
func runGroupMembers(args []string) error {
203
func runGroupMembers(cfg *Config, args []string) error {
204
fs := flag.NewFlagSet(
204
fs := flag.NewFlagSet(
205
"group members", flag.ContinueOnError)
205
"group members", flag.ContinueOnError)
206
asJSON := fs.Bool("json", false,
206
asJSON := fs.Bool("json", false,
207
"output full DAG (Up/Down/Usernames) as raw JSON")
207
"output full DAG (Up/Down/Usernames) as raw JSON")
208
if err := fs.Parse(args); err != nil {
208
if err := fs.Parse(args); err != nil {
209
return err
209
return err
210
}
210
}
211
positional := fs.Args()
211
positional := fs.Args()
212
if len(positional) != 1 {
212
if len(positional) != 1 {
213
return fmt.Errorf("usage: okg group members GROUP")
213
return fmt.Errorf("usage: okg group members GROUP")
214
}
214
}
215
groupName := positional[0]
215
groupName := positional[0]
216
216
217
c, err := newWhoClient()
217
c, err := newWhoClient(cfg)
218
if err != nil {
218
if err != nil {
219
return err
219
return err
220
}
220
}
221
groupOwid, err := resolveGroupOwid(c, groupName)
221
groupOwid, err := resolveGroupOwid(c, groupName)
222
if err != nil {
222
if err != nil {
223
return err
223
return err
224
}
224
}
225
ms, err := c.GroupMembers(groupOwid)
225
ms, err := c.GroupMembers(groupOwid)
226
if err != nil {
226
if err != nil {
227
return err
227
return err
228
}
228
}
229
229
230
if *asJSON {
230
if *asJSON {
231
buf, err := json.MarshalIndent(ms, "", " ")
231
buf, err := json.MarshalIndent(ms, "", " ")
232
if err != nil {
232
if err != nil {
233
return err
233
return err
234
}
234
}
235
fmt.Println(string(buf))
235
fmt.Println(string(buf))
236
return nil
236
return nil
237
}
237
}
238
238
239
// Direct members are the first level of Down. Print one
239
// Direct members are the first level of Down. Print one
240
// username per line; deeper nesting (group-of-groups) is
240
// username per line; deeper nesting (group-of-groups) is
241
// available via --json.
241
// available via --json.
242
if len(ms.Down) == 0 {
242
if len(ms.Down) == 0 {
243
return nil
243
return nil
244
}
244
}
245
for _, owid := range ms.Down[0] {
245
for _, owid := range ms.Down[0] {
246
fmt.Println(ms.Usernames[owid])
246
fmt.Println(ms.Usernames[owid])
247
}
247
}
248
return nil
248
return nil
249
}
249
}
250
250
251
func runGroupDelete(args []string) error {
251
func runGroupDelete(cfg *Config, args []string) error {
252
fs := flag.NewFlagSet(
252
fs := flag.NewFlagSet(
253
"group delete", flag.ContinueOnError)
253
"group delete", flag.ContinueOnError)
254
if err := fs.Parse(args); err != nil {
254
if err := fs.Parse(args); err != nil {
255
return err
255
return err
256
}
256
}
257
positional := fs.Args()
257
positional := fs.Args()
258
if len(positional) != 1 {
258
if len(positional) != 1 {
259
return fmt.Errorf("usage: okg group delete NAME")
259
return fmt.Errorf("usage: okg group delete NAME")
260
}
260
}
261
groupName := positional[0]
261
groupName := positional[0]
262
262
263
c, err := newWhoClient()
263
c, err := newWhoClient(cfg)
264
if err != nil {
264
if err != nil {
265
return err
265
return err
266
}
266
}
267
// /groups/delete takes an owid; resolve via /groups/list.
267
// /groups/delete takes an owid; resolve via /groups/list.
268
groupOwid, err := resolveGroupOwid(c, groupName)
268
groupOwid, err := resolveGroupOwid(c, groupName)
269
if err != nil {
269
if err != nil {
270
return err
270
return err
271
}
271
}
272
if err := c.DeleteGroup(who.DeleteGroupRequest{
272
if err := c.DeleteGroup(who.DeleteGroupRequest{
273
Owid: groupOwid,
273
Owid: groupOwid,
274
}); err != nil {
274
}); err != nil {
275
return err
275
return err
276
}
276
}
277
fmt.Printf("Deleted group %s\n", groupName)
277
fmt.Printf("Deleted group %s\n", groupName)
278
return nil
278
return nil
279
}
279
}
280
280
281
// resolveGroupOwid translates a group username to its owid via
281
// resolveGroupOwid translates a group username to its owid via
282
// /groups/list. Returns an error if the group isn't visible to
282
// /groups/list. Returns an error if the group isn't visible to
283
// the caller.
283
// the caller.
284
func resolveGroupOwid(
284
func resolveGroupOwid(
285
c *who.HTTPClient, name string,
285
c *who.HTTPClient, name string,
286
) (string, error) {
286
) (string, error) {
287
groups, err := c.ListGroups()
287
groups, err := c.ListGroups()
288
if err != nil {
288
if err != nil {
289
return "", err
289
return "", err
290
}
290
}
291
for _, g := range groups {
291
for _, g := range groups {
292
if g.Username == name {
292
if g.Username == name {
293
return g.Owid, nil
293
return g.Owid, nil
294
}
294
}
295
}
295
}
296
return "", fmt.Errorf(
296
return "", fmt.Errorf(
297
"group %q not found (or not visible to caller)", name)
297
"group %q not found (or not visible to caller)", name)
298
}
298
}
299
299
300
// newWhoClient builds a //who client from saved config. Shared
300
// newWhoClient builds a //who client. Shared by every
301
// by every `okg group` and `okg authz` subcommand.
301
// `okg group` and `okg authz` subcommand.
302
func newWhoClient() (*who.HTTPClient, error) {
302
func newWhoClient(cfg *Config) (*who.HTTPClient, error) {
303
cfg, err := loadConfig()
304
if err != nil {
305
return nil, err
306
}
307
if cfg.ApiKey == "" {
303
if cfg.ApiKey == "" {
308
return nil, fmt.Errorf(
304
return nil, fmt.Errorf(
309
"no API key — run `okg auth login --key sk-...`")
305
"no API key — run `okg auth login --key sk-...`")
310
}
306
}
311
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
307
return who.NewHTTPClient(cfg.Host, cfg.ApiKey), nil
312
}
308
}
313
309
a/klex.go
b/klex.go
1
package main
1
package main
2
2
3
import "os"
4
5
import "oscarkilo.com/klex-git/api"
3
import "oscarkilo.com/klex-git/api"
6
4
7
// KlexDefaultUrl is the production Klex LLM endpoint. Override
5
// newKlexClient builds a Klex LLM client. The endpoint is the
8
// via the KLEX_URL env var for dev/tests.
6
// public host's /klex path; tests redirect by overriding
9
const KlexDefaultUrl = "https://oscarkilo.com/klex"
7
// cfg.Host.
10
11
func newKlexClient(cfg *Config) *api.Client {
8
func newKlexClient(cfg *Config) *api.Client {
12
url := KlexDefaultUrl
9
return api.NewClient(cfg.Host+"/klex", cfg.ApiKey)
13
if v := os.Getenv("KLEX_URL"); v != "" {
14
url = v
15
}
16
return api.NewClient(url, cfg.ApiKey)
17
}
10
}
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
// commands maps each top-level subcommand to its handler.
7
// printUsage's sections need to stay in sync with this list.
8
var commands = map[string]func([]string) error{
9
"pr": runPR,
10
"repo": runRepo,
11
"auth": runAuth,
12
"embed": runEmbed,
13
"one": runOne,
14
"exemplary": runExemplary,
15
"group": runGroup,
16
"authz": runAuthz,
17
"chat": runChat,
18
}
19
20
func main() {
6
func main() {
21
args := os.Args[1:]
7
args := os.Args[1:]
22
if len(args) == 0 {
8
if len(args) == 0 {
23
printUsage()
9
printUsage()
24
os.Exit(1)
10
os.Exit(1)
25
}
11
}
26
switch args[0] {
12
switch args[0] {
27
case "help", "--help", "-h":
13
case "help", "--help", "-h":
28
printUsage()
14
printUsage()
29
return
15
return
30
}
16
}
31
fn, ok := commands[args[0]]
17
32
if !ok {
18
cfgPath := defaultConfigPath()
19
cfg, err := loadConfig(cfgPath)
20
if err != nil {
21
fmt.Fprintf(os.Stderr, "error: %v\n", err)
22
os.Exit(1)
23
}
24
25
rest := args[1:]
26
switch args[0] {
27
case "pr":
28
err = runPR(cfg, rest)
29
case "repo":
30
err = runRepo(cfg, rest)
31
case "auth":
32
err = runAuth(cfgPath, rest)
33
case "embed":
34
err = runEmbed(cfg, rest)
35
case "one":
36
err = runOne(cfg, rest)
37
case "exemplary":
38
err = runExemplary(cfg, rest)
39
case "group":
40
err = runGroup(cfg, rest)
41
case "authz":
42
err = runAuthz(cfg, rest)
43
case "chat":
44
err = runChat(cfg, rest)
45
default:
33
fmt.Fprintf(
46
fmt.Fprintf(
34
os.Stderr, "unknown command: %s\n", args[0])
47
os.Stderr, "unknown command: %s\n", args[0])
35
printUsage()
48
printUsage()
36
os.Exit(1)
49
os.Exit(1)
37
}
50
}
38
if err := fn(args[1:]); err != nil {
51
if err != nil {
39
fmt.Fprintf(os.Stderr, "error: %v\n", err)
52
fmt.Fprintf(os.Stderr, "error: %v\n", err)
40
os.Exit(1)
53
os.Exit(1)
41
}
54
}
42
}
55
}
43
56
44
func printUsage() {
57
func printUsage() {
45
fmt.Fprintf(os.Stderr, `NAME
58
fmt.Fprintf(os.Stderr, `NAME
46
okg — Oscar Kilo Goodness
59
okg — Oscar Kilo Goodness
47
60
48
SETUP
61
SETUP
49
okg auth login
62
okg auth login
50
--key KEY API key (also accepted via stdin)
63
--key KEY API key (also accepted via stdin)
51
--host HOST klee host (default: production)
64
--host HOST klee host (default: production)
52
65
53
GIT REPOS
66
GIT REPOS
54
okg repo list
67
okg repo list
55
--json output raw JSON
68
--json output raw JSON
56
69
57
okg repo create NAME
70
okg repo create NAME
58
--reader USER grant read to USER (default: anyone)
71
--reader USER grant read to USER (default: anyone)
59
72
60
GROUPS
73
GROUPS
61
okg group list
74
okg group list
62
--json output raw JSON
75
--json output raw JSON
63
76
64
okg group create NAME
77
okg group create NAME
65
--full-name TEXT display name (default: NAME)
78
--full-name TEXT display name (default: NAME)
66
--owner USER owner username (default: caller)
79
--owner USER owner username (default: caller)
67
80
68
okg group add-member GROUP USER [USER ...]
81
okg group add-member GROUP USER [USER ...]
69
82
70
okg group remove-member GROUP USER [USER ...]
83
okg group remove-member GROUP USER [USER ...]
71
84
72
okg group members GROUP
85
okg group members GROUP
73
--json full DAG (Up/Down/Usernames) as raw JSON
86
--json full DAG (Up/Down/Usernames) as raw JSON
74
87
75
okg group delete NAME
88
okg group delete NAME
76
89
77
AUTHZ
90
AUTHZ
78
okg authz list
91
okg authz list
79
--json output raw JSON
92
--json output raw JSON
80
93
81
okg authz set URI OWNER READER
94
okg authz set URI OWNER READER
82
95
83
okg authz delete URI
96
okg authz delete URI
84
97
85
CHAT
98
CHAT
86
okg chat send TO TEXT
99
okg chat send TO TEXT
87
100
88
okg chat fetch
101
okg chat fetch
89
--to GROUP filter by destination group
102
--to GROUP filter by destination group
90
--json output raw JSON
103
--json output raw JSON
91
104
92
PULL REQUESTS
105
PULL REQUESTS
93
okg pr list
106
okg pr list
94
--state STATE open or closed (default: open)
107
--state STATE open or closed (default: open)
95
--json output raw JSON
108
--json output raw JSON
96
109
97
okg pr create
110
okg pr create
98
--head BRANCH source branch
111
--head BRANCH source branch
99
--base BRANCH target branch (default: master)
112
--base BRANCH target branch (default: master)
100
--title TITLE PR title
113
--title TITLE PR title
101
--body BODY PR body (optional)
114
--body BODY PR body (optional)
102
--json output raw JSON
115
--json output raw JSON
103
116
104
okg pr view NUMBER
117
okg pr view NUMBER
105
--json output raw JSON
118
--json output raw JSON
106
119
107
okg pr diff NUMBER
120
okg pr diff NUMBER
108
121
109
okg pr comment NUMBER
122
okg pr comment NUMBER
110
--body BODY comment body
123
--body BODY comment body
111
--approve also approve the PR
124
--approve also approve the PR
112
--request-changes also request changes
125
--request-changes also request changes
113
126
114
okg pr merge NUMBER
127
okg pr merge NUMBER
115
--json output raw JSON
128
--json output raw JSON
116
129
117
okg pr close NUMBER
130
okg pr close NUMBER
118
--json output raw JSON
131
--json output raw JSON
119
132
120
okg pr reopen NUMBER
133
okg pr reopen NUMBER
121
--json output raw JSON
134
--json output raw JSON
122
135
123
ARTIFICIAL INTELLIGENCE
136
ARTIFICIAL INTELLIGENCE
124
okg embed
137
okg embed
125
--model NAME embedding model
138
--model NAME embedding model
126
(default: openai:text-embedding-3-small)
139
(default: openai:text-embedding-3-small)
127
--dims N number of dimensions (default: 1536)
140
--dims N number of dimensions (default: 1536)
128
--full-path one vector per prefix of input
141
--full-path one vector per prefix of input
129
(reads stdin; writes vectors to stdout, one per line)
142
(reads stdin; writes vectors to stdout, one per line)
130
143
131
okg one
144
okg one
132
--model NAME override .Model in the request
145
--model NAME override .Model in the request
133
--system-file FILE override .System with contents of FILE
146
--system-file FILE override .System with contents of FILE
134
--prompt-file FILE append FILE as a user prompt
147
--prompt-file FILE append FILE as a user prompt
135
--attach FILE attach an image or PDF to the prompt
148
--attach FILE attach an image or PDF to the prompt
136
--format FORMAT text | json | jsonindent (default: text)
149
--format FORMAT text | json | jsonindent (default: text)
137
--fast-fail preflight attachment MIME (default: on)
150
--fast-fail preflight attachment MIME (default: on)
138
(reads stdin as a JSON MessagesRequest; flags override
151
(reads stdin as a JSON MessagesRequest; flags override
139
its fields)
152
its fields)
140
153
141
okg exemplary
154
okg exemplary
142
--dir DIR directory of <case>.<ext> files
155
--dir DIR directory of <case>.<ext> files
143
(default: .)
156
(default: .)
144
--model NAME LLM model (default: "Gemini 3 Pro")
157
--model NAME LLM model (default: "Gemini 3 Pro")
145
--dry-run build but don't send requests
158
--dry-run build but don't send requests
146
--debug log each request to stderr
159
--debug log each request to stderr
147
160
148
GLOBAL FLAGS
161
GLOBAL FLAGS
149
--repo REPO override auto-detected repo name
162
--repo REPO override auto-detected repo name
150
--json output raw JSON (where applicable)
163
--json output raw JSON (where applicable)
151
164
152
EXAMPLES
165
EXAMPLES
153
Setup
166
Setup
154
okg auth login --key sk-...
167
okg auth login --key sk-...
155
cat ~/.klex.key | okg auth login
168
cat ~/.klex.key | okg auth login
156
169
157
Git repos
170
Git repos
158
okg repo list
171
okg repo list
159
okg repo create my-new-repo
172
okg repo create my-new-repo
160
173
161
Groups
174
Groups
162
okg group list
175
okg group list
163
okg group create chat-bots
176
okg group create chat-bots
164
okg group add-member chat-bots claude openclaw
177
okg group add-member chat-bots claude openclaw
165
okg group remove-member chat-bots openclaw
178
okg group remove-member chat-bots openclaw
166
okg group members chat-bots
179
okg group members chat-bots
167
okg group delete chat-bots
180
okg group delete chat-bots
168
181
169
Authz
182
Authz
170
okg authz set chat://chat-bots#post chat-bots chat-bots
183
okg authz set chat://chat-bots#post chat-bots chat-bots
171
okg authz list
184
okg authz list
172
185
173
Chat
186
Chat
174
okg chat send chat-bots 'hello team'
187
okg chat send chat-bots 'hello team'
175
okg chat fetch --to chat-bots
188
okg chat fetch --to chat-bots
176
189
177
Pull requests
190
Pull requests
178
okg pr list --state open
191
okg pr list --state open
179
okg pr view 42
192
okg pr view 42
180
okg pr comment 42 --body 'LGTM' --approve
193
okg pr comment 42 --body 'LGTM' --approve
181
194
182
Artificial intelligence
195
Artificial intelligence
183
echo 'hello world' | okg embed --dims 384
196
echo 'hello world' | okg embed --dims 384
184
echo Hello? > /tmp/q.txt && \
197
echo Hello? > /tmp/q.txt && \
185
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
198
okg one --model openai:gpt-4o-mini --prompt-file /tmp/q.txt
186
okg exemplary --dir tagging_cases
199
okg exemplary --dir tagging_cases
187
`)
200
`)
188
}
201
}
a/okg_test.go
b/okg_test.go
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 "path/filepath"
7
import "strings"
8
import "strings"
8
import "testing"
9
import "testing"
9
import "time"
10
import "time"
10
11
11
import "oscarkilo.com/okg/klee"
12
import "oscarkilo.com/okg/klee"
12
13
13
// TestMain points both host fields at localhost so the suite
14
// can't accidentally reach prod. Individual tests that need a
15
// specific URL (e.g. a mock httptest server for klee) override
16
// OKG_CODE_HOST themselves.
17
func TestMain(m *testing.M) {
18
os.Setenv("OKG_HOST", "http://localhost:42069")
19
os.Setenv("OKG_CODE_HOST", "http://localhost:42069")
20
os.Exit(m.Run())
21
}
22
23
func TestRepoRegex(t *testing.T) {
14
func TestRepoRegex(t *testing.T) {
15
t.Parallel()
24
check := func(url, want string) {
16
check := func(url, want string) {
25
t.Helper()
17
t.Helper()
26
m := repoRegex.FindStringSubmatch(url)
18
m := repoRegex.FindStringSubmatch(url)
27
if want == "" {
19
if want == "" {
28
if m != nil {
20
if m != nil {
29
t.Errorf(
21
t.Errorf(
30
"%q: want no match, got %q", url, m[1])
22
"%q: want no match, got %q", url, m[1])
31
}
23
}
32
return
24
return
33
}
25
}
34
if m == nil {
26
if m == nil {
35
t.Errorf(
27
t.Errorf(
36
"%q: want %q, got no match", url, want)
28
"%q: want %q, got no match", url, want)
37
return
29
return
38
}
30
}
39
if m[1] != want {
31
if m[1] != want {
40
t.Errorf(
32
t.Errorf(
41
"%q: want %q, got %q", url, want, m[1])
33
"%q: want %q, got %q", url, want, m[1])
42
}
34
}
43
}
35
}
44
check(
36
check(
45
"https://code.oscarkilo.com/widget.git",
37
"https://code.oscarkilo.com/widget.git",
46
"widget")
38
"widget")
47
check(
39
check(
48
"https://code.oscarkilo.com/klee.git",
40
"https://code.oscarkilo.com/klee.git",
49
"klee")
41
"klee")
50
check(
42
check(
51
"https://code.oscarkilo.com/my-repo.git",
43
"https://code.oscarkilo.com/my-repo.git",
52
"my-repo")
44
"my-repo")
53
check(
45
check(
54
"https://code.oscarkilo.com/a123.git",
46
"https://code.oscarkilo.com/a123.git",
55
"a123")
47
"a123")
56
check(
48
check(
57
"https://github.com/foo/bar.git",
49
"https://github.com/foo/bar.git",
58
"")
50
"")
59
check("not-a-url", "")
51
check("not-a-url", "")
60
}
52
}
61
53
62
func TestResolveRepo(t *testing.T) {
54
func TestResolveRepoFlag(t *testing.T) {
63
// Flag takes priority.
55
t.Parallel()
64
repo, err := resolveRepo("from-flag")
56
repo, err := resolveRepo("from-flag")
65
if err != nil {
57
if err != nil {
66
t.Fatal(err)
58
t.Fatal(err)
67
}
59
}
68
if repo != "from-flag" {
60
if repo != "from-flag" {
69
t.Errorf("want from-flag, got %q", repo)
61
t.Errorf("want from-flag, got %q", repo)
70
}
62
}
71
72
// Env var takes priority over detection.
73
os.Setenv("OKG_REPO", "from-env")
74
defer os.Unsetenv("OKG_REPO")
75
repo, err = resolveRepo("")
76
if err != nil {
77
t.Fatal(err)
78
}
79
if repo != "from-env" {
80
t.Errorf("want from-env, got %q", repo)
81
}
82
}
63
}
83
64
84
func TestParsePRFlags(t *testing.T) {
65
func TestParsePRFlags(t *testing.T) {
66
t.Parallel()
85
f, rest, err := parsePRFlags([]string{
67
f, rest, err := parsePRFlags([]string{
86
"--repo", "widget", "--json", "42",
68
"--repo", "widget", "--json", "42",
87
})
69
})
88
if err != nil {
70
if err != nil {
89
t.Fatal(err)
71
t.Fatal(err)
90
}
72
}
91
if f.repo != "widget" {
73
if f.repo != "widget" {
92
t.Errorf(
74
t.Errorf(
93
"repo: want widget, got %q", f.repo)
75
"repo: want widget, got %q", f.repo)
94
}
76
}
95
if !f.asJSON {
77
if !f.asJSON {
96
t.Error("asJSON: want true")
78
t.Error("asJSON: want true")
97
}
79
}
98
if len(rest) != 1 || rest[0] != "42" {
80
if len(rest) != 1 || rest[0] != "42" {
99
t.Errorf("rest: want [42], got %v", rest)
81
t.Errorf("rest: want [42], got %v", rest)
100
}
82
}
101
}
83
}
102
84
103
func TestParsePRFlagsEmpty(t *testing.T) {
85
func TestParsePRFlagsEmpty(t *testing.T) {
86
t.Parallel()
104
f, rest, err := parsePRFlags(nil)
87
f, rest, err := parsePRFlags(nil)
105
if err != nil {
88
if err != nil {
106
t.Fatal(err)
89
t.Fatal(err)
107
}
90
}
108
if f.repo != "" {
91
if f.repo != "" {
109
t.Errorf(
92
t.Errorf(
110
"repo: want empty, got %q", f.repo)
93
"repo: want empty, got %q", f.repo)
111
}
94
}
112
if f.asJSON {
95
if f.asJSON {
113
t.Error("asJSON: want false")
96
t.Error("asJSON: want false")
114
}
97
}
115
if len(rest) != 0 {
98
if len(rest) != 0 {
116
t.Errorf("rest: want empty, got %v", rest)
99
t.Errorf("rest: want empty, got %v", rest)
117
}
100
}
118
}
101
}
119
102
120
func TestParsePRFlagsMissingValue(t *testing.T) {
103
func TestParsePRFlagsMissingValue(t *testing.T) {
104
t.Parallel()
121
_, _, err := parsePRFlags([]string{"--repo"})
105
_, _, err := parsePRFlags([]string{"--repo"})
122
if err == nil {
106
if err == nil {
123
t.Error("want error for --repo without value")
107
t.Error("want error for --repo without value")
124
}
108
}
125
}
109
}
126
110
127
func TestAge(t *testing.T) {
111
func TestAge(t *testing.T) {
112
t.Parallel()
128
check := func(d time.Duration, want string) {
113
check := func(d time.Duration, want string) {
129
t.Helper()
114
t.Helper()
130
got := age(time.Now().Add(-d))
115
got := age(time.Now().Add(-d))
131
if got != want {
116
if got != want {
132
t.Errorf(
117
t.Errorf(
133
"age(-%v): want %q, got %q",
118
"age(-%v): want %q, got %q",
134
d, want, got)
119
d, want, got)
135
}
120
}
136
}
121
}
137
check(30*time.Second, "just now")
122
check(30*time.Second, "just now")
138
check(5*time.Minute, "5m")
123
check(5*time.Minute, "5m")
139
check(3*time.Hour, "3h")
124
check(3*time.Hour, "3h")
140
check(48*time.Hour, "2d")
125
check(48*time.Hour, "2d")
141
}
126
}
142
127
143
func TestConfigOKGHostOverride(t *testing.T) {
128
func TestConfigDefaultsEmptyPath(t *testing.T) {
144
t.Setenv("OKG_HOST", "http://test:1234")
129
t.Parallel()
145
cfg, err := loadConfig()
130
cfg, err := loadConfig("")
146
if err != nil {
131
if err != nil {
147
t.Fatal(err)
132
t.Fatal(err)
148
}
133
}
149
if cfg.Host != "http://test:1234" {
134
if cfg.Host != DefaultHost {
150
t.Errorf(
135
t.Errorf(
151
"Host: want http://test:1234, got %q",
136
"Host: want %q, got %q",
152
cfg.Host)
137
DefaultHost, cfg.Host)
138
}
139
if cfg.CodeHost != DefaultCodeHost {
140
t.Errorf(
141
"CodeHost: want %q, got %q",
142
DefaultCodeHost, cfg.CodeHost)
153
}
143
}
154
}
144
}
155
145
156
func TestConfigDefaultHost(t *testing.T) {
146
func TestConfigDefaultsMissingFile(t *testing.T) {
157
// Bypass the TestMain guardrail and isolate from any real
147
t.Parallel()
158
// ~/.config/okg/config.json so we can verify the prod default.
148
path := filepath.Join(t.TempDir(), "config.json")
159
t.Setenv("HOME", t.TempDir())
149
cfg, err := loadConfig(path)
160
t.Setenv("OKG_HOST", "")
161
t.Setenv("OKG_CODE_HOST", "")
162
cfg, err := loadConfig()
163
if err != nil {
150
if err != nil {
164
t.Fatal(err)
151
t.Fatal(err)
165
}
152
}
166
if cfg.Host != "https://oscarkilo.com" {
153
if cfg.Host != DefaultHost {
167
t.Errorf(
154
t.Errorf(
168
"Host: want https://oscarkilo.com, got %q",
155
"Host: want %q, got %q",
169
cfg.Host)
156
DefaultHost, cfg.Host)
170
}
157
}
171
if cfg.CodeHost != "https://code.oscarkilo.com" {
158
if cfg.CodeHost != DefaultCodeHost {
172
t.Errorf(
159
t.Errorf(
173
"CodeHost: want https://code.oscarkilo.com, got %q",
160
"CodeHost: want %q, got %q",
174
cfg.CodeHost)
161
DefaultCodeHost, cfg.CodeHost)
175
}
162
}
176
}
163
}
177
164
178
// writeTestKey creates an isolated ~/.config/okg/config.json
165
// TestConfigMigrateOldHost covers configs saved before the
179
// in a temp dir with the given API key. Used by tests that need
166
// Host/CodeHost split: Host held the klee URL. The migration
180
// loadConfig to return a key without touching the user's real
167
// moves it to CodeHost.
181
// config.
168
func TestConfigMigrateOldHost(t *testing.T) {
182
func writeTestKey(t *testing.T, key string) {
169
t.Parallel()
183
t.Helper()
170
path := filepath.Join(t.TempDir(), "config.json")
184
t.Setenv("HOME", t.TempDir())
171
old := []byte(
185
if err := saveConfig(&Config{ApiKey: key}); err != nil {
172
`{"host": "https://code.oscarkilo.com", ` +
173
`"api_key": "k"}`)
174
if err := os.WriteFile(path, old, 0600); err != nil {
175
t.Fatal(err)
176
}
177
cfg, err := loadConfig(path)
178
if err != nil {
186
t.Fatal(err)
179
t.Fatal(err)
187
}
180
}
181
if cfg.Host != DefaultHost {
182
t.Errorf(
183
"Host: want %q, got %q",
184
DefaultHost, cfg.Host)
185
}
186
if cfg.CodeHost != DefaultCodeHost {
187
t.Errorf(
188
"CodeHost: want %q, got %q",
189
DefaultCodeHost, cfg.CodeHost)
190
}
188
}
191
}
189
192
190
func TestAuthLoginKeyFlag(t *testing.T) {
193
func TestAuthLoginKeyFlag(t *testing.T) {
191
t.Setenv("HOME", t.TempDir())
194
t.Parallel()
192
if err := runAuthLogin(
195
path := filepath.Join(t.TempDir(), "config.json")
193
[]string{"--key", "sk-flag"},
196
err := runAuthLogin(path, []string{"--key", "sk-flag"})
194
); err != nil {
197
if err != nil {
195
t.Fatal(err)
198
t.Fatal(err)
196
}
199
}
197
cfg, err := loadConfig()
200
cfg, err := loadConfig(path)
198
if err != nil {
201
if err != nil {
199
t.Fatal(err)
202
t.Fatal(err)
200
}
203
}
201
if cfg.ApiKey != "sk-flag" {
204
if cfg.ApiKey != "sk-flag" {
202
t.Errorf(
205
t.Errorf(
203
"ApiKey: want sk-flag, got %q", cfg.ApiKey)
206
"ApiKey: want sk-flag, got %q", cfg.ApiKey)
204
}
207
}
205
}
208
}
206
209
210
// TestAuthLoginStdin mutates os.Stdin and so cannot run in
211
// parallel with other tests that might read stdin.
207
func TestAuthLoginStdin(t *testing.T) {
212
func TestAuthLoginStdin(t *testing.T) {
208
t.Setenv("HOME", t.TempDir())
209
210
r, w, err := os.Pipe()
213
r, w, err := os.Pipe()
211
if err != nil {
214
if err != nil {
212
t.Fatal(err)
215
t.Fatal(err)
213
}
216
}
214
orig := os.Stdin
217
orig := os.Stdin
215
os.Stdin = r
218
os.Stdin = r
216
defer func() { os.Stdin = orig }()
219
defer func() { os.Stdin = orig }()
217
go func() {
220
go func() {
218
w.Write([]byte("sk-piped\n"))
221
w.Write([]byte("sk-piped\n"))
219
w.Close()
222
w.Close()
220
}()
223
}()
221
224
222
if err := runAuthLogin(nil); err != nil {
225
path := filepath.Join(t.TempDir(), "config.json")
226
if err := runAuthLogin(path, nil); err != nil {
223
t.Fatal(err)
227
t.Fatal(err)
224
}
228
}
225
cfg, err := loadConfig()
229
cfg, err := loadConfig(path)
226
if err != nil {
230
if err != nil {
227
t.Fatal(err)
231
t.Fatal(err)
228
}
232
}
229
if cfg.ApiKey != "sk-piped" {
233
if cfg.ApiKey != "sk-piped" {
230
t.Errorf(
234
t.Errorf(
231
"ApiKey: want sk-piped, got %q", cfg.ApiKey)
235
"ApiKey: want sk-piped, got %q", cfg.ApiKey)
232
}
236
}
233
}
237
}
234
238
235
func TestRunRepoCreateArgs(t *testing.T) {
239
func TestRunRepoCreateArgs(t *testing.T) {
240
t.Parallel()
236
mock := newMockKleeRepo(t, 204, "")
241
mock := newMockKleeRepo(t, 204, "")
237
defer mock.Close()
242
defer mock.Close()
238
243
239
writeTestKey(t, "test-key")
244
cfg := &Config{
240
t.Setenv("OKG_CODE_HOST", mock.URL)
245
CodeHost: mock.URL,
241
246
ApiKey: "test-key",
242
err := runRepoCreate([]string{
247
}
248
err := runRepoCreate(cfg, []string{
243
"my-repo", "--reader", "igor.agents",
249
"my-repo", "--reader", "igor.agents",
244
})
250
})
245
if err != nil {
251
if err != nil {
246
t.Fatal(err)
252
t.Fatal(err)
247
}
253
}
248
if mock.req.RepoName != "my-repo" {
254
if mock.req.RepoName != "my-repo" {
249
t.Errorf(
255
t.Errorf(
250
"repo_name: want my-repo, got %q",
256
"repo_name: want my-repo, got %q",
251
mock.req.RepoName)
257
mock.req.RepoName)
252
}
258
}
253
if mock.req.ReaderUsername != "igor.agents" {
259
if mock.req.ReaderUsername != "igor.agents" {
254
t.Errorf(
260
t.Errorf(
255
"reader: want igor.agents, got %q",
261
"reader: want igor.agents, got %q",
256
mock.req.ReaderUsername)
262
mock.req.ReaderUsername)
257
}
263
}
258
}
264
}
259
265
260
func TestRunRepoCreateNoReader(t *testing.T) {
266
func TestRunRepoCreateNoReader(t *testing.T) {
267
t.Parallel()
261
mock := newMockKleeRepo(t, 204, "")
268
mock := newMockKleeRepo(t, 204, "")
262
defer mock.Close()
269
defer mock.Close()
263
270
264
writeTestKey(t, "test-key")
271
cfg := &Config{
265
t.Setenv("OKG_CODE_HOST", mock.URL)
272
CodeHost: mock.URL,
266
273
ApiKey: "test-key",
267
err := runRepoCreate([]string{"my-repo"})
274
}
275
err := runRepoCreate(cfg, []string{"my-repo"})
268
if err != nil {
276
if err != nil {
269
t.Fatal(err)
277
t.Fatal(err)
270
}
278
}
271
if mock.req.ReaderUsername != "" {
279
if mock.req.ReaderUsername != "" {
272
t.Errorf(
280
t.Errorf(
273
"reader: want empty, got %q",
281
"reader: want empty, got %q",
274
mock.req.ReaderUsername)
282
mock.req.ReaderUsername)
275
}
283
}
276
}
284
}
277
285
278
func TestRunRepoCreateMissingName(t *testing.T) {
286
func TestRunRepoCreateMissingName(t *testing.T) {
279
err := runRepoCreate(nil)
287
t.Parallel()
288
err := runRepoCreate(&Config{}, nil)
280
if err == nil {
289
if err == nil {
281
t.Fatal("want error for missing name")
290
t.Fatal("want error for missing name")
282
}
291
}
283
}
292
}
284
293
285
func TestRunRepoCreateReaderMissingValue(
294
func TestRunRepoCreateReaderMissingValue(t *testing.T) {
286
t *testing.T,
295
t.Parallel()
287
) {
296
err := runRepoCreate(&Config{}, []string{
288
err := runRepoCreate([]string{
289
"my-repo", "--reader",
297
"my-repo", "--reader",
290
})
298
})
291
if err == nil {
299
if err == nil {
292
t.Fatal(
300
t.Fatal(
293
"want error for --reader without value")
301
"want error for --reader without value")
294
}
302
}
295
}
303
}
296
304
297
func TestRunRepoCreateUnknownFlag(t *testing.T) {
305
func TestRunRepoCreateUnknownFlag(t *testing.T) {
298
err := runRepoCreate([]string{
306
t.Parallel()
307
err := runRepoCreate(&Config{}, []string{
299
"my-repo", "--bogus",
308
"my-repo", "--bogus",
300
})
309
})
301
if err == nil {
310
if err == nil {
302
t.Fatal("want error for unknown flag")
311
t.Fatal("want error for unknown flag")
303
}
312
}
304
}
313
}
305
314
306
func TestRunRepoCreateDuplicateName(
315
func TestRunRepoCreateDuplicateName(t *testing.T) {
307
t *testing.T,
316
t.Parallel()
308
) {
317
err := runRepoCreate(&Config{}, []string{
309
err := runRepoCreate([]string{
310
"my-repo", "extra",
318
"my-repo", "extra",
311
})
319
})
312
if err == nil {
320
if err == nil {
313
t.Fatal(
321
t.Fatal(
314
"want error for extra positional arg")
322
"want error for extra positional arg")
315
}
323
}
316
}
324
}
317
325
318
func TestRunRepoCreateServerError(t *testing.T) {
326
func TestRunRepoCreateServerError(t *testing.T) {
327
t.Parallel()
319
mock := newMockKleeRepo(t, 403,
328
mock := newMockKleeRepo(t, 403,
320
"you are not an owner of "+
329
"you are not an owner of "+
321
"klee://code.oscarkilo.com/.new-repo")
330
"klee://code.oscarkilo.com/.new-repo")
322
defer mock.Close()
331
defer mock.Close()
323
332
324
writeTestKey(t, "test-key")
333
cfg := &Config{
325
t.Setenv("OKG_CODE_HOST", mock.URL)
334
CodeHost: mock.URL,
326
335
ApiKey: "test-key",
327
err := runRepoCreate([]string{"my-repo"})
336
}
337
err := runRepoCreate(cfg, []string{"my-repo"})
328
if err == nil {
338
if err == nil {
329
t.Fatal("want error on 403")
339
t.Fatal("want error on 403")
330
}
340
}
331
if !strings.Contains(err.Error(), "403") {
341
if !strings.Contains(err.Error(), "403") {
332
t.Errorf("want 403 in error, got %q", err)
342
t.Errorf("want 403 in error, got %q", err)
333
}
343
}
334
}
344
}
335
345
336
// mockKleeRepo captures the /.add-repo request.
346
// mockKleeRepo captures the /.add-repo request.
337
type mockKleeRepo struct {
347
type mockKleeRepo struct {
338
*httptest.Server
348
*httptest.Server
339
req klee.CreateRepoRequest
349
req klee.CreateRepoRequest
340
}
350
}
341
351
342
func newMockKleeRepo(
352
func newMockKleeRepo(
343
t *testing.T, status int, body string,
353
t *testing.T, status int, body string,
344
) *mockKleeRepo {
354
) *mockKleeRepo {
345
t.Helper()
355
t.Helper()
346
mk := &mockKleeRepo{}
356
mk := &mockKleeRepo{}
347
mk.Server = httptest.NewServer(
357
mk.Server = httptest.NewServer(
348
http.HandlerFunc(func(
358
http.HandlerFunc(func(
349
w http.ResponseWriter, r *http.Request,
359
w http.ResponseWriter, r *http.Request,
350
) {
360
) {
351
json.NewDecoder(r.Body).Decode(&mk.req)
361
json.NewDecoder(r.Body).Decode(&mk.req)
352
w.WriteHeader(status)
362
w.WriteHeader(status)
353
if body != "" {
363
if body != "" {
354
w.Write([]byte(body))
364
w.Write([]byte(body))
355
}
365
}
356
}))
366
}))
357
return mk
367
return mk
358
}
368
}
a/one.go
b/one.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 "io"
6
import "io"
7
import "os"
7
import "os"
8
import "strings"
8
import "strings"
9
9
10
import "oscarkilo.com/klex-git/api"
10
import "oscarkilo.com/klex-git/api"
11
11
12
// runOne runs one LLM inference on one input. Mirrors the (now-
12
// runOne runs one LLM inference on one input. Mirrors the (now-
13
// deprecated) `klex-git/one` binary.
13
// deprecated) `klex-git/one` binary.
14
//
14
//
15
// Reads stdin as an api.MessagesRequest JSON; empty stdin is
15
// Reads stdin as an api.MessagesRequest JSON; empty stdin is
16
// allowed (treated as {}). Flags override individual fields.
16
// allowed (treated as {}). Flags override individual fields.
17
func runOne(args []string) error {
17
func runOne(cfg *Config, args []string) error {
18
fs := flag.NewFlagSet("one", flag.ContinueOnError)
18
fs := flag.NewFlagSet("one", flag.ContinueOnError)
19
model := fs.String("model", "",
19
model := fs.String("model", "",
20
"override .Model, if non-empty")
20
"override .Model, if non-empty")
21
systemFile := fs.String("system-file", "",
21
systemFile := fs.String("system-file", "",
22
"override .System with the contents of this file")
22
"override .System with the contents of this file")
23
promptFile := fs.String("prompt-file", "",
23
promptFile := fs.String("prompt-file", "",
24
"append this file to .Messages as a user prompt")
24
"append this file to .Messages as a user prompt")
25
attach := fs.String("attach", "",
25
attach := fs.String("attach", "",
26
"path to a file (image or PDF) to attach to the prompt")
26
"path to a file (image or PDF) to attach to the prompt")
27
format := fs.String("format", "text",
27
format := fs.String("format", "text",
28
"text | json | jsonindent")
28
"text | json | jsonindent")
29
fastFail := fs.Bool("fast-fail", true,
29
fastFail := fs.Bool("fast-fail", true,
30
"preflight-check the attachment MIME against the model's "+
30
"preflight-check the attachment MIME against the model's "+
31
"llm2 capabilities; fail before paying for the call. "+
31
"llm2 capabilities; fail before paying for the call. "+
32
"Set false in tight loops to skip the extra HTTP "+
32
"Set false in tight loops to skip the extra HTTP "+
33
"round-trip per call.")
33
"round-trip per call.")
34
if err := fs.Parse(args); err != nil {
34
if err := fs.Parse(args); err != nil {
35
return err
35
return err
36
}
36
}
37
37
38
cfg, err := loadConfig()
39
if err != nil {
40
return err
41
}
42
if cfg.ApiKey == "" {
38
if cfg.ApiKey == "" {
43
return fmt.Errorf(
39
return fmt.Errorf(
44
"no API key — run `okg auth login --key sk-...`")
40
"no API key — run `okg auth login --key sk-...`")
45
}
41
}
46
client := newKlexClient(cfg)
42
client := newKlexClient(cfg)
47
43
48
// Parse stdin as a MessagesRequest; empty → {}.
44
// Parse stdin as a MessagesRequest; empty → {}.
49
sin, err := io.ReadAll(os.Stdin)
45
sin, err := io.ReadAll(os.Stdin)
50
if err != nil {
46
if err != nil {
51
return fmt.Errorf("read stdin: %v", err)
47
return fmt.Errorf("read stdin: %v", err)
52
}
48
}
53
if len(sin) == 0 {
49
if len(sin) == 0 {
54
sin = []byte("{}")
50
sin = []byte("{}")
55
}
51
}
56
var req api.MessagesRequest
52
var req api.MessagesRequest
57
if err := json.Unmarshal(sin, &req); err != nil {
53
if err := json.Unmarshal(sin, &req); err != nil {
58
return fmt.Errorf("parse MessagesRequest: %v", err)
54
return fmt.Errorf("parse MessagesRequest: %v", err)
59
}
55
}
60
56
61
// Flag overrides.
57
// Flag overrides.
62
if *model != "" {
58
if *model != "" {
63
req.Model = *model
59
req.Model = *model
64
}
60
}
65
if *systemFile != "" {
61
if *systemFile != "" {
66
s, err := os.ReadFile(*systemFile)
62
s, err := os.ReadFile(*systemFile)
67
if err != nil {
63
if err != nil {
68
return fmt.Errorf(
64
return fmt.Errorf(
69
"read --system-file %s: %v", *systemFile, err)
65
"read --system-file %s: %v", *systemFile, err)
70
}
66
}
71
req.System = string(s)
67
req.System = string(s)
72
}
68
}
73
if *attach != "" && *promptFile == "" {
69
if *attach != "" && *promptFile == "" {
74
return fmt.Errorf(
70
return fmt.Errorf(
75
"--attach requires a non-empty --prompt-file")
71
"--attach requires a non-empty --prompt-file")
76
}
72
}
77
var attachMime string
73
var attachMime string
78
if *promptFile != "" {
74
if *promptFile != "" {
79
msg := api.ChatMessage{Role: "user"}
75
msg := api.ChatMessage{Role: "user"}
80
if *attach != "" {
76
if *attach != "" {
81
data, err := os.ReadFile(*attach)
77
data, err := os.ReadFile(*attach)
82
if err != nil {
78
if err != nil {
83
return fmt.Errorf(
79
return fmt.Errorf(
84
"read --attach %s: %v", *attach, err)
80
"read --attach %s: %v", *attach, err)
85
}
81
}
86
blk := api.NewDocumentBlock(data)
82
blk := api.NewDocumentBlock(data)
87
attachMime = blk.Source.MediaType
83
attachMime = blk.Source.MediaType
88
msg.Content = append(msg.Content, blk)
84
msg.Content = append(msg.Content, blk)
89
}
85
}
90
p, err := os.ReadFile(*promptFile)
86
p, err := os.ReadFile(*promptFile)
91
if err != nil {
87
if err != nil {
92
return fmt.Errorf(
88
return fmt.Errorf(
93
"read --prompt-file %s: %v", *promptFile, err)
89
"read --prompt-file %s: %v", *promptFile, err)
94
}
90
}
95
msg.Content = append(msg.Content, api.ContentBlock{
91
msg.Content = append(msg.Content, api.ContentBlock{
96
Type: "text",
92
Type: "text",
97
Text: string(p),
93
Text: string(p),
98
})
94
})
99
req.Messages = append(req.Messages, msg)
95
req.Messages = append(req.Messages, msg)
100
}
96
}
101
97
102
// Preflight: catch unsupported attachment types before the
98
// Preflight: catch unsupported attachment types before the
103
// call.
99
// call.
104
if *fastFail && attachMime != "" {
100
if *fastFail && attachMime != "" {
105
if err := preflightAttachment(
101
if err := preflightAttachment(
106
client, req.Model, attachMime,
102
client, req.Model, attachMime,
107
); err != nil {
103
); err != nil {
108
return err
104
return err
109
}
105
}
110
}
106
}
111
107
112
res, err := client.Messages(req)
108
res, err := client.Messages(req)
113
if err != nil {
109
if err != nil {
114
return fmt.Errorf("klex f() failed: %v", err)
110
return fmt.Errorf("klex f() failed: %v", err)
115
}
111
}
116
112
117
out, err := formatMessagesResponse(res, *format)
113
out, err := formatMessagesResponse(res, *format)
118
if err != nil {
114
if err != nil {
119
return err
115
return err
120
}
116
}
121
fmt.Print(out)
117
fmt.Print(out)
122
return nil
118
return nil
123
}
119
}
124
120
125
func formatMessagesResponse(
121
func formatMessagesResponse(
126
res *api.MessagesResponse, format string,
122
res *api.MessagesResponse, format string,
127
) (string, error) {
123
) (string, error) {
128
switch format {
124
switch format {
129
case "text":
125
case "text":
130
var parts []string
126
var parts []string
131
for _, c := range res.Content {
127
for _, c := range res.Content {
132
if c.Type == "text" {
128
if c.Type == "text" {
133
parts = append(parts, c.Text+"\n")
129
parts = append(parts, c.Text+"\n")
134
}
130
}
135
}
131
}
136
return strings.Join(parts, "\n"), nil
132
return strings.Join(parts, "\n"), nil
137
case "json":
133
case "json":
138
buf, err := json.Marshal(res)
134
buf, err := json.Marshal(res)
139
return string(buf), err
135
return string(buf), err
140
case "jsonindent":
136
case "jsonindent":
141
buf, err := json.MarshalIndent(res, "", " ")
137
buf, err := json.MarshalIndent(res, "", " ")
142
return string(buf), err
138
return string(buf), err
143
default:
139
default:
144
return "", fmt.Errorf(
140
return "", fmt.Errorf(
145
"unsupported --format=%s", format)
141
"unsupported --format=%s", format)
146
}
142
}
147
}
143
}
148
144
149
// preflightAttachment fetches the model's llm2 capabilities and
145
// preflightAttachment fetches the model's llm2 capabilities and
150
// returns an error if it can't accept the given attachment MIME
146
// returns an error if it can't accept the given attachment MIME
151
// type. Returns nil silently for MIME families Klex has no
147
// type. Returns nil silently for MIME families Klex has no
152
// capability flag for (anything that isn't image/* or
148
// capability flag for (anything that isn't image/* or
153
// application/pdf).
149
// application/pdf).
154
func preflightAttachment(
150
func preflightAttachment(
155
client *api.Client, modelName, mimeType string,
151
client *api.Client, modelName, mimeType string,
156
) error {
152
) error {
157
resp, err := client.ListFuncs("latest")
153
resp, err := client.ListFuncs("latest")
158
if err != nil {
154
if err != nil {
159
return fmt.Errorf(
155
return fmt.Errorf(
160
"preflight ListFuncs failed "+
156
"preflight ListFuncs failed "+
161
"(--fast-fail=false to bypass): %v", err)
157
"(--fast-fail=false to bypass): %v", err)
162
}
158
}
163
var fn *api.Func
159
var fn *api.Func
164
for i := range resp.Funcs {
160
for i := range resp.Funcs {
165
if resp.Funcs[i].Name == modelName {
161
if resp.Funcs[i].Name == modelName {
166
fn = &resp.Funcs[i]
162
fn = &resp.Funcs[i]
167
break
163
break
168
}
164
}
169
}
165
}
170
if fn == nil {
166
if fn == nil {
171
return fmt.Errorf(
167
return fmt.Errorf(
172
"unknown model %q (--fast-fail=false to bypass)",
168
"unknown model %q (--fast-fail=false to bypass)",
173
modelName)
169
modelName)
174
}
170
}
175
if len(fn.Versions) == 0 ||
171
if len(fn.Versions) == 0 ||
176
fn.Versions[len(fn.Versions)-1].LLM2 == nil {
172
fn.Versions[len(fn.Versions)-1].LLM2 == nil {
177
return fmt.Errorf(
173
return fmt.Errorf(
178
"model %q has no llm2 config", modelName)
174
"model %q has no llm2 config", modelName)
179
}
175
}
180
llm := fn.Versions[len(fn.Versions)-1].LLM2
176
llm := fn.Versions[len(fn.Versions)-1].LLM2
181
switch {
177
switch {
182
case strings.HasPrefix(mimeType, "image/"):
178
case strings.HasPrefix(mimeType, "image/"):
183
if !llm.CanSeeImages {
179
if !llm.CanSeeImages {
184
return fmt.Errorf(
180
return fmt.Errorf(
185
"model %q does not accept images "+
181
"model %q does not accept images "+
186
"(can_see_images=false)", modelName)
182
"(can_see_images=false)", modelName)
187
}
183
}
188
case mimeType == "application/pdf":
184
case mimeType == "application/pdf":
189
if !llm.CanSeePDFs {
185
if !llm.CanSeePDFs {
190
return fmt.Errorf(
186
return fmt.Errorf(
191
"model %q does not accept PDFs "+
187
"model %q does not accept PDFs "+
192
"(can_see_pdfs=false)", modelName)
188
"(can_see_pdfs=false)", modelName)
193
}
189
}
194
}
190
}
195
return nil
191
return nil
196
}
192
}
a/pr.go
b/pr.go
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 "os/exec"
6
import "os/exec"
7
import "strconv"
7
import "strconv"
8
import "text/tabwriter"
8
import "text/tabwriter"
9
import "time"
9
import "time"
10
10
11
import "oscarkilo.com/okg/klee"
11
import "oscarkilo.com/okg/klee"
12
12
13
func runPR(args []string) error {
13
func runPR(cfg *Config, args []string) error {
14
if len(args) == 0 {
14
if len(args) == 0 {
15
return fmt.Errorf(
15
return fmt.Errorf(
16
"usage: okg pr <list|create|view|diff" +
16
"usage: okg pr <list|create|view|diff" +
17
"|comment|merge|close|reopen>")
17
"|comment|merge|close|reopen>")
18
}
18
}
19
switch args[0] {
19
switch args[0] {
20
case "list":
20
case "list":
21
return runPRList(args[1:])
21
return runPRList(cfg, args[1:])
22
case "create":
22
case "create":
23
return runPRCreate(args[1:])
23
return runPRCreate(cfg, args[1:])
24
case "view":
24
case "view":
25
return runPRView(args[1:])
25
return runPRView(cfg, args[1:])
26
case "diff":
26
case "diff":
27
return runPRDiff(args[1:])
27
return runPRDiff(cfg, args[1:])
28
case "comment":
28
case "comment":
29
return runPRComment(args[1:])
29
return runPRComment(cfg, args[1:])
30
case "merge":
30
case "merge":
31
return runPRMerge(args[1:])
31
return runPRMerge(cfg, args[1:])
32
case "close":
32
case "close":
33
return runPRClose(args[1:])
33
return runPRClose(cfg, args[1:])
34
case "reopen":
34
case "reopen":
35
return runPRReopen(args[1:])
35
return runPRReopen(cfg, args[1:])
36
default:
36
default:
37
return fmt.Errorf(
37
return fmt.Errorf(
38
"unknown pr command: %s", args[0])
38
"unknown pr command: %s", args[0])
39
}
39
}
40
}
40
}
41
41
42
// parseFlags extracts --repo and --json from args,
42
// parseFlags extracts --repo and --json from args,
43
// returns remaining positional args.
43
// returns remaining positional args.
44
type prFlags struct {
44
type prFlags struct {
45
repo string
45
repo string
46
asJSON bool
46
asJSON bool
47
}
47
}
48
48
49
func parsePRFlags(args []string) (
49
func parsePRFlags(args []string) (
50
*prFlags, []string, error,
50
*prFlags, []string, error,
51
) {
51
) {
52
f := &prFlags{}
52
f := &prFlags{}
53
var rest []string
53
var rest []string
54
for i := 0; i < len(args); i++ {
54
for i := 0; i < len(args); i++ {
55
switch args[i] {
55
switch args[i] {
56
case "--repo":
56
case "--repo":
57
i++
57
i++
58
if i >= len(args) {
58
if i >= len(args) {
59
return nil, nil, fmt.Errorf(
59
return nil, nil, fmt.Errorf(
60
"--repo requires a value")
60
"--repo requires a value")
61
}
61
}
62
f.repo = args[i]
62
f.repo = args[i]
63
case "--json":
63
case "--json":
64
f.asJSON = true
64
f.asJSON = true
65
default:
65
default:
66
rest = append(rest, args[i])
66
rest = append(rest, args[i])
67
}
67
}
68
}
68
}
69
return f, rest, nil
69
return f, rest, nil
70
}
70
}
71
71
72
func setupClient(
72
func setupClient(
73
flag_repo string,
73
cfg *Config, flag_repo string,
74
) (*klee.Client, string, error) {
74
) (*klee.Client, string, error) {
75
cfg, err := loadConfig()
76
if err != nil {
77
return nil, "", err
78
}
79
repo, err := resolveRepo(flag_repo)
75
repo, err := resolveRepo(flag_repo)
80
if err != nil {
76
if err != nil {
81
return nil, "", err
77
return nil, "", err
82
}
78
}
83
return newKleeClient(cfg), repo, nil
79
return newKleeClient(cfg), repo, nil
84
}
80
}
85
81
86
func outputJSON(v interface{}) error {
82
func outputJSON(v interface{}) error {
87
enc := json.NewEncoder(os.Stdout)
83
enc := json.NewEncoder(os.Stdout)
88
enc.SetIndent("", " ")
84
enc.SetIndent("", " ")
89
return enc.Encode(v)
85
return enc.Encode(v)
90
}
86
}
91
87
92
func age(t time.Time) string {
88
func age(t time.Time) string {
93
d := time.Since(t)
89
d := time.Since(t)
94
switch {
90
switch {
95
case d < time.Minute:
91
case d < time.Minute:
96
return "just now"
92
return "just now"
97
case d < time.Hour:
93
case d < time.Hour:
98
return fmt.Sprintf("%dm", int(d.Minutes()))
94
return fmt.Sprintf("%dm", int(d.Minutes()))
99
case d < 24*time.Hour:
95
case d < 24*time.Hour:
100
return fmt.Sprintf("%dh", int(d.Hours()))
96
return fmt.Sprintf("%dh", int(d.Hours()))
101
default:
97
default:
102
return fmt.Sprintf(
98
return fmt.Sprintf(
103
"%dd", int(d.Hours()/24))
99
"%dd", int(d.Hours()/24))
104
}
100
}
105
}
101
}
106
102
107
// --- pr list ---
103
// --- pr list ---
108
104
109
func runPRList(args []string) error {
105
func runPRList(cfg *Config, args []string) error {
110
f, rest, err := parsePRFlags(args)
106
f, rest, err := parsePRFlags(args)
111
if err != nil {
107
if err != nil {
112
return err
108
return err
113
}
109
}
114
110
115
state := "open"
111
state := "open"
116
for i := 0; i < len(rest); i++ {
112
for i := 0; i < len(rest); i++ {
117
if rest[i] == "--state" {
113
if rest[i] == "--state" {
118
i++
114
i++
119
if i >= len(rest) {
115
if i >= len(rest) {
120
return fmt.Errorf(
116
return fmt.Errorf(
121
"--state requires a value")
117
"--state requires a value")
122
}
118
}
123
state = rest[i]
119
state = rest[i]
124
}
120
}
125
}
121
}
126
122
127
cl, repo, err := setupClient(f.repo)
123
cl, repo, err := setupClient(cfg, f.repo)
128
if err != nil {
124
if err != nil {
129
return err
125
return err
130
}
126
}
131
127
132
prs, err := cl.ListPRs(repo, state)
128
prs, err := cl.ListPRs(repo, state)
133
if err != nil {
129
if err != nil {
134
return err
130
return err
135
}
131
}
136
132
137
if f.asJSON {
133
if f.asJSON {
138
return outputJSON(prs)
134
return outputJSON(prs)
139
}
135
}
140
136
141
tw := tabwriter.NewWriter(
137
tw := tabwriter.NewWriter(
142
os.Stdout, 0, 4, 2, ' ', 0)
138
os.Stdout, 0, 4, 2, ' ', 0)
143
fmt.Fprintln(tw,
139
fmt.Fprintln(tw,
144
"#\tTITLE\tAUTHOR\tHEAD\tBASE\tAGE")
140
"#\tTITLE\tAUTHOR\tHEAD\tBASE\tAGE")
145
for _, p := range prs {
141
for _, p := range prs {
146
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n",
142
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n",
147
p.Number, p.Title, p.Author,
143
p.Number, p.Title, p.Author,
148
p.Head, p.Base, age(p.Created))
144
p.Head, p.Base, age(p.Created))
149
}
145
}
150
return tw.Flush()
146
return tw.Flush()
151
}
147
}
152
148
153
// --- pr view ---
149
// --- pr view ---
154
150
155
func runPRView(args []string) error {
151
func runPRView(cfg *Config, args []string) error {
156
f, rest, err := parsePRFlags(args)
152
f, rest, err := parsePRFlags(args)
157
if err != nil {
153
if err != nil {
158
return err
154
return err
159
}
155
}
160
if len(rest) < 1 {
156
if len(rest) < 1 {
161
return fmt.Errorf(
157
return fmt.Errorf(
162
"usage: okg pr view NUMBER")
158
"usage: okg pr view NUMBER")
163
}
159
}
164
num, err := strconv.Atoi(rest[0])
160
num, err := strconv.Atoi(rest[0])
165
if err != nil {
161
if err != nil {
166
return fmt.Errorf(
162
return fmt.Errorf(
167
"invalid PR number: %s", rest[0])
163
"invalid PR number: %s", rest[0])
168
}
164
}
169
165
170
cl, repo, err := setupClient(f.repo)
166
cl, repo, err := setupClient(cfg, f.repo)
171
if err != nil {
167
if err != nil {
172
return err
168
return err
173
}
169
}
174
170
175
p, err := cl.GetPR(repo, num)
171
p, err := cl.GetPR(repo, num)
176
if err != nil {
172
if err != nil {
177
return err
173
return err
178
}
174
}
179
175
180
comments, err := cl.GetComments(repo, num)
176
comments, err := cl.GetComments(repo, num)
181
if err != nil {
177
if err != nil {
182
return err
178
return err
183
}
179
}
184
180
185
if f.asJSON {
181
if f.asJSON {
186
return outputJSON(map[string]interface{}{
182
return outputJSON(map[string]interface{}{
187
"pr": p,
183
"pr": p,
188
"comments": comments,
184
"comments": comments,
189
})
185
})
190
}
186
}
191
187
192
// Header.
188
// Header.
193
state_str := p.State
189
state_str := p.State
194
if p.Merged {
190
if p.Merged {
195
state_str = "merged"
191
state_str = "merged"
196
}
192
}
197
fmt.Printf(
193
fmt.Printf(
198
"#%d %s (%s)\n", p.Number, p.Title, state_str)
194
"#%d %s (%s)\n", p.Number, p.Title, state_str)
199
fmt.Printf(" %s wants to merge %s into %s\n",
195
fmt.Printf(" %s wants to merge %s into %s\n",
200
p.Author, p.Head, p.Base)
196
p.Author, p.Head, p.Base)
201
fmt.Printf(" Created %s\n",
197
fmt.Printf(" Created %s\n",
202
p.Created.Format(time.RFC3339))
198
p.Created.Format(time.RFC3339))
203
if p.Body != "" {
199
if p.Body != "" {
204
fmt.Printf("\n%s\n", p.Body)
200
fmt.Printf("\n%s\n", p.Body)
205
}
201
}
206
202
207
// Comments.
203
// Comments.
208
if len(comments) > 0 {
204
if len(comments) > 0 {
209
fmt.Printf("\n--- Comments ---\n")
205
fmt.Printf("\n--- Comments ---\n")
210
for _, c := range comments {
206
for _, c := range comments {
211
verdict := ""
207
verdict := ""
212
if c.Verdict != "" {
208
if c.Verdict != "" {
213
verdict = fmt.Sprintf(
209
verdict = fmt.Sprintf(
214
" [%s]", c.Verdict)
210
" [%s]", c.Verdict)
215
}
211
}
216
fmt.Printf("\n@%s%s (%s):\n%s\n",
212
fmt.Printf("\n@%s%s (%s):\n%s\n",
217
c.Author, verdict,
213
c.Author, verdict,
218
c.Created.Format(time.RFC3339), c.Body)
214
c.Created.Format(time.RFC3339), c.Body)
219
}
215
}
220
}
216
}
221
return nil
217
return nil
222
}
218
}
223
219
224
// --- pr diff ---
220
// --- pr diff ---
225
221
226
func runPRDiff(args []string) error {
222
func runPRDiff(cfg *Config, args []string) error {
227
f, rest, err := parsePRFlags(args)
223
f, rest, err := parsePRFlags(args)
228
if err != nil {
224
if err != nil {
229
return err
225
return err
230
}
226
}
231
if len(rest) < 1 {
227
if len(rest) < 1 {
232
return fmt.Errorf(
228
return fmt.Errorf(
233
"usage: okg pr diff NUMBER")
229
"usage: okg pr diff NUMBER")
234
}
230
}
235
num, err := strconv.Atoi(rest[0])
231
num, err := strconv.Atoi(rest[0])
236
if err != nil {
232
if err != nil {
237
return fmt.Errorf(
233
return fmt.Errorf(
238
"invalid PR number: %s", rest[0])
234
"invalid PR number: %s", rest[0])
239
}
235
}
240
236
241
cl, repo, err := setupClient(f.repo)
237
cl, repo, err := setupClient(cfg, f.repo)
242
if err != nil {
238
if err != nil {
243
return err
239
return err
244
}
240
}
245
241
246
p, err := cl.GetPR(repo, num)
242
p, err := cl.GetPR(repo, num)
247
if err != nil {
243
if err != nil {
248
return err
244
return err
249
}
245
}
250
246
251
// Run git diff locally.
247
// Run git diff locally.
252
cmd := exec.Command(
248
cmd := exec.Command(
253
"git", "diff", p.Base+"..."+p.Head)
249
"git", "diff", p.Base+"..."+p.Head)
254
cmd.Stdout = os.Stdout
250
cmd.Stdout = os.Stdout
255
cmd.Stderr = os.Stderr
251
cmd.Stderr = os.Stderr
256
return cmd.Run()
252
return cmd.Run()
257
}
253
}
258
254
259
// --- pr create ---
255
// --- pr create ---
260
256
261
func runPRCreate(args []string) error {
257
func runPRCreate(cfg *Config, args []string) error {
262
f, rest, err := parsePRFlags(args)
258
f, rest, err := parsePRFlags(args)
263
if err != nil {
259
if err != nil {
264
return err
260
return err
265
}
261
}
266
262
267
head := ""
263
head := ""
268
base := "master"
264
base := "master"
269
title := ""
265
title := ""
270
body := ""
266
body := ""
271
for i := 0; i < len(rest); i++ {
267
for i := 0; i < len(rest); i++ {
272
switch rest[i] {
268
switch rest[i] {
273
case "--head":
269
case "--head":
274
i++
270
i++
275
if i >= len(rest) {
271
if i >= len(rest) {
276
return fmt.Errorf(
272
return fmt.Errorf(
277
"--head requires a value")
273
"--head requires a value")
278
}
274
}
279
head = rest[i]
275
head = rest[i]
280
case "--base":
276
case "--base":
281
i++
277
i++
282
if i >= len(rest) {
278
if i >= len(rest) {
283
return fmt.Errorf(
279
return fmt.Errorf(
284
"--base requires a value")
280
"--base requires a value")
285
}
281
}
286
base = rest[i]
282
base = rest[i]
287
case "--title":
283
case "--title":
288
i++
284
i++
289
if i >= len(rest) {
285
if i >= len(rest) {
290
return fmt.Errorf(
286
return fmt.Errorf(
291
"--title requires a value")
287
"--title requires a value")
292
}
288
}
293
title = rest[i]
289
title = rest[i]
294
case "--body":
290
case "--body":
295
i++
291
i++
296
if i >= len(rest) {
292
if i >= len(rest) {
297
return fmt.Errorf(
293
return fmt.Errorf(
298
"--body requires a value")
294
"--body requires a value")
299
}
295
}
300
body = rest[i]
296
body = rest[i]
301
default:
297
default:
302
return fmt.Errorf(
298
return fmt.Errorf(
303
"unknown flag: %s", rest[i])
299
"unknown flag: %s", rest[i])
304
}
300
}
305
}
301
}
306
302
307
if head == "" {
303
if head == "" {
308
return fmt.Errorf("--head is required")
304
return fmt.Errorf("--head is required")
309
}
305
}
310
if title == "" {
306
if title == "" {
311
return fmt.Errorf("--title is required")
307
return fmt.Errorf("--title is required")
312
}
308
}
313
309
314
cl, repo, err := setupClient(f.repo)
310
cl, repo, err := setupClient(cfg, f.repo)
315
if err != nil {
311
if err != nil {
316
return err
312
return err
317
}
313
}
318
314
319
p, err := cl.CreatePR(repo,
315
p, err := cl.CreatePR(repo,
320
klee.CreatePRRequest{
316
klee.CreatePRRequest{
321
Head: head,
317
Head: head,
322
Base: base,
318
Base: base,
323
Title: title,
319
Title: title,
324
Body: body,
320
Body: body,
325
})
321
})
326
if err != nil {
322
if err != nil {
327
return err
323
return err
328
}
324
}
329
325
330
if f.asJSON {
326
if f.asJSON {
331
return outputJSON(p)
327
return outputJSON(p)
332
}
328
}
333
329
334
fmt.Printf(
330
fmt.Printf(
335
"Created PR #%d: %s\n", p.Number, p.Title)
331
"Created PR #%d: %s\n", p.Number, p.Title)
336
fmt.Printf(" %s -> %s\n", p.Head, p.Base)
332
fmt.Printf(" %s -> %s\n", p.Head, p.Base)
337
return nil
333
return nil
338
}
334
}
339
335
340
// --- pr comment ---
336
// --- pr comment ---
341
337
342
func runPRComment(args []string) error {
338
func runPRComment(cfg *Config, args []string) error {
343
f, rest, err := parsePRFlags(args)
339
f, rest, err := parsePRFlags(args)
344
if err != nil {
340
if err != nil {
345
return err
341
return err
346
}
342
}
347
if len(rest) < 1 {
343
if len(rest) < 1 {
348
return fmt.Errorf(
344
return fmt.Errorf(
349
"usage: okg pr comment NUMBER --body BODY")
345
"usage: okg pr comment NUMBER --body BODY")
350
}
346
}
351
num, err := strconv.Atoi(rest[0])
347
num, err := strconv.Atoi(rest[0])
352
if err != nil {
348
if err != nil {
353
return fmt.Errorf(
349
return fmt.Errorf(
354
"invalid PR number: %s", rest[0])
350
"invalid PR number: %s", rest[0])
355
}
351
}
356
rest = rest[1:]
352
rest = rest[1:]
357
353
358
body := ""
354
body := ""
359
verdict := ""
355
verdict := ""
360
for i := 0; i < len(rest); i++ {
356
for i := 0; i < len(rest); i++ {
361
switch rest[i] {
357
switch rest[i] {
362
case "--body":
358
case "--body":
363
i++
359
i++
364
if i >= len(rest) {
360
if i >= len(rest) {
365
return fmt.Errorf(
361
return fmt.Errorf(
366
"--body requires a value")
362
"--body requires a value")
367
}
363
}
368
body = rest[i]
364
body = rest[i]
369
case "--approve":
365
case "--approve":
370
verdict = "approve"
366
verdict = "approve"
371
case "--request-changes":
367
case "--request-changes":
372
verdict = "request_changes"
368
verdict = "request_changes"
373
default:
369
default:
374
return fmt.Errorf(
370
return fmt.Errorf(
375
"unknown flag: %s", rest[i])
371
"unknown flag: %s", rest[i])
376
}
372
}
377
}
373
}
378
if body == "" {
374
if body == "" {
379
return fmt.Errorf("--body is required")
375
return fmt.Errorf("--body is required")
380
}
376
}
381
377
382
cl, repo, err := setupClient(f.repo)
378
cl, repo, err := setupClient(cfg, f.repo)
383
if err != nil {
379
if err != nil {
384
return err
380
return err
385
}
381
}
386
382
387
c, err := cl.AddComment(repo, num,
383
c, err := cl.AddComment(repo, num,
388
klee.AddCommentRequest{
384
klee.AddCommentRequest{
389
Body: body,
385
Body: body,
390
Verdict: verdict,
386
Verdict: verdict,
391
})
387
})
392
if err != nil {
388
if err != nil {
393
return err
389
return err
394
}
390
}
395
391
396
if f.asJSON {
392
if f.asJSON {
397
return outputJSON(c)
393
return outputJSON(c)
398
}
394
}
399
395
400
fmt.Printf("Comment #%d by @%s", c.ID, c.Author)
396
fmt.Printf("Comment #%d by @%s", c.ID, c.Author)
401
if c.Verdict != "" {
397
if c.Verdict != "" {
402
fmt.Printf(" [%s]", c.Verdict)
398
fmt.Printf(" [%s]", c.Verdict)
403
}
399
}
404
fmt.Printf(":\n%s\n", c.Body)
400
fmt.Printf(":\n%s\n", c.Body)
405
return nil
401
return nil
406
}
402
}
407
403
408
// --- pr merge ---
404
// --- pr merge ---
409
405
410
func runPRMerge(args []string) error {
406
func runPRMerge(cfg *Config, args []string) error {
411
f, rest, err := parsePRFlags(args)
407
f, rest, err := parsePRFlags(args)
412
if err != nil {
408
if err != nil {
413
return err
409
return err
414
}
410
}
415
if len(rest) < 1 {
411
if len(rest) < 1 {
416
return fmt.Errorf(
412
return fmt.Errorf(
417
"usage: okg pr merge NUMBER")
413
"usage: okg pr merge NUMBER")
418
}
414
}
419
num, err := strconv.Atoi(rest[0])
415
num, err := strconv.Atoi(rest[0])
420
if err != nil {
416
if err != nil {
421
return fmt.Errorf(
417
return fmt.Errorf(
422
"invalid PR number: %s", rest[0])
418
"invalid PR number: %s", rest[0])
423
}
419
}
424
420
425
cl, repo, err := setupClient(f.repo)
421
cl, repo, err := setupClient(cfg, f.repo)
426
if err != nil {
422
if err != nil {
427
return err
423
return err
428
}
424
}
429
425
430
p, err := cl.MergePR(repo, num)
426
p, err := cl.MergePR(repo, num)
431
if err != nil {
427
if err != nil {
432
return err
428
return err
433
}
429
}
434
430
435
if f.asJSON {
431
if f.asJSON {
436
return outputJSON(p)
432
return outputJSON(p)
437
}
433
}
438
434
439
fmt.Printf(
435
fmt.Printf(
440
"PR #%d merged by @%s\n", p.Number, p.MergedBy)
436
"PR #%d merged by @%s\n", p.Number, p.MergedBy)
441
return nil
437
return nil
442
}
438
}
443
439
444
// --- pr close ---
440
// --- pr close ---
445
441
446
func runPRClose(args []string) error {
442
func runPRClose(cfg *Config, args []string) error {
447
return runPRStateChange("closed", args)
443
return runPRStateChange(cfg, "closed", args)
448
}
444
}
449
445
450
// --- pr reopen ---
446
// --- pr reopen ---
451
447
452
func runPRReopen(args []string) error {
448
func runPRReopen(cfg *Config, args []string) error {
453
return runPRStateChange("open", args)
449
return runPRStateChange(cfg, "open", args)
454
}
450
}
455
451
456
func runPRStateChange(
452
func runPRStateChange(
457
new_state string, args []string,
453
cfg *Config, new_state string, args []string,
458
) error {
454
) error {
459
f, rest, err := parsePRFlags(args)
455
f, rest, err := parsePRFlags(args)
460
if err != nil {
456
if err != nil {
461
return err
457
return err
462
}
458
}
463
if len(rest) < 1 {
459
if len(rest) < 1 {
464
return fmt.Errorf(
460
return fmt.Errorf(
465
"usage: okg pr close|reopen NUMBER")
461
"usage: okg pr close|reopen NUMBER")
466
}
462
}
467
num, err := strconv.Atoi(rest[0])
463
num, err := strconv.Atoi(rest[0])
468
if err != nil {
464
if err != nil {
469
return fmt.Errorf(
465
return fmt.Errorf(
470
"invalid PR number: %s", rest[0])
466
"invalid PR number: %s", rest[0])
471
}
467
}
472
468
473
cl, repo, err := setupClient(f.repo)
469
cl, repo, err := setupClient(cfg, f.repo)
474
if err != nil {
470
if err != nil {
475
return err
471
return err
476
}
472
}
477
473
478
p, err := cl.SetPRState(repo, num, new_state)
474
p, err := cl.SetPRState(repo, num, new_state)
479
if err != nil {
475
if err != nil {
480
return err
476
return err
481
}
477
}
482
478
483
if f.asJSON {
479
if f.asJSON {
484
return outputJSON(p)
480
return outputJSON(p)
485
}
481
}
486
482
487
fmt.Printf(
483
fmt.Printf(
488
"PR #%d is now %s\n", p.Number, p.State)
484
"PR #%d is now %s\n", p.Number, p.State)
489
return nil
485
return nil
490
}
486
}
a/repo.go
b/repo.go
1
package main
1
package main
2
2
3
import "fmt"
3
import "fmt"
4
import "os"
4
import "os"
5
import "strings"
5
import "strings"
6
import "text/tabwriter"
6
import "text/tabwriter"
7
7
8
import "oscarkilo.com/okg/klee"
8
import "oscarkilo.com/okg/klee"
9
9
10
func runRepo(args []string) error {
10
func runRepo(cfg *Config, args []string) error {
11
if len(args) == 0 {
11
if len(args) == 0 {
12
return fmt.Errorf(
12
return fmt.Errorf(
13
"usage: okg repo <list|create>")
13
"usage: okg repo <list|create>")
14
}
14
}
15
switch args[0] {
15
switch args[0] {
16
case "list":
16
case "list":
17
return runRepoList(args[1:])
17
return runRepoList(cfg, args[1:])
18
case "create":
18
case "create":
19
return runRepoCreate(args[1:])
19
return runRepoCreate(cfg, args[1:])
20
default:
20
default:
21
return fmt.Errorf(
21
return fmt.Errorf(
22
"unknown repo command: %s", args[0])
22
"unknown repo command: %s", args[0])
23
}
23
}
24
}
24
}
25
25
26
func runRepoCreate(args []string) error {
26
func runRepoCreate(cfg *Config, args []string) error {
27
reader := ""
27
reader := ""
28
name := ""
28
name := ""
29
for i := 0; i < len(args); i++ {
29
for i := 0; i < len(args); i++ {
30
switch args[i] {
30
switch args[i] {
31
case "--reader":
31
case "--reader":
32
i++
32
i++
33
if i >= len(args) {
33
if i >= len(args) {
34
return fmt.Errorf(
34
return fmt.Errorf(
35
"--reader requires a value")
35
"--reader requires a value")
36
}
36
}
37
reader = args[i]
37
reader = args[i]
38
default:
38
default:
39
if strings.HasPrefix(args[i], "-") {
39
if strings.HasPrefix(args[i], "-") {
40
return fmt.Errorf(
40
return fmt.Errorf(
41
"unknown flag: %s", args[i])
41
"unknown flag: %s", args[i])
42
}
42
}
43
if name != "" {
43
if name != "" {
44
return fmt.Errorf(
44
return fmt.Errorf(
45
"unexpected argument: %s", args[i])
45
"unexpected argument: %s", args[i])
46
}
46
}
47
name = args[i]
47
name = args[i]
48
}
48
}
49
}
49
}
50
if name == "" {
50
if name == "" {
51
return fmt.Errorf(
51
return fmt.Errorf(
52
"usage: okg repo create NAME " +
52
"usage: okg repo create NAME " +
53
"[--reader USER]")
53
"[--reader USER]")
54
}
54
}
55
55
56
cfg, err := loadConfig()
57
if err != nil {
58
return err
59
}
60
cl := newKleeClient(cfg)
56
cl := newKleeClient(cfg)
61
57
62
err = cl.CreateRepo(klee.CreateRepoRequest{
58
err := cl.CreateRepo(klee.CreateRepoRequest{
63
RepoName: name,
59
RepoName: name,
64
ReaderUsername: reader,
60
ReaderUsername: reader,
65
})
61
})
66
if err != nil {
62
if err != nil {
67
return err
63
return err
68
}
64
}
69
fmt.Printf("Created repo %s\n", name)
65
fmt.Printf("Created repo %s\n", name)
70
if reader != "" {
66
if reader != "" {
71
fmt.Printf(" reader: %s\n", reader)
67
fmt.Printf(" reader: %s\n", reader)
72
}
68
}
73
fmt.Printf(" url: %s/%s\n", cfg.CodeHost, name)
69
fmt.Printf(" url: %s/%s\n", cfg.CodeHost, name)
74
return nil
70
return nil
75
}
71
}
76
72
77
func runRepoList(args []string) error {
73
func runRepoList(cfg *Config, args []string) error {
78
as_json := false
74
as_json := false
79
for _, a := range args {
75
for _, a := range args {
80
if a == "--json" {
76
if a == "--json" {
81
as_json = true
77
as_json = true
82
}
78
}
83
}
79
}
84
80
85
cfg, err := loadConfig()
86
if err != nil {
87
return err
88
}
89
cl := newKleeClient(cfg)
81
cl := newKleeClient(cfg)
90
82
91
res, err := cl.ListRepos()
83
res, err := cl.ListRepos()
92
if err != nil {
84
if err != nil {
93
return err
85
return err
94
}
86
}
95
87
96
if as_json {
88
if as_json {
97
return outputJSON(res)
89
return outputJSON(res)
98
}
90
}
99
91
100
tw := tabwriter.NewWriter(
92
tw := tabwriter.NewWriter(
101
os.Stdout, 0, 4, 2, ' ', 0)
93
os.Stdout, 0, 4, 2, ' ', 0)
102
fmt.Fprintln(tw, "NAME\tOWNER\tREADER")
94
fmt.Fprintln(tw, "NAME\tOWNER\tREADER")
103
for _, r := range res.Repos {
95
for _, r := range res.Repos {
104
owner := ""
96
owner := ""
105
reader := ""
97
reader := ""
106
if r.Authz != nil {
98
if r.Authz != nil {
107
owner = r.Authz.OwnerUsername
99
owner = r.Authz.OwnerUsername
108
reader = r.Authz.ReaderUsername
100
reader = r.Authz.ReaderUsername
109
}
101
}
110
fmt.Fprintf(tw, "%s\t%s\t%s\n",
102
fmt.Fprintf(tw, "%s\t%s\t%s\n",
111
r.Name, owner, reader)
103
r.Name, owner, reader)
112
}
104
}
113
return tw.Flush()
105
return tw.Flush()
114
}
106
}