clients/go: initial Go SDK for clawdforge

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).
This commit is contained in:
Kayos 2026-04-28 22:36:32 -07:00
parent 062d405a9e
commit 3c62613c30
7 changed files with 1274 additions and 0 deletions

263
clients/go/README.md Normal file
View file

@ -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.

320
clients/go/client.go Normal file
View file

@ -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
}

369
clients/go/client_test.go Normal file
View file

@ -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

View file

@ -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 <prompt> [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 <path> [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 <prompt> [model] [timeout_secs]
cf-cli upload <path> [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))
}

62
clients/go/errors.go Normal file
View file

@ -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)
}

3
clients/go/go.mod Normal file
View file

@ -0,0 +1,3 @@
module gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go
go 1.22

95
clients/go/models.go Normal file
View file

@ -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"`
}