Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions docs/cli-installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ ClaudeAgentSDK::CLIInstaller.installed_path
`install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot:

- The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again.
- An exclusive `flock` on `<dir>/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race; the loser simply observes the finished install.
- An exclusive `flock` on `<dir>/.install.lock` covers the whole check → download → record → place sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install.
- The shortcut also works where the running process cannot write the install directory: an image built as root and run as another user, or a read-only root filesystem. `install` cannot open its lock file there (`EACCES`, `EROFS` or `EPERM`), and still returns the binary when the request is a concrete version that is already installed and intact — the same `VERSION` and SHA-256 check, without the lock. Anything that would need a write raises `CLIInstallError` as before: a dist-tag (`'stable'` / `'latest'`, which has to be resolved and may have to be installed), a different version, a damaged binary, an empty directory.

Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`.

**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all.
**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails never publishes a binary. It can leave the install directory and its `.install.lock` behind and — when the step that failed is the last one, the rename — a `VERSION` that already records the version. Nothing trusts that file on its own: discovery finds no binary there and moves on, and the next `install` redoes it cleanly.

## When the pin moves

Expand Down Expand Up @@ -96,6 +97,17 @@ RUN bin/rails claude_agent_sdk:install_cli

The variable is deliberately not rake's conventional `VERSION`, which Rails' `db:migrate` uses and build environments often export for an app version or git SHA. An empty `CLAUDE_CLI_VERSION` means the gem's pin.

## Proxies and custom CAs

The installer downloads over HTTPS with Ruby's `Net::HTTP` and reads the usual environment variables:

- **`HTTPS_PROXY`** (or `https_proxy`) names the proxy to download through, as an `http://` URL: `HTTPS_PROXY=http://proxy.corp.example:3128`, with `user:password@` in front of the host for an authenticating proxy (percent-encode special characters). The download is tunnelled through it with `CONNECT`, so TLS still ends at the release endpoint.
- **`NO_PROXY`** (or `no_proxy`) lists the hosts, domain suffixes and IP ranges to reach directly, separated by commas.
- With neither spelling of `HTTPS_PROXY` set, `Net::HTTP`'s own default applies: it goes through `http_proxy` when that is set.
- **`SSL_CERT_FILE`** points OpenSSL at another CA bundle, which is what a TLS-inspecting proxy needs. Set it in the environment the process starts with. The server certificate is always verified; there is no switch to turn that off.

This is not everything `curl` understands. `ALL_PROXY` is not read, and only an `http://` proxy URL is used: a value without a scheme (`proxy.corp.example:3128`), a `socks5://` proxy or an `https://` one is ignored.

## Supported platforms

`darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported.
Expand Down
23 changes: 22 additions & 1 deletion docs/mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,28 @@ ClaudeAgentSDK.create_tool('lookup_order', 'Look up an order', { id: :string })
end
```

Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key.
Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. So is a Hash whose `:content` is not an Array — `{ content: 'text' }`, or a single block Hash — with a message saying `:content` must be an Array of content blocks; return the String itself, or wrap the block in an Array.

## Shorthand Input Schemas

`{ name: :string }` is shorthand for a JSON Schema object in which every listed parameter is required. Each value names a type, as a Ruby class or as a Symbol:

| Shorthand | JSON Schema type | The handler receives |
| --- | --- | --- |
| `String`, `:string` | `string` | a String |
| `Integer`, `:integer` | `integer` | an Integer |
| `Float`, `:float`, `:number` | `number` | an Integer or a Float |
| `TrueClass`, `FalseClass`, `:boolean` | `boolean` | `true` or `false` |
| `Array`, `:array` | `array` | an Array, with elements of any type |
| `Hash`, `:object` | `object` | a Hash with Symbol keys |

```ruby
ClaudeAgentSDK.create_tool('tag_order', 'Tag an order', { order_id: Integer, tags: Array }) do |args|
"Tagged order #{args[:order_id]} with #{args[:tags].join(', ')}"
end
```

Arguments are validated against these types before the handler runs. For an optional parameter, the type of an Array's elements, an enum or a per-parameter description, pass a full JSON Schema instead (next section).

## Pre-built JSON Schemas

Expand Down
22 changes: 21 additions & 1 deletion formal/tla/CLIInstaller.tla
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,27 @@ Fail(i) ==
/\ Leave(i, "fail")
/\ UNCHANGED <<tag, vfile, published, everInstalled>>

\* SIGKILL / OOM / power loss: no `ensure`, the temp file stays behind.
\* The process dies with no `ensure`: SIGKILL, OOM, or the machine losing
\* power. The temp file stays behind and the flock is gone (released by the
\* kernel, or lost with it).
\*
\* UNCHANGED is exact for a dead process: the files are what the completed
\* steps left. For a power loss it holds for the two files the properties
\* read, because of what the code fsyncs: the downloaded bytes, and the
\* binary's mode after its chmod, before Rename, and the VERSION bytes before
\* Record's rename. A name that survives the
\* outage therefore holds a complete file -- `binary` and `vfile` are each the
\* old value or the new one, never an empty or partial file. (Before those
\* fsyncs a first install could come back as an empty executable `claude`
\* beside a VERSION naming it, which breaks ReadersSeeVerifiedBinary.)
\* A rename that had not reached the disk is undone; on a filesystem that
\* commits directory operations in order, that is this step at an earlier pc.
\*
\* Not modelled: the directory fsync after Rename, which is what keeps a
\* FINISHED install across an outage (best-effort; skipped where a directory
\* cannot be synced), and `tmps` after an outage -- a leftover temp file may be
\* missing or short, which is harmless because temp files are only ever
\* swept, never read. All of it assumes fsync(2) reaches stable storage.
Crash(i) ==
/\ pc[i] # "idle"
/\ Leave(i, "crash")
Expand Down
2 changes: 2 additions & 0 deletions formal/tla/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,7 @@ There are three properties. `EverySenderFinishes` says every control method retu

- File contents are abstracted to "the version they are the bytes of", so a checksum match is equality. Platform detection is not modelled.
- The models assume that POSIX `rename` is atomic and that the kernel releases a dead process's flock. `ReadersSeeVerifiedBinary` holds by construction given those assumptions.
- Durability is assumed, not modelled. `Crash` leaves the files as the completed steps left them, which is exact when a process dies. Across a power loss it rests on the installer's `fsync`s — the downloaded bytes before `Rename`, the `VERSION` bytes before `Record`'s rename, and (best-effort) the directory after `Rename` — and on `fsync(2)` reaching stable storage: a surviving name then holds a complete file, and a rename that had not reached the disk is undone, which is `Crash` at an earlier step on a filesystem that commits directory operations in order.
- Not modelled: `install`'s read-only shortcut. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`) and the request is a concrete version that is already installed and re-hashes to its recorded checksum, `install` returns the binary without the lock. That path takes no lock and writes nothing, so it is one more lock-free reader, which `ReadersSeeVerifiedBinary` already covers; no modelled ordering and no `run.sh` expectation changes.
- Not modelled: control-request timeouts (see above); the inbound half (`handle_control_request`, the #119 process-exit path, cancellation during a response write, which the transport answers by poisoning itself so no partial frame is ever followed by another); `Metadata.write`'s own temp file; `TranscriptMirrorBatcher`.
- **The Ruby is not verified.** A model can drift from the code it describes. When you change one of the orderings the tables cite, update the model and `run.sh`'s expectations in the same change. Each counterexample trace can also be turned into an RSpec regression by stubbing `CLIInstaller::Http` / `Release` / `Metadata`, or with a scripted transport for `Query`, so that the step TLC found is exercised against the real code.
Loading
Loading