openclacky 0.6.1 → 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/CHANGELOG.md +26 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +557 -122
- data/lib/clacky/cli.rb +431 -3
- 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 +245 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +53 -17
- data/lib/clacky/tools/shell.rb +109 -5
- 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 +279 -141
- data/lib/clacky/ui2/layout_manager.rb +147 -67
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +3 -3
- data/lib/clacky/ui2/themes/minimal_theme.rb +3 -3
- data/lib/clacky/ui2/ui_controller.rb +80 -29
- data/lib/clacky/ui2.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +7 -2
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/utils/file_processor.rb +201 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +249 -0
- data/scripts/uninstall.sh +146 -0
- metadata +10 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "layout_manager"
|
|
4
4
|
require_relative "view_renderer"
|
|
5
|
-
require_relative "components/output_area"
|
|
6
5
|
require_relative "components/input_area"
|
|
7
6
|
require_relative "components/todo_area"
|
|
8
7
|
require_relative "components/welcome_banner"
|
|
@@ -31,13 +30,11 @@ module Clacky
|
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
# Initialize layout components
|
|
34
|
-
@output_area = Components::OutputArea.new(height: 20) # Will be recalculated
|
|
35
33
|
@input_area = Components::InputArea.new
|
|
36
34
|
@todo_area = Components::TodoArea.new
|
|
37
35
|
@welcome_banner = Components::WelcomeBanner.new
|
|
38
36
|
@inline_input = nil # Created when needed
|
|
39
37
|
@layout = LayoutManager.new(
|
|
40
|
-
output_area: @output_area,
|
|
41
38
|
input_area: @input_area,
|
|
42
39
|
todo_area: @todo_area
|
|
43
40
|
)
|
|
@@ -150,6 +147,12 @@ module Clacky
|
|
|
150
147
|
@mode_toggle_callback = block
|
|
151
148
|
end
|
|
152
149
|
|
|
150
|
+
# Set skill loader for command suggestions
|
|
151
|
+
# @param skill_loader [Clacky::SkillLoader] The skill loader instance
|
|
152
|
+
def set_skill_loader(skill_loader)
|
|
153
|
+
@input_area.set_skill_loader(skill_loader)
|
|
154
|
+
end
|
|
155
|
+
|
|
153
156
|
# Append output to the output area
|
|
154
157
|
# @param content [String] Content to append
|
|
155
158
|
def append_output(content)
|
|
@@ -211,18 +214,24 @@ module Clacky
|
|
|
211
214
|
# - cost: cost for this iteration
|
|
212
215
|
def show_token_usage(token_data)
|
|
213
216
|
theme = ThemeManager.current_theme
|
|
217
|
+
pastel = Pastel.new
|
|
214
218
|
|
|
215
219
|
token_info = []
|
|
216
220
|
|
|
217
|
-
# Delta tokens with color coding
|
|
221
|
+
# Delta tokens with color coding (green/yellow/red + dim)
|
|
218
222
|
delta_tokens = token_data[:delta_tokens]
|
|
219
|
-
delta_str = "+#{delta_tokens}"
|
|
220
|
-
|
|
221
|
-
|
|
223
|
+
delta_str = delta_tokens.negative? ? "#{delta_tokens}" : "+#{delta_tokens}"
|
|
224
|
+
color_style = if delta_tokens > 10000
|
|
225
|
+
:red
|
|
222
226
|
elsif delta_tokens > 5000
|
|
223
|
-
|
|
227
|
+
:yellow
|
|
228
|
+
else
|
|
229
|
+
:green
|
|
230
|
+
end
|
|
231
|
+
colored_delta = if delta_tokens.negative?
|
|
232
|
+
pastel.cyan(delta_str)
|
|
224
233
|
else
|
|
225
|
-
|
|
234
|
+
pastel.decorate(delta_str, color_style, :dim)
|
|
226
235
|
end
|
|
227
236
|
token_info << colored_delta
|
|
228
237
|
|
|
@@ -231,31 +240,44 @@ module Clacky
|
|
|
231
240
|
cache_read = token_data[:cache_read]
|
|
232
241
|
cache_used = cache_read > 0 || cache_write > 0
|
|
233
242
|
if cache_used
|
|
234
|
-
token_info << theme.
|
|
243
|
+
token_info << pastel.dim(theme.symbol(:cached))
|
|
235
244
|
end
|
|
236
245
|
|
|
237
246
|
# Input tokens (with cache breakdown if available)
|
|
238
247
|
prompt_tokens = token_data[:prompt_tokens]
|
|
239
248
|
if cache_write > 0 || cache_read > 0
|
|
240
249
|
input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
|
|
241
|
-
token_info << "Input: #{input_detail}"
|
|
250
|
+
token_info << pastel.dim("Input: #{input_detail}")
|
|
242
251
|
else
|
|
243
|
-
token_info << "Input: #{prompt_tokens}"
|
|
252
|
+
token_info << pastel.dim("Input: #{prompt_tokens}")
|
|
244
253
|
end
|
|
245
254
|
|
|
246
255
|
# Output tokens
|
|
247
|
-
token_info << "Output: #{token_data[:completion_tokens]}"
|
|
256
|
+
token_info << pastel.dim("Output: #{token_data[:completion_tokens]}")
|
|
248
257
|
|
|
249
258
|
# Total
|
|
250
|
-
token_info << "Total: #{token_data[:total_tokens]}"
|
|
259
|
+
token_info << pastel.dim("Total: #{token_data[:total_tokens]}")
|
|
251
260
|
|
|
252
|
-
# Cost for this iteration
|
|
261
|
+
# Cost for this iteration with color coding (red/yellow for high cost, dim for normal)
|
|
253
262
|
if token_data[:cost]
|
|
254
|
-
|
|
263
|
+
cost = token_data[:cost]
|
|
264
|
+
cost_value = "$#{cost.round(6)}"
|
|
265
|
+
if cost >= 0.1
|
|
266
|
+
# High cost - red warning
|
|
267
|
+
colored_cost = pastel.decorate(cost_value, :red, :dim)
|
|
268
|
+
token_info << pastel.dim("Cost: ") + colored_cost
|
|
269
|
+
elsif cost >= 0.05
|
|
270
|
+
# Medium cost - yellow warning
|
|
271
|
+
colored_cost = pastel.decorate(cost_value, :yellow, :dim)
|
|
272
|
+
token_info << pastel.dim("Cost: ") + colored_cost
|
|
273
|
+
else
|
|
274
|
+
# Low cost - normal gray
|
|
275
|
+
token_info << pastel.dim("Cost: #{cost_value}")
|
|
276
|
+
end
|
|
255
277
|
end
|
|
256
278
|
|
|
257
|
-
# Display through output system
|
|
258
|
-
token_display =
|
|
279
|
+
# Display through output system (already all dimmed, just add prefix)
|
|
280
|
+
token_display = pastel.dim(" [Tokens] ") + token_info.join(pastel.dim(' | '))
|
|
259
281
|
append_output(token_display)
|
|
260
282
|
end
|
|
261
283
|
|
|
@@ -313,10 +335,32 @@ module Clacky
|
|
|
313
335
|
# Show assistant message
|
|
314
336
|
# @param content [String] Message content
|
|
315
337
|
def show_assistant_message(content)
|
|
316
|
-
|
|
338
|
+
# Filter out thinking tags from models like MiniMax M2.1 that use <think>...</think>
|
|
339
|
+
filtered_content = filter_thinking_tags(content)
|
|
340
|
+
return if filtered_content.nil? || filtered_content.strip.empty?
|
|
341
|
+
|
|
342
|
+
output = @renderer.render_assistant_message(filtered_content)
|
|
317
343
|
append_output(output)
|
|
318
344
|
end
|
|
319
345
|
|
|
346
|
+
# Filter out thinking tags from content
|
|
347
|
+
# Some models (e.g., MiniMax M2.1) wrap their reasoning in <think>...</think> tags
|
|
348
|
+
# @param content [String] Raw content from model
|
|
349
|
+
# @return [String] Content with thinking tags removed
|
|
350
|
+
def filter_thinking_tags(content)
|
|
351
|
+
return content if content.nil?
|
|
352
|
+
|
|
353
|
+
# Remove <think>...</think> blocks (multiline, case-insensitive)
|
|
354
|
+
# Also handles variations like <thinking>...</thinking>
|
|
355
|
+
filtered = content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, '')
|
|
356
|
+
|
|
357
|
+
# Clean up multiple empty lines left behind (max 2 consecutive newlines)
|
|
358
|
+
filtered.gsub!(/\n{3,}/, "\n\n")
|
|
359
|
+
|
|
360
|
+
# Remove leading and trailing whitespace
|
|
361
|
+
filtered.strip
|
|
362
|
+
end
|
|
363
|
+
|
|
320
364
|
# Show tool call
|
|
321
365
|
# @param name [String] Tool name
|
|
322
366
|
# @param args [String, Hash] Tool arguments (JSON string or Hash)
|
|
@@ -525,8 +569,9 @@ module Clacky
|
|
|
525
569
|
# Collect input (blocks until user presses Enter)
|
|
526
570
|
result_text = inline_input.collect
|
|
527
571
|
|
|
528
|
-
# Clean up - remove the inline input
|
|
529
|
-
|
|
572
|
+
# Clean up - remove the inline input lines (handle wrapped lines)
|
|
573
|
+
line_count = inline_input.line_count
|
|
574
|
+
@layout.remove_last_line(line_count)
|
|
530
575
|
|
|
531
576
|
# Append the final response to output
|
|
532
577
|
if result_text.nil?
|
|
@@ -563,12 +608,15 @@ module Clacky
|
|
|
563
608
|
require 'diffy'
|
|
564
609
|
|
|
565
610
|
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
566
|
-
|
|
567
|
-
|
|
611
|
+
diff_lines = diff.to_s(:color).lines
|
|
612
|
+
|
|
613
|
+
# Show diff without line numbers
|
|
614
|
+
diff_lines.take(max_lines).each do |line|
|
|
615
|
+
append_output(line.chomp)
|
|
616
|
+
end
|
|
568
617
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
append_output("\n... (#{all_lines.size - max_lines} more lines, diff truncated)")
|
|
618
|
+
if diff_lines.size > max_lines
|
|
619
|
+
append_output("\n... (#{diff_lines.size - max_lines} more lines, diff truncated)")
|
|
572
620
|
end
|
|
573
621
|
rescue LoadError
|
|
574
622
|
# Fallback if diffy is not available
|
|
@@ -728,16 +776,19 @@ module Clacky
|
|
|
728
776
|
|
|
729
777
|
# Handle key input for InlineInput
|
|
730
778
|
def handle_inline_input_key(key)
|
|
779
|
+
# Get old line count BEFORE modification
|
|
780
|
+
old_line_count = @inline_input.line_count
|
|
781
|
+
|
|
731
782
|
result = @inline_input.handle_key(key)
|
|
732
783
|
|
|
733
784
|
case result[:action]
|
|
734
785
|
when :update
|
|
735
|
-
# Update the
|
|
736
|
-
@layout.update_last_line(@inline_input.render)
|
|
786
|
+
# Update the output area with current input (considering wrapped lines)
|
|
787
|
+
@layout.update_last_line(@inline_input.render, old_line_count)
|
|
737
788
|
# Position cursor for inline input
|
|
738
789
|
@layout.position_inline_input_cursor(@inline_input)
|
|
739
790
|
when :submit, :cancel
|
|
740
|
-
# InlineInput is done, will be cleaned up by request_confirmation
|
|
791
|
+
# InlineInput is done, will be cleaned up by request_confirmation after collect returns
|
|
741
792
|
nil
|
|
742
793
|
when :toggle_mode
|
|
743
794
|
# Update mode and session bar info, but don't render yet
|
data/lib/clacky/ui2.rb
CHANGED
|
@@ -10,7 +10,6 @@ require_relative "ui2/view_renderer"
|
|
|
10
10
|
require_relative "ui2/ui_controller"
|
|
11
11
|
|
|
12
12
|
require_relative "ui2/components/base_component"
|
|
13
|
-
require_relative "ui2/components/output_area"
|
|
14
13
|
require_relative "ui2/components/input_area"
|
|
15
14
|
require_relative "ui2/components/message_component"
|
|
16
15
|
require_relative "ui2/components/tool_component"
|
|
@@ -44,10 +44,11 @@ module Clacky
|
|
|
44
44
|
result
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Validate required parameters
|
|
47
|
+
# Validate required parameters and filter unknown parameters
|
|
48
48
|
def self.validate_required_params(call, args, tool_registry)
|
|
49
49
|
tool = tool_registry.get(call[:name])
|
|
50
50
|
required = tool.parameters&.dig(:required) || []
|
|
51
|
+
properties = tool.parameters&.dig(:properties) || {}
|
|
51
52
|
|
|
52
53
|
missing = required.reject { |param|
|
|
53
54
|
args.key?(param.to_sym) || args.key?(param.to_s)
|
|
@@ -57,7 +58,11 @@ module Clacky
|
|
|
57
58
|
raise MissingRequiredParamsError.new(call[:name], missing, args.keys)
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
# Filter out unknown parameters to prevent errors when LLM sends extra arguments
|
|
62
|
+
known_params = properties.keys.map(&:to_sym) + properties.keys.map(&:to_s)
|
|
63
|
+
filtered_args = args.select { |key, _| known_params.include?(key) }
|
|
64
|
+
|
|
65
|
+
filtered_args
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
# Generate error message with tool definition
|
|
@@ -30,20 +30,18 @@ module Clacky
|
|
|
30
30
|
/\.ini$/,
|
|
31
31
|
/\.conf$/,
|
|
32
32
|
/\.config$/,
|
|
33
|
-
/config\//,
|
|
34
|
-
/\.config\//
|
|
35
33
|
].freeze
|
|
36
34
|
|
|
37
35
|
# Find .gitignore file in the search path or parent directories
|
|
38
36
|
# Only searches within the search path and up to the current working directory
|
|
39
37
|
def self.find_gitignore(path)
|
|
40
38
|
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
41
|
-
|
|
39
|
+
|
|
42
40
|
# Look for .gitignore in current and parent directories
|
|
43
41
|
current = File.expand_path(search_path)
|
|
44
42
|
cwd = File.expand_path(Dir.pwd)
|
|
45
43
|
root = File.expand_path('/')
|
|
46
|
-
|
|
44
|
+
|
|
47
45
|
# Limit search: only go up to current working directory
|
|
48
46
|
# This prevents finding .gitignore files from unrelated parent directories
|
|
49
47
|
# when searching in temporary directories (like /tmp in tests)
|
|
@@ -52,16 +50,16 @@ module Clacky
|
|
|
52
50
|
else
|
|
53
51
|
current
|
|
54
52
|
end
|
|
55
|
-
|
|
53
|
+
|
|
56
54
|
loop do
|
|
57
55
|
gitignore = File.join(current, '.gitignore')
|
|
58
56
|
return gitignore if File.exist?(gitignore)
|
|
59
|
-
|
|
57
|
+
|
|
60
58
|
# Stop if we've reached the search limit or root
|
|
61
59
|
break if current == search_limit || current == root
|
|
62
60
|
current = File.dirname(current)
|
|
63
61
|
end
|
|
64
|
-
|
|
62
|
+
|
|
65
63
|
nil
|
|
66
64
|
end
|
|
67
65
|
|
|
@@ -71,10 +69,10 @@ module Clacky
|
|
|
71
69
|
# Expand both paths to handle symlinks and relative paths correctly
|
|
72
70
|
expanded_file = File.expand_path(file)
|
|
73
71
|
expanded_base = File.expand_path(base_path)
|
|
74
|
-
|
|
72
|
+
|
|
75
73
|
# For files, use the directory as base
|
|
76
74
|
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
77
|
-
|
|
75
|
+
|
|
78
76
|
# Calculate relative path
|
|
79
77
|
if expanded_file.start_with?(expanded_base)
|
|
80
78
|
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
@@ -82,10 +80,10 @@ module Clacky
|
|
|
82
80
|
# File is outside base path - use just the filename
|
|
83
81
|
relative_path = File.basename(expanded_file)
|
|
84
82
|
end
|
|
85
|
-
|
|
83
|
+
|
|
86
84
|
# Clean up relative path
|
|
87
85
|
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
88
|
-
|
|
86
|
+
|
|
89
87
|
if gitignore
|
|
90
88
|
# Use .gitignore rules
|
|
91
89
|
gitignore.ignored?(relative_path)
|
|
@@ -96,7 +94,7 @@ module Clacky
|
|
|
96
94
|
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
97
95
|
else
|
|
98
96
|
# Match pattern as a path component (not substring of absolute path)
|
|
99
|
-
relative_path.start_with?("#{pattern}/") ||
|
|
97
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
100
98
|
relative_path.include?("/#{pattern}/") ||
|
|
101
99
|
relative_path == pattern ||
|
|
102
100
|
File.basename(relative_path) == pattern
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Utils
|
|
7
|
+
# File processing utilities for binary files, images, and PDFs
|
|
8
|
+
class FileProcessor
|
|
9
|
+
# Maximum file size for binary files (5MB)
|
|
10
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
# Supported image formats
|
|
13
|
+
IMAGE_FORMATS = {
|
|
14
|
+
"png" => "image/png",
|
|
15
|
+
"jpg" => "image/jpeg",
|
|
16
|
+
"jpeg" => "image/jpeg",
|
|
17
|
+
"gif" => "image/gif",
|
|
18
|
+
"webp" => "image/webp"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Supported document formats
|
|
22
|
+
DOCUMENT_FORMATS = {
|
|
23
|
+
"pdf" => "application/pdf"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# All supported formats
|
|
27
|
+
SUPPORTED_FORMATS = IMAGE_FORMATS.merge(DOCUMENT_FORMATS).freeze
|
|
28
|
+
|
|
29
|
+
# File signatures (magic bytes) for format detection
|
|
30
|
+
FILE_SIGNATURES = {
|
|
31
|
+
"\x89PNG\r\n\x1a\n".b => "png",
|
|
32
|
+
"\xFF\xD8\xFF".b => "jpg",
|
|
33
|
+
"GIF87a".b => "gif",
|
|
34
|
+
"GIF89a".b => "gif",
|
|
35
|
+
"%PDF".b => "pdf"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Convert image file path to base64 data URL
|
|
40
|
+
# @param path [String] File path to image
|
|
41
|
+
# @return [String] base64 data URL (e.g., "data:image/png;base64,...")
|
|
42
|
+
# @raise [ArgumentError] If file not found or unsupported format
|
|
43
|
+
def image_path_to_data_url(path)
|
|
44
|
+
unless File.exist?(path)
|
|
45
|
+
raise ArgumentError, "Image file not found: #{path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check file size
|
|
49
|
+
file_size = File.size(path)
|
|
50
|
+
if file_size > MAX_FILE_SIZE
|
|
51
|
+
raise ArgumentError, "File too large: #{file_size} bytes (max: #{MAX_FILE_SIZE} bytes)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Read file as binary
|
|
55
|
+
image_data = File.binread(path)
|
|
56
|
+
|
|
57
|
+
# Detect MIME type from file extension or content
|
|
58
|
+
mime_type = detect_mime_type(path, image_data)
|
|
59
|
+
|
|
60
|
+
# Verify it's an image format
|
|
61
|
+
unless IMAGE_FORMATS.values.include?(mime_type)
|
|
62
|
+
raise ArgumentError, "Unsupported image format: #{mime_type}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Encode to base64
|
|
66
|
+
base64_data = Base64.strict_encode64(image_data)
|
|
67
|
+
|
|
68
|
+
"data:#{mime_type};base64,#{base64_data}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convert file to base64 with format detection
|
|
72
|
+
# @param path [String] File path
|
|
73
|
+
# @return [Hash] Hash with :format, :mime_type, :base64_data, :size_bytes
|
|
74
|
+
# @raise [ArgumentError] If file not found or too large
|
|
75
|
+
def file_to_base64(path)
|
|
76
|
+
unless File.exist?(path)
|
|
77
|
+
raise ArgumentError, "File not found: #{path}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check file size
|
|
81
|
+
file_size = File.size(path)
|
|
82
|
+
if file_size > MAX_FILE_SIZE
|
|
83
|
+
raise ArgumentError, "File too large: #{file_size} bytes (max: #{MAX_FILE_SIZE} bytes)"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Read file as binary
|
|
87
|
+
file_data = File.binread(path)
|
|
88
|
+
|
|
89
|
+
# Detect format and MIME type
|
|
90
|
+
format = detect_format(path, file_data)
|
|
91
|
+
mime_type = detect_mime_type(path, file_data)
|
|
92
|
+
|
|
93
|
+
# Encode to base64
|
|
94
|
+
base64_data = Base64.strict_encode64(file_data)
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
format: format,
|
|
98
|
+
mime_type: mime_type,
|
|
99
|
+
base64_data: base64_data,
|
|
100
|
+
size_bytes: file_size
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Detect file format from path and content
|
|
105
|
+
# @param path [String] File path
|
|
106
|
+
# @param data [String] Binary file data
|
|
107
|
+
# @return [String] Format (e.g., "png", "jpg", "pdf")
|
|
108
|
+
def detect_format(path, data)
|
|
109
|
+
# Try to detect from file extension first
|
|
110
|
+
ext = File.extname(path).downcase.delete_prefix(".")
|
|
111
|
+
return ext if SUPPORTED_FORMATS.key?(ext)
|
|
112
|
+
|
|
113
|
+
# Try to detect from file signature (magic bytes)
|
|
114
|
+
FILE_SIGNATURES.each do |signature, format|
|
|
115
|
+
return format if data.start_with?(signature)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Special case for WebP (RIFF format)
|
|
119
|
+
if data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
|
|
120
|
+
return "webp"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Detect MIME type from file path and content
|
|
127
|
+
# @param path [String] File path
|
|
128
|
+
# @param data [String] Binary file data
|
|
129
|
+
# @return [String] MIME type (e.g., "image/png")
|
|
130
|
+
def detect_mime_type(path, data)
|
|
131
|
+
format = detect_format(path, data)
|
|
132
|
+
return SUPPORTED_FORMATS[format] if format && SUPPORTED_FORMATS[format]
|
|
133
|
+
|
|
134
|
+
# Default to application/octet-stream for unknown formats
|
|
135
|
+
"application/octet-stream"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check if file is a supported binary format
|
|
139
|
+
# @param path [String] File path
|
|
140
|
+
# @return [Boolean] True if supported binary format
|
|
141
|
+
def supported_binary_file?(path)
|
|
142
|
+
return false unless File.exist?(path)
|
|
143
|
+
|
|
144
|
+
ext = File.extname(path).downcase.delete_prefix(".")
|
|
145
|
+
SUPPORTED_FORMATS.key?(ext)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Check if file is an image
|
|
149
|
+
# @param path [String] File path
|
|
150
|
+
# @return [Boolean] True if image format
|
|
151
|
+
def image_file?(path)
|
|
152
|
+
return false unless File.exist?(path)
|
|
153
|
+
|
|
154
|
+
ext = File.extname(path).downcase.delete_prefix(".")
|
|
155
|
+
IMAGE_FORMATS.key?(ext)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if file is a PDF
|
|
159
|
+
# @param path [String] File path
|
|
160
|
+
# @return [Boolean] True if PDF format
|
|
161
|
+
def pdf_file?(path)
|
|
162
|
+
return false unless File.exist?(path)
|
|
163
|
+
|
|
164
|
+
ext = File.extname(path).downcase.delete_prefix(".")
|
|
165
|
+
ext == "pdf"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check if file is binary (not text)
|
|
169
|
+
# @param data [String] File content
|
|
170
|
+
# @param sample_size [Integer] Number of bytes to check (default: 8192)
|
|
171
|
+
# @return [Boolean] True if file appears to be binary
|
|
172
|
+
def binary_file?(data, sample_size: 8192)
|
|
173
|
+
# Check first N bytes for null bytes or high ratio of non-printable characters
|
|
174
|
+
sample = data[0, sample_size] || ""
|
|
175
|
+
return false if sample.empty?
|
|
176
|
+
|
|
177
|
+
# Check for known binary signatures first
|
|
178
|
+
FILE_SIGNATURES.each do |signature, _format|
|
|
179
|
+
return true if sample.start_with?(signature)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Check for WebP (RIFF format)
|
|
183
|
+
if sample.start_with?("RIFF".b) && sample.length >= 12 && sample[8..11] == "WEBP".b
|
|
184
|
+
return true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# If contains null bytes, it's binary
|
|
188
|
+
return true if sample.include?("\x00")
|
|
189
|
+
|
|
190
|
+
# Count non-printable characters (excluding common whitespace)
|
|
191
|
+
non_printable = sample.bytes.count do |byte|
|
|
192
|
+
byte < 32 && ![9, 10, 13].include?(byte) || byte >= 127
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# If more than 30% non-printable, consider it binary
|
|
196
|
+
(non_printable.to_f / sample.size) > 0.3
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky.rb
CHANGED