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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ce8e2c7378bd0ed7559c37995d1886fb37367b8e53e29f77a2bc65ffc10344d
4
- data.tar.gz: a1e7bf9563ed416687cb0b8bac091850b3123b623ecf91fd25bbe31c240be9cc
3
+ metadata.gz: 0a4d20f9115801eff338db4cab7ac026b9cdfeb013ede2da9bb2e32dbd8d0c13
4
+ data.tar.gz: 7dd1564b9e3421f00520946263e926e5f007ed4da7cc8d666e9e8ef23c12ede4
5
5
  SHA512:
6
- metadata.gz: d158e722113e3b3ee48313f3fbb2cf0a9df9d7980b263045a1a89f5ff1c7786e396d4bc8d1195aef7edf256c720b18e5d0c1936253673b0a605c6e790f42b62d
7
- data.tar.gz: ebac20bcc17499f0b5007c62dba352699d52bb779285b160401daa7a1a08399f67f0df0efcf37bfecbbd0146b99361024f9707b84da17a94b445deb24e409470
6
+ metadata.gz: 2b49597cdd634ab90688ad07de32fa50cde70326118499f1d464c46936040984a61e4b79865fba1db602f3070031102668847870a8a48139ad1c3803c2df086e
7
+ data.tar.gz: 5fd8032c248da309d1f7d0215793dccae106c328c71856d153e855edb82f9d54abfe310494cf9f9a1671f8cb44f2213e11e3d1ef4761524b4d242540d126fef4
data/README.md CHANGED
@@ -1,172 +1,3 @@
1
- # Ruby Claude
1
+ # ruby_claude
2
2
 
3
- A tiny, zero-dependency Ruby SDK for Claude that shells out to the **Claude Code
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
- 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
3
+ # This release intentionally contains no functionality.
26
4
  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
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.1
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
- - !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.
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
- - 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
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.10
42
+ rubygems_version: 4.0.18
116
43
  specification_version: 4
117
- summary: Subscription-authenticated Ruby SDK for Claude via the Claude Code CLI.
44
+ summary: Discontinued. This release intentionally contains no functionality.
118
45
  test_files: []
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyClaude
4
- # The released version of the gem, following Semantic Versioning.
5
- VERSION = "0.0.1"
6
- end
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