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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52ab39c40c69d821b45d01ed1d14086a50446309421891e9cd6724a9b32864cf
4
- data.tar.gz: 0d61ef02a23f8a04157f8b7b2308a4f0c68fd37bba1407894b9841cd707c6873
3
+ metadata.gz: cc218b590b40d1a2301ef91a73b5a4ded7d6891da5b42eea62f4fa8bce200292
4
+ data.tar.gz: e828ac9fd724d0097f7557401127c513f35138c31d76a06e07c8c70db049cb1c
5
5
  SHA512:
6
- metadata.gz: d654bd29a093bf23f1e0c162867251e08a28ec722deaae7a9d234f2aed913329d991df29391778612c458c51b6775881ee0867cb2d959c0f7072b14e662d0c38
7
- data.tar.gz: 8056dff60d1c845751021e2c852e58071360c8d6a6b81cec72be238e647521ae36a211d72af88602239f8abcab5a15b7a345b8efd98e68b62a0a6dfde8a63b23
6
+ metadata.gz: 91db158259b243438e5af2fa26d257f30c4c5f755383e4bde6e20b71cf9e5481bc5b2d91cd5c63e5916ed8d6e373050e60dd16fd4b3a0231073702b75ad2702a
7
+ data.tar.gz: '0940f53a61a33f22de700c8609ede85e7d937f096688374eedef072758372f00e87fa6327050d323a7f5f3d869b487ff39bb942fe56534afe4a906b3b6cde494'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.4] - 2026-01-16
11
+
12
+ ### Added
13
+ - **Automatic Paste Detection**: Rapid input detection automatically identifies paste operations
14
+ - **Word Wrap Display**: Long input lines automatically wrap with scroll indicators (up to 15 visible lines)
15
+ - **Full-width Terminal Display**: Enhanced prompt box uses full terminal width for better visibility
16
+
17
+ ### Improved
18
+ - **Smart Ctrl+C Handling**: First press clears content, second press (within 2s) exits
19
+ - **UTF-8 Encoding**: Better handling of multi-byte characters in clipboard operations
20
+ - **Cursor Positioning**: Improved cursor tracking in wrapped lines
21
+ - **Multi-line Paste**: Better display for pasted content with placeholder support
22
+
10
23
  ## [0.5.0] - 2026-01-11
11
24
 
12
25
  ### Added
data/README.md CHANGED
@@ -6,6 +6,8 @@ A command-line interface for interacting with AI models. OpenClacky supports Ope
6
6
 
7
7
  - 💬 Interactive chat sessions with AI models
8
8
  - 🤖 Autonomous AI agent with tool use capabilities
9
+ - 📝 Enhanced input with multi-line support and Unicode (Chinese, etc.)
10
+ - 🖼️ Paste images from clipboard (macOS/Linux)
9
11
  - 🚀 Single-message mode for quick queries
10
12
  - 🔐 Secure API key management
11
13
  - 📝 Multi-turn conversation support
data/Rakefile CHANGED
@@ -5,10 +5,6 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require "rubocop/rake_task"
9
-
10
- RuboCop::RakeTask.new
11
-
12
8
  namespace :build do
13
9
  desc "Build both openclacky and clacky gems"
14
10
  task :all do
@@ -35,4 +31,4 @@ namespace :build do
35
31
  end
36
32
  end
37
33
 
38
- task default: %i[spec rubocop]
34
+ task default: %i[spec]
data/lib/clacky/agent.rb CHANGED
@@ -9,13 +9,7 @@ require_relative "utils/arguments_parser"
9
9
  module Clacky
10
10
  class Agent
11
11
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
12
- :cache_stats
13
-
14
- # Pricing per 1M tokens (approximate - adjust based on actual model)
15
- PRICING = {
16
- input: 0.50, # $0.50 per 1M input tokens
17
- output: 1.50 # $1.50 per 1M output tokens
18
- }.freeze
12
+ :cache_stats, :cost_source
19
13
 
20
14
  # System prompt for the coding agent
21
15
  SYSTEM_PROMPT = <<~PROMPT.freeze
@@ -76,6 +70,8 @@ module Clacky
76
70
  @working_dir = working_dir || Dir.pwd
77
71
  @created_at = Time.now.iso8601
78
72
  @total_tasks = 0
73
+ @cost_source = :estimated # Track whether cost is from API or estimated
74
+ @task_cost_source = :estimated # Track cost source for current task
79
75
 
80
76
  # Register built-in tools
81
77
  register_builtin_tools
@@ -133,6 +129,7 @@ module Clacky
133
129
 
134
130
  def run(user_input, &block)
135
131
  @start_time = Time.now
132
+ @task_cost_source = :estimated # Reset for new task
136
133
 
137
134
  # Add system prompt as the first message if this is the first run
138
135
  if @messages.empty?
@@ -221,14 +218,14 @@ module Clacky
221
218
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
222
219
  # @param error_message [String] Error message if status is :error
223
220
  def to_session_data(status: :success, error_message: nil)
224
- # Get first real user message for preview (skip compressed system messages)
225
- first_user_msg = @messages.find do |m|
221
+ # Get last real user message for preview (skip compressed system messages)
222
+ last_user_msg = @messages.reverse.find do |m|
226
223
  m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
227
224
  end
228
225
 
229
- # Extract preview text from first user message
230
- first_message_preview = if first_user_msg
231
- content = first_user_msg[:content]
226
+ # Extract preview text from last user message
227
+ last_message_preview = if last_user_msg
228
+ content = last_user_msg[:content]
232
229
  if content.is_a?(String)
233
230
  # Truncate to 100 characters for preview
234
231
  content.length > 100 ? "#{content[0..100]}..." : content
@@ -270,7 +267,7 @@ module Clacky
270
267
  },
271
268
  stats: stats_data,
272
269
  messages: @messages,
273
- first_user_message: first_message_preview
270
+ first_user_message: last_message_preview
274
271
  }
275
272
  end
276
273
 
@@ -613,9 +610,29 @@ module Clacky
613
610
  end
614
611
 
615
612
  def track_cost(usage)
616
- input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
617
- output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
618
- @total_cost += input_cost + output_cost
613
+ # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
614
+ if usage[:api_cost]
615
+ @total_cost += usage[:api_cost]
616
+ @cost_source = :api
617
+ @task_cost_source = :api
618
+ puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
619
+ else
620
+ # Priority 2: Calculate from tokens using ModelPricing
621
+ result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
622
+ cost = result[:cost]
623
+ pricing_source = result[:source]
624
+
625
+ @total_cost += cost
626
+ # Map pricing source to cost source: :price or :default
627
+ @cost_source = pricing_source
628
+ @task_cost_source = pricing_source
629
+
630
+ if @config.verbose
631
+ source_label = pricing_source == :price ? "model pricing" : "default pricing"
632
+ puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
633
+ puts "[DEBUG] Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}"
634
+ end
635
+ end
619
636
 
620
637
  # Track cache usage statistics
621
638
  @cache_stats[:total_requests] += 1
@@ -1081,6 +1098,7 @@ module Clacky
1081
1098
  iterations: @iterations,
1082
1099
  duration_seconds: Time.now - @start_time,
1083
1100
  total_cost_usd: @total_cost.round(4),
1101
+ cost_source: @task_cost_source, # Add cost source for this task
1084
1102
  cache_stats: @cache_stats,
1085
1103
  messages: @messages,
1086
1104
  error: error
data/lib/clacky/cli.rb CHANGED
@@ -4,7 +4,7 @@ require "thor"
4
4
  require "tty-prompt"
5
5
  require "tty-spinner"
6
6
  require_relative "ui/banner"
7
- require_relative "ui/prompt"
7
+ require_relative "ui/enhanced_prompt"
8
8
  require_relative "ui/statusbar"
9
9
  require_relative "ui/formatter"
10
10
 
@@ -14,41 +14,15 @@ module Clacky
14
14
  true
15
15
  end
16
16
 
17
- desc "chat [MESSAGE]", "Start a chat with Claude or send a single message"
18
- long_desc <<-LONGDESC
19
- Start an interactive chat session with Claude AI.
20
-
21
- If MESSAGE is provided, send it as a single message and exit.
22
- If no MESSAGE is provided, start an interactive chat session.
23
-
24
- Examples:
25
- $ clacky chat "What is Ruby?"
26
- $ clacky chat
27
- LONGDESC
28
- option :model, type: :string, desc: "Model to use (default from config)"
29
- def chat(message = nil)
30
- config = Clacky::Config.load
31
-
32
- unless config.api_key
33
- say "Error: API key not found. Please run 'clacky config set' first.", :red
34
- exit 1
35
- end
36
-
37
- if message
38
- # Single message mode
39
- send_single_message(message, config)
40
- else
41
- # Interactive mode
42
- start_interactive_chat(config)
43
- end
44
- end
17
+ # Set agent as the default command
18
+ default_task :agent
45
19
 
46
20
  desc "version", "Show clacky version"
47
21
  def version
48
22
  say "Clacky version #{Clacky::VERSION}"
49
23
  end
50
24
 
51
- desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use"
25
+ desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use (default)"
52
26
  long_desc <<-LONGDESC
53
27
  Run an AI agent in interactive mode that can autonomously use tools to complete tasks.
54
28
 
@@ -159,44 +133,48 @@ module Clacky
159
133
  end
160
134
  end
161
135
 
162
- desc "tools", "List available tools"
163
- option :category, type: :string, desc: "Filter by category"
164
- def tools
165
- registry = ToolRegistry.new
166
-
167
- registry.register(Tools::Shell.new)
168
- registry.register(Tools::FileReader.new)
169
- registry.register(Tools::Write.new)
170
- registry.register(Tools::Edit.new)
171
- registry.register(Tools::Glob.new)
172
- registry.register(Tools::Grep.new)
173
- registry.register(Tools::WebSearch.new)
174
- registry.register(Tools::WebFetch.new)
175
-
176
- say "\n📦 Available Tools:\n\n", :green
177
-
178
- tools_to_show = if options[:category]
179
- registry.by_category(options[:category])
180
- else
181
- registry.all
182
- end
183
-
184
- tools_to_show.each do |tool|
185
- say " #{tool.name}", :cyan
186
- say " #{tool.description}", :white
187
- say " Category: #{tool.category}", :yellow
188
-
189
- if tool.parameters[:properties]
190
- say " Parameters:", :yellow
191
- tool.parameters[:properties].each do |name, spec|
192
- required = tool.parameters[:required]&.include?(name.to_s) ? " (required)" : ""
193
- say " - #{name}: #{spec[:description]}#{required}", :white
194
- end
195
- end
196
- say ""
197
- end
198
-
199
- say "Total: #{tools_to_show.size} tools\n", :green
136
+ desc "price", "Show pricing information for AI models"
137
+ def price
138
+ say "\n💰 Model Pricing Information\n\n", :green
139
+
140
+ say "Clacky supports three pricing modes when calculating API costs:\n\n", :white
141
+
142
+ say " 1. ", :cyan
143
+ say "API-provided cost", :bold
144
+ say " (", :white
145
+ say ":api", :yellow
146
+ say ")", :white
147
+ say "\n The most accurate - uses actual cost data from the API response", :white
148
+ say "\n Supported by: OpenRouter, LiteLLM, and other compatible proxies\n\n"
149
+
150
+ say " 2. ", :cyan
151
+ say "Model-specific pricing", :bold
152
+ say " (", :white
153
+ say ":price", :yellow
154
+ say ")", :white
155
+ say "\n Uses official pricing from model providers (Claude models)", :white
156
+ say "\n Includes tiered pricing and prompt caching discounts\n\n"
157
+
158
+ say " 3. ", :cyan
159
+ say "Default fallback pricing", :bold
160
+ say " (", :white
161
+ say ":default", :yellow
162
+ say ")", :white
163
+ say "\n Conservative estimates for unknown models", :white
164
+ say "\n Input: $0.50/MTok, Output: $1.50/MTok\n\n"
165
+
166
+ say "Priority order: API cost > Model pricing > Default pricing\n\n", :yellow
167
+
168
+ say "Supported models with official pricing:\n", :green
169
+ say " • claude-opus-4.5\n", :cyan
170
+ say " • claude-sonnet-4.5\n", :cyan
171
+ say " • claude-haiku-4.5\n", :cyan
172
+ say " • claude-3-5-sonnet-20241022\n", :cyan
173
+ say " claude-3-5-sonnet-20240620\n", :cyan
174
+ say " • claude-3-5-haiku-20241022\n\n", :cyan
175
+
176
+ say "For detailed pricing information, visit:\n", :white
177
+ say "https://www.anthropic.com/pricing\n\n", :blue
200
178
  end
201
179
 
202
180
  no_commands do
@@ -408,8 +386,12 @@ module Clacky
408
386
  cost: total_cost
409
387
  )
410
388
 
411
- # Use enhanced prompt with "You:" prefix
412
- current_message = prompt.read_input(prefix: "You:")
389
+ # Use enhanced prompt with "" prefix
390
+ result = prompt.read_input(prefix: "")
391
+
392
+ # EnhancedPrompt returns { text: String, images: Array } or nil
393
+ # For now, we only use the text part
394
+ current_message = result.nil? ? nil : result[:text]
413
395
 
414
396
  break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
415
397
  next if current_message.strip.empty?
@@ -438,6 +420,7 @@ module Clacky
438
420
  cost: result[:total_cost_usd].round(4),
439
421
  total_tasks: total_tasks,
440
422
  total_cost: total_cost.round(4),
423
+ cost_source: result[:cost_source],
441
424
  cache_stats: result[:cache_stats]
442
425
  )
443
426
  rescue Clacky::AgentInterrupted
@@ -613,7 +596,7 @@ module Clacky
613
596
  end
614
597
 
615
598
  def ui_prompt
616
- @ui_prompt ||= UI::Prompt.new
599
+ @ui_prompt ||= UI::EnhancedPrompt.new
617
600
  end
618
601
 
619
602
  def ui_statusbar
@@ -628,68 +611,6 @@ module Clacky
628
611
  @pastel ||= Pastel.new
629
612
  end
630
613
  end
631
-
632
- private
633
-
634
- def send_single_message(message, config)
635
- spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
636
- spinner.auto_spin
637
-
638
- client = Clacky::Client.new(config.api_key, base_url: config.base_url)
639
- response = client.send_message(message, model: options[:model] || config.model)
640
-
641
- spinner.success("Done!")
642
- say "\n#{response}", :cyan
643
- rescue StandardError => e
644
- spinner.error("Failed!")
645
- say "Error: #{e.message}", :red
646
- exit 1
647
- end
648
-
649
- def start_interactive_chat(config)
650
- say "Starting interactive chat with Claude...", :green
651
- say "Type 'exit' or 'quit' to end the session.\n\n", :yellow
652
-
653
- conversation = Clacky::Conversation.new(
654
- config.api_key,
655
- model: options[:model] || config.model,
656
- base_url: config.base_url
657
- )
658
-
659
- # Use TTY::Prompt for input
660
- tty_prompt = TTY::Prompt.new(interrupt: :exit)
661
-
662
- loop do
663
- # Use TTY::Prompt for better input handling
664
- begin
665
- message = tty_prompt.ask("You:", required: false) do |q|
666
- q.modify :strip
667
- end
668
- rescue TTY::Reader::InputInterrupt
669
- # Handle Ctrl+C
670
- puts
671
- break
672
- end
673
-
674
- break if message.nil? || %w[exit quit].include?(message&.downcase&.strip)
675
- next if message.nil? || message.strip.empty?
676
-
677
- spinner = TTY::Spinner.new("[:spinner] Claude is thinking...", format: :dots)
678
- spinner.auto_spin
679
-
680
- begin
681
- response = conversation.send_message(message)
682
- spinner.success("Claude:")
683
- say response, :cyan
684
- say "\n"
685
- rescue StandardError => e
686
- spinner.error("Error!")
687
- say "Error: #{e.message}", :red
688
- end
689
- end
690
-
691
- say "\nGoodbye!", :green
692
- end
693
614
  end
694
615
 
695
616
  class ConfigCommand < Thor
data/lib/clacky/client.rb CHANGED
@@ -55,12 +55,28 @@ module Clacky
55
55
  # Add tools if provided
56
56
  # For Claude API with caching: mark the last tool definition with cache_control
57
57
  if tools&.any?
58
- if enable_caching && supports_prompt_caching?(model)
58
+ caching_supported = supports_prompt_caching?(model)
59
+ 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
+
70
+ if caching_enabled
59
71
  # Deep clone tools to avoid modifying original
60
72
  cached_tools = tools.map { |tool| deep_clone(tool) }
61
73
  # Mark the last tool for caching (Claude caches from cache breakpoint to end)
62
74
  cached_tools.last[:cache_control] = { type: "ephemeral" }
63
75
  body[:tools] = cached_tools
76
+
77
+ if verbose || ENV["CLACKY_DEBUG"]
78
+ puts " Cache Control Added: Last tool marked for caching"
79
+ end
64
80
  else
65
81
  body[:tools] = tools
66
82
  end
@@ -91,14 +107,28 @@ module Clacky
91
107
  private
92
108
 
93
109
  # Check if the model supports prompt caching
94
- # Currently only Claude 3.5 Sonnet and newer Claude models support this
110
+ # Currently only Claude 3.5+ models support this feature
95
111
  def supports_prompt_caching?(model)
96
112
  model_str = model.to_s.downcase
97
- # Claude 3.5 Sonnet (20241022 and newer) supports prompt caching
98
- # Also Claude 3.7 Sonnet and Opus models when they're released
99
- model_str.include?("claude-3.5-sonnet") ||
100
- model_str.include?("claude-3-7") ||
101
- model_str.include?("claude-4")
113
+
114
+ # Only Claude models support prompt caching
115
+ return false unless model_str.include?("claude")
116
+
117
+ # Pattern matching for supported Claude versions:
118
+ # - claude-3.5-*, claude-3-5-*, claude-3.5.*
119
+ # - claude-3.7-*, claude-3-7-*, claude-3.7.*
120
+ # - claude-4*, claude-sonnet-4*
121
+ # - anthropic/claude-sonnet-4* (OpenRouter format)
122
+ cache_pattern = /
123
+ claude # Must contain "claude"
124
+ (?: # Non-capturing group for version patterns
125
+ (?:-3[-.]?[5-9])| # 3.5, 3.6, 3.7, 3.8, 3.9 or 3-5, 3-6, etc
126
+ (?:-[4-9])| # 4, 5, 6, 7, 8, 9 (future versions)
127
+ (?:-sonnet-[34]) # OpenRouter: claude-sonnet-3, claude-sonnet-4
128
+ )
129
+ /x
130
+
131
+ model_str.match?(cache_pattern)
102
132
  end
103
133
 
104
134
  # Deep clone a hash/array structure (for tool definitions)
@@ -162,6 +192,11 @@ module Clacky
162
192
  total_tokens: usage["total_tokens"]
163
193
  }
164
194
 
195
+ # Add OpenRouter cost information if present
196
+ if usage["cost"]
197
+ usage_data[:api_cost] = usage["cost"]
198
+ end
199
+
165
200
  # Add cache metrics if present (Claude API with prompt caching)
166
201
  if usage["cache_creation_input_tokens"]
167
202
  usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"]
@@ -170,6 +205,17 @@ module Clacky
170
205
  usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"]
171
206
  end
172
207
 
208
+ # Add OpenRouter cache information from prompt_tokens_details
209
+ if usage["prompt_tokens_details"]
210
+ details = usage["prompt_tokens_details"]
211
+ if details["cached_tokens"] && details["cached_tokens"] > 0
212
+ usage_data[:cache_read_input_tokens] = details["cached_tokens"]
213
+ end
214
+ if details["cache_write_tokens"] && details["cache_write_tokens"] > 0
215
+ usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"]
216
+ end
217
+ end
218
+
173
219
  {
174
220
  content: message["content"],
175
221
  tool_calls: parse_tool_calls(message["tool_calls"]),
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # Parser for .gitignore files to determine which files should be ignored
5
+ class GitignoreParser
6
+ attr_reader :patterns
7
+
8
+ def initialize(gitignore_path = nil)
9
+ @patterns = []
10
+ @negation_patterns = []
11
+
12
+ if gitignore_path && File.exist?(gitignore_path)
13
+ parse_gitignore(gitignore_path)
14
+ end
15
+ end
16
+
17
+ # Check if a file path should be ignored
18
+ def ignored?(path)
19
+ relative_path = path.start_with?('./') ? path[2..] : path
20
+
21
+ # Check negation patterns first (! prefix in .gitignore)
22
+ @negation_patterns.each do |pattern|
23
+ return false if match_pattern?(relative_path, pattern)
24
+ end
25
+
26
+ # Then check ignore patterns
27
+ @patterns.each do |pattern|
28
+ return true if match_pattern?(relative_path, pattern)
29
+ end
30
+
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def parse_gitignore(path)
37
+ File.readlines(path, chomp: true).each do |line|
38
+ # Skip comments and empty lines
39
+ next if line.strip.empty? || line.start_with?('#')
40
+
41
+ # Handle negation patterns (lines starting with !)
42
+ if line.start_with?('!')
43
+ @negation_patterns << normalize_pattern(line[1..])
44
+ else
45
+ @patterns << normalize_pattern(line)
46
+ end
47
+ end
48
+ rescue StandardError => e
49
+ # If we can't parse .gitignore, just continue with empty patterns
50
+ warn "Warning: Failed to parse .gitignore: #{e.message}"
51
+ end
52
+
53
+ def normalize_pattern(pattern)
54
+ pattern = pattern.strip
55
+
56
+ # Remove trailing whitespace
57
+ pattern = pattern.rstrip
58
+
59
+ # Store original for directory detection
60
+ is_directory = pattern.end_with?('/')
61
+ pattern = pattern.chomp('/')
62
+
63
+ {
64
+ pattern: pattern,
65
+ is_directory: is_directory,
66
+ is_absolute: pattern.start_with?('/'),
67
+ has_wildcard: pattern.include?('*') || pattern.include?('?'),
68
+ has_double_star: pattern.include?('**')
69
+ }
70
+ end
71
+
72
+ def match_pattern?(path, pattern_info)
73
+ pattern = pattern_info[:pattern]
74
+
75
+ # Remove leading slash for absolute patterns
76
+ pattern = pattern[1..] if pattern_info[:is_absolute]
77
+
78
+ # Handle directory patterns
79
+ if pattern_info[:is_directory]
80
+ return false unless File.directory?(path)
81
+ end
82
+
83
+ # Handle different wildcard patterns
84
+ if pattern_info[:has_double_star]
85
+ # 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 /
91
+
92
+ regex = Regexp.new("^#{regex_pattern}$")
93
+ return true if path.match?(regex)
94
+ return true if path.split('/').any? { |part| part.match?(regex) }
95
+ elsif pattern_info[:has_wildcard]
96
+ # Convert glob pattern to regex
97
+ regex_pattern = pattern
98
+ .gsub('*', '[^/]*')
99
+ .gsub('?', '[^/]')
100
+
101
+ regex = Regexp.new("^#{regex_pattern}$")
102
+ return true if path.match?(regex)
103
+ return true if File.basename(path).match?(regex)
104
+ else
105
+ # Exact match
106
+ return true if path == pattern
107
+ return true if path.start_with?("#{pattern}/")
108
+ return true if File.basename(path) == pattern
109
+ end
110
+
111
+ false
112
+ end
113
+ end
114
+ end