ruby_claude 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +289 -0
- data/lib/ruby_claude/client.rb +157 -0
- data/lib/ruby_claude/command.rb +70 -0
- data/lib/ruby_claude/configuration.rb +95 -0
- data/lib/ruby_claude/errors.rb +42 -0
- data/lib/ruby_claude/event.rb +77 -0
- data/lib/ruby_claude/response.rb +57 -0
- data/lib/ruby_claude/runner.rb +139 -0
- data/lib/ruby_claude/session.rb +34 -0
- data/lib/ruby_claude/version.rb +6 -0
- data/lib/ruby_claude.rb +70 -0
- data/ruby_claude.gemspec +40 -0
- metadata +118 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 46dffd5cda4df76cdd6a657076af87f43c068929ce40b0973e2aa4dd26b4a7f1
|
|
4
|
+
data.tar.gz: 5c55b6f8448491d93d3716bbfe9694ea7f160bef7bd1c5410a3021778f5b362a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 34dcef3922757a6b7580f196f0528268b9827ec6bdf0fea979ef768a7d12ecc0723c3f75dcec094b25f8dd45d7a20c682aef654e535f878775d4470bcd33a615
|
|
7
|
+
data.tar.gz: 0b2ecd60e8dbf74ef7d64b4b0eda9932b7b9dc5aad6a1c09aeb1dabaa159de5f0fdd1656b13cfc5944b7b681d269bc69668a967e2e8d825a92320244b86d14c5
|
data/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Kaíque Kandy Koga
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Ruby Claude
|
|
2
|
+
|
|
3
|
+
A small, dependency-light, idiomatic Ruby SDK for talking to Claude — by
|
|
4
|
+
shelling out to the **Claude Code CLI** (`claude -p`) in headless mode and
|
|
5
|
+
authenticating with your **Claude Pro/Max subscription** instead of an
|
|
6
|
+
Anthropic API key.
|
|
7
|
+
|
|
8
|
+
> **Unofficial.** This is a community gem. It is *not* affiliated with or
|
|
9
|
+
> endorsed by Anthropic. It uses a documented, supported headless feature
|
|
10
|
+
> (`claude -p`) and stays within your subscription's normal rate limits. It
|
|
11
|
+
> does **not** extract or reuse OAuth tokens, and it makes **no** direct HTTP
|
|
12
|
+
> calls to the Anthropic API.
|
|
13
|
+
|
|
14
|
+
## Why a subscription instead of an API key?
|
|
15
|
+
|
|
16
|
+
`claude -p "<prompt>"` runs Claude Code non-interactively and prints the
|
|
17
|
+
result, using whatever credentials the CLI is logged in with. If you logged in
|
|
18
|
+
with a **subscription** (`claude` → `/login` → subscription option), those
|
|
19
|
+
calls draw on your subscription — **no API billing**.
|
|
20
|
+
|
|
21
|
+
The one catch: if `ANTHROPIC_API_KEY` is present in the environment, Claude
|
|
22
|
+
Code may use it and bill the API. **Ruby Claude strips `ANTHROPIC_API_KEY`
|
|
23
|
+
from the child process environment by default** (`use_subscription = true`) so
|
|
24
|
+
the CLI falls back to your logged-in subscription credentials. Set
|
|
25
|
+
`use_subscription = false` only if you *want* API-key billing.
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
This gem drives the `claude` binary; it does not install or replace it.
|
|
30
|
+
|
|
31
|
+
1. Install Node.js and the Claude Code CLI, and make sure `claude` is on your `PATH`:
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g @anthropic-ai/claude-code
|
|
34
|
+
claude --version
|
|
35
|
+
```
|
|
36
|
+
2. Log in **once**, choosing the subscription option:
|
|
37
|
+
```bash
|
|
38
|
+
claude # then run /login and pick "Claude account with subscription"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
Add it to your `Gemfile`:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
gem "ruby_claude"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or install directly:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
gem install ruby_claude
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Ruby **3.2+** is required (the value objects use `Data.define`). The gem has
|
|
56
|
+
**zero runtime dependencies** — it only uses the standard library.
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
require "ruby_claude"
|
|
62
|
+
|
|
63
|
+
puts RubyClaude.query("Summarize lib/foo.rb in two sentences")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That's it — if `claude` is installed and logged in, you get an answer back,
|
|
67
|
+
billed against your subscription.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### 1. One-shot convenience
|
|
72
|
+
|
|
73
|
+
Delegates to a memoized, globally-configured default client.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
puts RubyClaude.query("Summarize lib/foo.rb in two sentences")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. A configured client
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
client = RubyClaude::Client.new(
|
|
83
|
+
model: "claude-sonnet-4-6",
|
|
84
|
+
cwd: "/path/to/project",
|
|
85
|
+
append_system_prompt: "Always answer concisely.",
|
|
86
|
+
allowed_tools: ["Read", "Grep"],
|
|
87
|
+
timeout: 180
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
res = client.query("What does this project do?")
|
|
91
|
+
res.text # => String, the final assistant result
|
|
92
|
+
res.session_id # => String
|
|
93
|
+
res.cost_usd # => Float (often 0.0 on a subscription)
|
|
94
|
+
res.usage # => Hash (token counts, when present)
|
|
95
|
+
res.num_turns # => Integer
|
|
96
|
+
res.duration_ms # => Integer
|
|
97
|
+
res.error? # => false
|
|
98
|
+
res.raw # => parsed Hash of the CLI's final result JSON
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`Response#to_s` returns `text`, so `puts client.query("...")` prints the answer.
|
|
102
|
+
|
|
103
|
+
### 3. Streaming
|
|
104
|
+
|
|
105
|
+
`#stream` yields typed events as they arrive and returns the final `Response`.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
client.stream("Write a haiku about Ruby") do |event|
|
|
109
|
+
case event.type
|
|
110
|
+
when :assistant then print event.text # assistant text for the turn
|
|
111
|
+
when :result then puts "\n[done in #{event.duration_ms}ms]"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Each `Event` exposes `type` (`:system`, `:assistant`, `:user`, `:result`),
|
|
117
|
+
`text`, `session_id`, `cost_usd`, `duration_ms`, and `raw` (the full parsed
|
|
118
|
+
line). Streaming uses `--output-format stream-json --verbose` under the hood.
|
|
119
|
+
|
|
120
|
+
### 4. Multi-turn session
|
|
121
|
+
|
|
122
|
+
A `Session` captures the underlying `session_id` from the first reply and
|
|
123
|
+
transparently resumes it on later calls.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
session = client.session
|
|
127
|
+
session.query("My favorite number is 7.")
|
|
128
|
+
puts session.query("What's my favorite number?") # => "...7..."
|
|
129
|
+
session.id # => the session_id being resumed
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
You can also resume a known session: `client.session(id: "…")`.
|
|
133
|
+
|
|
134
|
+
### 5. Global configuration
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
RubyClaude.configure do |c|
|
|
138
|
+
c.model = "claude-sonnet-4-6"
|
|
139
|
+
c.timeout = 300
|
|
140
|
+
c.binary = "claude" # path/name of the CLI
|
|
141
|
+
c.cwd = Dir.pwd
|
|
142
|
+
c.use_subscription = true # strips ANTHROPIC_API_KEY from the child env
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
These become the defaults for `RubyClaude.query` and for new `Client`
|
|
147
|
+
instances. Per-client options passed to `Client.new(**opts)` override them.
|
|
148
|
+
|
|
149
|
+
> **Note:** there is intentionally no `#send` method (it would shadow
|
|
150
|
+
> `Object#send`). Use `#query`, or its alias `#ask`.
|
|
151
|
+
|
|
152
|
+
## Configuration options
|
|
153
|
+
|
|
154
|
+
| Option | Default | Maps to / effect |
|
|
155
|
+
|------------------------|----------------------|---------------------------------------------------------------------------|
|
|
156
|
+
| `binary` | `"claude"` | executable name/path |
|
|
157
|
+
| `model` | `nil` (CLI default) | `--model` |
|
|
158
|
+
| `cwd` | `Dir.pwd` | working directory for the subprocess |
|
|
159
|
+
| `timeout` | `300` | seconds before the child is killed |
|
|
160
|
+
| `use_subscription` | `true` | when true, delete `ANTHROPIC_API_KEY` from the child env |
|
|
161
|
+
| `append_system_prompt` | `nil` | `--append-system-prompt` |
|
|
162
|
+
| `allowed_tools` | `nil` | `--allowedTools` (array of tool/permission rules) |
|
|
163
|
+
| `disallowed_tools` | `nil` | `--disallowedTools` |
|
|
164
|
+
| `add_dirs` | `[]` | `--add-dir` (extra readable/writable directories) |
|
|
165
|
+
| `permission_mode` | `nil` | `--permission-mode` (`default` / `acceptEdits` / `plan` / `bypassPermissions`) |
|
|
166
|
+
| `max_turns` | `nil` | `--max-turns` |
|
|
167
|
+
|
|
168
|
+
Tool and directory lists are passed as separate CLI tokens, so permission-rule
|
|
169
|
+
patterns that contain spaces (e.g. `"Bash(git log *)"`) are preserved.
|
|
170
|
+
|
|
171
|
+
## Errors
|
|
172
|
+
|
|
173
|
+
All errors inherit from `RubyClaude::Error`:
|
|
174
|
+
|
|
175
|
+
| Error | Raised when |
|
|
176
|
+
|----------------------------------|-----------------------------------------------------------------------------|
|
|
177
|
+
| `RubyClaude::BinaryNotFoundError`| `claude` is not on `PATH` / not executable (message explains how to install)|
|
|
178
|
+
| `RubyClaude::AuthenticationError`| output/exit indicates you are not logged in (suggests `claude` + `/login`) |
|
|
179
|
+
| `RubyClaude::TimeoutError` | the child exceeded `timeout`; the gem killed it |
|
|
180
|
+
| `RubyClaude::ExecutionError` | non-zero exit, or a result with `is_error: true` (carries `#status`, `#stderr`) |
|
|
181
|
+
| `RubyClaude::ParseError` | the CLI output couldn't be parsed as the expected JSON |
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
begin
|
|
185
|
+
RubyClaude.query("hello")
|
|
186
|
+
rescue RubyClaude::BinaryNotFoundError => e
|
|
187
|
+
warn e.message # install + /login instructions
|
|
188
|
+
rescue RubyClaude::AuthenticationError
|
|
189
|
+
warn "Run `claude` and `/login` with your subscription."
|
|
190
|
+
rescue RubyClaude::ExecutionError => e
|
|
191
|
+
warn "claude failed (status #{e.status}): #{e.stderr}"
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## How it works
|
|
196
|
+
|
|
197
|
+
Ruby Claude is a thin, well-factored wrapper around `claude -p`:
|
|
198
|
+
|
|
199
|
+
- **`Command`** (pure, no I/O) turns your configuration + per-call options into
|
|
200
|
+
the argv array (`["claude", "-p", "--output-format", "json", …]`) and the
|
|
201
|
+
child-environment overrides (removing `ANTHROPIC_API_KEY` in subscription mode).
|
|
202
|
+
- **`Runner`** owns all subprocess concerns: it spawns `claude` via `Open3`
|
|
203
|
+
(always the array form — your prompt is **never** shell-interpolated), writes
|
|
204
|
+
the prompt to **stdin** (avoiding `ARG_MAX` and escaping issues), enforces the
|
|
205
|
+
timeout by killing the child, captures output, and — for streaming — reads
|
|
206
|
+
stdout line-by-line as newline-delimited JSON.
|
|
207
|
+
- **`Client`** composes the two and builds `Response` / `Event` objects.
|
|
208
|
+
- **`Session`** remembers the `session_id` and passes `--resume <id>`.
|
|
209
|
+
|
|
210
|
+
The runner is stateless and spawns one subprocess per call, so a `Client` is
|
|
211
|
+
safe to reuse and to call concurrently from multiple threads.
|
|
212
|
+
|
|
213
|
+
## Development
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
bundle install # install dev/test dependencies
|
|
217
|
+
rake test # run the test suite (hermetic — never spawns claude)
|
|
218
|
+
rake lint # rubocop
|
|
219
|
+
rake # test + lint
|
|
220
|
+
bin/console # IRB with the gem loaded
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Tests inject a fake runner at the `Client`'s runner boundary, so the suite is
|
|
224
|
+
fully hermetic: it never makes a network call and never invokes the real
|
|
225
|
+
`claude` binary. (A handful of `Runner` tests spawn a throwaway local `ruby`
|
|
226
|
+
process to exercise the subprocess plumbing.)
|
|
227
|
+
|
|
228
|
+
## Building and publishing the gem
|
|
229
|
+
|
|
230
|
+
The version lives in [`lib/ruby_claude/version.rb`](lib/ruby_claude/version.rb).
|
|
231
|
+
Before a release, bump it following [SemVer](https://semver.org).
|
|
232
|
+
|
|
233
|
+
### Build locally
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
gem build ruby_claude.gemspec # => ruby_claude-<version>.gem
|
|
237
|
+
gem install ./ruby_claude-<version>.gem # try the built gem locally
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`spec.files` is derived from `git ls-files`, so only **tracked** files are
|
|
241
|
+
packaged — commit (or at least stage) your changes before building, or the gem
|
|
242
|
+
will be missing files. Bundler's gem tasks do the same and drop the artifact in
|
|
243
|
+
`pkg/`:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
rake build # build into pkg/
|
|
247
|
+
rake install # build and install locally
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Publish to RubyGems
|
|
251
|
+
|
|
252
|
+
1. Create a [RubyGems.org](https://rubygems.org) account and sign in once
|
|
253
|
+
(credentials are stored in `~/.gem/credentials`):
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
gem signin
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
2. Make sure the tree is green and committed:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
rake # tests + lint
|
|
263
|
+
git status # nothing uncommitted
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
3. Build and push:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
gem build ruby_claude.gemspec
|
|
270
|
+
gem push ruby_claude-<version>.gem
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The name `ruby_claude` is currently available on RubyGems. Releasing
|
|
274
|
+
`0.0.0` is unusual — bump to e.g. `0.1.0` for your first real publish.
|
|
275
|
+
|
|
276
|
+
Alternatively, do it all in one step with Bundler's release task, which builds
|
|
277
|
+
the gem, creates and pushes a `v<version>` git tag, and pushes to RubyGems
|
|
278
|
+
(requires a clean, committed tree):
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
rake release
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
> The gemspec sets `rubygems_mfa_required`, so enable MFA on your RubyGems
|
|
285
|
+
> account; pushes and yanks will then prompt for a one-time code.
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
BSD-3-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyClaude
|
|
6
|
+
# Composes a {Command} and a {Runner} to execute queries and build
|
|
7
|
+
# {Response} and {Event} objects.
|
|
8
|
+
#
|
|
9
|
+
# A client holds an immutable {Configuration} and a stateless runner, builds
|
|
10
|
+
# fresh argv/env per call, and never mutates shared state — so one instance
|
|
11
|
+
# is safe to reuse and to call concurrently from many threads.
|
|
12
|
+
class Client
|
|
13
|
+
# Heuristic patterns in stderr/result text that indicate an auth problem.
|
|
14
|
+
# Deliberately specific: bare "authentication" / "api key" / "credit
|
|
15
|
+
# balance" match too much benign text and would misclassify ordinary
|
|
16
|
+
# execution and billing failures as authentication errors.
|
|
17
|
+
AUTH_PATTERNS = Regexp.union(
|
|
18
|
+
/invalid api key/i,
|
|
19
|
+
/authentication[ _](?:failed|error|required)/i,
|
|
20
|
+
/unauthorized/i,
|
|
21
|
+
/not logged ?in/i,
|
|
22
|
+
%r{/login}i,
|
|
23
|
+
/oauth/i,
|
|
24
|
+
/log ?in to claude/i
|
|
25
|
+
).freeze
|
|
26
|
+
|
|
27
|
+
# @return [Configuration] the effective configuration for this client
|
|
28
|
+
attr_reader :config
|
|
29
|
+
|
|
30
|
+
# @param runner [#run, #stream] subprocess runner (injectable for tests)
|
|
31
|
+
# @param overrides [Hash] per-instance {Configuration} overrides
|
|
32
|
+
# @raise [ArgumentError] on an unknown configuration option
|
|
33
|
+
def initialize(runner: Runner.new, **overrides)
|
|
34
|
+
@config = RubyClaude.configuration.merge(overrides)
|
|
35
|
+
@runner = runner
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run a one-shot query and return its {Response}.
|
|
39
|
+
#
|
|
40
|
+
# @param prompt [String]
|
|
41
|
+
# @param resume [String, nil] a session id to resume
|
|
42
|
+
# @return [Response]
|
|
43
|
+
# @raise [AuthenticationError, ExecutionError, ParseError, TimeoutError,
|
|
44
|
+
# BinaryNotFoundError]
|
|
45
|
+
def query(prompt, resume: nil)
|
|
46
|
+
argv, env = Command.new(@config).build(stream: false, resume: resume)
|
|
47
|
+
result = @runner.run(**run_args(argv, env, prompt))
|
|
48
|
+
interpret(result)
|
|
49
|
+
end
|
|
50
|
+
alias ask query
|
|
51
|
+
|
|
52
|
+
# Stream a query, yielding {Event}s as they arrive.
|
|
53
|
+
#
|
|
54
|
+
# @param prompt [String]
|
|
55
|
+
# @param resume [String, nil] a session id to resume
|
|
56
|
+
# @yieldparam event [Event]
|
|
57
|
+
# @return [Response] the final result, built from the +result+ event
|
|
58
|
+
# @raise [AuthenticationError, ExecutionError, TimeoutError,
|
|
59
|
+
# BinaryNotFoundError]
|
|
60
|
+
def stream(prompt, resume: nil)
|
|
61
|
+
argv, env = Command.new(@config).build(stream: true, resume: resume)
|
|
62
|
+
final = nil
|
|
63
|
+
result = @runner.stream(**run_args(argv, env, prompt)) do |line|
|
|
64
|
+
data = try_parse(line)
|
|
65
|
+
next unless data
|
|
66
|
+
|
|
67
|
+
final = data if data["type"] == "result"
|
|
68
|
+
yield Event.from_hash(data) if block_given?
|
|
69
|
+
end
|
|
70
|
+
check_stream_result!(final, result)
|
|
71
|
+
Response.from_result(final)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Start a multi-turn {Session} backed by this client.
|
|
75
|
+
#
|
|
76
|
+
# @param id [String, nil] an existing session id to resume
|
|
77
|
+
# @return [Session]
|
|
78
|
+
def session(id: nil)
|
|
79
|
+
Session.new(self, id: id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def run_args(argv, env, prompt)
|
|
85
|
+
{ argv: argv, env: env, cwd: @config.cwd, timeout: @config.timeout, stdin: prompt.to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Turn a one-shot {RunResult} into a {Response} or raise a typed error.
|
|
89
|
+
def interpret(result)
|
|
90
|
+
data = try_parse(result.stdout)
|
|
91
|
+
if data.is_a?(Hash) && data["type"] == "result"
|
|
92
|
+
raise_result_error!(data, result) if data["is_error"]
|
|
93
|
+
return Response.from_result(data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
raise failure_for(result) if failed?(result)
|
|
97
|
+
|
|
98
|
+
raise ParseError, "could not parse claude output as JSON: #{truncate(result.stdout)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_stream_result!(final, result)
|
|
102
|
+
raise_result_error!(final, result) if final && final["is_error"]
|
|
103
|
+
raise failure_for(result) if final.nil? && failed?(result)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def failed?(result)
|
|
107
|
+
status = result.exit_status
|
|
108
|
+
status.nil? || !status.zero?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def raise_result_error!(data, result)
|
|
112
|
+
detail = data["result"] || data["errors"]&.join("; ") || "subtype=#{data["subtype"]}"
|
|
113
|
+
raise AuthenticationError, auth_message(detail) if auth?(detail, result&.stderr)
|
|
114
|
+
|
|
115
|
+
raise ExecutionError.new(
|
|
116
|
+
"claude returned an error result: #{detail}",
|
|
117
|
+
status: result&.exit_status,
|
|
118
|
+
stderr: result&.stderr
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def failure_for(result)
|
|
123
|
+
stderr = result.stderr.to_s
|
|
124
|
+
return AuthenticationError.new(auth_message(stderr.strip)) if auth?(stderr, result.stdout)
|
|
125
|
+
|
|
126
|
+
status = result.exit_status
|
|
127
|
+
ExecutionError.new(
|
|
128
|
+
"claude exited with status #{status || "signal"}: #{truncate(stderr)}",
|
|
129
|
+
status: status,
|
|
130
|
+
stderr: stderr
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def auth?(*sources)
|
|
135
|
+
sources.compact.any? { |source| AUTH_PATTERNS.match?(source.to_s) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def auth_message(detail)
|
|
139
|
+
base = "Claude authentication failed. Run `claude` and use `/login` to sign in with " \
|
|
140
|
+
"your Claude subscription (or set use_subscription = false to use ANTHROPIC_API_KEY)."
|
|
141
|
+
detail.nil? || detail.empty? ? base : "#{base}\n#{detail}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def try_parse(string)
|
|
145
|
+
return nil if string.nil? || string.strip.empty?
|
|
146
|
+
|
|
147
|
+
JSON.parse(string)
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def truncate(string, max = 500)
|
|
153
|
+
stripped = string.to_s.strip
|
|
154
|
+
stripped.length > max ? "#{stripped[0, max]}..." : stripped
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# Pure translation of a {Configuration} plus per-call options into the argv
|
|
5
|
+
# array and child-environment overrides for the +claude+ CLI.
|
|
6
|
+
#
|
|
7
|
+
# Performs no I/O, which makes flag mapping trivial to unit-test. The prompt
|
|
8
|
+
# is intentionally *never* part of argv — it is written to the child's stdin
|
|
9
|
+
# by the {Runner} to avoid +ARG_MAX+ limits and shell-escaping concerns.
|
|
10
|
+
class Command
|
|
11
|
+
# @param config [Configuration]
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Build the argv array and child-environment overrides.
|
|
17
|
+
#
|
|
18
|
+
# @param stream [Boolean] use stream-json output (also adds +--verbose+,
|
|
19
|
+
# which the CLI requires for stream-json in print mode)
|
|
20
|
+
# @param resume [String, nil] a session id to resume via +--resume+
|
|
21
|
+
# @return [Array(Array<String>, Hash)] +[argv, env]+
|
|
22
|
+
def build(stream:, resume: nil)
|
|
23
|
+
argv = [@config.binary, "-p", "--output-format", stream ? "stream-json" : "json"]
|
|
24
|
+
argv << "--verbose" if stream
|
|
25
|
+
add_flag(argv, "--model", @config.model)
|
|
26
|
+
add_flag(argv, "--append-system-prompt", @config.append_system_prompt)
|
|
27
|
+
add_list(argv, "--allowedTools", @config.allowed_tools)
|
|
28
|
+
add_list(argv, "--disallowedTools", @config.disallowed_tools)
|
|
29
|
+
add_list(argv, "--add-dir", @config.add_dirs)
|
|
30
|
+
add_flag(argv, "--permission-mode", @config.permission_mode)
|
|
31
|
+
add_flag(argv, "--max-turns", @config.max_turns&.to_s)
|
|
32
|
+
add_flag(argv, "--resume", resume)
|
|
33
|
+
[argv, child_env]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Environment overrides for the child process. In subscription mode,
|
|
37
|
+
# +ANTHROPIC_API_KEY+ is mapped to +nil+, which tells +Open3+/+spawn+ to
|
|
38
|
+
# remove it from the inherited environment so the CLI falls back to the
|
|
39
|
+
# logged-in subscription credentials.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash{String => String, nil}]
|
|
42
|
+
def child_env
|
|
43
|
+
return {} unless @config.use_subscription
|
|
44
|
+
|
|
45
|
+
{ "ANTHROPIC_API_KEY" => nil }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Append +flag value+ when +value+ is present.
|
|
51
|
+
def add_flag(argv, flag, value)
|
|
52
|
+
return if value.nil?
|
|
53
|
+
|
|
54
|
+
string = value.to_s
|
|
55
|
+
return if string.empty?
|
|
56
|
+
|
|
57
|
+
argv.push(flag, string)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Append +flag item item ...+ (each list item as its own argv token, which
|
|
61
|
+
# matches the CLI's space-separated variadic options and preserves spaces
|
|
62
|
+
# inside permission-rule patterns such as +Bash(git log *)+).
|
|
63
|
+
def add_list(argv, flag, value)
|
|
64
|
+
items = Array(value).map(&:to_s).reject(&:empty?)
|
|
65
|
+
return if items.empty?
|
|
66
|
+
|
|
67
|
+
argv.push(flag, *items)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# Holds every tunable option with sane defaults.
|
|
5
|
+
#
|
|
6
|
+
# Used as the global default (via {RubyClaude.configure}) and as the basis
|
|
7
|
+
# for per-{Client} overrides through {#merge}. A configuration is only ever
|
|
8
|
+
# read while a query runs, never mutated, which keeps {Client} thread-safe.
|
|
9
|
+
class Configuration
|
|
10
|
+
# @return [String] executable name or path of the CLI
|
|
11
|
+
attr_accessor :binary
|
|
12
|
+
|
|
13
|
+
# @return [String, nil] model for +--model+ (nil uses the CLI default)
|
|
14
|
+
attr_accessor :model
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] working directory for the subprocess
|
|
17
|
+
attr_accessor :cwd
|
|
18
|
+
|
|
19
|
+
# @return [Integer] seconds before the child process is killed
|
|
20
|
+
attr_accessor :timeout
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] when true, strip +ANTHROPIC_API_KEY+ from the child env
|
|
23
|
+
attr_accessor :use_subscription
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] text for +--append-system-prompt+
|
|
26
|
+
attr_accessor :append_system_prompt
|
|
27
|
+
|
|
28
|
+
# @return [Array<String>, String, nil] tools for +--allowedTools+
|
|
29
|
+
attr_accessor :allowed_tools
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>, String, nil] tools for +--disallowedTools+
|
|
32
|
+
attr_accessor :disallowed_tools
|
|
33
|
+
|
|
34
|
+
# @return [Array<String>] directories for repeated +--add-dir+
|
|
35
|
+
attr_accessor :add_dirs
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] mode for +--permission-mode+
|
|
38
|
+
attr_accessor :permission_mode
|
|
39
|
+
|
|
40
|
+
# @return [Integer, nil] limit for +--max-turns+
|
|
41
|
+
attr_accessor :max_turns
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@binary = "claude"
|
|
45
|
+
@model = nil
|
|
46
|
+
@cwd = Dir.pwd
|
|
47
|
+
@timeout = 300
|
|
48
|
+
@use_subscription = true
|
|
49
|
+
@append_system_prompt = nil
|
|
50
|
+
@allowed_tools = nil
|
|
51
|
+
@disallowed_tools = nil
|
|
52
|
+
@add_dirs = []
|
|
53
|
+
@permission_mode = nil
|
|
54
|
+
@max_turns = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Return a copy with the given overrides applied. The receiver is left
|
|
58
|
+
# untouched, so the global configuration is never mutated by a {Client}.
|
|
59
|
+
#
|
|
60
|
+
# @param overrides [Hash{Symbol => Object}]
|
|
61
|
+
# @return [Configuration]
|
|
62
|
+
# @raise [ArgumentError] when an option is not recognized
|
|
63
|
+
def merge(overrides)
|
|
64
|
+
dup.tap do |copy|
|
|
65
|
+
overrides.each do |key, value|
|
|
66
|
+
setter = "#{key}="
|
|
67
|
+
raise ArgumentError, "unknown configuration option: #{key}" unless copy.respond_to?(setter)
|
|
68
|
+
|
|
69
|
+
copy.public_send(setter, value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Hash{Symbol => Object}] a plain-hash view of the configuration
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
binary: binary, model: model, cwd: cwd, timeout: timeout,
|
|
78
|
+
use_subscription: use_subscription, append_system_prompt: append_system_prompt,
|
|
79
|
+
allowed_tools: allowed_tools, disallowed_tools: disallowed_tools,
|
|
80
|
+
add_dirs: add_dirs, permission_mode: permission_mode, max_turns: max_turns
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Deep-copy the mutable array options so a {Client} can never mutate the
|
|
87
|
+
# array held by the global configuration.
|
|
88
|
+
def initialize_copy(source)
|
|
89
|
+
super
|
|
90
|
+
@add_dirs = source.add_dirs.dup if source.add_dirs.is_a?(Array)
|
|
91
|
+
@allowed_tools = source.allowed_tools.dup if source.allowed_tools.is_a?(Array)
|
|
92
|
+
@disallowed_tools = source.disallowed_tools.dup if source.disallowed_tools.is_a?(Array)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# Base class for every error raised by Ruby Claude.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the +claude+ binary cannot be found on PATH or executed.
|
|
8
|
+
#
|
|
9
|
+
# The message explains how to install Claude Code and reminds the user that
|
|
10
|
+
# they must run +claude+ and +/login+ at least once.
|
|
11
|
+
class BinaryNotFoundError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the CLI output or exit status indicates the user is not
|
|
14
|
+
# logged in or that authentication otherwise failed.
|
|
15
|
+
class AuthenticationError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when the child process exceeds the configured timeout and the gem
|
|
18
|
+
# kills it.
|
|
19
|
+
class TimeoutError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised on a non-zero exit status, or on a result payload that reports
|
|
22
|
+
# +is_error: true+. Carries the exit status and captured stderr.
|
|
23
|
+
class ExecutionError < Error
|
|
24
|
+
# @return [Integer, nil] the child process exit status, when known
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] captured standard error output, when available
|
|
28
|
+
attr_reader :stderr
|
|
29
|
+
|
|
30
|
+
# @param message [String, nil]
|
|
31
|
+
# @param status [Integer, nil]
|
|
32
|
+
# @param stderr [String, nil]
|
|
33
|
+
def initialize(message = nil, status: nil, stderr: nil)
|
|
34
|
+
@status = status
|
|
35
|
+
@stderr = stderr
|
|
36
|
+
super(message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raised when the CLI output cannot be parsed as the expected JSON.
|
|
41
|
+
class ParseError < Error; end
|
|
42
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# Immutable streaming event parsed from one line of +--output-format
|
|
5
|
+
# stream-json+ output. The {#type} mirrors the CLI's +type+ field as a
|
|
6
|
+
# Symbol (+:system+, +:assistant+, +:user+, +:result+, ...).
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] type
|
|
9
|
+
# @return [Symbol] the event type
|
|
10
|
+
# @!attribute [r] text
|
|
11
|
+
# @return [String, nil] text extracted from assistant/user/result payloads
|
|
12
|
+
# @!attribute [r] session_id
|
|
13
|
+
# @return [String, nil] the session id, when present
|
|
14
|
+
# @!attribute [r] cost_usd
|
|
15
|
+
# @return [Float, nil] total cost, present on the result event
|
|
16
|
+
# @!attribute [r] duration_ms
|
|
17
|
+
# @return [Integer, nil] duration, present on the result event
|
|
18
|
+
# @!attribute [r] raw
|
|
19
|
+
# @return [Hash] the full parsed line
|
|
20
|
+
Event = Data.define(:type, :text, :session_id, :cost_usd, :duration_ms, :raw) do
|
|
21
|
+
# Build an Event from one parsed NDJSON line.
|
|
22
|
+
#
|
|
23
|
+
# @param data [Hash, nil] the parsed line
|
|
24
|
+
# @return [Event]
|
|
25
|
+
def self.from_hash(data)
|
|
26
|
+
data ||= {}
|
|
27
|
+
new(
|
|
28
|
+
type: (data["type"] || "unknown").to_sym,
|
|
29
|
+
text: extract_text(data),
|
|
30
|
+
session_id: data["session_id"],
|
|
31
|
+
cost_usd: data["total_cost_usd"],
|
|
32
|
+
duration_ms: data["duration_ms"],
|
|
33
|
+
raw: data
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Pull human-readable text out of a parsed line, if any.
|
|
38
|
+
#
|
|
39
|
+
# @param data [Hash]
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def self.extract_text(data)
|
|
42
|
+
case data["type"]
|
|
43
|
+
when "assistant", "user"
|
|
44
|
+
message = data["message"] || data
|
|
45
|
+
text_from_content(message["content"])
|
|
46
|
+
when "result"
|
|
47
|
+
data["result"]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Join the text from a content array (or pass a bare string through).
|
|
52
|
+
#
|
|
53
|
+
# @param content [String, Array, nil]
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def self.text_from_content(content)
|
|
56
|
+
return content if content.is_a?(String)
|
|
57
|
+
return nil unless content.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
texts = content
|
|
60
|
+
.select { |block| block.is_a?(Hash) && block["type"] == "text" }
|
|
61
|
+
.filter_map { |block| block["text"] }
|
|
62
|
+
texts.empty? ? nil : texts.join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether this is the final result event
|
|
66
|
+
def result? = type == :result
|
|
67
|
+
|
|
68
|
+
# @return [Boolean] whether this is an assistant message event
|
|
69
|
+
def assistant? = type == :assistant
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether this is a system event
|
|
72
|
+
def system? = type == :system
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] whether this is a user message event
|
|
75
|
+
def user? = type == :user
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# Immutable value object describing the final result of a query.
|
|
5
|
+
#
|
|
6
|
+
# Built from the CLI's +--output-format json+ result object (or from the
|
|
7
|
+
# final +result+ line of a stream). Missing keys map to sensible defaults
|
|
8
|
+
# rather than raising.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] text
|
|
11
|
+
# @return [String] the assistant's final text result
|
|
12
|
+
# @!attribute [r] session_id
|
|
13
|
+
# @return [String, nil] the session id of this conversation
|
|
14
|
+
# @!attribute [r] cost_usd
|
|
15
|
+
# @return [Float] total cost in USD (often +0.0+ on a subscription)
|
|
16
|
+
# @!attribute [r] usage
|
|
17
|
+
# @return [Hash] token usage counts, when present
|
|
18
|
+
# @!attribute [r] num_turns
|
|
19
|
+
# @return [Integer] number of agentic turns
|
|
20
|
+
# @!attribute [r] duration_ms
|
|
21
|
+
# @return [Integer] wall-clock duration in milliseconds
|
|
22
|
+
# @!attribute [r] error
|
|
23
|
+
# @return [Boolean] whether the CLI reported an error
|
|
24
|
+
# @!attribute [r] raw
|
|
25
|
+
# @return [Hash] the full parsed result object
|
|
26
|
+
Response = Data.define(:text, :session_id, :cost_usd, :usage,
|
|
27
|
+
:num_turns, :duration_ms, :error, :raw) do
|
|
28
|
+
# Build a Response from a parsed CLI result hash.
|
|
29
|
+
#
|
|
30
|
+
# @param data [Hash, nil] the parsed result object
|
|
31
|
+
# @return [Response]
|
|
32
|
+
def self.from_result(data)
|
|
33
|
+
data ||= {}
|
|
34
|
+
new(
|
|
35
|
+
text: data["result"] || "",
|
|
36
|
+
session_id: data["session_id"],
|
|
37
|
+
cost_usd: (data["total_cost_usd"] || data["cost_usd"] || 0.0).to_f,
|
|
38
|
+
usage: data["usage"] || {},
|
|
39
|
+
num_turns: (data["num_turns"] || 0).to_i,
|
|
40
|
+
duration_ms: (data["duration_ms"] || 0).to_i,
|
|
41
|
+
error: data.fetch("is_error", false) ? true : false,
|
|
42
|
+
raw: data
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] whether the result represents an error
|
|
47
|
+
def error? = !!error
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] whether the result was successful
|
|
50
|
+
def success? = !error?
|
|
51
|
+
|
|
52
|
+
# Returns the assistant text, so +puts response+ prints the answer.
|
|
53
|
+
#
|
|
54
|
+
# @return [String]
|
|
55
|
+
def to_s = text
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RubyClaude
|
|
6
|
+
# The captured result of a completed subprocess run.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] stdout
|
|
9
|
+
# @return [String, nil] captured stdout (nil when streaming)
|
|
10
|
+
# @!attribute [r] stderr
|
|
11
|
+
# @return [String] captured stderr
|
|
12
|
+
# @!attribute [r] exit_status
|
|
13
|
+
# @return [Integer, nil] exit code, or nil if the process was signalled
|
|
14
|
+
RunResult = Data.define(:stdout, :stderr, :exit_status)
|
|
15
|
+
|
|
16
|
+
# Owns every subprocess concern: spawning +claude+ via +Open3+, writing the
|
|
17
|
+
# prompt to stdin, enforcing the timeout by killing the child, capturing
|
|
18
|
+
# output, and translating spawn failures into {BinaryNotFoundError}.
|
|
19
|
+
#
|
|
20
|
+
# The runner is stateless, so a single instance is safe to share across
|
|
21
|
+
# threads. The {Client} accepts an injected runner so tests never spawn.
|
|
22
|
+
class Runner
|
|
23
|
+
# Seconds to wait after +SIGTERM+ before escalating to +SIGKILL+.
|
|
24
|
+
KILL_GRACE = 2
|
|
25
|
+
|
|
26
|
+
# Run the command to completion and capture its output.
|
|
27
|
+
#
|
|
28
|
+
# @param argv [Array<String>] the command and its arguments
|
|
29
|
+
# @param env [Hash] environment overrides (nil values unset a variable)
|
|
30
|
+
# @param cwd [String, nil] working directory
|
|
31
|
+
# @param timeout [Numeric] seconds before the child is killed
|
|
32
|
+
# @param stdin [String, nil] data to write to the child's stdin
|
|
33
|
+
# @return [RunResult]
|
|
34
|
+
# @raise [TimeoutError] if the child exceeds +timeout+
|
|
35
|
+
# @raise [BinaryNotFoundError] if the binary cannot be executed
|
|
36
|
+
def run(argv:, env:, cwd:, timeout:, stdin: nil)
|
|
37
|
+
spawn(argv, env, cwd) do |stdin_io, stdout_io, stderr_io, wait_thr|
|
|
38
|
+
out_reader = Thread.new { stdout_io.read }
|
|
39
|
+
err_reader = Thread.new { stderr_io.read }
|
|
40
|
+
write_stdin(stdin_io, stdin)
|
|
41
|
+
|
|
42
|
+
if wait_thr.join(timeout).nil?
|
|
43
|
+
terminate(wait_thr)
|
|
44
|
+
out_reader.kill
|
|
45
|
+
err_reader.kill
|
|
46
|
+
raise TimeoutError, "claude did not finish within #{timeout}s; the process was killed"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
RunResult.new(
|
|
50
|
+
stdout: out_reader.value,
|
|
51
|
+
stderr: err_reader.value,
|
|
52
|
+
exit_status: wait_thr.value.exitstatus
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Run the command and yield each non-empty stdout line as it arrives.
|
|
58
|
+
#
|
|
59
|
+
# @param (see #run)
|
|
60
|
+
# @yieldparam line [String] one chomped, non-empty stdout line
|
|
61
|
+
# @return [RunResult] with +stdout+ nil (it was streamed, not captured)
|
|
62
|
+
# @raise [TimeoutError] if the child exceeds +timeout+
|
|
63
|
+
# @raise [BinaryNotFoundError] if the binary cannot be executed
|
|
64
|
+
def stream(argv:, env:, cwd:, timeout:, stdin: nil)
|
|
65
|
+
spawn(argv, env, cwd) do |stdin_io, stdout_io, stderr_io, wait_thr|
|
|
66
|
+
err_reader = Thread.new { stderr_io.read }
|
|
67
|
+
# Write stdin on its own thread so a prompt larger than the OS pipe
|
|
68
|
+
# buffer can't deadlock against stdout we haven't started reading yet.
|
|
69
|
+
writer = Thread.new { write_stdin(stdin_io, stdin) }
|
|
70
|
+
timed_out = false
|
|
71
|
+
watchdog = Thread.new do
|
|
72
|
+
sleep(timeout)
|
|
73
|
+
timed_out = true
|
|
74
|
+
terminate(wait_thr)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
stdout_io.each_line do |line|
|
|
79
|
+
chomped = line.chomp
|
|
80
|
+
yield chomped unless chomped.empty?
|
|
81
|
+
end
|
|
82
|
+
ensure
|
|
83
|
+
watchdog.kill
|
|
84
|
+
writer.join
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise TimeoutError, "claude streaming exceeded #{timeout}s; the process was killed" if timed_out
|
|
88
|
+
|
|
89
|
+
RunResult.new(stdout: nil, stderr: err_reader.value, exit_status: wait_thr.value.exitstatus)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def spawn(argv, env, cwd, &block)
|
|
96
|
+
validate_cwd!(cwd)
|
|
97
|
+
options = {}
|
|
98
|
+
options[:chdir] = cwd if cwd
|
|
99
|
+
Open3.popen3(env || {}, *argv, **options, &block)
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
raise BinaryNotFoundError, binary_not_found_message(argv.first)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_cwd!(cwd)
|
|
105
|
+
return if cwd.nil? || File.directory?(cwd)
|
|
106
|
+
|
|
107
|
+
raise Error, "working directory does not exist: #{cwd}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def write_stdin(stdin_io, data)
|
|
111
|
+
stdin_io.write(data) if data
|
|
112
|
+
rescue Errno::EPIPE
|
|
113
|
+
# The child exited before reading stdin; the failure surfaces via status.
|
|
114
|
+
ensure
|
|
115
|
+
stdin_io.close unless stdin_io.closed?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Send +SIGTERM+, wait up to {KILL_GRACE} for the child to exit, then
|
|
119
|
+
# escalate to +SIGKILL+ if it ignored the polite signal.
|
|
120
|
+
#
|
|
121
|
+
# This runs synchronously while holding +wait_thr+: the child's PID can't
|
|
122
|
+
# be reaped (and therefore can't be recycled by the OS) until we let go,
|
|
123
|
+
# so the +SIGKILL+ can never land on an unrelated, reused PID.
|
|
124
|
+
def terminate(wait_thr)
|
|
125
|
+
Process.kill("TERM", wait_thr.pid)
|
|
126
|
+
return if wait_thr.join(KILL_GRACE)
|
|
127
|
+
|
|
128
|
+
Process.kill("KILL", wait_thr.pid)
|
|
129
|
+
rescue Errno::ESRCH
|
|
130
|
+
# The process already exited.
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def binary_not_found_message(binary)
|
|
134
|
+
"could not run #{binary.inspect}: is Claude Code installed and on your PATH?\n" \
|
|
135
|
+
"Install it with `npm install -g @anthropic-ai/claude-code`, then run `claude` " \
|
|
136
|
+
"and `/login` once to sign in with your Claude subscription."
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyClaude
|
|
4
|
+
# A multi-turn conversation.
|
|
5
|
+
#
|
|
6
|
+
# The first {#query} captures the +session_id+ from the reply; subsequent
|
|
7
|
+
# queries transparently pass +--resume <id>+ so the conversation continues.
|
|
8
|
+
class Session
|
|
9
|
+
# @return [String, nil] the session id being resumed (nil until the first
|
|
10
|
+
# reply, unless one was supplied to {Client#session})
|
|
11
|
+
attr_reader :id
|
|
12
|
+
|
|
13
|
+
# @param client [Client] the client used to run each turn
|
|
14
|
+
# @param id [String, nil] an existing session id to resume from the start
|
|
15
|
+
def initialize(client, id: nil)
|
|
16
|
+
@client = client
|
|
17
|
+
@id = id
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Ask a question within this conversation, resuming the captured session.
|
|
22
|
+
#
|
|
23
|
+
# @param prompt [String]
|
|
24
|
+
# @return [Response]
|
|
25
|
+
def query(prompt)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
response = @client.query(prompt, resume: @id)
|
|
28
|
+
@id = response.session_id || @id
|
|
29
|
+
response
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
alias ask query
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/ruby_claude.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_claude/version"
|
|
4
|
+
require_relative "ruby_claude/errors"
|
|
5
|
+
require_relative "ruby_claude/configuration"
|
|
6
|
+
require_relative "ruby_claude/response"
|
|
7
|
+
require_relative "ruby_claude/event"
|
|
8
|
+
require_relative "ruby_claude/command"
|
|
9
|
+
require_relative "ruby_claude/runner"
|
|
10
|
+
require_relative "ruby_claude/session"
|
|
11
|
+
require_relative "ruby_claude/client"
|
|
12
|
+
|
|
13
|
+
# Ruby Claude — a subscription-authenticated Ruby SDK that talks to Claude by
|
|
14
|
+
# shelling out to the Claude Code CLI (+claude -p+) in headless mode.
|
|
15
|
+
#
|
|
16
|
+
# It is an unofficial, community wrapper around a supported headless feature.
|
|
17
|
+
# By default it strips +ANTHROPIC_API_KEY+ from the child environment so calls
|
|
18
|
+
# draw on the logged-in Pro/Max subscription rather than API billing.
|
|
19
|
+
#
|
|
20
|
+
# @example One-shot
|
|
21
|
+
# puts RubyClaude.query("Summarize lib/foo.rb in two sentences")
|
|
22
|
+
#
|
|
23
|
+
# @example A configured client
|
|
24
|
+
# client = RubyClaude::Client.new(model: "claude-sonnet-4-6", timeout: 180)
|
|
25
|
+
# client.query("What does this project do?").text
|
|
26
|
+
module RubyClaude
|
|
27
|
+
class << self
|
|
28
|
+
# The global configuration used by {RubyClaude.query} and as the default
|
|
29
|
+
# for new {Client} instances.
|
|
30
|
+
#
|
|
31
|
+
# @return [Configuration]
|
|
32
|
+
def configuration
|
|
33
|
+
@configuration ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Configure the global defaults.
|
|
37
|
+
#
|
|
38
|
+
# @yieldparam config [Configuration]
|
|
39
|
+
# @return [Configuration]
|
|
40
|
+
def configure
|
|
41
|
+
yield configuration if block_given?
|
|
42
|
+
@default_client = nil # rebuild with the new configuration on next use
|
|
43
|
+
configuration
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reset all global state. Mainly useful in tests.
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def reset_configuration!
|
|
50
|
+
@configuration = Configuration.new
|
|
51
|
+
@default_client = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One-shot convenience that delegates to a memoized default {Client}.
|
|
55
|
+
#
|
|
56
|
+
# @param prompt [String]
|
|
57
|
+
# @param options [Hash] forwarded to {Client#query} (e.g. +resume:+)
|
|
58
|
+
# @return [Response]
|
|
59
|
+
def query(prompt, **options)
|
|
60
|
+
default_client.query(prompt, **options)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The memoized default {Client}, rebuilt whenever {configure} is called.
|
|
64
|
+
#
|
|
65
|
+
# @return [Client]
|
|
66
|
+
def default_client
|
|
67
|
+
@default_client ||= Client.new
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/ruby_claude.gemspec
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/ruby_claude/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ruby_claude"
|
|
7
|
+
spec.version = RubyClaude::VERSION
|
|
8
|
+
spec.authors = ["Kaíque Kandy Koga"]
|
|
9
|
+
spec.email = ["kaique.koga@javln.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Subscription-authenticated Ruby SDK for Claude via the Claude Code CLI."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Ruby Claude is a small, dependency-light, idiomatic Ruby wrapper around the
|
|
14
|
+
Claude Code CLI in headless mode (claude -p). It lets Ruby programs talk to
|
|
15
|
+
Claude using a Claude Pro/Max subscription for authentication instead of an
|
|
16
|
+
Anthropic API key: by default it strips ANTHROPIC_API_KEY from the child
|
|
17
|
+
process environment so the CLI falls back to the logged-in subscription
|
|
18
|
+
credentials. Unofficial; uses a supported headless feature within the
|
|
19
|
+
subscription's rate limits.
|
|
20
|
+
DESC
|
|
21
|
+
spec.homepage = "https://github.com/kaiquekandykoga/ruby_claude"
|
|
22
|
+
spec.license = "BSD-3-Clause"
|
|
23
|
+
spec.required_ruby_version = ">= 3.2"
|
|
24
|
+
|
|
25
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
26
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.chdir(__dir__) do
|
|
29
|
+
`git ls-files -z`.split("\x0").select do |path|
|
|
30
|
+
path.start_with?("lib/") ||
|
|
31
|
+
%w[README.md LICENSE ruby_claude.gemspec].include?(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
spec.require_paths = ["lib"]
|
|
35
|
+
|
|
36
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
37
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
38
|
+
spec.add_development_dependency "test-unit", "~> 3.6"
|
|
39
|
+
spec.add_development_dependency "yard", "~> 0.9"
|
|
40
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_claude
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaíque Kandy Koga
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rubocop
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.60'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.60'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: test-unit
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.6'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.6'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: yard
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.9'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.9'
|
|
68
|
+
description: |
|
|
69
|
+
Ruby Claude is a small, dependency-light, idiomatic Ruby wrapper around the
|
|
70
|
+
Claude Code CLI in headless mode (claude -p). It lets Ruby programs talk to
|
|
71
|
+
Claude using a Claude Pro/Max subscription for authentication instead of an
|
|
72
|
+
Anthropic API key: by default it strips ANTHROPIC_API_KEY from the child
|
|
73
|
+
process environment so the CLI falls back to the logged-in subscription
|
|
74
|
+
credentials. Unofficial; uses a supported headless feature within the
|
|
75
|
+
subscription's rate limits.
|
|
76
|
+
email:
|
|
77
|
+
- kaique.koga@javln.com
|
|
78
|
+
executables: []
|
|
79
|
+
extensions: []
|
|
80
|
+
extra_rdoc_files: []
|
|
81
|
+
files:
|
|
82
|
+
- LICENSE
|
|
83
|
+
- README.md
|
|
84
|
+
- lib/ruby_claude.rb
|
|
85
|
+
- lib/ruby_claude/client.rb
|
|
86
|
+
- lib/ruby_claude/command.rb
|
|
87
|
+
- lib/ruby_claude/configuration.rb
|
|
88
|
+
- lib/ruby_claude/errors.rb
|
|
89
|
+
- lib/ruby_claude/event.rb
|
|
90
|
+
- lib/ruby_claude/response.rb
|
|
91
|
+
- lib/ruby_claude/runner.rb
|
|
92
|
+
- lib/ruby_claude/session.rb
|
|
93
|
+
- lib/ruby_claude/version.rb
|
|
94
|
+
- ruby_claude.gemspec
|
|
95
|
+
homepage: https://github.com/kaiquekandykoga/ruby_claude
|
|
96
|
+
licenses:
|
|
97
|
+
- BSD-3-Clause
|
|
98
|
+
metadata:
|
|
99
|
+
source_code_uri: https://github.com/kaiquekandykoga/ruby_claude
|
|
100
|
+
rubygems_mfa_required: 'true'
|
|
101
|
+
rdoc_options: []
|
|
102
|
+
require_paths:
|
|
103
|
+
- lib
|
|
104
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '3.2'
|
|
109
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0'
|
|
114
|
+
requirements: []
|
|
115
|
+
rubygems_version: 4.0.10
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: Subscription-authenticated Ruby SDK for Claude via the Claude Code CLI.
|
|
118
|
+
test_files: []
|