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, ''); + } +}