ruby_llm-claude_cli 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3c32a73de5301283e7fb20c62193d24353eced9373f95d35cf2877657937e057
4
+ data.tar.gz: 15c16a35ea36ccbc652a31530c4cbfb2ba4bb17f3736c46aa579b4965b7ca0ed
5
+ SHA512:
6
+ metadata.gz: 6a04712e0065cf570d1819dfac38c81051578b47ec34c8cef9d96188e85645c8798bf781e687943becd0f3f4da04fa623eab4ed30a2468e49a1515cf745fbdb2
7
+ data.tar.gz: 6e9cf1df56aff8e60c0fcf1bab27fce6832104747b497d131fa380c7912c32df10ea46dbe4f10cb0857eb902ce1f0cc2583bf531c00e1886bbe15542ffb47c86
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `claude_cli` provider for RubyLLM 2.x with streaming, images, PDFs, text and
6
+ Office attachments, staged files with a hex preview, an attachment converter hook, structured
7
+ output, effort, and emulated ruby_llm tool calls.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Idogawa
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # ruby_llm-claude_cli
2
+
3
+ A [RubyLLM](https://rubyllm.com) 2.x provider that runs chats through the local
4
+ `claude -p` (Claude Code) instead of the HTTP API. Requests use whatever login
5
+ Claude Code has, including a Pro/Max subscription. No API key is needed.
6
+
7
+ ```ruby
8
+ require 'ruby_llm/claude_cli'
9
+
10
+ chat = RubyLLM.chat(model: 'sonnet', provider: :claude_cli) # or opus, haiku, claude-opus-5-5
11
+ chat.ask('Summarise this', with: 'report.pdf')
12
+ chat.ask('Stream please') { |chunk| print chunk.content }
13
+ ```
14
+
15
+ ## How it works
16
+
17
+ It subclasses ruby_llm's Anthropic protocol and swaps the HTTP call for a
18
+ `claude -p --input-format stream-json --output-format stream-json` process.
19
+ The CLI streams raw Messages API events, so ruby_llm's own Anthropic parser
20
+ reads the stream. Each call starts a fresh, isolated CLI session with a
21
+ throwaway working directory, no settings files, MCP servers, skills or saved
22
+ session, and your system prompt in place of Claude Code's.
23
+
24
+ ## What is supported, and the workarounds
25
+
26
+ | Feature | How |
27
+ |---|---|
28
+ | Text, system prompts, multi-turn | History is sent as one tagged transcript (stream-json input only accepts user turns) |
29
+ | Streaming | Native, from the CLI's partial-message events |
30
+ | Images (png/jpeg/gif/webp ≤ 5 MB) | Inline base64 image blocks. URLs are downloaded first. Images in earlier turns are kept. |
31
+ | PDFs (≤ 20 MB) | Inline document blocks |
32
+ | Text, code, CSV, JSON | Inline text |
33
+ | docx / xlsx / pptx | Text is extracted from the Office XML (needs `rubyzip`) |
34
+ | Larger images and PDFs | Saved to the working directory. The model opens them with the Read tool, which downsizes images and pages through PDFs. |
35
+ | Other binaries | Saved to the working directory, plus a hex and strings preview |
36
+ | Audio, video, anything else | `config.claude_cli_attachment_converter` hook, see below |
37
+ | Tools (`with_tools`) | Emulated: tools are described in the system prompt and the reply is forced through `--json-schema` into `{text, tool_calls}`. ruby_llm runs the tools as usual. Honours `choice:` and `calls: :one`. |
38
+ | Structured output (`with_schema`) | `--json-schema` |
39
+ | `with_thinking(effort:)` | `--effort` (low/medium/high/xhigh/max) |
40
+ | temperature, max_output_tokens, token counting, citations, caching controls | Not available through the CLI. Ignored, or an error is raised. |
41
+
42
+ Transcribe audio with another provider before it reaches Claude:
43
+
44
+ ```ruby
45
+ RubyLLM.configure do |c|
46
+ c.claude_cli_attachment_converter = lambda do |attachment|
47
+ RubyLLM.transcribe(attachment.source.to_s, model: 'gpt-4o-transcribe').text if attachment.audio?
48
+ end
49
+ end
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ```ruby
55
+ RubyLLM.configure do |c|
56
+ c.claude_cli_command = 'claude' # path to the CLI
57
+ c.claude_cli_timeout = 300 # seconds; kills the process after that
58
+ c.claude_cli_native_tools = %w[WebSearch] # let Claude Code's own tools run (default: none)
59
+ c.claude_cli_extra_args = ['--max-budget-usd', '1']
60
+ c.claude_cli_workdir = nil # fixed dir instead of a fresh temp dir per call
61
+ c.claude_cli_default_system_prompt = 'You are a helpful assistant.'
62
+ end
63
+ ```
64
+
65
+ ## Trade-offs
66
+
67
+ - Each call starts a process, so there is about 2–4 s of extra latency per turn.
68
+ A tool round trip is two calls.
69
+ - The full history is re-sent every turn. There is no `--resume`, so ruby_llm
70
+ stays the source of truth for the conversation.
71
+ - Token counts come from the CLI's result. Costs follow your Claude Code plan.
72
+
73
+ ## Try it
74
+
75
+ ```bash
76
+ bundle install
77
+ bundle exec ruby bin/console # irb with a `chat` helper and a demo Weather tool
78
+ bundle exec ruby examples/demo.rb # scripted demo; MODEL=haiku for speed
79
+ ```
80
+
81
+ ## Tests
82
+
83
+ ```bash
84
+ ruby test/content_test.rb # offline
85
+ ruby examples/smoke.rb haiku # real claude -p calls, ~2 min
86
+ ```
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module RubyLLM
7
+ module ClaudeCLI
8
+ # Turns a ruby_llm conversation into the content blocks of the single user
9
+ # message `claude -p` receives.
10
+ #
11
+ # Attachment handling:
12
+ # * png/jpeg/gif/webp up to 5 MB -> inline image block (URLs are fetched)
13
+ # * PDF up to 20 MB -> inline document block
14
+ # * text, code, csv, json -> inline text
15
+ # * docx/xlsx/pptx -> text extracted from the Office XML
16
+ # (needs the rubyzip gem), else staged
17
+ # * anything, via hook -> config.claude_cli_attachment_converter
18
+ # * everything else, oversized -> copied into the work dir and the model
19
+ # images and PDFs is told the path; the Read tool is
20
+ # enabled so it can open it (Read also
21
+ # downsizes big images and pages PDFs);
22
+ # binaries also get a hex preview
23
+ class Content
24
+ INLINE_IMAGE_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
25
+ MAX_INLINE_IMAGE = 5 * 1024 * 1024
26
+ MAX_INLINE_PDF = 20 * 1024 * 1024
27
+ OFFICE_TEXT_PARTS = {
28
+ 'docx' => %r{\Aword/(document|header\d*|footer\d*|footnotes)\.xml\z},
29
+ 'pptx' => %r{\Appt/slides/slide\d+\.xml\z},
30
+ 'xlsx' => %r{\Axl/(sharedStrings|worksheets/sheet\d+)\.xml\z}
31
+ }.freeze
32
+
33
+ attr_reader :staged_files
34
+
35
+ def initialize(workdir, config = nil)
36
+ @workdir = workdir
37
+ @config = config
38
+ @staged_files = []
39
+ end
40
+
41
+ def system_prompt(system_messages)
42
+ system_messages.map { |msg| text_of(msg) }.reject(&:empty?).join("\n\n")
43
+ end
44
+
45
+ # One user turn goes in as-is; longer histories become a tagged
46
+ # transcript, because stream-json input only takes user messages.
47
+ def blocks(messages)
48
+ if messages.one? && messages.first.role == :user
49
+ return message_blocks(messages.first).then { |b| b.empty? ? [text('(empty message)')] : b }
50
+ end
51
+
52
+ out = [text("The conversation so far follows. Continue it by writing the assistant's next reply " \
53
+ "to the last turn. Do not repeat the tags.\n")]
54
+ messages.each { |msg| out.concat(transcript_turn(msg)) }
55
+ merge_texts(out)
56
+ end
57
+
58
+ private
59
+
60
+ def transcript_turn(msg)
61
+ case msg.role
62
+ when :tool
63
+ [text("<tool_result id=\"#{msg.tool_call_id}\">\n"), *message_blocks(msg), text("\n</tool_result>\n")]
64
+ when :assistant
65
+ calls = (msg.tool_calls || {}).values.map do |call|
66
+ text(%(\n<tool_call id="#{call.id}" name="#{call.name}">#{JSON.generate(call.arguments)}</tool_call>))
67
+ end
68
+ [text("<assistant>\n"), *message_blocks(msg), *calls, text("\n</assistant>\n")]
69
+ else
70
+ [text("<user>\n"), *message_blocks(msg), text("\n</user>\n")]
71
+ end
72
+ end
73
+
74
+ def message_blocks(msg)
75
+ parts = []
76
+ body = content_string(msg.content)
77
+ parts << text(body) unless body.empty?
78
+ msg.attachments.each { |attachment| parts.concat(attachment_blocks(attachment)) }
79
+ parts
80
+ end
81
+
82
+ def text_of(msg)
83
+ [content_string(msg.content), *msg.attachments.map { |a| a.text? ? a.for_llm : '' }].join("\n").strip
84
+ end
85
+
86
+ def content_string(content) = content.to_s
87
+
88
+ def attachment_blocks(attachment)
89
+ if (converted = convert(attachment))
90
+ [text("<file name='#{attachment.filename}' mime_type='#{attachment.mime_type}' " \
91
+ "note='converted to text'>#{converted}</file>")]
92
+ elsif attachment.image? && INLINE_IMAGE_TYPES.include?(attachment.mime_type) &&
93
+ attachment.content.bytesize <= MAX_INLINE_IMAGE
94
+ [{ type: 'image', source: { type: 'base64', media_type: attachment.mime_type, data: attachment.encoded } }]
95
+ elsif attachment.pdf? && attachment.content.bytesize <= MAX_INLINE_PDF
96
+ [{ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: attachment.encoded },
97
+ title: attachment.filename }.compact]
98
+ elsif attachment.text?
99
+ [text(attachment.for_llm)]
100
+ elsif (office = office_text(attachment))
101
+ [text("<file name='#{attachment.filename}' mime_type='#{attachment.mime_type}' " \
102
+ "note='text extracted from the Office file'>#{office}</file>")]
103
+ else
104
+ [text(stage(attachment))]
105
+ end
106
+ end
107
+
108
+ # config.claude_cli_attachment_converter = ->(attachment) { ... }
109
+ # returns text for an attachment (e.g. a transcript of audio made with
110
+ # RubyLLM.transcribe on another provider), or nil to fall through.
111
+ def convert(attachment)
112
+ converter = @config&.claude_cli_attachment_converter
113
+ result = converter&.call(attachment)
114
+ result.nil? || result.to_s.empty? ? nil : result.to_s
115
+ end
116
+
117
+ def office_text(attachment)
118
+ pattern = OFFICE_TEXT_PARTS[attachment.extension]
119
+ return unless pattern && zip_available?
120
+
121
+ require 'stringio'
122
+ texts = []
123
+ Zip::File.open_buffer(StringIO.new(attachment.content)) do |zip|
124
+ zip.entries.select { |e| e.name.match?(pattern) }.sort_by { |e| e.name.scan(/\d+/).map(&:to_i) }.each do |e|
125
+ texts << xml_to_text(e.get_input_stream.read)
126
+ end
127
+ end
128
+ joined = texts.join("\n\n").strip
129
+ joined.empty? ? nil : joined
130
+ rescue StandardError => e
131
+ RubyLLM.logger.debug { "claude-cli: office text extraction failed: #{e.message}" }
132
+ nil
133
+ end
134
+
135
+ def xml_to_text(xml)
136
+ xml.force_encoding('UTF-8')
137
+ .gsub(%r{</w:p>|</a:p>|</row>|<w:br/>}, "\n").gsub(%r{</c>|<w:tab/>}, "\t")
138
+ .gsub(/<[^>]+>/, '')
139
+ .gsub('&lt;', '<').gsub('&gt;', '>').gsub('&quot;', '"').gsub('&apos;', "'").gsub('&amp;', '&')
140
+ .gsub(/\n{3,}/, "\n\n")
141
+ end
142
+
143
+ def zip_available?
144
+ return @zip_available unless @zip_available.nil?
145
+
146
+ @zip_available = begin
147
+ require 'zip'
148
+ true
149
+ rescue LoadError
150
+ false
151
+ end
152
+ end
153
+
154
+ def stage(attachment)
155
+ dir = File.join(@workdir, 'attachments')
156
+ FileUtils.mkdir_p(dir)
157
+ name = File.basename(attachment.filename || "file#{@staged_files.size + 1}")
158
+ name = "#{@staged_files.size + 1}-#{name}" if File.exist?(File.join(dir, name))
159
+ path = File.join(dir, name)
160
+ File.binwrite(path, attachment.content)
161
+ @staged_files << path
162
+ size = File.size(path)
163
+ note = "[Attached file '#{name}' (#{attachment.mime_type}, #{size} bytes) is saved at #{path}. "
164
+ note += if attachment.image? || attachment.pdf?
165
+ 'It is too large to inline; open it with the Read tool.]'
166
+ else
167
+ 'The Read tool can open it if it is text. Its first bytes as hex, with printable ' \
168
+ "strings, are:\n#{hex_preview(attachment.content)}\n" \
169
+ 'If the format cannot be understood from this, say so.]'
170
+ end
171
+ note
172
+ end
173
+
174
+ def hex_preview(bytes, limit = 512)
175
+ head = bytes.byteslice(0, limit).b
176
+ lines = head.bytes.each_slice(16).map do |row|
177
+ hex = row.map { |b| format('%02x', b) }.join(' ')
178
+ "#{hex.ljust(47)} #{row.map { |b| b.between?(32, 126) ? b.chr : '.' }.join}"
179
+ end
180
+ strings = bytes.b.scan(/[\x20-\x7e]{6,}/n).first(40).join(' | ')
181
+ "#{lines.join("\n")}\nstrings: #{strings}"
182
+ end
183
+
184
+ def text(value) = { type: 'text', text: value }
185
+
186
+ def merge_texts(blocks)
187
+ blocks.each_with_object([]) do |block, out|
188
+ if block[:type] == 'text' && out.last && out.last[:type] == 'text'
189
+ out[-1] = text(out.last[:text] + block[:text])
190
+ else
191
+ out << block
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ module RubyLLM
8
+ module ClaudeCLI
9
+ # Reuses the Anthropic protocol's response and stream parsing, and swaps
10
+ # the HTTP request for a `claude -p` process. The CLI's stream-json output
11
+ # carries raw Messages API events, so build_chunk reads them as-is.
12
+ class Protocol < Protocols::Anthropic
13
+ EFFORTS = %w[low medium high xhigh max].freeze
14
+ DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.'
15
+
16
+ # The payload stays a plain description of the request; the CLI
17
+ # arguments are built when it runs, next to its work directory.
18
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
19
+ schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil) # rubocop:disable Lint/UnusedMethodArgument
20
+ if temperature || max_output_tokens
21
+ RubyLLM.logger.debug { 'claude-cli: temperature and max_output_tokens are not supported, ignoring' }
22
+ end
23
+ { model: model.id, messages: messages, tools: tools, tool_prefs: tool_prefs || {},
24
+ schema: schema, thinking: thinking, stream: stream }
25
+ end
26
+
27
+ def count_tokens(*, **)
28
+ raise RubyLLM::Error, 'claude_cli does not support token counting'
29
+ end
30
+
31
+ def preprocess_message(message)
32
+ message
33
+ end
34
+
35
+ private
36
+
37
+ def auto_upload_large_files? = false
38
+ def supports_provider_file_references? = false
39
+
40
+ def sync_response(payload, _headers = {})
41
+ run_cli(payload)
42
+ end
43
+
44
+ def stream_response(payload, _headers = {}, &)
45
+ run_cli(payload, &)
46
+ end
47
+
48
+ def run_cli(payload, &block)
49
+ workdir = make_workdir
50
+ request, emulate = build_request(payload, workdir, stream: !block.nil?)
51
+ model_id = nil
52
+ @cli_blocks = {}
53
+
54
+ result = Runner.new(@config).run(request) do |event|
55
+ model_id ||= event.dig('message', 'model') if event['type'] == 'assistant'
56
+ forward_stream_event(event, &block) if request.stream
57
+ end
58
+
59
+ message = parse_completion_body(response_body(result, payload, emulate, model_id), raw: result)
60
+ block&.call(Chunk.new(role: :assistant, content: message.content, model: message.model)) unless request.stream
61
+ message
62
+ ensure
63
+ FileUtils.rm_rf(workdir) if workdir && !@config.claude_cli_workdir
64
+ end
65
+
66
+ def build_request(payload, workdir, stream:)
67
+ content = Content.new(workdir, @config)
68
+ system_messages, chat_messages = payload[:messages].partition { |m| m.role == :system }
69
+ blocks = content.blocks(chat_messages)
70
+
71
+ system_prompt = content.system_prompt(system_messages)
72
+ system_prompt = @config.claude_cli_default_system_prompt || DEFAULT_SYSTEM_PROMPT if system_prompt.empty?
73
+
74
+ tools = ToolEmulation.active_tools(payload[:tools] || {}, payload[:tool_prefs])
75
+ emulate = tools.any?
76
+ json_schema = if emulate
77
+ system_prompt = "#{system_prompt}\n\n#{ToolEmulation.instructions(tools, payload[:tool_prefs])}"
78
+ ToolEmulation.schema(tools, payload[:tool_prefs], payload[:schema])
79
+ elsif payload[:schema]
80
+ ToolEmulation.user_schema_body(payload[:schema])
81
+ end
82
+
83
+ native = Array(@config.claude_cli_native_tools).map(&:to_s)
84
+ native |= ['Read'] if content.staged_files.any?
85
+
86
+ request = Runner::Request.new(
87
+ model: payload[:model], system_prompt:, content: blocks, json_schema:,
88
+ effort: effort(payload[:thinking]),
89
+ # Structured replies stream as StructuredOutput tool input, not text.
90
+ stream: stream && json_schema.nil?,
91
+ tools: native, add_dirs: [], workdir:
92
+ )
93
+ [request, emulate]
94
+ end
95
+
96
+ def effort(thinking)
97
+ value = thinking.respond_to?(:effort) ? thinking.effort.to_s : ''
98
+ EFFORTS.include?(value) ? value : nil
99
+ end
100
+
101
+ def make_workdir
102
+ base = @config.claude_cli_workdir
103
+ return Dir.mktmpdir('ruby_llm_claude_cli') unless base
104
+
105
+ FileUtils.mkdir_p(base)
106
+ base
107
+ end
108
+
109
+ # Only text from the top-level agent reaches the caller: tool_use
110
+ # blocks of native CLI tools would otherwise look like ruby_llm tool
111
+ # calls.
112
+ def forward_stream_event(event)
113
+ return unless event['type'] == 'stream_event' && event['parent_tool_use_id'].nil?
114
+
115
+ data = event['event']
116
+ case data['type']
117
+ when 'message_start'
118
+ @cli_blocks = {}
119
+ when 'content_block_start'
120
+ @cli_blocks[data['index']] = data.dig('content_block', 'type')
121
+ return unless @cli_blocks[data['index']] == 'text'
122
+ when 'content_block_delta', 'content_block_stop'
123
+ return unless @cli_blocks[data['index']] == 'text'
124
+ when 'message_delta', 'message_stop'
125
+ nil
126
+ else
127
+ return
128
+ end
129
+ yield build_chunk(data)
130
+ end
131
+
132
+ def response_body(result, payload, emulate, model_id)
133
+ blocks = if emulate
134
+ ToolEmulation.blocks(result['structured_output'], payload[:schema])
135
+ elsif payload[:schema]
136
+ [{ 'type' => 'text', 'text' => JSON.generate(result['structured_output']) }]
137
+ else
138
+ [{ 'type' => 'text', 'text' => result['result'].to_s }]
139
+ end
140
+ stop = if blocks.any? { |b| b['type'] == 'tool_use' } then 'tool_use'
141
+ elsif result['stop_reason'] == 'tool_use' then 'end_turn'
142
+ else result['stop_reason']
143
+ end
144
+ { 'content' => blocks, 'usage' => result['usage'] || {}, 'stop_reason' => stop,
145
+ 'model' => model_id || payload[:model] }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ClaudeCLI
5
+ # Runs chats through the local `claude -p` command instead of the HTTP API,
6
+ # so requests use whatever login Claude Code has (subscription or key).
7
+ #
8
+ # require 'ruby_llm/claude_cli'
9
+ # RubyLLM.chat(model: 'sonnet', provider: :claude_cli).ask('Hi')
10
+ class Provider < RubyLLM::Provider
11
+ protocol :claude_cli, ClaudeCLI::Protocol
12
+
13
+ # Never contacted; the transport layer just needs a base URL.
14
+ def api_base
15
+ 'http://claude-cli.invalid'
16
+ end
17
+
18
+ def self.display_name = 'ClaudeCLI'
19
+
20
+ # Model ids are passed straight to --model: aliases (sonnet, opus,
21
+ # haiku) or full ids such as claude-opus-5-5.
22
+ def self.assume_models_exist? = true
23
+
24
+ def self.local? = true
25
+
26
+ def self.configuration_options
27
+ %i[
28
+ claude_cli_command
29
+ claude_cli_workdir
30
+ claude_cli_native_tools
31
+ claude_cli_extra_args
32
+ claude_cli_timeout
33
+ claude_cli_default_system_prompt
34
+ claude_cli_attachment_converter
35
+ ]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'timeout'
6
+
7
+ module RubyLLM
8
+ module ClaudeCLI
9
+ # Starts one `claude -p` process, feeds it a stream-json user message and
10
+ # yields every JSON event it prints. Returns the final "result" event.
11
+ class Runner
12
+ Request = Struct.new(:model, :system_prompt, :content, :json_schema, :effort,
13
+ :stream, :tools, :add_dirs, :workdir, keyword_init: true)
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def run(request)
20
+ args = build_args(request)
21
+ RubyLLM.logger.debug { "claude-cli: #{args.reject { |a| a.length > 200 }.join(' ')}" }
22
+ input = JSON.generate({ type: 'user', message: { role: 'user', content: request.content } })
23
+
24
+ Open3.popen3(*args, chdir: request.workdir) do |stdin, stdout, stderr, wait|
25
+ err_reader = Thread.new { stderr.read }
26
+ stdin.binmode.write(input, "\n")
27
+ stdin.close
28
+
29
+ result = with_timeout(wait) { read_events(stdout) { |event| yield event if block_given? } }
30
+ status = wait.value
31
+ check!(result, status, err_reader.value)
32
+ end
33
+ end
34
+
35
+ def build_args(request)
36
+ args = [command, '-p',
37
+ '--input-format', 'stream-json',
38
+ '--output-format', 'stream-json', '--verbose',
39
+ '--model', request.model,
40
+ '--system-prompt', request.system_prompt,
41
+ '--tools', request.tools.join(','),
42
+ '--no-session-persistence',
43
+ '--setting-sources', '',
44
+ '--strict-mcp-config',
45
+ '--disable-slash-commands']
46
+ args << '--include-partial-messages' if request.stream
47
+ args.push('--allowedTools', request.tools.join(',')) if request.tools.any?
48
+ request.add_dirs.each { |dir| args.push('--add-dir', dir) }
49
+ args.push('--json-schema', JSON.generate(request.json_schema)) if request.json_schema
50
+ args.push('--effort', request.effort) if request.effort
51
+ args.concat(Array(@config.claude_cli_extra_args))
52
+ end
53
+
54
+ private
55
+
56
+ def command
57
+ @config.claude_cli_command || 'claude'
58
+ end
59
+
60
+ def read_events(stdout)
61
+ result = nil
62
+ stdout.each_line do |line|
63
+ next if line.strip.empty?
64
+
65
+ event = begin
66
+ JSON.parse(line)
67
+ rescue JSON::ParserError
68
+ RubyLLM.logger.debug { "claude-cli: non-JSON output #{line.inspect}" }
69
+ next
70
+ end
71
+ result = event if event['type'] == 'result'
72
+ yield event
73
+ end
74
+ result
75
+ end
76
+
77
+ def with_timeout(wait, &)
78
+ seconds = @config.claude_cli_timeout
79
+ return yield unless seconds
80
+
81
+ Timeout.timeout(seconds, &)
82
+ rescue Timeout::Error
83
+ Process.kill('KILL', wait.pid) rescue nil # rubocop:disable Style/RescueModifier
84
+ raise RubyLLM::Error, "claude -p did not finish within #{seconds}s"
85
+ end
86
+
87
+ def check!(result, status, stderr)
88
+ if result.nil?
89
+ raise RubyLLM::Error, "claude -p exited (#{status.exitstatus}) without a result: #{stderr.strip}"
90
+ end
91
+ if result['is_error']
92
+ detail = result['result'] || result['errors']&.join('; ') || result['subtype']
93
+ raise RubyLLM::Error, "claude -p failed: #{detail}"
94
+ end
95
+
96
+ result
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module RubyLLM
7
+ module ClaudeCLI
8
+ # `claude -p` cannot call back into Ruby, so ruby_llm tools are emulated:
9
+ # the tools are described in the system prompt and the reply is forced
10
+ # through --json-schema into {text, tool_calls}. Tool calls come back to
11
+ # ruby_llm as ordinary ToolCalls, ruby_llm runs them, and the results go
12
+ # into the next request's transcript.
13
+ module ToolEmulation
14
+ module_function
15
+
16
+ def active_tools(tools, tool_prefs)
17
+ return [] if tool_prefs[:choice] == :none
18
+
19
+ list = tools.values
20
+ choice = tool_prefs[:choice]
21
+ list = list.select { |t| t.name.to_s == choice.to_s } unless choice.nil? || %i[auto required].include?(choice)
22
+ list
23
+ end
24
+
25
+ def instructions(tools, tool_prefs)
26
+ specs = tools.map do |tool|
27
+ fn = Protocols::Anthropic::Tools.function_for(tool)
28
+ "- #{fn[:name]}: #{fn[:description]}\n input schema: #{JSON.generate(fn[:input_schema])}"
29
+ end
30
+ rule = if tool_prefs[:choice] && tool_prefs[:choice] != :auto
31
+ 'You must call at least one tool in this reply.'
32
+ else
33
+ 'Call tools only when they help; otherwise leave tool_calls empty.'
34
+ end
35
+ rule += ' Call at most one tool per reply.' if tool_prefs[:calls] == :one
36
+
37
+ <<~TEXT
38
+ # Tools
39
+ You can use these tools, which run on the caller's side:
40
+ #{specs.join("\n")}
41
+
42
+ To call tools, put them in "tool_calls" of your structured reply (name + arguments). You will
43
+ see their output in a later turn as <tool_result> blocks; never invent results yourself.
44
+ Put any text for the user in "text". #{rule}
45
+ TEXT
46
+ end
47
+
48
+ def schema(tools, tool_prefs, user_schema)
49
+ variants = tools.map do |tool|
50
+ fn = Protocols::Anthropic::Tools.function_for(tool)
51
+ { type: 'object',
52
+ properties: { name: { type: 'string', enum: [fn[:name]] }, arguments: clean(fn[:input_schema]) },
53
+ required: %w[name arguments] }
54
+ end
55
+ calls = { type: 'array', items: variants.one? ? variants.first : { anyOf: variants } }
56
+ calls[:minItems] = 1 if tool_prefs[:choice] && tool_prefs[:choice] != :auto
57
+ calls[:maxItems] = 1 if tool_prefs[:calls] == :one
58
+
59
+ properties = { text: { type: 'string' }, tool_calls: calls }
60
+ properties[:final] = user_schema_body(user_schema) if user_schema
61
+ { type: 'object', properties: properties, required: %w[text tool_calls] }
62
+ end
63
+
64
+ def user_schema_body(user_schema)
65
+ clean(user_schema[:schema])
66
+ end
67
+
68
+ # The CLI validates --json-schema strictly and rejects OpenAI's "strict".
69
+ def clean(schema)
70
+ case schema
71
+ when Hash then schema.reject { |k, _| k.to_s == 'strict' }.transform_values { |v| clean(v) }
72
+ when Array then schema.map { |v| clean(v) }
73
+ else schema
74
+ end
75
+ end
76
+
77
+ # Returns Anthropic-style content blocks for the structured reply.
78
+ def blocks(structured, user_schema)
79
+ structured ||= {}
80
+ blocks = []
81
+ text = if user_schema && structured['final'] && Array(structured['tool_calls']).empty?
82
+ JSON.generate(structured['final'])
83
+ else
84
+ structured['text'].to_s
85
+ end
86
+ blocks << { 'type' => 'text', 'text' => text } unless text.empty?
87
+ Array(structured['tool_calls']).each do |call|
88
+ blocks << { 'type' => 'tool_use', 'id' => "toolu_cli_#{SecureRandom.hex(10)}",
89
+ 'name' => call['name'], 'input' => call['arguments'] || {} }
90
+ end
91
+ blocks
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ClaudeCLI
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require_relative 'claude_cli/version'
5
+ require_relative 'claude_cli/runner'
6
+ require_relative 'claude_cli/content'
7
+ require_relative 'claude_cli/tool_emulation'
8
+ require_relative 'claude_cli/protocol'
9
+ require_relative 'claude_cli/provider'
10
+
11
+ RubyLLM::Provider.register :claude_cli, RubyLLM::ClaudeCLI::Provider
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-claude_cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andreas Idogawa
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
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: 'Use Claude Code (and its subscription login) as a RubyLLM provider:
27
+ streaming, images, PDFs, Office files, structured output and emulated tool calls
28
+ over `claude -p`.'
29
+ email:
30
+ - web@idogawa.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - lib/ruby_llm/claude_cli.rb
39
+ - lib/ruby_llm/claude_cli/content.rb
40
+ - lib/ruby_llm/claude_cli/protocol.rb
41
+ - lib/ruby_llm/claude_cli/provider.rb
42
+ - lib/ruby_llm/claude_cli/runner.rb
43
+ - lib/ruby_llm/claude_cli/tool_emulation.rb
44
+ - lib/ruby_llm/claude_cli/version.rb
45
+ homepage: https://github.com/Largo/ruby_llm-claude_cli
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ source_code_uri: https://github.com/Largo/ruby_llm-claude_cli
50
+ changelog_uri: https://github.com/Largo/ruby_llm-claude_cli/blob/main/CHANGELOG.md
51
+ bug_tracker_uri: https://github.com/Largo/ruby_llm-claude_cli/issues
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.2'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.20
68
+ specification_version: 4
69
+ summary: RubyLLM provider that runs chats through the local `claude -p` CLI
70
+ test_files: []