openclacky 0.6.2 → 0.6.4
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 +4 -4
- data/CHANGELOG.md +30 -0
- data/docs/why-openclacky.md +267 -0
- data/lib/clacky/agent.rb +579 -99
- data/lib/clacky/cli.rb +350 -9
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/edit.rb +111 -8
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/glob.rb +9 -2
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +38 -47
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +4 -1
- metadata +6 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "clacky"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
# Loader and registry for skills.
|
|
9
|
+
# Discovers skills from multiple locations and provides lookup functionality.
|
|
10
|
+
class SkillLoader
|
|
11
|
+
# Skill discovery locations (in priority order: lower index = lower priority)
|
|
12
|
+
LOCATIONS = [
|
|
13
|
+
:default, # gem's built-in default skills (lowest priority)
|
|
14
|
+
:global_claude, # ~/.claude/skills/ (compatibility)
|
|
15
|
+
:global_clacky, # ~/.clacky/skills/
|
|
16
|
+
:project_claude, # .claude/skills/ (project-level compatibility)
|
|
17
|
+
:project_clacky # .clacky/skills/ (highest priority)
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Initialize the skill loader and automatically load all skills
|
|
21
|
+
# @param working_dir [String] Current working directory for project-level discovery
|
|
22
|
+
def initialize(working_dir = nil)
|
|
23
|
+
@working_dir = working_dir || Dir.pwd
|
|
24
|
+
@skills = {} # Map identifier -> Skill
|
|
25
|
+
@skills_by_command = {} # Map slash_command -> Skill
|
|
26
|
+
@errors = [] # Store loading errors
|
|
27
|
+
@loaded_from = {} # Track which location each skill was loaded from
|
|
28
|
+
|
|
29
|
+
# Automatically load all skills on initialization
|
|
30
|
+
load_all
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Load all skills from configured locations
|
|
34
|
+
# Clears previously loaded skills before loading to ensure idempotency
|
|
35
|
+
# @return [Array<Skill>] Loaded skills
|
|
36
|
+
def load_all
|
|
37
|
+
# Clear existing skills to ensure idempotent reloading
|
|
38
|
+
clear
|
|
39
|
+
|
|
40
|
+
load_default_skills
|
|
41
|
+
load_global_claude_skills
|
|
42
|
+
load_global_clacky_skills
|
|
43
|
+
load_project_claude_skills
|
|
44
|
+
load_project_clacky_skills
|
|
45
|
+
|
|
46
|
+
all_skills
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Load skills from ~/.claude/skills/ (lowest priority, compatibility)
|
|
50
|
+
# @return [Array<Skill>]
|
|
51
|
+
def load_global_claude_skills
|
|
52
|
+
global_claude_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".claude", "skills")
|
|
53
|
+
load_skills_from_directory(global_claude_dir, :global_claude)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Load skills from ~/.clacky/skills/ (user global)
|
|
57
|
+
# @return [Array<Skill>]
|
|
58
|
+
def load_global_clacky_skills
|
|
59
|
+
global_clacky_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills")
|
|
60
|
+
load_skills_from_directory(global_clacky_dir, :global_clacky)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Load skills from .claude/skills/ (project-level compatibility)
|
|
64
|
+
# @return [Array<Skill>]
|
|
65
|
+
def load_project_claude_skills
|
|
66
|
+
project_claude_dir = Pathname.new(@working_dir).join(".claude", "skills")
|
|
67
|
+
load_skills_from_directory(project_claude_dir, :project_claude)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Load skills from .clacky/skills/ (project-level, highest priority)
|
|
71
|
+
# @return [Array<Skill>]
|
|
72
|
+
def load_project_clacky_skills
|
|
73
|
+
project_clacky_dir = Pathname.new(@working_dir).join(".clacky", "skills")
|
|
74
|
+
load_skills_from_directory(project_clacky_dir, :project_clacky)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Load skills from nested .claude/skills/ directories (monorepo support)
|
|
78
|
+
# @return [Array<Skill>]
|
|
79
|
+
def load_nested_project_skills
|
|
80
|
+
working_path = Pathname.new(@working_dir)
|
|
81
|
+
|
|
82
|
+
# Find all nested .claude/skills/ directories
|
|
83
|
+
nested_dirs = []
|
|
84
|
+
begin
|
|
85
|
+
Dir.glob("**/.claude/skills/", base: @working_dir).each do |relative_path|
|
|
86
|
+
nested_dirs << working_path.join(relative_path)
|
|
87
|
+
end
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
# Skip if working_dir contains special characters
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Filter out the main project .claude/skills/ (already loaded)
|
|
93
|
+
main_project_skills = working_path.join(".claude", "skills").realpath
|
|
94
|
+
|
|
95
|
+
nested_dirs.each do |dir|
|
|
96
|
+
next if dir.realpath == main_project_skills
|
|
97
|
+
|
|
98
|
+
# Determine the source path for priority resolution
|
|
99
|
+
# Use the parent directory of .claude as the source
|
|
100
|
+
source_path = dir.parent
|
|
101
|
+
|
|
102
|
+
# Determine skill identifier based on relative path from working_dir
|
|
103
|
+
relative_to_working = dir.relative_path_from(working_path).to_s
|
|
104
|
+
skill_name = relative_to_working.gsub(".claude/skills/", "").gsub("/", "-")
|
|
105
|
+
|
|
106
|
+
load_single_skill(dir, source_path, skill_name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get all loaded skills
|
|
111
|
+
# @return [Array<Skill>]
|
|
112
|
+
def all_skills
|
|
113
|
+
@skills.values
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get a skill by its identifier
|
|
117
|
+
# @param identifier [String] Skill name or directory name
|
|
118
|
+
# @return [Skill, nil]
|
|
119
|
+
def [](identifier)
|
|
120
|
+
@skills[identifier]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Find a skill by its slash command
|
|
124
|
+
# @param command [String] e.g., "/explain-code"
|
|
125
|
+
# @return [Skill, nil]
|
|
126
|
+
def find_by_command(command)
|
|
127
|
+
@skills_by_command[command]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get skills that can be invoked by user
|
|
131
|
+
# @return [Array<Skill>]
|
|
132
|
+
def user_invocable_skills
|
|
133
|
+
all_skills.select(&:user_invocable?)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get the count of loaded skills
|
|
137
|
+
# @return [Integer]
|
|
138
|
+
def count
|
|
139
|
+
@skills.size
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get loading errors
|
|
143
|
+
# @return [Array<String>]
|
|
144
|
+
def errors
|
|
145
|
+
@errors.dup
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get the source location for each loaded skill
|
|
149
|
+
# @return [Hash{String => Symbol}] Map of skill identifier to source location
|
|
150
|
+
def loaded_from
|
|
151
|
+
@loaded_from.dup
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Clear loaded skills and errors
|
|
155
|
+
def clear
|
|
156
|
+
@skills.clear
|
|
157
|
+
@skills_by_command.clear
|
|
158
|
+
@errors.clear
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Create a new skill directory and SKILL.md file
|
|
162
|
+
# @param name [String] Skill name (will be used for directory and slash command)
|
|
163
|
+
# @param content [String] Skill content (SKILL.md body)
|
|
164
|
+
# @param description [String] Skill description
|
|
165
|
+
# @param location [Symbol] Where to create: :global or :project
|
|
166
|
+
# @return [Skill] The created skill
|
|
167
|
+
def create_skill(name, content, description = nil, location: :global)
|
|
168
|
+
# Validate name
|
|
169
|
+
unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
|
|
170
|
+
raise Clacky::AgentError,
|
|
171
|
+
"Invalid skill name '#{name}'. Use lowercase letters, numbers, and hyphens only."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Determine directory path
|
|
175
|
+
skill_dir = case location
|
|
176
|
+
when :global
|
|
177
|
+
Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills", name)
|
|
178
|
+
when :project
|
|
179
|
+
Pathname.new(@working_dir).join(".clacky", "skills", name)
|
|
180
|
+
else
|
|
181
|
+
raise Clacky::AgentError, "Unknown skill location: #{location}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Create directory if it doesn't exist
|
|
185
|
+
FileUtils.mkdir_p(skill_dir)
|
|
186
|
+
|
|
187
|
+
# Build frontmatter
|
|
188
|
+
frontmatter = { "name" => name, "description" => description }
|
|
189
|
+
|
|
190
|
+
# Write SKILL.md
|
|
191
|
+
skill_content = build_skill_content(frontmatter, content)
|
|
192
|
+
skill_file = skill_dir.join("SKILL.md")
|
|
193
|
+
skill_file.write(skill_content)
|
|
194
|
+
|
|
195
|
+
# Load the newly created skill
|
|
196
|
+
source_type = case location
|
|
197
|
+
when :global then :global_clacky
|
|
198
|
+
when :project then :project_clacky
|
|
199
|
+
else :global_clacky
|
|
200
|
+
end
|
|
201
|
+
load_single_skill(skill_dir, skill_dir, name, source_type)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Delete a skill
|
|
205
|
+
# @param name [String] Skill name
|
|
206
|
+
# @return [Boolean] True if deleted, false if not found
|
|
207
|
+
def delete_skill(name)
|
|
208
|
+
skill = @skills[name]
|
|
209
|
+
return false unless skill
|
|
210
|
+
|
|
211
|
+
# Remove from registry
|
|
212
|
+
@skills.delete(name)
|
|
213
|
+
@skills_by_command.delete(skill.slash_command)
|
|
214
|
+
|
|
215
|
+
# Delete directory
|
|
216
|
+
FileUtils.rm_rf(skill.directory)
|
|
217
|
+
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def load_skills_from_directory(dir, source_type)
|
|
224
|
+
return [] unless dir.exist?
|
|
225
|
+
|
|
226
|
+
skills = []
|
|
227
|
+
dir.children.select(&:directory?).each do |skill_dir|
|
|
228
|
+
source_path = case source_type
|
|
229
|
+
when :global_claude
|
|
230
|
+
Pathname.new(ENV.fetch("HOME", "~")).join(".claude")
|
|
231
|
+
when :global_clacky
|
|
232
|
+
Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
|
|
233
|
+
when :project_claude, :project_clacky
|
|
234
|
+
Pathname.new(@working_dir)
|
|
235
|
+
else
|
|
236
|
+
skill_dir
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
skill_name = skill_dir.basename.to_s
|
|
240
|
+
skill = load_single_skill(skill_dir, source_path, skill_name, source_type)
|
|
241
|
+
skills << skill if skill
|
|
242
|
+
end
|
|
243
|
+
skills
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def load_single_skill(skill_dir, source_path, skill_name, source_type)
|
|
247
|
+
skill = Skill.new(skill_dir, source_path: source_path)
|
|
248
|
+
|
|
249
|
+
# Check for duplicate names
|
|
250
|
+
existing = @skills[skill.identifier]
|
|
251
|
+
if existing
|
|
252
|
+
# Skip duplicate (lower priority)
|
|
253
|
+
existing_source = @loaded_from[skill.identifier]
|
|
254
|
+
priority_order = [:global_claude, :global_clacky, :project_claude, :project_clacky]
|
|
255
|
+
|
|
256
|
+
if priority_order.index(source_type) > priority_order.index(existing_source)
|
|
257
|
+
# Replace with higher priority skill
|
|
258
|
+
@skills.delete(existing.identifier)
|
|
259
|
+
@skills_by_command.delete(existing.slash_command)
|
|
260
|
+
@loaded_from.delete(existing.identifier)
|
|
261
|
+
else
|
|
262
|
+
@errors << "Skipping duplicate skill '#{skill.identifier}' at #{skill_dir}"
|
|
263
|
+
return nil
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Register skill
|
|
268
|
+
@skills[skill.identifier] = skill
|
|
269
|
+
@skills_by_command[skill.slash_command] = skill
|
|
270
|
+
@loaded_from[skill.identifier] = source_type
|
|
271
|
+
|
|
272
|
+
skill
|
|
273
|
+
rescue Clacky::AgentError => e
|
|
274
|
+
@errors << "Error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
|
|
275
|
+
nil
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
@errors << "Unexpected error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_skill_content(frontmatter, content)
|
|
282
|
+
yaml = frontmatter
|
|
283
|
+
.reject { |_, v| v.nil? || v.to_s.empty? }
|
|
284
|
+
.to_yaml(line_width: 80)
|
|
285
|
+
|
|
286
|
+
"---\n#{yaml}---\n\n#{content}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Load default skills from gem's default_skills directory
|
|
290
|
+
private def load_default_skills
|
|
291
|
+
# Get the gem's lib directory
|
|
292
|
+
gem_lib_dir = File.expand_path("../", __dir__)
|
|
293
|
+
default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
|
|
294
|
+
|
|
295
|
+
return unless Dir.exist?(default_skills_dir)
|
|
296
|
+
|
|
297
|
+
# Load each skill directory
|
|
298
|
+
Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
|
|
299
|
+
skill_dir = File.dirname(skill_file)
|
|
300
|
+
skill_name = File.basename(skill_dir)
|
|
301
|
+
|
|
302
|
+
begin
|
|
303
|
+
skill = Skill.new(Pathname.new(skill_dir))
|
|
304
|
+
|
|
305
|
+
# Check for duplicates (higher priority skills override)
|
|
306
|
+
if @skills.key?(skill.identifier)
|
|
307
|
+
next # Skip if already loaded from higher priority location
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Register skill
|
|
311
|
+
@skills[skill.identifier] = skill
|
|
312
|
+
@skills_by_command[skill.slash_command] = skill
|
|
313
|
+
@loaded_from[skill.identifier] = :default
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
@errors << "Failed to load default skill #{skill_name}: #{e.message}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -46,14 +46,23 @@ module Clacky
|
|
|
46
46
|
content = File.read(path)
|
|
47
47
|
original_content = content.dup
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
64
|
end
|
|
53
65
|
|
|
54
|
-
# Count occurrences
|
|
55
|
-
occurrences = content.scan(old_string).length
|
|
56
|
-
|
|
57
66
|
# If not replace_all and multiple occurrences, warn about ambiguity
|
|
58
67
|
if !replace_all && occurrences > 1
|
|
59
68
|
return {
|
|
@@ -64,9 +73,9 @@ module Clacky
|
|
|
64
73
|
|
|
65
74
|
# Perform replacement
|
|
66
75
|
if replace_all
|
|
67
|
-
content = content.gsub(
|
|
76
|
+
content = content.gsub(actual_old_string, new_string)
|
|
68
77
|
else
|
|
69
|
-
content = content.sub(
|
|
78
|
+
content = content.sub(actual_old_string, new_string)
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
# Write modified content
|
|
@@ -84,6 +93,100 @@ module Clacky
|
|
|
84
93
|
end
|
|
85
94
|
end
|
|
86
95
|
|
|
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)
|
|
99
|
+
|
|
100
|
+
# Find all potential matches in content with normalized whitespace
|
|
101
|
+
matches = []
|
|
102
|
+
content_lines = content.lines
|
|
103
|
+
old_lines = old_string.lines
|
|
104
|
+
|
|
105
|
+
return nil if old_lines.empty?
|
|
106
|
+
|
|
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
|
|
111
|
+
|
|
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 }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
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
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
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
|
+
|
|
133
|
+
private def lines_match_normalized?(lines1, lines2)
|
|
134
|
+
return false unless lines1.length == lines2.length
|
|
135
|
+
|
|
136
|
+
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
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private def build_helpful_error(content, old_string, path)
|
|
145
|
+
# Find similar content to help debug
|
|
146
|
+
old_lines = old_string.lines
|
|
147
|
+
first_line_pattern = old_lines.first&.strip
|
|
148
|
+
|
|
149
|
+
if first_line_pattern && !first_line_pattern.empty?
|
|
150
|
+
# Find lines that match the first line (ignoring whitespace)
|
|
151
|
+
content_lines = content.lines
|
|
152
|
+
similar_locations = []
|
|
153
|
+
|
|
154
|
+
content_lines.each_with_index do |line, idx|
|
|
155
|
+
if line.strip == first_line_pattern
|
|
156
|
+
# Show context: 2 lines before and after
|
|
157
|
+
start_idx = [0, idx - 2].max
|
|
158
|
+
end_idx = [content_lines.length - 1, idx + old_lines.length + 2].min
|
|
159
|
+
context = content_lines[start_idx..end_idx].join
|
|
160
|
+
|
|
161
|
+
similar_locations << {
|
|
162
|
+
line_number: idx + 1,
|
|
163
|
+
context: context
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if similar_locations.any?
|
|
169
|
+
context_preview = similar_locations.first[:context]
|
|
170
|
+
# Escape newlines for better display
|
|
171
|
+
context_display = context_preview.lines.first(5).map { |l| " #{l}" }.join
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
error: "String to replace not found in file. The first line of old_string exists at line #{similar_locations.first[:line_number]}, " \
|
|
175
|
+
"but the full multi-line string doesn't match. This is often caused by whitespace differences (tabs vs spaces). " \
|
|
176
|
+
"\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."
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Generic error if no similar content found
|
|
183
|
+
{
|
|
184
|
+
error: "String to replace not found in file '#{File.basename(path)}'. " \
|
|
185
|
+
"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."
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
87
190
|
def format_call(args)
|
|
88
191
|
path = args[:file_path] || args['file_path'] || args[:path] || args['path']
|
|
89
192
|
"Edit(#{Utils::PathHelper.safe_basename(path)})"
|
|
@@ -18,8 +18,20 @@ module Clacky
|
|
|
18
18
|
},
|
|
19
19
|
max_lines: {
|
|
20
20
|
type: "integer",
|
|
21
|
-
description: "Maximum number of lines to read (
|
|
22
|
-
default:
|
|
21
|
+
description: "Maximum number of lines to read from start (default: 500)",
|
|
22
|
+
default: 500
|
|
23
|
+
},
|
|
24
|
+
keyword: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Search keyword and return matching lines with context (recommended for large files)"
|
|
27
|
+
},
|
|
28
|
+
start_line: {
|
|
29
|
+
type: "integer",
|
|
30
|
+
description: "Start line number (1-indexed, e.g., 100 reads from line 100)"
|
|
31
|
+
},
|
|
32
|
+
end_line: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "End line number (1-indexed, e.g., 200 reads up to line 200)"
|
|
23
35
|
}
|
|
24
36
|
},
|
|
25
37
|
required: ["path"]
|
|
@@ -27,7 +39,7 @@ module Clacky
|
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
|
|
30
|
-
def execute(path:, max_lines:
|
|
42
|
+
def execute(path:, max_lines: 500, keyword: nil, start_line: nil, end_line: nil)
|
|
31
43
|
# Expand ~ to home directory
|
|
32
44
|
expanded_path = File.expand_path(path)
|
|
33
45
|
|
|
@@ -57,17 +69,63 @@ module Clacky
|
|
|
57
69
|
if binary_file?(expanded_path)
|
|
58
70
|
return handle_binary_file(expanded_path)
|
|
59
71
|
end
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
|
|
73
|
+
# Handle keyword search with context
|
|
74
|
+
if keyword && !keyword.empty?
|
|
75
|
+
return find_with_context(expanded_path, keyword)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Read text file with optional line range
|
|
79
|
+
all_lines = File.readlines(expanded_path)
|
|
80
|
+
total_lines = all_lines.size
|
|
81
|
+
|
|
82
|
+
# Calculate start index (convert 1-indexed to 0-indexed)
|
|
83
|
+
start_idx = start_line ? [start_line - 1, 0].max : 0
|
|
84
|
+
|
|
85
|
+
# Calculate end index based on parameters
|
|
86
|
+
if end_line
|
|
87
|
+
# User specified end_line directly
|
|
88
|
+
end_idx = [end_line - 1, total_lines - 1].min
|
|
89
|
+
elsif start_line
|
|
90
|
+
# start_line + max_lines - 1 (relative to start_line, inclusive)
|
|
91
|
+
calculated_end_line = start_line + max_lines - 1
|
|
92
|
+
end_idx = [calculated_end_line - 1, total_lines - 1].min
|
|
93
|
+
else
|
|
94
|
+
# Read from beginning with max_lines limit
|
|
95
|
+
end_idx = [max_lines - 1, total_lines - 1].min
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if start_line exceeds file length first
|
|
99
|
+
if start_idx >= total_lines
|
|
100
|
+
return {
|
|
101
|
+
path: expanded_path,
|
|
102
|
+
content: nil,
|
|
103
|
+
lines_read: 0,
|
|
104
|
+
error: "Invalid line range: start_line #{start_line} exceeds total lines (#{total_lines})"
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Validate range
|
|
109
|
+
if start_idx > end_idx
|
|
110
|
+
return {
|
|
111
|
+
path: expanded_path,
|
|
112
|
+
content: nil,
|
|
113
|
+
lines_read: 0,
|
|
114
|
+
error: "Invalid line range: start_line #{start_line} > end_line #{end_line || (start_line + max_lines)}"
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
lines = all_lines[start_idx..end_idx] || []
|
|
119
|
+
truncated = total_lines > max_lines && !keyword
|
|
65
120
|
|
|
66
121
|
{
|
|
67
122
|
path: expanded_path,
|
|
68
|
-
content:
|
|
123
|
+
content: lines.join,
|
|
69
124
|
lines_read: lines.size,
|
|
125
|
+
total_lines: total_lines,
|
|
70
126
|
truncated: truncated,
|
|
127
|
+
start_line: start_line,
|
|
128
|
+
end_line: end_line,
|
|
71
129
|
error: nil
|
|
72
130
|
}
|
|
73
131
|
rescue StandardError => e
|
|
@@ -157,6 +215,51 @@ module Clacky
|
|
|
157
215
|
result
|
|
158
216
|
end
|
|
159
217
|
|
|
218
|
+
# Find lines matching keyword with context (5 lines before and after each match)
|
|
219
|
+
private def find_with_context(path, keyword)
|
|
220
|
+
context_lines_count = 5
|
|
221
|
+
all_lines = File.readlines(path)
|
|
222
|
+
total_lines = all_lines.size
|
|
223
|
+
matches = []
|
|
224
|
+
|
|
225
|
+
# Find all matching line indices (case-insensitive)
|
|
226
|
+
all_lines.each_with_index do |line, index|
|
|
227
|
+
if line.include?(keyword)
|
|
228
|
+
start_idx = [index - context_lines_count, 0].max
|
|
229
|
+
end_idx = [index + context_lines_count, total_lines - 1].min
|
|
230
|
+
matches << {
|
|
231
|
+
line_number: index + 1,
|
|
232
|
+
content: all_lines[start_idx..end_idx].join,
|
|
233
|
+
start_line: start_idx + 1,
|
|
234
|
+
end_line: end_idx + 1,
|
|
235
|
+
match_line: index + 1
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if matches.empty?
|
|
241
|
+
{
|
|
242
|
+
path: path,
|
|
243
|
+
content: nil,
|
|
244
|
+
matches_count: 0,
|
|
245
|
+
error: "Keyword '#{keyword}' not found in file"
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
# Combine all matches with separator
|
|
249
|
+
combined_content = matches.map do |m|
|
|
250
|
+
"... Lines #{m[:start_line]}-#{m[:end_line]} (match at line #{m[:line_number]}):\n#{m[:content]}"
|
|
251
|
+
end.join("\n---\n")
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
path: path,
|
|
255
|
+
content: combined_content,
|
|
256
|
+
matches_count: matches.size,
|
|
257
|
+
keyword: keyword,
|
|
258
|
+
error: nil
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
160
263
|
private def binary_file?(path)
|
|
161
264
|
# Use FileProcessor to detect binary files
|
|
162
265
|
File.open(path, 'rb') do |file|
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -39,6 +39,9 @@ module Clacky
|
|
|
39
39
|
return { error: "Pattern cannot be empty" }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Expand ~ in pattern to user's home directory
|
|
43
|
+
pattern = pattern.gsub("~", Dir.home)
|
|
44
|
+
|
|
42
45
|
# Validate base_path
|
|
43
46
|
unless Dir.exist?(base_path)
|
|
44
47
|
return { error: "Base path does not exist: #{base_path}" }
|
|
@@ -59,8 +62,12 @@ module Clacky
|
|
|
59
62
|
ignored: 0
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
#
|
|
63
|
-
full_pattern = File.
|
|
65
|
+
# Build full pattern - handle absolute paths correctly
|
|
66
|
+
full_pattern = if File.absolute_path?(pattern)
|
|
67
|
+
pattern
|
|
68
|
+
else
|
|
69
|
+
File.join(base_path, pattern)
|
|
70
|
+
end
|
|
64
71
|
all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
|
|
65
72
|
.reject { |path| File.directory?(path) }
|
|
66
73
|
.reject { |path| path.end_with?(".", "..") }
|