openclacky 0.5.6 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +376 -346
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +167 -398
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +66 -10
- data/lib/clacky/tools/grep.rb +6 -122
- data/lib/clacky/tools/run_project.rb +10 -5
- data/lib/clacky/tools/safe_shell.rb +149 -20
- data/lib/clacky/tools/shell.rb +3 -51
- data/lib/clacky/tools/todo_manager.rb +50 -3
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_fetch.rb +4 -4
- data/lib/clacky/tools/web_search.rb +40 -28
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +98 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1124 -0
- data/lib/clacky/ui2/components/message_component.rb +80 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +130 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
- data/lib/clacky/ui2/layout_manager.rb +437 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/markdown_renderer.rb +80 -0
- data/lib/clacky/ui2/screen_buffer.rb +257 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +85 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
- data/lib/clacky/ui2/ui_controller.rb +778 -0
- data/lib/clacky/ui2/view_renderer.rb +177 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +53 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
|
@@ -19,13 +19,9 @@ module Clacky
|
|
|
19
19
|
type: "string",
|
|
20
20
|
description: "Shell command to execute"
|
|
21
21
|
},
|
|
22
|
-
|
|
22
|
+
timeout: {
|
|
23
23
|
type: "integer",
|
|
24
|
-
description: "
|
|
25
|
-
},
|
|
26
|
-
hard_timeout: {
|
|
27
|
-
type: "integer",
|
|
28
|
-
description: "Hard timeout in seconds (force kill)"
|
|
24
|
+
description: "Command timeout in seconds (auto-detected if not specified: 60s for normal commands, 180s for build/install commands)"
|
|
29
25
|
},
|
|
30
26
|
max_output_lines: {
|
|
31
27
|
type: "integer",
|
|
@@ -36,19 +32,29 @@ module Clacky
|
|
|
36
32
|
required: ["command"]
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
def execute(command:,
|
|
35
|
+
def execute(command:, timeout: nil, max_output_lines: 1000)
|
|
40
36
|
# Get project root directory
|
|
41
37
|
project_root = Dir.pwd
|
|
42
38
|
|
|
43
39
|
begin
|
|
44
|
-
# 1.
|
|
40
|
+
# 1. Extract timeout from command if it starts with "timeout N"
|
|
41
|
+
command, extracted_timeout = extract_timeout_from_command(command)
|
|
42
|
+
|
|
43
|
+
# Use extracted timeout if not explicitly provided
|
|
44
|
+
timeout ||= extracted_timeout
|
|
45
|
+
|
|
46
|
+
# 2. Use safety replacer to process command
|
|
45
47
|
safety_replacer = CommandSafetyReplacer.new(project_root)
|
|
46
48
|
safe_command = safety_replacer.make_command_safe(command)
|
|
47
49
|
|
|
48
|
-
#
|
|
50
|
+
# 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
|
|
51
|
+
soft_timeout = 5
|
|
52
|
+
hard_timeout = calculate_hard_timeout(command, timeout)
|
|
53
|
+
|
|
54
|
+
# 4. Call parent class execution method
|
|
49
55
|
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
50
56
|
|
|
51
|
-
#
|
|
57
|
+
# 5. Enhance result information
|
|
52
58
|
enhance_result(result, command, safe_command)
|
|
53
59
|
|
|
54
60
|
rescue SecurityError => e
|
|
@@ -56,7 +62,7 @@ module Clacky
|
|
|
56
62
|
{
|
|
57
63
|
command: command,
|
|
58
64
|
stdout: "",
|
|
59
|
-
stderr: "
|
|
65
|
+
stderr: "[Security Protection] #{e.message}",
|
|
60
66
|
exit_code: 126,
|
|
61
67
|
success: false,
|
|
62
68
|
security_blocked: true
|
|
@@ -64,6 +70,48 @@ module Clacky
|
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
73
|
+
private def extract_timeout_from_command(command)
|
|
74
|
+
# Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
|
|
75
|
+
# Also supports: "cd xxx && timeout 30 command", "export X=Y && timeout 30 command"
|
|
76
|
+
# Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
|
|
77
|
+
|
|
78
|
+
# Try to match timeout at the beginning of command
|
|
79
|
+
match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
80
|
+
|
|
81
|
+
if match
|
|
82
|
+
timeout_value = match[1].to_i
|
|
83
|
+
actual_command = match[2]
|
|
84
|
+
return [actual_command, timeout_value]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Try to match timeout after && or ;
|
|
88
|
+
# Pattern: "prefix && timeout 30 command" or "prefix; timeout 30 command"
|
|
89
|
+
match = command.match(/^(.+?)\s*(&&|;)\s*timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
90
|
+
|
|
91
|
+
if match
|
|
92
|
+
prefix = match[1] # e.g., "cd /tmp"
|
|
93
|
+
separator = match[2] # && or ;
|
|
94
|
+
timeout_value = match[3].to_i
|
|
95
|
+
main_command = match[4] # e.g., "bundle exec rspec"
|
|
96
|
+
|
|
97
|
+
# Reconstruct command without timeout prefix
|
|
98
|
+
actual_command = "#{prefix} #{separator} #{main_command}"
|
|
99
|
+
return [actual_command, timeout_value]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# No timeout found, return original command
|
|
103
|
+
[command, nil]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private def calculate_hard_timeout(command, timeout)
|
|
107
|
+
# If timeout is provided, use it directly
|
|
108
|
+
return timeout if timeout
|
|
109
|
+
|
|
110
|
+
# Otherwise, auto-detect based on command type
|
|
111
|
+
is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }
|
|
112
|
+
is_slow ? 180 : 60
|
|
113
|
+
end
|
|
114
|
+
|
|
67
115
|
# Safe read-only commands that don't modify system state
|
|
68
116
|
SAFE_READONLY_COMMANDS = %w[
|
|
69
117
|
ls pwd cat less more head tail
|
|
@@ -105,7 +153,7 @@ module Clacky
|
|
|
105
153
|
result[:safe_command] = safe_command
|
|
106
154
|
|
|
107
155
|
# Add security note to stdout
|
|
108
|
-
security_note = "
|
|
156
|
+
security_note = "[Safe] Command was automatically made safe\n"
|
|
109
157
|
result[:stdout] = security_note + (result[:stdout] || "")
|
|
110
158
|
end
|
|
111
159
|
|
|
@@ -130,17 +178,92 @@ module Clacky
|
|
|
130
178
|
stderr = result[:stderr] || result['stderr'] || ""
|
|
131
179
|
|
|
132
180
|
if result[:security_blocked]
|
|
133
|
-
"
|
|
181
|
+
"[Blocked] Security protection"
|
|
134
182
|
elsif result[:security_enhanced]
|
|
135
183
|
lines = stdout.lines.size
|
|
136
|
-
"
|
|
184
|
+
"[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
137
185
|
elsif exit_code == 0
|
|
138
186
|
lines = stdout.lines.size
|
|
139
|
-
"
|
|
187
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
188
|
+
else
|
|
189
|
+
format_non_zero_exit(exit_code, stdout, stderr)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private def format_non_zero_exit(exit_code, stdout, stderr)
|
|
194
|
+
stdout_lines = stdout.lines.size
|
|
195
|
+
has_output = stdout_lines > 0
|
|
196
|
+
has_error = !stderr.empty?
|
|
197
|
+
|
|
198
|
+
if has_error
|
|
199
|
+
# Real error: show error summary
|
|
200
|
+
error_summary = extract_error_summary(stderr)
|
|
201
|
+
"[Exit #{exit_code}] #{error_summary}"
|
|
202
|
+
elsif has_output
|
|
203
|
+
# Command produced output but exited with non-zero code
|
|
204
|
+
# This is common in commands like "ls; exit 1" or grep with no matches
|
|
205
|
+
"[Exit #{exit_code}] #{stdout_lines} lines output"
|
|
140
206
|
else
|
|
141
|
-
|
|
142
|
-
"
|
|
207
|
+
# No output, no error message - just show exit code
|
|
208
|
+
"[Exit #{exit_code}] No output"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private def extract_error_summary(stderr)
|
|
213
|
+
return "No error message" if stderr.empty?
|
|
214
|
+
|
|
215
|
+
# Try to extract the most meaningful error line
|
|
216
|
+
lines = stderr.lines.map(&:strip).reject(&:empty?)
|
|
217
|
+
|
|
218
|
+
# Common error patterns with priority
|
|
219
|
+
patterns = [
|
|
220
|
+
# Ruby/Python exceptions with error type
|
|
221
|
+
{ regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
|
|
222
|
+
# File not found patterns
|
|
223
|
+
{ regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
|
|
224
|
+
{ regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
|
|
225
|
+
# Undefined method/variable
|
|
226
|
+
{ regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
|
|
227
|
+
# Syntax errors
|
|
228
|
+
{ regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# Try each pattern on each line
|
|
232
|
+
patterns.each do |pattern|
|
|
233
|
+
lines.each do |line|
|
|
234
|
+
match = line.match(pattern[:regex])
|
|
235
|
+
if match
|
|
236
|
+
result = pattern[:format].call(match)
|
|
237
|
+
return truncate_error(clean_path(result), 80)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Fallback: find the most informative line
|
|
243
|
+
informative_line = lines.find do |line|
|
|
244
|
+
!line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
|
|
245
|
+
line.length > 10 &&
|
|
246
|
+
(line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if informative_line
|
|
250
|
+
return truncate_error(clean_path(informative_line), 80)
|
|
143
251
|
end
|
|
252
|
+
|
|
253
|
+
# Last resort: use first meaningful line
|
|
254
|
+
first_line = lines.first || "Unknown error"
|
|
255
|
+
truncate_error(clean_path(first_line), 80)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private def clean_path(text)
|
|
259
|
+
# Remove long absolute paths, keep only filename
|
|
260
|
+
text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
|
|
261
|
+
.gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private def truncate_error(text, max_length)
|
|
265
|
+
return text if text.length <= max_length
|
|
266
|
+
"#{text[0...max_length-3]}..."
|
|
144
267
|
end
|
|
145
268
|
end
|
|
146
269
|
|
|
@@ -267,12 +390,18 @@ module Clacky
|
|
|
267
390
|
|
|
268
391
|
def validate_general_command(command)
|
|
269
392
|
# Check general command security
|
|
393
|
+
# Note: We need to be careful not to match patterns inside quoted strings
|
|
394
|
+
|
|
395
|
+
# First, remove quoted strings to avoid false positives
|
|
396
|
+
# This is a simplified approach - removes both single and double quoted content
|
|
397
|
+
cmd_without_quotes = command.gsub(/'[^']*'|"[^"]*"/, '')
|
|
398
|
+
|
|
270
399
|
dangerous_patterns = [
|
|
271
400
|
/eval\s*\(/,
|
|
272
401
|
/exec\s*\(/,
|
|
273
402
|
/system\s*\(/,
|
|
274
|
-
|
|
275
|
-
/\$\(
|
|
403
|
+
/`[^`]+`/, # Command substitution with backticks (but only if not in quotes)
|
|
404
|
+
/\$\([^)]+\)/, # Command substitution with $() (but only if not in quotes)
|
|
276
405
|
/\|\s*sh\s*$/,
|
|
277
406
|
/\|\s*bash\s*$/,
|
|
278
407
|
/>\s*\/etc\//,
|
|
@@ -281,7 +410,7 @@ module Clacky
|
|
|
281
410
|
]
|
|
282
411
|
|
|
283
412
|
dangerous_patterns.each do |pattern|
|
|
284
|
-
if
|
|
413
|
+
if cmd_without_quotes.match?(pattern)
|
|
285
414
|
raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
|
|
286
415
|
end
|
|
287
416
|
end
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -95,7 +95,7 @@ module Clacky
|
|
|
95
95
|
if elapsed > soft_timeout && !soft_timeout_triggered
|
|
96
96
|
soft_timeout_triggered = true
|
|
97
97
|
|
|
98
|
-
# L1:
|
|
98
|
+
# L1: Check for interaction patterns
|
|
99
99
|
interaction = detect_interaction(stdout_buffer.string)
|
|
100
100
|
if interaction
|
|
101
101
|
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
@@ -107,24 +107,6 @@ module Clacky
|
|
|
107
107
|
max_output_lines
|
|
108
108
|
)
|
|
109
109
|
end
|
|
110
|
-
|
|
111
|
-
# L2:
|
|
112
|
-
last_size = stdout_buffer.size
|
|
113
|
-
stdin.puts("\n") rescue nil
|
|
114
|
-
sleep 2
|
|
115
|
-
|
|
116
|
-
if stdout_buffer.size > last_size
|
|
117
|
-
next
|
|
118
|
-
else
|
|
119
|
-
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
120
|
-
return format_stuck_result(
|
|
121
|
-
command,
|
|
122
|
-
stdout_buffer.string,
|
|
123
|
-
stderr_buffer.string,
|
|
124
|
-
elapsed,
|
|
125
|
-
max_output_lines
|
|
126
|
-
)
|
|
127
|
-
end
|
|
128
110
|
end
|
|
129
111
|
|
|
130
112
|
break unless wait_thr.alive?
|
|
@@ -255,36 +237,6 @@ module Clacky
|
|
|
255
237
|
MSG
|
|
256
238
|
end
|
|
257
239
|
|
|
258
|
-
def format_stuck_result(command, stdout, stderr, elapsed, max_output_lines)
|
|
259
|
-
{
|
|
260
|
-
command: command,
|
|
261
|
-
stdout: truncate_output(stdout, max_output_lines),
|
|
262
|
-
stderr: truncate_output(stderr, max_output_lines),
|
|
263
|
-
exit_code: -3,
|
|
264
|
-
success: false,
|
|
265
|
-
state: 'STUCK',
|
|
266
|
-
elapsed: elapsed,
|
|
267
|
-
message: format_stuck_message(truncate_output(stdout, max_output_lines), elapsed),
|
|
268
|
-
output_truncated: output_truncated?(stdout, stderr, max_output_lines)
|
|
269
|
-
}
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def format_stuck_message(output, elapsed)
|
|
273
|
-
<<~MSG
|
|
274
|
-
#{output}
|
|
275
|
-
|
|
276
|
-
#{'=' * 60}
|
|
277
|
-
[Terminal State: STUCK]
|
|
278
|
-
#{'=' * 60}
|
|
279
|
-
|
|
280
|
-
The terminal is not responding after #{elapsed.round(1)}s.
|
|
281
|
-
|
|
282
|
-
Suggested actions:
|
|
283
|
-
• Try interrupting with Ctrl+C
|
|
284
|
-
• Check if command is frozen
|
|
285
|
-
MSG
|
|
286
|
-
end
|
|
287
|
-
|
|
288
240
|
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
|
|
289
241
|
{
|
|
290
242
|
command: command,
|
|
@@ -332,10 +284,10 @@ module Clacky
|
|
|
332
284
|
|
|
333
285
|
if exit_code == 0
|
|
334
286
|
lines = stdout.lines.size
|
|
335
|
-
"
|
|
287
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
336
288
|
else
|
|
337
289
|
error_msg = stderr.lines.first&.strip || "Failed"
|
|
338
|
-
"
|
|
290
|
+
"[Exit #{exit_code}] #{error_msg[0..50]}"
|
|
339
291
|
end
|
|
340
292
|
end
|
|
341
293
|
end
|
|
@@ -29,12 +29,17 @@ module Clacky
|
|
|
29
29
|
id: {
|
|
30
30
|
type: "integer",
|
|
31
31
|
description: "The task ID (required for 'complete' and 'remove' actions)"
|
|
32
|
+
},
|
|
33
|
+
ids: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "integer" },
|
|
36
|
+
description: "Array of task IDs for batch removal (for 'remove' action). Example: [1, 3, 5]"
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
required: ["action"]
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
|
|
42
|
+
def execute(action:, task: nil, tasks: nil, id: nil, ids: nil, todos_storage: nil)
|
|
38
43
|
# todos_storage is injected by Agent, stores todos in memory
|
|
39
44
|
@todos = todos_storage || []
|
|
40
45
|
|
|
@@ -46,7 +51,12 @@ module Clacky
|
|
|
46
51
|
when "complete"
|
|
47
52
|
complete_todo(id)
|
|
48
53
|
when "remove"
|
|
49
|
-
|
|
54
|
+
# Support both single ID and batch IDs
|
|
55
|
+
if ids && ids.is_a?(Array)
|
|
56
|
+
remove_todos(ids)
|
|
57
|
+
else
|
|
58
|
+
remove_todo(id)
|
|
59
|
+
end
|
|
50
60
|
when "clear"
|
|
51
61
|
clear_todos
|
|
52
62
|
else
|
|
@@ -65,7 +75,12 @@ module Clacky
|
|
|
65
75
|
when 'list'
|
|
66
76
|
"TodoManager(list)"
|
|
67
77
|
when 'remove'
|
|
68
|
-
|
|
78
|
+
ids = args[:ids] || args['ids']
|
|
79
|
+
if ids && ids.is_a?(Array) && !ids.empty?
|
|
80
|
+
"TodoManager(remove #{ids.size} tasks: #{ids.join(', ')})"
|
|
81
|
+
else
|
|
82
|
+
"TodoManager(remove ##{args[:id] || args['id']})"
|
|
83
|
+
end
|
|
69
84
|
when 'clear'
|
|
70
85
|
"TodoManager(clear all)"
|
|
71
86
|
else
|
|
@@ -223,6 +238,38 @@ module Clacky
|
|
|
223
238
|
cleared_count: count
|
|
224
239
|
}
|
|
225
240
|
end
|
|
241
|
+
|
|
242
|
+
def remove_todos(ids)
|
|
243
|
+
return { error: "Task IDs array is required" } if ids.nil? || ids.empty?
|
|
244
|
+
|
|
245
|
+
todos = load_todos
|
|
246
|
+
removed_todos = []
|
|
247
|
+
not_found_ids = []
|
|
248
|
+
|
|
249
|
+
ids.each do |id|
|
|
250
|
+
todo = todos.find { |t| t[:id] == id }
|
|
251
|
+
if todo
|
|
252
|
+
removed_todos << todo
|
|
253
|
+
else
|
|
254
|
+
not_found_ids << id
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Remove all found todos
|
|
259
|
+
todos.reject! { |t| ids.include?(t[:id]) }
|
|
260
|
+
save_todos(todos)
|
|
261
|
+
|
|
262
|
+
result = {
|
|
263
|
+
message: "#{removed_todos.size} task(s) removed",
|
|
264
|
+
removed: removed_todos,
|
|
265
|
+
remaining: todos.size
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Add warning about not found IDs
|
|
269
|
+
result[:not_found] = not_found_ids unless not_found_ids.empty?
|
|
270
|
+
|
|
271
|
+
result
|
|
272
|
+
end
|
|
226
273
|
end
|
|
227
274
|
end
|
|
228
275
|
end
|
|
@@ -40,8 +40,8 @@ module Clacky
|
|
|
40
40
|
# Fetch the web page
|
|
41
41
|
response = fetch_url(uri)
|
|
42
42
|
|
|
43
|
-
# Extract content
|
|
44
|
-
content = response.body
|
|
43
|
+
# Extract content and force UTF-8 encoding at the source
|
|
44
|
+
content = response.body.force_encoding('UTF-8').scrub('?')
|
|
45
45
|
content_type = response["content-type"] || ""
|
|
46
46
|
|
|
47
47
|
# Parse HTML if it's an HTML page
|
|
@@ -149,11 +149,11 @@ module Clacky
|
|
|
149
149
|
|
|
150
150
|
def format_result(result)
|
|
151
151
|
if result[:error]
|
|
152
|
-
"
|
|
152
|
+
"[Error] #{result[:error]}"
|
|
153
153
|
else
|
|
154
154
|
title = result[:title] || 'Untitled'
|
|
155
155
|
display_title = title.length > 40 ? "#{title[0..37]}..." : title
|
|
156
|
-
"
|
|
156
|
+
"[OK] Fetched: #{display_title}"
|
|
157
157
|
end
|
|
158
158
|
end
|
|
159
159
|
end
|
|
@@ -48,14 +48,14 @@ module Clacky
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def search_duckduckgo(query, max_results)
|
|
51
|
+
private def search_duckduckgo(query, max_results)
|
|
52
52
|
# DuckDuckGo HTML search endpoint
|
|
53
53
|
encoded_query = CGI.escape(query)
|
|
54
54
|
url = URI("https://html.duckduckgo.com/html/?q=#{encoded_query}")
|
|
55
55
|
|
|
56
56
|
# Make request with user agent
|
|
57
57
|
request = Net::HTTP::Get.new(url)
|
|
58
|
-
request["User-Agent"] = "Mozilla/5.0 (
|
|
58
|
+
request["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
|
59
59
|
|
|
60
60
|
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: 10) do |http|
|
|
61
61
|
http.request(request)
|
|
@@ -78,45 +78,57 @@ module Clacky
|
|
|
78
78
|
]
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
def parse_duckduckgo_html(html, max_results)
|
|
81
|
+
private def parse_duckduckgo_html(html, max_results)
|
|
82
82
|
results = []
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
# Ensure HTML is UTF-8 encoded
|
|
85
|
+
html = html.force_encoding('UTF-8') unless html.encoding == Encoding::UTF_8
|
|
86
|
+
|
|
87
|
+
# Extract all result links and snippets
|
|
88
|
+
# Pattern: <a class="result__a" href="//duckduckgo.com/l/?uddg=ENCODED_URL...">TITLE</a>
|
|
89
|
+
links = html.scan(%r{<a[^>]*class="result__a"[^>]*href="//duckduckgo\.com/l/\?uddg=([^"&]+)[^"]*"[^>]*>(.*?)</a>}m)
|
|
90
|
+
|
|
91
|
+
# Pattern: <a class="result__snippet">SNIPPET</a>
|
|
92
|
+
snippets = html.scan(%r{<a[^>]*class="result__snippet"[^>]*>(.*?)</a>}m)
|
|
93
|
+
|
|
94
|
+
# Combine links and snippets
|
|
95
|
+
links.each_with_index do |link_data, index|
|
|
87
96
|
break if results.length >= max_results
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
snippet = ""
|
|
96
|
-
|
|
97
|
-
snippet = $1.gsub(/<[^>]+>/, "").strip
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
results << {
|
|
101
|
-
title: title,
|
|
102
|
-
url: url,
|
|
103
|
-
snippet: snippet
|
|
104
|
-
}
|
|
98
|
+
url = CGI.unescape(link_data[0]).force_encoding('UTF-8')
|
|
99
|
+
title = link_data[1].gsub(/<[^>]+>/, "").strip
|
|
100
|
+
title = CGI.unescapeHTML(title) if title.include?("&")
|
|
101
|
+
|
|
102
|
+
snippet = ""
|
|
103
|
+
if snippets[index]
|
|
104
|
+
snippet = snippets[index][0].gsub(/<[^>]+>/, "").strip
|
|
105
|
+
snippet = CGI.unescapeHTML(snippet) if snippet.include?("&")
|
|
105
106
|
end
|
|
107
|
+
|
|
108
|
+
results << {
|
|
109
|
+
title: title,
|
|
110
|
+
url: url,
|
|
111
|
+
snippet: snippet
|
|
112
|
+
}
|
|
106
113
|
end
|
|
107
114
|
|
|
108
115
|
# If parsing failed, provide a fallback
|
|
109
116
|
if results.empty?
|
|
110
117
|
results << {
|
|
111
118
|
title: "Web search results",
|
|
112
|
-
url: "https://duckduckgo.com
|
|
113
|
-
snippet: "Could not parse search results.
|
|
119
|
+
url: "https://duckduckgo.com/",
|
|
120
|
+
snippet: "Could not parse search results. Please try again."
|
|
114
121
|
}
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
results
|
|
118
|
-
rescue StandardError
|
|
119
|
-
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
# Return fallback on error
|
|
127
|
+
[{
|
|
128
|
+
title: "Web search error",
|
|
129
|
+
url: "https://duckduckgo.com/",
|
|
130
|
+
snippet: "Error parsing results: #{e.message}"
|
|
131
|
+
}]
|
|
120
132
|
end
|
|
121
133
|
|
|
122
134
|
def format_call(args)
|
|
@@ -127,10 +139,10 @@ module Clacky
|
|
|
127
139
|
|
|
128
140
|
def format_result(result)
|
|
129
141
|
if result[:error]
|
|
130
|
-
"
|
|
142
|
+
"[Error] #{result[:error]}"
|
|
131
143
|
else
|
|
132
144
|
count = result[:count] || 0
|
|
133
|
-
"
|
|
145
|
+
"[OK] Found #{count} results"
|
|
134
146
|
end
|
|
135
147
|
end
|
|
136
148
|
end
|