clacky 0.5.0

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.
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "base"
6
+
7
+ module Clacky
8
+ module Tools
9
+ class TrashManager < Base
10
+ self.tool_name = "trash_manager"
11
+ self.tool_description = "Manage deleted files in the AI trash - list, restore, or permanently delete files"
12
+ self.tool_category = "system"
13
+ self.tool_parameters = {
14
+ type: "object",
15
+ properties: {
16
+ action: {
17
+ type: "string",
18
+ enum: ["list", "restore", "status", "empty", "help"],
19
+ description: "Action to perform: 'list' (show deleted files), 'restore' (restore a file), 'status' (show trash summary), 'empty' (permanently delete old files), 'help' (show usage)"
20
+ },
21
+ file_path: {
22
+ type: "string",
23
+ description: "Original file path to restore (required for 'restore' action)"
24
+ },
25
+ days_old: {
26
+ type: "integer",
27
+ description: "For 'empty' action: permanently delete files older than this many days (default: 7)"
28
+ }
29
+ },
30
+ required: ["action"]
31
+ }
32
+
33
+ def execute(action:, file_path: nil, days_old: 7)
34
+ project_root = Dir.pwd
35
+ trash_dir = File.join(project_root, '.ai_trash')
36
+
37
+ unless Dir.exist?(trash_dir)
38
+ return {
39
+ action: action,
40
+ success: false,
41
+ message: "No trash directory found. No files have been safely deleted yet."
42
+ }
43
+ end
44
+
45
+ case action.downcase
46
+ when 'list'
47
+ list_deleted_files(trash_dir)
48
+ when 'restore'
49
+ return { action: action, success: false, message: "file_path is required for restore action" } unless file_path
50
+ restore_file(trash_dir, file_path, project_root)
51
+ when 'status'
52
+ show_trash_status(trash_dir)
53
+ when 'empty'
54
+ empty_trash(trash_dir, days_old)
55
+ when 'help'
56
+ show_help
57
+ else
58
+ { action: action, success: false, message: "Unknown action: #{action}" }
59
+ end
60
+ end
61
+
62
+ def list_deleted_files(trash_dir)
63
+ deleted_files = get_deleted_files(trash_dir)
64
+
65
+ if deleted_files.empty?
66
+ return {
67
+ action: 'list',
68
+ success: true,
69
+ count: 0,
70
+ message: "🗑️ Trash is empty"
71
+ }
72
+ end
73
+
74
+ file_list = deleted_files.map.with_index(1) do |file, index|
75
+ size_info = file[:file_size] ? " (#{format_bytes(file[:file_size])})" : ""
76
+ "#{index}. #{file[:original_path]}#{size_info}\n Deleted: #{format_time(file[:deleted_at])}"
77
+ end
78
+
79
+ {
80
+ action: 'list',
81
+ success: true,
82
+ count: deleted_files.size,
83
+ files: deleted_files,
84
+ message: "🗑️ Deleted Files:\n\n#{file_list.join("\n\n")}\n\n💡 Use trash_manager with action='restore' and file_path='<path>' to restore a file"
85
+ }
86
+ end
87
+
88
+ def restore_file(trash_dir, file_path, project_root)
89
+ deleted_files = get_deleted_files(trash_dir)
90
+ expanded_path = File.expand_path(file_path, project_root)
91
+
92
+ target_file = deleted_files.find { |f| f[:original_path] == expanded_path }
93
+
94
+ unless target_file
95
+ similar_files = deleted_files.select { |f| File.basename(f[:original_path]) == File.basename(file_path) }
96
+
97
+ if similar_files.any?
98
+ suggestions = similar_files.map { |f| f[:original_path] }.join("\n - ")
99
+ return {
100
+ action: 'restore',
101
+ success: false,
102
+ message: "File not found in trash: #{file_path}\n\nDid you mean one of these?\n - #{suggestions}"
103
+ }
104
+ else
105
+ return {
106
+ action: 'restore',
107
+ success: false,
108
+ message: "File not found in trash: #{file_path}\n\nUse trash_manager with action='list' to see available files."
109
+ }
110
+ end
111
+ end
112
+
113
+ if File.exist?(expanded_path)
114
+ return {
115
+ action: 'restore',
116
+ success: false,
117
+ message: "Cannot restore: file already exists at #{file_path}"
118
+ }
119
+ end
120
+
121
+ begin
122
+ # Ensure target directory exists
123
+ FileUtils.mkdir_p(File.dirname(expanded_path))
124
+
125
+ # Restore file
126
+ FileUtils.mv(target_file[:trash_file], expanded_path)
127
+ File.delete("#{target_file[:trash_file]}.metadata.json")
128
+
129
+ {
130
+ action: 'restore',
131
+ success: true,
132
+ restored_file: expanded_path,
133
+ message: "✅ Successfully restored: #{file_path}"
134
+ }
135
+ rescue StandardError => e
136
+ {
137
+ action: 'restore',
138
+ success: false,
139
+ message: "❌ Failed to restore file: #{e.message}"
140
+ }
141
+ end
142
+ end
143
+
144
+ def show_trash_status(trash_dir)
145
+ deleted_files = get_deleted_files(trash_dir)
146
+ total_size = deleted_files.sum { |f| f[:file_size] || 0 }
147
+
148
+ if deleted_files.empty?
149
+ return {
150
+ action: 'status',
151
+ success: true,
152
+ count: 0,
153
+ total_size: 0,
154
+ message: "🗑️ Trash is empty"
155
+ }
156
+ end
157
+
158
+ # Group by file type
159
+ by_type = deleted_files.group_by { |f| f[:file_type] || 'no extension' }
160
+ type_summary = by_type.map do |ext, files|
161
+ size = files.sum { |f| f[:file_size] || 0 }
162
+ " #{ext}: #{files.size} files (#{format_bytes(size)})"
163
+ end.join("\n")
164
+
165
+ recent_files = deleted_files.first(3).map do |file|
166
+ " - #{File.basename(file[:original_path])} (#{format_time(file[:deleted_at])})"
167
+ end.join("\n")
168
+
169
+ message = []
170
+ message << "🗑️ Trash Status:"
171
+ message << " Files: #{deleted_files.count}"
172
+ message << " Total size: #{format_bytes(total_size)}"
173
+ message << " Location: #{trash_dir}"
174
+ message << ""
175
+ message << "📊 By file type:"
176
+ message << type_summary
177
+ message << ""
178
+ message << "📅 Recently deleted:"
179
+ message << recent_files
180
+
181
+ {
182
+ action: 'status',
183
+ success: true,
184
+ count: deleted_files.size,
185
+ total_size: total_size,
186
+ by_type: by_type.transform_values(&:size),
187
+ message: message.join("\n")
188
+ }
189
+ end
190
+
191
+ def empty_trash(trash_dir, days_old)
192
+ deleted_files = get_deleted_files(trash_dir)
193
+ cutoff_time = Time.now - (days_old * 24 * 60 * 60)
194
+
195
+ old_files = deleted_files.select do |file|
196
+ Time.parse(file[:deleted_at]) < cutoff_time
197
+ end
198
+
199
+ if old_files.empty?
200
+ return {
201
+ action: 'empty',
202
+ success: true,
203
+ deleted_count: 0,
204
+ message: "🗑️ No files older than #{days_old} days found in trash"
205
+ }
206
+ end
207
+
208
+ deleted_count = 0
209
+ freed_size = 0
210
+
211
+ old_files.each do |file|
212
+ begin
213
+ File.delete(file[:trash_file]) if File.exist?(file[:trash_file])
214
+ File.delete("#{file[:trash_file]}.metadata.json") if File.exist?("#{file[:trash_file]}.metadata.json")
215
+ deleted_count += 1
216
+ freed_size += file[:file_size] || 0
217
+ rescue StandardError => e
218
+ # Continue processing other files, but log the error
219
+ end
220
+ end
221
+
222
+ {
223
+ action: 'empty',
224
+ success: true,
225
+ deleted_count: deleted_count,
226
+ freed_size: freed_size,
227
+ days_old: days_old,
228
+ message: "🗑️ Permanently deleted #{deleted_count} files older than #{days_old} days\n💾 Freed up #{format_bytes(freed_size)} of disk space"
229
+ }
230
+ end
231
+
232
+ def show_help
233
+ help_text = <<~HELP
234
+ 🗑️ Trash Manager Help
235
+
236
+ The SafeShell tool automatically moves deleted files to a trash directory
237
+ instead of permanently deleting them. This tool helps you manage those files.
238
+
239
+ Available actions:
240
+
241
+ 📋 list - Show all deleted files
242
+ Example: trash_manager(action="list")
243
+
244
+ ♻️ restore - Restore a deleted file to its original location
245
+ Example: trash_manager(action="restore", file_path="path/to/file.txt")
246
+
247
+ 📊 status - Show trash summary with statistics
248
+ Example: trash_manager(action="status")
249
+
250
+ 🗑️ empty - Permanently delete files older than N days (default: 7)
251
+ Example: trash_manager(action="empty", days_old=7)
252
+
253
+ ❓ help - Show this help message
254
+
255
+ 💡 Tips:
256
+ - Use 'list' to see what files are in trash
257
+ - Use 'restore' to get back accidentally deleted files
258
+ - Use 'empty' periodically to free up disk space
259
+ - All deletions by SafeShell are logged in .ai_safety.log
260
+ HELP
261
+
262
+ {
263
+ action: 'help',
264
+ success: true,
265
+ message: help_text
266
+ }
267
+ end
268
+
269
+ def get_deleted_files(trash_dir)
270
+ deleted_files = []
271
+
272
+ Dir.glob(File.join(trash_dir, "*.metadata.json")).each do |metadata_file|
273
+ begin
274
+ metadata = JSON.parse(File.read(metadata_file))
275
+ trash_file = metadata_file.sub('.metadata.json', '')
276
+
277
+ # Only include existing trash files
278
+ if File.exist?(trash_file)
279
+ deleted_files << {
280
+ original_path: metadata['original_path'],
281
+ deleted_at: metadata['deleted_at'],
282
+ trash_file: trash_file,
283
+ file_size: metadata['file_size'],
284
+ file_type: metadata['file_type'],
285
+ file_mode: metadata['file_mode']
286
+ }
287
+ end
288
+ rescue StandardError
289
+ # Skip corrupted metadata files
290
+ end
291
+ end
292
+
293
+ deleted_files.sort_by { |f| f[:deleted_at] }.reverse
294
+ end
295
+
296
+ def format_bytes(bytes)
297
+ return "0 B" if bytes.zero?
298
+
299
+ units = %w[B KB MB GB]
300
+ unit_index = 0
301
+ size = bytes.to_f
302
+
303
+ while size >= 1024 && unit_index < units.length - 1
304
+ size /= 1024.0
305
+ unit_index += 1
306
+ end
307
+
308
+ if unit_index == 0
309
+ "#{size.to_i} #{units[unit_index]}"
310
+ else
311
+ "#{size.round(2)} #{units[unit_index]}"
312
+ end
313
+ end
314
+
315
+ def format_time(time_str)
316
+ time = Time.parse(time_str)
317
+ if time.to_date == Date.today
318
+ time.strftime("%H:%M")
319
+ elsif time.to_date == Date.today - 1
320
+ "yesterday #{time.strftime('%H:%M')}"
321
+ elsif time.year == Date.today.year
322
+ time.strftime("%m/%d %H:%M")
323
+ else
324
+ time.strftime("%Y/%m/%d")
325
+ end
326
+ rescue
327
+ time_str
328
+ end
329
+
330
+ def format_call(args)
331
+ action = args[:action] || args['action'] || 'unknown'
332
+ "TrashManager(#{action})"
333
+ end
334
+
335
+ def format_result(result)
336
+ action = result[:action] || 'unknown'
337
+ success = result[:success]
338
+
339
+ case action
340
+ when 'list'
341
+ count = result[:count] || 0
342
+ "📋 Listed #{count} deleted files"
343
+ when 'restore'
344
+ if success
345
+ "♻️ File restored successfully"
346
+ else
347
+ "❌ Restore failed"
348
+ end
349
+ when 'status'
350
+ count = result[:count] || 0
351
+ "📊 Trash: #{count} files"
352
+ when 'empty'
353
+ if success
354
+ deleted_count = result[:deleted_count] || 0
355
+ "🗑️ Emptied #{deleted_count} files"
356
+ else
357
+ "❌ Empty failed"
358
+ end
359
+ when 'help'
360
+ "❓ Help displayed"
361
+ else
362
+ success ? "✓ #{action} completed" : "✗ #{action} failed"
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Clacky
7
+ module Tools
8
+ class WebFetch < Base
9
+ self.tool_name = "web_fetch"
10
+ self.tool_description = "Fetch and parse content from a web page. Returns the page content, title, and metadata."
11
+ self.tool_category = "web"
12
+ self.tool_parameters = {
13
+ type: "object",
14
+ properties: {
15
+ url: {
16
+ type: "string",
17
+ description: "The URL to fetch (must be a valid HTTP/HTTPS URL)"
18
+ },
19
+ max_length: {
20
+ type: "integer",
21
+ description: "Maximum content length to return in characters (default: 50000)",
22
+ default: 50000
23
+ }
24
+ },
25
+ required: %w[url]
26
+ }
27
+
28
+ def execute(url:, max_length: 50000)
29
+ # Validate URL
30
+ begin
31
+ uri = URI.parse(url)
32
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
33
+ return { error: "URL must be HTTP or HTTPS" }
34
+ end
35
+ rescue URI::InvalidURIError => e
36
+ return { error: "Invalid URL: #{e.message}" }
37
+ end
38
+
39
+ begin
40
+ # Fetch the web page
41
+ response = fetch_url(uri)
42
+
43
+ # Extract content
44
+ content = response.body
45
+ content_type = response["content-type"] || ""
46
+
47
+ # Parse HTML if it's an HTML page
48
+ if content_type.include?("text/html")
49
+ result = parse_html(content, max_length)
50
+ result[:url] = url
51
+ result[:content_type] = content_type
52
+ result[:status_code] = response.code.to_i
53
+ result[:error] = nil
54
+ result
55
+ else
56
+ # For non-HTML content, return raw text
57
+ truncated_content = content[0, max_length]
58
+ {
59
+ url: url,
60
+ content_type: content_type,
61
+ status_code: response.code.to_i,
62
+ content: truncated_content,
63
+ truncated: content.length > max_length,
64
+ error: nil
65
+ }
66
+ end
67
+ rescue StandardError => e
68
+ { error: "Failed to fetch URL: #{e.message}" }
69
+ end
70
+ end
71
+
72
+ def fetch_url(uri)
73
+ # Follow redirects (max 5)
74
+ redirects = 0
75
+ max_redirects = 5
76
+
77
+ loop do
78
+ request = Net::HTTP::Get.new(uri)
79
+ request["User-Agent"] = "Mozilla/5.0 (compatible; Clacky/1.0)"
80
+
81
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: 15) do |http|
82
+ http.request(request)
83
+ end
84
+
85
+ case response
86
+ when Net::HTTPSuccess
87
+ return response
88
+ when Net::HTTPRedirection
89
+ redirects += 1
90
+ raise "Too many redirects" if redirects > max_redirects
91
+
92
+ location = response["location"]
93
+ uri = URI.parse(location)
94
+ else
95
+ raise "HTTP error: #{response.code} #{response.message}"
96
+ end
97
+ end
98
+ end
99
+
100
+ def parse_html(html, max_length)
101
+ # Extract title
102
+ title = ""
103
+ if html =~ %r{<title[^>]*>(.*?)</title>}mi
104
+ title = $1.strip.gsub(/\s+/, " ")
105
+ end
106
+
107
+ # Extract meta description
108
+ description = ""
109
+ if html =~ /<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/mi
110
+ description = $1.strip
111
+ elsif html =~ /<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/mi
112
+ description = $1.strip
113
+ end
114
+
115
+ # Remove script and style tags
116
+ text = html.gsub(%r{<script[^>]*>.*?</script>}mi, "")
117
+ .gsub(%r{<style[^>]*>.*?</style>}mi, "")
118
+
119
+ # Remove HTML tags
120
+ text = text.gsub(/<[^>]+>/, " ")
121
+
122
+ # Clean up whitespace
123
+ text = text.gsub(/\s+/, " ").strip
124
+
125
+ # Truncate if needed
126
+ truncated = text.length > max_length
127
+ text = text[0, max_length] if truncated
128
+
129
+ {
130
+ title: title,
131
+ description: description,
132
+ content: text,
133
+ truncated: truncated
134
+ }
135
+ end
136
+
137
+ def format_call(args)
138
+ url = args[:url] || args['url'] || ''
139
+ # Extract domain from URL for display
140
+ begin
141
+ uri = URI.parse(url)
142
+ domain = uri.host || url
143
+ "web_fetch(#{domain})"
144
+ rescue
145
+ display_url = url.length > 40 ? "#{url[0..37]}..." : url
146
+ "web_fetch(\"#{display_url}\")"
147
+ end
148
+ end
149
+
150
+ def format_result(result)
151
+ if result[:error]
152
+ "✗ #{result[:error]}"
153
+ else
154
+ title = result[:title] || 'Untitled'
155
+ display_title = title.length > 40 ? "#{title[0..37]}..." : title
156
+ "✓ Fetched: #{display_title}"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+
8
+ module Clacky
9
+ module Tools
10
+ class WebSearch < Base
11
+ self.tool_name = "web_search"
12
+ self.tool_description = "Search the web for current information. Returns search results with titles, URLs, and snippets."
13
+ self.tool_category = "web"
14
+ self.tool_parameters = {
15
+ type: "object",
16
+ properties: {
17
+ query: {
18
+ type: "string",
19
+ description: "The search query"
20
+ },
21
+ max_results: {
22
+ type: "integer",
23
+ description: "Maximum number of results to return (default: 10)",
24
+ default: 10
25
+ }
26
+ },
27
+ required: %w[query]
28
+ }
29
+
30
+ def execute(query:, max_results: 10)
31
+ # Validate query
32
+ if query.nil? || query.strip.empty?
33
+ return { error: "Query cannot be empty" }
34
+ end
35
+
36
+ begin
37
+ # Use DuckDuckGo HTML search (no API key needed)
38
+ results = search_duckduckgo(query, max_results)
39
+
40
+ {
41
+ query: query,
42
+ results: results,
43
+ count: results.length,
44
+ error: nil
45
+ }
46
+ rescue StandardError => e
47
+ { error: "Failed to perform web search: #{e.message}" }
48
+ end
49
+ end
50
+
51
+ def search_duckduckgo(query, max_results)
52
+ # DuckDuckGo HTML search endpoint
53
+ encoded_query = CGI.escape(query)
54
+ url = URI("https://html.duckduckgo.com/html/?q=#{encoded_query}")
55
+
56
+ # Make request with user agent
57
+ request = Net::HTTP::Get.new(url)
58
+ request["User-Agent"] = "Mozilla/5.0 (compatible; Clacky/1.0)"
59
+
60
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: 10) do |http|
61
+ http.request(request)
62
+ end
63
+
64
+ unless response.is_a?(Net::HTTPSuccess)
65
+ return []
66
+ end
67
+
68
+ # Parse HTML results (simple extraction)
69
+ parse_duckduckgo_html(response.body, max_results)
70
+ rescue StandardError => e
71
+ # Fallback: return basic search URL
72
+ [
73
+ {
74
+ title: "Search results for: #{query}",
75
+ url: "https://duckduckgo.com/?q=#{CGI.escape(query)}",
76
+ snippet: "Click to view search results in browser. Error: #{e.message}"
77
+ }
78
+ ]
79
+ end
80
+
81
+ def parse_duckduckgo_html(html, max_results)
82
+ results = []
83
+
84
+ # Simple regex-based parsing (not perfect but works for basic cases)
85
+ # Look for result blocks in DuckDuckGo HTML
86
+ html.scan(%r{<div class="result__body">.*?</div>}m).each do |block|
87
+ break if results.length >= max_results
88
+
89
+ # Extract title and URL
90
+ if block =~ %r{<a.*?href="//duckduckgo\.com/l/\?uddg=([^"&]+).*?".*?>(.*?)</a>}m
91
+ url = CGI.unescape($1)
92
+ title = $2.gsub(/<[^>]+>/, "").strip
93
+
94
+ # Extract snippet
95
+ snippet = ""
96
+ if block =~ %r{<a class="result__snippet".*?>(.*?)</a>}m
97
+ snippet = $1.gsub(/<[^>]+>/, "").strip
98
+ end
99
+
100
+ results << {
101
+ title: title,
102
+ url: url,
103
+ snippet: snippet
104
+ }
105
+ end
106
+ end
107
+
108
+ # If parsing failed, provide a fallback
109
+ if results.empty?
110
+ results << {
111
+ title: "Web search results",
112
+ url: "https://duckduckgo.com/?q=#{CGI.escape(query)}",
113
+ snippet: "Could not parse search results. Visit the URL to see results."
114
+ }
115
+ end
116
+
117
+ results
118
+ rescue StandardError
119
+ []
120
+ end
121
+
122
+ def format_call(args)
123
+ query = args[:query] || args['query'] || ''
124
+ display_query = query.length > 40 ? "#{query[0..37]}..." : query
125
+ "web_search(\"#{display_query}\")"
126
+ end
127
+
128
+ def format_result(result)
129
+ if result[:error]
130
+ "✗ #{result[:error]}"
131
+ else
132
+ count = result[:count] || 0
133
+ "✓ Found #{count} results"
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end