ruby_claude 0.0.1 → 1.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 +4 -4
- data/README.md +2 -171
- data/lib/ruby_claude.rb +2 -66
- metadata +7 -80
- data/lib/ruby_claude/client.rb +0 -157
- data/lib/ruby_claude/command.rb +0 -70
- data/lib/ruby_claude/configuration.rb +0 -95
- data/lib/ruby_claude/errors.rb +0 -42
- data/lib/ruby_claude/event.rb +0 -77
- data/lib/ruby_claude/response.rb +0 -57
- data/lib/ruby_claude/runner.rb +0 -139
- data/lib/ruby_claude/session.rb +0 -34
- data/lib/ruby_claude/version.rb +0 -6
- data/ruby_claude.gemspec +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a4d20f9115801eff338db4cab7ac026b9cdfeb013ede2da9bb2e32dbd8d0c13
|
|
4
|
+
data.tar.gz: 7dd1564b9e3421f00520946263e926e5f007ed4da7cc8d666e9e8ef23c12ede4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b49597cdd634ab90688ad07de32fa50cde70326118499f1d464c46936040984a61e4b79865fba1db602f3070031102668847870a8a48139ad1c3803c2df086e
|
|
7
|
+
data.tar.gz: 5fd8032c248da309d1f7d0215793dccae106c328c71856d153e855edb82f9d54abfe310494cf9f9a1671f8cb44f2213e11e3d1ef4761524b4d242540d126fef4
|
data/README.md
CHANGED
|
@@ -1,172 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ruby_claude
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
CLI** (`claude -p`) and authenticates with your **Claude Pro/Max subscription**
|
|
5
|
-
instead of an API key.
|
|
6
|
-
|
|
7
|
-
> **Unofficial** community gem — not affiliated with Anthropic. It uses the
|
|
8
|
-
> supported `claude -p` headless mode within your subscription's rate limits; no
|
|
9
|
-
> OAuth-token handling and no direct API calls.
|
|
10
|
-
|
|
11
|
-
## Subscription, not API key
|
|
12
|
-
|
|
13
|
-
`claude -p` uses whatever the CLI is logged in with — if that's a subscription,
|
|
14
|
-
calls draw on it with no API billing. Ruby Claude **strips `ANTHROPIC_API_KEY`
|
|
15
|
-
from the child environment by default** so the CLI can't silently fall back to
|
|
16
|
-
API billing; set `use_subscription = false` to opt back in.
|
|
17
|
-
|
|
18
|
-
## Prerequisites
|
|
19
|
-
|
|
20
|
-
`claude` must be installed and logged in (this gem drives it, it doesn't replace it):
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
npm install -g @anthropic-ai/claude-code
|
|
24
|
-
claude # run /login once and choose the subscription option
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
|
-
```ruby
|
|
30
|
-
gem "ruby_claude" # in your Gemfile
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
…or `gem install ruby_claude`. Requires Ruby 3.2+; zero runtime dependencies.
|
|
34
|
-
|
|
35
|
-
## Quickstart
|
|
36
|
-
|
|
37
|
-
```ruby
|
|
38
|
-
require "ruby_claude"
|
|
39
|
-
|
|
40
|
-
puts RubyClaude.query("Summarize lib/foo.rb in two sentences")
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Usage
|
|
44
|
-
|
|
45
|
-
```ruby
|
|
46
|
-
# A configured client
|
|
47
|
-
client = RubyClaude::Client.new(model: "claude-sonnet-4-6",
|
|
48
|
-
allowed_tools: ["Read", "Grep"], timeout: 180)
|
|
49
|
-
|
|
50
|
-
res = client.query("What does this project do?")
|
|
51
|
-
res.text # final text (Response#to_s returns it too, so `puts res` works)
|
|
52
|
-
res.session_id # String
|
|
53
|
-
res.cost_usd # Float (often 0.0 on a subscription)
|
|
54
|
-
res.usage # Hash — also: res.num_turns, res.duration_ms, res.error?, res.raw
|
|
55
|
-
|
|
56
|
-
# Streaming — yields typed events, returns the final Response
|
|
57
|
-
client.stream("Write a haiku about Ruby") do |event|
|
|
58
|
-
print event.text if event.type == :assistant
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Multi-turn session — resumes the underlying session_id automatically
|
|
62
|
-
chat = client.session
|
|
63
|
-
chat.query("My favorite number is 7.")
|
|
64
|
-
puts chat.query("What's my favorite number?") # => "...7..."
|
|
65
|
-
|
|
66
|
-
# Global defaults for RubyClaude.query and new clients
|
|
67
|
-
RubyClaude.configure { |c| c.model = "claude-sonnet-4-6"; c.timeout = 300 }
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
A streaming `Event#type` is `:system`, `:assistant`, `:user`, or `:result`. Use
|
|
71
|
-
`#query` (alias `#ask`) — there is intentionally no `#send`.
|
|
72
|
-
|
|
73
|
-
## Configuration
|
|
74
|
-
|
|
75
|
-
`Client.new(**opts)` overrides per instance; `RubyClaude.configure` sets globals.
|
|
76
|
-
|
|
77
|
-
| Option | Default | Maps to |
|
|
78
|
-
|--------|---------|---------|
|
|
79
|
-
| `binary` | `"claude"` | executable name/path |
|
|
80
|
-
| `model` | `nil` | `--model` |
|
|
81
|
-
| `cwd` | `Dir.pwd` | subprocess working directory |
|
|
82
|
-
| `timeout` | `300` | seconds before the child is killed |
|
|
83
|
-
| `use_subscription` | `true` | strip `ANTHROPIC_API_KEY` from the child env |
|
|
84
|
-
| `append_system_prompt` | `nil` | `--append-system-prompt` |
|
|
85
|
-
| `allowed_tools` / `disallowed_tools` | `nil` | `--allowedTools` / `--disallowedTools` |
|
|
86
|
-
| `add_dirs` | `[]` | `--add-dir` |
|
|
87
|
-
| `permission_mode` | `nil` | `--permission-mode` |
|
|
88
|
-
| `max_turns` | `nil` | `--max-turns` |
|
|
89
|
-
|
|
90
|
-
## Errors
|
|
91
|
-
|
|
92
|
-
All subclass `RubyClaude::Error`: `BinaryNotFoundError` (no `claude` on PATH),
|
|
93
|
-
`AuthenticationError` (not logged in), `TimeoutError`, `ExecutionError` (non-zero
|
|
94
|
-
exit or an `is_error` result; carries `#status`/`#stderr`), and `ParseError`.
|
|
95
|
-
|
|
96
|
-
## How it works
|
|
97
|
-
|
|
98
|
-
`Command` builds the argv + child env (pure, no I/O); `Runner` spawns `claude`
|
|
99
|
-
via `Open3` (array form — no shell; prompt on stdin), enforces the timeout, and
|
|
100
|
-
parses output; `Client` builds `Response`/`Event`; `Session` resumes via
|
|
101
|
-
`--resume`. The runner is stateless per call, so a `Client` is safe to share
|
|
102
|
-
across threads.
|
|
103
|
-
|
|
104
|
-
## Development
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
bundle exec rake # tests + lint (hermetic — never spawns the real claude)
|
|
108
|
-
bin/console # IRB with the gem loaded
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Contributing or tracking upstream SDK changes? See
|
|
112
|
-
[`doc/DEVELOPMENT.md`](doc/DEVELOPMENT.md).
|
|
113
|
-
|
|
114
|
-
## Building and publishing the gem
|
|
115
|
-
|
|
116
|
-
The version lives in [`lib/ruby_claude/version.rb`](lib/ruby_claude/version.rb).
|
|
117
|
-
Before a release, bump it following [SemVer](https://semver.org).
|
|
118
|
-
|
|
119
|
-
### Build locally
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
gem build ruby_claude.gemspec # => ruby_claude-<version>.gem
|
|
123
|
-
gem install ./ruby_claude-<version>.gem # try the built gem locally
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
`spec.files` is derived from `git ls-files`, so only **tracked** files are
|
|
127
|
-
packaged — commit (or at least stage) your changes before building, or the gem
|
|
128
|
-
will be missing files. Bundler's gem tasks do the same and drop the artifact in
|
|
129
|
-
`pkg/`:
|
|
130
|
-
|
|
131
|
-
```bash
|
|
132
|
-
rake build # build into pkg/
|
|
133
|
-
rake install # build and install locally
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Publish to RubyGems
|
|
137
|
-
|
|
138
|
-
1. Create a [RubyGems.org](https://rubygems.org) account and sign in once
|
|
139
|
-
(credentials are stored in `~/.gem/credentials`):
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
gem signin
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
2. Make sure the tree is green and committed:
|
|
146
|
-
|
|
147
|
-
```bash
|
|
148
|
-
rake # tests + lint
|
|
149
|
-
git status # nothing uncommitted
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
3. Build and push:
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
gem build ruby_claude.gemspec
|
|
156
|
-
gem push ruby_claude-<version>.gem
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
Alternatively, do it all in one step with Bundler's release task, which builds
|
|
160
|
-
the gem, creates and pushes a `v<version>` git tag, and pushes to RubyGems
|
|
161
|
-
(requires a clean, committed tree):
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
rake release
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
> The gemspec sets `rubygems_mfa_required`, so enable MFA on your RubyGems
|
|
168
|
-
> account; pushes and yanks will then prompt for a one-time code.
|
|
169
|
-
|
|
170
|
-
## License
|
|
171
|
-
|
|
172
|
-
BSD-3-Clause. See [LICENSE](LICENSE).
|
|
3
|
+
This gem is no longer maintained. Version 1.0.0 intentionally contains no functionality.
|
data/lib/ruby_claude.rb
CHANGED
|
@@ -1,70 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
3
|
+
# This release intentionally contains no functionality.
|
|
26
4
|
module RubyClaude
|
|
27
|
-
|
|
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
|
|
5
|
+
VERSION = "1.0.0"
|
|
70
6
|
end
|
metadata
CHANGED
|
@@ -1,78 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_claude
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaíque Kandy Koga
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
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.
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: ruby_claude is discontinued. Version 1.0.0 is an empty placeholder release.
|
|
76
13
|
email:
|
|
77
14
|
- kaiquekandykoga@gmail.com
|
|
78
15
|
executables: []
|
|
@@ -82,22 +19,12 @@ files:
|
|
|
82
19
|
- LICENSE
|
|
83
20
|
- README.md
|
|
84
21
|
- lib/ruby_claude.rb
|
|
85
|
-
|
|
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
|
|
22
|
+
homepage: https://rubygems.org/gems/ruby_claude
|
|
96
23
|
licenses:
|
|
97
24
|
- BSD-3-Clause
|
|
98
25
|
metadata:
|
|
99
|
-
source_code_uri: https://github.com/kaiquekandykoga/ruby_claude
|
|
100
26
|
rubygems_mfa_required: 'true'
|
|
27
|
+
post_install_message: ruby_claude is discontinued; this release contains no functionality.
|
|
101
28
|
rdoc_options: []
|
|
102
29
|
require_paths:
|
|
103
30
|
- lib
|
|
@@ -112,7 +39,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
112
39
|
- !ruby/object:Gem::Version
|
|
113
40
|
version: '0'
|
|
114
41
|
requirements: []
|
|
115
|
-
rubygems_version: 4.0.
|
|
42
|
+
rubygems_version: 4.0.18
|
|
116
43
|
specification_version: 4
|
|
117
|
-
summary:
|
|
44
|
+
summary: Discontinued. This release intentionally contains no functionality.
|
|
118
45
|
test_files: []
|
data/lib/ruby_claude/client.rb
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/command.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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
|
|
@@ -1,95 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/errors.rb
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/event.rb
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/response.rb
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/runner.rb
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
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
|
data/lib/ruby_claude/session.rb
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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/version.rb
DELETED
data/ruby_claude.gemspec
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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 = ["kaiquekandykoga@gmail.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
|