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.
- checksums.yaml +4 -4
- data/lib/clacky/agent.rb +542 -54
- data/lib/clacky/cli.rb +341 -2
- 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/file_reader.rb +112 -9
- 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 +34 -43
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- metadata +5 -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::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 (
|
|
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/grep.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
48
|
-
|
|
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
|