openclacky 0.6.1 → 0.6.3
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 +26 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +557 -122
- data/lib/clacky/cli.rb +431 -3
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/file_reader.rb +245 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +53 -17
- data/lib/clacky/tools/shell.rb +109 -5
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +279 -141
- data/lib/clacky/ui2/layout_manager.rb +147 -67
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +3 -3
- data/lib/clacky/ui2/themes/minimal_theme.rb +3 -3
- data/lib/clacky/ui2/ui_controller.rb +80 -29
- data/lib/clacky/ui2.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +7 -2
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/utils/file_processor.rb +201 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +249 -0
- data/scripts/uninstall.sh +146 -0
- metadata +10 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../utils/file_processor"
|
|
4
5
|
|
|
5
6
|
module Clacky
|
|
6
7
|
module Tools
|
|
@@ -17,14 +18,28 @@ module Clacky
|
|
|
17
18
|
},
|
|
18
19
|
max_lines: {
|
|
19
20
|
type: "integer",
|
|
20
|
-
description: "Maximum number of lines to read (
|
|
21
|
-
default:
|
|
21
|
+
description: "Maximum number of lines to read from start (default: 500)",
|
|
22
|
+
default: 500
|
|
23
|
+
},
|
|
24
|
+
keyword: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Search keyword and return matching lines with context (recommended for large files)"
|
|
27
|
+
},
|
|
28
|
+
start_line: {
|
|
29
|
+
type: "integer",
|
|
30
|
+
description: "Start line number (1-indexed, e.g., 100 reads from line 100)"
|
|
31
|
+
},
|
|
32
|
+
end_line: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "End line number (1-indexed, e.g., 200 reads up to line 200)"
|
|
22
35
|
}
|
|
23
36
|
},
|
|
24
37
|
required: ["path"]
|
|
25
38
|
}
|
|
39
|
+
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
|
|
42
|
+
def execute(path:, max_lines: 500, keyword: nil, start_line: nil, end_line: nil)
|
|
28
43
|
# Expand ~ to home directory
|
|
29
44
|
expanded_path = File.expand_path(path)
|
|
30
45
|
|
|
@@ -50,15 +65,67 @@ module Clacky
|
|
|
50
65
|
end
|
|
51
66
|
|
|
52
67
|
begin
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
# Check if file is binary
|
|
69
|
+
if binary_file?(expanded_path)
|
|
70
|
+
return handle_binary_file(expanded_path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Handle keyword search with context
|
|
74
|
+
if keyword && !keyword.empty?
|
|
75
|
+
return find_with_context(expanded_path, keyword)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Read text file with optional line range
|
|
79
|
+
all_lines = File.readlines(expanded_path)
|
|
80
|
+
total_lines = all_lines.size
|
|
81
|
+
|
|
82
|
+
# Calculate start index (convert 1-indexed to 0-indexed)
|
|
83
|
+
start_idx = start_line ? [start_line - 1, 0].max : 0
|
|
84
|
+
|
|
85
|
+
# Calculate end index based on parameters
|
|
86
|
+
if end_line
|
|
87
|
+
# User specified end_line directly
|
|
88
|
+
end_idx = [end_line - 1, total_lines - 1].min
|
|
89
|
+
elsif start_line
|
|
90
|
+
# start_line + max_lines - 1 (relative to start_line, inclusive)
|
|
91
|
+
calculated_end_line = start_line + max_lines - 1
|
|
92
|
+
end_idx = [calculated_end_line - 1, total_lines - 1].min
|
|
93
|
+
else
|
|
94
|
+
# Read from beginning with max_lines limit
|
|
95
|
+
end_idx = [max_lines - 1, total_lines - 1].min
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if start_line exceeds file length first
|
|
99
|
+
if start_idx >= total_lines
|
|
100
|
+
return {
|
|
101
|
+
path: expanded_path,
|
|
102
|
+
content: nil,
|
|
103
|
+
lines_read: 0,
|
|
104
|
+
error: "Invalid line range: start_line #{start_line} exceeds total lines (#{total_lines})"
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validate range
|
|
109
|
+
if start_idx > end_idx
|
|
110
|
+
return {
|
|
111
|
+
path: expanded_path,
|
|
112
|
+
content: nil,
|
|
113
|
+
lines_read: 0,
|
|
114
|
+
error: "Invalid line range: start_line #{start_line} > end_line #{end_line || (start_line + max_lines)}"
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
lines = all_lines[start_idx..end_idx] || []
|
|
119
|
+
truncated = total_lines > max_lines && !keyword
|
|
56
120
|
|
|
57
121
|
{
|
|
58
122
|
path: expanded_path,
|
|
59
|
-
content:
|
|
123
|
+
content: lines.join,
|
|
60
124
|
lines_read: lines.size,
|
|
125
|
+
total_lines: total_lines,
|
|
61
126
|
truncated: truncated,
|
|
127
|
+
start_line: start_line,
|
|
128
|
+
end_line: end_line,
|
|
62
129
|
error: nil
|
|
63
130
|
}
|
|
64
131
|
rescue StandardError => e
|
|
@@ -86,16 +153,185 @@ module Clacky
|
|
|
86
153
|
return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
|
|
87
154
|
end
|
|
88
155
|
|
|
89
|
-
# Handle file
|
|
156
|
+
# Handle binary file
|
|
157
|
+
if result[:binary] || result['binary']
|
|
158
|
+
format_type = result[:format] || result['format'] || 'unknown'
|
|
159
|
+
size = result[:size_bytes] || result['size_bytes'] || 0
|
|
160
|
+
|
|
161
|
+
# Check if it has base64 data (LLM-compatible format)
|
|
162
|
+
if result[:base64_data] || result['base64_data']
|
|
163
|
+
size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
|
|
164
|
+
return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
|
|
165
|
+
else
|
|
166
|
+
return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Handle text file reading
|
|
90
171
|
lines = result[:lines_read] || result['lines_read'] || 0
|
|
91
172
|
truncated = result[:truncated] || result['truncated']
|
|
92
173
|
"Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
|
|
93
174
|
end
|
|
175
|
+
|
|
176
|
+
# Format result for LLM - handles both text and binary (image/PDF) content
|
|
177
|
+
# This method is called by the agent to format tool results before sending to LLM
|
|
178
|
+
def format_result_for_llm(result)
|
|
179
|
+
# For LLM-compatible binary files with base64 data, return as content blocks
|
|
180
|
+
if result[:binary] && result[:base64_data]
|
|
181
|
+
# Create a text description
|
|
182
|
+
description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
|
|
183
|
+
|
|
184
|
+
# Add size warning for large files
|
|
185
|
+
if result[:size_bytes] > 5_000_000
|
|
186
|
+
description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# For images, return both description and image content
|
|
190
|
+
if result[:mime_type]&.start_with?("image/")
|
|
191
|
+
return {
|
|
192
|
+
type: "image",
|
|
193
|
+
path: result[:path],
|
|
194
|
+
format: result[:format],
|
|
195
|
+
size_bytes: result[:size_bytes],
|
|
196
|
+
mime_type: result[:mime_type],
|
|
197
|
+
base64_data: result[:base64_data],
|
|
198
|
+
description: description
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# For PDFs and other binary formats, just return metadata with base64
|
|
203
|
+
return {
|
|
204
|
+
type: "document",
|
|
205
|
+
path: result[:path],
|
|
206
|
+
format: result[:format],
|
|
207
|
+
size_bytes: result[:size_bytes],
|
|
208
|
+
mime_type: result[:mime_type],
|
|
209
|
+
base64_data: result[:base64_data],
|
|
210
|
+
description: description
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# For other cases, return the result as-is (agent will JSON.generate it)
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Find lines matching keyword with context (5 lines before and after each match)
|
|
219
|
+
private def find_with_context(path, keyword)
|
|
220
|
+
context_lines_count = 5
|
|
221
|
+
all_lines = File.readlines(path)
|
|
222
|
+
total_lines = all_lines.size
|
|
223
|
+
matches = []
|
|
224
|
+
|
|
225
|
+
# Find all matching line indices (case-insensitive)
|
|
226
|
+
all_lines.each_with_index do |line, index|
|
|
227
|
+
if line.include?(keyword)
|
|
228
|
+
start_idx = [index - context_lines_count, 0].max
|
|
229
|
+
end_idx = [index + context_lines_count, total_lines - 1].min
|
|
230
|
+
matches << {
|
|
231
|
+
line_number: index + 1,
|
|
232
|
+
content: all_lines[start_idx..end_idx].join,
|
|
233
|
+
start_line: start_idx + 1,
|
|
234
|
+
end_line: end_idx + 1,
|
|
235
|
+
match_line: index + 1
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if matches.empty?
|
|
241
|
+
{
|
|
242
|
+
path: path,
|
|
243
|
+
content: nil,
|
|
244
|
+
matches_count: 0,
|
|
245
|
+
error: "Keyword '#{keyword}' not found in file"
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
# Combine all matches with separator
|
|
249
|
+
combined_content = matches.map do |m|
|
|
250
|
+
"... Lines #{m[:start_line]}-#{m[:end_line]} (match at line #{m[:line_number]}):\n#{m[:content]}"
|
|
251
|
+
end.join("\n---\n")
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
path: path,
|
|
255
|
+
content: combined_content,
|
|
256
|
+
matches_count: matches.size,
|
|
257
|
+
keyword: keyword,
|
|
258
|
+
error: nil
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private def binary_file?(path)
|
|
264
|
+
# Use FileProcessor to detect binary files
|
|
265
|
+
File.open(path, 'rb') do |file|
|
|
266
|
+
sample = file.read(8192) || ""
|
|
267
|
+
Utils::FileProcessor.binary_file?(sample)
|
|
268
|
+
end
|
|
269
|
+
rescue StandardError
|
|
270
|
+
# If we can't read the file, assume it's not binary
|
|
271
|
+
false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private def handle_binary_file(path)
|
|
275
|
+
# Check if it's a supported format using FileProcessor
|
|
276
|
+
if Utils::FileProcessor.supported_binary_file?(path)
|
|
277
|
+
# Use FileProcessor to convert to base64
|
|
278
|
+
begin
|
|
279
|
+
result = Utils::FileProcessor.file_to_base64(path)
|
|
280
|
+
{
|
|
281
|
+
path: path,
|
|
282
|
+
binary: true,
|
|
283
|
+
format: result[:format],
|
|
284
|
+
mime_type: result[:mime_type],
|
|
285
|
+
size_bytes: result[:size_bytes],
|
|
286
|
+
base64_data: result[:base64_data],
|
|
287
|
+
error: nil
|
|
288
|
+
}
|
|
289
|
+
rescue ArgumentError => e
|
|
290
|
+
# File too large or other error
|
|
291
|
+
file_size = File.size(path)
|
|
292
|
+
ext = File.extname(path).downcase
|
|
293
|
+
{
|
|
294
|
+
path: path,
|
|
295
|
+
binary: true,
|
|
296
|
+
format: ext.empty? ? "unknown" : ext[1..-1],
|
|
297
|
+
size_bytes: file_size,
|
|
298
|
+
content: nil,
|
|
299
|
+
error: e.message
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
else
|
|
303
|
+
# Binary file that we can't send to LLM
|
|
304
|
+
file_size = File.size(path)
|
|
305
|
+
ext = File.extname(path).downcase
|
|
306
|
+
{
|
|
307
|
+
path: path,
|
|
308
|
+
binary: true,
|
|
309
|
+
format: ext.empty? ? "unknown" : ext[1..-1],
|
|
310
|
+
size_bytes: file_size,
|
|
311
|
+
content: nil,
|
|
312
|
+
error: "Binary file detected. This format cannot be read as text. File size: #{format_file_size(file_size)}"
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private def detect_mime_type(path, data)
|
|
318
|
+
Utils::FileProcessor.detect_mime_type(path, data)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private def format_file_size(bytes)
|
|
322
|
+
if bytes < 1024
|
|
323
|
+
"#{bytes} bytes"
|
|
324
|
+
elsif bytes < 1024 * 1024
|
|
325
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
326
|
+
else
|
|
327
|
+
"#{(bytes / (1024.0 * 1024)).round(2)} MB"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
94
330
|
|
|
95
331
|
private
|
|
96
332
|
|
|
97
333
|
# List first-level directory contents (files and directories)
|
|
98
|
-
def list_directory_contents(path)
|
|
334
|
+
private def list_directory_contents(path)
|
|
99
335
|
begin
|
|
100
336
|
entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
|
|
101
337
|
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -59,11 +59,6 @@ module Clacky
|
|
|
59
59
|
description: "Maximum file size in bytes to search (default: 1MB)",
|
|
60
60
|
default: MAX_FILE_SIZE
|
|
61
61
|
},
|
|
62
|
-
max_files_to_search: {
|
|
63
|
-
type: "integer",
|
|
64
|
-
description: "Maximum number of files to search",
|
|
65
|
-
default: 500
|
|
66
|
-
}
|
|
67
62
|
},
|
|
68
63
|
required: %w[pattern]
|
|
69
64
|
}
|
|
@@ -78,7 +73,7 @@ module Clacky
|
|
|
78
73
|
max_matches_per_file: 50,
|
|
79
74
|
max_total_matches: 200,
|
|
80
75
|
max_file_size: MAX_FILE_SIZE,
|
|
81
|
-
max_files_to_search:
|
|
76
|
+
max_files_to_search: 10000
|
|
82
77
|
)
|
|
83
78
|
# Validate pattern
|
|
84
79
|
if pattern.nil? || pattern.strip.empty?
|
|
@@ -135,7 +130,7 @@ module Clacky
|
|
|
135
130
|
end
|
|
136
131
|
|
|
137
132
|
# Skip if file should be ignored (unless it's a config file)
|
|
138
|
-
if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
|
|
133
|
+
if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
|
|
139
134
|
!Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
|
|
140
135
|
skipped[:ignored] += 1
|
|
141
136
|
next
|
|
@@ -217,12 +212,12 @@ module Clacky
|
|
|
217
212
|
matches = result[:total_matches] || 0
|
|
218
213
|
files = result[:files_with_matches] || 0
|
|
219
214
|
msg = "[OK] Found #{matches} matches in #{files} files"
|
|
220
|
-
|
|
215
|
+
|
|
221
216
|
# Add truncation info if present
|
|
222
217
|
if result[:truncated] && result[:truncation_reason]
|
|
223
218
|
msg += " (truncated: #{result[:truncation_reason]})"
|
|
224
219
|
end
|
|
225
|
-
|
|
220
|
+
|
|
226
221
|
msg
|
|
227
222
|
end
|
|
228
223
|
end
|
|
@@ -275,12 +270,12 @@ module Clacky
|
|
|
275
270
|
|
|
276
271
|
def search_file(file, regex, context_lines, max_matches)
|
|
277
272
|
matches = []
|
|
278
|
-
|
|
273
|
+
|
|
279
274
|
# Use File.foreach for memory-efficient line-by-line reading
|
|
280
275
|
File.foreach(file, chomp: true).with_index do |line, index|
|
|
281
276
|
# Stop if we have enough matches for this file
|
|
282
277
|
break if matches.length >= max_matches
|
|
283
|
-
|
|
278
|
+
|
|
284
279
|
next unless line.match?(regex)
|
|
285
280
|
|
|
286
281
|
# Truncate long lines
|
|
@@ -315,10 +310,10 @@ module Clacky
|
|
|
315
310
|
(start_line..end_line).each do |i|
|
|
316
311
|
line_content = lines[i]
|
|
317
312
|
# Truncate long lines in context too
|
|
318
|
-
display_content = line_content.length > MAX_LINE_LENGTH ?
|
|
319
|
-
"#{line_content[0...MAX_LINE_LENGTH]}..." :
|
|
313
|
+
display_content = line_content.length > MAX_LINE_LENGTH ?
|
|
314
|
+
"#{line_content[0...MAX_LINE_LENGTH]}..." :
|
|
320
315
|
line_content
|
|
321
|
-
|
|
316
|
+
|
|
322
317
|
context << {
|
|
323
318
|
line_number: i + 1,
|
|
324
319
|
content: display_content,
|
|
@@ -32,20 +32,26 @@ module Clacky
|
|
|
32
32
|
required: ["command"]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
def execute(command:, timeout: nil, max_output_lines: 1000)
|
|
35
|
+
def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false)
|
|
36
36
|
# Get project root directory
|
|
37
37
|
project_root = Dir.pwd
|
|
38
38
|
|
|
39
39
|
begin
|
|
40
40
|
# 1. Extract timeout from command if it starts with "timeout N"
|
|
41
41
|
command, extracted_timeout = extract_timeout_from_command(command)
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
# Use extracted timeout if not explicitly provided
|
|
44
44
|
timeout ||= extracted_timeout
|
|
45
45
|
|
|
46
|
-
# 2. Use safety replacer to process command
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# 2. Use safety replacer to process command (skip if user already confirmed)
|
|
47
|
+
if skip_safety_check
|
|
48
|
+
# User has confirmed, execute command as-is (no safety modifications)
|
|
49
|
+
safe_command = command
|
|
50
|
+
safety_replacer = nil
|
|
51
|
+
else
|
|
52
|
+
safety_replacer = CommandSafetyReplacer.new(project_root)
|
|
53
|
+
safe_command = safety_replacer.make_command_safe(command)
|
|
54
|
+
end
|
|
49
55
|
|
|
50
56
|
# 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
|
|
51
57
|
soft_timeout = 5
|
|
@@ -55,7 +61,7 @@ module Clacky
|
|
|
55
61
|
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
56
62
|
|
|
57
63
|
# 5. Enhance result information
|
|
58
|
-
enhance_result(result, command, safe_command)
|
|
64
|
+
enhance_result(result, command, safe_command, safety_replacer)
|
|
59
65
|
|
|
60
66
|
rescue SecurityError => e
|
|
61
67
|
# Security error, return friendly error message
|
|
@@ -145,9 +151,9 @@ module Clacky
|
|
|
145
151
|
end
|
|
146
152
|
end
|
|
147
153
|
|
|
148
|
-
def enhance_result(result, original_command, safe_command)
|
|
154
|
+
def enhance_result(result, original_command, safe_command, safety_replacer = nil)
|
|
149
155
|
# If command was replaced, add security information
|
|
150
|
-
if original_command != safe_command
|
|
156
|
+
if safety_replacer && original_command != safe_command
|
|
151
157
|
result[:security_enhanced] = true
|
|
152
158
|
result[:original_command] = original_command
|
|
153
159
|
result[:safe_command] = safe_command
|
|
@@ -190,6 +196,24 @@ module Clacky
|
|
|
190
196
|
end
|
|
191
197
|
end
|
|
192
198
|
|
|
199
|
+
# Override format_result_for_llm to preserve security fields
|
|
200
|
+
def format_result_for_llm(result)
|
|
201
|
+
# If security blocked, return as-is (small and important)
|
|
202
|
+
return result if result[:security_blocked]
|
|
203
|
+
|
|
204
|
+
# Call parent's format_result_for_llm to truncate output
|
|
205
|
+
compact = super(result)
|
|
206
|
+
|
|
207
|
+
# Add security enhancement fields if present (they're small and important for LLM to understand)
|
|
208
|
+
if result[:security_enhanced]
|
|
209
|
+
compact[:security_enhanced] = true
|
|
210
|
+
compact[:original_command] = result[:original_command]
|
|
211
|
+
compact[:safe_command] = result[:safe_command]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
compact
|
|
215
|
+
end
|
|
216
|
+
|
|
193
217
|
private def format_non_zero_exit(exit_code, stdout, stderr)
|
|
194
218
|
stdout_lines = stdout.lines.size
|
|
195
219
|
has_output = stdout_lines > 0
|
|
@@ -331,7 +355,12 @@ module Clacky
|
|
|
331
355
|
|
|
332
356
|
def replace_chmod_command(command)
|
|
333
357
|
# Parse chmod command to ensure it's safe
|
|
334
|
-
|
|
358
|
+
begin
|
|
359
|
+
parts = Shellwords.split(command)
|
|
360
|
+
rescue Shellwords::BadQuotedString => e
|
|
361
|
+
# If Shellwords.split fails, use simple split as fallback
|
|
362
|
+
parts = command.split(/\s+/)
|
|
363
|
+
end
|
|
335
364
|
|
|
336
365
|
# Only allow chmod +x on files in project directory
|
|
337
366
|
files = parts[2..-1] || []
|
|
@@ -340,8 +369,6 @@ module Clacky
|
|
|
340
369
|
# Allow chmod +x as it's generally safe
|
|
341
370
|
log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
|
|
342
371
|
command
|
|
343
|
-
rescue Shellwords::BadQuotedString
|
|
344
|
-
raise SecurityError, "Invalid chmod command syntax: #{command}"
|
|
345
372
|
end
|
|
346
373
|
|
|
347
374
|
def replace_curl_pipe_command(command)
|
|
@@ -370,7 +397,14 @@ module Clacky
|
|
|
370
397
|
|
|
371
398
|
def validate_and_allow(command)
|
|
372
399
|
# Check basic file operation commands
|
|
373
|
-
|
|
400
|
+
begin
|
|
401
|
+
parts = Shellwords.split(command)
|
|
402
|
+
rescue Shellwords::BadQuotedString => e
|
|
403
|
+
# If Shellwords.split fails due to quote issues, try simple split as fallback
|
|
404
|
+
# This handles cases where paths don't actually need shell escaping
|
|
405
|
+
parts = command.split(/\s+/)
|
|
406
|
+
end
|
|
407
|
+
|
|
374
408
|
cmd = parts.first
|
|
375
409
|
args = parts[1..-1]
|
|
376
410
|
|
|
@@ -384,8 +418,6 @@ module Clacky
|
|
|
384
418
|
end
|
|
385
419
|
|
|
386
420
|
command
|
|
387
|
-
rescue Shellwords::BadQuotedString
|
|
388
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
389
421
|
end
|
|
390
422
|
|
|
391
423
|
def validate_general_command(command)
|
|
@@ -419,11 +451,15 @@ module Clacky
|
|
|
419
451
|
end
|
|
420
452
|
|
|
421
453
|
def parse_rm_files(command)
|
|
422
|
-
|
|
454
|
+
begin
|
|
455
|
+
parts = Shellwords.split(command)
|
|
456
|
+
rescue Shellwords::BadQuotedString => e
|
|
457
|
+
# If Shellwords.split fails, use simple split as fallback
|
|
458
|
+
parts = command.split(/\s+/)
|
|
459
|
+
end
|
|
460
|
+
|
|
423
461
|
# Skip rm command itself and option parameters
|
|
424
462
|
parts.drop(1).reject { |part| part.start_with?('-') }
|
|
425
|
-
rescue Shellwords::BadQuotedString
|
|
426
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
427
463
|
end
|
|
428
464
|
|
|
429
465
|
def validate_file_path(path)
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "tmpdir"
|
|
3
4
|
require_relative "base"
|
|
4
5
|
|
|
5
6
|
module Clacky
|
|
@@ -35,7 +36,7 @@ module Clacky
|
|
|
35
36
|
INTERACTION_PATTERNS = [
|
|
36
37
|
[/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
|
|
37
38
|
[/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
|
|
38
|
-
[/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s
|
|
39
|
+
[/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
|
|
39
40
|
[/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
|
|
40
41
|
[/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
|
|
41
42
|
[/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
|
|
@@ -143,7 +144,7 @@ module Clacky
|
|
|
143
144
|
|
|
144
145
|
stdout_output = stdout_buffer.string
|
|
145
146
|
stderr_output = stderr_buffer.string
|
|
146
|
-
|
|
147
|
+
|
|
147
148
|
{
|
|
148
149
|
command: command,
|
|
149
150
|
stdout: truncate_output(stdout_output, max_output_lines),
|
|
@@ -157,7 +158,7 @@ module Clacky
|
|
|
157
158
|
rescue StandardError => e
|
|
158
159
|
stdout_output = stdout_buffer.string
|
|
159
160
|
stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
160
|
-
|
|
161
|
+
|
|
161
162
|
{
|
|
162
163
|
command: command,
|
|
163
164
|
stdout: truncate_output(stdout_output, max_output_lines),
|
|
@@ -253,10 +254,10 @@ module Clacky
|
|
|
253
254
|
# Truncate output to max_lines, adding a truncation notice if needed
|
|
254
255
|
def truncate_output(output, max_lines)
|
|
255
256
|
return output if output.nil? || output.empty?
|
|
256
|
-
|
|
257
|
+
|
|
257
258
|
lines = output.lines
|
|
258
259
|
return output if lines.length <= max_lines
|
|
259
|
-
|
|
260
|
+
|
|
260
261
|
truncated_lines = lines.first(max_lines)
|
|
261
262
|
truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
|
|
262
263
|
truncated_lines.join + truncation_notice
|
|
@@ -290,6 +291,109 @@ module Clacky
|
|
|
290
291
|
"[Exit #{exit_code}] #{error_msg[0..50]}"
|
|
291
292
|
end
|
|
292
293
|
end
|
|
294
|
+
|
|
295
|
+
# Format result for LLM consumption - limit output size to save tokens
|
|
296
|
+
# Maximum characters to include in LLM output
|
|
297
|
+
MAX_LLM_OUTPUT_CHARS = 1000
|
|
298
|
+
|
|
299
|
+
def format_result_for_llm(result)
|
|
300
|
+
# Return error info as-is if command failed or timed out
|
|
301
|
+
return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
|
|
302
|
+
|
|
303
|
+
stdout = result[:stdout] || ""
|
|
304
|
+
stderr = result[:stderr] || ""
|
|
305
|
+
exit_code = result[:exit_code] || 0
|
|
306
|
+
|
|
307
|
+
# Build compact result with truncated output
|
|
308
|
+
compact = {
|
|
309
|
+
command: result[:command],
|
|
310
|
+
exit_code: exit_code,
|
|
311
|
+
success: result[:success]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Add elapsed time if available (keep original precision)
|
|
315
|
+
compact[:elapsed] = result[:elapsed] if result[:elapsed]
|
|
316
|
+
|
|
317
|
+
# Extract command name for temp file naming
|
|
318
|
+
command_name = extract_command_name(result[:command])
|
|
319
|
+
|
|
320
|
+
# Process stdout: truncate and optionally save to temp file
|
|
321
|
+
stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
|
|
322
|
+
compact[:stdout] = stdout_info[:content]
|
|
323
|
+
compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
|
|
324
|
+
|
|
325
|
+
# Process stderr: truncate and optionally save to temp file
|
|
326
|
+
stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
|
|
327
|
+
compact[:stderr] = stderr_info[:content]
|
|
328
|
+
compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
|
|
329
|
+
|
|
330
|
+
# Add output_truncated flag if present
|
|
331
|
+
compact[:output_truncated] = true if result[:output_truncated]
|
|
332
|
+
|
|
333
|
+
compact
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Extract command name from full command for temp file naming
|
|
337
|
+
def extract_command_name(command)
|
|
338
|
+
first_word = command.strip.split(/\s+/).first
|
|
339
|
+
File.basename(first_word, ".*")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Truncate output for LLM and optionally save full content to temp file
|
|
343
|
+
def truncate_and_save(output, max_chars, label, command_name)
|
|
344
|
+
return { content: "", temp_file: nil } if output.empty?
|
|
345
|
+
|
|
346
|
+
return { content: output, temp_file: nil } if output.length <= max_chars
|
|
347
|
+
|
|
348
|
+
# Sanitize command name for file name
|
|
349
|
+
safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
|
|
350
|
+
|
|
351
|
+
# Use Ruby tmpdir for safe temp file creation
|
|
352
|
+
temp_dir = Dir.mktmpdir
|
|
353
|
+
temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
|
|
354
|
+
|
|
355
|
+
# Write full output to temp file
|
|
356
|
+
File.write(temp_file, output)
|
|
357
|
+
|
|
358
|
+
# For LLM display: show first and last lines to preserve structure
|
|
359
|
+
lines = output.lines
|
|
360
|
+
return { content: output, temp_file: nil } if lines.length <= 2
|
|
361
|
+
|
|
362
|
+
# Reserve 60 chars for truncation notice
|
|
363
|
+
available_chars = max_chars - 60
|
|
364
|
+
|
|
365
|
+
# Take first few lines
|
|
366
|
+
first_part = []
|
|
367
|
+
accumulated = 0
|
|
368
|
+
lines.each do |line|
|
|
369
|
+
break if accumulated + line.length > available_chars / 2
|
|
370
|
+
first_part << line
|
|
371
|
+
accumulated += line.length
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Take last few lines
|
|
375
|
+
last_part = []
|
|
376
|
+
accumulated = 0
|
|
377
|
+
lines.reverse_each do |line|
|
|
378
|
+
break if accumulated + line.length > available_chars / 2
|
|
379
|
+
last_part.unshift(line)
|
|
380
|
+
accumulated += line.length
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
total_lines = lines.length
|
|
384
|
+
|
|
385
|
+
# Create notice message based on label
|
|
386
|
+
if label == "stderr"
|
|
387
|
+
notice = "... [Error output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
|
|
388
|
+
else
|
|
389
|
+
notice = "... [Output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Combine with compact notice
|
|
393
|
+
content = first_part.join + "\n#{notice}\n" + last_part.join
|
|
394
|
+
|
|
395
|
+
{ content: content, temp_file: temp_file }
|
|
396
|
+
end
|
|
293
397
|
end
|
|
294
398
|
end
|
|
295
399
|
end
|