diff --git a/clients/ruby/.gitignore b/clients/ruby/.gitignore new file mode 100644 index 0000000..f44ae8b --- /dev/null +++ b/clients/ruby/.gitignore @@ -0,0 +1,5 @@ +/.bundle/ +/vendor/ +/pkg/ +*.gem +Gemfile.lock diff --git a/clients/ruby/Gemfile b/clients/ruby/Gemfile new file mode 100644 index 0000000..be173b2 --- /dev/null +++ b/clients/ruby/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec diff --git a/clients/ruby/README.md b/clients/ruby/README.md new file mode 100644 index 0000000..e3ea74c --- /dev/null +++ b/clients/ruby/README.md @@ -0,0 +1,143 @@ +# clawdforge — Ruby SDK + +A small, dependency-free Ruby client for the [clawdforge](../../) HTTP service. + +clawdforge is a LAN-only HTTP service that wraps `claude -p` subprocess calls +behind a bearer-token-gated REST API. This SDK is a thin idiomatic wrapper +around that API. + +- **Stdlib only at runtime** — `net/http` + `json`. No `httparty`, no `faraday`. +- **Ruby 3.0+** — keyword args, frozen string literals. +- **Five public methods** — `healthz`, `run`, `upload_file`, `create_token`, + `list_tokens`, `revoke_token`. That's it. + +## Install + +From a path checkout (typical inside the clawdforge monorepo): + +```ruby +# Gemfile +gem "clawdforge", path: "clients/ruby" +``` + +Or from a built gem: + +```sh +cd clients/ruby +gem build clawdforge.gemspec +gem install ./clawdforge-0.1.0.gem +``` + +## Quickstart + +```ruby +require "clawdforge" + +forge = Clawdforge::Client.new( + base_url: "http://localhost:8800", + token: ENV.fetch("CLAWDFORGE_TOKEN"), +) + +# 1) liveness +health = forge.healthz +puts health["claude_version"] + +# 2) run a prompt +result = forge.run( + prompt: 'Reply with JSON: {"hello": "world"}', + model: "sonnet", # optional, default "sonnet" + system: "Be terse.", # optional + timeout_secs: 60, # optional, default 120, server range 5..600 +) + +puts result.duration_ms +puts result.stop_reason + +# `result.result` is parsed JSON (Hash) when the model returned valid JSON, +# otherwise the raw String. +puts result.result["hello"] + +# 3) upload a file, then attach it to a /run call +ft = forge.upload_file("./recipe.png", ttl_secs: 3600) +forge.run(prompt: "extract recipe data", files: [ft.file_token]) +``` + +## Public API + +### `Clawdforge::Client.new(base_url:, token:, **opts)` + +| keyword | type | default | meaning | +|-----------------------|---------|---------|--------------------------------------------------| +| `base_url:` | String | — | e.g. `"http://localhost:8800"`. Required. | +| `token:` | String | — | bearer token. Required. | +| `default_model:` | String | `"sonnet"` | model used when `run` doesn't supply one. | +| `default_timeout:` | Integer | `120` | `timeout_secs` used when `run` doesn't supply. | +| `http_timeout_margin:`| Integer | `30` | added to subprocess timeout for HTTP timeout. | +| `http_client:` | Net::HTTP | `nil` | inject a pre-built Net::HTTP (mostly for tests). | + +### `#healthz` +Returns a Hash: `{"ok" => Bool, "claude_present" => Bool, "claude_version" => String | nil}`. + +### `#run(prompt:, model: nil, system: nil, files: nil, timeout_secs: nil) -> RunResult` +- `prompt` (String, required, non-empty) +- `model` (String, optional) +- `system` (String, optional) +- `files` (Array, optional) — file tokens from `upload_file` +- `timeout_secs` (Integer, optional, server range 5..600) + +`RunResult` is a Struct with reader methods: +- `ok` (Bool) +- `result` (Hash | String) — parsed JSON if the model returned valid JSON, raw String otherwise +- `duration_ms` (Integer) +- `stop_reason` (String | nil) + +### `#upload_file(path, ttl_secs: 3600, filename: nil, content_type: "application/octet-stream") -> FileToken` +Streams the file in 1 MiB chunks via `IO.copy_stream`. `FileToken` carries +`file_token`, `ttl_secs`, `size`. + +### Admin methods (admin-bootstrap-token gated) +- `#create_token(name, ip_cidrs: nil) -> AppToken` +- `#list_tokens -> Array` +- `#revoke_token(name) -> true` (raises `APIError(404)` if not found) + +`AppToken`'s `token` field holds the plaintext bearer **only** at create +time; on list responses it is `nil` (the server stores only a sha256 hash). + +## Errors + +``` +Clawdforge::Error +├── Clawdforge::TransportError # connection refused, DNS, TCP timeout, TLS, … +└── Clawdforge::APIError # 4xx / 5xx, exposes #status and #body + └── Clawdforge::AuthError # 401 / 403 +``` + +Catch `Clawdforge::Error` to handle the whole family. On `APIError`, +inspect `.status` (Integer) and `.body` (Hash if JSON, String otherwise). + +```ruby +begin + forge.run(prompt: "hi", timeout_secs: 5) +rescue Clawdforge::AuthError => e + warn "bad token: #{e.message}" +rescue Clawdforge::APIError => e + warn "server said #{e.status}: #{e.body.inspect}" +rescue Clawdforge::TransportError => e + warn "couldn't reach forge: #{e.message}" +end +``` + +No retry logic is built in. clawdforge runs are not idempotent (they spawn +`claude -p`), so retry policy belongs with the caller. + +## Development + +```sh +bundle install +bundle exec rake test # runs Minitest with WebMock +bundle exec rake build # builds clawdforge-X.Y.Z.gem +``` + +## License + +MIT. diff --git a/clients/ruby/Rakefile b/clients/ruby/Rakefile new file mode 100644 index 0000000..3ad767e --- /dev/null +++ b/clients/ruby/Rakefile @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "lib" + t.libs << "test" + t.test_files = FileList["test/test_*.rb"] + t.warning = false +end + +desc "Build the gem" +task :build do + sh "gem build clawdforge.gemspec" +end + +task default: :test diff --git a/clients/ruby/clawdforge.gemspec b/clients/ruby/clawdforge.gemspec new file mode 100644 index 0000000..e539bc9 --- /dev/null +++ b/clients/ruby/clawdforge.gemspec @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "lib/clawdforge/version" + +Gem::Specification.new do |spec| + spec.name = "clawdforge" + spec.version = Clawdforge::VERSION + spec.authors = ["Kayos"] + spec.email = ["kayos@sulkta.com"] + + spec.summary = "Ruby SDK for clawdforge — a LAN HTTP service that wraps `claude -p`." + spec.description = <<~DESC + A small, dependency-free Ruby client for the clawdforge HTTP service. + Talks to /healthz, /run, /files, and /admin/tokens with a bearer token. + Built on Net::HTTP and the JSON stdlib — no external runtime gems. + DESC + spec.homepage = "https://github.com/Sulkta-Coop/clawdforge" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["rubygems_mfa_required"] = "true" + + spec.files = Dir[ + "lib/**/*.rb", + "README.md", + "clawdforge.gemspec", + ] + spec.require_paths = ["lib"] + + spec.add_development_dependency "minitest", "~> 5.18" + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "webmock", "~> 3.18" +end diff --git a/clients/ruby/examples/basic.rb b/clients/ruby/examples/basic.rb new file mode 100644 index 0000000..baee36a --- /dev/null +++ b/clients/ruby/examples/basic.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Basic usage example for the clawdforge Ruby SDK. +# +# Run with: +# CLAWDFORGE_URL=http://localhost:8800 \ +# CLAWDFORGE_TOKEN=cf_xxx \ +# ruby -Ilib examples/basic.rb + +require "clawdforge" + +base_url = ENV.fetch("CLAWDFORGE_URL", "http://localhost:8800") +token = ENV.fetch("CLAWDFORGE_TOKEN") + +forge = Clawdforge::Client.new(base_url: base_url, token: token) + +# 1) Healthz — verify the service is up and `claude` is installed. +health = forge.healthz +puts "claude_present: #{health["claude_present"]}" +puts "claude_version: #{health["claude_version"]}" + +# 2) Run a prompt that asks for JSON. `result` will be a parsed Hash. +result = forge.run( + prompt: 'Reply with JSON: {"hello": "world"}', + model: "sonnet", + system: "Be terse. Reply with JSON only.", + timeout_secs: 60, +) +puts "duration_ms: #{result.duration_ms}" +puts "stop_reason: #{result.stop_reason}" +puts "result: #{result.result.inspect}" + +# 3) Upload a file, then reference it from a /run call. +if File.file?("./recipe.png") + ft = forge.upload_file("./recipe.png", ttl_secs: 3600) + puts "uploaded #{ft.size} bytes; token=#{ft.file_token} ttl=#{ft.ttl_secs}s" + + vision = forge.run( + prompt: "extract recipe data as JSON", + files: [ft.file_token], + timeout_secs: 120, + ) + puts vision.result.inspect +end diff --git a/clients/ruby/lib/clawdforge.rb b/clients/ruby/lib/clawdforge.rb new file mode 100644 index 0000000..02a27a4 --- /dev/null +++ b/clients/ruby/lib/clawdforge.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Clawdforge — Ruby SDK for the clawdforge HTTP service. +# +# The clawdforge service wraps `claude -p` subprocess calls behind a bearer- +# token-gated REST API. This SDK is a thin idiomatic wrapper over that API. +# +# Quickstart: +# +# require "clawdforge" +# forge = Clawdforge::Client.new(base_url: "http://localhost:8800", token: "cf_...") +# r = forge.run(prompt: 'Reply with JSON: {"hello": "world"}') +# r.result # => {"hello" => "world"} +module Clawdforge +end + +require_relative "clawdforge/version" +require_relative "clawdforge/errors" +require_relative "clawdforge/models" +require_relative "clawdforge/client" diff --git a/clients/ruby/lib/clawdforge/client.rb b/clients/ruby/lib/clawdforge/client.rb new file mode 100644 index 0000000..cbbfdc5 --- /dev/null +++ b/clients/ruby/lib/clawdforge/client.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "securerandom" +require "uri" + +require_relative "errors" +require_relative "models" + +module Clawdforge + # Sync HTTP client for the clawdforge service. + # + # No retry logic is built in. Wrap calls in your own retry layer if you + # need it — clawdforge runs are not idempotent (they spawn `claude -p`), + # so retries belong with the caller who knows the prompt's semantics. + # + # @example + # forge = Clawdforge::Client.new(base_url: "http://localhost:8800", token: "cf_...") + # r = forge.run(prompt: 'Reply with JSON: {"hi": "ok"}') + # r.result # => {"hi" => "ok"} + class Client + DEFAULT_MODEL = "sonnet" + DEFAULT_RUN_TIMEOUT_SECS = 120 + # HTTP timeout = run's subprocess timeout + this margin so we don't bail + # while clawdforge is still doing legitimate work for us. + HTTP_TIMEOUT_MARGIN_SECS = 30 + HEALTHZ_TIMEOUT_SECS = 10 + ADMIN_TIMEOUT_SECS = 10 + + attr_reader :base_url, :default_model, :default_timeout, :http_timeout_margin + + # @param base_url [String] e.g. "http://localhost:8800". Trailing slash is stripped. + # @param token [String] bearer token. App token (`cf_...`) for `/run` and + # `/files`; admin bootstrap token for `/admin/*`. + # @param default_model [String] model passed to `/run` when caller doesn't + # supply one. Defaults to "sonnet". + # @param default_timeout [Integer] `timeout_secs` passed to `/run` when + # caller doesn't supply one. Defaults to 120. + # @param http_timeout_margin [Integer] seconds added to the subprocess + # timeout to derive the HTTP-level timeout. Defaults to 30. + # @param http_client [Net::HTTP, nil] optional pre-built Net::HTTP. Mostly + # useful for tests. + def initialize(base_url:, token:, + default_model: DEFAULT_MODEL, + default_timeout: DEFAULT_RUN_TIMEOUT_SECS, + http_timeout_margin: HTTP_TIMEOUT_MARGIN_SECS, + http_client: nil) + raise ArgumentError, "base_url is required" if base_url.nil? || base_url.empty? + raise ArgumentError, "token is required" if token.nil? || token.empty? + + @base_url = base_url.sub(%r{/+\z}, "") + @token = token + @default_model = default_model + @default_timeout = default_timeout + @http_timeout_margin = http_timeout_margin + @http_client = http_client + @uri = URI.parse(@base_url) + end + + # GET /healthz — liveness + `claude --version` smoke check. + # + # @return [Hash] `{"ok" => true, "claude_present" => Boolean, + # "claude_version" => String | nil}` + def healthz + request(:get, "/healthz", timeout: HEALTHZ_TIMEOUT_SECS) + end + + # POST /run — run a prompt through `claude -p`. + # + # @param prompt [String] required, non-empty. + # @param model [String, nil] override; defaults to `default_model`. + # @param system [String, nil] optional system prompt. + # @param files [Array, nil] `ff_...` file tokens previously + # returned from `upload_file`. + # @param timeout_secs [Integer, nil] per-run subprocess timeout (5..600). + # Defaults to `default_timeout`. + # @return [RunResult] + # @raise [APIError] on non-2xx; 502 carries `error`/`stderr`/etc. in `body`. + # @raise [AuthError] on 401/403. + # @raise [TransportError] on connection-level failures. + # @raise [ArgumentError] on empty prompt. + def run(prompt:, model: nil, system: nil, files: nil, timeout_secs: nil) + raise ArgumentError, "prompt must be non-empty" if prompt.nil? || prompt.empty? + + body = { "prompt" => prompt, "model" => model || @default_model } + body["system"] = system unless system.nil? + body["files"] = Array(files) if files && !files.empty? + body["timeout_secs"] = timeout_secs unless timeout_secs.nil? + + effective_run_timeout = timeout_secs || @default_timeout + http_timeout = effective_run_timeout + @http_timeout_margin + + payload = request(:post, "/run", json_body: body, timeout: http_timeout) + raise Error, "unexpected /run response type: #{payload.class}" unless payload.is_a?(Hash) + + RunResult.from_response(payload) + end + + # POST /files — upload a file, get back an `ff_...` token. + # + # Streams the file from disk in 1 MiB chunks via IO.copy_stream so big + # uploads don't load the whole payload into memory. + # + # @param path [String] filesystem path to the file to upload. + # @param ttl_secs [Integer] server-side TTL (60..86400). Defaults to 3600. + # @param filename [String, nil] override the filename advertised to the + # server. Defaults to `File.basename(path)`. + # @param content_type [String] MIME type. Defaults to "application/octet-stream". + # @return [FileToken] + def upload_file(path, ttl_secs: 3600, filename: nil, content_type: "application/octet-stream") + raise ArgumentError, "no such file: #{path}" unless File.file?(path) + + advertised_name = filename || File.basename(path) + boundary = "----ClawdforgeRB#{SecureRandom.hex(12)}" + + File.open(path, "rb") do |io| + body_io = build_multipart(io, advertised_name, content_type, ttl_secs, boundary) + headers = { + "Content-Type" => "multipart/form-data; boundary=#{boundary}", + "Content-Length" => body_io.size.to_s, + } + payload = request( + :post, "/files", + raw_body: body_io, + extra_headers: headers, + timeout: @default_timeout + @http_timeout_margin, + ) + unless payload.is_a?(Hash) + raise Error, "unexpected /files response type: #{payload.class}" + end + + FileToken.from_response(payload) + end + end + + # POST /admin/tokens — mint a per-app token. Admin-bootstrap-token gated. + # + # The returned AppToken's `token` field holds the plaintext bearer; store + # it immediately, the server only keeps a sha256 hash. + # + # @param name [String] app name (lowercase alnum + `_-`). + # @param ip_cidrs [Array, nil] per-app IP allowlist. + # @return [AppToken] + def create_token(name, ip_cidrs: nil) + body = { "name" => name, "ip_cidrs" => Array(ip_cidrs) } + payload = request(:post, "/admin/tokens", json_body: body, timeout: ADMIN_TIMEOUT_SECS) + raise Error, "unexpected /admin/tokens response" unless payload.is_a?(Hash) + + AppToken.from_create_response(payload) + end + + # GET /admin/tokens — list all tokens. + # @return [Array] + def list_tokens + payload = request(:get, "/admin/tokens", timeout: ADMIN_TIMEOUT_SECS) + unless payload.is_a?(Hash) && payload["tokens"].is_a?(Array) + raise Error, "unexpected /admin/tokens response" + end + + payload["tokens"].map { |row| AppToken.from_list_row(row) } + end + + # DELETE /admin/tokens/ — revoke a token. + # @return [Boolean] true on success. Raises APIError(404) if missing. + def revoke_token(name) + payload = request(:delete, "/admin/tokens/#{URI.encode_www_form_component(name)}", + timeout: ADMIN_TIMEOUT_SECS) + payload.is_a?(Hash) ? !!payload.fetch("ok", true) : true + end + + private + + # Build a multipart/form-data body containing the `ttl_secs` form field + # and the `file` part with the uploaded bytes. Returns a StringIO whose + # contents include the entire body (so `Content-Length` is exact). + def build_multipart(file_io, filename, content_type, ttl_secs, boundary) + out = StringIO.new + out.binmode + + out << "--#{boundary}\r\n" + out << %(Content-Disposition: form-data; name="ttl_secs"\r\n\r\n) + out << ttl_secs.to_s + out << "\r\n" + + out << "--#{boundary}\r\n" + out << %(Content-Disposition: form-data; name="file"; filename="#{quote_filename(filename)}"\r\n) + out << "Content-Type: #{content_type}\r\n\r\n" + IO.copy_stream(file_io, out) + out << "\r\n" + + out << "--#{boundary}--\r\n" + out.rewind + out + end + + def quote_filename(name) + # RFC 7578 §4.2 — escape backslashes and double quotes; strip CR/LF. + name.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').delete("\r\n") + end + + def request(method, path, json_body: nil, raw_body: nil, extra_headers: nil, timeout: nil) + uri = URI.join("#{@base_url}/", path.sub(%r{\A/}, "")) + req = build_request(method, uri, json_body: json_body, raw_body: raw_body, + extra_headers: extra_headers) + + http = @http_client || Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") if @http_client.nil? + if timeout + http.open_timeout = timeout + http.read_timeout = timeout + http.write_timeout = timeout if http.respond_to?(:write_timeout=) + end + + resp = + begin + http.request(req) + rescue StandardError => e + # Wrap any network/transport-level failure: SocketError, Errno::*, + # Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, etc. + raise TransportError, "transport: #{e.class}: #{e.message}" + end + + parse_response(resp) + end + + def build_request(method, uri, json_body:, raw_body:, extra_headers:) + klass = + case method + when :get then Net::HTTP::Get + when :post then Net::HTTP::Post + when :delete then Net::HTTP::Delete + else raise ArgumentError, "unsupported HTTP method: #{method}" + end + + req = klass.new(uri.request_uri) + req["Authorization"] = "Bearer #{@token}" + req["Accept"] = "application/json" + + if json_body + req["Content-Type"] = "application/json" + req.body = JSON.generate(json_body) + elsif raw_body + # Caller supplies Content-Type / Content-Length via extra_headers. + req.body_stream = raw_body + end + + extra_headers&.each { |k, v| req[k] = v } + req + end + + def parse_response(resp) + raw = resp.body + body = + if raw.nil? || raw.empty? + nil + else + begin + JSON.parse(raw) + rescue JSON::ParserError + raw + end + end + + status = resp.code.to_i + return body if status < 400 + + short = + case body + when Hash then body["error"] || body["detail"] || "" + when String then body[0, 200] + else "" + end + msg = "#{status} #{resp.message}: #{short}".sub(/: \z/, "") + + if [401, 403].include?(status) + raise AuthError.new(msg, status: status, body: body) + end + + raise APIError.new(msg, status: status, body: body) + end + end +end diff --git a/clients/ruby/lib/clawdforge/errors.rb b/clients/ruby/lib/clawdforge/errors.rb new file mode 100644 index 0000000..42ab819 --- /dev/null +++ b/clients/ruby/lib/clawdforge/errors.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Clawdforge + # Base class for everything this SDK raises. Catch this to handle the + # whole family in one rescue. + class Error < StandardError; end + + # Raised when the HTTP request never produced a response: connection + # refused, DNS failure, TCP timeout, TLS handshake failure, etc. The + # underlying exception is preserved on `cause`. + class TransportError < Error; end + + # Raised on any 4xx or 5xx response from the server. + # + # Inspect: + # * `status` — the HTTP status code + # * `body` — the parsed response body (Hash if JSON, String otherwise, or nil) + class APIError < Error + attr_reader :status, :body + + def initialize(message, status:, body: nil) + super(message) + @status = status + @body = body + end + + def to_s + "#{super} (status=#{@status})" + end + end + + # 401 / 403 — bad token, missing token, or IP not in the allowlist. + # Subclass of APIError so a single `rescue APIError` still catches it. + class AuthError < APIError; end +end diff --git a/clients/ruby/lib/clawdforge/models.rb b/clients/ruby/lib/clawdforge/models.rb new file mode 100644 index 0000000..8669168 --- /dev/null +++ b/clients/ruby/lib/clawdforge/models.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Clawdforge + # Result of a successful `POST /run`. + # + # `result` is whatever clawdforge parsed out of `claude -p --output-format + # json`: a Hash if the model returned valid JSON, otherwise the raw String. + # `ok` is always true for a RunResult — failures raise APIError instead. + RunResult = Struct.new(:ok, :result, :duration_ms, :stop_reason, keyword_init: true) do + def self.from_response(payload) + new( + ok: payload.fetch("ok", true) ? true : false, + result: payload["result"], + duration_ms: payload.fetch("duration_ms", 0).to_i, + stop_reason: payload["stop_reason"], + ) + end + end + + # Result of a successful `POST /files`. Pass `file_token` to + # `Client#run(files: [...])` to attach the upload to a prompt. + FileToken = Struct.new(:file_token, :ttl_secs, :size, keyword_init: true) do + def self.from_response(payload) + new( + file_token: payload.fetch("file_token"), + ttl_secs: payload.fetch("ttl_secs").to_i, + size: payload.fetch("size").to_i, + ) + end + end + + # A row from `GET /admin/tokens` or the result of `POST /admin/tokens`. + # `token` is the plaintext bearer string, returned ONLY at create time. + # On list responses it is nil; the server stores only a sha256 hash. + AppToken = Struct.new( + :name, :token, :ip_cidrs, :created_at, :last_used, :enabled, + keyword_init: true, + ) do + def self.from_create_response(payload) + new( + name: payload.fetch("name"), + token: payload["token"], + ip_cidrs: Array(payload["ip_cidrs"]), + created_at: nil, + last_used: nil, + enabled: true, + ) + end + + def self.from_list_row(row) + raw = row["ip_cidrs"] + cidrs = + case raw + when String then raw.split(",").reject(&:empty?) + when Array then raw.dup + else [] + end + new( + name: row.fetch("name"), + token: nil, + ip_cidrs: cidrs, + created_at: row["created_at"], + last_used: row["last_used"], + enabled: row.fetch("enabled", 1).to_i != 0, + ) + end + end +end diff --git a/clients/ruby/lib/clawdforge/version.rb b/clients/ruby/lib/clawdforge/version.rb new file mode 100644 index 0000000..1f1160f --- /dev/null +++ b/clients/ruby/lib/clawdforge/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Clawdforge + VERSION = "0.1.0" +end diff --git a/clients/ruby/test/test_client.rb b/clients/ruby/test/test_client.rb new file mode 100644 index 0000000..2917b5e --- /dev/null +++ b/clients/ruby/test/test_client.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "webmock/minitest" +require "json" +require "tempfile" +require "stringio" + +require "clawdforge" + +BASE_URL = "http://forge.test:8800" +TOKEN = "cf_test_token_xxxxxxxx" + +def build_client(**overrides) + Clawdforge::Client.new( + base_url: BASE_URL, + token: TOKEN, + default_timeout: 60, + **overrides, + ) +end + +class HealthzTest < Minitest::Test + def test_healthz_returns_parsed_json + stub_request(:get, "#{BASE_URL}/healthz") + .with(headers: { "Authorization" => "Bearer #{TOKEN}" }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "ok" => true, "claude_present" => true, "claude_version" => "1.2.3", + ), + ) + + out = build_client.healthz + + assert_equal true, out["ok"] + assert_equal "1.2.3", out["claude_version"] + end +end + +class RunTest < Minitest::Test + def test_run_success_with_json_result + stub_request(:post, "#{BASE_URL}/run").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "ok" => true, + "result" => { "hello" => "world" }, + "duration_ms" => 1234, + "stop_reason" => "end_turn", + ), + ) + + r = build_client.run(prompt: 'Reply with JSON: {"hello": "world"}') + + assert_kind_of Clawdforge::RunResult, r + assert_equal true, r.ok + assert_equal({ "hello" => "world" }, r.result) + assert_equal 1234, r.duration_ms + assert_equal "end_turn", r.stop_reason + end + + def test_run_success_with_string_result + stub_request(:post, "#{BASE_URL}/run").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "ok" => true, "result" => "plain text reply", + "duration_ms" => 800, "stop_reason" => "end_turn", + ), + ) + + r = build_client.run(prompt: "hello") + assert_equal "plain text reply", r.result + end + + def test_run_sends_full_body_and_auth + captured = {} + stub_request(:post, "#{BASE_URL}/run").with do |req| + captured[:auth] = req.headers["Authorization"] + captured[:body] = JSON.parse(req.body) + true + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("ok" => true, "result" => {}, "duration_ms" => 1, "stop_reason" => "end_turn"), + ) + + build_client.run( + prompt: "hi", model: "opus", system: "be terse", + files: ["ff_abc"], timeout_secs: 42, + ) + + assert_equal "Bearer #{TOKEN}", captured[:auth] + assert_equal( + { + "prompt" => "hi", "model" => "opus", "system" => "be terse", + "files" => ["ff_abc"], "timeout_secs" => 42, + }, + captured[:body], + ) + end + + def test_run_uses_default_model_and_omits_optional_fields + captured = {} + stub_request(:post, "#{BASE_URL}/run").with do |req| + captured[:body] = JSON.parse(req.body) + true + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("ok" => true, "result" => "x", "duration_ms" => 1, "stop_reason" => "end_turn"), + ) + + build_client.run(prompt: "hi") + + assert_equal "sonnet", captured[:body]["model"] + refute captured[:body].key?("system") + refute captured[:body].key?("files") + refute captured[:body].key?("timeout_secs") + end + + def test_run_502_raises_api_error_with_body + stub_request(:post, "#{BASE_URL}/run").to_return( + status: 502, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "ok" => false, "error" => "subprocess timed out", + "stderr" => "...", "duration_ms" => 60_000, "stop_reason" => "timeout", + ), + ) + + err = assert_raises(Clawdforge::APIError) do + build_client.run(prompt: "hi", timeout_secs: 60) + end + assert_equal 502, err.status + assert_kind_of Hash, err.body + assert_equal "timeout", err.body["stop_reason"] + assert_match(/subprocess timed out/, err.message) + end + + def test_run_401_raises_auth_error + stub_request(:post, "#{BASE_URL}/run").to_return( + status: 401, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("detail" => "missing bearer"), + ) + + err = assert_raises(Clawdforge::AuthError) { build_client.run(prompt: "hi") } + assert_equal 401, err.status + assert_kind_of Clawdforge::APIError, err + assert_kind_of Clawdforge::Error, err + end + + def test_run_transport_error_when_unreachable + stub_request(:post, "#{BASE_URL}/run").to_raise(SocketError.new("getaddrinfo: nope")) + + assert_raises(Clawdforge::TransportError) { build_client.run(prompt: "hi") } + end + + def test_run_empty_prompt_rejected_locally + assert_raises(ArgumentError) { build_client.run(prompt: "") } + end +end + +class FilesTest < Minitest::Test + def test_upload_file_from_path + stub_request(:post, "#{BASE_URL}/files") + .with(headers: { "Content-Type" => %r{\Amultipart/form-data; boundary=} }) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("file_token" => "ff_abc123", "ttl_secs" => 3600, "size" => 11), + ) + + Tempfile.create(["upload", ".txt"]) do |tf| + tf.binmode + tf.write("hello world") + tf.flush + + ft = build_client.upload_file(tf.path, ttl_secs: 3600) + + assert_kind_of Clawdforge::FileToken, ft + assert_equal "ff_abc123", ft.file_token + assert_equal 3600, ft.ttl_secs + assert_equal 11, ft.size + end + end + + def test_upload_file_sends_multipart_with_ttl_and_file_parts + captured = {} + stub_request(:post, "#{BASE_URL}/files").with do |req| + captured[:content_type] = req.headers["Content-Type"] + captured[:body] = req.body + true + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("file_token" => "ff_xyz", "ttl_secs" => 60, "size" => 5), + ) + + Tempfile.create(["snippet", ".bin"]) do |tf| + tf.binmode + tf.write("hello") + tf.flush + + build_client.upload_file(tf.path, ttl_secs: 60, filename: "snippet.txt") + end + + assert_match(%r{\Amultipart/form-data; boundary=}, captured[:content_type]) + assert_match(/name="ttl_secs"/, captured[:body]) + assert_match(/name="file"; filename="snippet\.txt"/, captured[:body]) + assert_match(/hello/, captured[:body]) + end + + def test_upload_file_400_raises_api_error + stub_request(:post, "#{BASE_URL}/files").to_return( + status: 400, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("detail" => "ttl_secs out of range (60..86400)"), + ) + + Tempfile.create(["bad", ".bin"]) do |tf| + tf.binmode + tf.write("x") + tf.flush + + err = assert_raises(Clawdforge::APIError) do + build_client.upload_file(tf.path, ttl_secs: 10) + end + assert_equal 400, err.status + end + end +end + +class AdminTokensTest < Minitest::Test + def test_create_token + stub_request(:post, "#{BASE_URL}/admin/tokens").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "name" => "cauldron", + "token" => "cf_brandnew_xxx", + "ip_cidrs" => ["172.24.0.0/16"], + ), + ) + + t = build_client.create_token("cauldron", ip_cidrs: ["172.24.0.0/16"]) + + assert_kind_of Clawdforge::AppToken, t + assert_equal "cauldron", t.name + assert_equal "cf_brandnew_xxx", t.token + assert_equal ["172.24.0.0/16"], t.ip_cidrs + end + + def test_list_tokens_normalizes_csv_ip_cidrs + stub_request(:get, "#{BASE_URL}/admin/tokens").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + "tokens" => [ + { + "name" => "cauldron", "ip_cidrs" => "172.24.0.0/16", + "created_at" => 100, "last_used" => 200, "enabled" => 1, + }, + { + "name" => "petalparse", "ip_cidrs" => "", + "created_at" => 50, "last_used" => nil, "enabled" => 0, + }, + ], + ), + ) + + toks = build_client.list_tokens + + assert_equal 2, toks.length + assert_equal "cauldron", toks[0].name + assert_equal ["172.24.0.0/16"], toks[0].ip_cidrs + assert_equal true, toks[0].enabled + assert_nil toks[0].token + assert_equal [], toks[1].ip_cidrs + assert_equal false, toks[1].enabled + end + + def test_revoke_token_ok + stub_request(:delete, "#{BASE_URL}/admin/tokens/cauldron").to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("ok" => true), + ) + + assert_equal true, build_client.revoke_token("cauldron") + end + + def test_revoke_token_404 + stub_request(:delete, "#{BASE_URL}/admin/tokens/nosuch").to_return( + status: 404, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate("detail" => "no such token"), + ) + + err = assert_raises(Clawdforge::APIError) { build_client.revoke_token("nosuch") } + assert_equal 404, err.status + end +end + +class ClientConstructionTest < Minitest::Test + def test_requires_base_url + assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "", token: "cf_x") } + end + + def test_requires_token + assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "http://x", token: "") } + end + + def test_strips_trailing_slash + c = Clawdforge::Client.new(base_url: "http://x:8800/", token: "cf_x") + assert_equal "http://x:8800", c.base_url + end +end