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.
@@ -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
@@ -46,14 +46,23 @@ module Clacky
46
46
  content = File.read(path)
47
47
  original_content = content.dup
48
48
 
49
- # Check if old_string exists
50
- unless content.include?(old_string)
51
- return { error: "String to replace not found in file" }
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(old_string, new_string)
76
+ content = content.gsub(actual_old_string, new_string)
68
77
  else
69
- content = content.sub(old_string, new_string)
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 (optional)",
22
- default: 1000
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: 1000)
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
- # Read text file
62
- lines = File.readlines(expanded_path).first(max_lines)
63
- content = lines.join
64
- truncated = File.readlines(expanded_path).size > max_lines
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: 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|
@@ -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
- # Change to base path and find matches
63
- full_pattern = File.join(base_path, pattern)
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?(".", "..") }