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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ class Edit < Base
6
+ self.tool_name = "edit"
7
+ self.tool_description = "Make precise edits to existing files by replacing old text with new text. " \
8
+ "The old_string must match exactly (including whitespace and indentation)."
9
+ self.tool_category = "file_system"
10
+ self.tool_parameters = {
11
+ type: "object",
12
+ properties: {
13
+ path: {
14
+ type: "string",
15
+ description: "The path of the file to edit (absolute or relative)"
16
+ },
17
+ old_string: {
18
+ type: "string",
19
+ description: "The exact string to find and replace (must match exactly including whitespace)"
20
+ },
21
+ new_string: {
22
+ type: "string",
23
+ description: "The new string to replace the old string with"
24
+ },
25
+ replace_all: {
26
+ type: "boolean",
27
+ description: "If true, replace all occurrences. If false (default), replace only the first occurrence",
28
+ default: false
29
+ }
30
+ },
31
+ required: %w[path old_string new_string]
32
+ }
33
+
34
+ def execute(path:, old_string:, new_string:, replace_all: false)
35
+ # Validate path
36
+ unless File.exist?(path)
37
+ return { error: "File not found: #{path}" }
38
+ end
39
+
40
+ unless File.file?(path)
41
+ return { error: "Path is not a file: #{path}" }
42
+ end
43
+
44
+ begin
45
+ # Read current content
46
+ content = File.read(path)
47
+ original_content = content.dup
48
+
49
+ # Check if old_string exists
50
+ unless content.include?(old_string)
51
+ return { error: "String to replace not found in file" }
52
+ end
53
+
54
+ # Count occurrences
55
+ occurrences = content.scan(old_string).length
56
+
57
+ # If not replace_all and multiple occurrences, warn about ambiguity
58
+ if !replace_all && occurrences > 1
59
+ return {
60
+ error: "String appears #{occurrences} times in the file. Use replace_all: true to replace all occurrences, " \
61
+ "or provide a more specific string that appears only once."
62
+ }
63
+ end
64
+
65
+ # Perform replacement
66
+ if replace_all
67
+ content = content.gsub(old_string, new_string)
68
+ else
69
+ content = content.sub(old_string, new_string)
70
+ end
71
+
72
+ # Write modified content
73
+ File.write(path, content)
74
+
75
+ {
76
+ path: File.expand_path(path),
77
+ replacements: replace_all ? occurrences : 1,
78
+ error: nil
79
+ }
80
+ rescue Errno::EACCES => e
81
+ { error: "Permission denied: #{e.message}" }
82
+ rescue StandardError => e
83
+ { error: "Failed to edit file: #{e.message}" }
84
+ end
85
+ end
86
+
87
+ def format_call(args)
88
+ path = args[:file_path] || args['file_path'] || args[:path] || args['path']
89
+ "Edit(#{Utils::PathHelper.safe_basename(path)})"
90
+ end
91
+
92
+ def format_result(result)
93
+ return result[:error] if result[:error]
94
+
95
+ replacements = result[:replacements] || result['replacements'] || 1
96
+ "Modified #{replacements} occurrence#{replacements > 1 ? 's' : ''}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Clacky
6
+ module Tools
7
+ class FileReader < Base
8
+ self.tool_name = "file_reader"
9
+ self.tool_description = "Read contents of a file from the filesystem"
10
+ self.tool_category = "file_system"
11
+ self.tool_parameters = {
12
+ type: "object",
13
+ properties: {
14
+ path: {
15
+ type: "string",
16
+ description: "Absolute or relative path to the file"
17
+ },
18
+ max_lines: {
19
+ type: "integer",
20
+ description: "Maximum number of lines to read (optional)",
21
+ default: 1000
22
+ }
23
+ },
24
+ required: ["path"]
25
+ }
26
+
27
+ def execute(path:, max_lines: 1000)
28
+ unless File.exist?(path)
29
+ return {
30
+ path: path,
31
+ content: nil,
32
+ error: "File not found: #{path}"
33
+ }
34
+ end
35
+
36
+ unless File.file?(path)
37
+ return {
38
+ path: path,
39
+ content: nil,
40
+ error: "Path is not a file: #{path}"
41
+ }
42
+ end
43
+
44
+ begin
45
+ lines = File.readlines(path).first(max_lines)
46
+ content = lines.join
47
+ truncated = File.readlines(path).size > max_lines
48
+
49
+ {
50
+ path: path,
51
+ content: content,
52
+ lines_read: lines.size,
53
+ truncated: truncated,
54
+ error: nil
55
+ }
56
+ rescue StandardError => e
57
+ {
58
+ path: path,
59
+ content: nil,
60
+ error: "Error reading file: #{e.message}"
61
+ }
62
+ end
63
+ end
64
+
65
+ def format_call(args)
66
+ path = args[:path] || args['path']
67
+ "Read(#{Utils::PathHelper.safe_basename(path)})"
68
+ end
69
+
70
+ def format_result(result)
71
+ return result[:error] if result[:error]
72
+
73
+ lines = result[:lines_read] || result['lines_read'] || 0
74
+ truncated = result[:truncated] || result['truncated']
75
+ "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Clacky
6
+ module Tools
7
+ class Glob < Base
8
+ self.tool_name = "glob"
9
+ self.tool_description = "Find files matching a glob pattern (e.g., '**/*.rb', 'src/**/*.js'). " \
10
+ "Returns file paths sorted by modification time."
11
+ self.tool_category = "file_system"
12
+ self.tool_parameters = {
13
+ type: "object",
14
+ properties: {
15
+ pattern: {
16
+ type: "string",
17
+ description: "The glob pattern to match files (e.g., '**/*.rb', 'lib/**/*.rb', '*.txt')"
18
+ },
19
+ base_path: {
20
+ type: "string",
21
+ description: "The base directory to search in (defaults to current directory)",
22
+ default: "."
23
+ },
24
+ limit: {
25
+ type: "integer",
26
+ description: "Maximum number of results to return (default: 100)",
27
+ default: 100
28
+ }
29
+ },
30
+ required: %w[pattern]
31
+ }
32
+
33
+ def execute(pattern:, base_path: ".", limit: 100)
34
+ # Validate pattern
35
+ if pattern.nil? || pattern.strip.empty?
36
+ return { error: "Pattern cannot be empty" }
37
+ end
38
+
39
+ # Validate base_path
40
+ unless Dir.exist?(base_path)
41
+ return { error: "Base path does not exist: #{base_path}" }
42
+ end
43
+
44
+ begin
45
+ # Change to base path and find matches
46
+ full_pattern = File.join(base_path, pattern)
47
+ matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
48
+ .reject { |path| File.directory?(path) }
49
+ .reject { |path| path.end_with?(".", "..") }
50
+
51
+ # Sort by modification time (most recent first)
52
+ matches = matches.sort_by { |path| -File.mtime(path).to_i }
53
+
54
+ # Apply limit
55
+ total_matches = matches.length
56
+ matches = matches.take(limit)
57
+
58
+ # Convert to relative or absolute paths
59
+ matches = matches.map { |path| File.expand_path(path) }
60
+
61
+ {
62
+ matches: matches,
63
+ total_matches: total_matches,
64
+ returned: matches.length,
65
+ truncated: total_matches > limit,
66
+ error: nil
67
+ }
68
+ rescue StandardError => e
69
+ { error: "Failed to glob files: #{e.message}" }
70
+ end
71
+ end
72
+
73
+ def format_call(args)
74
+ pattern = args[:pattern] || args['pattern'] || ''
75
+ base_path = args[:base_path] || args['base_path'] || '.'
76
+
77
+ display_base = base_path == '.' ? '' : " in #{base_path}"
78
+ "glob(\"#{pattern}\"#{display_base})"
79
+ end
80
+
81
+ def format_result(result)
82
+ if result[:error]
83
+ "✗ #{result[:error]}"
84
+ else
85
+ count = result[:returned] || 0
86
+ total = result[:total_matches] || 0
87
+ truncated = result[:truncated] ? " (truncated)" : ""
88
+ "✓ Found #{count}/#{total} files#{truncated}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Tools
5
+ class Grep < Base
6
+ self.tool_name = "grep"
7
+ self.tool_description = "Search file contents using regular expressions. Returns matching lines with context."
8
+ self.tool_category = "file_system"
9
+ self.tool_parameters = {
10
+ type: "object",
11
+ properties: {
12
+ pattern: {
13
+ type: "string",
14
+ description: "The regular expression pattern to search for"
15
+ },
16
+ path: {
17
+ type: "string",
18
+ description: "File or directory to search in (defaults to current directory)",
19
+ default: "."
20
+ },
21
+ file_pattern: {
22
+ type: "string",
23
+ description: "Glob pattern to filter files (e.g., '*.rb', '**/*.js')",
24
+ default: "**/*"
25
+ },
26
+ case_insensitive: {
27
+ type: "boolean",
28
+ description: "Perform case-insensitive search",
29
+ default: false
30
+ },
31
+ context_lines: {
32
+ type: "integer",
33
+ description: "Number of context lines to show before and after each match",
34
+ default: 0
35
+ },
36
+ max_matches: {
37
+ type: "integer",
38
+ description: "Maximum number of matching files to return",
39
+ default: 50
40
+ }
41
+ },
42
+ required: %w[pattern]
43
+ }
44
+
45
+ def execute(pattern:, path: ".", file_pattern: "**/*", case_insensitive: false, context_lines: 0, max_matches: 50)
46
+ # Validate pattern
47
+ if pattern.nil? || pattern.strip.empty?
48
+ return { error: "Pattern cannot be empty" }
49
+ end
50
+
51
+ # Validate path
52
+ unless File.exist?(path)
53
+ return { error: "Path does not exist: #{path}" }
54
+ end
55
+
56
+ begin
57
+ # Compile regex
58
+ regex_options = case_insensitive ? Regexp::IGNORECASE : 0
59
+ regex = Regexp.new(pattern, regex_options)
60
+
61
+ results = []
62
+ total_matches = 0
63
+
64
+ # Get files to search
65
+ files = if File.file?(path)
66
+ [path]
67
+ else
68
+ Dir.glob(File.join(path, file_pattern))
69
+ .select { |f| File.file?(f) }
70
+ .reject { |f| binary_file?(f) }
71
+ end
72
+
73
+ # Search each file
74
+ files.each do |file|
75
+ break if results.length >= max_matches
76
+
77
+ matches = search_file(file, regex, context_lines)
78
+ next if matches.empty?
79
+
80
+ results << {
81
+ file: File.expand_path(file),
82
+ matches: matches
83
+ }
84
+ total_matches += matches.length
85
+ end
86
+
87
+ {
88
+ results: results,
89
+ total_matches: total_matches,
90
+ files_searched: files.length,
91
+ files_with_matches: results.length,
92
+ truncated: results.length >= max_matches,
93
+ error: nil
94
+ }
95
+ rescue RegexpError => e
96
+ { error: "Invalid regex pattern: #{e.message}" }
97
+ rescue StandardError => e
98
+ { error: "Failed to search files: #{e.message}" }
99
+ end
100
+ end
101
+
102
+ def format_call(args)
103
+ pattern = args[:pattern] || args['pattern'] || ''
104
+ path = args[:path] || args['path'] || '.'
105
+
106
+ # Truncate pattern if too long
107
+ display_pattern = pattern.length > 30 ? "#{pattern[0..27]}..." : pattern
108
+ display_path = path == '.' ? 'current dir' : (path.length > 20 ? "...#{path[-17..]}" : path)
109
+
110
+ "grep(\"#{display_pattern}\" in #{display_path})"
111
+ end
112
+
113
+ def format_result(result)
114
+ if result[:error]
115
+ "✗ #{result[:error]}"
116
+ else
117
+ matches = result[:total_matches] || 0
118
+ files = result[:files_with_matches] || 0
119
+ "✓ Found #{matches} matches in #{files} files"
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def search_file(file, regex, context_lines)
126
+ matches = []
127
+ lines = File.readlines(file, chomp: true)
128
+
129
+ lines.each_with_index do |line, index|
130
+ next unless line.match?(regex)
131
+
132
+ # Get context
133
+ start_line = [0, index - context_lines].max
134
+ end_line = [lines.length - 1, index + context_lines].min
135
+
136
+ context = []
137
+ (start_line..end_line).each do |i|
138
+ context << {
139
+ line_number: i + 1,
140
+ content: lines[i],
141
+ is_match: i == index
142
+ }
143
+ end
144
+
145
+ matches << {
146
+ line_number: index + 1,
147
+ line: line,
148
+ context: context_lines > 0 ? context : nil
149
+ }
150
+ end
151
+
152
+ matches
153
+ rescue StandardError
154
+ []
155
+ end
156
+
157
+ def binary_file?(file)
158
+ # Simple heuristic: check if file contains null bytes in first 8KB
159
+ return false unless File.exist?(file)
160
+ return false if File.size(file).zero?
161
+
162
+ sample = File.read(file, 8192, encoding: "ASCII-8BIT")
163
+ sample.include?("\x00")
164
+ rescue StandardError
165
+ true
166
+ end
167
+ end
168
+ end
169
+ end