openclacky 0.6.3 → 0.6.4

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.
data/lib/clacky/config.rb CHANGED
@@ -4,25 +4,92 @@ require "yaml"
4
4
  require "fileutils"
5
5
 
6
6
  module Clacky
7
+ # ClaudeCode environment variable compatibility layer
8
+ # Provides configuration detection from ClaudeCode's environment variables
9
+ module ClaudeCodeEnv
10
+ # Environment variable names used by ClaudeCode
11
+ ENV_API_KEY = "ANTHROPIC_API_KEY"
12
+ ENV_AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN"
13
+ ENV_BASE_URL = "ANTHROPIC_BASE_URL"
14
+
15
+ # Default Anthropic API endpoint
16
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
17
+
18
+ class << self
19
+ # Check if any ClaudeCode authentication is configured
20
+ def configured?
21
+ !api_key.nil? && !api_key.empty?
22
+ end
23
+
24
+ # Get API key - prefer ANTHROPIC_API_KEY, fallback to ANTHROPIC_AUTH_TOKEN
25
+ def api_key
26
+ if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
27
+ ENV[ENV_API_KEY]
28
+ elsif ENV[ENV_AUTH_TOKEN] && !ENV[ENV_AUTH_TOKEN].empty?
29
+ ENV[ENV_AUTH_TOKEN]
30
+ end
31
+ end
32
+
33
+ # Get base URL from environment, or return default Anthropic API URL
34
+ def base_url
35
+ ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty? ? ENV[ENV_BASE_URL] : DEFAULT_BASE_URL
36
+ end
37
+
38
+ # Get configuration as a hash (includes configured values)
39
+ # Returns api_key and base_url (always available as there's a default)
40
+ def to_h
41
+ {
42
+ "api_key" => api_key,
43
+ "base_url" => base_url
44
+ }.compact
45
+ end
46
+ end
47
+ end
48
+
7
49
  class Config
8
50
  CONFIG_DIR = File.join(Dir.home, ".clacky")
9
51
  CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
10
52
 
11
- attr_accessor :api_key, :model, :base_url
53
+ # Default model for ClaudeCode environment
54
+ CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-5"
55
+
56
+ attr_accessor :api_key, :model, :base_url, :config_source
12
57
 
13
58
  def initialize(data = {})
14
59
  @api_key = data["api_key"]
15
- @model = data["model"] || "gpt-3.5-turbo"
60
+ @model = data["model"]
16
61
  @base_url = data["base_url"] || "https://api.openai.com"
62
+ @config_source = data["_config_source"] || "default"
17
63
  end
18
64
 
19
65
  def self.load(config_file = CONFIG_FILE)
66
+ # Load from config file first
20
67
  if File.exist?(config_file)
21
68
  data = YAML.load_file(config_file) || {}
22
- new(data)
69
+ config_source = "file"
23
70
  else
24
- new
71
+ data = {}
72
+ config_source = nil
73
+ end
74
+
75
+ # If api_key not found in config file, check ClaudeCode environment variables
76
+ if data["api_key"].nil? || data["api_key"].empty?
77
+ if ClaudeCodeEnv.configured?
78
+ data["api_key"] = ClaudeCodeEnv.api_key
79
+ data["base_url"] = ClaudeCodeEnv.base_url if data["base_url"].nil? || data["base_url"].empty?
80
+ # Use Claude default model if not specified in config file
81
+ data["model"] = CLAUDE_DEFAULT_MODEL if data["model"].nil? || data["model"].empty?
82
+ config_source = "claude_code"
83
+ elsif config_source.nil?
84
+ config_source = "default"
85
+ end
86
+ elsif config_source.nil?
87
+ # Config file existed but didn't have api_key
88
+ config_source = "default"
25
89
  end
90
+
91
+ data["_config_source"] = config_source
92
+ new(data)
26
93
  end
27
94
 
28
95
  def save(config_file = CONFIG_FILE)
data/lib/clacky/skill.rb CHANGED
@@ -135,7 +135,7 @@ module Clacky
135
135
  skill_file = @directory.join("SKILL.md")
136
136
 
137
137
  unless skill_file.exist?
138
- raise Clacky::Error, "SKILL.md not found in skill directory: #{@directory}"
138
+ raise Clacky::AgentError, "SKILL.md not found in skill directory: #{@directory}"
139
139
  end
140
140
 
141
141
  content = skill_file.read
@@ -160,7 +160,7 @@ module Clacky
160
160
  frontmatter_match = content.match(/^---\n(.*?)\n---/m)
161
161
 
162
162
  unless frontmatter_match
163
- raise Clacky::Error, "Invalid frontmatter format in SKILL.md: missing closing ---"
163
+ raise Clacky::AgentError, "Invalid frontmatter format in SKILL.md: missing closing ---"
164
164
  end
165
165
 
166
166
  yaml_content = frontmatter_match[1]
@@ -185,22 +185,22 @@ module Clacky
185
185
  # Validate name if provided
186
186
  if @name
187
187
  unless @name.match?(/^[a-z0-9][a-z0-9-]*$/)
188
- raise Clacky::Error,
188
+ raise Clacky::AgentError,
189
189
  "Invalid skill name '#{@name}'. Use lowercase letters, numbers, and hyphens only (max 64 chars)."
190
190
  end
191
191
  if @name.length > 64
192
- raise Clacky::Error, "Skill name '#{@name}' exceeds 64 characters."
192
+ raise Clacky::AgentError, "Skill name '#{@name}' exceeds 64 characters."
193
193
  end
194
194
  end
195
195
 
196
196
  # Validate context
197
197
  if @context && @context != "fork"
198
- raise Clacky::Error, "Invalid context '#{@context}'. Only 'fork' is supported."
198
+ raise Clacky::AgentError, "Invalid context '#{@context}'. Only 'fork' is supported."
199
199
  end
200
200
 
201
201
  # Validate allowed-tools format
202
202
  if @allowed_tools && !@allowed_tools.is_a?(Array)
203
- raise Clacky::Error, "allowed-tools must be an array of tool names"
203
+ raise Clacky::AgentError, "allowed-tools must be an array of tool names"
204
204
  end
205
205
  end
206
206
 
@@ -167,7 +167,7 @@ module Clacky
167
167
  def create_skill(name, content, description = nil, location: :global)
168
168
  # Validate name
169
169
  unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
170
- raise Clacky::Error,
170
+ raise Clacky::AgentError,
171
171
  "Invalid skill name '#{name}'. Use lowercase letters, numbers, and hyphens only."
172
172
  end
173
173
 
@@ -178,7 +178,7 @@ module Clacky
178
178
  when :project
179
179
  Pathname.new(@working_dir).join(".clacky", "skills", name)
180
180
  else
181
- raise Clacky::Error, "Unknown skill location: #{location}"
181
+ raise Clacky::AgentError, "Unknown skill location: #{location}"
182
182
  end
183
183
 
184
184
  # Create directory if it doesn't exist
@@ -270,7 +270,7 @@ module Clacky
270
270
  @loaded_from[skill.identifier] = source_type
271
271
 
272
272
  skill
273
- rescue Clacky::Error => e
273
+ rescue Clacky::AgentError => e
274
274
  @errors << "Error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
275
275
  nil
276
276
  rescue StandardError => e
@@ -46,14 +46,23 @@ module Clacky
46
46
  content = File.read(path)
47
47
  original_content = content.dup
48
48
 
49
- # Check if old_string exists
50
- unless content.include?(old_string)
51
- return { error: "String to replace not found in file" }
49
+ # Try exact match first
50
+ if content.include?(old_string)
51
+ actual_old_string = old_string
52
+ occurrences = content.scan(old_string).length
53
+ else
54
+ # Try smart whitespace normalization
55
+ match_result = try_smart_match(content, old_string)
56
+
57
+ if match_result
58
+ actual_old_string = match_result[:matched_string]
59
+ occurrences = match_result[:occurrences]
60
+ else
61
+ # Provide helpful error with context
62
+ return build_helpful_error(content, old_string, path)
63
+ end
52
64
  end
53
65
 
54
- # Count occurrences
55
- occurrences = content.scan(old_string).length
56
-
57
66
  # If not replace_all and multiple occurrences, warn about ambiguity
58
67
  if !replace_all && occurrences > 1
59
68
  return {
@@ -64,9 +73,9 @@ module Clacky
64
73
 
65
74
  # Perform replacement
66
75
  if replace_all
67
- content = content.gsub(old_string, new_string)
76
+ content = content.gsub(actual_old_string, new_string)
68
77
  else
69
- content = content.sub(old_string, new_string)
78
+ content = content.sub(actual_old_string, new_string)
70
79
  end
71
80
 
72
81
  # Write modified content
@@ -84,6 +93,100 @@ module Clacky
84
93
  end
85
94
  end
86
95
 
96
+ private def try_smart_match(content, old_string)
97
+ # Normalize whitespace: convert all leading whitespace to single space for comparison
98
+ normalized_old = normalize_leading_whitespace(old_string)
99
+
100
+ # Find all potential matches in content with normalized whitespace
101
+ matches = []
102
+ content_lines = content.lines
103
+ old_lines = old_string.lines
104
+
105
+ return nil if old_lines.empty?
106
+
107
+ # Scan through content to find matches
108
+ (0..content_lines.length - old_lines.length).each do |start_idx|
109
+ slice = content_lines[start_idx, old_lines.length]
110
+ next unless slice
111
+
112
+ # Check if this slice matches when normalized
113
+ if lines_match_normalized?(slice, old_lines)
114
+ matched_string = slice.join
115
+ matches << { start: start_idx, matched_string: matched_string }
116
+ end
117
+ end
118
+
119
+ return nil if matches.empty?
120
+
121
+ # Return the first match and count total occurrences
122
+ {
123
+ matched_string: matches.first[:matched_string],
124
+ occurrences: matches.length
125
+ }
126
+ end
127
+
128
+ private def normalize_leading_whitespace(text)
129
+ # Normalize each line's leading whitespace
130
+ text.lines.map { |line| line.sub(/^\s+/, ' ') }.join
131
+ end
132
+
133
+ private def lines_match_normalized?(lines1, lines2)
134
+ return false unless lines1.length == lines2.length
135
+
136
+ lines1.zip(lines2).all? do |line1, line2|
137
+ # Normalize leading whitespace and compare
138
+ norm1 = line1.sub(/^\s+/, ' ')
139
+ norm2 = line2.sub(/^\s+/, ' ')
140
+ norm1 == norm2
141
+ end
142
+ end
143
+
144
+ private def build_helpful_error(content, old_string, path)
145
+ # Find similar content to help debug
146
+ old_lines = old_string.lines
147
+ first_line_pattern = old_lines.first&.strip
148
+
149
+ if first_line_pattern && !first_line_pattern.empty?
150
+ # Find lines that match the first line (ignoring whitespace)
151
+ content_lines = content.lines
152
+ similar_locations = []
153
+
154
+ content_lines.each_with_index do |line, idx|
155
+ if line.strip == first_line_pattern
156
+ # Show context: 2 lines before and after
157
+ start_idx = [0, idx - 2].max
158
+ end_idx = [content_lines.length - 1, idx + old_lines.length + 2].min
159
+ context = content_lines[start_idx..end_idx].join
160
+
161
+ similar_locations << {
162
+ line_number: idx + 1,
163
+ context: context
164
+ }
165
+ end
166
+ end
167
+
168
+ if similar_locations.any?
169
+ context_preview = similar_locations.first[:context]
170
+ # Escape newlines for better display
171
+ context_display = context_preview.lines.first(5).map { |l| " #{l}" }.join
172
+
173
+ return {
174
+ error: "String to replace not found in file. The first line of old_string exists at line #{similar_locations.first[:line_number]}, " \
175
+ "but the full multi-line string doesn't match. This is often caused by whitespace differences (tabs vs spaces). " \
176
+ "\n\nContext around line #{similar_locations.first[:line_number]}:\n#{context_display}\n\n" \
177
+ "TIP: Make sure to copy the exact whitespace characters from the file. Use file_reader to see the actual content."
178
+ }
179
+ end
180
+ end
181
+
182
+ # Generic error if no similar content found
183
+ {
184
+ error: "String to replace not found in file '#{File.basename(path)}'. " \
185
+ "Make sure old_string matches exactly (including all whitespace). " \
186
+ "TIP: Use file_reader to view the exact content first, then copy the exact string including all spaces and tabs."
187
+ }
188
+ end
189
+
87
190
  def format_call(args)
88
191
  path = args[:file_path] || args['file_path'] || args[:path] || args['path']
89
192
  "Edit(#{Utils::PathHelper.safe_basename(path)})"
@@ -39,6 +39,9 @@ module Clacky
39
39
  return { error: "Pattern cannot be empty" }
40
40
  end
41
41
 
42
+ # Expand ~ in pattern to user's home directory
43
+ pattern = pattern.gsub("~", Dir.home)
44
+
42
45
  # Validate base_path
43
46
  unless Dir.exist?(base_path)
44
47
  return { error: "Base path does not exist: #{base_path}" }
@@ -59,8 +62,12 @@ module Clacky
59
62
  ignored: 0
60
63
  }
61
64
 
62
- # Change to base path and find matches
63
- full_pattern = File.join(base_path, pattern)
65
+ # Build full pattern - handle absolute paths correctly
66
+ full_pattern = if File.absolute_path?(pattern)
67
+ pattern
68
+ else
69
+ File.join(base_path, pattern)
70
+ end
64
71
  all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
65
72
  .reject { |path| File.directory?(path) }
66
73
  .reject { |path| path.end_with?(".", "..") }
@@ -393,7 +393,7 @@ module Clacky
393
393
  def show_complete(iterations:, cost:, duration: nil, cache_stats: nil)
394
394
  # Update status back to 'idle' when task is complete
395
395
  update_sessionbar(status: 'idle')
396
-
396
+
397
397
  # Clear user tip when agent stops working
398
398
  @input_area.clear_user_tip
399
399
  @layout.render_input
@@ -452,10 +452,10 @@ module Clacky
452
452
  def clear_progress
453
453
  # Calculate elapsed time before stopping
454
454
  elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
455
-
455
+
456
456
  # Stop the progress thread
457
457
  stop_progress_thread
458
-
458
+
459
459
  # Update the final progress line to gray (stopped state)
460
460
  if @progress_message && elapsed_time > 0
461
461
  final_output = @renderer.render_progress("#{@progress_message}… (#{elapsed_time}s)")
@@ -557,7 +557,7 @@ module Clacky
557
557
 
558
558
  # Create InlineInput with styled prompt
559
559
  inline_input = Components::InlineInput.new(
560
- prompt: " Press Enter to approve, 'n' to reject, or provide feedback: ",
560
+ prompt: "Press Enter/y to approve(Shift+Tab for all), 'n' to reject, or type feedback: ",
561
561
  default: nil
562
562
  )
563
563
  @inline_input = inline_input
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.6.3"
4
+ VERSION = "0.6.4"
5
5
  end
data/lib/clacky.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "clacky/agent"
37
37
  require_relative "clacky/cli"
38
38
 
39
39
  module Clacky
40
- class Error < StandardError; end
40
+ class AgentError < StandardError; end
41
41
  class AgentInterrupted < StandardError; end
42
+ class ToolCallError < AgentError; end # Raised when tool call fails due to invalid parameters
42
43
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -159,6 +159,7 @@ files:
159
159
  - clacky-legacy/clacky.gemspec
160
160
  - clacky-legacy/clarky.gemspec
161
161
  - docs/ui2-architecture.md
162
+ - docs/why-openclacky.md
162
163
  - homebrew/README.md
163
164
  - homebrew/openclacky.rb
164
165
  - lib/clacky.rb