clawdforge/clients/go/client.go
Kayos 3c62613c30 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).
2026-04-28 22:36:56 -07:00

320 lines
9.7 KiB
Go

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