diff --git a/clients/csharp/.gitignore b/clients/csharp/.gitignore new file mode 100644 index 0000000..0d81868 --- /dev/null +++ b/clients/csharp/.gitignore @@ -0,0 +1,19 @@ +# Build outputs +bin/ +obj/ + +# IDE +.vs/ +.vscode/ +*.user +*.suo +*.userprefs + +# Test results +TestResults/ +[Cc]overage/ +*.coverage +*.coveragexml + +# NuGet pack output (kept under dist/ but don't commit binaries) +dist/ diff --git a/clients/csharp/Clawdforge.sln b/clients/csharp/Clawdforge.sln new file mode 100644 index 0000000..a8adfc9 --- /dev/null +++ b/clients/csharp/Clawdforge.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9D786BB-785A-4DD2-AA4B-FBCF953A6816}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdforge", "src\Clawdforge\Clawdforge.csproj", "{6251BE4A-3B4B-4C5E-9792-166E28C978BC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7C4F95D8-3F8B-4082-B0EA-174DE5755A93}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clawdforge.Tests", "tests\Clawdforge.Tests\Clawdforge.Tests.csproj", "{A031363E-683D-4479-B65A-032F503F0469}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{F57873C5-4CEA-47F0-BD17-1689B3530587}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Basic", "examples\Basic\Basic.csproj", "{D9F5228E-8D49-4BCC-8942-884B53BF8C40}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6251BE4A-3B4B-4C5E-9792-166E28C978BC}.Release|Any CPU.Build.0 = Release|Any CPU + {A031363E-683D-4479-B65A-032F503F0469}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A031363E-683D-4479-B65A-032F503F0469}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A031363E-683D-4479-B65A-032F503F0469}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A031363E-683D-4479-B65A-032F503F0469}.Release|Any CPU.Build.0 = Release|Any CPU + {D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9F5228E-8D49-4BCC-8942-884B53BF8C40}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6251BE4A-3B4B-4C5E-9792-166E28C978BC} = {C9D786BB-785A-4DD2-AA4B-FBCF953A6816} + {A031363E-683D-4479-B65A-032F503F0469} = {7C4F95D8-3F8B-4082-B0EA-174DE5755A93} + {D9F5228E-8D49-4BCC-8942-884B53BF8C40} = {F57873C5-4CEA-47F0-BD17-1689B3530587} + EndGlobalSection +EndGlobal diff --git a/clients/csharp/README.md b/clients/csharp/README.md new file mode 100644 index 0000000..4526d49 --- /dev/null +++ b/clients/csharp/README.md @@ -0,0 +1,284 @@ +# clawdforge C# / .NET SDK + +C# / .NET 8 SDK for the LAN-only `clawdforge` HTTP service — a thin REST +wrapper around `claude -p` subprocess invocations. + +- **Target framework:** `net8.0` (LTS). +- **HTTP:** `System.Net.Http.HttpClient` — `IHttpClientFactory` friendly. +- **JSON:** `System.Text.Json` (no Newtonsoft). +- **Async:** every I/O method returns `Task` and accepts a + `CancellationToken`. No sync wrappers. +- **Nullable reference types:** enabled. Warnings as errors. +- **License:** MIT. + +## Install + +The package isn't published to nuget.org (LAN-only service). You have two +local install paths. + +### Option A — local NuGet feed via `dotnet pack` + +From this directory: + +```sh +dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist +``` + +That drops `Clawdforge.0.1.0.nupkg` (and `.snupkg` symbols) under `./dist/`. +Add it as a source on the consumer machine: + +```sh +dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \ + --name clawdforge-local +dotnet add package Clawdforge --version 0.1.0 +``` + +### Option B — project reference + +For sibling checkouts inside the same monorepo: + +```xml + + + +``` + +## Quickstart + +```csharp +using System.Text.Json; +using Clawdforge; +using Clawdforge.Models; + +using var client = new ForgeClient(new ForgeOptions { + BaseUrl = "http://192.168.0.5:8800", + Token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"), +}); + +// 60s wall-clock cap via cancellation, NOT HttpClient.Timeout. +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + +var h = await client.HealthzAsync(cts.Token); +Console.WriteLine($"claude: {h.ClaudeVersion}"); + +var res = await client.RunAsync(new RunRequest { + Prompt = "Reply with JSON: {\"hello\": \"world\"}", + Model = "sonnet", + TimeoutSecs = 60, +}, cts.Token); + +Console.WriteLine($"duration: {res.DurationMs}ms"); + +// res.Result is JsonElement — inspect ValueKind, then narrow. +if (res.Result.ValueKind == JsonValueKind.Object) { + Console.WriteLine(res.Result.GetProperty("hello").GetString()); +} +``` + +## DI / `IHttpClientFactory` + +Inject an external `HttpClient` so it shares the host's +connection-pooling, retry, and proxy configuration: + +```csharp +services.AddHttpClient("clawdforge", c => +{ + c.BaseAddress = new Uri("http://192.168.0.5:8800"); + c.Timeout = Timeout.InfiniteTimeSpan; // see "Timeouts" below +}); + +services.AddTransient(sp => +{ + var http = sp.GetRequiredService().CreateClient("clawdforge"); + return new ForgeClient(new ForgeOptions { + BaseUrl = "http://192.168.0.5:8800", + Token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"), + }, http); +}); +``` + +When you pass an external `HttpClient`, `ForgeClient` does NOT dispose it. + +## API surface + +The SDK mirrors the FastAPI surface in `clawdforge/server.py` 1:1. + +### `new ForgeClient(ForgeOptions, HttpClient? = null)` + +`ForgeOptions`: + +| Property | Type | Notes | +|---------------|-------------|--------------------------------------------------------------------| +| `BaseUrl` | `string` | Required. Trailing slash is trimmed. | +| `Token` | `string?` | Bearer token. Optional for `/healthz`. | +| `HttpTimeout` | `TimeSpan?` | Wall-clock cap on the SDK-owned `HttpClient`. Default: `Infinite`. | + +### `HealthzAsync(CancellationToken)` + +`GET /healthz` → `HealthStatus { Ok, ClaudePresent, ClaudeVersion }`. +Bearer token optional; the server only enforces the global IP allowlist +on this endpoint. + +### `RunAsync(RunRequest, CancellationToken)` + +`POST /run` → `RunResult`. The `Result` field is a `JsonElement` because +`claude` may return parsed JSON or plain text. + +```csharp +var res = await client.RunAsync(new RunRequest { + Prompt = "Sterilize: 'about 2 cups cooked white rice'", + Model = "sonnet", + System = "Reply ONLY with valid JSON.", + Files = new[] { "ff_abc123" }, + TimeoutSecs = 30, +}); +``` + +`RunResult` helpers: + +```csharp +// Deserialize into a typed shape: +public record Ingredient(int Qty, string Unit, string Food); +var ing = res.AsJson(); + +// Or pull a string regardless of underlying JSON kind: +string? text = res.AsText(); +``` + +### `UploadFileAsync(string path, int ttlSecs = 0, CancellationToken)` + +`POST /files` — multipart upload from disk. Streams via +`StreamContent(FileStream)`; the file is not buffered into memory. +`ttlSecs == 0` uses the server default of 3600 (valid range 60..86400). + +```csharp +var ft = await client.UploadFileAsync("./recipe.png", ttlSecs: 3600); + +await client.RunAsync(new RunRequest { + Prompt = "extract recipe data", + Files = new[] { ft.Token }, +}); +``` + +### `UploadStreamAsync(Stream, string fileName, int ttlSecs = 0, CancellationToken)` + +Same as `UploadFileAsync` but takes any `Stream`, useful for in-memory +blobs or piped data. The stream is not closed by the SDK. + +### Admin (require admin bootstrap token) + +```csharp +using var admin = new ForgeClient(new ForgeOptions { + BaseUrl = "http://192.168.0.5:8800", + Token = Environment.GetEnvironmentVariable("CLAWDFORGE_ADMIN_TOKEN"), +}); + +var t = await admin.CreateTokenAsync(new CreateTokenRequest { + Name = "petalparse", + IpCidrs = new[] { "172.24.0.0/16" }, +}); +// t.Token is the plaintext — store it now, you can't see it again. + +IReadOnlyList list = await admin.ListTokensAsync(); + +await admin.RevokeTokenAsync("petalparse"); +``` + +## Field naming — PascalCase ↔ snake_case + +The clawdforge wire is snake_case (Python / Pydantic). The SDK uses +PascalCase property names with `[JsonPropertyName("...")]` annotations to +do the translation: + +| C# property | Wire field | +|----------------------------|-----------------| +| `RunRequest.TimeoutSecs` | `timeout_secs` | +| `RunResult.DurationMs` | `duration_ms` | +| `RunResult.StopReason` | `stop_reason` | +| `FileToken.Token` | `file_token` | +| `FileToken.TtlSecs` | `ttl_secs` | +| `AppToken.IpCidrs` | `ip_cidrs` | +| `HealthStatus.ClaudePresent` | `claude_present` | +| `HealthStatus.ClaudeVersion` | `claude_version` | + +`null` properties are omitted on the wire (configured at the serializer +level), so optional fields behave correctly without manual checks. + +## Error handling + +Three exception types, all derived from `ForgeException`: + +| Exception | When | +|----------------------------|----------------------------------------| +| `ForgeAuthException` | HTTP 401, 403 (subclass of `ForgeApiException`) | +| `ForgeApiException` | Other HTTP >= 400 (incl. 502 `/run` failures) | +| `ForgeTransportException` | DNS / connect / TLS / decode failures | + +Cancellation propagates as `OperationCanceledException` directly so callers +can distinguish caller-cancellation from network failures. + +```csharp +try { + var res = await client.RunAsync(req, ct); +} +catch (ForgeAuthException ex) { + // 401 / 403 — bad bearer or IP not allowlisted. +} +catch (ForgeApiException ex) when (ex.StatusCode == 502) { + // /run failure — clawdforge took the request but `claude` failed. + // ex.Body has {"ok":false,"error":"...","stderr":"...","duration_ms":..., "stop_reason":"..."} +} +catch (ForgeApiException ex) { + // Any other 4xx / 5xx. +} +catch (ForgeTransportException ex) { + // DNS / connect refused / TLS / EOF / JSON decode. +} +catch (OperationCanceledException) { + // Caller cancelled. +} +``` + +## Timeouts + +The SDK does NOT set a wall-clock timeout on its `HttpClient` by default — +long `claude` runs (curate jobs, big recipe corpora) can legitimately take +multiple minutes. Set per-call timeouts via cancellation: + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs + 30)); +var res = await client.RunAsync(new RunRequest { Prompt = "...", TimeoutSecs = timeoutSecs }, cts.Token); +``` + +The `+30s` margin over `TimeoutSecs` mirrors the upstream Python `Forge` +client so the HTTP call doesn't bail while clawdforge is still working. + +## Build / test / pack + +```sh +dotnet build -c Release +dotnet test -c Release +dotnet pack -c Release -o ./dist +``` + +The example app under `examples/Basic/`: + +```sh +export CLAWDFORGE_URL=http://192.168.0.5:8800 +export CLAWDFORGE_TOKEN=cf_... +dotnet run --project examples/Basic +``` + +## Notes + +- The CLI wrapped server-side is `@anthropic-ai/claude-code` (not the + Python `anthropic` SDK). +- `RunRequest.TimeoutSecs` is omitted when `null`; server clamps to + `5..600`. +- `502` from `/run` is just a `ForgeApiException` with `StatusCode = 502`. + The `/run` failure body is preserved verbatim in `ex.Body`; deserialize + with `JsonSerializer` if you want structured access. +- `HealthzAsync` does not require a bearer token. The SDK still sends it + if present — the server ignores it. +- File uploads stream via `StreamContent(FileStream)` — the file body is + not buffered into memory. diff --git a/clients/csharp/examples/Basic/Basic.csproj b/clients/csharp/examples/Basic/Basic.csproj new file mode 100644 index 0000000..aa4870f --- /dev/null +++ b/clients/csharp/examples/Basic/Basic.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + latest + enable + enable + true + Clawdforge.Examples.Basic + false + + + + + + + diff --git a/clients/csharp/examples/Basic/Program.cs b/clients/csharp/examples/Basic/Program.cs new file mode 100644 index 0000000..f2dfd4d --- /dev/null +++ b/clients/csharp/examples/Basic/Program.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Clawdforge; +using Clawdforge.Exceptions; +using Clawdforge.Models; + +// Quickstart for the clawdforge C# SDK. +// +// Set environment vars: +// CLAWDFORGE_URL=http://192.168.0.5:8800 +// CLAWDFORGE_TOKEN=cf_ + +var baseUrl = Environment.GetEnvironmentVariable("CLAWDFORGE_URL") + ?? "http://localhost:8800"; +var token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN") + ?? throw new InvalidOperationException("CLAWDFORGE_TOKEN env var is required"); + +using var client = new ForgeClient(new ForgeOptions +{ + BaseUrl = baseUrl, + Token = token, +}); + +// 60s ceiling for the whole demo via cancellation. +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); +var ct = cts.Token; + +try +{ + var h = await client.HealthzAsync(ct); + Console.WriteLine($"clawdforge ok={h.Ok} claude_present={h.ClaudePresent} version={h.ClaudeVersion}"); + + var res = await client.RunAsync(new RunRequest + { + Prompt = "Reply with JSON: {\"hello\": \"world\"}", + Model = "sonnet", + TimeoutSecs = 60, + }, ct); + + Console.WriteLine($"duration: {res.DurationMs}ms stop_reason: {res.StopReason}"); + + // res.Result is a JsonElement — caller narrows by ValueKind. + if (res.Result.ValueKind == JsonValueKind.Object + && res.Result.TryGetProperty("hello", out var hello)) + { + Console.WriteLine($"hello: {hello.GetString()}"); + } + else + { + Console.WriteLine($"raw result: {res.AsText()}"); + } + + // Optional file-upload demo: only runs if a path is in argv[0]. + if (args.Length > 0 && File.Exists(args[0])) + { + var ft = await client.UploadFileAsync(args[0], ttlSecs: 3600, ct); + Console.WriteLine($"uploaded: token={ft.Token} size={ft.Size}"); + + var withFile = await client.RunAsync(new RunRequest + { + Prompt = "Describe the attached file.", + Files = new[] { ft.Token }, + }, ct); + Console.WriteLine($"file run: {withFile.AsText()}"); + } +} +catch (ForgeAuthException ex) +{ + Console.Error.WriteLine($"auth failed: {ex.StatusCode}: {ex.Body}"); + Environment.Exit(2); +} +catch (ForgeApiException ex) +{ + Console.Error.WriteLine($"api error {ex.StatusCode}: {ex.Body}"); + Environment.Exit(3); +} +catch (ForgeTransportException ex) +{ + Console.Error.WriteLine($"transport: {ex.Message}"); + Environment.Exit(4); +} diff --git a/clients/csharp/src/Clawdforge/Clawdforge.csproj b/clients/csharp/src/Clawdforge/Clawdforge.csproj new file mode 100644 index 0000000..7fda48e --- /dev/null +++ b/clients/csharp/src/Clawdforge/Clawdforge.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + latest + enable + enable + true + true + + $(NoWarn);CS1591 + + + Clawdforge + 0.1.0 + Kayos + C# / .NET 8 SDK for clawdforge — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. + MIT + http://192.168.0.5:3001/Sulkta-Coop/clawdforge + http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git + git + clawdforge;claude;anthropic;llm;sdk;rest-client + true + snupkg + README.md + + + + + + + diff --git a/clients/csharp/src/Clawdforge/Exceptions/ForgeApiException.cs b/clients/csharp/src/Clawdforge/Exceptions/ForgeApiException.cs new file mode 100644 index 0000000..657b183 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Exceptions/ForgeApiException.cs @@ -0,0 +1,32 @@ +namespace Clawdforge.Exceptions; + +/// +/// Thrown for any non-2xx HTTP response from clawdforge that wasn't an auth +/// failure. Carries the status code and the (truncated) response body so +/// callers can debug or surface upstream errors. +/// +public class ForgeApiException : ForgeException +{ + /// The HTTP status code that triggered this exception. + public int StatusCode { get; } + + /// + /// The raw response body (may be JSON or plain text, truncated to a + /// sane bound by the SDK). + /// + public string Body { get; } + + /// + public ForgeApiException(int statusCode, string body, string? message = null) + : base(message ?? $"clawdforge HTTP {statusCode}: {Truncate(body)}") + { + StatusCode = statusCode; + Body = body; + } + + private static string Truncate(string s, int max = 500) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + return s.Length <= max ? s : s.Substring(0, max) + "..."; + } +} diff --git a/clients/csharp/src/Clawdforge/Exceptions/ForgeAuthException.cs b/clients/csharp/src/Clawdforge/Exceptions/ForgeAuthException.cs new file mode 100644 index 0000000..9a1b5d7 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Exceptions/ForgeAuthException.cs @@ -0,0 +1,14 @@ +namespace Clawdforge.Exceptions; + +/// +/// Thrown for HTTP 401 / 403 responses — bad bearer token or the calling +/// IP is not in the global allowlist. Subclass of +/// so callers that only filter on the +/// generic API exception still pick it up. +/// +public sealed class ForgeAuthException : ForgeApiException +{ + /// + public ForgeAuthException(int statusCode, string body, string? message = null) + : base(statusCode, body, message ?? "authentication failed") { } +} diff --git a/clients/csharp/src/Clawdforge/Exceptions/ForgeException.cs b/clients/csharp/src/Clawdforge/Exceptions/ForgeException.cs new file mode 100644 index 0000000..3bf41a7 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Exceptions/ForgeException.cs @@ -0,0 +1,16 @@ +namespace Clawdforge.Exceptions; + +/// +/// Base type for every exception thrown by the clawdforge SDK. Catch this +/// to handle "anything from the SDK" without binding to a specific failure +/// shape. +/// +public class ForgeException : Exception +{ + /// + public ForgeException(string message) : base(message) { } + + /// + public ForgeException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/clients/csharp/src/Clawdforge/Exceptions/ForgeTransportException.cs b/clients/csharp/src/Clawdforge/Exceptions/ForgeTransportException.cs new file mode 100644 index 0000000..5d890b7 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Exceptions/ForgeTransportException.cs @@ -0,0 +1,16 @@ +namespace Clawdforge.Exceptions; + +/// +/// Thrown for low-level transport failures — DNS, connect refused, TLS, +/// premature EOF, JSON decode failures on otherwise-2xx responses, etc. +/// The original is preserved. +/// +public sealed class ForgeTransportException : ForgeException +{ + /// + public ForgeTransportException(string message, Exception innerException) + : base(message, innerException) { } + + /// + public ForgeTransportException(string message) : base(message) { } +} diff --git a/clients/csharp/src/Clawdforge/ForgeClient.cs b/clients/csharp/src/Clawdforge/ForgeClient.cs new file mode 100644 index 0000000..a2c4971 --- /dev/null +++ b/clients/csharp/src/Clawdforge/ForgeClient.cs @@ -0,0 +1,381 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Clawdforge.Exceptions; +using Clawdforge.Models; + +namespace Clawdforge; + +/// +/// Thread-safe HTTP client for the LAN-only clawdforge service. +/// +/// +/// +/// Construct directly with for a quick standalone +/// use, or pass an alongside the options for +/// dependency-injection / IHttpClientFactory friendliness. +/// +/// +/// All public methods are async and accept a +/// . The SDK does NOT impose a wall-clock +/// timeout on the underlying by default — long +/// claude runs can legitimately take minutes. Set per-call timeouts +/// via cancellation tokens (or via +/// if you really want a wall-clock cap on the client itself). +/// +/// +public sealed class ForgeClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _http; + private readonly bool _ownsHttpClient; + private readonly Uri _baseUri; + private readonly string? _token; + + /// The base URL the client targets (no trailing slash). + public string BaseUrl => _baseUri.GetLeftPart(UriPartial.Authority) + _baseUri.AbsolutePath.TrimEnd('/'); + + /// + /// Construct a standalone backed by an + /// internally-owned . The internal client is + /// disposed when this instance is disposed. + /// + public ForgeClient(ForgeOptions options) : this(options, httpClient: null) { } + + /// + /// Construct a with a caller-supplied + /// — the recommended path for DI / + /// IHttpClientFactory. The injected client is NOT disposed by + /// this instance. + /// + /// Connection options. + /// + /// External HTTP client. Pass null to let the SDK build one. + /// + public ForgeClient(ForgeOptions options, HttpClient? httpClient) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + { + throw new ArgumentException("BaseUrl is required", nameof(options)); + } + + _baseUri = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + _token = options.Token; + + if (httpClient is null) + { + _http = new HttpClient + { + // Effectively no wall-clock cap unless caller set one. + Timeout = options.HttpTimeout ?? Timeout.InfiniteTimeSpan, + }; + _ownsHttpClient = true; + } + else + { + _http = httpClient; + _ownsHttpClient = false; + } + } + + /// + /// GET /healthz. Returns liveness + claude --version smoke. + /// Bearer token is optional — the server only enforces the global IP + /// allowlist on this endpoint. + /// + public async Task HealthzAsync(CancellationToken cancellationToken = default) + { + using var req = BuildRequest(HttpMethod.Get, "healthz"); + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + /// + /// POST /run. Send a prompt and return whatever + /// claude -p --output-format json produced. + /// + /// 401 / 403. + /// Other 4xx / 5xx (including 502 from a failed claude run). + /// DNS / connect / TLS / decode failures. + public async Task RunAsync(RunRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrEmpty(request.Prompt)) + { + throw new ArgumentException("RunRequest.Prompt is required", nameof(request)); + } + + using var req = BuildRequest(HttpMethod.Post, "run"); + req.Content = JsonContent.Create(request, options: JsonOpts); + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + /// + /// POST /files. Stream a file from disk via + /// + — + /// the file is NOT buffered into memory. + /// + /// Path to the file on disk. + /// + /// Server clamps to 60..86400. Pass 0 to use the server + /// default (3600). + /// + /// Cancellation token. + public async Task UploadFileAsync( + string path, + int ttlSecs = 0, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("path is required", nameof(path)); + } + + var fileName = Path.GetFileName(path); + await using var fs = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + useAsync: true); + + return await UploadStreamAsync(fs, fileName, ttlSecs, cancellationToken).ConfigureAwait(false); + } + + /// + /// POST /files from an arbitrary — useful for + /// in-memory blobs or data piped from another source. The stream is read + /// without buffering its contents into the SDK. + /// + /// Stream of file bytes; not closed by the SDK. + /// Filename to advertise on the wire. + /// + /// Server clamps to 60..86400. Pass 0 to use the server + /// default (3600). + /// + /// Cancellation token. + public async Task UploadStreamAsync( + Stream content, + string fileName, + int ttlSecs = 0, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(content); + if (string.IsNullOrEmpty(fileName)) + { + throw new ArgumentException("fileName is required", nameof(fileName)); + } + + using var multipart = new MultipartFormDataContent(); + if (ttlSecs > 0) + { + multipart.Add(new StringContent(ttlSecs.ToString(System.Globalization.CultureInfo.InvariantCulture)), "ttl_secs"); + } + + var streamContent = new StreamContent(content); + streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + multipart.Add(streamContent, "file", fileName); + + using var req = BuildRequest(HttpMethod.Post, "files"); + req.Content = multipart; + + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + /// + /// POST /admin/tokens. Mint a new per-app token. The plaintext + /// is returned in and is the only chance + /// to capture it — the server stores only the SHA-256. + /// + /// Requires the admin bootstrap token in . + public async Task CreateTokenAsync( + CreateTokenRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrEmpty(request.Name)) + { + throw new ArgumentException("CreateTokenRequest.Name is required", nameof(request)); + } + + using var req = BuildRequest(HttpMethod.Post, "admin/tokens"); + req.Content = JsonContent.Create(request, options: JsonOpts); + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + /// + /// GET /admin/tokens. List configured app tokens (plaintexts not + /// included). Requires the admin bootstrap token. + /// + public async Task> ListTokensAsync(CancellationToken cancellationToken = default) + { + using var req = BuildRequest(HttpMethod.Get, "admin/tokens"); + var resp = await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + return resp.Tokens; + } + + /// + /// DELETE /admin/tokens/<name>. Revoke the named token. + /// Requires the admin bootstrap token. + /// + public async Task RevokeTokenAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("name is required", nameof(name)); + } + + using var req = BuildRequest(HttpMethod.Delete, "admin/tokens/" + Uri.EscapeDataString(name)); + await SendAsync(req, cancellationToken).ConfigureAwait(false); + } + + // ---- internals --------------------------------------------------------- + + private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath) + { + var uri = new Uri(_baseUri, relativePath); + var msg = new HttpRequestMessage(method, uri); + msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + if (!string.IsNullOrEmpty(_token)) + { + msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + } + return msg; + } + + private async Task SendAndReadAsync(HttpRequestMessage req, CancellationToken cancellationToken) + { + using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false); + + try + { + var result = await resp.Content + .ReadFromJsonAsync(JsonOpts, cancellationToken) + .ConfigureAwait(false); + if (result is null) + { + throw new ForgeTransportException( + $"clawdforge: empty body decoding {typeof(T).Name} from {req.Method} {req.RequestUri?.AbsolutePath}"); + } + return result; + } + catch (JsonException ex) + { + throw new ForgeTransportException( + $"clawdforge: failed to decode {typeof(T).Name} from {req.Method} {req.RequestUri?.AbsolutePath}", + ex); + } + } + + private async Task SendAsync(HttpRequestMessage req, CancellationToken cancellationToken) + { + HttpResponseMessage resp; + try + { + resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cooperative cancellation surfaces directly so callers can + // distinguish it from transport errors. + throw; + } + catch (HttpRequestException ex) + { + throw new ForgeTransportException( + $"clawdforge: transport failure on {req.Method} {req.RequestUri?.AbsolutePath}: {ex.Message}", + ex); + } + catch (TaskCanceledException ex) + { + // HttpClient internal timeout (not user cancellation). + throw new ForgeTransportException( + $"clawdforge: HTTP timeout on {req.Method} {req.RequestUri?.AbsolutePath}", + ex); + } + + if (!resp.IsSuccessStatusCode) + { + var body = await SafeReadBodyAsync(resp, cancellationToken).ConfigureAwait(false); + var status = (int)resp.StatusCode; + resp.Dispose(); + + if (resp.StatusCode == HttpStatusCode.Unauthorized + || resp.StatusCode == HttpStatusCode.Forbidden) + { + throw new ForgeAuthException(status, body, SummarizeBody(body)); + } + throw new ForgeApiException(status, body); + } + return resp; + } + + private static async Task SafeReadBodyAsync(HttpResponseMessage resp, CancellationToken ct) + { + try + { + // 8 MiB cap on error bodies. + var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var ms = new MemoryStream(); + var buf = new byte[16 * 1024]; + const int max = 8 * 1024 * 1024; + int total = 0; + int n; + while ((n = await stream.ReadAsync(buf.AsMemory(0, buf.Length), ct).ConfigureAwait(false)) > 0) + { + if (total + n > max) + { + ms.Write(buf, 0, max - total); + break; + } + ms.Write(buf, 0, n); + total += n; + } + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } + catch + { + return string.Empty; + } + } + + private static string? SummarizeBody(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var key in new[] { "error", "detail", "message" }) + { + if (doc.RootElement.TryGetProperty(key, out var v) + && v.ValueKind == JsonValueKind.String) + { + return v.GetString(); + } + } + } + } + catch (JsonException) { /* not JSON */ } + return null; + } + + /// + public void Dispose() + { + if (_ownsHttpClient) + { + _http.Dispose(); + } + } +} diff --git a/clients/csharp/src/Clawdforge/ForgeOptions.cs b/clients/csharp/src/Clawdforge/ForgeOptions.cs new file mode 100644 index 0000000..0aa238f --- /dev/null +++ b/clients/csharp/src/Clawdforge/ForgeOptions.cs @@ -0,0 +1,34 @@ +namespace Clawdforge; + +/// +/// Configuration for a . All values are init-only so +/// the options object is effectively immutable once constructed. +/// +public sealed class ForgeOptions +{ + /// + /// Base URL of the clawdforge service (no trailing slash required). + /// Example: http://192.168.0.5:8800. + /// + public required string BaseUrl { get; init; } + + /// + /// Bearer token sent in Authorization: Bearer <token>. + /// Use a per-app cf_... token for /run and /files, + /// or the admin bootstrap token for /admin/*. May be null + /// for unauthenticated calls (e.g. /healthz) when the client's + /// IP already satisfies the global allowlist. + /// + public string? Token { get; init; } + + /// + /// HTTP-level wall-clock timeout for the underlying HttpClient + /// (when the SDK creates one). Defaults to null — long + /// claude runs can legitimately take minutes, so wall-clock + /// timeouts on individual calls should be set via + /// . + /// Ignored when an external HttpClient is injected via the + /// constructor. + /// + public TimeSpan? HttpTimeout { get; init; } +} diff --git a/clients/csharp/src/Clawdforge/Models/AppToken.cs b/clients/csharp/src/Clawdforge/Models/AppToken.cs new file mode 100644 index 0000000..66f8c4b --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/AppToken.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// One per-app bearer token, returned from POST /admin/tokens and +/// surfaced (without the plaintext ) from +/// GET /admin/tokens. +/// +public sealed class AppToken +{ + /// App / consumer name (e.g. "cauldron"). + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + /// + /// Plaintext bearer (cf_...). Only populated on the create response; + /// the server stores only the SHA-256 thereafter, so this is the only + /// chance to capture it. + /// + [JsonPropertyName("token")] + public string? Token { get; init; } + + /// + /// Per-token CIDR allowlist (in addition to the global one). Empty / null + /// means no per-token restriction. + /// + [JsonPropertyName("ip_cidrs")] + public IReadOnlyList? IpCidrs { get; init; } + + /// Unix epoch seconds when the token was created. + [JsonPropertyName("created_at")] + public long? CreatedAt { get; init; } +} + +/// +/// Request body for POST /admin/tokens. +/// +public sealed class CreateTokenRequest +{ + /// + /// App / consumer name. Server enforces + /// [a-z0-9][a-z0-9_-]{0,63}. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// Optional CIDR allowlist for this token. + [JsonPropertyName("ip_cidrs")] + public IReadOnlyList? IpCidrs { get; init; } +} + +/// Wire shape for GET /admin/tokens. +internal sealed class TokenListResponse +{ + [JsonPropertyName("tokens")] + public List Tokens { get; init; } = new(); +} diff --git a/clients/csharp/src/Clawdforge/Models/FileToken.cs b/clients/csharp/src/Clawdforge/Models/FileToken.cs new file mode 100644 index 0000000..e2c496e --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/FileToken.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Response body from POST /files. +/// +/// +/// The class is named FileToken for consistency with the other SDKs. +/// The actual opaque token string is on ; the property +/// can't share the type's name in C#. +/// +public sealed class FileToken +{ + /// + /// The opaque file token (prefix ff_). Pass via + /// on subsequent /run calls. + /// Wire field: file_token. + /// + [JsonPropertyName("file_token")] + public string Token { get; init; } = string.Empty; + + /// + /// TTL the server registered (clamped to 60..86400). + /// Wire field: ttl_secs. + /// + [JsonPropertyName("ttl_secs")] + public int TtlSecs { get; init; } + + /// Bytes written to the server's staging dir. + [JsonPropertyName("size")] + public long Size { get; init; } +} diff --git a/clients/csharp/src/Clawdforge/Models/HealthStatus.cs b/clients/csharp/src/Clawdforge/Models/HealthStatus.cs new file mode 100644 index 0000000..cf354a8 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/HealthStatus.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Response body from GET /healthz. +/// +public sealed class HealthStatus +{ + /// Always true if the server replied. + [JsonPropertyName("ok")] + public bool Ok { get; init; } + + /// Whether the claude binary was found on PATH. + [JsonPropertyName("claude_present")] + public bool ClaudePresent { get; init; } + + /// + /// First line of claude --version output. null when the + /// binary isn't present or the version check failed. + /// + [JsonPropertyName("claude_version")] + public string? ClaudeVersion { get; init; } +} diff --git a/clients/csharp/src/Clawdforge/Models/RunRequest.cs b/clients/csharp/src/Clawdforge/Models/RunRequest.cs new file mode 100644 index 0000000..d9fb8a9 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/RunRequest.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Request body for POST /run. +/// +/// +/// Field naming: C# uses PascalCase, the wire is snake_case. Each property +/// is mapped via . Optional fields +/// are null by default and skipped on serialization (configured at +/// the serializer level in ). +/// +public sealed class RunRequest +{ + /// + /// Prompt text. Required; must be non-empty server-side. + /// + [JsonPropertyName("prompt")] + public required string Prompt { get; init; } + + /// + /// Model alias passed through to claude -p --model. null + /// falls back to the server-side default (typically sonnet). + /// + [JsonPropertyName("model")] + public string? Model { get; init; } + + /// + /// Optional system prompt appended via claude -p --append-system-prompt. + /// + [JsonPropertyName("system")] + public string? System { get; init; } + + /// + /// File tokens previously returned from + /// . + /// + [JsonPropertyName("files")] + public IReadOnlyList? Files { get; init; } + + /// + /// Subprocess timeout in seconds. Server clamps to 5..600. + /// + [JsonPropertyName("timeout_secs")] + public int? TimeoutSecs { get; init; } +} diff --git a/clients/csharp/src/Clawdforge/Models/RunResult.cs b/clients/csharp/src/Clawdforge/Models/RunResult.cs new file mode 100644 index 0000000..ce8c60f --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/RunResult.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Successful response body from POST /run (HTTP 200). +/// +/// +/// +/// is intentionally a : +/// clawdforge auto-parses the claude reply as JSON when possible and +/// falls back to a raw string otherwise. Inspect +/// or use / to materialize it. +/// +/// +/// Failure responses (HTTP 502 from /run) are surfaced via +/// rather than this type. +/// +/// +public sealed class RunResult +{ + /// Always true for a 200 response. + [JsonPropertyName("ok")] + public bool Ok { get; init; } + + /// + /// Parsed claude output. JSON object/array/number/bool when the model + /// emitted JSON; string otherwise. + /// + [JsonPropertyName("result")] + public JsonElement Result { get; init; } + + /// Wall-clock duration of the subprocess in milliseconds. + [JsonPropertyName("duration_ms")] + public long DurationMs { get; init; } + + /// + /// claude stop reason (e.g. "end_turn"). May be null + /// on edge cases. + /// + [JsonPropertyName("stop_reason")] + public string? StopReason { get; init; } + + /// + /// Deserialize as using the + /// supplied options (or a sensible default). + /// + /// + /// Thrown if doesn't deserialize into + /// . + /// + public T? AsJson(JsonSerializerOptions? options = null) + { + return Result.Deserialize(options ?? JsonDefaults.Options); + } + + /// + /// Materialize as a string. Returns the underlying + /// string for , otherwise the raw JSON + /// representation. + /// + public string? AsText() + { + return Result.ValueKind switch + { + JsonValueKind.Undefined => null, + JsonValueKind.Null => null, + JsonValueKind.String => Result.GetString(), + _ => Result.GetRawText(), + }; + } +} + +internal static class JsonDefaults +{ + internal static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} diff --git a/clients/csharp/tests/Clawdforge.Tests/Clawdforge.Tests.csproj b/clients/csharp/tests/Clawdforge.Tests/Clawdforge.Tests.csproj new file mode 100644 index 0000000..4d7a37a --- /dev/null +++ b/clients/csharp/tests/Clawdforge.Tests/Clawdforge.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + latest + enable + enable + true + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs b/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs new file mode 100644 index 0000000..0ee19c0 --- /dev/null +++ b/clients/csharp/tests/Clawdforge.Tests/ForgeClientTests.cs @@ -0,0 +1,387 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Clawdforge; +using Clawdforge.Exceptions; +using Clawdforge.Models; +using Xunit; + +namespace Clawdforge.Tests; + +/// +/// Tests use a custom mock — zero extra +/// dependencies, more idiomatic for an SDK than spinning up an ASP.NET +/// TestHost. +/// +public class ForgeClientTests +{ + [Fact] + public async Task Healthz_ReturnsParsedBody() + { + var handler = new MockHandler((req, ct) => + { + Assert.Equal(HttpMethod.Get, req.Method); + Assert.Equal("/healthz", req.RequestUri!.AbsolutePath); + return JsonResponse(new + { + ok = true, + claude_present = true, + claude_version = "1.2.3 (Claude Code)", + }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var h = await client.HealthzAsync(); + + Assert.True(h.Ok); + Assert.True(h.ClaudePresent); + Assert.Equal("1.2.3 (Claude Code)", h.ClaudeVersion); + } + + [Fact] + public async Task Run_SerializesSnakeCase_AndDeserializesResult() + { + string? capturedBody = null; + string? capturedAuth = null; + + var handler = new MockHandler(async (req, ct) => + { + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Equal("/run", req.RequestUri!.AbsolutePath); + capturedAuth = req.Headers.Authorization?.ToString(); + capturedBody = await req.Content!.ReadAsStringAsync(ct); + + return JsonResponse(new + { + ok = true, + result = new { hello = "world" }, + duration_ms = 4321, + stop_reason = "end_turn", + }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }, + http); + + var res = await client.RunAsync(new RunRequest + { + Prompt = "Reply with JSON: {\"hello\":\"world\"}", + Model = "sonnet", + TimeoutSecs = 60, + }); + + Assert.True(res.Ok); + Assert.Equal(4321, res.DurationMs); + Assert.Equal("end_turn", res.StopReason); + Assert.Equal(JsonValueKind.Object, res.Result.ValueKind); + Assert.Equal("world", res.Result.GetProperty("hello").GetString()); + + Assert.Equal("Bearer cf_test", capturedAuth); + Assert.NotNull(capturedBody); + // Verify wire shape: snake_case + no nulls. + using var doc = JsonDocument.Parse(capturedBody!); + Assert.True(doc.RootElement.TryGetProperty("prompt", out _)); + Assert.True(doc.RootElement.TryGetProperty("model", out _)); + Assert.True(doc.RootElement.TryGetProperty("timeout_secs", out _)); + Assert.False(doc.RootElement.TryGetProperty("system", out _)); + Assert.False(doc.RootElement.TryGetProperty("files", out _)); + } + + [Fact] + public async Task Run_PropagatesStringResult_AsJsonElementString() + { + var handler = new MockHandler((req, ct) => JsonResponse(new + { + ok = true, + result = "plain text reply", + duration_ms = 100, + stop_reason = "end_turn", + })); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var res = await client.RunAsync(new RunRequest { Prompt = "x" }); + Assert.Equal(JsonValueKind.String, res.Result.ValueKind); + Assert.Equal("plain text reply", res.AsText()); + } + + [Fact] + public async Task Run_AsJsonGenericRoundtrips() + { + var handler = new MockHandler((req, ct) => JsonResponse(new + { + ok = true, + result = new { qty = 2, unit = "cup", food = "rice" }, + duration_ms = 1, + stop_reason = "end_turn", + })); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var res = await client.RunAsync(new RunRequest { Prompt = "x" }); + var ing = res.AsJson(); + Assert.NotNull(ing); + Assert.Equal(2, ing!.Qty); + Assert.Equal("cup", ing.Unit); + Assert.Equal("rice", ing.Food); + } + + [Fact] + public async Task Run_401_RaisesForgeAuthException() + { + var handler = new MockHandler((req, ct) => + JsonResponse(new { detail = "Not authenticated" }, HttpStatusCode.Unauthorized)); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_bad" }, + http); + + var ex = await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" })); + Assert.Equal(401, ex.StatusCode); + Assert.Contains("Not authenticated", ex.Body); + } + + [Fact] + public async Task Run_502_RaisesForgeApiException_WithRunFailureBody() + { + var handler = new MockHandler((req, ct) => + JsonResponse(new + { + ok = false, + error = "timeout after 30s", + stderr = "...", + duration_ms = 30001, + stop_reason = "timeout", + }, HttpStatusCode.BadGateway)); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ex = await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" })); + Assert.Equal(502, ex.StatusCode); + // Auth exception type must NOT match — only ForgeApiException. + Assert.IsNotType(ex); + Assert.Contains("timeout", ex.Body); + } + + [Fact] + public async Task Run_TransportFailure_RaisesForgeTransportException() + { + var handler = new MockHandler((HttpRequestMessage req, CancellationToken ct) => + Task.FromException(new HttpRequestException("connection refused"))); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ex = await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" })); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task Run_HonorsCancellation() + { + var handler = new MockHandler(async (req, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return JsonResponse(new { ok = true, result = "x", duration_ms = 0, stop_reason = "end_turn" }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + await Assert.ThrowsAnyAsync(() => + client.RunAsync(new RunRequest { Prompt = "x" }, cts.Token)); + } + + [Fact] + public async Task UploadFile_StreamsMultipart_AndReturnsToken() + { + var seenBoundary = false; + var seenTtl = false; + var seenFile = false; + + var fileName = $"forge-upload-{Guid.NewGuid():N}.txt"; + var tmp = Path.Combine(Path.GetTempPath(), fileName); + + var handler = new MockHandler(async (req, ct) => + { + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Equal("/files", req.RequestUri!.AbsolutePath); + var contentType = req.Content!.Headers.ContentType!; + Assert.Equal("multipart/form-data", contentType.MediaType); + Assert.NotNull(contentType.Parameters.SingleOrDefault(p => p.Name == "boundary")); + seenBoundary = true; + + var body = await req.Content.ReadAsStringAsync(ct); + // .NET's MultipartFormDataContent emits unquoted name= / filename= params. + seenTtl = body.Contains("name=ttl_secs") && body.Contains("3600"); + seenFile = body.Contains("name=file") + && body.Contains($"filename={fileName}") + && body.Contains("hello forge"); + + return JsonResponse(new { file_token = "ff_abc123", ttl_secs = 3600, size = 11 }); + }); + + await File.WriteAllTextAsync(tmp, "hello forge"); + try + { + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ft = await client.UploadFileAsync(tmp, ttlSecs: 3600); + Assert.Equal("ff_abc123", ft.Token); + Assert.Equal(3600, ft.TtlSecs); + Assert.Equal(11, ft.Size); + } + finally + { + File.Delete(tmp); + } + + Assert.True(seenBoundary); + Assert.True(seenTtl, "ttl_secs field not found in multipart body"); + Assert.True(seenFile, "file field/filename not found in multipart body"); + } + + [Fact] + public async Task AdminTokens_FullCrudCycle() + { + var calls = new List<(HttpMethod method, string path, string? body)>(); + + var handler = new MockHandler(async (req, ct) => + { + string? body = null; + if (req.Content is not null) + { + body = await req.Content.ReadAsStringAsync(ct); + } + calls.Add((req.Method, req.RequestUri!.AbsolutePath, body)); + + return req.Method.Method switch + { + "POST" => JsonResponse(new + { + name = "petalparse", + token = "cf_secret_xyz", + ip_cidrs = new[] { "172.24.0.0/16" }, + }), + "GET" => JsonResponse(new + { + tokens = new object[] + { + new { name = "petalparse", ip_cidrs = new[] { "172.24.0.0/16" }, created_at = 1714000000 }, + new { name = "cauldron", ip_cidrs = Array.Empty(), created_at = 1714000100 }, + }, + }), + "DELETE" => JsonResponse(new { ok = true }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" }, + http); + + var created = await client.CreateTokenAsync(new CreateTokenRequest + { + Name = "petalparse", + IpCidrs = new[] { "172.24.0.0/16" }, + }); + Assert.Equal("petalparse", created.Name); + Assert.Equal("cf_secret_xyz", created.Token); + + var list = await client.ListTokensAsync(); + Assert.Equal(2, list.Count); + Assert.Contains(list, t => t.Name == "cauldron"); + + await client.RevokeTokenAsync("petalparse"); + + Assert.Equal(3, calls.Count); + Assert.Equal("/admin/tokens", calls[0].path); + Assert.Equal("/admin/tokens", calls[1].path); + Assert.Equal("/admin/tokens/petalparse", calls[2].path); + } + + [Fact] + public async Task RunRequest_WithoutPrompt_ThrowsArgumentException() + { + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }); + + await Assert.ThrowsAsync(() => + client.RunAsync(new RunRequest { Prompt = "" })); + } + + [Fact] + public void BaseUrl_IsTrimmedAtConstruction() + { + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" }); + + Assert.Equal("http://forge.test", client.BaseUrl); + } + + // ---- helpers ----------------------------------------------------------- + + private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK) + { + var json = JsonSerializer.Serialize(payload); + return new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + + private sealed class Ingredient + { + public int Qty { get; init; } + public string Unit { get; init; } = string.Empty; + public string Food { get; init; } = string.Empty; + } + + private sealed class MockHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public MockHandler(Func> handler) + { + _handler = handler; + } + + public MockHandler(Func handler) + : this((req, ct) => Task.FromResult(handler(req, ct))) { } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } +}