clients/typescript: initial TypeScript SDK for clawdforge

Drop-in Node 18+ client with strict types, native fetch, AbortSignal
support, and a typed error hierarchy (ForgeAuthError / ForgeAPIError /
ForgeTransportError). Mirrors the existing Python client surface but
stays generic — no Sulkta-specific assumptions, suitable for anyone
running their own clawdforge instance.

- camelCase TS, snake_case wire — converted at the boundary
- node:test suite (17 tests) covering healthz, run success/error paths,
  502 envelopes, abort/timeout, file upload, and admin token CRUD
- tsc --noEmit clean with strict mode + Node16 module resolution
This commit is contained in:
Kayos 2026-04-28 22:42:26 -07:00
parent 1cff9b89d2
commit 15de6e765f
10 changed files with 1538 additions and 0 deletions

4
clients/typescript/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

View file

@ -0,0 +1,225 @@
# clawdforge — TypeScript SDK
A small, dependency-free TypeScript / Node.js SDK for [clawdforge](../../README.md):
a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a
bearer-token-gated REST API.
- **Node 18+** — uses native `fetch` and `AbortController`
- **No runtime dependencies**`typescript` is a dev dep only
- **Strict TypeScript** — public types are explicit, `result` is `unknown`
so you narrow it where you know the shape
- **camelCase TS / snake_case wire** — converted at the boundary, you never
see `timeout_secs` or `duration_ms` in your code
- **Typed errors**`ForgeAuthError`, `ForgeAPIError`, `ForgeTransportError`
- **Cancellable** — every method takes an optional `signal: AbortSignal`
## Install
For now, the SDK is consumed straight from this repo subtree (no npm publish):
```bash
# from your app's repo
npm install file:/path/to/clawdforge/clients/typescript
```
Or, if you check the clawdforge repo out alongside your app:
```bash
npm install file:../clawdforge/clients/typescript
```
You can also point at a Git URL, since `package.json` lives at this path:
```bash
npm install "git+https://your-git-host/your-org/clawdforge.git#main:clients/typescript"
```
## Quickstart
```typescript
import { Forge, ForgeAPIError, ForgeError } from "clawdforge";
const forge = new Forge({
baseUrl: "http://localhost:8800",
token: process.env.CLAWDFORGE_TOKEN!, // a per-app `cf_...` token
});
const h = await forge.healthz();
console.log(h.claudeVersion); // "1.2.3 (Claude Code)" (or null)
const r = await forge.run({
prompt: 'Reply with JSON: {"hello":"world"}',
model: "sonnet",
system: "Be terse.",
timeoutSecs: 60,
});
console.log(r.result, r.durationMs, r.stopReason);
// Narrow the result if you trust the prompt:
type Hello = { hello: string };
const data = r.result as Hello;
console.log(data.hello);
```
## API
All methods are `async` and return Promises.
### `new Forge(options: ForgeOptions)`
| field | type | default | notes |
|--------------------|----------------|-----------------------|--------------------------------------------------|
| `baseUrl` | `string` | required | trailing slashes are stripped |
| `token` | `string` | required | per-app `cf_...` for `/run`+`/files`, admin for `/admin/*` |
| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables |
| `fetch` | `typeof fetch` | `globalThis.fetch` | inject a custom fetch (testing, proxies, etc.) |
### `forge.healthz(): Promise<HealthzResponse>`
```ts
{ ok: boolean; claudePresent: boolean; claudeVersion: string | null }
```
### `forge.run(req: RunRequest): Promise<RunResult>`
```ts
interface RunRequest {
prompt: string; // required, non-empty
model?: string; // e.g. "sonnet"
system?: string; // append-system-prompt
files?: string[]; // file_tokens from uploadFile()
timeoutSecs?: number; // 5..600, server-side subprocess timeout
signal?: AbortSignal; // cancel the request
}
interface RunResult {
ok: true;
result: unknown; // narrow at call site (object or string)
durationMs: number;
stopReason: string | null; // typically "end_turn"
}
```
On a server-reported subprocess failure (HTTP 502), the SDK throws a
`ForgeAPIError` whose `body` is a `RunErrorBody`:
```ts
interface RunErrorBody {
ok: false;
error: string | null;
stderr: string;
durationMs: number;
stopReason: string | null; // "timeout" | "error" | ...
}
```
### `forge.uploadFile(source, opts?): Promise<FileToken>`
```ts
const ft = await forge.uploadFile("./recipe.png", { ttlSecs: 3600 });
await forge.run({
prompt: "extract recipe data",
files: [ft.fileToken],
});
```
`source` can be a string path, a `Uint8Array` / `Buffer`, or a `Blob`. Pass
`filename` and/or `contentType` in `opts` to override defaults.
```ts
interface FileToken {
fileToken: string; // "ff_..."
ttlSecs: number; // 60..86400, default 3600
size: number;
}
```
### Admin: `createToken`, `listTokens`, `revokeToken`
These require an admin-bootstrap token in the `Forge` constructor, not a
per-app token.
```ts
const admin = new Forge({ baseUrl, token: process.env.CLAWDFORGE_ADMIN! });
const minted = await admin.createToken({
name: "my-app",
ipCidrs: ["10.0.0.0/8"],
});
console.log(minted.token); // shown ONCE — save it now
for (const t of await admin.listTokens()) {
console.log(t.name, t.enabled, t.lastUsed);
}
await admin.revokeToken("my-app"); // returns true; false if not found
```
## Errors
All SDK errors extend `ForgeError`. Catch the leaf you care about, or the
base for blanket handling:
```ts
import {
ForgeError,
ForgeAuthError,
ForgeAPIError,
ForgeTransportError,
} from "clawdforge";
try {
await forge.run({ prompt: "..." });
} catch (e) {
if (e instanceof ForgeAuthError) {
// 401 / 403 — bearer wrong/revoked, or IP not allow-listed
} else if (e instanceof ForgeAPIError) {
// any other 4xx/5xx; e.status, e.body
if (e.status === 502) {
// subprocess failure: e.body is RunErrorBody
}
} else if (e instanceof ForgeTransportError) {
// network failure, DNS, TLS, abort, or timeout
if (e.aborted) {
// either caller's AbortSignal fired or the SDK's defaultTimeoutMs did
}
} else if (e instanceof ForgeError) {
// input-validation errors thrown before the network
}
}
```
## Cancellation
Every method accepts an optional `signal: AbortSignal`. On abort the SDK
throws `ForgeTransportError` with `aborted: true`:
```ts
const ac = new AbortController();
const p = forge.run({ prompt: "long task", signal: ac.signal });
setTimeout(() => ac.abort(), 5_000);
```
The SDK also enforces a client-side `defaultTimeoutMs` (120s by default). For
`run()`, when you pass `timeoutSecs`, the network timeout is automatically
set to `timeoutSecs * 1000 + 30_000`, so the HTTP socket outlives the
server-side subprocess timeout — that way the server gets a chance to send
back a clean 502 envelope rather than the client tearing down first.
## Develop
```bash
cd clients/typescript
npm install
npm run typecheck # tsc --noEmit
npm test # node --test --import tsx tests/*.test.ts
npm run build # emit dist/
```
The test suite uses `node:test` with a mock `fetch` injected via the
`fetch` constructor option — no live server needed.
## License
MIT (see repo root).

View file

@ -0,0 +1,66 @@
/**
* Minimal end-to-end example.
*
* CLAWDFORGE_URL=http://localhost:8800 \
* CLAWDFORGE_TOKEN=cf_xxx \
* npx tsx examples/basic.ts
*/
import { Forge, ForgeAPIError, ForgeError } from "../src/index.js";
async function main(): Promise<void> {
const baseUrl = process.env["CLAWDFORGE_URL"] ?? "http://localhost:8800";
const token = process.env["CLAWDFORGE_TOKEN"];
if (!token) {
console.error("Set CLAWDFORGE_TOKEN to a per-app token (cf_...)");
process.exit(1);
}
const forge = new Forge({ baseUrl, token });
// 1) liveness
const h = await forge.healthz();
console.log(`healthz: ok=${h.ok} present=${h.claudePresent} version=${h.claudeVersion}`);
// 2) plain prompt — JSON result
try {
const r = await forge.run({
prompt: 'Reply with valid JSON: {"hello":"world"}',
model: "sonnet",
timeoutSecs: 60,
});
console.log(`run: durationMs=${r.durationMs} stopReason=${r.stopReason}`);
console.log("result:", r.result);
// Type-narrow the result if you know the prompt returns this shape.
type HelloResult = { hello: string };
const data = r.result as HelloResult;
console.log("hello field:", data.hello);
} catch (e) {
if (e instanceof ForgeAPIError) {
console.error(`API error ${e.status}:`, e.body);
} else if (e instanceof ForgeError) {
console.error("Forge error:", e.message);
} else {
throw e;
}
}
// 3) cancellable run
const ac = new AbortController();
const slow = forge.run({
prompt: "summarize anything; this might take a while",
signal: ac.signal,
});
setTimeout(() => ac.abort(), 2_000); // give up after 2s
try {
const r2 = await slow;
console.log("slow run finished:", r2.durationMs, "ms");
} catch (e) {
console.log("slow run cancelled:", e instanceof Error ? e.message : e);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,42 @@
{
"name": "clawdforge",
"version": "0.1.0",
"description": "TypeScript SDK for the clawdforge HTTP service — a token-gated wrapper around `claude -p`.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "node --test --import tsx tests/*.test.ts",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"claude",
"claude-code",
"anthropic",
"sdk",
"clawdforge"
],
"license": "MIT",
"devDependencies": {
"@types/node": "^20.19.39",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,447 @@
import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";
import {
ForgeAPIError,
ForgeAuthError,
ForgeError,
ForgeTransportError,
} from "./errors.js";
import type {
AppToken,
AppTokenCreated,
CreateTokenOptions,
FileToken,
ForgeOptions,
HealthzResponse,
RunErrorBody,
RunRequest,
RunResult,
UploadFileOptions,
} from "./types.js";
/**
* Thin HTTP client for clawdforge.
*
* @example
* ```ts
* const forge = new Forge({ baseUrl: "http://localhost:8800", token: "cf_..." });
* const r = await forge.run({ prompt: "Reply with JSON: {\"hello\":\"world\"}" });
* console.log(r.result);
* ```
*/
export class Forge {
private readonly baseUrl: string;
private readonly token: string;
private readonly defaultTimeoutMs: number;
private readonly fetchImpl: typeof fetch;
constructor(options: ForgeOptions) {
if (!options || typeof options !== "object") {
throw new ForgeError("Forge constructor requires an options object");
}
if (!options.baseUrl) {
throw new ForgeError("Forge: baseUrl is required");
}
if (!options.token) {
throw new ForgeError("Forge: token is required");
}
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.token = options.token;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
const fetchFn = options.fetch ?? globalThis.fetch;
if (typeof fetchFn !== "function") {
throw new ForgeError(
"Forge: no fetch implementation found. On Node < 18, pass `fetch` explicitly."
);
}
this.fetchImpl = fetchFn;
}
// ------------------------------------------------------------------ healthz
/**
* `GET /healthz` returns liveness info plus a `claude --version` smoke check.
*
* The server allows healthz from any IP in the global CIDR allowlist (no
* bearer required), but the SDK still sends one for symmetry.
*/
async healthz(opts: { signal?: AbortSignal } = {}): Promise<HealthzResponse> {
const raw = await this.request<{
ok: boolean;
claude_present: boolean;
claude_version: string | null;
}>("GET", "/healthz", { signal: opts.signal });
return {
ok: raw.ok,
claudePresent: raw.claude_present,
claudeVersion: raw.claude_version,
};
}
// ---------------------------------------------------------------------- run
/**
* `POST /run` execute a prompt against `claude -p`.
*
* On success returns a {@link RunResult} with the parsed `result` (object
* or string). On a server-reported subprocess failure (HTTP 502) throws
* {@link ForgeAPIError} whose `body` is a {@link RunErrorBody}.
*/
async run(req: RunRequest): Promise<RunResult> {
if (!req || typeof req.prompt !== "string" || req.prompt.length === 0) {
throw new ForgeError("run: prompt is required and must be non-empty");
}
const wire: Record<string, unknown> = { prompt: req.prompt };
if (req.model !== undefined) wire["model"] = req.model;
if (req.system !== undefined) wire["system"] = req.system;
if (req.files !== undefined) wire["files"] = req.files;
if (req.timeoutSecs !== undefined) wire["timeout_secs"] = req.timeoutSecs;
// Network timeout = server-side timeout + 30s headroom, matching the
// Python client convention.
const networkTimeoutMs =
req.timeoutSecs !== undefined
? req.timeoutSecs * 1000 + 30_000
: this.defaultTimeoutMs;
const raw = await this.request<{
ok: true;
result: unknown;
duration_ms: number;
stop_reason: string | null;
}>("POST", "/run", {
jsonBody: wire,
signal: req.signal,
timeoutMs: networkTimeoutMs,
});
return {
ok: true,
result: raw.result,
durationMs: raw.duration_ms,
stopReason: raw.stop_reason,
};
}
// -------------------------------------------------------------- file upload
/**
* `POST /files` upload a file from disk and receive a `file_token`.
*
* The first argument may be:
* - a string path to a local file
* - a Node `Buffer` / `Uint8Array`
* - a Web `Blob` / `File`
*
* For Buffers and Blobs, pass `filename` in `opts` to control the on-disk name.
*/
async uploadFile(
source: string | Uint8Array | Blob,
opts: UploadFileOptions = {}
): Promise<FileToken> {
const ttlSecs = opts.ttlSecs ?? 3600;
if (ttlSecs < 60 || ttlSecs > 86400) {
throw new ForgeError("uploadFile: ttlSecs must be between 60 and 86400");
}
const form = new FormData();
let blob: Blob;
let filename: string;
if (typeof source === "string") {
const data = await readFile(source);
// Verify the path actually existed; readFile would throw otherwise,
// but stat clarifies the error message vs a transport-level surprise.
await stat(source);
blob = new Blob([new Uint8Array(data)], {
type: opts.contentType ?? "application/octet-stream",
});
filename = opts.filename ?? basename(source);
} else if (source instanceof Uint8Array) {
blob = new Blob([new Uint8Array(source)], {
type: opts.contentType ?? "application/octet-stream",
});
filename = opts.filename ?? "upload";
} else if (typeof Blob !== "undefined" && source instanceof Blob) {
// Re-wrap to override the content type if requested.
blob = opts.contentType
? new Blob([source], { type: opts.contentType })
: source;
filename =
opts.filename ??
(typeof (source as { name?: unknown }).name === "string"
? ((source as { name?: string }).name as string)
: "upload");
} else {
throw new ForgeError(
"uploadFile: source must be a string path, Uint8Array, or Blob"
);
}
form.append("file", blob, filename);
form.append("ttl_secs", String(ttlSecs));
const raw = await this.request<{
file_token: string;
ttl_secs: number;
size: number;
}>("POST", "/files", { body: form, signal: opts.signal });
return {
fileToken: raw.file_token,
ttlSecs: raw.ttl_secs,
size: raw.size,
};
}
// ----------------------------------------------------------------- /admin/*
/**
* `POST /admin/tokens` mint a new per-app bearer token.
*
* Requires the bearer to be the admin bootstrap token (configure on the
* server via `ADMIN_BOOTSTRAP_TOKEN`). The plaintext token is returned
* **once**; the server only stores a SHA-256 hash thereafter.
*/
async createToken(opts: CreateTokenOptions): Promise<AppTokenCreated> {
if (!opts.name) {
throw new ForgeError("createToken: name is required");
}
const wire = {
name: opts.name,
ip_cidrs: opts.ipCidrs ?? [],
};
const raw = await this.request<{
name: string;
token: string;
ip_cidrs: string[];
}>("POST", "/admin/tokens", { jsonBody: wire, signal: opts.signal });
return {
name: raw.name,
token: raw.token,
ipCidrs: raw.ip_cidrs,
};
}
/** `GET /admin/tokens` — list all minted tokens (without plaintext). */
async listTokens(opts: { signal?: AbortSignal } = {}): Promise<AppToken[]> {
const raw = await this.request<{
tokens: Array<{
name: string;
ip_cidrs: string;
created_at: number;
last_used: number | null;
enabled: number;
}>;
}>("GET", "/admin/tokens", { signal: opts.signal });
return raw.tokens.map((t) => ({
name: t.name,
ipCidrs: t.ip_cidrs,
createdAt: t.created_at,
lastUsed: t.last_used,
enabled: t.enabled,
}));
}
/** `DELETE /admin/tokens/<name>` — revoke. Returns true on success, false if not found. */
async revokeToken(
name: string,
opts: { signal?: AbortSignal } = {}
): Promise<boolean> {
if (!name) {
throw new ForgeError("revokeToken: name is required");
}
try {
const raw = await this.request<{ ok: boolean }>(
"DELETE",
`/admin/tokens/${encodeURIComponent(name)}`,
{ signal: opts.signal }
);
return raw.ok === true;
} catch (e) {
if (e instanceof ForgeAPIError && e.status === 404) {
return false;
}
throw e;
}
}
// ------------------------------------------------------------- internals
/**
* Low-level request helper.
*
* Routes both JSON and FormData bodies, applies the bearer header, wires
* up an AbortSignal (combining the caller's with our timeout), and maps
* non-2xx responses to typed errors.
*/
private async request<T>(
method: "GET" | "POST" | "DELETE",
path: string,
opts: {
jsonBody?: unknown;
body?: BodyInit;
signal?: AbortSignal;
timeoutMs?: number;
} = {}
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
};
let body: BodyInit | undefined;
if (opts.jsonBody !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.jsonBody);
} else if (opts.body !== undefined) {
// FormData — let fetch set the multipart boundary itself.
body = opts.body;
}
const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs;
const signal = combineSignals(opts.signal, timeoutMs);
const url = `${this.baseUrl}${path}`;
let res: Response;
try {
res = await this.fetchImpl(url, {
method,
headers,
body,
signal: signal.signal,
});
} catch (e: unknown) {
const aborted =
(e instanceof Error && e.name === "AbortError") ||
opts.signal?.aborted === true;
if (aborted) {
throw new ForgeTransportError(
opts.signal?.aborted
? "request aborted by caller"
: `request timed out after ${timeoutMs}ms`,
{ cause: e, aborted: true }
);
}
const msg = e instanceof Error ? e.message : String(e);
throw new ForgeTransportError(`fetch failed: ${msg}`, { cause: e });
} finally {
signal.cleanup();
}
if (res.status >= 200 && res.status < 300) {
// Empty body? Return an empty object — only happens on synthetic responses.
const text = await res.text();
if (!text) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch (e) {
throw new ForgeAPIError(
res.status,
text,
`clawdforge returned non-JSON body: ${truncate(text, 200)}`
);
}
}
// Error path — try to parse the body as JSON, fall back to text.
const rawText = await res.text();
let parsed: unknown = rawText;
try {
parsed = rawText ? JSON.parse(rawText) : null;
} catch {
// keep rawText
}
// 502 from /run carries the snake_case failure envelope; camelCase it
// before exposing to the caller via ForgeAPIError.body.
const camelBody = camelCaseRunFailure(parsed);
if (res.status === 401 || res.status === 403) {
throw new ForgeAuthError(res.status, camelBody);
}
throw new ForgeAPIError(res.status, camelBody);
}
}
// ---------------------------------------------------------- helpers (private)
/** Truncate a string to N chars, appending an ellipsis. */
function truncate(s: string, n: number): string {
return s.length <= n ? s : `${s.slice(0, n)}...`;
}
/**
* If the server returned a `/run` failure envelope `{ok:false, error,
* stderr, duration_ms, stop_reason}`, convert the snake_case keys to
* camelCase. Returns the input untouched otherwise.
*/
function camelCaseRunFailure(body: unknown): unknown {
if (
body &&
typeof body === "object" &&
"ok" in body &&
(body as { ok: unknown }).ok === false
) {
const b = body as Record<string, unknown>;
const out: RunErrorBody = {
ok: false,
error: typeof b["error"] === "string" ? (b["error"] as string) : null,
stderr: typeof b["stderr"] === "string" ? (b["stderr"] as string) : "",
durationMs:
typeof b["duration_ms"] === "number"
? (b["duration_ms"] as number)
: 0,
stopReason:
typeof b["stop_reason"] === "string"
? (b["stop_reason"] as string)
: null,
};
return out;
}
return body;
}
/**
* Combine an optional caller-supplied AbortSignal with a timeout-driven one,
* returning a single signal plus a cleanup function to clear the timer.
*/
function combineSignals(
caller: AbortSignal | undefined,
timeoutMs: number
): { signal: AbortSignal; cleanup: () => void } {
const ac = new AbortController();
let timer: ReturnType<typeof setTimeout> | null = null;
const onCallerAbort = () => {
ac.abort(caller?.reason);
};
if (caller) {
if (caller.aborted) {
ac.abort(caller.reason);
} else {
caller.addEventListener("abort", onCallerAbort, { once: true });
}
}
if (timeoutMs > 0) {
timer = setTimeout(() => ac.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
// Don't keep the event loop alive just for our timeout — if every other
// handle has unwound, the request is effectively done.
(timer as unknown as { unref?: () => void }).unref?.();
}
return {
signal: ac.signal,
cleanup: () => {
if (timer) clearTimeout(timer);
caller?.removeEventListener("abort", onCallerAbort);
},
};
}

View file

@ -0,0 +1,74 @@
/**
* Error hierarchy for the clawdforge SDK.
*
* All errors thrown by the {@link Forge} client extend {@link ForgeError},
* so a single `catch (e: unknown) { if (e instanceof ForgeError) ... }` is
* sufficient at most call sites.
*/
/** Base class. Catch this to handle anything thrown by the SDK. */
export class ForgeError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "ForgeError";
// Preserve prototype chain across transpilers / older targets.
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* 401 / 403 bearer token missing, wrong, revoked, or IP not allow-listed.
*
* The server returns 401 for "missing bearer" and 403 for "unknown or
* disabled token" / "ip not in allowlist" / "admin auth failed". Both map
* to this class so callers can treat them uniformly.
*/
export class ForgeAuthError extends ForgeError {
public readonly status: number;
public readonly body: unknown;
constructor(status: number, body: unknown, message?: string) {
super(message ?? `clawdforge auth failed (${status})`);
this.name = "ForgeAuthError";
this.status = status;
this.body = body;
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Any other non-2xx response from the server (4xx not auth, or 5xx).
*
* Notably: `/run` returns **502** with `{ok:false, error, stderr, ...}` when
* the underlying `claude -p` subprocess fails or times out. The SDK exposes
* those failures as `ForgeAPIError` so callers can react to `error.body`.
*/
export class ForgeAPIError extends ForgeError {
public readonly status: number;
public readonly body: unknown;
constructor(status: number, body: unknown, message?: string) {
super(message ?? `clawdforge API error (${status})`);
this.name = "ForgeAPIError";
this.status = status;
this.body = body;
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Thrown when the request never completed: network failure, DNS, TLS,
* connection refused, or the caller's `AbortSignal` fired.
*
* `aborted` is true when the cause was an `AbortSignal` cancellation.
*/
export class ForgeTransportError extends ForgeError {
public readonly aborted: boolean;
constructor(message: string, options?: { cause?: unknown; aborted?: boolean }) {
super(message, options);
this.name = "ForgeTransportError";
this.aborted = options?.aborted ?? false;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View file

@ -0,0 +1,42 @@
/**
* clawdforge TypeScript SDK.
*
* Wrap a clawdforge HTTP service in a typed, Promise-based client. Compatible
* with Node 18+ (uses native `fetch`).
*
* @example
* ```ts
* import { Forge, ForgeError } from "clawdforge";
*
* const forge = new Forge({
* baseUrl: "http://localhost:8800",
* token: process.env.CLAWDFORGE_TOKEN!,
* });
*
* const r = await forge.run({
* prompt: 'Reply with JSON: {"hello":"world"}',
* model: "sonnet",
* timeoutSecs: 60,
* });
* console.log(r.result, r.durationMs, r.stopReason);
* ```
*/
export { Forge } from "./client.js";
export {
ForgeError,
ForgeAuthError,
ForgeAPIError,
ForgeTransportError,
} from "./errors.js";
export type {
AppToken,
AppTokenCreated,
CreateTokenOptions,
FileToken,
ForgeOptions,
HealthzResponse,
RunErrorBody,
RunRequest,
RunResult,
UploadFileOptions,
} from "./types.js";

View file

@ -0,0 +1,149 @@
/**
* Public types for the clawdforge TypeScript SDK.
*
* Naming convention: TypeScript code is camelCase, the wire format is
* snake_case. The {@link Forge} client converts at the boundary so consumers
* never see snake_case keys.
*/
/** Construction options for the {@link Forge} client. */
export interface ForgeOptions {
/** Base URL of the clawdforge service, e.g. `http://localhost:8800`. Trailing slash is optional. */
baseUrl: string;
/**
* Bearer token. For `/run` and `/files` use a per-app token (`cf_...`).
* For `/admin/*` use the admin bootstrap token configured on the server.
*/
token: string;
/**
* Default request timeout in milliseconds, used when the caller doesn't
* pass `signal`. Defaults to 120_000 (2 minutes). Set to 0 to disable.
*
* Note: `/run` has its own server-side `timeoutSecs`; this is a separate,
* client-side network timeout. The SDK adds 30s of headroom so the HTTP
* timeout outlives the subprocess timeout.
*/
defaultTimeoutMs?: number;
/**
* Override the underlying `fetch` implementation. Useful for testing or
* for custom user-agents / proxies. Defaults to `globalThis.fetch`.
*/
fetch?: typeof fetch;
}
/** Response shape from `GET /healthz`. */
export interface HealthzResponse {
ok: boolean;
/** Whether the `claude` binary was located on the server's PATH. */
claudePresent: boolean;
/** First line of `claude --version`, or null if the smoke check failed. */
claudeVersion: string | null;
}
/** Request body for {@link Forge.run}. */
export interface RunRequest {
/** Prompt text. Must be non-empty. */
prompt: string;
/** Model alias, e.g. `"sonnet"`, `"opus"`, `"haiku"`. Server has its own default. */
model?: string;
/** System prompt. Forwarded to `claude -p --append-system-prompt`. */
system?: string;
/**
* File tokens previously returned by {@link Forge.uploadFile}.
* Each token is scoped to the uploading app token A cannot reference
* token B's files.
*/
files?: string[];
/** Server-side timeout in seconds for the underlying `claude -p` subprocess. 5..600. */
timeoutSecs?: number;
/** Optional `AbortSignal` to cancel this request. */
signal?: AbortSignal;
}
/**
* Successful response from `POST /run`.
*
* `result` is whatever the server parsed out of the inner `claude -p`
* JSON envelope: usually an object (when the prompt asked for JSON), often
* a plain string. Consumers should narrow it themselves the SDK does not
* guess the shape.
*/
export interface RunResult {
ok: true;
/** Parsed JSON value or fallback string. Narrow at the call site. */
result: unknown;
/** Wall-clock duration of the subprocess, in milliseconds. */
durationMs: number;
/** Typically `"end_turn"` on success. */
stopReason: string | null;
}
/**
* Body shape returned by the server on a 502 from `/run` (subprocess
* failed or timed out). Surfaced via `ForgeAPIError.body` after camel-casing.
*/
export interface RunErrorBody {
ok: false;
error: string | null;
stderr: string;
durationMs: number;
stopReason: string | null;
}
/** Options for {@link Forge.uploadFile}. */
export interface UploadFileOptions {
/** Time-to-live in seconds for the file token. 60..86400, default 3600. */
ttlSecs?: number;
/** Override the filename sent to the server (defaults to basename of the path / Blob name). */
filename?: string;
/** Override the MIME type sent in the multipart part. */
contentType?: string;
/** Optional `AbortSignal` to cancel this request. */
signal?: AbortSignal;
}
/** Response from `POST /files`. */
export interface FileToken {
/** Opaque token, prefix `ff_`. Pass into `RunRequest.files`. */
fileToken: string;
/** TTL the server actually applied. */
ttlSecs: number;
/** Stored size in bytes. */
size: number;
}
/** Options for {@link Forge.createToken}. */
export interface CreateTokenOptions {
/**
* App slug. The server requires `^[a-z0-9][a-z0-9_-]*$` (1..64 chars).
* Two tokens cannot share a name; revoke first if reusing.
*/
name: string;
/**
* Optional per-app CIDR allowlist on top of the global one. Empty means
* "fall back to global allowlist only".
*/
ipCidrs?: string[];
/** Optional `AbortSignal`. */
signal?: AbortSignal;
}
/** Response from `POST /admin/tokens` — contains the **plaintext token**, shown ONCE. */
export interface AppTokenCreated {
name: string;
/** Plaintext bearer token. Save it now — server only stores a SHA-256 hash. */
token: string;
ipCidrs: string[];
}
/** Row shape returned from `GET /admin/tokens`. */
export interface AppToken {
name: string;
ipCidrs: string;
/** Unix epoch seconds. */
createdAt: number;
/** Unix epoch seconds, or null if never used. */
lastUsed: number | null;
/** 1 = active, 0 = revoked. */
enabled: number;
}

View file

@ -0,0 +1,462 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Forge,
ForgeAPIError,
ForgeAuthError,
ForgeError,
ForgeTransportError,
} from "../src/index.js";
import type { RunErrorBody } from "../src/index.js";
// -------------------------------------------------------------- mock plumbing
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
bodyText: string | null;
bodyForm: FormData | null;
}
function makeMockFetch(
handler: (req: CapturedRequest) => Response | Promise<Response>
): { fetch: typeof fetch; calls: CapturedRequest[] } {
const calls: CapturedRequest[] = [];
const fn: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : (input as URL).toString();
const method = (init?.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {};
const rawHeaders = init?.headers;
if (rawHeaders) {
if (rawHeaders instanceof Headers) {
rawHeaders.forEach((v, k) => (headers[k.toLowerCase()] = v));
} else if (Array.isArray(rawHeaders)) {
for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v;
} else {
for (const [k, v] of Object.entries(rawHeaders)) {
headers[k.toLowerCase()] = v as string;
}
}
}
let bodyText: string | null = null;
let bodyForm: FormData | null = null;
if (init?.body !== undefined && init.body !== null) {
if (typeof init.body === "string") {
bodyText = init.body;
} else if (init.body instanceof FormData) {
bodyForm = init.body;
} else {
bodyText = String(init.body);
}
}
const captured: CapturedRequest = { url, method, headers, bodyText, bodyForm };
calls.push(captured);
// Honor abort like real fetch does — raise AbortError synchronously if
// the signal is already aborted, or asynchronously if it fires while
// the handler is still pending.
const sig = init?.signal;
if (sig?.aborted) {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
return await Promise.race([
handler(captured),
new Promise<Response>((_resolve, reject) => {
sig?.addEventListener(
"abort",
() => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
},
{ once: true }
);
}),
]);
};
return { fetch: fn, calls };
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const BASE = { baseUrl: "http://forge.test:8800", token: "cf_unit_test" };
// ---------------------------------------------------------------------- tests
test("constructor rejects missing baseUrl/token", () => {
// @ts-expect-error — we want to verify runtime guard
assert.throws(() => new Forge({ token: "x" }), ForgeError);
// @ts-expect-error
assert.throws(() => new Forge({ baseUrl: "http://x" }), ForgeError);
});
test("healthz: GET, bearer header, snake→camel mapping", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, {
ok: true,
claude_present: true,
claude_version: "1.2.3 (Claude Code)",
})
);
const forge = new Forge({ ...BASE, fetch });
const h = await forge.healthz();
assert.equal(calls.length, 1);
assert.equal(calls[0]!.method, "GET");
assert.equal(calls[0]!.url, "http://forge.test:8800/healthz");
assert.equal(calls[0]!.headers["authorization"], "Bearer cf_unit_test");
assert.equal(h.ok, true);
assert.equal(h.claudePresent, true);
assert.equal(h.claudeVersion, "1.2.3 (Claude Code)");
});
test("healthz: trailing slashes in baseUrl are stripped", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { ok: true, claude_present: false, claude_version: null })
);
const forge = new Forge({
...BASE,
baseUrl: "http://forge.test:8800///",
fetch,
});
await forge.healthz();
assert.equal(calls[0]!.url, "http://forge.test:8800/healthz");
});
test("run: POST, JSON body camel→snake, parses JSON result", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, {
ok: true,
result: { hello: "world" },
duration_ms: 4321,
stop_reason: "end_turn",
})
);
const forge = new Forge({ ...BASE, fetch });
const r = await forge.run({
prompt: "Reply with JSON",
model: "sonnet",
system: "Be terse.",
timeoutSecs: 60,
});
assert.equal(calls.length, 1);
assert.equal(calls[0]!.method, "POST");
assert.equal(calls[0]!.url, "http://forge.test:8800/run");
assert.equal(calls[0]!.headers["content-type"], "application/json");
const sent = JSON.parse(calls[0]!.bodyText!);
assert.deepEqual(sent, {
prompt: "Reply with JSON",
model: "sonnet",
system: "Be terse.",
timeout_secs: 60,
});
assert.equal(r.ok, true);
assert.deepEqual(r.result, { hello: "world" });
assert.equal(r.durationMs, 4321);
assert.equal(r.stopReason, "end_turn");
});
test("run: result can also be a plain string", async () => {
const { fetch } = makeMockFetch(() =>
jsonResponse(200, {
ok: true,
result: "just text, no json this time",
duration_ms: 100,
stop_reason: "end_turn",
})
);
const forge = new Forge({ ...BASE, fetch });
const r = await forge.run({ prompt: "say hi" });
assert.equal(typeof r.result, "string");
assert.equal(r.result, "just text, no json this time");
});
test("run: empty prompt rejected client-side", async () => {
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {}));
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.run({ prompt: "" }), ForgeError);
assert.equal(calls.length, 0, "must not hit the network");
});
test("run: 502 → ForgeAPIError with camelCased body", async () => {
const { fetch } = makeMockFetch(() =>
jsonResponse(502, {
ok: false,
error: "subprocess timeout",
stderr: "...claude killed by timeout...",
duration_ms: 60_000,
stop_reason: "timeout",
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(
forge.run({ prompt: "hello", timeoutSecs: 60 }),
(e: unknown) => {
assert.ok(e instanceof ForgeAPIError);
assert.equal((e as ForgeAPIError).status, 502);
const body = (e as ForgeAPIError).body as RunErrorBody;
assert.equal(body.ok, false);
assert.equal(body.error, "subprocess timeout");
assert.equal(body.durationMs, 60_000);
assert.equal(body.stopReason, "timeout");
assert.match(body.stderr, /killed by timeout/);
return true;
}
);
});
test("run: 403 → ForgeAuthError", async () => {
const { fetch } = makeMockFetch(
() =>
new Response(JSON.stringify({ detail: "unknown or disabled token" }), {
status: 403,
headers: { "Content-Type": "application/json" },
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
assert.ok(e instanceof ForgeAuthError);
assert.equal((e as ForgeAuthError).status, 403);
return true;
});
});
test("run: AbortSignal cancellation surfaces as ForgeTransportError(aborted=true)", async () => {
const { fetch } = makeMockFetch(async () => {
// Never resolve naturally — only abort can end this.
await new Promise<void>(() => {});
return jsonResponse(200, {});
});
const forge = new Forge({ ...BASE, fetch });
const ac = new AbortController();
const p = forge.run({ prompt: "long task", signal: ac.signal });
setImmediate(() => ac.abort());
await assert.rejects(p, (e: unknown) => {
assert.ok(e instanceof ForgeTransportError);
assert.equal((e as ForgeTransportError).aborted, true);
return true;
});
});
test("run: file_token list is forwarded under snake_case key", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, {
ok: true,
result: "ok",
duration_ms: 1,
stop_reason: "end_turn",
})
);
const forge = new Forge({ ...BASE, fetch });
await forge.run({ prompt: "extract", files: ["ff_one", "ff_two"] });
const sent = JSON.parse(calls[0]!.bodyText!);
assert.deepEqual(sent.files, ["ff_one", "ff_two"]);
});
test("uploadFile: from disk path, multipart includes file + ttl_secs", async () => {
const tmp = join(tmpdir(), `clawdforge-ts-test-${Date.now()}.bin`);
writeFileSync(tmp, Buffer.from("fake recipe pixels"));
try {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, {
file_token: "ff_abc123",
ttl_secs: 3600,
size: 18,
})
);
const forge = new Forge({ ...BASE, fetch });
const ft = await forge.uploadFile(tmp);
assert.equal(ft.fileToken, "ff_abc123");
assert.equal(ft.ttlSecs, 3600);
assert.equal(ft.size, 18);
const c = calls[0]!;
assert.equal(c.method, "POST");
assert.equal(c.url, "http://forge.test:8800/files");
assert.ok(c.bodyForm, "expected multipart form body");
assert.equal(c.bodyForm!.get("ttl_secs"), "3600");
const filePart = c.bodyForm!.get("file");
assert.ok(
filePart && typeof filePart === "object" && "size" in filePart,
"expected a Blob/File under field 'file'"
);
} finally {
try {
unlinkSync(tmp);
} catch {
/* ignore */
}
}
});
test("uploadFile: ttlSecs out of range rejected client-side", async () => {
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {}));
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(
forge.uploadFile(new Uint8Array([1, 2, 3]), { ttlSecs: 30 }),
ForgeError
);
await assert.rejects(
forge.uploadFile(new Uint8Array([1, 2, 3]), { ttlSecs: 99999 }),
ForgeError
);
assert.equal(calls.length, 0);
});
test("uploadFile: from Uint8Array, default filename 'upload'", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 3 })
);
const forge = new Forge({ ...BASE, fetch });
await forge.uploadFile(new Uint8Array([1, 2, 3]), { filename: "foo.png" });
const filePart = calls[0]!.bodyForm!.get("file");
// FormData.get on a File-like returns something with a `name` property.
assert.ok(filePart && typeof filePart === "object");
if ("name" in filePart) {
assert.equal((filePart as { name: string }).name, "foo.png");
}
});
test("admin: createToken / listTokens / revokeToken round-trip", async () => {
const { fetch, calls } = makeMockFetch((req) => {
if (req.method === "POST" && req.url.endsWith("/admin/tokens")) {
const sent = JSON.parse(req.bodyText!);
return jsonResponse(200, {
name: sent.name,
token: "cf_freshly_minted",
ip_cidrs: sent.ip_cidrs,
});
}
if (req.method === "GET" && req.url.endsWith("/admin/tokens")) {
return jsonResponse(200, {
tokens: [
{
name: "alpha",
ip_cidrs: "10.0.0.0/8",
created_at: 1700000000,
last_used: 1700000500,
enabled: 1,
},
{
name: "beta",
ip_cidrs: "",
created_at: 1700001000,
last_used: null,
enabled: 0,
},
],
});
}
if (req.method === "DELETE" && req.url.endsWith("/admin/tokens/alpha")) {
return jsonResponse(200, { ok: true });
}
if (req.method === "DELETE" && req.url.endsWith("/admin/tokens/missing")) {
return jsonResponse(404, { detail: "no such token" });
}
return jsonResponse(500, { detail: "unhandled" });
});
const forge = new Forge({ ...BASE, fetch });
const created = await forge.createToken({
name: "alpha",
ipCidrs: ["10.0.0.0/8"],
});
assert.equal(created.name, "alpha");
assert.equal(created.token, "cf_freshly_minted");
assert.deepEqual(created.ipCidrs, ["10.0.0.0/8"]);
const tokens = await forge.listTokens();
assert.equal(tokens.length, 2);
assert.equal(tokens[0]!.name, "alpha");
assert.equal(tokens[0]!.createdAt, 1700000000);
assert.equal(tokens[0]!.lastUsed, 1700000500);
assert.equal(tokens[0]!.enabled, 1);
assert.equal(tokens[1]!.lastUsed, null);
assert.equal(tokens[1]!.enabled, 0);
const revoked = await forge.revokeToken("alpha");
assert.equal(revoked, true);
const missing = await forge.revokeToken("missing");
assert.equal(missing, false);
// Ensure URL encoding works on names.
assert.ok(
calls.some((c) => c.method === "DELETE" && c.url.endsWith("/admin/tokens/alpha"))
);
});
test("non-JSON 200 body → ForgeAPIError", async () => {
const { fetch } = makeMockFetch(
() =>
new Response("<!doctype html>oops", {
status: 200,
headers: { "Content-Type": "text/html" },
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.healthz(), ForgeAPIError);
});
test("network error → ForgeTransportError(aborted=false)", async () => {
const fetchFn: typeof fetch = async () => {
throw new TypeError("fetch failed: ECONNREFUSED");
};
const forge = new Forge({ ...BASE, fetch: fetchFn });
await assert.rejects(forge.healthz(), (e: unknown) => {
assert.ok(e instanceof ForgeTransportError);
assert.equal((e as ForgeTransportError).aborted, false);
return true;
});
});
test("default timeout fires → ForgeTransportError(aborted=true)", async () => {
const fetchFn: typeof fetch = async (_input, init) => {
return await new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
},
{ once: true }
);
});
};
const forge = new Forge({ ...BASE, fetch: fetchFn, defaultTimeoutMs: 25 });
// The SDK's internal timer is unref'd so it doesn't keep the process
// alive in the wild. In a test the event loop would unwind too quickly,
// so we hold a timer to keep things ticking until the abort fires.
const keepalive = setInterval(() => {}, 5);
try {
await assert.rejects(forge.healthz(), (e: unknown) => {
assert.ok(e instanceof ForgeTransportError);
assert.equal((e as ForgeTransportError).aborted, true);
return true;
});
} finally {
clearInterval(keepalive);
}
});

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022", "DOM"],
"strict": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "examples"]
}