openclacky 0.5.6 → 0.6.0
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 +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/client.rb
CHANGED
|
@@ -45,11 +45,24 @@ module Clacky
|
|
|
45
45
|
# Send messages with function calling (tools) support
|
|
46
46
|
# Options:
|
|
47
47
|
# - enable_caching: Enable prompt caching for system prompt and tools (default: false)
|
|
48
|
-
def send_messages_with_tools(messages, model:, tools:, max_tokens:,
|
|
48
|
+
def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false)
|
|
49
|
+
# Apply caching to messages if enabled
|
|
50
|
+
caching_supported = supports_prompt_caching?(model)
|
|
51
|
+
caching_enabled = enable_caching && caching_supported
|
|
52
|
+
|
|
53
|
+
# Deep clone messages to avoid modifying the original array
|
|
54
|
+
processed_messages = messages.map { |msg| deep_clone(msg) }
|
|
55
|
+
|
|
56
|
+
# Add cache control to messages if caching is enabled
|
|
57
|
+
# Strategy: Cache system prompt and first user message for stable prefix caching
|
|
58
|
+
if caching_enabled
|
|
59
|
+
processed_messages = apply_message_caching(processed_messages)
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
body = {
|
|
50
63
|
model: model,
|
|
51
64
|
max_tokens: max_tokens,
|
|
52
|
-
messages:
|
|
65
|
+
messages: processed_messages
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
# Add tools if provided
|
|
@@ -57,44 +70,23 @@ module Clacky
|
|
|
57
70
|
if tools&.any?
|
|
58
71
|
caching_supported = supports_prompt_caching?(model)
|
|
59
72
|
caching_enabled = enable_caching && caching_supported
|
|
60
|
-
|
|
61
|
-
# Debug logging for caching decisions
|
|
62
|
-
if verbose || ENV["CLACKY_DEBUG"]
|
|
63
|
-
puts "\n[DEBUG] Prompt Caching Analysis:"
|
|
64
|
-
puts " Model: #{model}"
|
|
65
|
-
puts " Caching Requested: #{enable_caching}"
|
|
66
|
-
puts " Caching Supported: #{caching_supported}"
|
|
67
|
-
puts " Caching Enabled: #{caching_enabled}"
|
|
68
|
-
end
|
|
69
|
-
|
|
73
|
+
|
|
70
74
|
if caching_enabled
|
|
71
75
|
# Deep clone tools to avoid modifying original
|
|
72
76
|
cached_tools = tools.map { |tool| deep_clone(tool) }
|
|
73
77
|
# Mark the last tool for caching (Claude caches from cache breakpoint to end)
|
|
74
78
|
cached_tools.last[:cache_control] = { type: "ephemeral" }
|
|
75
79
|
body[:tools] = cached_tools
|
|
76
|
-
|
|
77
|
-
if verbose || ENV["CLACKY_DEBUG"]
|
|
78
|
-
puts " Cache Control Added: Last tool marked for caching"
|
|
79
|
-
end
|
|
80
80
|
else
|
|
81
81
|
body[:tools] = tools
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# Debug
|
|
86
|
-
if
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# Create a simplified version of the body for display
|
|
91
|
-
display_body = body.dup
|
|
92
|
-
if display_body[:tools]&.any?
|
|
93
|
-
tool_names = display_body[:tools].map { |t| t.dig(:function, :name) }.compact
|
|
94
|
-
display_body[:tools] = "use tools: #{tool_names.join(', ')}"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
puts JSON.pretty_generate(display_body)
|
|
85
|
+
# Debug: Save request body to see what we're actually sending
|
|
86
|
+
if ENV['CLACKY_DEBUG_REQUEST']
|
|
87
|
+
debug_file = "/tmp/clacky_request_#{Time.now.to_i}.json"
|
|
88
|
+
File.write(debug_file, JSON.pretty_generate(body))
|
|
89
|
+
puts "DEBUG: Request saved to #{debug_file}"
|
|
98
90
|
end
|
|
99
91
|
|
|
100
92
|
response = connection.post("chat/completions") do |req|
|
|
@@ -131,6 +123,49 @@ module Clacky
|
|
|
131
123
|
model_str.match?(cache_pattern)
|
|
132
124
|
end
|
|
133
125
|
|
|
126
|
+
# Apply cache_control to messages for prompt caching
|
|
127
|
+
# Strategy: Add cache_control on the LAST message before tools
|
|
128
|
+
# This ensures everything from start to the breakpoint gets cached
|
|
129
|
+
def apply_message_caching(messages)
|
|
130
|
+
return messages if messages.empty?
|
|
131
|
+
|
|
132
|
+
# Add cache_control to the last message (before tools are added)
|
|
133
|
+
# This will cache: system message + all conversation history
|
|
134
|
+
messages.map.with_index do |msg, idx|
|
|
135
|
+
if idx == messages.length - 1
|
|
136
|
+
# Last message: add cache_control in content block
|
|
137
|
+
add_cache_control_to_message(msg)
|
|
138
|
+
else
|
|
139
|
+
msg
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Convert message content to array format and add cache_control
|
|
145
|
+
# Claude API format: content: [{type: "text", text: "...", cache_control: {...}}]
|
|
146
|
+
def add_cache_control_to_message(msg)
|
|
147
|
+
content = msg[:content]
|
|
148
|
+
|
|
149
|
+
# Convert content to array format if it's a string
|
|
150
|
+
content_array = if content.is_a?(String)
|
|
151
|
+
[{ type: "text", text: content, cache_control: { type: "ephemeral" } }]
|
|
152
|
+
elsif content.is_a?(Array)
|
|
153
|
+
# Content is already an array, add cache_control to the last block
|
|
154
|
+
content.map.with_index do |block, idx|
|
|
155
|
+
if idx == content.length - 1
|
|
156
|
+
block.merge(cache_control: { type: "ephemeral" })
|
|
157
|
+
else
|
|
158
|
+
block
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
# Unknown format, return as-is
|
|
163
|
+
return msg
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
msg.merge(content: content_array)
|
|
167
|
+
end
|
|
168
|
+
|
|
134
169
|
# Deep clone a hash/array structure (for tool definitions)
|
|
135
170
|
def deep_clone(obj)
|
|
136
171
|
case obj
|
|
@@ -178,12 +213,8 @@ module Clacky
|
|
|
178
213
|
message = data["choices"].first["message"]
|
|
179
214
|
usage = data["usage"]
|
|
180
215
|
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
puts "\n[DEBUG] Raw API response content:"
|
|
184
|
-
puts " content: #{message["content"].inspect}"
|
|
185
|
-
puts " content length: #{message["content"]&.length || 0}"
|
|
186
|
-
end
|
|
216
|
+
# Store raw API usage for debugging
|
|
217
|
+
raw_api_usage = usage.dup
|
|
187
218
|
|
|
188
219
|
# Parse usage with cache information
|
|
189
220
|
usage_data = {
|
|
@@ -220,7 +251,8 @@ module Clacky
|
|
|
220
251
|
content: message["content"],
|
|
221
252
|
tool_calls: parse_tool_calls(message["tool_calls"]),
|
|
222
253
|
finish_reason: data["choices"].first["finish_reason"],
|
|
223
|
-
usage: usage_data
|
|
254
|
+
usage: usage_data,
|
|
255
|
+
raw_api_usage: raw_api_usage
|
|
224
256
|
}
|
|
225
257
|
when 401
|
|
226
258
|
raise Error, "Invalid API key"
|
|
@@ -71,41 +71,55 @@ module Clacky
|
|
|
71
71
|
|
|
72
72
|
def match_pattern?(path, pattern_info)
|
|
73
73
|
pattern = pattern_info[:pattern]
|
|
74
|
+
is_absolute = pattern_info[:is_absolute]
|
|
74
75
|
|
|
75
|
-
#
|
|
76
|
-
|
|
76
|
+
# For absolute patterns (starting with /), remove the leading slash
|
|
77
|
+
# These patterns match from the root of the repository
|
|
78
|
+
if is_absolute
|
|
79
|
+
pattern = pattern[1..]
|
|
80
|
+
# Absolute patterns match exactly from the start of the path
|
|
81
|
+
return true if path == pattern
|
|
82
|
+
return true if path.start_with?("#{pattern}/")
|
|
83
|
+
end
|
|
77
84
|
|
|
78
85
|
# Handle directory patterns
|
|
79
86
|
if pattern_info[:is_directory]
|
|
80
|
-
|
|
87
|
+
# Directory patterns should match the directory and all its contents
|
|
88
|
+
return true if path == pattern
|
|
89
|
+
return true if path.start_with?("#{pattern}/")
|
|
90
|
+
# Also check if any path component matches the directory pattern
|
|
91
|
+
return true if path.split('/').include?(pattern)
|
|
81
92
|
end
|
|
82
93
|
|
|
83
94
|
# Handle different wildcard patterns
|
|
84
95
|
if pattern_info[:has_double_star]
|
|
85
96
|
# Convert ** to match any number of directories
|
|
86
|
-
regex_pattern = pattern
|
|
87
|
-
.gsub('
|
|
88
|
-
.gsub('
|
|
89
|
-
.gsub('
|
|
90
|
-
.gsub('
|
|
97
|
+
regex_pattern = Regexp.escape(pattern)
|
|
98
|
+
.gsub('\*\*/', '(.*/)?') # **/ matches zero or more directories
|
|
99
|
+
.gsub('\*\*', '.*') # ** at end matches anything
|
|
100
|
+
.gsub('\*', '[^/]*') # * matches anything except /
|
|
101
|
+
.gsub('\?', '[^/]') # ? matches single character except /
|
|
91
102
|
|
|
92
103
|
regex = Regexp.new("^#{regex_pattern}$")
|
|
93
104
|
return true if path.match?(regex)
|
|
94
105
|
return true if path.split('/').any? { |part| part.match?(regex) }
|
|
95
106
|
elsif pattern_info[:has_wildcard]
|
|
96
107
|
# Convert glob pattern to regex
|
|
97
|
-
regex_pattern = pattern
|
|
98
|
-
.gsub('
|
|
99
|
-
.gsub('
|
|
108
|
+
regex_pattern = Regexp.escape(pattern)
|
|
109
|
+
.gsub('\*', '[^/]*')
|
|
110
|
+
.gsub('\?', '[^/]')
|
|
100
111
|
|
|
101
112
|
regex = Regexp.new("^#{regex_pattern}$")
|
|
102
113
|
return true if path.match?(regex)
|
|
103
114
|
return true if File.basename(path).match?(regex)
|
|
104
115
|
else
|
|
105
|
-
# Exact match
|
|
116
|
+
# Exact match - pattern without wildcards
|
|
117
|
+
# Match as basename or as path prefix
|
|
106
118
|
return true if path == pattern
|
|
107
119
|
return true if path.start_with?("#{pattern}/")
|
|
108
120
|
return true if File.basename(path) == pattern
|
|
121
|
+
# Also check if pattern matches any path component
|
|
122
|
+
return true if path.split('/').include?(pattern)
|
|
109
123
|
end
|
|
110
124
|
|
|
111
125
|
false
|
data/lib/clacky/model_pricing.rb
CHANGED
|
@@ -148,11 +148,15 @@ module Clacky
|
|
|
148
148
|
cache_read_tokens = usage[:cache_read_input_tokens] || 0
|
|
149
149
|
|
|
150
150
|
# Determine if we're in the over_200k tier
|
|
151
|
-
|
|
151
|
+
# Note: prompt_tokens includes cache_read_tokens but NOT cache_write_tokens
|
|
152
|
+
# cache_write_tokens are additional tokens that were written to cache
|
|
153
|
+
total_input_tokens = prompt_tokens + cache_write_tokens
|
|
152
154
|
over_threshold = total_input_tokens > TIERED_PRICING_THRESHOLD
|
|
153
155
|
|
|
154
156
|
# Calculate regular input cost (non-cached tokens)
|
|
155
|
-
|
|
157
|
+
# prompt_tokens already includes cache_read_tokens, so we need to subtract them
|
|
158
|
+
# cache_write_tokens are not part of prompt_tokens, so they're handled separately in cache_cost
|
|
159
|
+
regular_input_tokens = prompt_tokens - cache_read_tokens
|
|
156
160
|
input_rate = over_threshold ? pricing[:input][:over_200k] : pricing[:input][:default]
|
|
157
161
|
input_cost = (regular_input_tokens / 1_000_000.0) * input_rate
|
|
158
162
|
|
|
@@ -20,6 +20,10 @@ module Clacky
|
|
|
20
20
|
FileUtils.chmod(0o600, filepath)
|
|
21
21
|
|
|
22
22
|
@last_saved_path = filepath
|
|
23
|
+
|
|
24
|
+
# Keep only the most recent 10 sessions
|
|
25
|
+
cleanup_by_count(keep: 10)
|
|
26
|
+
|
|
23
27
|
filepath
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -104,9 +108,9 @@ module Clacky
|
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
def generate_filename(session_id, created_at)
|
|
107
|
-
|
|
111
|
+
datetime = Time.parse(created_at).strftime("%Y-%m-%d-%H-%M-%S")
|
|
108
112
|
short_id = session_id[0..7]
|
|
109
|
-
"#{
|
|
113
|
+
"#{datetime}-#{short_id}.json"
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def all_sessions
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -5,9 +5,12 @@ require "pathname"
|
|
|
5
5
|
module Clacky
|
|
6
6
|
module Tools
|
|
7
7
|
class Glob < Base
|
|
8
|
+
# Maximum file size to search (1MB)
|
|
9
|
+
MAX_FILE_SIZE = 1_048_576
|
|
10
|
+
|
|
8
11
|
self.tool_name = "glob"
|
|
9
12
|
self.tool_description = "Find files matching a glob pattern (e.g., '**/*.rb', 'src/**/*.js'). " \
|
|
10
|
-
"Returns file paths sorted by modification time."
|
|
13
|
+
"Returns file paths sorted by modification time. Respects .gitignore patterns."
|
|
11
14
|
self.tool_category = "file_system"
|
|
12
15
|
self.tool_parameters = {
|
|
13
16
|
type: "object",
|
|
@@ -23,14 +26,14 @@ module Clacky
|
|
|
23
26
|
},
|
|
24
27
|
limit: {
|
|
25
28
|
type: "integer",
|
|
26
|
-
description: "Maximum number of results to return (default:
|
|
27
|
-
default:
|
|
29
|
+
description: "Maximum number of results to return (default: 10)",
|
|
30
|
+
default: 10
|
|
28
31
|
}
|
|
29
32
|
},
|
|
30
33
|
required: %w[pattern]
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
def execute(pattern:, base_path: ".", limit:
|
|
36
|
+
def execute(pattern:, base_path: ".", limit: 10)
|
|
34
37
|
# Validate pattern
|
|
35
38
|
if pattern.nil? || pattern.strip.empty?
|
|
36
39
|
return { error: "Pattern cannot be empty" }
|
|
@@ -42,11 +45,49 @@ module Clacky
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
begin
|
|
48
|
+
# Expand base path
|
|
49
|
+
expanded_path = File.expand_path(base_path)
|
|
50
|
+
|
|
51
|
+
# Initialize gitignore parser
|
|
52
|
+
gitignore_path = Clacky::Utils::FileIgnoreHelper.find_gitignore(expanded_path)
|
|
53
|
+
gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
|
|
54
|
+
|
|
55
|
+
# Track skipped files
|
|
56
|
+
skipped = {
|
|
57
|
+
binary: 0,
|
|
58
|
+
too_large: 0,
|
|
59
|
+
ignored: 0
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
# Change to base path and find matches
|
|
46
63
|
full_pattern = File.join(base_path, pattern)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
|
|
65
|
+
.reject { |path| File.directory?(path) }
|
|
66
|
+
.reject { |path| path.end_with?(".", "..") }
|
|
67
|
+
|
|
68
|
+
# Filter out ignored, binary, and too large files
|
|
69
|
+
matches = all_matches.select do |file|
|
|
70
|
+
# Skip if file should be ignored (unless it's a config file)
|
|
71
|
+
if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
|
|
72
|
+
!Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
|
|
73
|
+
skipped[:ignored] += 1
|
|
74
|
+
next false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Skip binary files
|
|
78
|
+
if Clacky::Utils::FileIgnoreHelper.binary_file?(file)
|
|
79
|
+
skipped[:binary] += 1
|
|
80
|
+
next false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Skip files that are too large
|
|
84
|
+
if File.size(file) > MAX_FILE_SIZE
|
|
85
|
+
skipped[:too_large] += 1
|
|
86
|
+
next false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
true
|
|
90
|
+
end
|
|
50
91
|
|
|
51
92
|
# Sort by modification time (most recent first)
|
|
52
93
|
matches = matches.sort_by { |path| -File.mtime(path).to_i }
|
|
@@ -55,7 +96,7 @@ module Clacky
|
|
|
55
96
|
total_matches = matches.length
|
|
56
97
|
matches = matches.take(limit)
|
|
57
98
|
|
|
58
|
-
# Convert to
|
|
99
|
+
# Convert to absolute paths
|
|
59
100
|
matches = matches.map { |path| File.expand_path(path) }
|
|
60
101
|
|
|
61
102
|
{
|
|
@@ -63,6 +104,7 @@ module Clacky
|
|
|
63
104
|
total_matches: total_matches,
|
|
64
105
|
returned: matches.length,
|
|
65
106
|
truncated: total_matches > limit,
|
|
107
|
+
skipped_files: skipped,
|
|
66
108
|
error: nil
|
|
67
109
|
}
|
|
68
110
|
rescue StandardError => e
|
|
@@ -85,7 +127,21 @@ module Clacky
|
|
|
85
127
|
count = result[:returned] || 0
|
|
86
128
|
total = result[:total_matches] || 0
|
|
87
129
|
truncated = result[:truncated] ? " (truncated)" : ""
|
|
88
|
-
|
|
130
|
+
|
|
131
|
+
msg = "✓ Found #{count}/#{total} files#{truncated}"
|
|
132
|
+
|
|
133
|
+
# Add skipped files info if present
|
|
134
|
+
if result[:skipped_files]
|
|
135
|
+
skipped = result[:skipped_files]
|
|
136
|
+
skipped_parts = []
|
|
137
|
+
skipped_parts << "#{skipped[:ignored]} ignored" if skipped[:ignored] > 0
|
|
138
|
+
skipped_parts << "#{skipped[:binary]} binary" if skipped[:binary] > 0
|
|
139
|
+
skipped_parts << "#{skipped[:too_large]} too large" if skipped[:too_large] > 0
|
|
140
|
+
|
|
141
|
+
msg += " (skipped: #{skipped_parts.join(', ')})" unless skipped_parts.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
msg
|
|
89
145
|
end
|
|
90
146
|
end
|
|
91
147
|
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -3,36 +3,6 @@
|
|
|
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
6
|
# Maximum file size to search (1MB)
|
|
37
7
|
MAX_FILE_SIZE = 1_048_576
|
|
38
8
|
|
|
@@ -135,7 +105,7 @@ module Clacky
|
|
|
135
105
|
regex = Regexp.new(pattern, regex_options)
|
|
136
106
|
|
|
137
107
|
# Initialize gitignore parser
|
|
138
|
-
gitignore_path = find_gitignore(expanded_path)
|
|
108
|
+
gitignore_path = Clacky::Utils::FileIgnoreHelper.find_gitignore(expanded_path)
|
|
139
109
|
gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
|
|
140
110
|
|
|
141
111
|
results = []
|
|
@@ -165,13 +135,14 @@ module Clacky
|
|
|
165
135
|
end
|
|
166
136
|
|
|
167
137
|
# Skip if file should be ignored (unless it's a config file)
|
|
168
|
-
if should_ignore_file?(file, expanded_path, gitignore) &&
|
|
138
|
+
if Clacky::Utils::FileIgnoreHelper.should_ignore_file?(file, expanded_path, gitignore) &&
|
|
139
|
+
!Clacky::Utils::FileIgnoreHelper.is_config_file?(file)
|
|
169
140
|
skipped[:ignored] += 1
|
|
170
141
|
next
|
|
171
142
|
end
|
|
172
143
|
|
|
173
144
|
# Skip binary files
|
|
174
|
-
if binary_file?(file)
|
|
145
|
+
if Clacky::Utils::FileIgnoreHelper.binary_file?(file)
|
|
175
146
|
skipped[:binary] += 1
|
|
176
147
|
next
|
|
177
148
|
end
|
|
@@ -302,82 +273,6 @@ module Clacky
|
|
|
302
273
|
|
|
303
274
|
private
|
|
304
275
|
|
|
305
|
-
# Find .gitignore file in the search path or parent directories
|
|
306
|
-
# Only searches within the search path and up to the current working directory
|
|
307
|
-
def find_gitignore(path)
|
|
308
|
-
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
309
|
-
|
|
310
|
-
# Look for .gitignore in current and parent directories
|
|
311
|
-
current = File.expand_path(search_path)
|
|
312
|
-
cwd = File.expand_path(Dir.pwd)
|
|
313
|
-
root = File.expand_path('/')
|
|
314
|
-
|
|
315
|
-
# Limit search: only go up to current working directory
|
|
316
|
-
# This prevents finding .gitignore files from unrelated parent directories
|
|
317
|
-
# when searching in temporary directories (like /tmp in tests)
|
|
318
|
-
search_limit = if current.start_with?(cwd)
|
|
319
|
-
cwd
|
|
320
|
-
else
|
|
321
|
-
current
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
loop do
|
|
325
|
-
gitignore = File.join(current, '.gitignore')
|
|
326
|
-
return gitignore if File.exist?(gitignore)
|
|
327
|
-
|
|
328
|
-
# Stop if we've reached the search limit or root
|
|
329
|
-
break if current == search_limit || current == root
|
|
330
|
-
current = File.dirname(current)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
nil
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
# Check if file should be ignored based on .gitignore or default patterns
|
|
337
|
-
def should_ignore_file?(file, base_path, gitignore)
|
|
338
|
-
# Always calculate path relative to base_path for consistency
|
|
339
|
-
# Expand both paths to handle symlinks and relative paths correctly
|
|
340
|
-
expanded_file = File.expand_path(file)
|
|
341
|
-
expanded_base = File.expand_path(base_path)
|
|
342
|
-
|
|
343
|
-
# For files, use the directory as base
|
|
344
|
-
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
345
|
-
|
|
346
|
-
# Calculate relative path
|
|
347
|
-
if expanded_file.start_with?(expanded_base)
|
|
348
|
-
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
349
|
-
else
|
|
350
|
-
# File is outside base path - use just the filename
|
|
351
|
-
relative_path = File.basename(expanded_file)
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
# Clean up relative path
|
|
355
|
-
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
356
|
-
|
|
357
|
-
if gitignore
|
|
358
|
-
# Use .gitignore rules
|
|
359
|
-
gitignore.ignored?(relative_path)
|
|
360
|
-
else
|
|
361
|
-
# Use default ignore patterns - only match against relative path components
|
|
362
|
-
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
363
|
-
if pattern.include?('*')
|
|
364
|
-
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
365
|
-
else
|
|
366
|
-
# Match pattern as a path component (not substring of absolute path)
|
|
367
|
-
relative_path.start_with?("#{pattern}/") ||
|
|
368
|
-
relative_path.include?("/#{pattern}/") ||
|
|
369
|
-
relative_path == pattern ||
|
|
370
|
-
File.basename(relative_path) == pattern
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
end
|
|
375
|
-
|
|
376
|
-
# Check if file is a config file (should not be ignored even if in .gitignore)
|
|
377
|
-
def is_config_file?(file)
|
|
378
|
-
CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
|
|
379
|
-
end
|
|
380
|
-
|
|
381
276
|
def search_file(file, regex, context_lines, max_matches)
|
|
382
277
|
matches = []
|
|
383
278
|
|
|
@@ -435,17 +330,6 @@ module Clacky
|
|
|
435
330
|
rescue StandardError
|
|
436
331
|
nil
|
|
437
332
|
end
|
|
438
|
-
|
|
439
|
-
def binary_file?(file)
|
|
440
|
-
# Simple heuristic: check if file contains null bytes in first 8KB
|
|
441
|
-
return false unless File.exist?(file)
|
|
442
|
-
return false if File.size(file).zero?
|
|
443
|
-
|
|
444
|
-
sample = File.read(file, 8192, encoding: "ASCII-8BIT")
|
|
445
|
-
sample.include?("\x00")
|
|
446
|
-
rescue StandardError
|
|
447
|
-
true
|
|
448
|
-
end
|
|
449
333
|
end
|
|
450
334
|
end
|
|
451
335
|
end
|
|
@@ -255,6 +255,11 @@ module Clacky
|
|
|
255
255
|
ready[0].each do |io|
|
|
256
256
|
begin
|
|
257
257
|
data = io.read_nonblock(4096)
|
|
258
|
+
# Force UTF-8 encoding to avoid incompatible encoding errors
|
|
259
|
+
data.force_encoding('UTF-8')
|
|
260
|
+
# Replace invalid UTF-8 sequences with replacement character
|
|
261
|
+
data = data.scrub('?') unless data.valid_encoding?
|
|
262
|
+
|
|
258
263
|
if io == stdout
|
|
259
264
|
stdout_buf.push_lines(data)
|
|
260
265
|
else
|
|
@@ -19,13 +19,9 @@ module Clacky
|
|
|
19
19
|
type: "string",
|
|
20
20
|
description: "Shell command to execute"
|
|
21
21
|
},
|
|
22
|
-
|
|
22
|
+
timeout: {
|
|
23
23
|
type: "integer",
|
|
24
|
-
description: "
|
|
25
|
-
},
|
|
26
|
-
hard_timeout: {
|
|
27
|
-
type: "integer",
|
|
28
|
-
description: "Hard timeout in seconds (force kill)"
|
|
24
|
+
description: "Command timeout in seconds (auto-detected if not specified: 60s for normal commands, 180s for build/install commands)"
|
|
29
25
|
},
|
|
30
26
|
max_output_lines: {
|
|
31
27
|
type: "integer",
|
|
@@ -36,19 +32,29 @@ module Clacky
|
|
|
36
32
|
required: ["command"]
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
def execute(command:,
|
|
35
|
+
def execute(command:, timeout: nil, max_output_lines: 1000)
|
|
40
36
|
# Get project root directory
|
|
41
37
|
project_root = Dir.pwd
|
|
42
38
|
|
|
43
39
|
begin
|
|
44
|
-
# 1.
|
|
40
|
+
# 1. Extract timeout from command if it starts with "timeout N"
|
|
41
|
+
command, extracted_timeout = extract_timeout_from_command(command)
|
|
42
|
+
|
|
43
|
+
# Use extracted timeout if not explicitly provided
|
|
44
|
+
timeout ||= extracted_timeout
|
|
45
|
+
|
|
46
|
+
# 2. Use safety replacer to process command
|
|
45
47
|
safety_replacer = CommandSafetyReplacer.new(project_root)
|
|
46
48
|
safe_command = safety_replacer.make_command_safe(command)
|
|
47
49
|
|
|
48
|
-
#
|
|
50
|
+
# 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
|
|
51
|
+
soft_timeout = 5
|
|
52
|
+
hard_timeout = calculate_hard_timeout(command, timeout)
|
|
53
|
+
|
|
54
|
+
# 4. Call parent class execution method
|
|
49
55
|
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
50
56
|
|
|
51
|
-
#
|
|
57
|
+
# 5. Enhance result information
|
|
52
58
|
enhance_result(result, command, safe_command)
|
|
53
59
|
|
|
54
60
|
rescue SecurityError => e
|
|
@@ -64,6 +70,30 @@ module Clacky
|
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
|
|
73
|
+
private def extract_timeout_from_command(command)
|
|
74
|
+
# Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
|
|
75
|
+
# Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
|
|
76
|
+
match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
77
|
+
|
|
78
|
+
if match
|
|
79
|
+
timeout_value = match[1].to_i
|
|
80
|
+
actual_command = match[2]
|
|
81
|
+
return [actual_command, timeout_value]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# No timeout prefix found, return original command
|
|
85
|
+
[command, nil]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private def calculate_hard_timeout(command, timeout)
|
|
89
|
+
# If timeout is provided, use it directly
|
|
90
|
+
return timeout if timeout
|
|
91
|
+
|
|
92
|
+
# Otherwise, auto-detect based on command type
|
|
93
|
+
is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }
|
|
94
|
+
is_slow ? 180 : 60
|
|
95
|
+
end
|
|
96
|
+
|
|
67
97
|
# Safe read-only commands that don't modify system state
|
|
68
98
|
SAFE_READONLY_COMMANDS = %w[
|
|
69
99
|
ls pwd cat less more head tail
|
|
@@ -267,12 +297,18 @@ module Clacky
|
|
|
267
297
|
|
|
268
298
|
def validate_general_command(command)
|
|
269
299
|
# Check general command security
|
|
300
|
+
# Note: We need to be careful not to match patterns inside quoted strings
|
|
301
|
+
|
|
302
|
+
# First, remove quoted strings to avoid false positives
|
|
303
|
+
# This is a simplified approach - removes both single and double quoted content
|
|
304
|
+
cmd_without_quotes = command.gsub(/'[^']*'|"[^"]*"/, '')
|
|
305
|
+
|
|
270
306
|
dangerous_patterns = [
|
|
271
307
|
/eval\s*\(/,
|
|
272
308
|
/exec\s*\(/,
|
|
273
309
|
/system\s*\(/,
|
|
274
|
-
|
|
275
|
-
/\$\(
|
|
310
|
+
/`[^`]+`/, # Command substitution with backticks (but only if not in quotes)
|
|
311
|
+
/\$\([^)]+\)/, # Command substitution with $() (but only if not in quotes)
|
|
276
312
|
/\|\s*sh\s*$/,
|
|
277
313
|
/\|\s*bash\s*$/,
|
|
278
314
|
/>\s*\/etc\//,
|
|
@@ -281,7 +317,7 @@ module Clacky
|
|
|
281
317
|
]
|
|
282
318
|
|
|
283
319
|
dangerous_patterns.each do |pattern|
|
|
284
|
-
if
|
|
320
|
+
if cmd_without_quotes.match?(pattern)
|
|
285
321
|
raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
|
|
286
322
|
end
|
|
287
323
|
end
|