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).
320 lines
9.7 KiB
Go
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
|
|
}
|