clients/php: initial PHP SDK for clawdforge

PHP 8.2+ Guzzle-based client mirroring the Python SDK surface:

- Client::healthz / run / uploadFile / createToken / listTokens / revokeToken
- Readonly value objects: RunRequest, RunResult, FileToken, AppToken
- Exception hierarchy: ForgeException (abstract) -> ApiException ->
  AuthException, plus TransportException
- camelCase PHP <-> snake_case wire conversion at the boundary
- Streamed multipart uploads via fopen($path, 'r')
- Injectable GuzzleHttp\ClientInterface (MockHandler-friendly)
- HTTP timeout = subprocess timeout + 30s margin
- 15 PHPUnit tests, 61 assertions, no live network
- README with Laravel + WordPress integration snippets
- MIT license, no Sulkta-specific assumptions
This commit is contained in:
Kayos 2026-04-28 22:40:48 -07:00
parent 093021cb36
commit 1cff9b89d2
15 changed files with 1382 additions and 0 deletions

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

@ -0,0 +1,4 @@
/vendor/
/composer.lock
/.phpunit.cache/
/.phpunit.result.cache

256
clients/php/README.md Normal file
View file

@ -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
<?php
require 'vendor/autoload.php';
use Clawdforge\Client;
use Clawdforge\RunRequest;
use Clawdforge\Exception\ForgeException;
$forge = new Client(
baseUrl: 'http://localhost:8800',
token: 'cf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);
// Liveness + claude --version smoke check.
$health = $forge->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.

47
clients/php/composer.json Normal file
View file

@ -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"
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* Minimal clawdforge usage from PHP.
*
* Set CLAWDFORGE_URL and CLAWDFORGE_TOKEN in the environment before running:
*
* CLAWDFORGE_URL=http://localhost:8800 \
* CLAWDFORGE_TOKEN=cf_xxxxxxxx \
* php examples/basic.php
*/
require __DIR__ . '/../vendor/autoload.php';
use Clawdforge\Client;
use Clawdforge\Exception\ForgeException;
use Clawdforge\RunRequest;
$baseUrl = getenv('CLAWDFORGE_URL') ?: 'http://localhost:8800';
$token = getenv('CLAWDFORGE_TOKEN') ?: '';
if ($token === '') {
fwrite(STDERR, "set CLAWDFORGE_TOKEN in env\n");
exit(2);
}
$forge = new Client($baseUrl, $token);
try {
$health = $forge->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);
}

23
clients/php/phpunit.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
/**
* A row from `GET /admin/tokens` or the result of `POST /admin/tokens`.
*
* `$token` is the plaintext bearer string, returned ONLY at create time.
* On list responses it is `null` the server stores only a sha256 hash.
* Persist the plaintext immediately when minting; you cannot recover it.
*/
final readonly class AppToken
{
/**
* @param list<string> $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<string, mixed> $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<string, mixed> $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<string>.
*
* @param mixed $raw
* @return list<string>
*/
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 [];
}
}

351
clients/php/src/Client.php Normal file
View file

@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
use Clawdforge\Exception\ApiException;
use Clawdforge\Exception\AuthException;
use Clawdforge\Exception\TransportException;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
/**
* Sync HTTP client for the clawdforge service.
*
* One Client per (baseUrl, token) pair; reuse it across calls. The Guzzle
* client is injectable so callers can swap transports, add middleware, or
* mock with `GuzzleHttp\Handler\MockHandler` in tests.
*
* camelCase on the PHP side, snake_case on the wire translation happens
* at the boundary inside this class and {@see RunRequest::toWire()}.
*
* No retry logic is built in. Wrap calls in your own retry layer if you
* need it clawdforge runs are not idempotent (they spawn `claude -p`).
*
* @api
*/
final class Client
{
public const DEFAULT_MODEL = 'sonnet';
public const DEFAULT_RUN_TIMEOUT_SECS = 120;
/**
* Seconds added to the per-run subprocess timeout to derive the HTTP-level
* timeout. Keeps us from bailing while clawdforge is still doing work.
*/
public const HTTP_TIMEOUT_MARGIN_SECS = 30;
public const HEALTHZ_TIMEOUT_SECS = 10;
public const ADMIN_TIMEOUT_SECS = 10;
private readonly string $baseUrl;
private readonly ClientInterface $http;
public function __construct(
string $baseUrl,
private readonly string $token,
?ClientInterface $http = null,
private readonly string $defaultModel = self::DEFAULT_MODEL,
private readonly int $defaultTimeoutSecs = self::DEFAULT_RUN_TIMEOUT_SECS,
private readonly int $httpTimeoutMargin = self::HTTP_TIMEOUT_MARGIN_SECS,
) {
if ($baseUrl === '') {
throw new InvalidArgumentException('baseUrl is required');
}
if ($token === '') {
throw new InvalidArgumentException('token is required');
}
$this->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<string> $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<AppToken>
*/
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/<name>` 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<string, mixed>|null $json
* @param list<array<string, mixed>>|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);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Exception;
use Throwable;
/**
* The server returned a 4xx or 5xx response.
*
* Inspect `$statusCode` for the HTTP status, `$body` for the verbatim response
* body (string), and `$decoded` for the JSON-decoded body when one was returned.
* `/run` failures land here with status 502 and a `decoded` envelope holding
* `error`, `stderr`, `duration_ms`, `stop_reason`.
*/
class ApiException extends ForgeException
{
public function __construct(
string $message,
public readonly int $statusCode,
public readonly string $body = '',
public readonly ?array $decoded = null,
?Throwable $previous = null,
) {
parent::__construct($message, $statusCode, $previous);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Exception;
/**
* 401 / 403 from the server.
*
* Either the bearer token is missing/wrong, or the IP is not in the
* allowlist (global or per-app). Inspect `$body` / `$decoded` for the
* server's message.
*
* `AuthException` extends `ApiException`, so a `catch (ApiException $e)`
* catches both narrow with `instanceof AuthException` when needed.
*/
final class AuthException extends ApiException
{
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Exception;
use RuntimeException;
/**
* Base exception for everything the clawdforge SDK raises.
*
* All other ForgeException subclasses inherit from this, so a single
* `catch (ForgeException $e)` catches the whole family.
*/
abstract class ForgeException extends RuntimeException
{
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Exception;
/**
* The HTTP request never produced a response.
*
* Connection refused, DNS failure, TCP timeout, TLS handshake failure, etc.
* The original Guzzle exception is available via `getPrevious()`.
*/
final class TransportException extends ForgeException
{
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
/**
* Result of a successful `POST /files`.
*
* Pass `$fileToken` (`ff_...`) into a future {@see RunRequest::$files} list to
* attach the upload to a prompt. `$ttlSecs` is the lifetime the server
* actually assigned, which may differ from the value the client requested.
*/
final readonly class FileToken
{
public function __construct(
public string $fileToken,
public int $ttlSecs,
public int $size,
) {
}
/**
* @param array<string, mixed> $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),
);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
use InvalidArgumentException;
/**
* Input for `Client::run()`.
*
* camelCase on the PHP side, snake_case on the wire the conversion happens
* inside {@see self::toWire()} so callers never deal with the JSON shape.
*
* `timeoutSecs` is the per-run subprocess timeout enforced server-side
* (5..600 inclusive on the current server). Pass `null` to let the server
* fall back to its configured default.
*
* @phpstan-type WirePayload array{
* prompt: string,
* model?: string,
* system?: string,
* files?: list<string>,
* timeout_secs?: int,
* }
*/
final readonly class RunRequest
{
/**
* @param list<string>|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;
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
/**
* Result of a successful `POST /run`.
*
* `$result` is whatever clawdforge parsed out of `claude -p --output-format json`:
* an `array` if the model returned valid JSON, otherwise the raw `string`. It
* may also be `null` for an empty completion. Narrow at the call site:
*
* if (is_array($result->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<string, mixed> $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,
);
}
}

View file

@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Tests;
use Clawdforge\AppToken;
use Clawdforge\Client;
use Clawdforge\Exception\ApiException;
use Clawdforge\Exception\AuthException;
use Clawdforge\Exception\ForgeException;
use Clawdforge\Exception\TransportException;
use Clawdforge\FileToken;
use Clawdforge\RunRequest;
use Clawdforge\RunResult;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class ClientTest extends TestCase
{
private const BASE_URL = 'http://localhost:8800';
private const TOKEN = 'cf_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
/**
* @param list<Response|\Throwable> $responses
* @param array<int, array{0: \Psr\Http\Message\RequestInterface, 1: array<string, mixed>}> $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, '');
}
}