anima-core 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +20 -32
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +220 -26
- data/app/decorators/agent_message_decorator.rb +24 -0
- data/app/decorators/application_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +173 -0
- data/app/decorators/system_message_decorator.rb +21 -0
- data/app/decorators/tool_call_decorator.rb +48 -0
- data/app/decorators/tool_response_decorator.rb +37 -0
- data/app/decorators/user_message_decorator.rb +35 -0
- data/app/jobs/agent_request_job.rb +31 -2
- data/app/jobs/count_event_tokens_job.rb +14 -3
- data/app/models/concerns/event/broadcasting.rb +63 -0
- data/app/models/event.rb +36 -0
- data/app/models/session.rb +46 -14
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/cable_schema.rb +14 -2
- data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/lib/agent_loop.rb +5 -2
- data/lib/anima/cli.rb +1 -40
- data/lib/anima/version.rb +1 -1
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/providers/anthropic.rb +3 -13
- data/lib/tools/edit.rb +227 -0
- data/lib/tools/read.rb +152 -0
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +831 -55
- data/lib/tui/cable_client.rb +79 -31
- data/lib/tui/input_buffer.rb +181 -0
- data/lib/tui/message_store.rb +162 -14
- data/lib/tui/screens/chat.rb +504 -75
- metadata +30 -5
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -35
- data/lib/tui/screens/anthropic.rb +0 -25
- data/lib/tui/screens/settings.rb +0 -52
data/lib/providers/anthropic.rb
CHANGED
|
@@ -25,21 +25,11 @@ module Providers
|
|
|
25
25
|
class ServerError < TransientError; end
|
|
26
26
|
|
|
27
27
|
class << self
|
|
28
|
-
def validate!
|
|
29
|
-
token = fetch_token
|
|
30
|
-
validate_token_format!(token)
|
|
31
|
-
validate_token_api!(token)
|
|
32
|
-
true
|
|
33
|
-
end
|
|
34
|
-
|
|
35
28
|
def fetch_token
|
|
36
29
|
token = Rails.application.credentials.dig(:anthropic, :subscription_token)
|
|
37
30
|
raise AuthenticationError, <<~MSG.strip if token.blank?
|
|
38
31
|
No Anthropic subscription token found in credentials.
|
|
39
|
-
|
|
40
|
-
Add:
|
|
41
|
-
anthropic:
|
|
42
|
-
subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
|
|
32
|
+
Use the TUI token setup (Ctrl+a → a) to configure your token.
|
|
43
33
|
MSG
|
|
44
34
|
token
|
|
45
35
|
end
|
|
@@ -123,7 +113,7 @@ module Providers
|
|
|
123
113
|
true
|
|
124
114
|
when 401
|
|
125
115
|
raise AuthenticationError,
|
|
126
|
-
"Token rejected by Anthropic API (401). Re-run `claude setup-token` and
|
|
116
|
+
"Token rejected by Anthropic API (401). Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
|
|
127
117
|
when 403
|
|
128
118
|
raise AuthenticationError,
|
|
129
119
|
"Token not authorized for API access (403). This credential may be restricted to Claude Code only."
|
|
@@ -151,7 +141,7 @@ module Providers
|
|
|
151
141
|
raise Error, "Bad request: #{error_message(response)}"
|
|
152
142
|
when 401
|
|
153
143
|
raise AuthenticationError,
|
|
154
|
-
"Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and
|
|
144
|
+
"Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
|
|
155
145
|
when 403
|
|
156
146
|
raise AuthenticationError,
|
|
157
147
|
"Forbidden (403): #{error_message(response)}"
|
data/lib/tools/edit.rb
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Performs surgical text replacement with uniqueness constraint.
|
|
5
|
+
# Finds old_text in the file (must match exactly one location), replaces
|
|
6
|
+
# with new_text, and returns a unified diff. Falls back to
|
|
7
|
+
# whitespace-normalized fuzzy matching when exact match fails.
|
|
8
|
+
#
|
|
9
|
+
# Normalizes BOM and CRLF line endings for matching, restoring them after
|
|
10
|
+
# the edit. Rejects ambiguous edits where old_text matches zero or
|
|
11
|
+
# multiple locations.
|
|
12
|
+
#
|
|
13
|
+
# @example Replacing a method body
|
|
14
|
+
# tool.execute("path" => "app.rb",
|
|
15
|
+
# "old_text" => "def greet\n 'hi'\nend",
|
|
16
|
+
# "new_text" => "def greet\n 'hello'\nend")
|
|
17
|
+
# # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
|
|
18
|
+
class Edit < Base
|
|
19
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
20
|
+
|
|
21
|
+
def self.tool_name = "edit"
|
|
22
|
+
|
|
23
|
+
def self.description = "Replace exact text in a file. old_text must match exactly one location; " \
|
|
24
|
+
"include surrounding lines for uniqueness. Use for surgical edits; " \
|
|
25
|
+
"use write for new files or full replacement."
|
|
26
|
+
|
|
27
|
+
def self.input_schema
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
|
|
32
|
+
old_text: {type: "string", description: "Exact text to find (must match exactly one location — include surrounding context if needed)"},
|
|
33
|
+
new_text: {type: "string", description: "Replacement text (empty string to delete)"}
|
|
34
|
+
},
|
|
35
|
+
required: %w[path old_text new_text]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
|
|
40
|
+
def initialize(shell_session: nil, **)
|
|
41
|
+
@working_directory = shell_session&.pwd
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
45
|
+
# @return [String] unified diff showing the change
|
|
46
|
+
# @return [Hash] with :error key on failure
|
|
47
|
+
def execute(input)
|
|
48
|
+
path, old_text, new_text = extract_params(input)
|
|
49
|
+
return {error: "Path cannot be blank"} if path.empty?
|
|
50
|
+
return {error: "old_text cannot be blank"} if old_text.empty?
|
|
51
|
+
|
|
52
|
+
path = resolve_path(path)
|
|
53
|
+
|
|
54
|
+
error = validate_file(path)
|
|
55
|
+
return error if error
|
|
56
|
+
|
|
57
|
+
edit_file(path, old_text, new_text)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def extract_params(input)
|
|
63
|
+
path = input["path"].to_s.strip
|
|
64
|
+
old_text = input["old_text"].to_s
|
|
65
|
+
new_text = input["new_text"].to_s
|
|
66
|
+
[path, old_text, new_text]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_path(path)
|
|
70
|
+
if @working_directory
|
|
71
|
+
File.expand_path(path, @working_directory)
|
|
72
|
+
else
|
|
73
|
+
File.expand_path(path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_file(path)
|
|
78
|
+
return {error: "File not found: #{path}"} unless File.exist?(path)
|
|
79
|
+
return {error: "Is a directory: #{path}"} if File.directory?(path)
|
|
80
|
+
return {error: "Permission denied: #{path}"} unless File.readable?(path) && File.writable?(path)
|
|
81
|
+
size = File.size(path)
|
|
82
|
+
if size > MAX_FILE_SIZE
|
|
83
|
+
{error: "File is #{size} bytes (#{size / 1_048_576} MB). " \
|
|
84
|
+
"Max editable size is #{MAX_FILE_SIZE / 1_048_576} MB. Use bash tool with sed instead."}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def edit_file(path, old_text, new_text)
|
|
89
|
+
raw = File.binread(path)
|
|
90
|
+
bom = extract_bom(raw)
|
|
91
|
+
content = raw[bom.length..].force_encoding("UTF-8")
|
|
92
|
+
had_crlf = content.include?("\r\n")
|
|
93
|
+
normalized = had_crlf ? content.gsub("\r\n", "\n") : content
|
|
94
|
+
|
|
95
|
+
match = find_unique_match(normalized, old_text, path)
|
|
96
|
+
return match if match.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
position, matched_text, fuzzy = match
|
|
99
|
+
new_content = normalized[0...position] + new_text + normalized[(position + matched_text.length)..]
|
|
100
|
+
|
|
101
|
+
if normalized == new_content
|
|
102
|
+
return {error: "old_text and new_text are identical. No changes made to #{path}."}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
output = had_crlf ? new_content.gsub("\n", "\r\n") : new_content
|
|
106
|
+
File.binwrite(path, bom + output.b)
|
|
107
|
+
|
|
108
|
+
build_diff(path, normalized, new_content, fuzzy)
|
|
109
|
+
rescue Errno::EACCES
|
|
110
|
+
{error: "Permission denied: #{path}"}
|
|
111
|
+
rescue Errno::ENOSPC
|
|
112
|
+
{error: "No space left on device: #{path}"}
|
|
113
|
+
rescue Errno::EROFS
|
|
114
|
+
{error: "Read-only file system: #{path}"}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [String] UTF-8 BOM bytes if present, empty binary string otherwise
|
|
118
|
+
def extract_bom(raw)
|
|
119
|
+
bytes = raw.b
|
|
120
|
+
bytes.start_with?("\xEF\xBB\xBF".b) ? bytes[0, 3] : "".b
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Finds exactly one match for old_text in content.
|
|
124
|
+
# Tries exact match first, then whitespace-normalized fuzzy match.
|
|
125
|
+
# @return [Array(Integer, String, Boolean)] position, matched text, fuzzy flag
|
|
126
|
+
# @return [Hash] error hash if zero or multiple matches found
|
|
127
|
+
def find_unique_match(content, old_text, path)
|
|
128
|
+
exact = find_all_positions(content, old_text)
|
|
129
|
+
return [exact[0], old_text, false] if exact.one?
|
|
130
|
+
return ambiguity_error(exact, content, path) if exact.length > 1
|
|
131
|
+
|
|
132
|
+
fuzzy = find_fuzzy_matches(content, old_text)
|
|
133
|
+
return [fuzzy[0][0], fuzzy[0][1], true] if fuzzy.one?
|
|
134
|
+
return ambiguity_error(fuzzy.map(&:first), content, path, fuzzy: true) if fuzzy.length > 1
|
|
135
|
+
|
|
136
|
+
{error: "Could not find old_text in #{path}. " \
|
|
137
|
+
"Verify the text exists and matches exactly (including whitespace). " \
|
|
138
|
+
"Use the read tool to check current file contents."}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ambiguity_error(positions, content, path, fuzzy: false)
|
|
142
|
+
kind = fuzzy ? "fuzzy matches" : "matches"
|
|
143
|
+
line_numbers = positions.map { |pos| line_number_at(content, pos) }
|
|
144
|
+
{error: "Found #{positions.length} #{kind} for old_text in #{path}. " \
|
|
145
|
+
"Provide more surrounding context to uniquely identify the location. " \
|
|
146
|
+
"Matches at lines: #{line_numbers.join(", ")}"}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def line_number_at(content, position)
|
|
150
|
+
content[0...position].count("\n") + 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_all_positions(content, text)
|
|
154
|
+
positions = []
|
|
155
|
+
offset = 0
|
|
156
|
+
while (pos = content.index(text, offset))
|
|
157
|
+
positions << pos
|
|
158
|
+
offset = pos + 1
|
|
159
|
+
end
|
|
160
|
+
positions
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Finds old_text in content using whitespace-normalized line comparison.
|
|
164
|
+
# @return [Array<Array(Integer, String)>] array of [position, matched_text] pairs
|
|
165
|
+
def find_fuzzy_matches(content, old_text)
|
|
166
|
+
content_lines = content.split("\n", -1)
|
|
167
|
+
search_lines = old_text.split("\n", -1)
|
|
168
|
+
search_lines.pop if search_lines.last&.empty? && old_text.end_with?("\n")
|
|
169
|
+
trailing_newline = old_text.end_with?("\n")
|
|
170
|
+
|
|
171
|
+
normalized_search = search_lines.map { |line| collapse_whitespace(line) }
|
|
172
|
+
return [] if normalized_search.all?(&:empty?)
|
|
173
|
+
|
|
174
|
+
window_size = search_lines.length
|
|
175
|
+
matches = []
|
|
176
|
+
(0..content_lines.length - window_size).each do |start_idx|
|
|
177
|
+
window = content_lines[start_idx, window_size]
|
|
178
|
+
next unless window.map { |line| collapse_whitespace(line) } == normalized_search
|
|
179
|
+
|
|
180
|
+
pos = start_idx.zero? ? 0 : content_lines[0...start_idx].sum { |line| line.length + 1 }
|
|
181
|
+
matched = window.join("\n")
|
|
182
|
+
matched += "\n" if trailing_newline
|
|
183
|
+
matches << [pos, matched]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
matches
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def collapse_whitespace(text)
|
|
190
|
+
text.gsub(/[[:blank:]]+/, " ").strip
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Generates a unified diff between old and new content with 3 lines of context.
|
|
194
|
+
DIFF_CONTEXT = 3
|
|
195
|
+
|
|
196
|
+
def build_diff(path, old_content, new_content, fuzzy)
|
|
197
|
+
before = old_content.lines(chomp: true)
|
|
198
|
+
after = new_content.lines(chomp: true)
|
|
199
|
+
|
|
200
|
+
first = 0
|
|
201
|
+
first += 1 while first < before.length && first < after.length && before[first] == after[first]
|
|
202
|
+
|
|
203
|
+
old_end = before.length - 1
|
|
204
|
+
new_end = after.length - 1
|
|
205
|
+
while old_end > first && new_end > first && before[old_end] == after[new_end]
|
|
206
|
+
old_end -= 1
|
|
207
|
+
new_end -= 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
ctx_start = [first - DIFF_CONTEXT, 0].max
|
|
211
|
+
old_ctx_end = [old_end + DIFF_CONTEXT, before.length - 1].min
|
|
212
|
+
new_ctx_end = [new_end + DIFF_CONTEXT, after.length - 1].min
|
|
213
|
+
|
|
214
|
+
hunk = []
|
|
215
|
+
hunk << "--- #{path}"
|
|
216
|
+
hunk << "+++ #{path}"
|
|
217
|
+
hunk << "@@ -#{ctx_start + 1},#{old_ctx_end - ctx_start + 1} +#{ctx_start + 1},#{new_ctx_end - ctx_start + 1} @@"
|
|
218
|
+
(ctx_start...first).each { |idx| hunk << " #{before[idx]}" }
|
|
219
|
+
(first..old_end).each { |idx| hunk << "-#{before[idx]}" }
|
|
220
|
+
(first..new_end).each { |idx| hunk << "+#{after[idx]}" }
|
|
221
|
+
((old_end + 1)..old_ctx_end).each { |idx| hunk << " #{before[idx]}" }
|
|
222
|
+
|
|
223
|
+
diff = hunk.join("\n")
|
|
224
|
+
fuzzy ? "(fuzzy match — whitespace differences were ignored)\n#{diff}" : diff
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/tools/read.rb
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Reads file contents with smart truncation and offset/limit paging.
|
|
5
|
+
# Returns plain text without line numbers, normalized to LF line endings.
|
|
6
|
+
#
|
|
7
|
+
# Truncation limits: `MAX_LINES` lines or `MAX_BYTES` bytes, whichever
|
|
8
|
+
# hits first. When truncated, appends a continuation hint with the next
|
|
9
|
+
# offset value so the agent can page through large files.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic read
|
|
12
|
+
# tool.execute("path" => "config/routes.rb")
|
|
13
|
+
# # => "Rails.application.routes.draw do\n ..."
|
|
14
|
+
#
|
|
15
|
+
# @example Paging through a large file
|
|
16
|
+
# tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
|
|
17
|
+
# # => "line 2001 content\n..."
|
|
18
|
+
class Read < Base
|
|
19
|
+
MAX_LINES = 2_000
|
|
20
|
+
MAX_BYTES = 50_000
|
|
21
|
+
|
|
22
|
+
def self.tool_name = "read"
|
|
23
|
+
|
|
24
|
+
def self.description = "Read file contents. Returns plain text with smart truncation. Use offset/limit to page through large files."
|
|
25
|
+
|
|
26
|
+
def self.input_schema
|
|
27
|
+
{
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
|
|
31
|
+
offset: {type: "integer", description: "1-indexed line number to start from (default: 1)"},
|
|
32
|
+
limit: {type: "integer", description: "Maximum number of lines to read (default: 2000, also limited by #{MAX_BYTES} byte cap)"}
|
|
33
|
+
},
|
|
34
|
+
required: ["path"]
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
|
|
39
|
+
def initialize(shell_session: nil, **)
|
|
40
|
+
@working_directory = shell_session&.pwd
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
44
|
+
# @return [String] file contents (possibly truncated with continuation hint)
|
|
45
|
+
# @return [Hash] with :error key on failure
|
|
46
|
+
def execute(input)
|
|
47
|
+
path, offset, limit = extract_params(input)
|
|
48
|
+
return {error: "Path cannot be blank"} if path.empty?
|
|
49
|
+
|
|
50
|
+
path = resolve_path(path)
|
|
51
|
+
|
|
52
|
+
error = validate_file(path)
|
|
53
|
+
return error if error
|
|
54
|
+
|
|
55
|
+
read_file(path, offset, limit)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def extract_params(input)
|
|
61
|
+
path = input["path"].to_s.strip
|
|
62
|
+
offset = [input["offset"].to_i, 1].max
|
|
63
|
+
raw_limit = input["limit"]
|
|
64
|
+
limit = raw_limit ? [raw_limit.to_i, 1].max : MAX_LINES
|
|
65
|
+
[path, offset, limit]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_path(path)
|
|
69
|
+
if @working_directory
|
|
70
|
+
File.expand_path(path, @working_directory)
|
|
71
|
+
else
|
|
72
|
+
File.expand_path(path)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_file(path)
|
|
77
|
+
return {error: "File not found: #{path}"} unless File.exist?(path)
|
|
78
|
+
return {error: "Is a directory: #{path}"} if File.directory?(path)
|
|
79
|
+
{error: "Permission denied: #{path}"} unless File.readable?(path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reads the file, normalizes line endings, and applies truncation limits.
|
|
83
|
+
# Two limits are enforced as first-hit-wins: line count and byte size.
|
|
84
|
+
# A single line exceeding `MAX_BYTES` is rejected outright (likely minified).
|
|
85
|
+
# Files larger than `MAX_READ_SIZE` are rejected to avoid memory exhaustion.
|
|
86
|
+
MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
87
|
+
|
|
88
|
+
def read_file(path, offset, limit)
|
|
89
|
+
file_size = File.size(path)
|
|
90
|
+
if file_size > MAX_READ_SIZE
|
|
91
|
+
return {error: "File is #{file_size} bytes (#{file_size / 1_048_576} MB). " \
|
|
92
|
+
"Max readable size is #{MAX_READ_SIZE / 1_048_576} MB. " \
|
|
93
|
+
"Use bash tool with: head -n #{offset + limit} #{path} | tail -n +#{offset}"}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lines = normalize(File.read(path))
|
|
97
|
+
return "" if lines.empty?
|
|
98
|
+
|
|
99
|
+
start_index = offset - 1
|
|
100
|
+
return "[File has #{lines.size} lines. Offset #{offset} is beyond end of file.]" if start_index >= lines.size
|
|
101
|
+
|
|
102
|
+
window = lines[start_index, [limit, MAX_LINES].min]
|
|
103
|
+
|
|
104
|
+
error = check_oversized_lines(window, offset, path)
|
|
105
|
+
return error if error
|
|
106
|
+
|
|
107
|
+
build_output(window, lines.size, offset)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize(content)
|
|
111
|
+
content.gsub("\r\n", "\n").lines
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check_oversized_lines(window, offset, path)
|
|
115
|
+
index = window.index { |line| line.bytesize > MAX_BYTES }
|
|
116
|
+
return unless index
|
|
117
|
+
|
|
118
|
+
line_num = offset + index
|
|
119
|
+
{error: "Line #{line_num} exceeds #{MAX_BYTES} bytes (likely minified). " \
|
|
120
|
+
"Use bash tool with: sed -n '#{line_num}p' #{path}"}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_output(window, total_lines, offset)
|
|
124
|
+
text, count = accumulate_lines(window)
|
|
125
|
+
end_line = offset + count - 1
|
|
126
|
+
|
|
127
|
+
if end_line < total_lines
|
|
128
|
+
text + "\n\n[Showing lines #{offset}-#{end_line} of #{total_lines}. Use offset=#{end_line + 1} to continue.]"
|
|
129
|
+
else
|
|
130
|
+
text
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Accumulates lines until `MAX_BYTES` would be exceeded.
|
|
135
|
+
# @return [Array(String, Integer)] accumulated text and number of lines included
|
|
136
|
+
def accumulate_lines(window)
|
|
137
|
+
output = +""
|
|
138
|
+
bytes = 0
|
|
139
|
+
count = 0
|
|
140
|
+
|
|
141
|
+
window.each_with_index do |line, index|
|
|
142
|
+
break if bytes + line.bytesize > MAX_BYTES && index > 0
|
|
143
|
+
|
|
144
|
+
output << line
|
|
145
|
+
bytes += line.bytesize
|
|
146
|
+
count += 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
[output, count]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/tools/write.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Tools
|
|
6
|
+
# Creates or overwrites files with automatic intermediate directory creation.
|
|
7
|
+
# Writes content exactly as given — no line ending normalization, no BOM
|
|
8
|
+
# handling. Full replacement only; no append or merge.
|
|
9
|
+
#
|
|
10
|
+
# @example Creating a new file
|
|
11
|
+
# tool.execute("path" => "config/new.yml", "content" => "key: value\n")
|
|
12
|
+
# # => "Wrote 11 bytes to /home/user/project/config/new.yml"
|
|
13
|
+
#
|
|
14
|
+
# @example Overwriting an existing file
|
|
15
|
+
# tool.execute("path" => "README.md", "content" => "# Title\n")
|
|
16
|
+
# # => "Wrote 9 bytes to /home/user/project/README.md"
|
|
17
|
+
class Write < Base
|
|
18
|
+
def self.tool_name = "write"
|
|
19
|
+
|
|
20
|
+
def self.description = "Create or overwrite a file. Creates intermediate directories automatically. Use for new files or full replacement."
|
|
21
|
+
|
|
22
|
+
def self.input_schema
|
|
23
|
+
{
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
|
|
27
|
+
content: {type: "string", description: "Full file content to write"}
|
|
28
|
+
},
|
|
29
|
+
required: %w[path content]
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
|
|
34
|
+
def initialize(shell_session: nil, **)
|
|
35
|
+
@working_directory = shell_session&.pwd
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
39
|
+
# @return [String] confirmation with bytes written and resolved path
|
|
40
|
+
# @return [Hash] with :error key on failure
|
|
41
|
+
def execute(input)
|
|
42
|
+
path, content = extract_params(input)
|
|
43
|
+
return {error: "Path cannot be blank"} if path.empty?
|
|
44
|
+
|
|
45
|
+
path = resolve_path(path)
|
|
46
|
+
|
|
47
|
+
error = validate_target(path)
|
|
48
|
+
return error if error
|
|
49
|
+
|
|
50
|
+
write_file(path, content)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def extract_params(input)
|
|
56
|
+
path = input["path"].to_s.strip
|
|
57
|
+
content = input["content"].to_s
|
|
58
|
+
[path, content]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_path(path)
|
|
62
|
+
if @working_directory
|
|
63
|
+
File.expand_path(path, @working_directory)
|
|
64
|
+
else
|
|
65
|
+
File.expand_path(path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_target(path)
|
|
70
|
+
return {error: "Is a directory: #{path}"} if File.directory?(path)
|
|
71
|
+
{error: "Not writable: #{path}"} if File.exist?(path) && !File.writable?(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def write_file(path, content)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
76
|
+
bytes = File.write(path, content)
|
|
77
|
+
"Wrote #{bytes} bytes to #{path}"
|
|
78
|
+
rescue Errno::EACCES
|
|
79
|
+
{error: "Permission denied: #{path}"}
|
|
80
|
+
rescue Errno::ENOSPC
|
|
81
|
+
{error: "No space left on device: #{path}"}
|
|
82
|
+
rescue Errno::EROFS
|
|
83
|
+
{error: "Read-only file system: #{path}"}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|