ruby_llm-providers-claude_code 0.0.1
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/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/lib/ruby_llm/providers/claude_code/command.rb +66 -0
- data/lib/ruby_llm/providers/claude_code/errors.rb +17 -0
- data/lib/ruby_llm/providers/claude_code/response.rb +120 -0
- data/lib/ruby_llm/providers/claude_code/runner.rb +102 -0
- data/lib/ruby_llm/providers/claude_code/session.rb +57 -0
- data/lib/ruby_llm/providers/claude_code/settings.rb +88 -0
- data/lib/ruby_llm/providers/claude_code/transcript.rb +100 -0
- data/lib/ruby_llm/providers/claude_code/version.rb +15 -0
- data/lib/ruby_llm/providers/claude_code.rb +184 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fe5cd79fe98220731f2e1a919d13189ba320ddb9ac825358953e0ee57dd371f0
|
|
4
|
+
data.tar.gz: bb081f3ccfaa77ac8b49a2dbc126835d72532ee3cd321994b9185702fa6436f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0fad42bba710715989f5bd57a8e45603db0ffa04e8d1a0cfc09b29ae3961ccfb0cfcdf1c25276cd495a01ff03f2b4234f60fa0e69e8da00d79bcaa09b2663e08
|
|
7
|
+
data.tar.gz: f1ca13d507b49572d7a943504de2858b5ee819c7a3fbedd8f8f4e2822afc691638f63268b41b5e3785731c6e80469f564051da222781c2eca93415928141c5b3
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewayne VanHoozer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ruby_llm-providers-claude_code
|
|
2
|
+
|
|
3
|
+
A [RubyLLM](https://rubyllm.com) provider that uses the **Claude Code agent**
|
|
4
|
+
as the model. Each completion runs `claude -p` in batch mode, writes the
|
|
5
|
+
prompt to its stdin and reads the JSON result from stdout.
|
|
6
|
+
|
|
7
|
+
This is deliberately *not* an isolated stand-in for the Anthropic API. The
|
|
8
|
+
agent keeps everything that makes it an agent: its own tools (Read, Edit,
|
|
9
|
+
Bash, ...), its settings, hooks and MCP servers, and it works in the current
|
|
10
|
+
directory. Multi-turn chats resume the same CLI session, so the agent keeps
|
|
11
|
+
its context, tool results and plan between turns.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require 'ruby_llm/providers/claude_code'
|
|
15
|
+
|
|
16
|
+
chat = RubyLLM.chat(model: 'sonnet', provider: :claude_code)
|
|
17
|
+
chat.ask('What does the Rakefile in this directory do?')
|
|
18
|
+
chat.ask('Add a `lint` task to it') { |chunk| print chunk.content }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
With [aia](https://github.com/madbomber/aia) the provider is picked from the
|
|
22
|
+
model prefix, so any prompt, pipeline or multi-model run can be driven by the
|
|
23
|
+
agent:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
aia --model claude_code/opus refactor_prompt
|
|
27
|
+
aia --model claude_code/sonnet=implementer,codex/default=reviewer --consensus review
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Ruby 3.2 or newer
|
|
33
|
+
- RubyLLM 2.1 or newer
|
|
34
|
+
- The Claude Code CLI on your PATH (`claude --version`), logged in. Requests
|
|
35
|
+
use whatever login Claude Code has; no API key is read by this gem.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem 'ruby_llm-providers-claude_code', require: 'ruby_llm/providers/claude_code'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The provider registers itself as `:claude_code` when required. No
|
|
44
|
+
configuration is required.
|
|
45
|
+
|
|
46
|
+
## Models
|
|
47
|
+
|
|
48
|
+
The part after the slash goes straight to `claude --model`: an alias
|
|
49
|
+
(`opus`, `sonnet`, `haiku`) or a full id such as `claude-opus-5-5`. The id
|
|
50
|
+
`default` (or an empty id) sends no `--model` flag and lets the CLI choose.
|
|
51
|
+
`list_models` returns this static set; any other id is passed through.
|
|
52
|
+
|
|
53
|
+
## Sessions
|
|
54
|
+
|
|
55
|
+
RubyLLM hands the provider the whole transcript on every turn. The provider
|
|
56
|
+
fingerprints what it has sent before, and when the transcript minus the newest
|
|
57
|
+
message matches what the CLI session already saw, it sends only the new
|
|
58
|
+
message with `--resume <session id>`. Any other shape, such as a rewound or
|
|
59
|
+
edited history, a cleared chat or a changed system prompt, starts a fresh
|
|
60
|
+
session with the full conversation replayed as a tagged transcript.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
chat.with_provider_options(claude_code: { resume: false }) # fresh session every turn
|
|
64
|
+
chat.with_provider_options(claude_code: { session_id: 'uuid' }) # continue a known session
|
|
65
|
+
provider = chat.instance_variable_get(:@provider)
|
|
66
|
+
provider.session.id # the CLI session in use
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Global options are `RubyLLM.config.claude_code_*`; the same keys can be set
|
|
72
|
+
per chat with `with_provider_options(claude_code: { ... })`.
|
|
73
|
+
|
|
74
|
+
| Option | Default | Meaning |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `command` | `claude` | CLI executable |
|
|
77
|
+
| `cwd` | current directory | Directory the agent works in |
|
|
78
|
+
| `timeout` | `600` | Seconds before the process group is killed; `nil` or `0` disables |
|
|
79
|
+
| `permission_mode` | CLI default | `--permission-mode` (`acceptEdits`, `plan`, `bypassPermissions`, ...) |
|
|
80
|
+
| `args` | none | Extra CLI arguments, array or shell string (`--add-dir`, `--max-turns`, `--mcp-config`, ...) |
|
|
81
|
+
| `system_prompt_mode` | `append` | `append` adds RubyLLM instructions to Claude Code's prompt; `replace` uses them instead |
|
|
82
|
+
| `env` | none | Extra environment variables for the process |
|
|
83
|
+
| `resume` | `true` | Reuse the CLI session across turns |
|
|
84
|
+
| `session_id` | none | Per-request only: force `--resume` with this id |
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
RubyLLM.configure do |c|
|
|
88
|
+
c.claude_code_permission_mode = 'acceptEdits'
|
|
89
|
+
c.claude_code_timeout = 900
|
|
90
|
+
c.claude_code_args = ['--add-dir', '/path/to/other/repo']
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What is supported
|
|
95
|
+
|
|
96
|
+
| Feature | How |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Text, system prompts, multi-turn | Native; see Sessions |
|
|
99
|
+
| Streaming | `--output-format stream-json`; only the top-level agent's text is streamed, not subagent output |
|
|
100
|
+
| Structured output (`with_schema`) | `--json-schema`; the result is the JSON string |
|
|
101
|
+
| `with_thinking(effort:)` | `--effort` (low, medium, high, xhigh, max) |
|
|
102
|
+
| Token counts and cost | From the CLI result, including cache reads and writes and `total_cost_usd` |
|
|
103
|
+
| Attachments | Noted in the prompt as `[Attached file 'name': path]`; the agent opens local files itself |
|
|
104
|
+
| RubyLLM tools (`with_tools`) | **Not supported**: raises `UnsupportedFeatureError`. The agent uses its own tools and MCP servers. The provider class answers `accepts_tools?` with `false` so hosts such as aia can skip attaching tools |
|
|
105
|
+
| temperature, max tokens | Ignored (logged at debug level) |
|
|
106
|
+
|
|
107
|
+
The streamed text is everything the agent says along the way; the returned
|
|
108
|
+
message content is its final result.
|
|
109
|
+
|
|
110
|
+
## Safety
|
|
111
|
+
|
|
112
|
+
In batch mode the agent can edit files and run commands in `cwd` under its
|
|
113
|
+
usual permission rules. Set `permission_mode` (or pass `--allowedTools` via
|
|
114
|
+
`args`) deliberately rather than relying on defaults you have not looked at.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
bundle install
|
|
120
|
+
asgard test_verbose # offline: the CLI is replaced by a fake runner
|
|
121
|
+
asgard quality # tests, rubocop, flog, flay, reek, ...
|
|
122
|
+
bin/console
|
|
123
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class ClaudeCode < Provider
|
|
8
|
+
# Builds the argv for one `claude -p` invocation.
|
|
9
|
+
class Command
|
|
10
|
+
EFFORTS = %w[low medium high xhigh max].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(settings)
|
|
13
|
+
@settings = settings
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param model [String, nil] model id or alias; "default" means the CLI's own choice
|
|
17
|
+
# @param stream [Boolean] ask for stream-json output with partial messages
|
|
18
|
+
# @param resume_id [String, nil] CLI session id to continue
|
|
19
|
+
# @param system_prompt [String, nil] sent only when starting a session
|
|
20
|
+
# @param schema [Hash, nil] RubyLLM schema payload for structured output
|
|
21
|
+
# @param effort [String, nil] one of EFFORTS
|
|
22
|
+
# @return [Array<String>]
|
|
23
|
+
def build(model: nil, stream: false, resume_id: nil, system_prompt: nil, schema: nil, effort: nil)
|
|
24
|
+
argv = [@settings.command, '-p', '--output-format', stream ? 'stream-json' : 'json']
|
|
25
|
+
argv.push('--verbose', '--include-partial-messages') if stream
|
|
26
|
+
argv.push('--model', model.to_s) if self.class.named?(model)
|
|
27
|
+
argv.push('--resume', resume_id) if resume_id
|
|
28
|
+
argv.concat(system_prompt_args(system_prompt)) unless resume_id
|
|
29
|
+
argv.push('--permission-mode', @settings.permission_mode) if @settings.permission_mode
|
|
30
|
+
argv.push('--json-schema', JSON.generate(self.class.schema_definition(schema))) if schema
|
|
31
|
+
argv.push('--effort', effort) if effort
|
|
32
|
+
argv.concat(@settings.args)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def named?(model)
|
|
37
|
+
!(model.nil? || model.to_s.empty? || model.to_s == DEFAULT_MODEL)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The bare JSON Schema from RubyLLM's payload, without the "strict"
|
|
41
|
+
# keys the CLI rejects.
|
|
42
|
+
def schema_definition(schema)
|
|
43
|
+
clean(schema[:schema] || schema['schema'] || schema)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clean(schema)
|
|
47
|
+
case schema
|
|
48
|
+
when Hash then schema.reject { |key, _| key.to_s == 'strict' }.transform_values { |value| clean(value) }
|
|
49
|
+
when Array then schema.map { |value| clean(value) }
|
|
50
|
+
else schema
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def system_prompt_args(text)
|
|
58
|
+
return [] if text.nil? || text.strip.empty?
|
|
59
|
+
|
|
60
|
+
flag = @settings.system_prompt_mode == 'replace' ? '--system-prompt' : '--append-system-prompt'
|
|
61
|
+
[flag, text]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class ClaudeCode < Provider
|
|
6
|
+
# Raised when the claude CLI cannot be run, exits without a result, or
|
|
7
|
+
# reports an error of its own.
|
|
8
|
+
class Error < RubyLLM::Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the CLI runs past the configured timeout and is killed.
|
|
11
|
+
class TimeoutError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised for RubyLLM features the CLI cannot honour, such as tools.
|
|
14
|
+
class UnsupportedFeatureError < Error; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class ClaudeCode < Provider
|
|
8
|
+
# Reads the CLI's JSON output line by line. While streaming, text deltas
|
|
9
|
+
# from the top-level agent become Chunks; the final "result" event
|
|
10
|
+
# becomes the Message.
|
|
11
|
+
class Response
|
|
12
|
+
STOP_REASONS = { 'end_turn' => :stop, 'stop_sequence' => :stop, 'max_tokens' => :max_tokens }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :session_id, :result, :model_id
|
|
15
|
+
|
|
16
|
+
def initialize(model:, schema: nil)
|
|
17
|
+
@model = model
|
|
18
|
+
@schema = schema
|
|
19
|
+
@blocks = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param line [String] one line of stdout
|
|
23
|
+
# @yield [RubyLLM::Chunk] a streamed piece of assistant text
|
|
24
|
+
# @return [Hash, nil] the parsed event, or nil for non-JSON lines
|
|
25
|
+
def ingest(line)
|
|
26
|
+
event = self.class.parse(line)
|
|
27
|
+
return nil unless event
|
|
28
|
+
|
|
29
|
+
case event['type']
|
|
30
|
+
when 'stream_event'
|
|
31
|
+
text = text_delta(event)
|
|
32
|
+
yield chunk(text) if text && block_given?
|
|
33
|
+
when 'assistant' then @model_id ||= event.dig('message', 'model')
|
|
34
|
+
when 'result'
|
|
35
|
+
@result = event
|
|
36
|
+
@session_id = event['session_id']
|
|
37
|
+
when 'system' then @session_id ||= event['session_id']
|
|
38
|
+
end
|
|
39
|
+
event
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param run [Runner::Result, nil] used for diagnostics when no result arrived
|
|
43
|
+
# @return [RubyLLM::Message]
|
|
44
|
+
def message(run = nil)
|
|
45
|
+
raise_without_result!(run) unless @result
|
|
46
|
+
raise Error, "claude -p failed: #{error_detail}" if @result['is_error']
|
|
47
|
+
|
|
48
|
+
usage = @result['usage'] || {}
|
|
49
|
+
Message.new(
|
|
50
|
+
role: :assistant,
|
|
51
|
+
content: content,
|
|
52
|
+
model: @model,
|
|
53
|
+
input_tokens: usage['input_tokens'],
|
|
54
|
+
output_tokens: usage['output_tokens'],
|
|
55
|
+
cache_read_tokens: usage['cache_read_input_tokens'],
|
|
56
|
+
cache_write_tokens: usage['cache_creation_input_tokens'],
|
|
57
|
+
thinking_tokens: usage.dig('output_tokens_details', 'thinking_tokens'),
|
|
58
|
+
reported_cost: @result['total_cost_usd'],
|
|
59
|
+
finish_reason: STOP_REASONS.fetch(@result['stop_reason'], :stop),
|
|
60
|
+
raw: @result
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.parse(line)
|
|
65
|
+
text = line.to_s.strip
|
|
66
|
+
return nil unless text.start_with?('{')
|
|
67
|
+
|
|
68
|
+
JSON.parse(text)
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
RubyLLM.logger.debug { "claude_code: non-JSON output #{text.inspect}" }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def content
|
|
77
|
+
structured = @result['structured_output']
|
|
78
|
+
return JSON.generate(structured) if @schema && !structured.nil?
|
|
79
|
+
|
|
80
|
+
@result['result'].to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def error_detail
|
|
84
|
+
detail = @result['result'].to_s
|
|
85
|
+
detail = Array(@result['errors']).join('; ') if detail.empty?
|
|
86
|
+
detail = @result['subtype'].to_s if detail.empty?
|
|
87
|
+
detail
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def raise_without_result!(run)
|
|
91
|
+
status = run&.status&.exitstatus
|
|
92
|
+
stderr = run&.stderr.to_s.strip
|
|
93
|
+
raise Error,
|
|
94
|
+
"claude -p exited#{" (#{status})" if status} without a result#{": #{stderr}" unless stderr.empty?}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Text from the top-level agent only: subagent output carries a
|
|
98
|
+
# parent_tool_use_id and is not part of the reply.
|
|
99
|
+
def text_delta(event)
|
|
100
|
+
return nil unless event['parent_tool_use_id'].nil?
|
|
101
|
+
|
|
102
|
+
data = event['event'] || {}
|
|
103
|
+
index = data['index']
|
|
104
|
+
case data['type']
|
|
105
|
+
when 'content_block_start'
|
|
106
|
+
@blocks[index] = data.dig('content_block', 'type')
|
|
107
|
+
nil
|
|
108
|
+
when 'content_block_delta'
|
|
109
|
+
delta = data['delta'] || {}
|
|
110
|
+
delta['text'] if delta['type'] == 'text_delta' && (@blocks[index].nil? || @blocks[index] == 'text')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def chunk(text)
|
|
115
|
+
Chunk.new(role: :assistant, content: text, model: @model)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class ClaudeCode < Provider
|
|
8
|
+
# Spawns the CLI, feeds it stdin, and yields each stdout line on the
|
|
9
|
+
# caller's thread as it arrives. The process group is killed when the
|
|
10
|
+
# timeout passes.
|
|
11
|
+
class Runner
|
|
12
|
+
# What the process produced: full stdout, full stderr, and its exit status.
|
|
13
|
+
Result = Data.define(:stdout, :stderr, :status)
|
|
14
|
+
GRACE_SECONDS = 2
|
|
15
|
+
EOF = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
# @param argv [Array<String>] command and arguments
|
|
18
|
+
# @param stdin [String] text written to the process's stdin
|
|
19
|
+
# @param cwd [String, nil] working directory (default: Dir.pwd)
|
|
20
|
+
# @param timeout [Numeric, nil] seconds before the process is killed
|
|
21
|
+
# @param env [Hash] extra environment variables
|
|
22
|
+
# @yield [String] each line of stdout
|
|
23
|
+
# @return [Result]
|
|
24
|
+
def run(argv, stdin: '', cwd: nil, timeout: nil, env: {}, &block)
|
|
25
|
+
Open3.popen3(env.to_h, *argv, chdir: cwd || Dir.pwd, pgroup: true) do |input, output, error, wait|
|
|
26
|
+
collect(input, output, error, wait, stdin:, timeout:, &block)
|
|
27
|
+
rescue TimeoutError
|
|
28
|
+
terminate(wait)
|
|
29
|
+
raise TimeoutError, "#{argv.first} did not finish within #{timeout}s"
|
|
30
|
+
end
|
|
31
|
+
rescue Errno::ENOENT => e
|
|
32
|
+
raise Error, "cannot run #{argv.first.inspect}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Drives one spawned process: feeds stdin and reads stderr on their own
|
|
38
|
+
# threads, pumps stdout through a queue so each line can be yielded
|
|
39
|
+
# before the process exits, and assembles the Result once it does.
|
|
40
|
+
def collect(input, output, error, wait, stdin:, timeout:)
|
|
41
|
+
feeder = Thread.new { feed(input, stdin) }
|
|
42
|
+
errors = Thread.new { error.read }
|
|
43
|
+
queue = Queue.new
|
|
44
|
+
reader = Thread.new { pump(output, queue) }
|
|
45
|
+
lines = []
|
|
46
|
+
drain(queue, deadline(timeout)) do |line|
|
|
47
|
+
lines << line
|
|
48
|
+
yield line if block_given?
|
|
49
|
+
end
|
|
50
|
+
[feeder, reader].each(&:join)
|
|
51
|
+
Result.new(stdout: lines.join, stderr: errors.value.to_s, status: wait.value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deadline(timeout)
|
|
55
|
+
timeout && (now + timeout)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def feed(input, text)
|
|
59
|
+
input.binmode
|
|
60
|
+
input.write(text.to_s)
|
|
61
|
+
rescue Errno::EPIPE, IOError
|
|
62
|
+
nil
|
|
63
|
+
ensure
|
|
64
|
+
input.close unless input.closed?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pump(output, queue)
|
|
68
|
+
output.each_line { |line| queue << line }
|
|
69
|
+
ensure
|
|
70
|
+
queue << EOF
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pops lines until EOF, raising TimeoutError once the deadline passes.
|
|
74
|
+
def drain(queue, deadline)
|
|
75
|
+
loop do
|
|
76
|
+
remaining = deadline && [deadline - now, 0].max
|
|
77
|
+
line = remaining ? queue.pop(timeout: remaining) : queue.pop
|
|
78
|
+
raise TimeoutError if line.nil?
|
|
79
|
+
break if line.equal?(EOF)
|
|
80
|
+
|
|
81
|
+
yield line
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def terminate(wait)
|
|
86
|
+
pgid = -wait.pid
|
|
87
|
+
Process.kill('TERM', pgid)
|
|
88
|
+
return if wait.join(GRACE_SECONDS)
|
|
89
|
+
|
|
90
|
+
Process.kill('KILL', pgid)
|
|
91
|
+
wait.join
|
|
92
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def now
|
|
97
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class ClaudeCode < Provider
|
|
6
|
+
# Decides, per completion, whether to resume the CLI session that
|
|
7
|
+
# answered the previous turn or to start a new one. A session is resumed
|
|
8
|
+
# only when the transcript RubyLLM hands over, minus the newest message,
|
|
9
|
+
# is exactly what that session already saw. A rewound, edited or cleared
|
|
10
|
+
# history gets a fresh session replayed from the whole transcript.
|
|
11
|
+
class Session
|
|
12
|
+
Plan = Data.define(:messages, :resume_id) do
|
|
13
|
+
def resume?
|
|
14
|
+
!resume_id.nil?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :id
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
reset!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param messages [Array<RubyLLM::Message>] the full RubyLLM transcript
|
|
25
|
+
# @param session_id [String, nil] a caller-chosen session to continue
|
|
26
|
+
# @param resume [Boolean] false forces a fresh session every time
|
|
27
|
+
# @return [Plan] the messages to send and the session to resume, if any
|
|
28
|
+
def plan(messages, session_id: nil, resume: true)
|
|
29
|
+
raise ArgumentError, 'no messages to send' if messages.empty?
|
|
30
|
+
return Plan.new(messages: [messages.last], resume_id: session_id) if session_id
|
|
31
|
+
return Plan.new(messages: messages, resume_id: nil) unless resume && continues?(messages)
|
|
32
|
+
|
|
33
|
+
Plan.new(messages: [messages.last], resume_id: @id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remember which session answered, and what it has now seen.
|
|
37
|
+
def record(messages, reply, id:)
|
|
38
|
+
@id = id
|
|
39
|
+
@fingerprint = id ? Transcript.fingerprint(messages + [reply]) : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reset!
|
|
43
|
+
@id = nil
|
|
44
|
+
@fingerprint = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def continues?(messages)
|
|
50
|
+
return false unless @id && @fingerprint && messages.size > 1
|
|
51
|
+
|
|
52
|
+
Transcript.fingerprint(messages[0...-1]) == @fingerprint
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class ClaudeCode < Provider
|
|
8
|
+
# The settings for one completion. Each value comes from, in order of
|
|
9
|
+
# precedence, chat.with_provider_options(claude_code: {...}), the
|
|
10
|
+
# RubyLLM.config.claude_code_* option, then the default here.
|
|
11
|
+
class Settings
|
|
12
|
+
DEFAULT_COMMAND = 'claude'
|
|
13
|
+
DEFAULT_TIMEOUT = 600
|
|
14
|
+
SYSTEM_PROMPT_MODES = %w[append replace].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :command, :cwd, :timeout, :permission_mode, :args, :system_prompt_mode, :env, :session_id
|
|
17
|
+
|
|
18
|
+
def initialize(config, provider_options = {})
|
|
19
|
+
opts = self.class.namespaced(provider_options)
|
|
20
|
+
@command = opts.fetch(:command) { config.claude_code_command } || DEFAULT_COMMAND
|
|
21
|
+
@cwd = opts.fetch(:cwd) { config.claude_code_cwd }
|
|
22
|
+
@timeout = self.class.positive_or_nil(opts.fetch(:timeout) { timeout_default(config) })
|
|
23
|
+
@permission_mode = opts.fetch(:permission_mode) { config.claude_code_permission_mode }&.to_s
|
|
24
|
+
@args = self.class.split_args(opts.fetch(:args) { config.claude_code_args })
|
|
25
|
+
@system_prompt_mode = self.class.mode(opts.fetch(:system_prompt_mode) do
|
|
26
|
+
config.claude_code_system_prompt_mode
|
|
27
|
+
end)
|
|
28
|
+
@env = self.class.stringify(opts.fetch(:env) { config.claude_code_env })
|
|
29
|
+
@session_id = opts[:session_id]
|
|
30
|
+
@resume = opts.fetch(:resume) { config.claude_code_resume.nil? || config.claude_code_resume }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# False when every completion must start a fresh CLI session.
|
|
34
|
+
def resume?
|
|
35
|
+
@resume ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# The claude_code: sub-hash of provider_options with symbol keys.
|
|
40
|
+
def namespaced(provider_options)
|
|
41
|
+
return {} unless provider_options.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
raw = provider_options[:claude_code] || provider_options['claude_code'] || {}
|
|
44
|
+
raise ArgumentError, 'provider_options[:claude_code] must be a Hash' unless raw.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
raw.transform_keys(&:to_sym)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A positive number of seconds, or nil for "no timeout".
|
|
50
|
+
def positive_or_nil(value)
|
|
51
|
+
return nil if value.nil?
|
|
52
|
+
|
|
53
|
+
number = Float(value)
|
|
54
|
+
number.positive? ? number : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Extra CLI arguments given as an array or a shell-style string.
|
|
58
|
+
def split_args(value)
|
|
59
|
+
case value
|
|
60
|
+
when nil then []
|
|
61
|
+
when String then Shellwords.split(value)
|
|
62
|
+
else Array(value).map(&:to_s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def mode(value)
|
|
67
|
+
return 'append' if value.nil?
|
|
68
|
+
|
|
69
|
+
text = value.to_s
|
|
70
|
+
return text if SYSTEM_PROMPT_MODES.include?(text)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "claude_code system_prompt_mode must be one of #{SYSTEM_PROMPT_MODES.join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stringify(value)
|
|
76
|
+
(value || {}).to_h.to_h { |key, item| [key.to_s, item.to_s] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def timeout_default(config)
|
|
83
|
+
config.claude_code_timeout.nil? ? DEFAULT_TIMEOUT : config.claude_code_timeout
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Providers
|
|
8
|
+
class ClaudeCode < Provider
|
|
9
|
+
# Renders RubyLLM messages into the text the CLI reads from stdin, and
|
|
10
|
+
# fingerprints transcripts so Session can tell whether a CLI session
|
|
11
|
+
# already holds the earlier turns.
|
|
12
|
+
class Transcript
|
|
13
|
+
REPLAY_PREAMBLE = "The conversation so far follows. Continue it by writing the assistant's next reply " \
|
|
14
|
+
"to the last turn. Do not repeat the tags.\n\n"
|
|
15
|
+
EMPTY = '(empty message)'
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def fingerprint(messages)
|
|
19
|
+
Digest::SHA256.hexdigest(JSON.generate(messages.map { |message| summary(message) }))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def summary(message)
|
|
23
|
+
{
|
|
24
|
+
role: message.role.to_s,
|
|
25
|
+
content: message.content.to_s,
|
|
26
|
+
tool_call_id: message.tool_call_id,
|
|
27
|
+
tool_calls: tool_calls(message).map { |call| [call.id, call.name, call.arguments] },
|
|
28
|
+
attachments: attachments(message).map { |attachment| attachment_note(attachment) }
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The message text plus a note per attachment naming its location;
|
|
33
|
+
# the agent can open local files with its own tools.
|
|
34
|
+
def text_of(message)
|
|
35
|
+
parts = [message.content.to_s]
|
|
36
|
+
attachments(message).each { |attachment| parts << attachment_note(attachment) }
|
|
37
|
+
parts.reject(&:empty?).join("\n").strip
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def attachment_note(attachment)
|
|
41
|
+
source = attachment.respond_to?(:source) ? attachment.source : attachment
|
|
42
|
+
name = attachment.respond_to?(:filename) ? attachment.filename : nil
|
|
43
|
+
location = source.respond_to?(:to_path) ? source.to_path : source.to_s
|
|
44
|
+
"[Attached file#{" '#{name}'" if name}: #{location}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def attachments(message)
|
|
48
|
+
message.respond_to?(:attachments) ? Array(message.attachments) : []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tool_calls(message)
|
|
52
|
+
calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
|
|
53
|
+
calls.respond_to?(:values) ? calls.values : Array(calls)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(messages)
|
|
58
|
+
@system, @turns = messages.partition { |message| message.role == :system }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# All system messages joined, or nil when there are none.
|
|
62
|
+
def system_prompt
|
|
63
|
+
text = @system.map { |message| self.class.text_of(message) }.reject(&:empty?).join("\n\n")
|
|
64
|
+
text.empty? ? nil : text
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A lone user turn goes through as-is; anything longer becomes a
|
|
68
|
+
# tagged replay of the conversation.
|
|
69
|
+
def prompt
|
|
70
|
+
return EMPTY if @turns.empty?
|
|
71
|
+
return single_turn if @turns.one? && @turns.first.role == :user
|
|
72
|
+
|
|
73
|
+
REPLAY_PREAMBLE + @turns.map { |message| turn(message) }.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def single_turn
|
|
79
|
+
text = self.class.text_of(@turns.first)
|
|
80
|
+
text.empty? ? EMPTY : text
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def turn(message)
|
|
84
|
+
body = self.class.text_of(message)
|
|
85
|
+
case message.role
|
|
86
|
+
when :tool then "<tool_result id=\"#{message.tool_call_id}\">\n#{body}\n</tool_result>"
|
|
87
|
+
when :assistant then "<assistant>\n#{body}#{tool_calls_text(message)}\n</assistant>"
|
|
88
|
+
else "<user>\n#{body}\n</user>"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def tool_calls_text(message)
|
|
93
|
+
self.class.tool_calls(message).map do |call|
|
|
94
|
+
"\n<tool_call id=\"#{call.id}\" name=\"#{call.name}\">#{JSON.generate(call.arguments)}</tool_call>"
|
|
95
|
+
end.join
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
# Version of the ruby_llm-providers-claude_code gem. Kept in its own file
|
|
6
|
+
# so the gemspec can read the literal without loading the provider.
|
|
7
|
+
class ClaudeCode < Provider
|
|
8
|
+
VERSION = '0.0.1'
|
|
9
|
+
|
|
10
|
+
def self.version
|
|
11
|
+
VERSION
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require 'ruby_llm/providers/claude_code/version'
|
|
5
|
+
require 'ruby_llm/providers/claude_code/errors'
|
|
6
|
+
require 'ruby_llm/providers/claude_code/settings'
|
|
7
|
+
require 'ruby_llm/providers/claude_code/command'
|
|
8
|
+
require 'ruby_llm/providers/claude_code/transcript'
|
|
9
|
+
require 'ruby_llm/providers/claude_code/session'
|
|
10
|
+
require 'ruby_llm/providers/claude_code/runner'
|
|
11
|
+
require 'ruby_llm/providers/claude_code/response'
|
|
12
|
+
|
|
13
|
+
module RubyLLM
|
|
14
|
+
module Providers
|
|
15
|
+
# ClaudeCode: the Claude Code agent (`claude -p`) as a RubyLLM provider.
|
|
16
|
+
#
|
|
17
|
+
# Each completion spawns the claude CLI in batch mode, writes the prompt
|
|
18
|
+
# to its stdin and reads its JSON result from stdout. The agent keeps its
|
|
19
|
+
# own tools, settings and MCP servers and works in the current directory
|
|
20
|
+
# (or config.claude_code_cwd), so the "model" can read and edit files and
|
|
21
|
+
# run commands. Multi-turn chats resume the same CLI session (--resume)
|
|
22
|
+
# as long as the RubyLLM transcript still matches what that session has
|
|
23
|
+
# seen; otherwise a fresh session is started from the whole transcript.
|
|
24
|
+
#
|
|
25
|
+
# require 'ruby_llm/providers/claude_code'
|
|
26
|
+
# chat = RubyLLM.chat(model: 'sonnet', provider: :claude_code)
|
|
27
|
+
# chat.ask('Summarise the README in this directory')
|
|
28
|
+
class ClaudeCode < Provider
|
|
29
|
+
DEFAULT_MODEL = 'default'
|
|
30
|
+
MODELS = %w[default opus sonnet haiku].freeze
|
|
31
|
+
CAPABILITIES = %w[streaming structured_output].freeze
|
|
32
|
+
|
|
33
|
+
# Satisfies RubyLLM's protocol lookups; every request goes through
|
|
34
|
+
# #complete below, never through a wire protocol.
|
|
35
|
+
protocol :claude_code, RubyLLM::Protocol
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Anything responding to #run like Runner; tests inject fakes here.
|
|
39
|
+
attr_accessor :runner
|
|
40
|
+
|
|
41
|
+
def configuration_options
|
|
42
|
+
%i[
|
|
43
|
+
claude_code_command
|
|
44
|
+
claude_code_cwd
|
|
45
|
+
claude_code_timeout
|
|
46
|
+
claude_code_permission_mode
|
|
47
|
+
claude_code_args
|
|
48
|
+
claude_code_system_prompt_mode
|
|
49
|
+
claude_code_env
|
|
50
|
+
claude_code_resume
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def configuration_requirements
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def local?
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Any id goes straight to --model, so there is no catalog to check.
|
|
63
|
+
def assume_models_exist?
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The agent runs its own tools; RubyLLM tools cannot be handed to it.
|
|
68
|
+
# Hosts such as aia check this before attaching tools or MCP servers.
|
|
69
|
+
def accepts_tools?
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def display_name
|
|
74
|
+
'Claude Code'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :session
|
|
79
|
+
|
|
80
|
+
def initialize(config)
|
|
81
|
+
super
|
|
82
|
+
@session = Session.new
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Never contacted; the transport layer only needs a base URL.
|
|
86
|
+
def api_base
|
|
87
|
+
'cli://claude'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Messages go to the CLI as text, so there is nothing to upload or
|
|
91
|
+
# convert beforehand.
|
|
92
|
+
def preprocess_messages(messages, **)
|
|
93
|
+
messages
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def discard_missing_uploads(_messages, _error, **)
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# :reek:LongParameterList -- the keyword set is RubyLLM's Provider#complete contract
|
|
101
|
+
def complete(messages, model:, tools: {}, provider_options: {}, schema: nil, thinking: nil,
|
|
102
|
+
temperature: nil, max_output_tokens: nil, before_request: [], **, &block)
|
|
103
|
+
reject_tools!(tools)
|
|
104
|
+
log_ignored(temperature:, max_output_tokens:)
|
|
105
|
+
settings = Settings.new(@config, provider_options)
|
|
106
|
+
plan = session.plan(messages, session_id: settings.session_id, resume: settings.resume?)
|
|
107
|
+
transcript = Transcript.new(plan.messages)
|
|
108
|
+
argv = build_argv(settings, model, transcript, plan, schema, thinking, block)
|
|
109
|
+
before_request.each { |hook| hook.call({ argv: argv, prompt: transcript.prompt }) }
|
|
110
|
+
response = Response.new(model: model, schema: schema)
|
|
111
|
+
run = runner.run(argv, stdin: transcript.prompt, cwd: settings.cwd, timeout: settings.timeout,
|
|
112
|
+
env: settings.env) do |line|
|
|
113
|
+
response.ingest(line) { |chunk| block.call(chunk) }
|
|
114
|
+
end
|
|
115
|
+
message = response.message(run)
|
|
116
|
+
session.record(messages, message, id: response.session_id)
|
|
117
|
+
message
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A static catalog: the CLI's aliases plus "default" for its own choice.
|
|
121
|
+
def list_models
|
|
122
|
+
MODELS.map { |id| static_model(id) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def runner
|
|
128
|
+
self.class.runner || Runner.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_argv(settings, model, transcript, plan, schema, thinking, block)
|
|
132
|
+
Command.new(settings).build(
|
|
133
|
+
model: self.class.model_id(model),
|
|
134
|
+
stream: !block.nil?,
|
|
135
|
+
resume_id: plan.resume_id,
|
|
136
|
+
system_prompt: transcript.system_prompt,
|
|
137
|
+
schema: schema,
|
|
138
|
+
effort: self.class.effort_for(thinking)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def static_model(id)
|
|
143
|
+
Model.new(
|
|
144
|
+
id: id,
|
|
145
|
+
name: "Claude Code (#{id})",
|
|
146
|
+
provider: slug,
|
|
147
|
+
capabilities: CAPABILITIES,
|
|
148
|
+
modalities: { input: %w[text], output: %w[text] },
|
|
149
|
+
metadata: { source: 'ruby_llm-providers-claude_code' }
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def reject_tools!(tools)
|
|
154
|
+
return if tools.nil? || tools.empty?
|
|
155
|
+
|
|
156
|
+
raise UnsupportedFeatureError,
|
|
157
|
+
'RubyLLM tools cannot be handed to the claude CLI; the agent brings its own tools. ' \
|
|
158
|
+
'Remove with_tools or choose another provider.'
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def log_ignored(**options)
|
|
162
|
+
ignored = options.compact.keys
|
|
163
|
+
return if ignored.empty?
|
|
164
|
+
|
|
165
|
+
RubyLLM.logger.debug { "claude_code: ignoring #{ignored.join(', ')} (not supported by claude -p)" }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
class << self
|
|
169
|
+
def model_id(model)
|
|
170
|
+
model.respond_to?(:id) ? model.id : model.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def effort_for(thinking)
|
|
174
|
+
return nil unless thinking.respond_to?(:effort) && thinking.effort
|
|
175
|
+
|
|
176
|
+
value = thinking.effort.to_s
|
|
177
|
+
Command::EFFORTS.include?(value) ? value : nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
RubyLLM::Provider.register :claude_code, RubyLLM::Providers::ClaudeCode unless RubyLLM::Provider.resolve(:claude_code)
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_llm-providers-claude_code
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dewayne VanHoozer
|
|
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: ruby_llm
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
-
|
|
17
|
+
- ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: "2.1"
|
|
20
|
+
-
|
|
21
|
+
- "<"
|
|
22
|
+
- !ruby/object:Gem::Version
|
|
23
|
+
version: "3"
|
|
24
|
+
type: :runtime
|
|
25
|
+
prerelease: false
|
|
26
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
27
|
+
requirements:
|
|
28
|
+
-
|
|
29
|
+
- ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: "2.1"
|
|
32
|
+
-
|
|
33
|
+
- "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: "3"
|
|
36
|
+
description: "Adds a claude_code provider to RubyLLM. Prompts are written to the Claude Code CLI's stdin in batch mode and its JSON result is read back from stdout, so the agent (with its own tools, settings and resumable sessions) can be used wherever RubyLLM expects a model, e.g. `--model claude_code/opus` in aia."
|
|
37
|
+
email:
|
|
38
|
+
- dvanhoozer@gmail.com
|
|
39
|
+
executables: []
|
|
40
|
+
extensions: []
|
|
41
|
+
extra_rdoc_files: []
|
|
42
|
+
files:
|
|
43
|
+
- CHANGELOG.md
|
|
44
|
+
- LICENSE.txt
|
|
45
|
+
- README.md
|
|
46
|
+
- lib/ruby_llm/providers/claude_code.rb
|
|
47
|
+
- lib/ruby_llm/providers/claude_code/command.rb
|
|
48
|
+
- lib/ruby_llm/providers/claude_code/errors.rb
|
|
49
|
+
- lib/ruby_llm/providers/claude_code/response.rb
|
|
50
|
+
- lib/ruby_llm/providers/claude_code/runner.rb
|
|
51
|
+
- lib/ruby_llm/providers/claude_code/session.rb
|
|
52
|
+
- lib/ruby_llm/providers/claude_code/settings.rb
|
|
53
|
+
- lib/ruby_llm/providers/claude_code/transcript.rb
|
|
54
|
+
- lib/ruby_llm/providers/claude_code/version.rb
|
|
55
|
+
homepage: "https://github.com/madbomber/ruby_llm-providers-claude_code"
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: "https://github.com/madbomber/ruby_llm-providers-claude_code"
|
|
60
|
+
source_code_uri: "https://github.com/madbomber/ruby_llm-providers-claude_code"
|
|
61
|
+
changelog_uri: "https://github.com/madbomber/ruby_llm-providers-claude_code/blob/main/CHANGELOG.md"
|
|
62
|
+
bug_tracker_uri: "https://github.com/madbomber/ruby_llm-providers-claude_code/issues"
|
|
63
|
+
rubygems_mfa_required: "true"
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
-
|
|
70
|
+
- ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: "3.2"
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
-
|
|
76
|
+
- ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: "0"
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 4.1.0.beta3
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: RubyLLM provider that drives the Claude Code agent through `claude -p`.
|
|
83
|
+
test_files: []
|