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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/docs/why-openclacky.md +267 -0
- data/lib/clacky/agent.rb +52 -60
- data/lib/clacky/cli.rb +9 -7
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- data/lib/clacky/skill.rb +6 -6
- data/lib/clacky/skill_loader.rb +3 -3
- data/lib/clacky/tools/edit.rb +111 -8
- data/lib/clacky/tools/glob.rb +9 -2
- data/lib/clacky/ui2/ui_controller.rb +4 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -1
- metadata +2 -1
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
|
-
|
|
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"]
|
|
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
|
-
|
|
69
|
+
config_source = "file"
|
|
23
70
|
else
|
|
24
|
-
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
203
|
+
raise Clacky::AgentError, "allowed-tools must be an array of tool names"
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
206
|
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -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::
|
|
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::
|
|
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::
|
|
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
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -46,14 +46,23 @@ module Clacky
|
|
|
46
46
|
content = File.read(path)
|
|
47
47
|
original_content = content.dup
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
76
|
+
content = content.gsub(actual_old_string, new_string)
|
|
68
77
|
else
|
|
69
|
-
content = content.sub(
|
|
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)})"
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -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
|
-
#
|
|
63
|
-
full_pattern = File.
|
|
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: "
|
|
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
|
data/lib/clacky/version.rb
CHANGED
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
|
|
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.
|
|
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
|