clients/ruby: initial Ruby SDK for clawdforge
This commit is contained in:
parent
90e158f2fe
commit
b1d6e3f697
12 changed files with 981 additions and 0 deletions
5
clients/ruby/.gitignore
vendored
Normal file
5
clients/ruby/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/.bundle/
|
||||
/vendor/
|
||||
/pkg/
|
||||
*.gem
|
||||
Gemfile.lock
|
||||
5
clients/ruby/Gemfile
Normal file
5
clients/ruby/Gemfile
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
gemspec
|
||||
143
clients/ruby/README.md
Normal file
143
clients/ruby/README.md
Normal file
|
|
@ -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<String>, 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<AppToken>`
|
||||
- `#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.
|
||||
17
clients/ruby/Rakefile
Normal file
17
clients/ruby/Rakefile
Normal file
|
|
@ -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
|
||||
35
clients/ruby/clawdforge.gemspec
Normal file
35
clients/ruby/clawdforge.gemspec
Normal file
|
|
@ -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
|
||||
44
clients/ruby/examples/basic.rb
Normal file
44
clients/ruby/examples/basic.rb
Normal file
|
|
@ -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
|
||||
20
clients/ruby/lib/clawdforge.rb
Normal file
20
clients/ruby/lib/clawdforge.rb
Normal file
|
|
@ -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"
|
||||
283
clients/ruby/lib/clawdforge/client.rb
Normal file
283
clients/ruby/lib/clawdforge/client.rb
Normal file
|
|
@ -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<String>, 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<String>, 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<AppToken>]
|
||||
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/<name> — 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
|
||||
35
clients/ruby/lib/clawdforge/errors.rb
Normal file
35
clients/ruby/lib/clawdforge/errors.rb
Normal file
|
|
@ -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
|
||||
68
clients/ruby/lib/clawdforge/models.rb
Normal file
68
clients/ruby/lib/clawdforge/models.rb
Normal file
|
|
@ -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
|
||||
5
clients/ruby/lib/clawdforge/version.rb
Normal file
5
clients/ruby/lib/clawdforge/version.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Clawdforge
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
321
clients/ruby/test/test_client.rb
Normal file
321
clients/ruby/test/test_client.rb
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue