openclacky 0.6.2 → 0.6.3

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::Error,
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::Error, "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::Error => 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
@@ -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|
@@ -59,11 +59,6 @@ module Clacky
59
59
  description: "Maximum file size in bytes to search (default: 1MB)",
60
60
  default: MAX_FILE_SIZE
61
61
  },
62
- max_files_to_search: {
63
- type: "integer",
64
- description: "Maximum number of files to search",
65
- default: 500
66
- }
67
62
  },
68
63
  required: %w[pattern]
69
64
  }
@@ -78,7 +73,7 @@ module Clacky
78
73
  max_matches_per_file: 50,
79
74
  max_total_matches: 200,
80
75
  max_file_size: MAX_FILE_SIZE,
81
- max_files_to_search: 500
76
+ max_files_to_search: 10000
82
77
  )
83
78
  # Validate pattern
84
79
  if pattern.nil? || pattern.strip.empty?
@@ -135,7 +130,7 @@ module Clacky
135
130
  end
136
131
 
137
132
  # Skip if file should be ignored (unless it's a config file)
138
- if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
133
+ if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
139
134
  !Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
140
135
  skipped[:ignored] += 1
141
136
  next
@@ -217,12 +212,12 @@ module Clacky
217
212
  matches = result[:total_matches] || 0
218
213
  files = result[:files_with_matches] || 0
219
214
  msg = "[OK] Found #{matches} matches in #{files} files"
220
-
215
+
221
216
  # Add truncation info if present
222
217
  if result[:truncated] && result[:truncation_reason]
223
218
  msg += " (truncated: #{result[:truncation_reason]})"
224
219
  end
225
-
220
+
226
221
  msg
227
222
  end
228
223
  end
@@ -275,12 +270,12 @@ module Clacky
275
270
 
276
271
  def search_file(file, regex, context_lines, max_matches)
277
272
  matches = []
278
-
273
+
279
274
  # Use File.foreach for memory-efficient line-by-line reading
280
275
  File.foreach(file, chomp: true).with_index do |line, index|
281
276
  # Stop if we have enough matches for this file
282
277
  break if matches.length >= max_matches
283
-
278
+
284
279
  next unless line.match?(regex)
285
280
 
286
281
  # Truncate long lines
@@ -315,10 +310,10 @@ module Clacky
315
310
  (start_line..end_line).each do |i|
316
311
  line_content = lines[i]
317
312
  # Truncate long lines in context too
318
- display_content = line_content.length > MAX_LINE_LENGTH ?
319
- "#{line_content[0...MAX_LINE_LENGTH]}..." :
313
+ display_content = line_content.length > MAX_LINE_LENGTH ?
314
+ "#{line_content[0...MAX_LINE_LENGTH]}..." :
320
315
  line_content
321
-
316
+
322
317
  context << {
323
318
  line_number: i + 1,
324
319
  content: display_content,
@@ -32,20 +32,26 @@ module Clacky
32
32
  required: ["command"]
33
33
  }
34
34
 
35
- def execute(command:, timeout: nil, max_output_lines: 1000)
35
+ def execute(command:, timeout: nil, max_output_lines: 1000, skip_safety_check: false)
36
36
  # Get project root directory
37
37
  project_root = Dir.pwd
38
38
 
39
39
  begin
40
40
  # 1. Extract timeout from command if it starts with "timeout N"
41
41
  command, extracted_timeout = extract_timeout_from_command(command)
42
-
42
+
43
43
  # Use extracted timeout if not explicitly provided
44
44
  timeout ||= extracted_timeout
45
45
 
46
- # 2. Use safety replacer to process command
47
- safety_replacer = CommandSafetyReplacer.new(project_root)
48
- safe_command = safety_replacer.make_command_safe(command)
46
+ # 2. Use safety replacer to process command (skip if user already confirmed)
47
+ if skip_safety_check
48
+ # User has confirmed, execute command as-is (no safety modifications)
49
+ safe_command = command
50
+ safety_replacer = nil
51
+ else
52
+ safety_replacer = CommandSafetyReplacer.new(project_root)
53
+ safe_command = safety_replacer.make_command_safe(command)
54
+ end
49
55
 
50
56
  # 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
51
57
  soft_timeout = 5
@@ -55,7 +61,7 @@ module Clacky
55
61
  result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
56
62
 
57
63
  # 5. Enhance result information
58
- enhance_result(result, command, safe_command)
64
+ enhance_result(result, command, safe_command, safety_replacer)
59
65
 
60
66
  rescue SecurityError => e
61
67
  # Security error, return friendly error message
@@ -145,9 +151,9 @@ module Clacky
145
151
  end
146
152
  end
147
153
 
148
- def enhance_result(result, original_command, safe_command)
154
+ def enhance_result(result, original_command, safe_command, safety_replacer = nil)
149
155
  # If command was replaced, add security information
150
- if original_command != safe_command
156
+ if safety_replacer && original_command != safe_command
151
157
  result[:security_enhanced] = true
152
158
  result[:original_command] = original_command
153
159
  result[:safe_command] = safe_command