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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +86 -0
- data/lib/ruby_llm/claude_cli/content.rb +197 -0
- data/lib/ruby_llm/claude_cli/protocol.rb +149 -0
- data/lib/ruby_llm/claude_cli/provider.rb +39 -0
- data/lib/ruby_llm/claude_cli/runner.rb +100 -0
- data/lib/ruby_llm/claude_cli/tool_emulation.rb +95 -0
- data/lib/ruby_llm/claude_cli/version.rb +7 -0
- data/lib/ruby_llm/claude_cli.rb +11 -0
- metadata +70 -0
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('<', '<').gsub('>', '>').gsub('"', '"').gsub(''', "'").gsub('&', '&')
|
|
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,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: []
|