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
data/lib/clacky/skill.rb CHANGED
@@ -18,12 +18,17 @@ module Clacky
18
18
  agent
19
19
  argument-hint
20
20
  hooks
21
+ fork_agent
22
+ model
23
+ forbidden_tools
24
+ auto_summarize
21
25
  ].freeze
22
26
 
23
27
  attr_reader :directory, :frontmatter, :source_path
24
28
  attr_reader :name, :description, :content
25
29
  attr_reader :disable_model_invocation, :user_invocable
26
30
  attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
31
+ attr_reader :fork_agent, :model, :forbidden_tools, :auto_summarize
27
32
 
28
33
  # @param directory [Pathname, String] Path to the skill directory
29
34
  # @param source_path [Pathname, String, nil] Optional source path for priority resolution
@@ -52,10 +57,28 @@ module Clacky
52
57
  !@disable_model_invocation
53
58
  end
54
59
 
55
- # Check if skill runs in a forked subagent context
60
+ # Check if this skill should fork a subagent
56
61
  # @return [Boolean]
57
- def forked_context?
58
- @context == "fork"
62
+ def fork_agent?
63
+ @fork_agent == true
64
+ end
65
+
66
+ # Get the model to use for the subagent (if fork_agent is true)
67
+ # @return [String, nil]
68
+ def subagent_model
69
+ @model
70
+ end
71
+
72
+ # Get the list of forbidden tools for the subagent
73
+ # @return [Array<String>]
74
+ def forbidden_tools_list
75
+ @forbidden_tools || []
76
+ end
77
+
78
+ # Check if subagent should auto-summarize results
79
+ # @return [Boolean]
80
+ def auto_summarize?
81
+ @auto_summarize != false
59
82
  end
60
83
 
61
84
  # Get the slash command for this skill
@@ -101,6 +124,16 @@ module Clacky
101
124
  processed_content.gsub!(placeholder, output.to_s)
102
125
  end
103
126
 
127
+ # Append supporting files list if any exist
128
+ if has_supporting_files?
129
+ processed_content += "\n\n## Supporting Files\n\n"
130
+ processed_content += "The following files are available in this skill's directory:\n\n"
131
+ supporting_files.each do |file|
132
+ relative_path = file.relative_path_from(@directory)
133
+ processed_content += "- `#{relative_path}`\n"
134
+ end
135
+ end
136
+
104
137
  processed_content
105
138
  end
106
139
 
@@ -114,7 +147,9 @@ module Clacky
114
147
  source_path: @source_path.to_s,
115
148
  user_invocable: user_invocable?,
116
149
  model_invocation_allowed: model_invocation_allowed?,
117
- forked_context: forked_context?,
150
+ fork_agent: fork_agent?,
151
+ subagent_model: @model,
152
+ forbidden_tools: @forbidden_tools,
118
153
  allowed_tools: @allowed_tools,
119
154
  argument_hint: @argument_hint,
120
155
  content_length: @content.length
@@ -179,6 +214,12 @@ module Clacky
179
214
  @agent_type = @frontmatter["agent"]
180
215
  @argument_hint = @frontmatter["argument-hint"]
181
216
  @hooks = @frontmatter["hooks"]
217
+
218
+ # Subagent configuration
219
+ @fork_agent = @frontmatter["fork_agent"]
220
+ @model = @frontmatter["model"]
221
+ @forbidden_tools = @frontmatter["forbidden_tools"]
222
+ @auto_summarize = @frontmatter["auto_summarize"]
182
223
  end
183
224
 
184
225
  def validate_frontmatter
@@ -193,9 +234,9 @@ module Clacky
193
234
  end
194
235
  end
195
236
 
196
- # Validate context
197
- if @context && @context != "fork"
198
- raise Clacky::AgentError, "Invalid context '#{@context}'. Only 'fork' is supported."
237
+ # Validate forbidden_tools format
238
+ if @forbidden_tools && !@forbidden_tools.is_a?(Array)
239
+ raise Clacky::AgentError, "forbidden_tools must be an array of tool names"
199
240
  end
200
241
 
201
242
  # Validate allowed-tools format
@@ -127,6 +127,13 @@ module Clacky
127
127
  @skills_by_command[command]
128
128
  end
129
129
 
130
+ # Find a skill by its name (identifier)
131
+ # @param name [String] Skill identifier (e.g., "code-explorer", "pptx")
132
+ # @return [Skill, nil]
133
+ def find_by_name(name)
134
+ @skills[name]
135
+ end
136
+
130
137
  # Get skills that can be invoked by user
131
138
  # @return [Array<Skill>]
132
139
  def user_invocable_skills
@@ -46,22 +46,16 @@ module Clacky
46
46
  content = File.read(path)
47
47
  original_content = content.dup
48
48
 
49
- # Try exact match first
50
- if content.include?(old_string)
51
- actual_old_string = old_string
52
- occurrences = content.scan(old_string).length
53
- else
54
- # Try smart whitespace normalization
55
- match_result = try_smart_match(content, old_string)
56
-
57
- if match_result
58
- actual_old_string = match_result[:matched_string]
59
- occurrences = match_result[:occurrences]
60
- else
61
- # Provide helpful error with context
62
- return build_helpful_error(content, old_string, path)
63
- end
49
+ # Find matching string using layered strategy
50
+ match_result = find_match(content, old_string)
51
+
52
+ unless match_result
53
+ # Provide helpful error with context
54
+ return build_helpful_error(content, old_string, path)
64
55
  end
56
+
57
+ actual_old_string = match_result[:matched_string]
58
+ occurrences = match_result[:occurrences]
65
59
 
66
60
  # If not replace_all and multiple occurrences, warn about ambiguity
67
61
  if !replace_all && occurrences > 1
@@ -93,54 +87,117 @@ module Clacky
93
87
  end
94
88
  end
95
89
 
96
- private def try_smart_match(content, old_string)
97
- # Normalize whitespace: convert all leading whitespace to single space for comparison
98
- normalized_old = normalize_leading_whitespace(old_string)
90
+ # Find matching string using layered strategy
91
+ private def find_match(content, old_string)
92
+ # Generate candidate strings with different transformations
93
+ candidates = generate_candidates(old_string)
99
94
 
100
- # Find all potential matches in content with normalized whitespace
101
- matches = []
102
- content_lines = content.lines
103
- old_lines = old_string.lines
95
+ # Try simple string matching for each candidate
96
+ candidates.each do |candidate|
97
+ next if candidate.empty?
98
+ if content.include?(candidate)
99
+ return {
100
+ matched_string: candidate,
101
+ occurrences: content.scan(candidate).length
102
+ }
103
+ end
104
+ end
104
105
 
105
- return nil if old_lines.empty?
106
+ # If simple matching fails, try smart line-by-line matching (allows leading whitespace differences)
107
+ try_smart_match(content, old_string)
108
+ end
109
+
110
+ # Generate candidate strings by applying different transformations
111
+ private def generate_candidates(old_string)
112
+ trimmed = old_string.strip
113
+ unescaped = unescape_over_escaped(old_string)
114
+ unescaped_trimmed = unescape_over_escaped(trimmed)
106
115
 
107
- # Scan through content to find matches
108
- (0..content_lines.length - old_lines.length).each do |start_idx|
109
- slice = content_lines[start_idx, old_lines.length]
110
- next unless slice
116
+ [
117
+ old_string, # Original
118
+ trimmed, # Trim leading/trailing whitespace
119
+ unescaped, # Unescape over-escaped sequences
120
+ unescaped_trimmed # Combined: trim + unescape
121
+ ].uniq # Remove duplicates
122
+ end
123
+
124
+ private def try_smart_match(content, old_string)
125
+ # Smart matching: allows leading whitespace differences (tabs vs spaces)
126
+ # Also tries with unescaped versions of old_string
127
+
128
+ candidates = generate_candidates(old_string)
129
+
130
+ candidates.each do |candidate|
131
+ next if candidate.empty?
132
+
133
+ candidate_lines = candidate.lines
134
+ next if candidate_lines.empty?
111
135
 
112
- # Check if this slice matches when normalized
113
- if lines_match_normalized?(slice, old_lines)
114
- matched_string = slice.join
115
- matches << { start: start_idx, matched_string: matched_string }
136
+ # Find all potential matches in content with normalized whitespace
137
+ matches = []
138
+ content_lines = content.lines
139
+
140
+ # Scan through content to find matches
141
+ (0..content_lines.length - candidate_lines.length).each do |start_idx|
142
+ slice = content_lines[start_idx, candidate_lines.length]
143
+ next unless slice
144
+
145
+ # Check if this slice matches when normalized
146
+ if lines_match_normalized?(slice, candidate_lines)
147
+ matched_string = slice.join
148
+ matches << { start: start_idx, matched_string: matched_string }
149
+ end
150
+ end
151
+
152
+ # If we found matches with this candidate, return it
153
+ unless matches.empty?
154
+ return {
155
+ matched_string: matches.first[:matched_string],
156
+ occurrences: matches.length
157
+ }
116
158
  end
117
159
  end
118
160
 
119
- return nil if matches.empty?
120
-
121
- # Return the first match and count total occurrences
122
- {
123
- matched_string: matches.first[:matched_string],
124
- occurrences: matches.length
125
- }
161
+ # No matches found
162
+ nil
126
163
  end
127
164
 
128
- private def normalize_leading_whitespace(text)
129
- # Normalize each line's leading whitespace
130
- text.lines.map { |line| line.sub(/^\s+/, ' ') }.join
131
- end
132
165
 
133
166
  private def lines_match_normalized?(lines1, lines2)
134
167
  return false unless lines1.length == lines2.length
135
168
 
136
169
  lines1.zip(lines2).all? do |line1, line2|
137
- # Normalize leading whitespace and compare
138
- norm1 = line1.sub(/^\s+/, ' ')
139
- norm2 = line2.sub(/^\s+/, ' ')
140
- norm1 == norm2
170
+ # Normalize leading whitespace and trailing newlines for comparison
171
+ norm1 = line1.sub(/^\s+/, ' ').chomp
172
+ norm2 = line2.sub(/^\s+/, ' ').chomp
173
+
174
+ # Try exact match first, then try with unescaping over-escaped sequences
175
+ norm1 == norm2 || norm1 == unescape_over_escaped(norm2)
141
176
  end
142
177
  end
143
178
 
179
+ private def unescape_over_escaped(str)
180
+ # Convert over-escaped sequences back to normal escape sequences
181
+ # This handles common cases where AI double-escapes backslashes
182
+ result = str.dup
183
+
184
+ # Handle Unicode escapes: \uXXXX -> actual Unicode character
185
+ # Example: "\u000C" (literal backslash-u) -> form feed character
186
+ result = result.gsub(/\\u([0-9a-fA-F]{4})/) { [$1.hex].pack('U') }
187
+
188
+ # Handle common escape sequences
189
+ result = result.gsub('\\n', "\n")
190
+ result = result.gsub('\\t', "\t")
191
+ result = result.gsub('\\r', "\r")
192
+ result = result.gsub('\\f', "\f")
193
+ result = result.gsub('\\b', "\b")
194
+ result = result.gsub('\\v', "\v")
195
+ result = result.gsub('\\"', '"')
196
+ result = result.gsub('\\\\', '\\')
197
+
198
+ result
199
+ end
200
+
144
201
  private def build_helpful_error(content, old_string, path)
145
202
  # Find similar content to help debug
146
203
  old_lines = old_string.lines
@@ -174,7 +231,7 @@ module Clacky
174
231
  error: "String to replace not found in file. The first line of old_string exists at line #{similar_locations.first[:line_number]}, " \
175
232
  "but the full multi-line string doesn't match. This is often caused by whitespace differences (tabs vs spaces). " \
176
233
  "\n\nContext around line #{similar_locations.first[:line_number]}:\n#{context_display}\n\n" \
177
- "TIP: Make sure to copy the exact whitespace characters from the file. Use file_reader to see the actual content."
234
+ "TIP: Use file_reader to see the actual content, then retry. No need to explain, just execute the tools."
178
235
  }
179
236
  end
180
237
  end
@@ -183,7 +240,7 @@ module Clacky
183
240
  {
184
241
  error: "String to replace not found in file '#{File.basename(path)}'. " \
185
242
  "Make sure old_string matches exactly (including all whitespace). " \
186
- "TIP: Use file_reader to view the exact content first, then copy the exact string including all spaces and tabs."
243
+ "TIP: Use file_reader to view the exact content first, then retry. No need to explain, just execute the tools."
187
244
  }
188
245
  end
189
246
 
@@ -7,7 +7,7 @@ module Clacky
7
7
  module Tools
8
8
  class FileReader < Base
9
9
  self.tool_name = "file_reader"
10
- self.tool_description = "Read contents of a file from the filesystem"
10
+ self.tool_description = "Read contents of a file from the filesystem."
11
11
  self.tool_category = "file_system"
12
12
  self.tool_parameters = {
13
13
  type: "object",
@@ -21,10 +21,6 @@ module Clacky
21
21
  description: "Maximum number of lines to read from start (default: 500)",
22
22
  default: 500
23
23
  },
24
- keyword: {
25
- type: "string",
26
- description: "Search keyword and return matching lines with context (recommended for large files)"
27
- },
28
24
  start_line: {
29
25
  type: "integer",
30
26
  description: "Start line number (1-indexed, e.g., 100 reads from line 100)"
@@ -36,16 +32,22 @@ module Clacky
36
32
  },
37
33
  required: ["path"]
38
34
  }
39
-
35
+
40
36
 
41
37
 
42
38
  # Maximum text file size (1MB)
43
39
  MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024
44
40
 
45
- def execute(path:, max_lines: 500, keyword: nil, start_line: nil, end_line: nil)
41
+ # Maximum content size to return (~10,000 tokens = ~40,000 characters)
42
+ MAX_CONTENT_CHARS = 40_000
43
+
44
+ # Maximum characters per line (prevent single huge lines from bloating tokens)
45
+ MAX_LINE_CHARS = 1000
46
+
47
+ def execute(path:, max_lines: 500, start_line: nil, end_line: nil)
46
48
  # Expand ~ to home directory
47
49
  expanded_path = File.expand_path(path)
48
-
50
+
49
51
  unless File.exist?(expanded_path)
50
52
  return {
51
53
  path: expanded_path,
@@ -84,11 +86,6 @@ module Clacky
84
86
  }
85
87
  end
86
88
 
87
- # Handle keyword search with context
88
- if keyword && !keyword.empty?
89
- return find_with_context(expanded_path, keyword)
90
- end
91
-
92
89
  # Read text file with optional line range
93
90
  all_lines = File.readlines(expanded_path)
94
91
  total_lines = all_lines.size
@@ -130,11 +127,30 @@ module Clacky
130
127
  end
131
128
 
132
129
  lines = all_lines[start_idx..end_idx] || []
133
- truncated = total_lines > max_lines && !keyword
130
+
131
+ # Truncate individual lines that are too long
132
+ lines = lines.map do |line|
133
+ if line.length > MAX_LINE_CHARS
134
+ line[0...MAX_LINE_CHARS] + "... [Line truncated - #{line.length} chars]\n"
135
+ else
136
+ line
137
+ end
138
+ end
139
+
140
+ content = lines.join
141
+ truncated = end_idx < (total_lines - 1)
142
+
143
+ # Truncate total content if it exceeds maximum size
144
+ if content.length > MAX_CONTENT_CHARS
145
+ content = content[0...MAX_CONTENT_CHARS] +
146
+ "\n\n[Content truncated - exceeded #{MAX_CONTENT_CHARS} characters (~10,000 tokens)]" +
147
+ "\nUse start_line/end_line parameters to read specific sections, or grep tool to search for keywords."
148
+ truncated = true
149
+ end
134
150
 
135
151
  {
136
152
  path: expanded_path,
137
- content: lines.join,
153
+ content: content,
138
154
  lines_read: lines.size,
139
155
  total_lines: total_lines,
140
156
  truncated: truncated,
@@ -171,7 +187,7 @@ module Clacky
171
187
  if result[:binary] || result['binary']
172
188
  format_type = result[:format] || result['format'] || 'unknown'
173
189
  size = result[:size_bytes] || result['size_bytes'] || 0
174
-
190
+
175
191
  # Check if it has base64 data (LLM-compatible format)
176
192
  if result[:base64_data] || result['base64_data']
177
193
  size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
@@ -186,7 +202,7 @@ module Clacky
186
202
  truncated = result[:truncated] || result['truncated']
187
203
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
188
204
  end
189
-
205
+
190
206
  # Format result for LLM - handles both text and binary (image/PDF) content
191
207
  # This method is called by the agent to format tool results before sending to LLM
192
208
  def format_result_for_llm(result)
@@ -194,12 +210,12 @@ module Clacky
194
210
  if result[:binary] && result[:base64_data]
195
211
  # Create a text description
196
212
  description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
197
-
213
+
198
214
  # Add size warning for large files
199
215
  if result[:size_bytes] > 5_000_000
200
216
  description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
201
217
  end
202
-
218
+
203
219
  # For images, return both description and image content
204
220
  if result[:mime_type]&.start_with?("image/")
205
221
  return {
@@ -212,7 +228,7 @@ module Clacky
212
228
  description: description
213
229
  }
214
230
  end
215
-
231
+
216
232
  # For PDFs and other binary formats, just return metadata with base64
217
233
  return {
218
234
  type: "document",
@@ -224,56 +240,11 @@ module Clacky
224
240
  description: description
225
241
  }
226
242
  end
227
-
243
+
228
244
  # For other cases, return the result as-is (agent will JSON.generate it)
229
245
  result
230
246
  end
231
247
 
232
- # Find lines matching keyword with context (5 lines before and after each match)
233
- private def find_with_context(path, keyword)
234
- context_lines_count = 5
235
- all_lines = File.readlines(path)
236
- total_lines = all_lines.size
237
- matches = []
238
-
239
- # Find all matching line indices (case-insensitive)
240
- all_lines.each_with_index do |line, index|
241
- if line.include?(keyword)
242
- start_idx = [index - context_lines_count, 0].max
243
- end_idx = [index + context_lines_count, total_lines - 1].min
244
- matches << {
245
- line_number: index + 1,
246
- content: all_lines[start_idx..end_idx].join,
247
- start_line: start_idx + 1,
248
- end_line: end_idx + 1,
249
- match_line: index + 1
250
- }
251
- end
252
- end
253
-
254
- if matches.empty?
255
- {
256
- path: path,
257
- content: nil,
258
- matches_count: 0,
259
- error: "Keyword '#{keyword}' not found in file"
260
- }
261
- else
262
- # Combine all matches with separator
263
- combined_content = matches.map do |m|
264
- "... Lines #{m[:start_line]}-#{m[:end_line]} (match at line #{m[:line_number]}):\n#{m[:content]}"
265
- end.join("\n---\n")
266
-
267
- {
268
- path: path,
269
- content: combined_content,
270
- matches_count: matches.size,
271
- keyword: keyword,
272
- error: nil
273
- }
274
- end
275
- end
276
-
277
248
  private def handle_binary_file(path)
278
249
  # Check if it's a supported format using FileProcessor
279
250
  if Utils::FileProcessor.supported_binary_file?(path)
@@ -316,11 +287,11 @@ module Clacky
316
287
  }
317
288
  end
318
289
  end
319
-
290
+
320
291
  private def detect_mime_type(path, data)
321
292
  Utils::FileProcessor.detect_mime_type(path, data)
322
293
  end
323
-
294
+
324
295
  private def format_file_size(bytes)
325
296
  if bytes < 1024
326
297
  "#{bytes} bytes"
@@ -337,11 +308,11 @@ module Clacky
337
308
  private def list_directory_contents(path)
338
309
  begin
339
310
  entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
340
-
311
+
341
312
  # Separate files and directories
342
313
  files = []
343
314
  directories = []
344
-
315
+
345
316
  entries.each do |entry|
346
317
  full_path = File.join(path, entry)
347
318
  if File.directory?(full_path)
@@ -350,15 +321,15 @@ module Clacky
350
321
  files << entry
351
322
  end
352
323
  end
353
-
324
+
354
325
  # Sort directories and files separately, then combine
355
326
  directories.sort!
356
327
  files.sort!
357
328
  all_entries = directories + files
358
-
329
+
359
330
  # Format as a tree-like structure
360
331
  content = all_entries.map { |entry| " #{entry}" }.join("\n")
361
-
332
+
362
333
  {
363
334
  path: path,
364
335
  content: "Directory listing:\n#{content}",
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ # Tool for invoking skills within the agent
6
+ # This allows the AI to call skills as tools rather than requiring explicit user commands
7
+ class InvokeSkill < Base
8
+ self.tool_name = "invoke_skill"
9
+ self.tool_description = "Invoke a specialized skill to handle specific tasks. Use this when user's request matches a skill's purpose (e.g., code exploration, document creation, etc.). This will read the skill's instructions and execute them appropriately (either inline or in a subagent)."
10
+ self.tool_category = "skill_management"
11
+ self.tool_parameters = {
12
+ type: "object",
13
+ properties: {
14
+ skill_name: {
15
+ type: "string",
16
+ description: "Name of the skill to invoke (e.g., 'code-explorer', 'pptx', 'pdf')"
17
+ },
18
+ task: {
19
+ type: "string",
20
+ description: "The task or query to pass to the skill"
21
+ }
22
+ },
23
+ required: ["skill_name", "task"]
24
+ }
25
+
26
+ # Execute the skill invocation
27
+ # @param skill_name [String] Name of the skill to invoke
28
+ # @param task [String] Task description to pass to the skill
29
+ # @param agent [Clacky::Agent] Agent instance (injected)
30
+ # @param skill_loader [Clacky::SkillLoader] Skill loader instance (injected)
31
+ # @return [Hash] Result of skill execution
32
+ def execute(skill_name:, task:, agent: nil, skill_loader: nil)
33
+ # Validate injected dependencies
34
+ return { error: "Agent context is required" } unless agent
35
+ return { error: "Skill loader is required" } unless skill_loader
36
+
37
+ # Find skill by name
38
+ skill = skill_loader.find_by_name(skill_name)
39
+ return { error: "Skill not found: #{skill_name}" } unless skill
40
+
41
+ # Check if skill allows model invocation
42
+ unless skill.model_invocation_allowed?
43
+ return { error: "Skill '#{skill_name}' does not allow model invocation" }
44
+ end
45
+
46
+ # Execute skill based on its configuration
47
+ if skill.fork_agent?
48
+ # Execute in subagent - use private method via send
49
+ result = agent.send(:execute_skill_with_subagent, skill, task)
50
+ {
51
+ message: "Skill '#{skill_name}' executed in subagent",
52
+ result: result,
53
+ skill_type: "subagent"
54
+ }
55
+ else
56
+ # Expand skill content inline
57
+ expanded = skill.process_content(task)
58
+ {
59
+ message: "Skill '#{skill_name}' content expanded",
60
+ content: expanded,
61
+ skill_type: "inline",
62
+ note: "The expanded content has been added to the conversation. Continue following its instructions."
63
+ }
64
+ end
65
+ end
66
+
67
+ # Format the tool call for display
68
+ # @param args [Hash] Tool arguments
69
+ # @return [String] Formatted call description
70
+ def format_call(args)
71
+ skill = args[:skill_name] || args["skill_name"]
72
+ "InvokeSkill(#{skill})"
73
+ end
74
+
75
+ # Format the tool result for display
76
+ # @param result [Hash] Tool execution result
77
+ # @return [String] Formatted result summary
78
+ def format_result(result)
79
+ if result[:error]
80
+ "Error: #{result[:error]}"
81
+ elsif result[:skill_type] == "subagent"
82
+ "Subagent executed successfully"
83
+ else
84
+ "Skill content expanded"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end