diff --git a/clients/php/.gitignore b/clients/php/.gitignore
new file mode 100644
index 0000000..e8c60e9
--- /dev/null
+++ b/clients/php/.gitignore
@@ -0,0 +1,4 @@
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/.phpunit.result.cache
diff --git a/clients/php/README.md b/clients/php/README.md
new file mode 100644
index 0000000..34e7b52
--- /dev/null
+++ b/clients/php/README.md
@@ -0,0 +1,256 @@
+# clawdforge — PHP SDK
+
+PHP 8.2+ client for the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/clawdforge) LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API.
+
+- **PSR-4 autoload**, **PSR-12** code style, **strict types** everywhere.
+- **Guzzle 7** transport, injectable `ClientInterface` for tests / custom middleware.
+- **Readonly value objects** for request input, run results, file tokens, app tokens.
+- **Typed exception hierarchy** so callers can discriminate auth / api / transport failures without sniffing status codes.
+
+---
+
+## Install
+
+Once published, install via Composer:
+
+```bash
+composer require clawdforge/clawdforge
+```
+
+To install from a local checkout (e.g. while the SDK lives in this repo):
+
+```jsonc
+{
+ "repositories": [
+ { "type": "path", "url": "../path/to/clawdforge/clients/php" }
+ ],
+ "require": {
+ "clawdforge/clawdforge": "*"
+ }
+}
+```
+
+## Quickstart
+
+```php
+healthz();
+echo $health['claude_version'], "\n";
+
+// Run a prompt.
+try {
+ $result = $forge->run(new RunRequest(
+ prompt: 'Reply with JSON: {"hello": "world"}',
+ model: 'sonnet',
+ system: 'Be terse.',
+ timeoutSecs: 60,
+ ));
+} catch (ForgeException $e) {
+ // ForgeException is the abstract base; sub-types tell you what failed.
+ fwrite(STDERR, "forge: {$e->getMessage()}\n");
+ exit(1);
+}
+
+echo "{$result->durationMs}ms, stop_reason={$result->stopReason}\n";
+
+// `result` is mixed: array if the model produced valid JSON, string otherwise.
+if (is_array($result->result)) {
+ echo $result->result['hello'] ?? 'no key', "\n";
+} else {
+ echo (string) $result->result, "\n";
+}
+```
+
+## API surface
+
+| Method | Endpoint | Purpose |
+|---|---|---|
+| `Client::healthz()` | `GET /healthz` | Liveness + `claude --version`. |
+| `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. |
+| `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file, return a `FileToken`. |
+| `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). |
+| `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). |
+| `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). |
+
+### Files in a prompt
+
+```php
+$ft = $forge->uploadFile('./recipe.png', ttlSecs: 3600);
+$out = $forge->run(new RunRequest(
+ prompt: 'extract recipe data',
+ files: [$ft->fileToken],
+));
+```
+
+### Naming convention
+
+PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside `RunRequest::toWire()` and the response object factories.
+
+| PHP | Wire |
+|------------------------------|----------------|
+| `RunRequest::$timeoutSecs` | `timeout_secs` |
+| `FileToken::$fileToken` | `file_token` |
+| `FileToken::$ttlSecs` | `ttl_secs` |
+| `RunResult::$durationMs` | `duration_ms` |
+| `RunResult::$stopReason` | `stop_reason` |
+| `AppToken::$ipCidrs` | `ip_cidrs` |
+
+### Exceptions
+
+```
+Clawdforge\Exception\ForgeException (abstract base)
+├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded)
+│ └── Clawdforge\Exception\AuthException (401/403)
+└── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS)
+```
+
+A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish.
+
+A `/run` failure (subprocess timeout, claude crash, etc.) lands as a 502 with a JSON envelope — the SDK throws `ApiException` with `$statusCode = 502` and the envelope available as `$decoded` (`error`, `stderr`, `duration_ms`, `stop_reason`).
+
+### Custom HTTP client
+
+The constructor accepts any `GuzzleHttp\ClientInterface`, which lets you add middleware (retries, logging, circuit breakers) or swap to a mock handler in tests:
+
+```php
+use GuzzleHttp\Client as GuzzleClient;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+
+$stack = HandlerStack::create();
+$stack->push(Middleware::retry(/* decider */, /* delay */));
+
+$forge = new Client(
+ baseUrl: 'http://localhost:8800',
+ token: $token,
+ http: new GuzzleClient(['handler' => $stack, 'http_errors' => false]),
+);
+```
+
+## Framework snippets
+
+### Laravel
+
+Bind the client as a singleton in `app/Providers/AppServiceProvider.php`:
+
+```php
+use Clawdforge\Client;
+
+public function register(): void
+{
+ $this->app->singleton(Client::class, function () {
+ return new Client(
+ baseUrl: config('services.clawdforge.url'),
+ token: config('services.clawdforge.token'),
+ );
+ });
+}
+```
+
+`config/services.php`:
+
+```php
+'clawdforge' => [
+ 'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'),
+ 'token' => env('CLAWDFORGE_TOKEN'),
+],
+```
+
+Then inject anywhere:
+
+```php
+use Clawdforge\Client;
+use Clawdforge\RunRequest;
+
+class RecipeParser
+{
+ public function __construct(private readonly Client $forge) {}
+
+ public function parse(string $line): array
+ {
+ $r = $this->forge->run(new RunRequest(
+ prompt: "Sterilize this ingredient line: '{$line}'",
+ system: 'You are a precise recipe parser. Always reply with valid JSON.',
+ timeoutSecs: 30,
+ ));
+ return is_array($r->result) ? $r->result : [];
+ }
+}
+```
+
+### WordPress
+
+```php
+require_once ABSPATH . 'vendor/autoload.php';
+
+use Clawdforge\Client;
+use Clawdforge\RunRequest;
+use Clawdforge\Exception\ForgeException;
+
+function my_plugin_clawdforge(): Clawdforge\Client {
+ static $client = null;
+ if ($client === null) {
+ $client = new Client(
+ baseUrl: defined('CLAWDFORGE_URL') ? CLAWDFORGE_URL : 'http://localhost:8800',
+ token: defined('CLAWDFORGE_TOKEN') ? CLAWDFORGE_TOKEN : '',
+ );
+ }
+ return $client;
+}
+
+add_action('rest_api_init', function () {
+ register_rest_route('myplugin/v1', '/summarize', [
+ 'methods' => 'POST',
+ 'permission_callback' => fn() => current_user_can('edit_posts'),
+ 'callback' => function (WP_REST_Request $req) {
+ try {
+ $r = my_plugin_clawdforge()->run(new RunRequest(
+ prompt: 'Summarize: ' . (string) $req->get_param('text'),
+ timeoutSecs: 30,
+ ));
+ return new WP_REST_Response(['summary' => $r->result], 200);
+ } catch (ForgeException $e) {
+ return new WP_Error('clawdforge', $e->getMessage(), ['status' => 502]);
+ }
+ },
+ ]);
+});
+```
+
+## Testing
+
+The SDK ships with PHPUnit 10 tests that mock Guzzle via `GuzzleHttp\Handler\MockHandler` — no live network.
+
+```bash
+composer install
+vendor/bin/phpunit
+```
+
+`php -l` over every source file:
+
+```bash
+find src -name '*.php' -exec php -l {} \;
+```
+
+## Design notes
+
+- **No retries.** `claude -p` runs are not idempotent (they spawn subprocesses with side effects). Wrap calls in your own retry layer if you need one — Guzzle's `Middleware::retry` plugs right into the constructor.
+- **HTTP timeout = subprocess timeout + 30 s margin.** Keeps the client from bailing while clawdforge is still doing legitimate work for us. Override via the `httpTimeoutMargin` constructor argument.
+- **Streamed file uploads.** `uploadFile()` opens the file via `fopen($path, 'r')` and hands the resource to Guzzle's multipart adapter; payloads never get slurped into memory.
+- **Plaintext token discipline.** `createToken()` returns the plaintext bearer in `AppToken::$token` exactly once. `listTokens()` always returns `null` for that field — the server keeps only a sha256 hash. Persist immediately on mint.
+
+## License
+
+MIT.
diff --git a/clients/php/composer.json b/clients/php/composer.json
new file mode 100644
index 0000000..14af00d
--- /dev/null
+++ b/clients/php/composer.json
@@ -0,0 +1,47 @@
+{
+ "name": "clawdforge/clawdforge",
+ "description": "PHP SDK for the clawdforge LAN-only HTTP service (claude -p subprocess wrapper).",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "clawdforge",
+ "claude",
+ "anthropic",
+ "sdk",
+ "http-client"
+ ],
+ "homepage": "http://192.168.0.5:3001/Sulkta-Coop/clawdforge",
+ "authors": [
+ {
+ "name": "Kayos",
+ "email": "kayos@sulkta.com"
+ }
+ ],
+ "require": {
+ "php": "^8.2",
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "^7.5"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "autoload": {
+ "psr-4": {
+ "Clawdforge\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Clawdforge\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "lint": "find src -name '*.php' -exec php -l {} \\;"
+ },
+ "config": {
+ "sort-packages": true,
+ "optimize-autoloader": true
+ },
+ "minimum-stability": "stable"
+}
diff --git a/clients/php/examples/basic.php b/clients/php/examples/basic.php
new file mode 100644
index 0000000..cfac825
--- /dev/null
+++ b/clients/php/examples/basic.php
@@ -0,0 +1,49 @@
+healthz();
+ fwrite(STDOUT, "claude_version: " . ($health['claude_version'] ?? 'unknown') . "\n");
+
+ $result = $forge->run(new RunRequest(
+ prompt: 'Reply with JSON: {"hello": "world"}',
+ model: 'sonnet',
+ timeoutSecs: 30,
+ ));
+
+ fwrite(STDOUT, "duration_ms: {$result->durationMs}, stop_reason: {$result->stopReason}\n");
+ if (is_array($result->result)) {
+ fwrite(STDOUT, "json: " . json_encode($result->result) . "\n");
+ } else {
+ fwrite(STDOUT, "text: " . (string) $result->result . "\n");
+ }
+} catch (ForgeException $e) {
+ fwrite(STDERR, "forge error: {$e->getMessage()}\n");
+ exit(1);
+}
diff --git a/clients/php/phpunit.xml b/clients/php/phpunit.xml
new file mode 100644
index 0000000..8f2f5e1
--- /dev/null
+++ b/clients/php/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/clients/php/src/AppToken.php b/clients/php/src/AppToken.php
new file mode 100644
index 0000000..8dd75e4
--- /dev/null
+++ b/clients/php/src/AppToken.php
@@ -0,0 +1,81 @@
+ $ipCidrs CIDR allowlist for this token
+ */
+ public function __construct(
+ public string $name,
+ public ?string $token = null,
+ public array $ipCidrs = [],
+ public ?int $createdAt = null,
+ public ?int $lastUsed = null,
+ public bool $enabled = true,
+ ) {
+ }
+
+ /**
+ * Build from `POST /admin/tokens` response (includes plaintext token).
+ *
+ * @param array $payload
+ */
+ public static function fromCreateResponse(array $payload): self
+ {
+ $cidrs = $payload['ip_cidrs'] ?? [];
+ return new self(
+ name: (string) ($payload['name'] ?? ''),
+ token: isset($payload['token']) ? (string) $payload['token'] : null,
+ ipCidrs: self::normalizeCidrs($cidrs),
+ );
+ }
+
+ /**
+ * Build from a `GET /admin/tokens` row (no plaintext token).
+ *
+ * @param array $row
+ */
+ public static function fromListRow(array $row): self
+ {
+ return new self(
+ name: (string) ($row['name'] ?? ''),
+ token: null,
+ ipCidrs: self::normalizeCidrs($row['ip_cidrs'] ?? ''),
+ createdAt: isset($row['created_at']) ? (int) $row['created_at'] : null,
+ lastUsed: isset($row['last_used']) && $row['last_used'] !== null ? (int) $row['last_used'] : null,
+ enabled: (bool) ($row['enabled'] ?? true),
+ );
+ }
+
+ /**
+ * Server's `list_tokens` returns ip_cidrs as a comma-joined string; the
+ * create endpoint returns it as a list. Normalize both into list.
+ *
+ * @param mixed $raw
+ * @return list
+ */
+ private static function normalizeCidrs(mixed $raw): array
+ {
+ if (is_array($raw)) {
+ return array_values(array_filter(
+ array_map(static fn ($v): string => (string) $v, $raw),
+ static fn (string $s): bool => $s !== '',
+ ));
+ }
+ if (is_string($raw) && $raw !== '') {
+ return array_values(array_filter(explode(',', $raw), static fn (string $s): bool => $s !== ''));
+ }
+ return [];
+ }
+}
diff --git a/clients/php/src/Client.php b/clients/php/src/Client.php
new file mode 100644
index 0000000..0309c1b
--- /dev/null
+++ b/clients/php/src/Client.php
@@ -0,0 +1,351 @@
+baseUrl = rtrim($baseUrl, '/');
+ $this->http = $http ?? new GuzzleClient([
+ 'http_errors' => false,
+ ]);
+ }
+
+ // --- /healthz ---------------------------------------------------------
+
+ /**
+ * `GET /healthz` — liveness + `claude --version` smoke check.
+ *
+ * @return array{ok: bool, claude_present: bool, claude_version: ?string}
+ *
+ * @throws TransportException
+ * @throws ApiException
+ */
+ public function healthz(): array
+ {
+ $resp = $this->send('GET', '/healthz', timeoutSecs: self::HEALTHZ_TIMEOUT_SECS);
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (!is_array($decoded)) {
+ throw new ApiException(
+ 'unexpected /healthz response (not a JSON object)',
+ $resp->getStatusCode(),
+ (string) $resp->getBody(),
+ );
+ }
+
+ /** @var array{ok: bool, claude_present: bool, claude_version: ?string} $decoded */
+ return $decoded;
+ }
+
+ // --- /run -------------------------------------------------------------
+
+ /**
+ * `POST /run` — run a prompt through `claude -p`.
+ *
+ * @throws ApiException server returned 4xx/5xx (including 502 for subprocess timeout)
+ * @throws AuthException 401/403 (bad token / IP not allowed)
+ * @throws TransportException connection-level failure
+ */
+ public function run(RunRequest $request): RunResult
+ {
+ $body = $request->toWire();
+ if (!isset($body['model'])) {
+ $body['model'] = $this->defaultModel;
+ }
+ $effectiveRunTimeout = $request->timeoutSecs ?? $this->defaultTimeoutSecs;
+ $httpTimeout = $effectiveRunTimeout + $this->httpTimeoutMargin;
+
+ $resp = $this->send('POST', '/run', json: $body, timeoutSecs: $httpTimeout);
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (!is_array($decoded)) {
+ throw new ApiException(
+ 'unexpected /run response (not a JSON object)',
+ $resp->getStatusCode(),
+ (string) $resp->getBody(),
+ );
+ }
+
+ return RunResult::fromResponse($decoded);
+ }
+
+ // --- /files -----------------------------------------------------------
+
+ /**
+ * `POST /files` — upload a file, get back an `ff_...` token.
+ *
+ * Streams the file via a `fopen($path, 'r')` resource so it never has to
+ * be slurped into memory.
+ *
+ * @param string $path filesystem path to a readable file
+ * @param int $ttlSecs server-side TTL (60..86400). Out-of-range values are rejected with 400.
+ *
+ * @throws InvalidArgumentException path is missing or unreadable
+ * @throws ApiException server returned 4xx/5xx
+ * @throws AuthException 401/403
+ * @throws TransportException connection-level failure
+ */
+ public function uploadFile(string $path, int $ttlSecs = 3600): FileToken
+ {
+ if (!is_file($path) || !is_readable($path)) {
+ throw new InvalidArgumentException("file not readable: {$path}");
+ }
+
+ $stream = Utils::tryFopen($path, 'r');
+ $resp = $this->send(
+ 'POST',
+ '/files',
+ multipart: [
+ ['name' => 'ttl_secs', 'contents' => (string) $ttlSecs],
+ [
+ 'name' => 'file',
+ 'contents' => $stream,
+ 'filename' => basename($path),
+ ],
+ ],
+ timeoutSecs: $this->defaultTimeoutSecs + $this->httpTimeoutMargin,
+ );
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (!is_array($decoded)) {
+ throw new ApiException(
+ 'unexpected /files response (not a JSON object)',
+ $resp->getStatusCode(),
+ (string) $resp->getBody(),
+ );
+ }
+
+ return FileToken::fromResponse($decoded);
+ }
+
+ // --- /admin/tokens ---------------------------------------------------
+
+ /**
+ * `POST /admin/tokens` — mint a per-app token (admin-bootstrap-token gated).
+ *
+ * The returned {@see AppToken::$token} holds the plaintext bearer; the
+ * server only retains a sha256 hash, so persist this immediately.
+ *
+ * @param list $ipCidrs optional per-app IP allowlist
+ */
+ public function createToken(string $name, array $ipCidrs = []): AppToken
+ {
+ $resp = $this->send(
+ 'POST',
+ '/admin/tokens',
+ json: ['name' => $name, 'ip_cidrs' => array_values($ipCidrs)],
+ timeoutSecs: self::ADMIN_TIMEOUT_SECS,
+ );
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (!is_array($decoded)) {
+ throw new ApiException(
+ 'unexpected /admin/tokens response',
+ $resp->getStatusCode(),
+ (string) $resp->getBody(),
+ );
+ }
+
+ return AppToken::fromCreateResponse($decoded);
+ }
+
+ /**
+ * `GET /admin/tokens` — list known app tokens (admin-bootstrap-token gated).
+ *
+ * @return list
+ */
+ public function listTokens(): array
+ {
+ $resp = $this->send('GET', '/admin/tokens', timeoutSecs: self::ADMIN_TIMEOUT_SECS);
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (!is_array($decoded) || !isset($decoded['tokens']) || !is_array($decoded['tokens'])) {
+ throw new ApiException(
+ 'unexpected /admin/tokens list response',
+ $resp->getStatusCode(),
+ (string) $resp->getBody(),
+ );
+ }
+
+ $out = [];
+ foreach ($decoded['tokens'] as $row) {
+ if (is_array($row)) {
+ $out[] = AppToken::fromListRow($row);
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * `DELETE /admin/tokens/` — revoke a token (admin-bootstrap-token gated).
+ *
+ * Returns `true` on success. A 404 (no such token) raises {@see ApiException}
+ * rather than returning `false`, matching the server's contract — so callers
+ * can tell "missing" apart from "revoked".
+ */
+ public function revokeToken(string $name): bool
+ {
+ if ($name === '') {
+ throw new InvalidArgumentException('name is required');
+ }
+ $resp = $this->send('DELETE', '/admin/tokens/' . rawurlencode($name), timeoutSecs: self::ADMIN_TIMEOUT_SECS);
+ $decoded = $this->decode($resp);
+ $this->ensureSuccess($resp, $decoded);
+
+ if (is_array($decoded) && array_key_exists('ok', $decoded)) {
+ return (bool) $decoded['ok'];
+ }
+ return true;
+ }
+
+ // --- internals --------------------------------------------------------
+
+ /**
+ * Send a request, normalize Guzzle transport errors, return the response.
+ *
+ * @param array|null $json
+ * @param list>|null $multipart
+ */
+ private function send(
+ string $method,
+ string $path,
+ ?array $json = null,
+ ?array $multipart = null,
+ int $timeoutSecs = self::HEALTHZ_TIMEOUT_SECS,
+ ): ResponseInterface {
+ $options = [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $this->token,
+ 'Accept' => 'application/json',
+ ],
+ 'timeout' => $timeoutSecs,
+ 'http_errors' => false,
+ ];
+ if ($json !== null) {
+ $options['json'] = $json;
+ }
+ if ($multipart !== null) {
+ $options['multipart'] = $multipart;
+ }
+
+ try {
+ return $this->http->request($method, $this->baseUrl . $path, $options);
+ } catch (GuzzleException $e) {
+ throw new TransportException("transport: {$e->getMessage()}", 0, $e);
+ }
+ }
+
+ /**
+ * Try to JSON-decode the body, returning the decoded value on success or
+ * `null` if the body is empty / not JSON. Errors are not raised here —
+ * higher-level callers decide whether a missing JSON body is fatal based
+ * on the status code.
+ */
+ private function decode(ResponseInterface $resp): mixed
+ {
+ $body = (string) $resp->getBody();
+ if ($body === '') {
+ return null;
+ }
+ try {
+ return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ return null;
+ }
+ }
+
+ /**
+ * Convert a 4xx/5xx response into the right typed exception.
+ *
+ * @throws ApiException
+ * @throws AuthException
+ */
+ private function ensureSuccess(ResponseInterface $resp, mixed $decoded): void
+ {
+ $status = $resp->getStatusCode();
+ if ($status < 400) {
+ return;
+ }
+
+ $body = (string) $resp->getBody();
+ $short = '';
+ $decodedArr = is_array($decoded) ? $decoded : null;
+ if ($decodedArr !== null) {
+ foreach (['error', 'detail', 'message'] as $field) {
+ if (isset($decodedArr[$field]) && is_string($decodedArr[$field])) {
+ $short = $decodedArr[$field];
+ break;
+ }
+ }
+ } elseif ($body !== '') {
+ $short = substr($body, 0, 200);
+ }
+
+ $reason = $resp->getReasonPhrase() !== '' ? $resp->getReasonPhrase() : 'HTTP ' . $status;
+ $message = trim("{$status} {$reason}: {$short}", " :");
+
+ if ($status === 401 || $status === 403) {
+ throw new AuthException($message, $status, $body, $decodedArr);
+ }
+ throw new ApiException($message, $status, $body, $decodedArr);
+ }
+}
diff --git a/clients/php/src/Exception/ApiException.php b/clients/php/src/Exception/ApiException.php
new file mode 100644
index 0000000..dcfdd38
--- /dev/null
+++ b/clients/php/src/Exception/ApiException.php
@@ -0,0 +1,28 @@
+ $payload decoded `/files` 200 body
+ */
+ public static function fromResponse(array $payload): self
+ {
+ return new self(
+ fileToken: (string) ($payload['file_token'] ?? ''),
+ ttlSecs: (int) ($payload['ttl_secs'] ?? 0),
+ size: (int) ($payload['size'] ?? 0),
+ );
+ }
+}
diff --git a/clients/php/src/RunRequest.php b/clients/php/src/RunRequest.php
new file mode 100644
index 0000000..9cbc03f
--- /dev/null
+++ b/clients/php/src/RunRequest.php
@@ -0,0 +1,76 @@
+,
+ * timeout_secs?: int,
+ * }
+ */
+final readonly class RunRequest
+{
+ /**
+ * @param list|null $files file tokens (`ff_...`) previously returned by `Client::uploadFile()`
+ */
+ public function __construct(
+ public string $prompt,
+ public ?string $model = null,
+ public ?string $system = null,
+ public ?array $files = null,
+ public ?int $timeoutSecs = null,
+ ) {
+ if ($prompt === '') {
+ throw new InvalidArgumentException('prompt must be non-empty');
+ }
+ if ($timeoutSecs !== null && ($timeoutSecs < 5 || $timeoutSecs > 600)) {
+ throw new InvalidArgumentException('timeoutSecs must be in range 5..600');
+ }
+ if ($files !== null) {
+ foreach ($files as $token) {
+ if (!is_string($token) || $token === '') {
+ throw new InvalidArgumentException('files must be a list of non-empty strings');
+ }
+ }
+ }
+ }
+
+ /**
+ * Serialize to the snake_case wire shape the server expects.
+ *
+ * @return WirePayload
+ */
+ public function toWire(): array
+ {
+ $out = ['prompt' => $this->prompt];
+ if ($this->model !== null) {
+ $out['model'] = $this->model;
+ }
+ if ($this->system !== null) {
+ $out['system'] = $this->system;
+ }
+ if ($this->files !== null && $this->files !== []) {
+ $out['files'] = array_values($this->files);
+ }
+ if ($this->timeoutSecs !== null) {
+ $out['timeout_secs'] = $this->timeoutSecs;
+ }
+ return $out;
+ }
+}
diff --git a/clients/php/src/RunResult.php b/clients/php/src/RunResult.php
new file mode 100644
index 0000000..3c8ace5
--- /dev/null
+++ b/clients/php/src/RunResult.php
@@ -0,0 +1,42 @@
+result)) { ... }
+ * elseif (is_string($result->result)) { ... }
+ *
+ * `$ok` is always true for a RunResult — failures raise {@see \Clawdforge\Exception\ApiException}.
+ */
+final readonly class RunResult
+{
+ public function __construct(
+ public bool $ok,
+ public mixed $result,
+ public int $durationMs,
+ public ?string $stopReason = null,
+ ) {
+ }
+
+ /**
+ * @param array $payload decoded `/run` 200 body
+ */
+ public static function fromResponse(array $payload): self
+ {
+ $stopReason = $payload['stop_reason'] ?? null;
+ return new self(
+ ok: (bool) ($payload['ok'] ?? true),
+ result: $payload['result'] ?? null,
+ durationMs: (int) ($payload['duration_ms'] ?? 0),
+ stopReason: is_string($stopReason) ? $stopReason : null,
+ );
+ }
+}
diff --git a/clients/php/tests/ClientTest.php b/clients/php/tests/ClientTest.php
new file mode 100644
index 0000000..e292698
--- /dev/null
+++ b/clients/php/tests/ClientTest.php
@@ -0,0 +1,340 @@
+ $responses
+ * @param array}> $history captured (request, options) pairs
+ */
+ private function makeClient(array $responses, array &$history = []): Client
+ {
+ $mock = new MockHandler($responses);
+ $stack = HandlerStack::create($mock);
+ $stack->push(Middleware::history($history));
+ $guzzle = new GuzzleClient(['handler' => $stack, 'http_errors' => false]);
+
+ return new Client(self::BASE_URL, self::TOKEN, $guzzle);
+ }
+
+ public function testHealthzReturnsDecodedJson(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
+ 'ok' => true,
+ 'claude_present' => true,
+ 'claude_version' => '1.2.3',
+ ])),
+ ], $history);
+
+ $health = $client->healthz();
+
+ self::assertTrue($health['ok']);
+ self::assertSame('1.2.3', $health['claude_version']);
+ self::assertCount(1, $history);
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ self::assertSame('GET', $req->getMethod());
+ self::assertSame('/healthz', $req->getUri()->getPath());
+ self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization'));
+ }
+
+ public function testRunSuccessJsonResult(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
+ 'ok' => true,
+ 'result' => ['hello' => 'world'],
+ 'duration_ms' => 1234,
+ 'stop_reason' => 'end_turn',
+ ])),
+ ], $history);
+
+ $r = $client->run(new RunRequest(prompt: 'Reply with JSON: {"hello": "world"}'));
+
+ self::assertInstanceOf(RunResult::class, $r);
+ self::assertTrue($r->ok);
+ self::assertIsArray($r->result);
+ self::assertSame(['hello' => 'world'], $r->result);
+ self::assertSame(1234, $r->durationMs);
+ self::assertSame('end_turn', $r->stopReason);
+
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ $sentBody = json_decode((string) $req->getBody(), true);
+ self::assertIsArray($sentBody);
+ self::assertSame('Reply with JSON: {"hello": "world"}', $sentBody['prompt']);
+ self::assertSame('sonnet', $sentBody['model']); // default
+ self::assertArrayNotHasKey('system', $sentBody);
+ self::assertArrayNotHasKey('files', $sentBody);
+ self::assertArrayNotHasKey('timeout_secs', $sentBody);
+ }
+
+ public function testRunSuccessStringResult(): void
+ {
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode([
+ 'ok' => true,
+ 'result' => 'plain text reply',
+ 'duration_ms' => 800,
+ 'stop_reason' => 'end_turn',
+ ])),
+ ]);
+
+ $r = $client->run(new RunRequest(prompt: 'hi'));
+
+ self::assertIsString($r->result);
+ self::assertSame('plain text reply', $r->result);
+ }
+
+ public function testRunSendsExpectedBodyWithAllFields(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode([
+ 'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn',
+ ])),
+ ], $history);
+
+ $client->run(new RunRequest(
+ prompt: 'hi',
+ model: 'opus',
+ system: 'be terse',
+ files: ['ff_abc'],
+ timeoutSecs: 42,
+ ));
+
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ $sent = json_decode((string) $req->getBody(), true);
+ self::assertSame([
+ 'prompt' => 'hi',
+ 'model' => 'opus',
+ 'system' => 'be terse',
+ 'files' => ['ff_abc'],
+ 'timeout_secs' => 42,
+ ], $sent);
+ }
+
+ public function testRun502RaisesApiExceptionWithDecodedEnvelope(): void
+ {
+ $client = $this->makeClient([
+ new Response(502, [], (string) json_encode([
+ 'ok' => false,
+ 'error' => 'subprocess timed out',
+ 'stderr' => '...',
+ 'duration_ms' => 60000,
+ 'stop_reason' => 'timeout',
+ ])),
+ ]);
+
+ try {
+ $client->run(new RunRequest(prompt: 'hi', timeoutSecs: 60));
+ self::fail('expected ApiException');
+ } catch (ApiException $e) {
+ self::assertSame(502, $e->statusCode);
+ self::assertNotNull($e->decoded);
+ self::assertSame('timeout', $e->decoded['stop_reason']);
+ self::assertStringContainsString('subprocess timed out', $e->getMessage());
+ self::assertInstanceOf(ForgeException::class, $e);
+ }
+ }
+
+ public function testRun401RaisesAuthException(): void
+ {
+ $client = $this->makeClient([
+ new Response(401, [], (string) json_encode(['detail' => 'missing bearer'])),
+ ]);
+
+ try {
+ $client->run(new RunRequest(prompt: 'hi'));
+ self::fail('expected AuthException');
+ } catch (AuthException $e) {
+ self::assertSame(401, $e->statusCode);
+ self::assertInstanceOf(ApiException::class, $e);
+ self::assertInstanceOf(ForgeException::class, $e);
+ }
+ }
+
+ public function testRunTransportErrorWraps(): void
+ {
+ $client = $this->makeClient([
+ new ConnectException('Connection refused', new Request('POST', self::BASE_URL . '/run')),
+ ]);
+
+ $this->expectException(TransportException::class);
+ $client->run(new RunRequest(prompt: 'hi'));
+ }
+
+ public function testRunRejectsEmptyPromptLocally(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new RunRequest(prompt: '');
+ }
+
+ public function testUploadFileFromPath(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode([
+ 'file_token' => 'ff_abc123',
+ 'ttl_secs' => 3600,
+ 'size' => 11,
+ ])),
+ ], $history);
+
+ $tmp = tempnam(sys_get_temp_dir(), 'cf_');
+ self::assertNotFalse($tmp);
+ file_put_contents($tmp, 'hello world');
+
+ try {
+ $ft = $client->uploadFile($tmp, 3600);
+ } finally {
+ @unlink($tmp);
+ }
+
+ self::assertInstanceOf(FileToken::class, $ft);
+ self::assertSame('ff_abc123', $ft->fileToken);
+ self::assertSame(3600, $ft->ttlSecs);
+ self::assertSame(11, $ft->size);
+
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ self::assertSame('POST', $req->getMethod());
+ self::assertSame('/files', $req->getUri()->getPath());
+ self::assertStringStartsWith('multipart/form-data', $req->getHeaderLine('Content-Type'));
+ // Multipart body should mention both fields by name.
+ $body = (string) $req->getBody();
+ self::assertStringContainsString('name="ttl_secs"', $body);
+ self::assertStringContainsString('name="file"', $body);
+ }
+
+ public function testCreateTokenRoundTrip(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode([
+ 'name' => 'cauldron',
+ 'token' => 'cf_brandnew_xxx',
+ 'ip_cidrs' => ['172.24.0.0/16'],
+ ])),
+ ], $history);
+
+ $t = $client->createToken('cauldron', ['172.24.0.0/16']);
+
+ self::assertInstanceOf(AppToken::class, $t);
+ self::assertSame('cauldron', $t->name);
+ self::assertSame('cf_brandnew_xxx', $t->token);
+ self::assertSame(['172.24.0.0/16'], $t->ipCidrs);
+
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ $sent = json_decode((string) $req->getBody(), true);
+ self::assertSame(['name' => 'cauldron', 'ip_cidrs' => ['172.24.0.0/16']], $sent);
+ }
+
+ public function testListTokensNormalizesCommaJoinedCidrsAndEnabledFlag(): void
+ {
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode([
+ 'tokens' => [
+ [
+ 'name' => 'cauldron',
+ 'ip_cidrs' => '172.24.0.0/16',
+ 'created_at' => 100,
+ 'last_used' => 200,
+ 'enabled' => 1,
+ ],
+ [
+ 'name' => 'petalparse',
+ 'ip_cidrs' => '',
+ 'created_at' => 50,
+ 'last_used' => null,
+ 'enabled' => 0,
+ ],
+ ],
+ ])),
+ ]);
+
+ $toks = $client->listTokens();
+
+ self::assertCount(2, $toks);
+ self::assertSame('cauldron', $toks[0]->name);
+ self::assertSame(['172.24.0.0/16'], $toks[0]->ipCidrs);
+ self::assertTrue($toks[0]->enabled);
+ self::assertNull($toks[0]->token); // never returned on list
+ self::assertSame(200, $toks[0]->lastUsed);
+
+ self::assertSame([], $toks[1]->ipCidrs);
+ self::assertFalse($toks[1]->enabled);
+ self::assertNull($toks[1]->lastUsed);
+ }
+
+ public function testRevokeTokenReturnsTrueOnOk(): void
+ {
+ $history = [];
+ $client = $this->makeClient([
+ new Response(200, [], (string) json_encode(['ok' => true])),
+ ], $history);
+
+ self::assertTrue($client->revokeToken('cauldron'));
+
+ /** @var \Psr\Http\Message\RequestInterface $req */
+ $req = $history[0]['request'];
+ self::assertSame('DELETE', $req->getMethod());
+ self::assertSame('/admin/tokens/cauldron', $req->getUri()->getPath());
+ }
+
+ public function testRevokeTokenMissingRaises404(): void
+ {
+ $client = $this->makeClient([
+ new Response(404, [], (string) json_encode(['detail' => 'no such token'])),
+ ]);
+
+ try {
+ $client->revokeToken('nosuch');
+ self::fail('expected ApiException');
+ } catch (ApiException $e) {
+ self::assertSame(404, $e->statusCode);
+ }
+ }
+
+ public function testConstructorRequiresBaseUrl(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new Client('', 'cf_x');
+ }
+
+ public function testConstructorRequiresToken(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new Client(self::BASE_URL, '');
+ }
+}