diff --git a/clients/kotlin/.gitignore b/clients/kotlin/.gitignore new file mode 100644 index 0000000..8c0bd38 --- /dev/null +++ b/clients/kotlin/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +*.iws +*.ipr +local.properties +.DS_Store diff --git a/clients/kotlin/README.md b/clients/kotlin/README.md new file mode 100644 index 0000000..46b97ed --- /dev/null +++ b/clients/kotlin/README.md @@ -0,0 +1,184 @@ +# clawdforge-kotlin + +Async Kotlin client for the [clawdforge](../../README.md) HTTP service — a +LAN-only bearer-token-gated REST wrapper around `claude -p` subprocess calls. + +- **Kotlin 1.9+**, JVM 17 target +- **Coroutines** — every I/O method is `suspend`. No blocking variants. +- **Ktor Client** (CIO engine) for HTTP, **kotlinx.serialization** for JSON +- `ForgeClient` is `Closeable` and works inside `use { }` + +## Install + +The jar is published to your local Maven repo; consume it from any JVM build. + +```bash +./gradlew publishToMavenLocal +``` + +Then in the consumer's `build.gradle.kts`: + +```kotlin +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("com.clawdforge:clawdforge:0.1.0") +} +``` + +You'll also pull Ktor + kotlinx-serialization transitively (they're on the +public API surface — `RunResult.result` is a `JsonElement`). + +## Quickstart + +```kotlin +import com.clawdforge.ForgeClient +import com.clawdforge.RunRequest +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path + +suspend fun main() { + ForgeClient( + baseUrl = "http://localhost:8800", + token = System.getenv("CLAWDFORGE_TOKEN"), + ).use { client -> + + val h = client.healthz() + println(h.claudeVersion) + + val res = client.run(RunRequest( + prompt = """Reply with JSON: {"hello": "world"}""", + model = "sonnet", + timeoutSecs = 60, + )) + println("duration: ${res.durationMs}ms") + println(res.result.jsonObject["hello"]?.jsonPrimitive?.content) + + // Upload + reference a file + val ft = client.uploadFile(Path.of("./recipe.png"), ttlSecs = 3600) + client.run(RunRequest( + prompt = "Extract recipe data from the attached image.", + files = listOf(ft.fileToken), + )) + } +} +``` + +## Public API + +All methods are `suspend` — call them from a coroutine scope. + +| Method | HTTP | Notes | +| --- | --- | --- | +| `healthz()` | `GET /healthz` | No bearer required; IP allowlist still applies. | +| `run(RunRequest)` | `POST /run` | 200 → `RunResult`; 502 → `ForgeRunFailureException`. | +| `uploadFile(Path, ttlSecs?)` | `POST /files` | Streams via Ktor's `submitFormWithBinaryData`. | +| `createToken(CreateTokenRequest)` | `POST /admin/tokens` | Admin bootstrap token only. | +| `listTokens()` | `GET /admin/tokens` | Admin only. | +| `revokeToken(name)` | `DELETE /admin/tokens/{name}` | Admin only. | +| `close()` | — | Disposes the underlying Ktor client. | + +### Constructor + +```kotlin +ForgeClient( + baseUrl: String, + token: String, + options: ForgeOptions = ForgeOptions(), +) +``` + +`ForgeOptions` knobs: + +```kotlin +data class ForgeOptions( + val defaultModel: String = "sonnet", + val defaultTimeout: Duration = 60.seconds, + val requestMargin: Duration = 30.seconds, + val engine: HttpClientEngine? = null, // override (e.g. MockEngine in tests) + val configure: (HttpClientConfig<*>.() -> Unit)? = null, +) +``` + +## JSON wire format + +The server speaks **snake_case** (`timeout_secs`, `claude_version`, +`file_token`, `ip_cidrs`). The Kotlin SDK exposes idiomatic **camelCase** +properties and uses `@SerialName` to bridge — callers never see the +snake_case names: + +```kotlin +@Serializable +data class RunRequest( + val prompt: String, + val model: String? = null, + val system: String? = null, + val files: List? = null, + @SerialName("timeout_secs") val timeoutSecs: Int? = null, +) +``` + +`RunResult.result` is a `kotlinx.serialization.json.JsonElement` — the +server may return either a parsed JSON object/array (when the prompt asked +for JSON) or a plain string. Narrow it at the call site: + +```kotlin +val obj = res.result.jsonObject // throws if not object +val txt = res.result.jsonPrimitive.contentOrNull // null if not primitive +``` + +## Errors + +A sealed exception hierarchy makes `when` exhaustive: + +```kotlin +try { + client.run(req) +} catch (e: ForgeException) { + when (e) { + is ForgeAuthException -> rotateToken() // 401/403 + is ForgeRunFailureException -> log("claude died: ${e.errorMessage}") // 502 + is ForgeApiException -> log("api ${e.statusCode}: ${e.body}") // other 4xx/5xx + is ForgeTransportException -> backoffAndRetry() // network/decode + } +} +``` + +Cancellation propagates correctly: `CancellationException` is rethrown +unmodified, so structured concurrency (timeouts, parent-cancel) keep working. + +## File uploads stream + +`uploadFile(path, ttlSecs)` uses Ktor's `submitFormWithBinaryData` with an +`InputProvider` backed by the file's `InputStream`. A 100 MB file uses +roughly the buffer size of heap, not 100 MB. + +## Builds & tests + +```bash +./gradlew build # compile + tests + jar +./gradlew test # tests only +./gradlew publishToMavenLocal +``` + +Tests use Ktor's `MockEngine` — no network, no extra deps. + +## Compatibility + +- JVM 17+ (toolchain pinned in `build.gradle.kts`) +- Kotlin 1.9.25 +- Ktor 2.3.x +- kotlinx-serialization 1.6.x +- kotlinx-coroutines 1.8.x + +Multiplatform isn't enabled out of the box, but the only JVM-only choices +in the SDK are the CIO engine and `java.nio.file.Path` for `uploadFile`. +A KMP fork would replace those with `okio.Path` and a per-target engine. + +## License + +MIT. diff --git a/clients/kotlin/build.gradle.kts b/clients/kotlin/build.gradle.kts new file mode 100644 index 0000000..c3d2280 --- /dev/null +++ b/clients/kotlin/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + kotlin("jvm") version "1.9.25" + kotlin("plugin.serialization") version "1.9.25" + `java-library` + `maven-publish` +} + +group = "com.clawdforge" +version = "0.1.0" + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + withSourcesJar() +} + +kotlin { + jvmToolchain(17) + explicitApi() +} + +val ktorVersion = "2.3.12" + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + + api("io.ktor:ktor-client-core:$ktorVersion") + api("io.ktor:ktor-client-content-negotiation:$ktorVersion") + api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.10.3") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + testImplementation("io.ktor:ktor-client-mock:$ktorVersion") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.addAll( + "-Xjsr305=strict", + "-opt-in=kotlin.RequiresOptIn", + ) + } +} + +publishing { + publications { + create("maven") { + from(components["java"]) + artifactId = "clawdforge" + + pom { + name.set("clawdforge-kotlin") + description.set( + "Async Kotlin client for the clawdforge HTTP service " + + "(a LAN bearer-token-gated wrapper around `claude -p`).", + ) + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + } + } + } +} diff --git a/clients/kotlin/examples/main/kotlin/Basic.kt b/clients/kotlin/examples/main/kotlin/Basic.kt new file mode 100644 index 0000000..574d985 --- /dev/null +++ b/clients/kotlin/examples/main/kotlin/Basic.kt @@ -0,0 +1,75 @@ +@file:JvmName("Basic") + +import com.clawdforge.CreateTokenRequest +import com.clawdforge.ForgeClient +import com.clawdforge.ForgeException +import com.clawdforge.RunRequest +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path + +/** + * End-to-end smoke against a running clawdforge instance. + * + * Set: + * FORGE_URL e.g. http://192.168.0.5:8800 + * FORGE_TOKEN cf_xxx (obtained from /admin/tokens) + * + * Optional: + * FORGE_FILE path to a file to upload + reference in a second /run + * + * Run with: + * ./gradlew --quiet :build + * kotlin -classpath build/libs/clawdforge-kotlin-0.1.0.jar: Basic + * + * (or set up an `application` plugin / JavaExec task in your own build). + */ +suspend fun main() { + val baseUrl = System.getenv("FORGE_URL") ?: "http://localhost:8800" + val token = System.getenv("FORGE_TOKEN") ?: error("set FORGE_TOKEN env var") + + ForgeClient(baseUrl = baseUrl, token = token).use { client -> + // ----- /healthz ------------------------------------------------ + val h = client.healthz() + println("ok=${h.ok} claude_present=${h.claudePresent} version=${h.claudeVersion}") + + // ----- /run ---------------------------------------------------- + try { + val res = client.run( + RunRequest( + prompt = """Reply with JSON: {"hello": "world"}""", + model = "sonnet", + timeoutSecs = 60, + ), + ) + println("duration: ${res.durationMs}ms stop=${res.stopReason}") + + // result is a JsonElement — narrow it however you like + val obj = res.result.jsonObject + println("hello = ${obj["hello"]?.jsonPrimitive?.content}") + } catch (e: ForgeException) { + System.err.println("run failed: ${e.message}") + } + + // ----- /files + /run with attachment -------------------------- + System.getenv("FORGE_FILE")?.let { p -> + val ft = client.uploadFile(Path.of(p), ttlSecs = 3600) + println("uploaded ${ft.fileToken} (${ft.size} bytes, ttl=${ft.ttlSecs}s)") + + val res = client.run( + RunRequest( + prompt = "Summarize the attached file in one sentence.", + files = listOf(ft.fileToken), + ), + ) + println("summary: ${res.result}") + } + + // ----- /admin/tokens (requires admin bootstrap token) --------- + // Uncomment if your $token is the admin bootstrap token. + // val t = client.createToken(CreateTokenRequest(name = "demo", ipCidrs = listOf("127.0.0.1/32"))) + // println("minted ${t.name} = ${t.token}") + // println("tokens: ${client.listTokens().map { it.name }}") + // client.revokeToken("demo") + } +} diff --git a/clients/kotlin/gradle.properties b/clients/kotlin/gradle.properties new file mode 100644 index 0000000..b1962a7 --- /dev/null +++ b/clients/kotlin/gradle.properties @@ -0,0 +1,4 @@ +kotlin.code.style=official +org.gradle.jvmargs=-Xmx1024m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.parallel=true diff --git a/clients/kotlin/gradle/wrapper/gradle-wrapper.jar b/clients/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/clients/kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/clients/kotlin/gradle/wrapper/gradle-wrapper.properties b/clients/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/clients/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/clients/kotlin/gradlew b/clients/kotlin/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/clients/kotlin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/clients/kotlin/gradlew.bat b/clients/kotlin/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/clients/kotlin/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/clients/kotlin/settings.gradle.kts b/clients/kotlin/settings.gradle.kts new file mode 100644 index 0000000..46fad70 --- /dev/null +++ b/clients/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "clawdforge-kotlin" diff --git a/clients/kotlin/src/main/kotlin/com/clawdforge/Exceptions.kt b/clients/kotlin/src/main/kotlin/com/clawdforge/Exceptions.kt new file mode 100644 index 0000000..4dde39e --- /dev/null +++ b/clients/kotlin/src/main/kotlin/com/clawdforge/Exceptions.kt @@ -0,0 +1,54 @@ +package com.clawdforge + +/** + * Base class for every exception this SDK throws. Sealed so a `when (e)` in + * a `catch` block can be exhaustive over the failure modes: + * + * ```kotlin + * try { client.run(req) } catch (e: ForgeException) { + * when (e) { + * is ForgeAuthException -> rotateToken() + * is ForgeRunFailureException -> retryOrAbort(e) + * is ForgeApiException -> log("api ${e.statusCode}: ${e.body}") + * is ForgeTransportException -> backoffAndRetry() + * } + * } + * ``` + */ +public sealed class ForgeException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) + +/** 401 / 403 from clawdforge — bad token, IP not allowlisted, or admin token required. */ +public class ForgeAuthException( + public val statusCode: Int, + public val body: String, +) : ForgeException("clawdforge: authentication failed: HTTP $statusCode: ${body.take(500)}") + +/** + * 502 from `POST /run` — the server accepted the request but `claude -p` + * itself failed (timeout, non-zero exit, stop_reason != end_turn, ...). + * + * This is distinct from [ForgeApiException] because the body shape is + * predictable and the failure is *upstream of* claude rather than a + * malformed request. + */ +public class ForgeRunFailureException( + public val errorMessage: String, + public val stderr: String, + public val durationMs: Long, + public val stopReason: String?, +) : ForgeException("clawdforge: run failed (stop_reason=$stopReason): $errorMessage") + +/** Any other non-2xx response that isn't auth or a /run failure. */ +public class ForgeApiException( + public val statusCode: Int, + public val body: String, +) : ForgeException("clawdforge: HTTP $statusCode: ${body.take(500)}") + +/** DNS/connect/TLS/EOF/cancellation/decode failures. The wrapped cause is preserved. */ +public class ForgeTransportException( + public val operation: String, + cause: Throwable, +) : ForgeException("clawdforge: transport $operation: ${cause.message}", cause) diff --git a/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeClient.kt b/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeClient.kt new file mode 100644 index 0000000..f7b17f6 --- /dev/null +++ b/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeClient.kt @@ -0,0 +1,288 @@ +package com.clawdforge + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.delete +import io.ktor.client.request.forms.InputProvider +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.request +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.encodeURLPathPart +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.streams.asInput +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.Closeable +import java.nio.file.Files +import java.nio.file.Path + +/** + * Async Kotlin client for the clawdforge HTTP service. + * + * Every I/O method is `suspend`. There is no blocking variant — wrap a call + * in `runBlocking { ... }` if you really need synchronous behaviour. + * + * The client is `Closeable`, so it composes with `use { }`: + * + * ```kotlin + * ForgeClient("http://localhost:8800", "cf_...").use { client -> + * val h = client.healthz() + * val r = client.run(RunRequest(prompt = "Reply with 'hi'")) + * println(r.result) + * } + * ``` + * + * Methods are thread-safe; you can share a single instance across coroutines + * and dispatchers. + * + * ### JSON wire format + * + * Request/response models use `kotlinx.serialization` with `@SerialName` + * annotations to map idiomatic camelCase Kotlin properties (`timeoutSecs`, + * `claudeVersion`, `fileToken`) to the snake_case keys the FastAPI server + * speaks (`timeout_secs`, `claude_version`, `file_token`). Callers don't + * see the snake_case names. + */ +public class ForgeClient @JvmOverloads public constructor( + baseUrl: String, + private val token: String, + private val options: ForgeOptions = ForgeOptions(), +) : Closeable { + + /** Trailing-slash-stripped base URL, e.g. `http://192.168.0.5:8800`. */ + public val baseUrl: String = baseUrl.trimEnd('/') + + @OptIn(ExperimentalSerializationApi::class) + private val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + } + + private val http: HttpClient = run { + val configBlock: io.ktor.client.HttpClientConfig<*>.() -> Unit = { + install(ContentNegotiation) { + json(this@ForgeClient.json) + } + install(HttpTimeout) { + // Server-side claude budget + a margin so we don't bail mid-run. + requestTimeoutMillis = + (options.defaultTimeout + options.requestMargin).inWholeMilliseconds + connectTimeoutMillis = 10_000 + socketTimeoutMillis = + (options.defaultTimeout + options.requestMargin).inWholeMilliseconds + } + defaultRequest { + header(HttpHeaders.Accept, ContentType.Application.Json.toString()) + } + options.configure?.invoke(this) + } + if (options.engine != null) { + HttpClient(options.engine, configBlock) + } else { + HttpClient(CIO, configBlock) + } + } + + // ------------------------------------------------------------------ + // public methods + // ------------------------------------------------------------------ + + /** + * `GET /healthz`. Doesn't require a bearer token, but the caller's IP + * must satisfy the server's global allowlist. + */ + public suspend fun healthz(): HealthStatus = wrapTransport("GET /healthz") { + val resp = http.get("$baseUrl/healthz") { authHeader() } + decodeOrThrow(resp) + } + + /** + * `POST /run`. + * + * On HTTP 200, returns a [RunResult]. On HTTP 502 (the server accepted + * the request but `claude -p` failed), throws [ForgeRunFailureException]. + * 401/403 throw [ForgeAuthException]; other 4xx/5xx throw + * [ForgeApiException]; transport problems throw [ForgeTransportException]. + */ + public suspend fun run(request: RunRequest): RunResult { + require(request.prompt.isNotEmpty()) { "RunRequest.prompt must not be empty" } + val effective = request.copy( + model = request.model ?: options.defaultModel, + timeoutSecs = request.timeoutSecs ?: options.defaultTimeout.inWholeSeconds.toInt(), + ) + return wrapTransport("POST /run") { + val resp = http.post("$baseUrl/run") { + authHeader() + contentType(ContentType.Application.Json) + setBody(effective) + } + decodeOrThrow(resp) + } + } + + /** + * Stream a file from disk to `POST /files`. Returns a [FileToken] you + * can pass via [RunRequest.files] on subsequent calls. + * + * The file is streamed — not buffered into memory — using Ktor's + * `submitFormWithBinaryData` + an [InputProvider] backed by the file's + * `InputStream`. So a 100 MB upload will use roughly the buffer size, + * not 100 MB of heap. + * + * @param ttlSecs server-clamped to `[60, 86400]`. Pass `null` for the + * server default of 3600. + */ + public suspend fun uploadFile(path: Path, ttlSecs: Int? = null): FileToken { + require(Files.isRegularFile(path)) { "not a regular file: $path" } + val size = Files.size(path) + val filename = path.fileName?.toString() ?: "upload" + return wrapTransport("POST /files") { + + val resp = http.submitFormWithBinaryData( + url = "$baseUrl/files", + formData = formData { + if (ttlSecs != null) { + append("ttl_secs", ttlSecs.toString()) + } + append( + key = "file", + value = InputProvider(size) { + Files.newInputStream(path).asInput() + }, + headers = io.ktor.http.Headers.build { + append( + HttpHeaders.ContentDisposition, + "filename=\"${filename.replace("\"", "")}\"", + ) + }, + ) + }, + ) { + authHeader() + } + decodeOrThrow(resp) + } + } + + /** + * `POST /admin/tokens`. Mints a per-app token; the plaintext lives in + * [AppToken.token] of the return value and cannot be retrieved again. + * Requires the admin bootstrap token. + */ + public suspend fun createToken(request: CreateTokenRequest): AppToken { + require(request.name.isNotEmpty()) { "CreateTokenRequest.name must not be empty" } + return wrapTransport("POST /admin/tokens") { + val resp = http.post("$baseUrl/admin/tokens") { + authHeader() + contentType(ContentType.Application.Json) + setBody(request) + } + decodeOrThrow(resp) + } + } + + /** `GET /admin/tokens` — list all configured app tokens (no plaintexts). */ + public suspend fun listTokens(): List = wrapTransport("GET /admin/tokens") { + val resp = http.get("$baseUrl/admin/tokens") { authHeader() } + decodeOrThrow(resp).tokens + } + + /** `DELETE /admin/tokens/{name}` — revoke an app token. */ + public suspend fun revokeToken(name: String) { + require(name.isNotEmpty()) { "name must not be empty" } + wrapTransport("DELETE /admin/tokens") { + val resp = http.delete("$baseUrl/admin/tokens/${name.encodeURLPathPart()}") { + authHeader() + } + if (!resp.status.isSuccess()) { + throwForStatus(resp) + } + } + } + + /** Disposes the underlying Ktor client. After this, the instance is unusable. */ + override fun close() { + http.close() + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + private fun HttpRequestBuilder.authHeader() { + if (token.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $token") + } + } + + private suspend inline fun decodeOrThrow(resp: HttpResponse): T { + if (!resp.status.isSuccess()) { + throwForStatus(resp) + } + return try { + resp.body() + } catch (e: SerializationException) { + throw ForgeTransportException("decode ${resp.request.url.encodedPath}", e) + } + } + + private suspend fun throwForStatus(resp: HttpResponse): Nothing { + val body = runCatching { resp.bodyAsText() }.getOrDefault("") + val status = resp.status.value + when (status) { + HttpStatusCode.Unauthorized.value, HttpStatusCode.Forbidden.value -> + throw ForgeAuthException(status, body) + + HttpStatusCode.BadGateway.value -> { + if (resp.request.url.encodedPath == "/run") { + val parsed = runCatching { json.decodeFromString(body) }.getOrNull() + if (parsed != null && parsed.error != null) { + throw ForgeRunFailureException( + errorMessage = parsed.error, + stderr = parsed.stderr.orEmpty(), + durationMs = parsed.durationMs, + stopReason = parsed.stopReason, + ) + } + } + throw ForgeApiException(status, body) + } + + else -> throw ForgeApiException(status, body) + } + } + + /** + * Wraps a block so any non-Forge exception (Ktor, IO, decode) becomes a + * [ForgeTransportException]. [CancellationException] is rethrown + * unmodified so structured concurrency keeps working. + */ + private inline fun wrapTransport(op: String, block: () -> T): T = try { + block() + } catch (ce: CancellationException) { + throw ce + } catch (fe: ForgeException) { + throw fe + } catch (e: Throwable) { + throw ForgeTransportException(op, e) + } +} diff --git a/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeOptions.kt b/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeOptions.kt new file mode 100644 index 0000000..2656b7a --- /dev/null +++ b/clients/kotlin/src/main/kotlin/com/clawdforge/ForgeOptions.kt @@ -0,0 +1,48 @@ +package com.clawdforge + +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngine +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Tunable knobs for [ForgeClient]. Everything has a sane default so the + * common case stays one-liner clean: + * + * ```kotlin + * ForgeClient("http://localhost:8800", "cf_...") + * ``` + * + * Override only what you need: + * + * ```kotlin + * ForgeClient( + * baseUrl = "http://192.168.0.5:8800", + * token = token, + * options = ForgeOptions( + * defaultModel = "opus", + * defaultTimeout = 120.seconds, + * requestMargin = 30.seconds, + * ), + * ) + * ``` + * + * @property defaultModel sent as `model` when [RunRequest.model] is null. + * @property defaultTimeout sent as `timeout_secs` when [RunRequest.timeoutSecs] + * is null. Subprocess wall-clock budget on the server. + * @property requestMargin extra time the HTTP request waits beyond the + * subprocess budget so we don't disconnect while clawdforge is + * still doing useful work for us. + * @property engine a pre-built Ktor engine to use instead of the default + * CIO engine. Useful for tests (`MockEngine`) or for plugging in + * OkHttp/Apache. + * @property configure additional configuration applied to the underlying + * Ktor `HttpClient`. Runs after our defaults so it can override. + */ +public data class ForgeOptions( + val defaultModel: String = "sonnet", + val defaultTimeout: Duration = 60.seconds, + val requestMargin: Duration = 30.seconds, + val engine: HttpClientEngine? = null, + val configure: (HttpClientConfig<*>.() -> Unit)? = null, +) diff --git a/clients/kotlin/src/main/kotlin/com/clawdforge/Models.kt b/clients/kotlin/src/main/kotlin/com/clawdforge/Models.kt new file mode 100644 index 0000000..0b1bff1 --- /dev/null +++ b/clients/kotlin/src/main/kotlin/com/clawdforge/Models.kt @@ -0,0 +1,107 @@ +package com.clawdforge + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Parsed body of `GET /healthz`. + * + * Property names are camelCase in Kotlin and `@SerialName`d to the snake_case + * keys the server emits (`claude_present`, `claude_version`). + */ +@Serializable +public data class HealthStatus( + val ok: Boolean = false, + @SerialName("claude_present") val claudePresent: Boolean = false, + @SerialName("claude_version") val claudeVersion: String? = null, +) + +/** + * Body for `POST /run`. + * + * Only [prompt] is required; the rest are optional and omitted from the + * wire when null. Use named arguments at the call site: + * + * ```kotlin + * client.run(RunRequest(prompt = "hi", model = "sonnet", timeoutSecs = 60)) + * ``` + * + * `timeout_secs` is clamped server-side to `[5, 600]`. + */ +@Serializable +public data class RunRequest( + val prompt: String, + val model: String? = null, + val system: String? = null, + val files: List? = null, + @SerialName("timeout_secs") val timeoutSecs: Int? = null, +) + +/** + * Parsed body of `POST /run` on success (HTTP 200). + * + * [result] is intentionally a [JsonElement] because the server may return + * a parsed JSON object/array (when claude returned valid JSON) OR a plain + * string (when it didn't). Narrow it with the `kotlinx.serialization.json` + * extensions: + * + * ```kotlin + * val obj = res.result.jsonObject // throws if not an object + * val txt = res.result.jsonPrimitive.contentOrNull + * ``` + * + * The companion-object helpers [resultAsObjectOrNull] and [resultAsTextOrNull] + * provide null-safe shortcuts for the common cases. + */ +@Serializable +public data class RunResult( + val ok: Boolean = false, + val result: JsonElement, + @SerialName("duration_ms") val durationMs: Long = 0, + @SerialName("stop_reason") val stopReason: String? = null, +) + +/** Failure shape returned by the server when `claude -p` fails (HTTP 502). */ +@Serializable +internal data class RunFailureBody( + val ok: Boolean = false, + val error: String? = null, + val stderr: String? = null, + @SerialName("duration_ms") val durationMs: Long = 0, + @SerialName("stop_reason") val stopReason: String? = null, +) + +/** Parsed body of `POST /files`. */ +@Serializable +public data class FileToken( + @SerialName("file_token") val fileToken: String, + @SerialName("ttl_secs") val ttlSecs: Int, + val size: Long, +) + +/** + * One entry from `GET /admin/tokens`, also returned (with [token] populated) + * by `POST /admin/tokens`. The plaintext [token] is only present at create + * time and cannot be retrieved later. + */ +@Serializable +public data class AppToken( + val name: String, + val token: String? = null, + @SerialName("ip_cidrs") val ipCidrs: List = emptyList(), + @SerialName("created_at") val createdAt: Long? = null, +) + +/** Body for `POST /admin/tokens`. */ +@Serializable +public data class CreateTokenRequest( + val name: String, + @SerialName("ip_cidrs") val ipCidrs: List = emptyList(), +) + +/** Wrapper for `GET /admin/tokens`. */ +@Serializable +internal data class TokenList( + val tokens: List = emptyList(), +) diff --git a/clients/kotlin/src/test/kotlin/com/clawdforge/ForgeClientTest.kt b/clients/kotlin/src/test/kotlin/com/clawdforge/ForgeClientTest.kt new file mode 100644 index 0000000..e5b6b8d --- /dev/null +++ b/clients/kotlin/src/test/kotlin/com/clawdforge/ForgeClientTest.kt @@ -0,0 +1,306 @@ +package com.clawdforge + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.io.path.createTempFile +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Unit tests for [ForgeClient] using Ktor's [MockEngine] — no real network. + */ +class ForgeClientTest { + + private fun client( + handler: suspend MockRequestHandleScope.(HttpRequestData) -> io.ktor.client.request.HttpResponseData, + ): ForgeClient { + val engine = MockEngine { req -> handler(req) } + return ForgeClient( + baseUrl = "http://forge.test", + token = "cf_test", + options = ForgeOptions(engine = engine), + ) + } + + private val jsonContentType = headersOf(HttpHeaders.ContentType, "application/json") + + // ------------------------------------------------------------------ + + @Test + fun healthz_parses_snake_case_into_camelCase() = runTest { + val c = client { req -> + assertEquals("/healthz", req.url.encodedPath) + assertEquals("Bearer cf_test", req.headers[HttpHeaders.Authorization]) + respond( + """{"ok": true, "claude_present": true, "claude_version": "1.2.3"}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val h = it.healthz() + assertTrue(h.ok) + assertTrue(h.claudePresent) + assertEquals("1.2.3", h.claudeVersion) + } + } + + @Test + fun run_serializes_snake_case_and_returns_json_object_result() = runTest { + var captured: String? = null + val c = client { req -> + assertEquals(HttpMethod.Post, req.method) + assertEquals("/run", req.url.encodedPath) + captured = (req.body as io.ktor.http.content.TextContent).text + respond( + """{"ok":true,"result":{"hello":"world","n":42},"duration_ms":120,"stop_reason":"end_turn"}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val r = it.run(RunRequest(prompt = "say hi", model = "sonnet", timeoutSecs = 30)) + assertTrue(r.ok) + assertEquals(120L, r.durationMs) + assertEquals("end_turn", r.stopReason) + assertEquals("world", r.result.jsonObject["hello"]?.jsonPrimitive?.content) + assertEquals(42, r.result.jsonObject["n"]?.jsonPrimitive?.int) + } + assertNotNull(captured) + // Wire format MUST be snake_case + assertContains(captured!!, "\"timeout_secs\":30") + assertContains(captured!!, "\"model\":\"sonnet\"") + assertContains(captured!!, "\"prompt\":\"say hi\"") + // omitted-when-null + assertTrue(!captured!!.contains("\"system\"")) + assertTrue(!captured!!.contains("\"files\"")) + } + + @Test + fun run_defaults_apply_when_unset() = runTest { + var captured: String? = null + val c = ForgeClient( + baseUrl = "http://forge.test", + token = "cf_test", + options = ForgeOptions( + engine = MockEngine { req -> + captured = (req.body as io.ktor.http.content.TextContent).text + respond( + """{"ok":true,"result":"hi","duration_ms":1,"stop_reason":"end_turn"}""", + HttpStatusCode.OK, + jsonContentType, + ) + }, + defaultModel = "opus", + ), + ) + c.use { it.run(RunRequest(prompt = "x")) } + assertContains(captured!!, "\"model\":\"opus\"") + assertContains(captured!!, "\"timeout_secs\":60") + } + + @Test + fun run_string_result_is_jsonPrimitive() = runTest { + val c = client { + respond( + """{"ok":true,"result":"plain text","duration_ms":5,"stop_reason":"end_turn"}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val r = it.run(RunRequest(prompt = "x")) + assertEquals("plain text", r.result.jsonPrimitive.content) + } + } + + @Test + fun run_502_throws_ForgeRunFailureException() = runTest { + val c = client { + respond( + """{"ok":false,"error":"timeout","stderr":"...","duration_ms":61000,"stop_reason":"timeout"}""", + HttpStatusCode.BadGateway, + jsonContentType, + ) + } + c.use { + val ex = assertFailsWith { + it.run(RunRequest(prompt = "x")) + } + assertEquals("timeout", ex.errorMessage) + assertEquals("timeout", ex.stopReason) + assertEquals(61000L, ex.durationMs) + } + } + + @Test + fun auth_failure_throws_ForgeAuthException() = runTest { + val c = client { + respondError(HttpStatusCode.Unauthorized, """{"detail":"bad token"}""") + } + c.use { + val ex = assertFailsWith { it.healthz() } + assertEquals(401, ex.statusCode) + assertContains(ex.body, "bad token") + } + } + + @Test + fun other_4xx_throws_ForgeApiException() = runTest { + val c = client { + respondError(HttpStatusCode.NotFound, """{"detail":"nope"}""") + } + c.use { + val ex = assertFailsWith { + it.run(RunRequest(prompt = "x")) + } + assertEquals(404, ex.statusCode) + assertContains(ex.body, "nope") + } + } + + @Test + fun createToken_round_trip() = runTest { + var capturedBody: String? = null + val c = client { req -> + assertEquals(HttpMethod.Post, req.method) + assertEquals("/admin/tokens", req.url.encodedPath) + capturedBody = (req.body as io.ktor.http.content.TextContent).text + respond( + """{"name":"cauldron","token":"cf_secret","ip_cidrs":["10.0.0.0/8"]}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val t = it.createToken(CreateTokenRequest(name = "cauldron", ipCidrs = listOf("10.0.0.0/8"))) + assertEquals("cauldron", t.name) + assertEquals("cf_secret", t.token) + assertEquals(listOf("10.0.0.0/8"), t.ipCidrs) + } + assertNotNull(capturedBody) + assertContains(capturedBody!!, "\"ip_cidrs\":[\"10.0.0.0/8\"]") + } + + @Test + fun listTokens_unwraps_envelope() = runTest { + val c = client { + respond( + """{"tokens":[{"name":"a","ip_cidrs":[],"created_at":1700000000},{"name":"b","ip_cidrs":["192.168.0.0/16"]}]}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val tokens = it.listTokens() + assertEquals(2, tokens.size) + assertEquals("a", tokens[0].name) + assertEquals(1700000000L, tokens[0].createdAt) + assertEquals(listOf("192.168.0.0/16"), tokens[1].ipCidrs) + } + } + + @Test + fun revokeToken_DELETE_path_url_encoded() = runTest { + var seenPath: String? = null + val c = client { req -> + assertEquals(HttpMethod.Delete, req.method) + seenPath = req.url.encodedPath + respond("""{"ok":true}""", HttpStatusCode.OK, jsonContentType) + } + c.use { it.revokeToken("cauldron") } + assertEquals("/admin/tokens/cauldron", seenPath) + } + + @Test + fun uploadFile_posts_multipart_to_files_endpoint() = runTest { + val tmp = createTempFile("forge-upload", ".txt") + tmp.writeText("hello upload") + val c = client { req -> + assertEquals(HttpMethod.Post, req.method) + assertEquals("/files", req.url.encodedPath) + assertEquals("Bearer cf_test", req.headers[HttpHeaders.Authorization]) + val ct = req.headers[HttpHeaders.ContentType] ?: req.body.contentType?.toString() ?: "" + assertContains(ct, "multipart/form-data") + respond( + """{"file_token":"ff_abc","ttl_secs":3600,"size":12}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val ft = it.uploadFile(tmp, ttlSecs = 3600) + assertEquals("ff_abc", ft.fileToken) + assertEquals(3600, ft.ttlSecs) + assertEquals(12L, ft.size) + } + } + + @Test + fun trailing_slash_baseUrl_is_stripped() = runTest { + val seen = mutableListOf() + val engine = MockEngine { req -> + seen += req.url.toString() + respond( + """{"ok":true,"claude_present":true,"claude_version":"x"}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + ForgeClient( + baseUrl = "http://forge.test/", + token = "t", + options = ForgeOptions(engine = engine), + ).use { it.healthz() } + assertEquals("http://forge.test/healthz", seen.single()) + } + + @Test + fun empty_prompt_throws_IllegalArgumentException() = runTest { + val c = client { + respond("""{}""", HttpStatusCode.OK, jsonContentType) + } + c.use { + assertFailsWith { + it.run(RunRequest(prompt = "")) + } + } + } + + @Test + fun result_jsonObject_can_be_narrowed_to_typed_value() = runTest { + val c = client { + respond( + """{"ok":true,"result":{"qty":2,"unit":"cup","approx":true},"duration_ms":1,"stop_reason":"end_turn"}""", + HttpStatusCode.OK, + jsonContentType, + ) + } + c.use { + val r = it.run(RunRequest(prompt = "p")) + val obj: JsonObject = r.result.jsonObject + assertEquals(2, (obj["qty"] as JsonPrimitive).int) + assertEquals("cup", (obj["unit"] as JsonPrimitive).content) + assertEquals(true, (obj["approx"] as JsonPrimitive).boolean) + } + } +}