openclacky 0.7.0 → 0.7.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ # Tool execution and permission management
6
+ # Handles tool confirmation, preview, and result building
7
+ module ToolExecutor
8
+ # Check if a tool should be auto-executed based on permission mode
9
+ # @param tool_name [String] Name of the tool
10
+ # @param tool_params [Hash, String] Tool parameters
11
+ # @return [Boolean] true if should auto-execute
12
+ def should_auto_execute?(tool_name, tool_params = {})
13
+ case @config.permission_mode
14
+ when :auto_approve
15
+ true
16
+ when :confirm_safes
17
+ # Use SafeShell integration for safety check
18
+ is_safe_operation?(tool_name, tool_params)
19
+ when :plan_only
20
+ false
21
+ else
22
+ false
23
+ end
24
+ end
25
+
26
+ # Check if an operation is considered safe for auto-execution
27
+ # @param tool_name [String] Name of the tool
28
+ # @param tool_params [Hash, String] Tool parameters
29
+ # @return [Boolean] true if safe operation
30
+ def is_safe_operation?(tool_name, tool_params = {})
31
+ # For shell commands, use SafeShell to check safety
32
+ if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
33
+ params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
34
+ command = params[:command] || params['command']
35
+ return false unless command
36
+
37
+ return Tools::SafeShell.command_safe_for_auto_execution?(command)
38
+ end
39
+
40
+ if tool_name.to_s.downcase == 'edit' || tool_name.to_s.downcase == 'write'
41
+ return false
42
+ end
43
+
44
+ true
45
+ end
46
+
47
+ # Request user confirmation for tool execution
48
+ # Shows preview and returns approval status
49
+ # @param call [Hash] Tool call with :name and :arguments
50
+ # @return [Hash] { approved: Boolean, feedback: String, system_injected: Boolean }
51
+ def confirm_tool_use?(call)
52
+ # Show preview first and check for errors
53
+ preview_error = show_tool_preview(call)
54
+
55
+ # If preview detected an error, auto-deny and provide feedback
56
+ if preview_error && preview_error[:error]
57
+ feedback = build_preview_error_feedback(call[:name], preview_error)
58
+ return { approved: false, feedback: feedback, system_injected: true }
59
+ end
60
+
61
+ # Request confirmation via UI
62
+ if @ui
63
+ prompt_text = format_tool_prompt(call)
64
+ result = @ui.request_confirmation(prompt_text, default: true)
65
+
66
+ case result
67
+ when true
68
+ { approved: true, feedback: nil }
69
+ when false, nil
70
+ # User denied - add visual marker based on tool type
71
+ tool_name_capitalized = call[:name].capitalize
72
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
73
+ { approved: false, feedback: nil }
74
+ else
75
+ # String feedback - also add visual marker
76
+ tool_name_capitalized = call[:name].capitalize
77
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
78
+ { approved: false, feedback: result.to_s }
79
+ end
80
+ else
81
+ # Fallback: auto-approve if no UI
82
+ { approved: true, feedback: nil }
83
+ end
84
+ end
85
+
86
+ # Show preview for tool execution
87
+ # @param call [Hash] Tool call with :name and :arguments
88
+ # @return [Hash, nil] Error information if preview detected issues
89
+ def show_tool_preview(call)
90
+ return nil unless @ui
91
+
92
+ begin
93
+ args = JSON.parse(call[:arguments], symbolize_names: true)
94
+
95
+ preview_error = nil
96
+ case call[:name]
97
+ when "write"
98
+ preview_error = show_write_preview(args)
99
+ when "edit"
100
+ preview_error = show_edit_preview(args)
101
+ when "shell", "safe_shell"
102
+ show_shell_preview(args)
103
+ else
104
+ # For other tools, show formatted arguments
105
+ tool = @tool_registry.get(call[:name]) rescue nil
106
+ if tool
107
+ formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
108
+ @ui&.show_tool_args(formatted)
109
+ else
110
+ @ui&.show_tool_args(call[:arguments])
111
+ end
112
+ end
113
+
114
+ preview_error
115
+ rescue JSON::ParserError
116
+ @ui&.show_tool_args(call[:arguments])
117
+ nil
118
+ end
119
+ end
120
+
121
+ # Format tool call for user confirmation prompt
122
+ # @param call [Hash] Tool call with :name and :arguments
123
+ # @return [String] Formatted prompt text
124
+ def format_tool_prompt(call)
125
+ begin
126
+ args = JSON.parse(call[:arguments], symbolize_names: true)
127
+
128
+ # Try to use tool's format_call method for better formatting
129
+ tool = @tool_registry.get(call[:name]) rescue nil
130
+ if tool
131
+ formatted = tool.format_call(args) rescue nil
132
+ return formatted if formatted
133
+ end
134
+
135
+ # Fallback to manual formatting for common tools
136
+ case call[:name]
137
+ when "edit"
138
+ path = args[:path] || args[:file_path]
139
+ filename = Utils::PathHelper.safe_basename(path)
140
+ "Edit(#{filename})"
141
+ when "write"
142
+ filename = Utils::PathHelper.safe_basename(args[:path])
143
+ if args[:path] && File.exist?(args[:path])
144
+ "Write(#{filename}) - overwrite existing"
145
+ else
146
+ "Write(#{filename}) - create new"
147
+ end
148
+ when "shell", "safe_shell"
149
+ cmd = args[:command] || ''
150
+ display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
151
+ "#{call[:name]}(\"#{display_cmd}\")"
152
+ else
153
+ "Allow #{call[:name]}"
154
+ end
155
+ rescue JSON::ParserError
156
+ "Allow #{call[:name]}"
157
+ end
158
+ end
159
+
160
+ # Build success result for tool execution
161
+ # @param call [Hash] Tool call
162
+ # @param result [Object] Tool execution result
163
+ # @return [Hash] Formatted result for LLM
164
+ def build_success_result(call, result)
165
+ # Try to get tool instance to use its format_result_for_llm method
166
+ tool = @tool_registry.get(call[:name]) rescue nil
167
+
168
+ formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
169
+ # Tool provides a custom LLM-friendly format
170
+ tool.format_result_for_llm(result)
171
+ else
172
+ # Fallback: use the original result
173
+ result
174
+ end
175
+
176
+ # Inject TODO reminder for non-todo_manager tools
177
+ formatted_result = inject_todo_reminder(call[:name], formatted_result)
178
+
179
+ {
180
+ id: call[:id],
181
+ content: JSON.generate(formatted_result)
182
+ }
183
+ end
184
+
185
+ # Build error result for tool execution
186
+ # @param call [Hash] Tool call
187
+ # @param error_message [String] Error message
188
+ # @return [Hash] Formatted error result
189
+ def build_error_result(call, error_message)
190
+ {
191
+ id: call[:id],
192
+ content: JSON.generate({ error: error_message })
193
+ }
194
+ end
195
+
196
+ # Build denied result when user denies tool execution
197
+ # @param call [Hash] Tool call
198
+ # @param user_feedback [String, nil] User's feedback message
199
+ # @param system_injected [Boolean] Whether this is a system-generated denial
200
+ # @return [Hash] Formatted denial result
201
+ def build_denied_result(call, user_feedback = nil, system_injected = false)
202
+ if system_injected
203
+ # System-generated feedback (e.g., from preview errors)
204
+ tool_content = {
205
+ error: "Tool #{call[:name]} denied: #{user_feedback}",
206
+ system_injected: true
207
+ }
208
+ else
209
+ # User manually denied or provided feedback
210
+ message = if user_feedback && !user_feedback.empty?
211
+ "Tool use denied by user. User feedback: #{user_feedback}"
212
+ else
213
+ "Tool use denied by user"
214
+ end
215
+
216
+ tool_content = {
217
+ error: message,
218
+ user_feedback: user_feedback
219
+ }
220
+ end
221
+
222
+ {
223
+ id: call[:id],
224
+ content: JSON.generate(tool_content)
225
+ }
226
+ end
227
+
228
+ # Build planned result for plan-only mode
229
+ # @param call [Hash] Tool call
230
+ # @return [Hash] Formatted planned result
231
+ def build_planned_result(call)
232
+ {
233
+ id: call[:id],
234
+ content: JSON.generate({ planned: true, message: "Tool execution skipped (plan mode)" })
235
+ }
236
+ end
237
+
238
+ # Check if a tool is potentially slow and should show progress
239
+ # @param tool_name [String] Name of the tool
240
+ # @param args [Hash] Tool arguments
241
+ # @return [Boolean] true if tool is potentially slow
242
+ private def potentially_slow_tool?(tool_name, args)
243
+ case tool_name.to_s.downcase
244
+ when 'shell', 'safe_shell'
245
+ # Check if the command is a slow command
246
+ command = args[:command] || args['command']
247
+ return false unless command
248
+
249
+ # List of slow command patterns
250
+ slow_patterns = [
251
+ /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
252
+ /npm\s+(install|run\s+test|run\s+build)/,
253
+ /yarn\s+(install|test|build)/,
254
+ /pnpm\s+install/,
255
+ /cargo\s+(build|test)/,
256
+ /go\s+(build|test)/,
257
+ /make\s+(test|build)/,
258
+ /pytest/,
259
+ /jest/
260
+ ]
261
+
262
+ slow_patterns.any? { |pattern| command.match?(pattern) }
263
+ when 'web_fetch', 'web_search'
264
+ true # Network operations can be slow
265
+ else
266
+ false # Most file operations are fast
267
+ end
268
+ end
269
+
270
+ # Build progress message for tool execution
271
+ # @param tool_name [String] Name of the tool
272
+ # @param args [Hash] Tool arguments
273
+ # @return [String] Progress message
274
+ private def build_tool_progress_message(tool_name, args)
275
+ case tool_name.to_s.downcase
276
+ when 'shell', 'safe_shell'
277
+ "Running command"
278
+ when 'web_fetch'
279
+ "Fetching web page"
280
+ when 'web_search'
281
+ "Searching web"
282
+ else
283
+ "Executing #{tool_name}"
284
+ end
285
+ end
286
+
287
+ # Inject TODO reminder into tool results for non-todo_manager tools
288
+ # This helps AI remember to mark TODOs as complete after executing tasks
289
+ # @param tool_name [String] Name of the tool
290
+ # @param result [Object] Tool execution result
291
+ # @return [Object] Result with optional TODO reminder
292
+ private def inject_todo_reminder(tool_name, result)
293
+ # Skip injection for todo_manager tool itself to avoid redundancy
294
+ return result if tool_name == "todo_manager"
295
+
296
+ # Get pending TODOs
297
+ todo_tool = @tool_registry.get("todo_manager")
298
+ return result unless todo_tool
299
+
300
+ pending_todos = begin
301
+ todo_result = todo_tool.execute(action: "list", todos_storage: @todos)
302
+ if todo_result.is_a?(Hash) && todo_result[:todos]
303
+ todo_result[:todos].select { |t| t[:status] == "pending" }
304
+ else
305
+ []
306
+ end
307
+ rescue
308
+ []
309
+ end
310
+
311
+ # Only inject reminder if there are pending TODOs
312
+ return result unless pending_todos && !pending_todos.empty?
313
+
314
+ # Create a friendly reminder message
315
+ reminder = "\n\n📋 REMINDER: You have #{pending_todos.length} pending TODO(s). " \
316
+ "After completing each task, remember to mark it as complete using " \
317
+ "todo_manager with action 'complete' and the task id."
318
+
319
+ # Inject reminder based on result type
320
+ case result
321
+ when String
322
+ result + reminder
323
+ when Hash
324
+ result.merge({ _todo_reminder: reminder.strip })
325
+ when Array
326
+ result + [{ _todo_reminder: reminder.strip }]
327
+ else
328
+ result
329
+ end
330
+ end
331
+
332
+ # Build feedback message from preview error
333
+ # @param tool_name [String] Name of the tool
334
+ # @param error_info [Hash] Error information from preview
335
+ # @return [String] Feedback message
336
+ private def build_preview_error_feedback(tool_name, error_info)
337
+ case tool_name
338
+ when "edit"
339
+ "Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
340
+ "Please use file_reader to read '#{error_info[:path]}' first, " \
341
+ "find the correct string to replace, and try again with the exact string (including whitespace)."
342
+ else
343
+ "Tool preview error: #{error_info[:error]}"
344
+ end
345
+ end
346
+
347
+ # Show preview for write tool
348
+ # @param args [Hash] Write tool arguments
349
+ # @return [nil] Always returns nil (no errors for write)
350
+ private def show_write_preview(args)
351
+ path = args[:path] || args['path']
352
+ new_content = args[:content] || args['content'] || ""
353
+
354
+ is_new_file = !(path && File.exist?(path))
355
+ @ui&.show_file_write_preview(path, is_new_file: is_new_file)
356
+
357
+ if is_new_file
358
+ @ui&.show_diff("", new_content, max_lines: 50)
359
+ else
360
+ old_content = File.read(path)
361
+ @ui&.show_diff(old_content, new_content, max_lines: 50)
362
+ end
363
+ nil
364
+ end
365
+
366
+ # Show preview for edit tool
367
+ # @param args [Hash] Edit tool arguments
368
+ # @return [Hash, nil] Error information if preview detected issues
369
+ private def show_edit_preview(args)
370
+ path = args[:path] || args[:file_path] || args['path'] || args['file_path']
371
+ old_string = args[:old_string] || args['old_string'] || ""
372
+ new_string = args[:new_string] || args['new_string'] || ""
373
+ replace_all = args[:replace_all] || args['replace_all'] || false
374
+
375
+ @ui&.show_file_edit_preview(path)
376
+
377
+ if !path || path.empty?
378
+ @ui&.show_file_error("No file path provided")
379
+ return { error: "No file path provided for edit operation" }
380
+ end
381
+
382
+ unless File.exist?(path)
383
+ @ui&.show_file_error("File not found: #{path}")
384
+ return { error: "File not found: #{path}", path: path }
385
+ end
386
+
387
+ if old_string.empty?
388
+ @ui&.show_file_error("No old_string provided (nothing to replace)")
389
+ return { error: "No old_string provided (nothing to replace)" }
390
+ end
391
+
392
+ file_content = File.read(path)
393
+
394
+ # Check if old_string exists in file
395
+ unless file_content.include?(old_string)
396
+ # Log debug info for troubleshooting
397
+ @debug_logs << {
398
+ timestamp: Time.now.iso8601,
399
+ event: "edit_preview_failed",
400
+ path: path,
401
+ looking_for: old_string[0..500],
402
+ file_content_preview: file_content[0..1000],
403
+ file_size: file_content.length
404
+ }
405
+
406
+ @ui&.show_file_error("Edit file error")
407
+ return {
408
+ error: "String to replace not found in file",
409
+ path: path,
410
+ looking_for: old_string[0..200]
411
+ }
412
+ end
413
+
414
+ # Use the same replace logic as the actual tool execution
415
+ new_content = if replace_all
416
+ file_content.gsub(old_string, new_string)
417
+ else
418
+ file_content.sub(old_string, new_string)
419
+ end
420
+ @ui&.show_diff(file_content, new_content, max_lines: 50)
421
+ nil # No error
422
+ end
423
+
424
+ # Show preview for shell tool
425
+ # @param args [Hash] Shell tool arguments
426
+ # @return [nil] Always returns nil
427
+ private def show_shell_preview(args)
428
+ command = args[:command] || ""
429
+ @ui&.show_shell_preview(command)
430
+ nil
431
+ end
432
+ end
433
+ end
434
+ end
@@ -14,7 +14,7 @@ module Clacky
14
14
  # Handle shell alias to safe_shell for backward compatibility
15
15
  name = 'safe_shell' if name == 'shell' && @tools.key?('safe_shell') && !@tools.key?('shell')
16
16
 
17
- @tools[name] || raise(Error, "Tool not found: #{name}")
17
+ @tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
18
18
  end
19
19
 
20
20
  def all