From 3c62613c307b2dd5d25826cf0206217efea7abdc Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:36:32 -0700 Subject: [PATCH] clients/go: initial Go SDK for clawdforge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idiomatic Go client wrapping the FastAPI surface in server.py — Healthz, Run, UploadFile/UploadReader, and admin token CRUD. stdlib net/http only, context-first signatures, typed errors (ErrAuth sentinel, RunFailure for /run 502s, APIError for other 4xx/5xx, TransportError for network/EOF). RunResult.Result is captured as json.RawMessage and materialized via .AsJSON(out) / .AsText() because claude returns either parsed JSON or plain text depending on prompt. UploadFile streams via io.Pipe + multipart without buffering the file in memory. Module: gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go Includes cmd/cf-cli demo binary and httptest-based test suite (13 tests). --- clients/go/README.md | 263 ++++++++++++++++++++++++ clients/go/client.go | 320 +++++++++++++++++++++++++++++ clients/go/client_test.go | 369 ++++++++++++++++++++++++++++++++++ clients/go/cmd/cf-cli/main.go | 162 +++++++++++++++ clients/go/errors.go | 62 ++++++ clients/go/go.mod | 3 + clients/go/models.go | 95 +++++++++ 7 files changed, 1274 insertions(+) create mode 100644 clients/go/README.md create mode 100644 clients/go/client.go create mode 100644 clients/go/client_test.go create mode 100644 clients/go/cmd/cf-cli/main.go create mode 100644 clients/go/errors.go create mode 100644 clients/go/go.mod create mode 100644 clients/go/models.go diff --git a/clients/go/README.md b/clients/go/README.md new file mode 100644 index 0000000..0117dcc --- /dev/null +++ b/clients/go/README.md @@ -0,0 +1,263 @@ +# clawdforge Go SDK + +Idiomatic Go client for the LAN-only `clawdforge` HTTP service. + +## Install + +This module is hosted on Sulkta's internal Gitea, **not GitHub**. To `go get` +it you have two reasonable options. + +### Option 1 — replace directive (vendored / sibling checkout) + +In your consumer's `go.mod`: + +``` +require gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go v0.0.0 +replace gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go => ../clawdforge/clients/go +``` + +### Option 2 — `GOPRIVATE` + git insteadOf + +``` +export GOPRIVATE=gitea.sulkta.com/* +git config --global url."http://192.168.0.5:3001/".insteadOf "https://gitea.sulkta.com/" +go get gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go@latest +``` + +Either way, the module path is `gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go` +and the recommended import alias is `cf`. + +## Quickstart + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + cf "gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go" +) + +func main() { + client := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN")) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + h, err := client.Healthz(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("claude:", h.ClaudeVersion) + + res, err := client.Run(ctx, cf.RunRequest{ + Prompt: `Reply with JSON: {"hello": "world"}`, + Model: "sonnet", + }) + if err != nil { + log.Fatal(err) + } + + var data map[string]string + if err := res.AsJSON(&data); err != nil { + log.Fatal(err) + } + fmt.Println(data["hello"]) // "world" +} +``` + +## API surface + +The SDK mirrors the FastAPI surface in `clawdforge/server.py` 1:1. + +### `New(baseURL, token string) *Client` + +Constructs a Client with a default `*http.Client` (no wall-clock timeout — +control timing via context). Trailing slash on `baseURL` is trimmed. + +### `NewWithClient(baseURL, token string, hc *http.Client) *Client` + +Same as `New` but lets you pass a tuned `*http.Client` (custom transport, +proxy, TLS config, connection pool, etc.). + +### `(*Client).Healthz(ctx) (*Healthz, error)` + +`GET /healthz` — liveness + `claude --version` smoke. Returns +`Healthz{OK, ClaudePresent, ClaudeVersion}`. + +```go +h, err := client.Healthz(ctx) +``` + +### `(*Client).Run(ctx, RunRequest) (*RunResult, error)` + +`POST /run`. Sends a prompt, returns whatever `claude -p --output-format json` +produced. The `Result` field is `json.RawMessage` because claude may return +parsed JSON or plain text. + +```go +res, err := client.Run(ctx, cf.RunRequest{ + Prompt: "Sterilize: 'about 2 cups cooked white rice'", + Model: "sonnet", + System: "Reply ONLY with JSON.", + Files: []string{"ff_abc123"}, + TimeoutSecs: 30, +}) +``` + +#### `(*RunResult).AsJSON(out any) error` + +Unmarshal `Result` into a typed value: + +```go +var ing struct { + Qty float64 `json:"qty"` + Unit string `json:"unit"` + Food string `json:"food"` +} +if err := res.AsJSON(&ing); err != nil { + log.Fatal(err) +} +``` + +#### `(*RunResult).AsText() (string, error)` + +Materialize `Result` as a string. Works for both quoted-string results +(`"hello"`) and structured JSON (returns the raw JSON encoding). + +### `(*Client).UploadFile(ctx, path string, ttlSecs int) (*FileToken, error)` + +`POST /files` — multipart upload, streamed via `io.Pipe` so large files don't +buffer in memory. `ttlSecs == 0` uses the server default (3600); valid range +is 60..86400. Returns `FileToken{FileToken, TTLSecs, Size}`. Pass +`ft.FileToken` in `RunRequest.Files`. + +```go +ft, err := client.UploadFile(ctx, "/srv/recipes/recipe.png", 3600) +res, err := client.Run(ctx, cf.RunRequest{ + Prompt: "Extract recipe data", + Files: []string{ft.FileToken}, +}) +``` + +### `(*Client).UploadReader(ctx, filename string, r io.Reader, ttlSecs int) (*FileToken, error)` + +Same as `UploadFile` but takes any `io.Reader` and a filename — useful for +in-memory blobs or piped data. + +### Admin (require admin bootstrap token) + +```go +admin := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_ADMIN_TOKEN")) + +tok, err := admin.CreateToken(ctx, cf.CreateTokenRequest{ + Name: "petalparse", + IPCidrs: []string{"172.24.0.0/16"}, +}) +// tok.Token is the plaintext — store it now, you can't see it again. + +list, err := admin.ListTokens(ctx) + +err = admin.RevokeToken(ctx, "petalparse") +``` + +## Error handling + +Three typed error families. Use `errors.Is` / `errors.As`: + +```go +res, err := client.Run(ctx, cf.RunRequest{Prompt: "x"}) +if err != nil { + switch { + case errors.Is(err, cf.ErrAuth): + // 401/403 — bad bearer or IP not allowlisted + case errors.Is(err, context.DeadlineExceeded), + errors.Is(err, context.Canceled): + // caller's context died + + default: + var rf *cf.RunFailure + var apiErr *cf.APIError + var transportErr *cf.TransportError + + switch { + case errors.As(err, &rf): + // 502 — clawdforge took the request but `claude` failed + // (timeout, non-zero exit). rf.StopReason in {"timeout","error"}. + log.Printf("claude failed after %dms: %s", rf.DurationMS, rf.Err) + case errors.As(err, &apiErr): + // generic 4xx/5xx — apiErr.StatusCode + apiErr.Body + case errors.As(err, &transportErr): + // network/transport — DNS, connect refused, TLS, EOF, ... + default: + log.Printf("unexpected: %v", err) + } + } +} +``` + +| Error | When | Detect with | +|---|---|---| +| `cf.ErrAuth` | HTTP 401, 403 | `errors.Is(err, cf.ErrAuth)` | +| `*cf.RunFailure` | HTTP 502 from `/run` | `errors.As(err, &rf)` | +| `*cf.APIError` | other HTTP >= 400 | `errors.As(err, &apiErr)` | +| `*cf.TransportError` | DNS / connect / TLS / EOF / context | `errors.As(err, &te)` | + +## Context discipline + +Every public method takes `context.Context` as the first argument. The SDK's +underlying `*http.Client` does **not** set a wall-clock timeout — long +`claude` runs (curate jobs, big recipe corpora) can legitimately take +multiple minutes. Set timeouts on the caller side: + +```go +ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs+30)*time.Second) +defer cancel() +``` + +The `+30s margin` over `timeout_secs` mirrors the Python `Forge` client so +the HTTP call doesn't bail while clawdforge is still working. + +## CLI demo + +`cmd/cf-cli` is a small demo binary: + +``` +go install gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go/cmd/cf-cli@latest + +export CLAWDFORGE_URL=http://192.168.0.5:8800 +export CLAWDFORGE_TOKEN=cf_... + +cf-cli health +cf-cli run 'Reply with JSON: {"hello":"world"}' sonnet 30 +cf-cli upload /path/to/file.png 3600 +cf-cli tokens +``` + +## Tests + +``` +cd clients/go +go test ./... +``` + +All tests use `httptest.NewServer` — no network required. + +## Ambiguities resolved (mirrored from `server.py`) + +- `RunRequest.timeout_secs` is `omitempty` — server defaults to its + `default_timeout_secs` config when absent. Server validates `5 <= n <= 600`. +- `result` field is captured as `json.RawMessage` because the server + returns either parsed JSON or a plain string depending on what + `claude -p` produced. +- `502` is the only "logical failure" status the SDK special-cases; any + other 4xx/5xx becomes a generic `*APIError`. +- `Healthz` does not require a bearer token (the server only enforces the + global IP allowlist on it). The SDK still sends the token if present — + the server ignores it. +- File uploads stream via `io.Pipe` + `mime/multipart` — neither the file + body nor the multipart envelope is buffered in memory. diff --git a/clients/go/client.go b/clients/go/client.go new file mode 100644 index 0000000..e8c235a --- /dev/null +++ b/clients/go/client.go @@ -0,0 +1,320 @@ +// Package clawdforge is the Go SDK for the LAN-only clawdforge HTTP service — +// a thin REST wrapper around `claude -p` subprocess invocations. +// +// Example: +// +// client := clawdforge.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN")) +// ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) +// defer cancel() +// res, err := client.Run(ctx, clawdforge.RunRequest{Prompt: "hi"}) +// +// All public methods take a context.Context as the first argument. +// Cancellation and per-request timeouts come from the caller's context; +// the underlying *http.Client is left without a wall-clock timeout so +// long claude runs aren't cut off prematurely. +package clawdforge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Client is an immutable handle to a clawdforge instance. Construct with +// New or NewWithClient. Methods are safe for concurrent use. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// New returns a Client configured against baseURL with the given bearer +// token and a default *http.Client (no wall-clock timeout — control +// timeouts via context.Context on each call). +// +// baseURL should NOT include a trailing slash; if one is present it is +// trimmed. +func New(baseURL, token string) *Client { + return NewWithClient(baseURL, token, &http.Client{}) +} + +// NewWithClient is like New but takes a caller-supplied *http.Client so +// transport, proxy, TLS, and connection-pool settings can be tuned. +func NewWithClient(baseURL, token string, hc *http.Client) *Client { + if hc == nil { + hc = &http.Client{} + } + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTPClient: hc, + } +} + +// ---------- public methods -------------------------------------------------- + +// Healthz issues GET /healthz. Does not require a bearer token, but the +// caller's IP must satisfy the global allowlist on the server. +func (c *Client) Healthz(ctx context.Context) (*Healthz, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil, "") + if err != nil { + return nil, err + } + var out Healthz + if err := c.do(req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Run issues POST /run. +// +// On HTTP 200 it returns a *RunResult. On HTTP 502 (clawdforge accepted +// the request but claude failed) it returns a *RunFailure error — use +// errors.As to extract it. Auth failures (401/403) return ErrAuth. +// Other 4xx/5xx return *APIError. +func (c *Client) Run(ctx context.Context, body RunRequest) (*RunResult, error) { + if body.Prompt == "" { + return nil, errors.New("clawdforge: Run: prompt is required") + } + buf, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("clawdforge: marshal RunRequest: %w", err) + } + req, err := c.newRequest(ctx, http.MethodPost, "/run", bytes.NewReader(buf), "application/json") + if err != nil { + return nil, err + } + var out RunResult + if err := c.do(req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UploadFile streams a file from disk to POST /files and returns the +// resulting *FileToken. The token can be passed in RunRequest.Files for +// subsequent /run calls. +// +// ttlSecs is clamped server-side to [60, 86400]; pass 0 to use the +// server default of 3600. +func (c *Client) UploadFile(ctx context.Context, path string, ttlSecs int) (*FileToken, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("clawdforge: open %s: %w", path, err) + } + defer f.Close() + return c.uploadReader(ctx, filepath.Base(path), f, ttlSecs) +} + +// UploadReader is like UploadFile but takes an io.Reader and a filename. +// Useful for in-memory uploads or piping from another source. +func (c *Client) UploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) { + return c.uploadReader(ctx, filename, r, ttlSecs) +} + +// CreateToken mints a new per-app token. The plaintext token is in the +// returned AppToken.Token and will not be retrievable again. Requires +// the admin bootstrap token. +func (c *Client) CreateToken(ctx context.Context, body CreateTokenRequest) (*AppToken, error) { + if body.Name == "" { + return nil, errors.New("clawdforge: CreateToken: name is required") + } + buf, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("clawdforge: marshal CreateTokenRequest: %w", err) + } + req, err := c.newRequest(ctx, http.MethodPost, "/admin/tokens", bytes.NewReader(buf), "application/json") + if err != nil { + return nil, err + } + var out AppToken + if err := c.do(req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTokens returns the configured app tokens (no plaintexts). Requires +// the admin bootstrap token. +func (c *Client) ListTokens(ctx context.Context) ([]AppToken, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/admin/tokens", nil, "") + if err != nil { + return nil, err + } + var out TokenList + if err := c.do(req, &out); err != nil { + return nil, err + } + return out.Tokens, nil +} + +// RevokeToken deletes the token with the given name. Requires the admin +// bootstrap token. +func (c *Client) RevokeToken(ctx context.Context, name string) error { + if name == "" { + return errors.New("clawdforge: RevokeToken: name is required") + } + req, err := c.newRequest(ctx, http.MethodDelete, "/admin/tokens/"+url.PathEscape(name), nil, "") + if err != nil { + return err + } + return c.do(req, nil) +} + +// ---------- internals ------------------------------------------------------- + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Request, error) { + u := c.BaseURL + path + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return nil, &TransportError{Op: "build-request", Err: err} + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + return req, nil +} + +// do executes req, decodes a JSON response into out (if non-nil), and +// translates any non-2xx status into the appropriate typed error. +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.HTTPClient.Do(req) + if err != nil { + // context cancellation / deadline exceeded show up here too; + // preserve them via Unwrap so errors.Is works. + return &TransportError{Op: req.Method + " " + req.URL.Path, Err: err} + } + defer resp.Body.Close() + + // Cap the body read at 8 MiB to keep error paths bounded; success + // payloads from /run can be larger so we don't cap on the happy path. + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return c.translateError(req, resp.StatusCode, bodyBytes) + } + + if out == nil { + // Drain so the connection can be reused. + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return &TransportError{Op: "decode " + req.URL.Path, Err: err} + } + return nil +} + +func (c *Client) translateError(req *http.Request, status int, body []byte) error { + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: HTTP %d: %s", ErrAuth, status, summarizeBody(body)) + case http.StatusBadGateway: + // /run failure shape: {"ok":false, "error":"...", "stderr":"...", "duration_ms":..., "stop_reason":"..."} + // Only treat it as RunFailure if the body matches that shape. + if req.URL.Path == "/run" { + var rf RunFailure + if err := json.Unmarshal(body, &rf); err == nil && rf.Err != "" { + rf.StatusCode = status + return &rf + } + } + } + return &APIError{ + StatusCode: status, + Body: string(body), + Message: summarizeBody(body), + } +} + +// summarizeBody pulls a short message out of a JSON error body. Looks for +// "error" or "detail" keys; falls back to the raw body trimmed. +func summarizeBody(body []byte) string { + if len(body) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(body, &m); err == nil { + for _, k := range []string{"error", "detail", "message"} { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + } + s := strings.TrimSpace(string(body)) + if len(s) > 500 { + s = s[:500] + "..." + } + return s +} + +// ---------- multipart upload ------------------------------------------------ + +// uploadReader streams to /files via multipart/form-data without buffering +// the whole file in memory. It uses an io.Pipe + goroutine pattern so the +// HTTP request body is consumed as fast as the upstream can read it. +func (c *Client) uploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) { + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + + // Goroutine writes the multipart payload into the pipe; the http + // client reads from the other end and ships it upstream. + errCh := make(chan error, 1) + go func() { + defer pw.Close() + defer mw.Close() + + if ttlSecs > 0 { + if err := mw.WriteField("ttl_secs", strconv.Itoa(ttlSecs)); err != nil { + errCh <- err + _ = pw.CloseWithError(err) + return + } + } + fw, err := mw.CreateFormFile("file", filename) + if err != nil { + errCh <- err + _ = pw.CloseWithError(err) + return + } + if _, err := io.Copy(fw, r); err != nil { + errCh <- err + _ = pw.CloseWithError(err) + return + } + errCh <- nil + }() + + req, err := c.newRequest(ctx, http.MethodPost, "/files", pr, mw.FormDataContentType()) + if err != nil { + _ = pr.Close() + return nil, err + } + var out FileToken + if err := c.do(req, &out); err != nil { + // Drain the producer so the goroutine doesn't leak. + _ = pr.CloseWithError(err) + <-errCh + return nil, err + } + if perr := <-errCh; perr != nil { + return nil, &TransportError{Op: "multipart-write", Err: perr} + } + return &out, nil +} diff --git a/clients/go/client_test.go b/clients/go/client_test.go new file mode 100644 index 0000000..4901715 --- /dev/null +++ b/clients/go/client_test.go @@ -0,0 +1,369 @@ +package clawdforge + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// helper: spin up an httptest.Server with the supplied handler and return +// (client, teardown). +func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, func()) { + t.Helper() + srv := httptest.NewServer(h) + c := New(srv.URL, "cf_test_token") + return c, srv.Close +} + +func TestHealthz(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + t.Errorf("path = %q, want /healthz", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"claude_present":true,"claude_version":"1.2.3"}`)) + }) + defer done() + + h, err := c.Healthz(context.Background()) + if err != nil { + t.Fatalf("Healthz: %v", err) + } + if !h.OK || !h.ClaudePresent || h.ClaudeVersion != "1.2.3" { + t.Errorf("got %+v", h) + } +} + +func TestRunSuccessJSON(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/run" { + t.Errorf("path = %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer cf_test_token" { + t.Errorf("Authorization = %q", got) + } + var body RunRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Prompt != "say hi" || body.Model != "sonnet" { + t.Errorf("body = %+v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"}`)) + }) + defer done() + + res, err := c.Run(context.Background(), RunRequest{Prompt: "say hi", Model: "sonnet"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !res.OK || res.DurationMS != 42 || res.StopReason != "end_turn" { + t.Errorf("got %+v", res) + } + var data map[string]string + if err := res.AsJSON(&data); err != nil { + t.Fatalf("AsJSON: %v", err) + } + if data["hello"] != "world" { + t.Errorf("AsJSON: got %v", data) + } +} + +func TestRunSuccessText(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"result":"plain text reply","duration_ms":10,"stop_reason":"end_turn"}`)) + }) + defer done() + + res, err := c.Run(context.Background(), RunRequest{Prompt: "hi"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + s, err := res.AsText() + if err != nil { + t.Fatalf("AsText: %v", err) + } + if s != "plain text reply" { + t.Errorf("AsText = %q", s) + } + // AsJSON on a string result should still work — string is valid JSON + var got string + if err := res.AsJSON(&got); err != nil || got != "plain text reply" { + t.Errorf("AsJSON-on-string: got=%q err=%v", got, err) + } +} + +func TestRunFailure502(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"ok":false,"error":"timeout after 30s","stderr":"...","duration_ms":30000,"stop_reason":"timeout"}`)) + }) + defer done() + + _, err := c.Run(context.Background(), RunRequest{Prompt: "loop forever"}) + if err == nil { + t.Fatal("expected error") + } + var rf *RunFailure + if !errors.As(err, &rf) { + t.Fatalf("err is not *RunFailure: %T %v", err, err) + } + if rf.StopReason != "timeout" || rf.DurationMS != 30000 { + t.Errorf("got %+v", rf) + } +} + +func TestRunAuthFailure(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"missing bearer"}`)) + }) + defer done() + + _, err := c.Run(context.Background(), RunRequest{Prompt: "x"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrAuth) { + t.Errorf("err is not ErrAuth: %v", err) + } +} + +func TestRunGenericAPIError(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"unknown file token: ff_xyz"}`)) + }) + defer done() + + _, err := c.Run(context.Background(), RunRequest{Prompt: "x", Files: []string{"ff_xyz"}}) + if err == nil { + t.Fatal("expected error") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err is not *APIError: %T %v", err, err) + } + if apiErr.StatusCode != 404 || !strings.Contains(apiErr.Message, "ff_xyz") { + t.Errorf("got %+v", apiErr) + } +} + +func TestRunEmptyPromptRejected(t *testing.T) { + c := New("http://nowhere.invalid", "tok") + _, err := c.Run(context.Background(), RunRequest{}) + if err == nil { + t.Fatal("expected error for empty prompt") + } +} + +func TestUploadFile(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/files" { + t.Errorf("path = %q", r.URL.Path) + } + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data") { + t.Fatalf("Content-Type = %q", ct) + } + mr, err := r.MultipartReader() + if err != nil { + t.Fatalf("MultipartReader: %v", err) + } + var ( + gotTTL string + gotFile string + gotData []byte + ) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart: %v", err) + } + switch part.FormName() { + case "ttl_secs": + b, _ := io.ReadAll(part) + gotTTL = string(b) + case "file": + gotFile = part.FileName() + gotData, _ = io.ReadAll(part) + } + } + if gotTTL != "7200" { + t.Errorf("ttl_secs = %q", gotTTL) + } + if gotFile != "recipe.txt" { + t.Errorf("filename = %q", gotFile) + } + if string(gotData) != "hello world" { + t.Errorf("data = %q", string(gotData)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"file_token":"ff_abc","ttl_secs":7200,"size":11}`)) + }) + defer done() + + ft, err := c.UploadReader(context.Background(), "recipe.txt", strings.NewReader("hello world"), 7200) + if err != nil { + t.Fatalf("UploadReader: %v", err) + } + if ft.FileToken != "ff_abc" || ft.TTLSecs != 7200 || ft.Size != 11 { + t.Errorf("got %+v", ft) + } +} + +func TestCreateAndListAndRevokeToken(t *testing.T) { + created := false + listed := false + revoked := false + + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/admin/tokens": + var body CreateTokenRequest + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Name != "petalparse" { + t.Errorf("name = %q", body.Name) + } + created = true + _, _ = w.Write([]byte(`{"name":"petalparse","token":"cf_freshplaintext","ip_cidrs":["172.24.0.0/16"]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/admin/tokens": + listed = true + _, _ = w.Write([]byte(`{"tokens":[{"name":"petalparse","ip_cidrs":["172.24.0.0/16"],"created_at":1700000000}]}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/admin/tokens/petalparse": + revoked = true + _, _ = w.Write([]byte(`{"ok":true}`)) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + defer done() + + tok, err := c.CreateToken(context.Background(), CreateTokenRequest{ + Name: "petalparse", + IPCidrs: []string{"172.24.0.0/16"}, + }) + if err != nil { + t.Fatalf("CreateToken: %v", err) + } + if tok.Token != "cf_freshplaintext" { + t.Errorf("plaintext not returned: %+v", tok) + } + + list, err := c.ListTokens(context.Background()) + if err != nil { + t.Fatalf("ListTokens: %v", err) + } + if len(list) != 1 || list[0].Name != "petalparse" { + t.Errorf("list = %+v", list) + } + + if err := c.RevokeToken(context.Background(), "petalparse"); err != nil { + t.Fatalf("RevokeToken: %v", err) + } + + if !created || !listed || !revoked { + t.Errorf("not all endpoints hit: c=%v l=%v r=%v", created, listed, revoked) + } +} + +func TestContextCancellation(t *testing.T) { + // Block server-side handler until the request context dies OR a hard + // safety timer fires (so a misbehaving client can't hang the test). + handlerDone := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + close(handlerDone) + })) + defer srv.Close() + c := New(srv.URL, "tok") + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := c.Run(ctx, RunRequest{Prompt: "x"}) + if err == nil { + t.Fatal("expected error") + } + var te *TransportError + if !errors.As(err, &te) { + t.Fatalf("err is not *TransportError: %T %v", err, err) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err does not wrap DeadlineExceeded: %v", err) + } + + // Force the abandoned connection closed so the handler's r.Context() + // fires immediately rather than waiting for the safety timer. + srv.CloseClientConnections() + + select { + case <-handlerDone: + case <-time.After(3 * time.Second): + t.Error("server handler did not exit") + } +} + +func TestBaseURLTrailingSlashTrimmed(t *testing.T) { + c := New("http://example.com:8800/", "tok") + if c.BaseURL != "http://example.com:8800" { + t.Errorf("BaseURL = %q", c.BaseURL) + } +} + +func TestNewWithClientNilFallback(t *testing.T) { + c := NewWithClient("http://x", "tok", nil) + if c.HTTPClient == nil { + t.Fatal("HTTPClient should be set") + } +} + +// Sanity check: multipart payload is constructed correctly even when ttlSecs=0. +func TestUploadReaderDefaultTTL(t *testing.T) { + c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + mr, _ := r.MultipartReader() + sawTTL := false + for { + part, err := mr.NextPart() + if err != nil { + break + } + if part.FormName() == "ttl_secs" { + sawTTL = true + } + } + if sawTTL { + t.Error("ttl_secs should be omitted when 0") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"file_token":"ff_x","ttl_secs":3600,"size":3}`)) + }) + defer done() + + if _, err := c.UploadReader(context.Background(), "a.bin", strings.NewReader("abc"), 0); err != nil { + t.Fatalf("UploadReader: %v", err) + } +} + +// Reference unused symbol so multipart import in test file is exercised. +var _ = multipart.NewWriter diff --git a/clients/go/cmd/cf-cli/main.go b/clients/go/cmd/cf-cli/main.go new file mode 100644 index 0000000..1902e04 --- /dev/null +++ b/clients/go/cmd/cf-cli/main.go @@ -0,0 +1,162 @@ +// cf-cli is a tiny demo client for clawdforge. +// +// cf-cli health +// cf-cli run "Reply with JSON: {\"hello\":\"world\"}" +// cf-cli upload /path/to/file.png +// +// Configuration via env: +// +// CLAWDFORGE_URL base URL, default http://192.168.0.5:8800 +// CLAWDFORGE_TOKEN bearer token (required for run/upload) +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + cf "gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + baseURL := envOr("CLAWDFORGE_URL", "http://192.168.0.5:8800") + token := os.Getenv("CLAWDFORGE_TOKEN") + client := cf.New(baseURL, token) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cmd := os.Args[1] + switch cmd { + case "health", "healthz": + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + h, err := client.Healthz(ctx) + fail(err) + printJSON(h) + + case "run": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: cf-cli run [model] [timeout_secs]") + os.Exit(2) + } + prompt := os.Args[2] + model := "" + timeoutSecs := 60 + if len(os.Args) >= 4 { + model = os.Args[3] + } + if len(os.Args) >= 5 { + n, err := strconv.Atoi(os.Args[4]) + if err != nil { + fmt.Fprintln(os.Stderr, "timeout_secs must be int") + os.Exit(2) + } + timeoutSecs = n + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs+30)*time.Second) + defer cancel() + res, err := client.Run(ctx, cf.RunRequest{ + Prompt: prompt, + Model: model, + TimeoutSecs: timeoutSecs, + }) + if err != nil { + var rf *cf.RunFailure + if errors.As(err, &rf) { + fmt.Fprintf(os.Stderr, "run failed (stop_reason=%s): %s\n", rf.StopReason, rf.Err) + if rf.Stderr != "" { + fmt.Fprintln(os.Stderr, "--- stderr ---") + fmt.Fprintln(os.Stderr, rf.Stderr) + } + os.Exit(1) + } + fail(err) + } + printJSON(res) + + case "upload": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: cf-cli upload [ttl_secs]") + os.Exit(2) + } + path := os.Args[2] + ttl := 0 + if len(os.Args) >= 4 { + n, err := strconv.Atoi(os.Args[3]) + if err != nil { + fmt.Fprintln(os.Stderr, "ttl_secs must be int") + os.Exit(2) + } + ttl = n + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + ft, err := client.UploadFile(ctx, path, ttl) + fail(err) + printJSON(ft) + + case "tokens": + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + toks, err := client.ListTokens(ctx) + fail(err) + printJSON(toks) + + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `cf-cli — clawdforge demo client + +usage: + cf-cli health + cf-cli run [model] [timeout_secs] + cf-cli upload [ttl_secs] + cf-cli tokens + +env: + CLAWDFORGE_URL (default http://192.168.0.5:8800) + CLAWDFORGE_TOKEN (required)`) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func fail(err error) { + if err == nil { + return + } + fmt.Fprintln(os.Stderr, "error:", err) + if errors.Is(err, cf.ErrAuth) { + fmt.Fprintln(os.Stderr, "(auth — check CLAWDFORGE_TOKEN)") + } + os.Exit(1) +} + +func printJSON(v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal:", err) + os.Exit(1) + } + fmt.Println(string(b)) +} diff --git a/clients/go/errors.go b/clients/go/errors.go new file mode 100644 index 0000000..ddbcc8f --- /dev/null +++ b/clients/go/errors.go @@ -0,0 +1,62 @@ +package clawdforge + +import ( + "errors" + "fmt" +) + +// ErrAuth is the sentinel returned for 401/403 responses from clawdforge. +// Use errors.Is(err, ErrAuth) to detect auth failures. +var ErrAuth = errors.New("clawdforge: authentication failed") + +// APIError carries a non-2xx response from clawdforge — status code plus +// the response body (truncated to a sane size). It is returned for any +// HTTP status >= 400 that isn't an auth failure. +type APIError struct { + StatusCode int + // Body is the raw response body (truncated). Useful for debugging. + Body string + // Message is a short human-readable summary derived from the body + // when the body is JSON of the form {"error": "..."} or {"detail": "..."}. + Message string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("clawdforge: HTTP %d: %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("clawdforge: HTTP %d: %s", e.StatusCode, e.Body) +} + +// TransportError wraps a low-level network/transport failure (DNS, connect +// refused, TLS, EOF, context cancellation, etc.). The wrapped error is +// available via errors.Unwrap. +type TransportError struct { + Op string + Err error +} + +func (e *TransportError) Error() string { + return fmt.Sprintf("clawdforge: transport %s: %v", e.Op, e.Err) +} + +func (e *TransportError) Unwrap() error { return e.Err } + +// RunFailure represents a 502 response from /run — clawdforge accepted the +// request but `claude -p` failed (timeout, non-zero exit, etc.). The body +// fields mirror the server's failure shape. +// +// RunFailure satisfies APIError-like semantics by also embedding the status +// code via the underlying APIError, but it's a distinct type so callers can +// branch on errors.As(err, &cf.RunFailure{}). +type RunFailure struct { + StatusCode int + Err string `json:"error"` + Stderr string `json:"stderr"` + DurationMS int `json:"duration_ms"` + StopReason string `json:"stop_reason"` +} + +func (e *RunFailure) Error() string { + return fmt.Sprintf("clawdforge: run failed (stop_reason=%s): %s", e.StopReason, e.Err) +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 0000000..1cdc038 --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,3 @@ +module gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go + +go 1.22 diff --git a/clients/go/models.go b/clients/go/models.go new file mode 100644 index 0000000..f4aca3f --- /dev/null +++ b/clients/go/models.go @@ -0,0 +1,95 @@ +package clawdforge + +import ( + "encoding/json" + "fmt" +) + +// Healthz is the parsed response from GET /healthz. +type Healthz struct { + OK bool `json:"ok"` + ClaudePresent bool `json:"claude_present"` + ClaudeVersion string `json:"claude_version"` +} + +// RunRequest is the body for POST /run. Matches server.py RunRequest exactly. +// +// Only Prompt is required. Model, System, Files, TimeoutSecs are zero-value +// optional — empty strings/slices and zero ints are omitted from the wire. +type RunRequest struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + System string `json:"system,omitempty"` + Files []string `json:"files,omitempty"` + TimeoutSecs int `json:"timeout_secs,omitempty"` +} + +// RunResult is the parsed response from POST /run on success (HTTP 200). +// +// Result is captured raw because the upstream may return either a parsed +// JSON value (object/array/etc) or a plain string. Use AsJSON or AsText +// to materialize it. +type RunResult struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + DurationMS int `json:"duration_ms"` + StopReason string `json:"stop_reason"` +} + +// AsJSON unmarshals the Result field into out. Use this when your prompt +// asked claude for JSON and you have a target type. +// +// var data map[string]string +// if err := res.AsJSON(&data); err != nil { ... } +func (r *RunResult) AsJSON(out any) error { + if len(r.Result) == 0 { + return fmt.Errorf("clawdforge: empty result") + } + if err := json.Unmarshal(r.Result, out); err != nil { + return fmt.Errorf("clawdforge: result is not JSON: %w", err) + } + return nil +} + +// AsText returns the Result as a string. Works for both JSON-string results +// (e.g. claude returned "hello") and structured JSON (returns the raw JSON +// representation as a string in that case). +func (r *RunResult) AsText() (string, error) { + if len(r.Result) == 0 { + return "", fmt.Errorf("clawdforge: empty result") + } + // First try to decode as a plain JSON string + var s string + if err := json.Unmarshal(r.Result, &s); err == nil { + return s, nil + } + // Otherwise return the raw JSON encoding + return string(r.Result), nil +} + +// FileToken is the parsed response from POST /files. +type FileToken struct { + FileToken string `json:"file_token"` + TTLSecs int `json:"ttl_secs"` + Size int64 `json:"size"` +} + +// AppToken is one entry from GET /admin/tokens, also returned (with the +// plaintext Token populated) by POST /admin/tokens. +type AppToken struct { + Name string `json:"name"` + Token string `json:"token,omitempty"` // only set on create + IPCidrs []string `json:"ip_cidrs,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` +} + +// TokenList is the response from GET /admin/tokens. +type TokenList struct { + Tokens []AppToken `json:"tokens"` +} + +// CreateTokenRequest is the body for POST /admin/tokens. +type CreateTokenRequest struct { + Name string `json:"name"` + IPCidrs []string `json:"ip_cidrs,omitempty"` +}