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 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
@@ -0,0 +1,6 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-11-06
4
+
5
+ - Initial release: `claude -p` as a RubyLLM provider with resumable sessions,
6
+ streaming, structured output, effort levels and attachment notes.
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: []