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.
- checksums.yaml +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -0
|
@@ -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
|