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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +245 -340
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +156 -397
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +65 -9
  12. data/lib/clacky/tools/grep.rb +4 -120
  13. data/lib/clacky/tools/run_project.rb +5 -0
  14. data/lib/clacky/tools/safe_shell.rb +49 -13
  15. data/lib/clacky/tools/shell.rb +1 -49
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +38 -26
  18. data/lib/clacky/ui2/README.md +214 -0
  19. data/lib/clacky/ui2/components/base_component.rb +163 -0
  20. data/lib/clacky/ui2/components/common_component.rb +89 -0
  21. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  22. data/lib/clacky/ui2/components/input_area.rb +1029 -0
  23. data/lib/clacky/ui2/components/message_component.rb +76 -0
  24. data/lib/clacky/ui2/components/output_area.rb +112 -0
  25. data/lib/clacky/ui2/components/todo_area.rb +137 -0
  26. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  27. data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
  28. data/lib/clacky/ui2/layout_manager.rb +331 -0
  29. data/lib/clacky/ui2/line_editor.rb +201 -0
  30. data/lib/clacky/ui2/screen_buffer.rb +238 -0
  31. data/lib/clacky/ui2/theme_manager.rb +68 -0
  32. data/lib/clacky/ui2/themes/base_theme.rb +99 -0
  33. data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
  34. data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
  35. data/lib/clacky/ui2/ui_controller.rb +720 -0
  36. data/lib/clacky/ui2/view_renderer.rb +160 -0
  37. data/lib/clacky/ui2.rb +37 -0
  38. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky.rb +1 -6
  41. metadata +38 -6
  42. data/lib/clacky/ui/banner.rb +0 -155
  43. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  44. data/lib/clacky/ui/formatter.rb +0 -209
  45. 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:, verbose: false, enable_caching: false)
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: 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 output
86
- if verbose || ENV["CLACKY_DEBUG"]
87
- puts "\n[DEBUG] Current directory: #{Dir.pwd}"
88
- puts "[DEBUG] Request to API:"
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
- # Debug: show raw API response content
182
- if ENV["CLACKY_DEBUG"]
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
- # Remove leading slash for absolute patterns
76
- pattern = pattern[1..] if pattern_info[:is_absolute]
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
- return false unless File.directory?(path)
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('**/', '(.*/)?') # **/ matches zero or more directories
88
- .gsub('**', '.*') # ** at end matches anything
89
- .gsub('*', '[^/]*') # * matches anything except /
90
- .gsub('?', '[^/]') # ? matches single character except /
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
@@ -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
- total_input_tokens = prompt_tokens + cache_write_tokens + cache_read_tokens
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
- regular_input_tokens = prompt_tokens - cache_write_tokens - cache_read_tokens
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
- date = Time.parse(created_at).strftime("%Y-%m-%d")
111
+ datetime = Time.parse(created_at).strftime("%Y-%m-%d-%H-%M-%S")
108
112
  short_id = session_id[0..7]
109
- "#{date}_#{short_id}.json"
113
+ "#{datetime}-#{short_id}.json"
110
114
  end
111
115
 
112
116
  def all_sessions
@@ -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: 100)",
27
- default: 100
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: 100)
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
- matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
48
- .reject { |path| File.directory?(path) }
49
- .reject { |path| path.end_with?(".", "..") }
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 relative or absolute paths
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
- "✓ Found #{count}/#{total} files#{truncated}"
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
@@ -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) && !is_config_file?(file)
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
- soft_timeout: {
22
+ timeout: {
23
23
  type: "integer",
24
- description: "Soft timeout in seconds (for interaction detection)"
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:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
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. Use safety replacer to process command
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
- # 2. Call parent class execution method
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
- # 3. Enhance result information
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 command.match?(pattern)
320
+ if cmd_without_quotes.match?(pattern)
285
321
  raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
286
322
  end
287
323
  end