openclacky 0.6.0 → 0.6.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -88
  4. data/homebrew/README.md +96 -0
  5. data/homebrew/openclacky.rb +24 -0
  6. data/lib/clacky/agent.rb +139 -67
  7. data/lib/clacky/cli.rb +105 -6
  8. data/lib/clacky/tools/file_reader.rb +135 -2
  9. data/lib/clacky/tools/glob.rb +2 -2
  10. data/lib/clacky/tools/grep.rb +2 -2
  11. data/lib/clacky/tools/run_project.rb +5 -5
  12. data/lib/clacky/tools/safe_shell.rb +140 -17
  13. data/lib/clacky/tools/shell.rb +69 -2
  14. data/lib/clacky/tools/todo_manager.rb +50 -3
  15. data/lib/clacky/tools/trash_manager.rb +1 -1
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +2 -2
  18. data/lib/clacky/ui2/components/common_component.rb +14 -5
  19. data/lib/clacky/ui2/components/input_area.rb +300 -89
  20. data/lib/clacky/ui2/components/message_component.rb +7 -3
  21. data/lib/clacky/ui2/components/todo_area.rb +38 -45
  22. data/lib/clacky/ui2/components/welcome_banner.rb +10 -0
  23. data/lib/clacky/ui2/layout_manager.rb +180 -50
  24. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  25. data/lib/clacky/ui2/screen_buffer.rb +26 -7
  26. data/lib/clacky/ui2/themes/base_theme.rb +32 -46
  27. data/lib/clacky/ui2/themes/hacker_theme.rb +4 -2
  28. data/lib/clacky/ui2/themes/minimal_theme.rb +4 -2
  29. data/lib/clacky/ui2/ui_controller.rb +150 -32
  30. data/lib/clacky/ui2/view_renderer.rb +21 -4
  31. data/lib/clacky/ui2.rb +0 -1
  32. data/lib/clacky/utils/arguments_parser.rb +7 -2
  33. data/lib/clacky/utils/file_processor.rb +201 -0
  34. data/lib/clacky/version.rb +1 -1
  35. data/scripts/install.sh +249 -0
  36. data/scripts/uninstall.sh +146 -0
  37. metadata +21 -2
  38. data/lib/clacky/ui2/components/output_area.rb +0 -112
@@ -122,13 +122,13 @@ module Clacky
122
122
 
123
123
  def format_result(result)
124
124
  if result[:error]
125
- " #{result[:error]}"
125
+ "[Error] #{result[:error]}"
126
126
  else
127
127
  count = result[:returned] || 0
128
128
  total = result[:total_matches] || 0
129
129
  truncated = result[:truncated] ? " (truncated)" : ""
130
130
 
131
- msg = " Found #{count}/#{total} files#{truncated}"
131
+ msg = "[OK] Found #{count}/#{total} files#{truncated}"
132
132
 
133
133
  # Add skipped files info if present
134
134
  if result[:skipped_files]
@@ -212,11 +212,11 @@ module Clacky
212
212
 
213
213
  def format_result(result)
214
214
  if result[:error]
215
- " #{result[:error]}"
215
+ "[Error] #{result[:error]}"
216
216
  else
217
217
  matches = result[:total_matches] || 0
218
218
  files = result[:files_with_matches] || 0
219
- msg = " Found #{matches} matches in #{files} files"
219
+ msg = "[OK] Found #{matches} matches in #{files} files"
220
220
 
221
221
  # Add truncation info if present
222
222
  if result[:truncated] && result[:truncation_reason]
@@ -74,22 +74,22 @@ module Clacky
74
74
 
75
75
  def format_result(result)
76
76
  if result[:error]
77
- " #{result[:error]}"
77
+ "[Error] #{result[:error]}"
78
78
  elsif result[:status]
79
79
  case result[:status]
80
80
  when 'started'
81
81
  cmd_preview = result[:command] ? result[:command][0..50] : ''
82
82
  output_preview = result[:output]&.lines&.first(2)&.join&.strip
83
- msg = " Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
83
+ msg = "[OK] Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
84
84
  msg += "\n #{output_preview}" if output_preview && !output_preview.empty?
85
85
  msg
86
86
  when 'stopped'
87
- " Stopped"
87
+ "[OK] Stopped"
88
88
  when 'running'
89
89
  uptime = result[:uptime] ? "#{result[:uptime].round(1)}s" : "unknown"
90
- "Running (#{uptime}, PID: #{result[:pid]})"
90
+ "[Running] #{uptime}, PID: #{result[:pid]}"
91
91
  when 'not_running'
92
- "Not running"
92
+ "[Not Running]"
93
93
  else
94
94
  result[:status].to_s
95
95
  end
@@ -62,7 +62,7 @@ module Clacky
62
62
  {
63
63
  command: command,
64
64
  stdout: "",
65
- stderr: "🔒 Security Protection: #{e.message}",
65
+ stderr: "[Security Protection] #{e.message}",
66
66
  exit_code: 126,
67
67
  success: false,
68
68
  security_blocked: true
@@ -72,7 +72,10 @@ module Clacky
72
72
 
73
73
  private def extract_timeout_from_command(command)
74
74
  # Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
75
+ # Also supports: "cd xxx && timeout 30 command", "export X=Y && timeout 30 command"
75
76
  # Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
77
+
78
+ # Try to match timeout at the beginning of command
76
79
  match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
77
80
 
78
81
  if match
@@ -81,7 +84,22 @@ module Clacky
81
84
  return [actual_command, timeout_value]
82
85
  end
83
86
 
84
- # No timeout prefix found, return original command
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
85
103
  [command, nil]
86
104
  end
87
105
 
@@ -135,7 +153,7 @@ module Clacky
135
153
  result[:safe_command] = safe_command
136
154
 
137
155
  # Add security note to stdout
138
- security_note = "🔒 Command was automatically made safe\n"
156
+ security_note = "[Safe] Command was automatically made safe\n"
139
157
  result[:stdout] = security_note + (result[:stdout] || "")
140
158
  end
141
159
 
@@ -160,18 +178,111 @@ module Clacky
160
178
  stderr = result[:stderr] || result['stderr'] || ""
161
179
 
162
180
  if result[:security_blocked]
163
- "🔒 Blocked for security"
181
+ "[Blocked] Security protection"
164
182
  elsif result[:security_enhanced]
165
183
  lines = stdout.lines.size
166
- "🔒✓ Safe execution#{lines > 0 ? " (#{lines} lines)" : ''}"
184
+ "[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
167
185
  elsif exit_code == 0
168
186
  lines = stdout.lines.size
169
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
187
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
188
+ else
189
+ format_non_zero_exit(exit_code, stdout, stderr)
190
+ end
191
+ end
192
+
193
+ # Override format_result_for_llm to preserve security fields
194
+ def format_result_for_llm(result)
195
+ # If security blocked, return as-is (small and important)
196
+ return result if result[:security_blocked]
197
+
198
+ # Call parent's format_result_for_llm to truncate output
199
+ compact = super(result)
200
+
201
+ # Add security enhancement fields if present (they're small and important for LLM to understand)
202
+ if result[:security_enhanced]
203
+ compact[:security_enhanced] = true
204
+ compact[:original_command] = result[:original_command]
205
+ compact[:safe_command] = result[:safe_command]
206
+ end
207
+
208
+ compact
209
+ end
210
+
211
+ private def format_non_zero_exit(exit_code, stdout, stderr)
212
+ stdout_lines = stdout.lines.size
213
+ has_output = stdout_lines > 0
214
+ has_error = !stderr.empty?
215
+
216
+ if has_error
217
+ # Real error: show error summary
218
+ error_summary = extract_error_summary(stderr)
219
+ "[Exit #{exit_code}] #{error_summary}"
220
+ elsif has_output
221
+ # Command produced output but exited with non-zero code
222
+ # This is common in commands like "ls; exit 1" or grep with no matches
223
+ "[Exit #{exit_code}] #{stdout_lines} lines output"
170
224
  else
171
- error_msg = stderr.lines.first&.strip || "Failed"
172
- "Exit #{exit_code}: #{error_msg[0..50]}"
225
+ # No output, no error message - just show exit code
226
+ "[Exit #{exit_code}] No output"
173
227
  end
174
228
  end
229
+
230
+ private def extract_error_summary(stderr)
231
+ return "No error message" if stderr.empty?
232
+
233
+ # Try to extract the most meaningful error line
234
+ lines = stderr.lines.map(&:strip).reject(&:empty?)
235
+
236
+ # Common error patterns with priority
237
+ patterns = [
238
+ # Ruby/Python exceptions with error type
239
+ { regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
240
+ # File not found patterns
241
+ { regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
242
+ { regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
243
+ # Undefined method/variable
244
+ { regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
245
+ # Syntax errors
246
+ { regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
247
+ ]
248
+
249
+ # Try each pattern on each line
250
+ patterns.each do |pattern|
251
+ lines.each do |line|
252
+ match = line.match(pattern[:regex])
253
+ if match
254
+ result = pattern[:format].call(match)
255
+ return truncate_error(clean_path(result), 80)
256
+ end
257
+ end
258
+ end
259
+
260
+ # Fallback: find the most informative line
261
+ informative_line = lines.find do |line|
262
+ !line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
263
+ line.length > 10 &&
264
+ (line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
265
+ end
266
+
267
+ if informative_line
268
+ return truncate_error(clean_path(informative_line), 80)
269
+ end
270
+
271
+ # Last resort: use first meaningful line
272
+ first_line = lines.first || "Unknown error"
273
+ truncate_error(clean_path(first_line), 80)
274
+ end
275
+
276
+ private def clean_path(text)
277
+ # Remove long absolute paths, keep only filename
278
+ text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
279
+ .gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
280
+ end
281
+
282
+ private def truncate_error(text, max_length)
283
+ return text if text.length <= max_length
284
+ "#{text[0...max_length-3]}..."
285
+ end
175
286
  end
176
287
 
177
288
  class CommandSafetyReplacer
@@ -238,7 +349,12 @@ module Clacky
238
349
 
239
350
  def replace_chmod_command(command)
240
351
  # Parse chmod command to ensure it's safe
241
- parts = Shellwords.split(command)
352
+ begin
353
+ parts = Shellwords.split(command)
354
+ rescue Shellwords::BadQuotedString => e
355
+ # If Shellwords.split fails, use simple split as fallback
356
+ parts = command.split(/\s+/)
357
+ end
242
358
 
243
359
  # Only allow chmod +x on files in project directory
244
360
  files = parts[2..-1] || []
@@ -247,8 +363,6 @@ module Clacky
247
363
  # Allow chmod +x as it's generally safe
248
364
  log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
249
365
  command
250
- rescue Shellwords::BadQuotedString
251
- raise SecurityError, "Invalid chmod command syntax: #{command}"
252
366
  end
253
367
 
254
368
  def replace_curl_pipe_command(command)
@@ -277,7 +391,14 @@ module Clacky
277
391
 
278
392
  def validate_and_allow(command)
279
393
  # Check basic file operation commands
280
- parts = Shellwords.split(command)
394
+ begin
395
+ parts = Shellwords.split(command)
396
+ rescue Shellwords::BadQuotedString => e
397
+ # If Shellwords.split fails due to quote issues, try simple split as fallback
398
+ # This handles cases where paths don't actually need shell escaping
399
+ parts = command.split(/\s+/)
400
+ end
401
+
281
402
  cmd = parts.first
282
403
  args = parts[1..-1]
283
404
 
@@ -291,8 +412,6 @@ module Clacky
291
412
  end
292
413
 
293
414
  command
294
- rescue Shellwords::BadQuotedString
295
- raise SecurityError, "Invalid command syntax: #{command}"
296
415
  end
297
416
 
298
417
  def validate_general_command(command)
@@ -326,11 +445,15 @@ module Clacky
326
445
  end
327
446
 
328
447
  def parse_rm_files(command)
329
- parts = Shellwords.split(command)
448
+ begin
449
+ parts = Shellwords.split(command)
450
+ rescue Shellwords::BadQuotedString => e
451
+ # If Shellwords.split fails, use simple split as fallback
452
+ parts = command.split(/\s+/)
453
+ end
454
+
330
455
  # Skip rm command itself and option parameters
331
456
  parts.drop(1).reject { |part| part.start_with?('-') }
332
- rescue Shellwords::BadQuotedString
333
- raise SecurityError, "Invalid command syntax: #{command}"
334
457
  end
335
458
 
336
459
  def validate_file_path(path)
@@ -284,12 +284,79 @@ module Clacky
284
284
 
285
285
  if exit_code == 0
286
286
  lines = stdout.lines.size
287
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
287
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
288
288
  else
289
289
  error_msg = stderr.lines.first&.strip || "Failed"
290
- "Exit #{exit_code}: #{error_msg[0..50]}"
290
+ "[Exit #{exit_code}] #{error_msg[0..50]}"
291
291
  end
292
292
  end
293
+
294
+ # Format result for LLM consumption - limit output size to save tokens
295
+ # Maximum characters to include in LLM output
296
+ MAX_LLM_OUTPUT_CHARS = 2000
297
+
298
+ def format_result_for_llm(result)
299
+ # Return error info as-is if command failed or timed out
300
+ return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
301
+
302
+ stdout = result[:stdout] || ""
303
+ stderr = result[:stderr] || ""
304
+ exit_code = result[:exit_code] || 0
305
+
306
+ # Build compact result with truncated output
307
+ compact = {
308
+ command: result[:command],
309
+ exit_code: exit_code,
310
+ success: result[:success]
311
+ }
312
+
313
+ # Add elapsed time if available
314
+ compact[:elapsed] = result[:elapsed] if result[:elapsed]
315
+
316
+ # Truncate stdout to save tokens
317
+ if stdout.empty?
318
+ compact[:stdout] = ""
319
+ else
320
+ stdout_lines = stdout.lines
321
+ stdout_line_count = stdout_lines.length
322
+
323
+ if stdout.length <= MAX_LLM_OUTPUT_CHARS
324
+ compact[:stdout] = stdout
325
+ else
326
+ # Take first N lines that fit within the character limit
327
+ accumulated_chars = 0
328
+ lines_to_include = []
329
+
330
+ stdout_lines.each do |line|
331
+ break if accumulated_chars + line.length > MAX_LLM_OUTPUT_CHARS
332
+ lines_to_include << line
333
+ accumulated_chars += line.length
334
+ end
335
+
336
+ compact[:stdout] = lines_to_include.join
337
+ compact[:stdout] += "\n\n... [Output truncated for LLM: showing #{lines_to_include.length} of #{stdout_line_count} lines, #{accumulated_chars} of #{stdout.length} chars] ...\n"
338
+ end
339
+ end
340
+
341
+ # Truncate stderr to save tokens (usually more important than stdout, so keep more)
342
+ if stderr.empty?
343
+ compact[:stderr] = ""
344
+ else
345
+ stderr_line_count = stderr.lines.length
346
+
347
+ if stderr.length <= MAX_LLM_OUTPUT_CHARS
348
+ compact[:stderr] = stderr
349
+ else
350
+ compact[:stderr] = stderr[0...MAX_LLM_OUTPUT_CHARS]
351
+ compact[:stderr] += "\n\n... [Error output truncated for LLM: showing #{MAX_LLM_OUTPUT_CHARS} of #{stderr.length} chars, #{stderr_line_count} lines] ...\n"
352
+ end
353
+ end
354
+
355
+ # Add output_truncated flag
356
+ compact[:output_truncated] = result[:output_truncated] if result[:output_truncated]
357
+
358
+ compact
359
+ end
293
360
  end
294
361
  end
295
362
  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
- remove_todo(id)
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
- "TodoManager(remove ##{args[:id] || args['id']})"
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
@@ -363,7 +363,7 @@ module Clacky
363
363
  when 'help'
364
364
  "❓ Help displayed"
365
365
  else
366
- success ? " #{action} completed" : " #{action} failed"
366
+ success ? "[OK] #{action} completed" : "[Error] #{action} failed"
367
367
  end
368
368
  end
369
369
  end
@@ -149,11 +149,11 @@ module Clacky
149
149
 
150
150
  def format_result(result)
151
151
  if result[:error]
152
- " #{result[:error]}"
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
- " Fetched: #{display_title}"
156
+ "[OK] Fetched: #{display_title}"
157
157
  end
158
158
  end
159
159
  end
@@ -139,10 +139,10 @@ module Clacky
139
139
 
140
140
  def format_result(result)
141
141
  if result[:error]
142
- " #{result[:error]}"
142
+ "[Error] #{result[:error]}"
143
143
  else
144
144
  count = result[:count] || 0
145
- " Found #{count} results"
145
+ "[OK] Found #{count} results"
146
146
  end
147
147
  end
148
148
  end
@@ -15,7 +15,7 @@ module Clacky
15
15
  "#{symbol} #{text}"
16
16
  end
17
17
 
18
- # Render progress indicator
18
+ # Render progress indicator (stopped state, gray)
19
19
  # @param message [String] Progress message
20
20
  # @return [String] Progress indicator
21
21
  def render_progress(message)
@@ -24,6 +24,15 @@ module Clacky
24
24
  "#{symbol} #{text}"
25
25
  end
26
26
 
27
+ # Render working indicator (active state, yellow)
28
+ # @param message [String] Progress message
29
+ # @return [String] Working indicator
30
+ def render_working(message)
31
+ symbol = format_symbol(:working)
32
+ text = format_text(message, :working)
33
+ "#{symbol} #{text}"
34
+ end
35
+
27
36
  # Render success message
28
37
  # @param message [String] Success message
29
38
  # @return [String] Success message
@@ -65,22 +74,22 @@ module Clacky
65
74
  lines << @pastel.dim("─" * 60)
66
75
  lines << render_success("Task Complete")
67
76
  lines << ""
68
-
77
+
69
78
  # Display each stat on a separate line
70
79
  lines << " Iterations: #{iterations}"
71
80
  lines << " Cost: $#{cost.round(4)}"
72
81
  lines << " Duration: #{duration.round(1)}s" if duration
73
-
82
+
74
83
  # Display cache information if available
75
84
  if cache_tokens && cache_tokens > 0
76
85
  lines << " Cache Tokens: #{cache_tokens} tokens"
77
86
  end
78
-
87
+
79
88
  if cache_requests && cache_requests > 0
80
89
  hit_rate = cache_hits > 0 ? ((cache_hits.to_f / cache_requests) * 100).round(1) : 0
81
90
  lines << " Cache Requests: #{cache_requests} (#{cache_hits} hits, #{hit_rate}% hit rate)"
82
91
  end
83
-
92
+
84
93
  lines.join("\n")
85
94
  end
86
95
  end