openclacky 0.5.3 → 0.5.5
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 +13 -0
- data/README.md +2 -0
- data/Rakefile +1 -5
- data/lib/clacky/agent.rb +34 -16
- data/lib/clacky/cli.rb +54 -133
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/gitignore_parser.rb +114 -0
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/tools/grep.rb +268 -30
- data/lib/clacky/tools/safe_shell.rb +9 -4
- data/lib/clacky/tools/shell.rb +60 -22
- data/lib/clacky/ui/banner.rb +22 -11
- data/lib/clacky/ui/enhanced_prompt.rb +540 -0
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +3 -2
- metadata +4 -3
- data/lib/clacky/conversation.rb +0 -41
- data/lib/clacky/ui/prompt.rb +0 -72
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Module for handling AI model pricing
|
|
5
|
+
# Supports different pricing tiers and prompt caching
|
|
6
|
+
module ModelPricing
|
|
7
|
+
# Pricing per 1M tokens (MTok) in USD
|
|
8
|
+
# All pricing is based on official API documentation
|
|
9
|
+
PRICING_TABLE = {
|
|
10
|
+
# Claude 4.5 models - tiered pricing based on prompt length
|
|
11
|
+
"claude-opus-4.5" => {
|
|
12
|
+
input: {
|
|
13
|
+
default: 5.00, # $5/MTok for prompts ≤ 200K tokens
|
|
14
|
+
over_200k: 5.00 # same for all tiers
|
|
15
|
+
},
|
|
16
|
+
output: {
|
|
17
|
+
default: 25.00, # $25/MTok for prompts ≤ 200K tokens
|
|
18
|
+
over_200k: 25.00 # same for all tiers
|
|
19
|
+
},
|
|
20
|
+
cache: {
|
|
21
|
+
write: 6.25, # $6.25/MTok cache write
|
|
22
|
+
read: 0.50 # $0.50/MTok cache read
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"claude-sonnet-4.5" => {
|
|
27
|
+
input: {
|
|
28
|
+
default: 3.00, # $3/MTok for prompts ≤ 200K tokens
|
|
29
|
+
over_200k: 6.00 # $6/MTok for prompts > 200K tokens
|
|
30
|
+
},
|
|
31
|
+
output: {
|
|
32
|
+
default: 15.00, # $15/MTok for prompts ≤ 200K tokens
|
|
33
|
+
over_200k: 22.50 # $22.50/MTok for prompts > 200K tokens
|
|
34
|
+
},
|
|
35
|
+
cache: {
|
|
36
|
+
write_default: 3.75, # $3.75/MTok cache write (≤ 200K)
|
|
37
|
+
write_over_200k: 7.50, # $7.50/MTok cache write (> 200K)
|
|
38
|
+
read_default: 0.30, # $0.30/MTok cache read (≤ 200K)
|
|
39
|
+
read_over_200k: 0.60 # $0.60/MTok cache read (> 200K)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
"claude-haiku-4.5" => {
|
|
44
|
+
input: {
|
|
45
|
+
default: 1.00, # $1/MTok
|
|
46
|
+
over_200k: 1.00 # same for all tiers
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
default: 5.00, # $5/MTok
|
|
50
|
+
over_200k: 5.00 # same for all tiers
|
|
51
|
+
},
|
|
52
|
+
cache: {
|
|
53
|
+
write: 1.25, # $1.25/MTok cache write
|
|
54
|
+
read: 0.10 # $0.10/MTok cache read
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
# Claude 3.5 models (for backwards compatibility)
|
|
59
|
+
"claude-3-5-sonnet-20241022" => {
|
|
60
|
+
input: {
|
|
61
|
+
default: 3.00,
|
|
62
|
+
over_200k: 6.00
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
default: 15.00,
|
|
66
|
+
over_200k: 22.50
|
|
67
|
+
},
|
|
68
|
+
cache: {
|
|
69
|
+
write_default: 3.75,
|
|
70
|
+
write_over_200k: 7.50,
|
|
71
|
+
read_default: 0.30,
|
|
72
|
+
read_over_200k: 0.60
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
"claude-3-5-sonnet-20240620" => {
|
|
77
|
+
input: {
|
|
78
|
+
default: 3.00,
|
|
79
|
+
over_200k: 6.00
|
|
80
|
+
},
|
|
81
|
+
output: {
|
|
82
|
+
default: 15.00,
|
|
83
|
+
over_200k: 22.50
|
|
84
|
+
},
|
|
85
|
+
cache: {
|
|
86
|
+
write_default: 3.75,
|
|
87
|
+
write_over_200k: 7.50,
|
|
88
|
+
read_default: 0.30,
|
|
89
|
+
read_over_200k: 0.60
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"claude-3-5-haiku-20241022" => {
|
|
94
|
+
input: {
|
|
95
|
+
default: 1.00,
|
|
96
|
+
over_200k: 1.00
|
|
97
|
+
},
|
|
98
|
+
output: {
|
|
99
|
+
default: 5.00,
|
|
100
|
+
over_200k: 5.00
|
|
101
|
+
},
|
|
102
|
+
cache: {
|
|
103
|
+
write: 1.25,
|
|
104
|
+
read: 0.10
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
# Default fallback pricing (conservative estimates)
|
|
109
|
+
"default" => {
|
|
110
|
+
input: {
|
|
111
|
+
default: 0.50,
|
|
112
|
+
over_200k: 0.50
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
default: 1.50,
|
|
116
|
+
over_200k: 1.50
|
|
117
|
+
},
|
|
118
|
+
cache: {
|
|
119
|
+
write: 0.625,
|
|
120
|
+
read: 0.05
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}.freeze
|
|
124
|
+
|
|
125
|
+
# Threshold for tiered pricing (200K tokens)
|
|
126
|
+
TIERED_PRICING_THRESHOLD = 200_000
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
# Calculate cost for the given model and usage
|
|
130
|
+
#
|
|
131
|
+
# @param model [String] Model identifier
|
|
132
|
+
# @param usage [Hash] Usage statistics containing:
|
|
133
|
+
# - prompt_tokens: number of input tokens
|
|
134
|
+
# - completion_tokens: number of output tokens
|
|
135
|
+
# - cache_creation_input_tokens: tokens written to cache (optional)
|
|
136
|
+
# - cache_read_input_tokens: tokens read from cache (optional)
|
|
137
|
+
# @return [Hash] Hash containing:
|
|
138
|
+
# - cost: Cost in USD (Float)
|
|
139
|
+
# - source: Cost source (:price or :default) (Symbol)
|
|
140
|
+
def calculate_cost(model:, usage:)
|
|
141
|
+
pricing_result = get_pricing_with_source(model)
|
|
142
|
+
pricing = pricing_result[:pricing]
|
|
143
|
+
source = pricing_result[:source]
|
|
144
|
+
|
|
145
|
+
prompt_tokens = usage[:prompt_tokens] || 0
|
|
146
|
+
completion_tokens = usage[:completion_tokens] || 0
|
|
147
|
+
cache_write_tokens = usage[:cache_creation_input_tokens] || 0
|
|
148
|
+
cache_read_tokens = usage[:cache_read_input_tokens] || 0
|
|
149
|
+
|
|
150
|
+
# Determine if we're in the over_200k tier
|
|
151
|
+
total_input_tokens = prompt_tokens + cache_write_tokens + cache_read_tokens
|
|
152
|
+
over_threshold = total_input_tokens > TIERED_PRICING_THRESHOLD
|
|
153
|
+
|
|
154
|
+
# Calculate regular input cost (non-cached tokens)
|
|
155
|
+
regular_input_tokens = prompt_tokens - cache_write_tokens - cache_read_tokens
|
|
156
|
+
input_rate = over_threshold ? pricing[:input][:over_200k] : pricing[:input][:default]
|
|
157
|
+
input_cost = (regular_input_tokens / 1_000_000.0) * input_rate
|
|
158
|
+
|
|
159
|
+
# Calculate output cost
|
|
160
|
+
output_rate = over_threshold ? pricing[:output][:over_200k] : pricing[:output][:default]
|
|
161
|
+
output_cost = (completion_tokens / 1_000_000.0) * output_rate
|
|
162
|
+
|
|
163
|
+
# Calculate cache costs
|
|
164
|
+
cache_cost = calculate_cache_cost(
|
|
165
|
+
pricing: pricing,
|
|
166
|
+
cache_write_tokens: cache_write_tokens,
|
|
167
|
+
cache_read_tokens: cache_read_tokens,
|
|
168
|
+
over_threshold: over_threshold
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
cost: input_cost + output_cost + cache_cost,
|
|
173
|
+
source: source
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get pricing for a specific model
|
|
178
|
+
# Falls back to default pricing if model not found
|
|
179
|
+
#
|
|
180
|
+
# @param model [String] Model identifier
|
|
181
|
+
# @return [Hash] Pricing structure for the model
|
|
182
|
+
def get_pricing(model)
|
|
183
|
+
get_pricing_with_source(model)[:pricing]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get pricing with source information
|
|
187
|
+
#
|
|
188
|
+
# @param model [String] Model identifier
|
|
189
|
+
# @return [Hash] Hash containing:
|
|
190
|
+
# - pricing: Pricing structure for the model
|
|
191
|
+
# - source: :price (matched model) or :default (fallback)
|
|
192
|
+
def get_pricing_with_source(model)
|
|
193
|
+
# Normalize model name (remove version suffixes, handle variations)
|
|
194
|
+
normalized_model = normalize_model_name(model)
|
|
195
|
+
|
|
196
|
+
if normalized_model == "default"
|
|
197
|
+
# Using default fallback pricing
|
|
198
|
+
{
|
|
199
|
+
pricing: PRICING_TABLE["default"],
|
|
200
|
+
source: :default
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
# Found specific pricing for this model
|
|
204
|
+
{
|
|
205
|
+
pricing: PRICING_TABLE[normalized_model],
|
|
206
|
+
source: :price
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# Normalize model name to match pricing table keys
|
|
214
|
+
def normalize_model_name(model)
|
|
215
|
+
return "default" if model.nil? || model.empty?
|
|
216
|
+
|
|
217
|
+
model = model.downcase.strip
|
|
218
|
+
|
|
219
|
+
# Direct match
|
|
220
|
+
return model if PRICING_TABLE.key?(model)
|
|
221
|
+
|
|
222
|
+
# Check for Claude model variations
|
|
223
|
+
# Support both dot and dash separators (e.g., "4.5" or "4-5")
|
|
224
|
+
case model
|
|
225
|
+
when /claude.*opus.*4[.-]?5/i
|
|
226
|
+
"claude-opus-4.5"
|
|
227
|
+
when /claude.*sonnet.*4[.-]?5/i
|
|
228
|
+
"claude-sonnet-4.5"
|
|
229
|
+
when /claude.*haiku.*4[.-]?5/i
|
|
230
|
+
"claude-haiku-4.5"
|
|
231
|
+
when /claude-3-5-sonnet-20241022/i
|
|
232
|
+
"claude-3-5-sonnet-20241022"
|
|
233
|
+
when /claude-3-5-sonnet-20240620/i
|
|
234
|
+
"claude-3-5-sonnet-20240620"
|
|
235
|
+
when /claude-3-5-haiku-20241022/i
|
|
236
|
+
"claude-3-5-haiku-20241022"
|
|
237
|
+
else
|
|
238
|
+
"default"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Calculate cache-related costs
|
|
243
|
+
def calculate_cache_cost(pricing:, cache_write_tokens:, cache_read_tokens:, over_threshold:)
|
|
244
|
+
cache_cost = 0.0
|
|
245
|
+
|
|
246
|
+
# Cache write cost
|
|
247
|
+
if cache_write_tokens > 0
|
|
248
|
+
write_rate = if pricing[:cache].key?(:write)
|
|
249
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
250
|
+
pricing[:cache][:write]
|
|
251
|
+
elsif over_threshold
|
|
252
|
+
# Tiered pricing (Sonnet 4.5)
|
|
253
|
+
pricing[:cache][:write_over_200k]
|
|
254
|
+
else
|
|
255
|
+
pricing[:cache][:write_default]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
cache_cost += (cache_write_tokens / 1_000_000.0) * write_rate
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Cache read cost
|
|
262
|
+
if cache_read_tokens > 0
|
|
263
|
+
read_rate = if pricing[:cache].key?(:read)
|
|
264
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
265
|
+
pricing[:cache][:read]
|
|
266
|
+
elsif over_threshold
|
|
267
|
+
# Tiered pricing (Sonnet 4.5)
|
|
268
|
+
pricing[:cache][:read_over_200k]
|
|
269
|
+
else
|
|
270
|
+
pricing[:cache][:read_default]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
cache_cost += (cache_read_tokens / 1_000_000.0) * read_rate
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
cache_cost
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Tools
|
|
5
5
|
class Grep < Base
|
|
6
|
+
# Default patterns to ignore when .gitignore is not available
|
|
7
|
+
DEFAULT_IGNORED_PATTERNS = [
|
|
8
|
+
'node_modules',
|
|
9
|
+
'vendor/bundle',
|
|
10
|
+
'.git',
|
|
11
|
+
'.svn',
|
|
12
|
+
'tmp',
|
|
13
|
+
'log',
|
|
14
|
+
'coverage',
|
|
15
|
+
'dist',
|
|
16
|
+
'build',
|
|
17
|
+
'.bundle',
|
|
18
|
+
'.sass-cache',
|
|
19
|
+
'.DS_Store',
|
|
20
|
+
'*.log'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Config file patterns that should always be searchable
|
|
24
|
+
CONFIG_FILE_PATTERNS = [
|
|
25
|
+
/\.env/,
|
|
26
|
+
/\.ya?ml$/,
|
|
27
|
+
/\.json$/,
|
|
28
|
+
/\.toml$/,
|
|
29
|
+
/\.ini$/,
|
|
30
|
+
/\.conf$/,
|
|
31
|
+
/\.config$/,
|
|
32
|
+
/config\//,
|
|
33
|
+
/\.config\//
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Maximum file size to search (1MB)
|
|
37
|
+
MAX_FILE_SIZE = 1_048_576
|
|
38
|
+
|
|
39
|
+
# Maximum line length to display (to avoid huge outputs)
|
|
40
|
+
MAX_LINE_LENGTH = 500
|
|
41
|
+
|
|
6
42
|
self.tool_name = "grep"
|
|
7
43
|
self.tool_description = "Search file contents using regular expressions. Returns matching lines with context."
|
|
8
44
|
self.tool_category = "file_system"
|
|
@@ -30,53 +66,144 @@ module Clacky
|
|
|
30
66
|
},
|
|
31
67
|
context_lines: {
|
|
32
68
|
type: "integer",
|
|
33
|
-
description: "Number of context lines to show before and after each match",
|
|
69
|
+
description: "Number of context lines to show before and after each match (max: 10)",
|
|
34
70
|
default: 0
|
|
35
71
|
},
|
|
36
|
-
|
|
72
|
+
max_files: {
|
|
37
73
|
type: "integer",
|
|
38
74
|
description: "Maximum number of matching files to return",
|
|
39
75
|
default: 50
|
|
76
|
+
},
|
|
77
|
+
max_matches_per_file: {
|
|
78
|
+
type: "integer",
|
|
79
|
+
description: "Maximum number of matches to return per file",
|
|
80
|
+
default: 50
|
|
81
|
+
},
|
|
82
|
+
max_total_matches: {
|
|
83
|
+
type: "integer",
|
|
84
|
+
description: "Maximum total number of matches to return across all files",
|
|
85
|
+
default: 200
|
|
86
|
+
},
|
|
87
|
+
max_file_size: {
|
|
88
|
+
type: "integer",
|
|
89
|
+
description: "Maximum file size in bytes to search (default: 1MB)",
|
|
90
|
+
default: MAX_FILE_SIZE
|
|
91
|
+
},
|
|
92
|
+
max_files_to_search: {
|
|
93
|
+
type: "integer",
|
|
94
|
+
description: "Maximum number of files to search",
|
|
95
|
+
default: 500
|
|
40
96
|
}
|
|
41
97
|
},
|
|
42
98
|
required: %w[pattern]
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
def execute(
|
|
101
|
+
def execute(
|
|
102
|
+
pattern:,
|
|
103
|
+
path: ".",
|
|
104
|
+
file_pattern: "**/*",
|
|
105
|
+
case_insensitive: false,
|
|
106
|
+
context_lines: 0,
|
|
107
|
+
max_files: 50,
|
|
108
|
+
max_matches_per_file: 50,
|
|
109
|
+
max_total_matches: 200,
|
|
110
|
+
max_file_size: MAX_FILE_SIZE,
|
|
111
|
+
max_files_to_search: 500
|
|
112
|
+
)
|
|
46
113
|
# Validate pattern
|
|
47
114
|
if pattern.nil? || pattern.strip.empty?
|
|
48
115
|
return { error: "Pattern cannot be empty" }
|
|
49
116
|
end
|
|
50
117
|
|
|
51
|
-
# Validate path
|
|
52
|
-
|
|
118
|
+
# Validate and expand path
|
|
119
|
+
begin
|
|
120
|
+
expanded_path = File.expand_path(path)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
return { error: "Invalid path: #{e.message}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
unless File.exist?(expanded_path)
|
|
53
126
|
return { error: "Path does not exist: #{path}" }
|
|
54
127
|
end
|
|
55
128
|
|
|
129
|
+
# Limit context_lines
|
|
130
|
+
context_lines = [[context_lines, 0].max, 10].min
|
|
131
|
+
|
|
56
132
|
begin
|
|
57
133
|
# Compile regex
|
|
58
134
|
regex_options = case_insensitive ? Regexp::IGNORECASE : 0
|
|
59
135
|
regex = Regexp.new(pattern, regex_options)
|
|
60
136
|
|
|
137
|
+
# Initialize gitignore parser
|
|
138
|
+
gitignore_path = find_gitignore(expanded_path)
|
|
139
|
+
gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
|
|
140
|
+
|
|
61
141
|
results = []
|
|
62
142
|
total_matches = 0
|
|
143
|
+
files_searched = 0
|
|
144
|
+
skipped = {
|
|
145
|
+
binary: 0,
|
|
146
|
+
too_large: 0,
|
|
147
|
+
ignored: 0
|
|
148
|
+
}
|
|
149
|
+
truncation_reason = nil
|
|
63
150
|
|
|
64
151
|
# Get files to search
|
|
65
|
-
files = if File.file?(
|
|
66
|
-
[
|
|
152
|
+
files = if File.file?(expanded_path)
|
|
153
|
+
[expanded_path]
|
|
67
154
|
else
|
|
68
|
-
Dir.glob(File.join(
|
|
155
|
+
Dir.glob(File.join(expanded_path, file_pattern))
|
|
69
156
|
.select { |f| File.file?(f) }
|
|
70
|
-
.reject { |f| binary_file?(f) }
|
|
71
157
|
end
|
|
72
158
|
|
|
73
159
|
# Search each file
|
|
74
160
|
files.each do |file|
|
|
75
|
-
|
|
161
|
+
# Check if we've searched enough files
|
|
162
|
+
if files_searched >= max_files_to_search
|
|
163
|
+
truncation_reason ||= "max_files_to_search limit reached"
|
|
164
|
+
break
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Skip if file should be ignored (unless it's a config file)
|
|
168
|
+
if should_ignore_file?(file, expanded_path, gitignore) && !is_config_file?(file)
|
|
169
|
+
skipped[:ignored] += 1
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Skip binary files
|
|
174
|
+
if binary_file?(file)
|
|
175
|
+
skipped[:binary] += 1
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Skip files that are too large
|
|
180
|
+
if File.size(file) > max_file_size
|
|
181
|
+
skipped[:too_large] += 1
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
files_searched += 1
|
|
186
|
+
|
|
187
|
+
# Check if we've found enough matching files
|
|
188
|
+
if results.length >= max_files
|
|
189
|
+
truncation_reason ||= "max_files limit reached"
|
|
190
|
+
break
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Check if we've found enough total matches
|
|
194
|
+
if total_matches >= max_total_matches
|
|
195
|
+
truncation_reason ||= "max_total_matches limit reached"
|
|
196
|
+
break
|
|
197
|
+
end
|
|
76
198
|
|
|
77
|
-
|
|
199
|
+
# Search the file
|
|
200
|
+
matches = search_file(file, regex, context_lines, max_matches_per_file)
|
|
78
201
|
next if matches.empty?
|
|
79
202
|
|
|
203
|
+
# Add remaining matches respecting max_total_matches
|
|
204
|
+
remaining_matches = max_total_matches - total_matches
|
|
205
|
+
matches = matches.take(remaining_matches) if remaining_matches < matches.length
|
|
206
|
+
|
|
80
207
|
results << {
|
|
81
208
|
file: File.expand_path(file),
|
|
82
209
|
matches: matches
|
|
@@ -87,9 +214,11 @@ module Clacky
|
|
|
87
214
|
{
|
|
88
215
|
results: results,
|
|
89
216
|
total_matches: total_matches,
|
|
90
|
-
files_searched:
|
|
217
|
+
files_searched: files_searched,
|
|
91
218
|
files_with_matches: results.length,
|
|
92
|
-
|
|
219
|
+
skipped_files: skipped,
|
|
220
|
+
truncated: !truncation_reason.nil?,
|
|
221
|
+
truncation_reason: truncation_reason,
|
|
93
222
|
error: nil
|
|
94
223
|
}
|
|
95
224
|
rescue RegexpError => e
|
|
@@ -116,36 +245,119 @@ module Clacky
|
|
|
116
245
|
else
|
|
117
246
|
matches = result[:total_matches] || 0
|
|
118
247
|
files = result[:files_with_matches] || 0
|
|
119
|
-
"✓ Found #{matches} matches in #{files} files"
|
|
248
|
+
msg = "✓ Found #{matches} matches in #{files} files"
|
|
249
|
+
|
|
250
|
+
# Add truncation info if present
|
|
251
|
+
if result[:truncated] && result[:truncation_reason]
|
|
252
|
+
msg += " (truncated: #{result[:truncation_reason]})"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
msg
|
|
120
256
|
end
|
|
121
257
|
end
|
|
122
258
|
|
|
123
259
|
private
|
|
124
260
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
261
|
+
# Find .gitignore file in the search path or parent directories
|
|
262
|
+
# Only searches within the search path and up to the current working directory
|
|
263
|
+
def find_gitignore(path)
|
|
264
|
+
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
265
|
+
|
|
266
|
+
# Look for .gitignore in current and parent directories
|
|
267
|
+
current = File.expand_path(search_path)
|
|
268
|
+
cwd = File.expand_path(Dir.pwd)
|
|
269
|
+
root = File.expand_path('/')
|
|
270
|
+
|
|
271
|
+
# Limit search: only go up to current working directory
|
|
272
|
+
# This prevents finding .gitignore files from unrelated parent directories
|
|
273
|
+
# when searching in temporary directories (like /tmp in tests)
|
|
274
|
+
search_limit = if current.start_with?(cwd)
|
|
275
|
+
cwd
|
|
276
|
+
else
|
|
277
|
+
current
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
loop do
|
|
281
|
+
gitignore = File.join(current, '.gitignore')
|
|
282
|
+
return gitignore if File.exist?(gitignore)
|
|
283
|
+
|
|
284
|
+
# Stop if we've reached the search limit or root
|
|
285
|
+
break if current == search_limit || current == root
|
|
286
|
+
current = File.dirname(current)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Check if file should be ignored based on .gitignore or default patterns
|
|
293
|
+
def should_ignore_file?(file, base_path, gitignore)
|
|
294
|
+
# Always calculate path relative to base_path for consistency
|
|
295
|
+
# Expand both paths to handle symlinks and relative paths correctly
|
|
296
|
+
expanded_file = File.expand_path(file)
|
|
297
|
+
expanded_base = File.expand_path(base_path)
|
|
298
|
+
|
|
299
|
+
# For files, use the directory as base
|
|
300
|
+
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
301
|
+
|
|
302
|
+
# Calculate relative path
|
|
303
|
+
if expanded_file.start_with?(expanded_base)
|
|
304
|
+
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
305
|
+
else
|
|
306
|
+
# File is outside base path - use just the filename
|
|
307
|
+
relative_path = File.basename(expanded_file)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Clean up relative path
|
|
311
|
+
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
312
|
+
|
|
313
|
+
if gitignore
|
|
314
|
+
# Use .gitignore rules
|
|
315
|
+
gitignore.ignored?(relative_path)
|
|
316
|
+
else
|
|
317
|
+
# Use default ignore patterns - only match against relative path components
|
|
318
|
+
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
319
|
+
if pattern.include?('*')
|
|
320
|
+
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
321
|
+
else
|
|
322
|
+
# Match pattern as a path component (not substring of absolute path)
|
|
323
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
324
|
+
relative_path.include?("/#{pattern}/") ||
|
|
325
|
+
relative_path == pattern ||
|
|
326
|
+
File.basename(relative_path) == pattern
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
128
331
|
|
|
129
|
-
|
|
332
|
+
# Check if file is a config file (should not be ignored even if in .gitignore)
|
|
333
|
+
def is_config_file?(file)
|
|
334
|
+
CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def search_file(file, regex, context_lines, max_matches)
|
|
338
|
+
matches = []
|
|
339
|
+
|
|
340
|
+
# Use File.foreach for memory-efficient line-by-line reading
|
|
341
|
+
File.foreach(file, chomp: true).with_index do |line, index|
|
|
342
|
+
# Stop if we have enough matches for this file
|
|
343
|
+
break if matches.length >= max_matches
|
|
344
|
+
|
|
130
345
|
next unless line.match?(regex)
|
|
131
346
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
end_line = [lines.length - 1, index + context_lines].min
|
|
347
|
+
# Truncate long lines
|
|
348
|
+
display_line = line.length > MAX_LINE_LENGTH ? "#{line[0...MAX_LINE_LENGTH]}..." : line
|
|
135
349
|
|
|
136
|
-
context
|
|
137
|
-
|
|
138
|
-
context
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
is_match: i == index
|
|
142
|
-
}
|
|
350
|
+
# Get context if requested
|
|
351
|
+
if context_lines > 0
|
|
352
|
+
context = get_line_context(file, index, context_lines)
|
|
353
|
+
else
|
|
354
|
+
context = nil
|
|
143
355
|
end
|
|
144
356
|
|
|
145
357
|
matches << {
|
|
146
358
|
line_number: index + 1,
|
|
147
|
-
line:
|
|
148
|
-
context:
|
|
359
|
+
line: display_line,
|
|
360
|
+
context: context
|
|
149
361
|
}
|
|
150
362
|
end
|
|
151
363
|
|
|
@@ -154,6 +366,32 @@ module Clacky
|
|
|
154
366
|
[]
|
|
155
367
|
end
|
|
156
368
|
|
|
369
|
+
# Get context lines around a match
|
|
370
|
+
def get_line_context(file, match_index, context_lines)
|
|
371
|
+
lines = File.readlines(file, chomp: true)
|
|
372
|
+
start_line = [0, match_index - context_lines].max
|
|
373
|
+
end_line = [lines.length - 1, match_index + context_lines].min
|
|
374
|
+
|
|
375
|
+
context = []
|
|
376
|
+
(start_line..end_line).each do |i|
|
|
377
|
+
line_content = lines[i]
|
|
378
|
+
# Truncate long lines in context too
|
|
379
|
+
display_content = line_content.length > MAX_LINE_LENGTH ?
|
|
380
|
+
"#{line_content[0...MAX_LINE_LENGTH]}..." :
|
|
381
|
+
line_content
|
|
382
|
+
|
|
383
|
+
context << {
|
|
384
|
+
line_number: i + 1,
|
|
385
|
+
content: display_content,
|
|
386
|
+
is_match: i == match_index
|
|
387
|
+
}
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
context
|
|
391
|
+
rescue StandardError
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
157
395
|
def binary_file?(file)
|
|
158
396
|
# Simple heuristic: check if file contains null bytes in first 8KB
|
|
159
397
|
return false unless File.exist?(file)
|
|
@@ -26,12 +26,17 @@ module Clacky
|
|
|
26
26
|
hard_timeout: {
|
|
27
27
|
type: "integer",
|
|
28
28
|
description: "Hard timeout in seconds (force kill)"
|
|
29
|
+
},
|
|
30
|
+
max_output_lines: {
|
|
31
|
+
type: "integer",
|
|
32
|
+
description: "Maximum number of output lines to return (default: 1000)",
|
|
33
|
+
default: 1000
|
|
29
34
|
}
|
|
30
35
|
},
|
|
31
36
|
required: ["command"]
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
def execute(command:, soft_timeout: nil, hard_timeout: nil)
|
|
39
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
|
|
35
40
|
# Get project root directory
|
|
36
41
|
project_root = Dir.pwd
|
|
37
42
|
|
|
@@ -41,7 +46,7 @@ module Clacky
|
|
|
41
46
|
safe_command = safety_replacer.make_command_safe(command)
|
|
42
47
|
|
|
43
48
|
# 2. Call parent class execution method
|
|
44
|
-
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout)
|
|
49
|
+
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
45
50
|
|
|
46
51
|
# 3. Enhance result information
|
|
47
52
|
enhance_result(result, command, safe_command)
|
|
@@ -112,8 +117,8 @@ module Clacky
|
|
|
112
117
|
return "safe_shell(<no command>)" if cmd.empty?
|
|
113
118
|
|
|
114
119
|
# Truncate long commands intelligently
|
|
115
|
-
if cmd.length >
|
|
116
|
-
"safe_shell(\"#{cmd[0..
|
|
120
|
+
if cmd.length > 150
|
|
121
|
+
"safe_shell(\"#{cmd[0..147]}...\")"
|
|
117
122
|
else
|
|
118
123
|
"safe_shell(\"#{cmd}\")"
|
|
119
124
|
end
|