openclacky 0.6.2 → 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 +579 -99
- data/lib/clacky/cli.rb +350 -9
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- 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/edit.rb +111 -8
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/glob.rb +9 -2
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- 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 +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +38 -47
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +4 -1
- metadata +6 -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)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-add
|
|
3
|
+
description: Guide for creating new SKILL.md files
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
user-invocable: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Skill Creation Guide
|
|
9
|
+
|
|
10
|
+
## SKILL.md Structure
|
|
11
|
+
|
|
12
|
+
### 1. Front Matter (Required)
|
|
13
|
+
```yaml
|
|
14
|
+
---
|
|
15
|
+
name: skill-name
|
|
16
|
+
description: Brief one-line description
|
|
17
|
+
disable-model-invocation: false
|
|
18
|
+
user-invocable: true
|
|
19
|
+
---
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 2. Main Content
|
|
23
|
+
```markdown
|
|
24
|
+
# Skill Title
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
How to invoke: "command description" or `/skill-name`
|
|
28
|
+
|
|
29
|
+
## Process Steps
|
|
30
|
+
|
|
31
|
+
### 1. First Step
|
|
32
|
+
What to do
|
|
33
|
+
|
|
34
|
+
### 2. Next Step
|
|
35
|
+
Continue the task
|
|
36
|
+
|
|
37
|
+
## Commands Used
|
|
38
|
+
```bash
|
|
39
|
+
# Key commands
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
- Important points
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## File Location
|
|
47
|
+
`.clacky/skills/{skill-name}/SKILL.md`
|
|
48
|
+
|
|
49
|
+
## Minimal Example
|
|
50
|
+
```markdown
|
|
51
|
+
---
|
|
52
|
+
name: hello
|
|
53
|
+
description: Simple greeting
|
|
54
|
+
disable-model-invocation: false
|
|
55
|
+
user-invocable: true
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# Hello Skill
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
Say "hello" or `/hello`
|
|
62
|
+
|
|
63
|
+
## Process Steps
|
|
64
|
+
### 1. Greet user
|
|
65
|
+
### 2. Offer help
|
|
66
|
+
```
|
data/lib/clacky/skill.rb
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# Represents a skill with its metadata and content.
|
|
8
|
+
# A skill is defined by a SKILL.md file with optional YAML frontmatter.
|
|
9
|
+
class Skill
|
|
10
|
+
# Frontmatter fields that are recognized
|
|
11
|
+
FRONTMATTER_FIELDS = %w[
|
|
12
|
+
name
|
|
13
|
+
description
|
|
14
|
+
disable-model-invocation
|
|
15
|
+
user-invocable
|
|
16
|
+
allowed-tools
|
|
17
|
+
context
|
|
18
|
+
agent
|
|
19
|
+
argument-hint
|
|
20
|
+
hooks
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :directory, :frontmatter, :source_path
|
|
24
|
+
attr_reader :name, :description, :content
|
|
25
|
+
attr_reader :disable_model_invocation, :user_invocable
|
|
26
|
+
attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
|
|
27
|
+
|
|
28
|
+
# @param directory [Pathname, String] Path to the skill directory
|
|
29
|
+
# @param source_path [Pathname, String, nil] Optional source path for priority resolution
|
|
30
|
+
def initialize(directory, source_path: nil)
|
|
31
|
+
@directory = Pathname.new(directory)
|
|
32
|
+
@source_path = source_path ? Pathname.new(source_path) : @directory
|
|
33
|
+
|
|
34
|
+
load_skill
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get the skill identifier (uses name from frontmatter or directory name)
|
|
38
|
+
# @return [String]
|
|
39
|
+
def identifier
|
|
40
|
+
@name || @directory.basename.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if skill can be invoked by user via slash command
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def user_invocable?
|
|
46
|
+
@user_invocable.nil? || @user_invocable
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if skill can be automatically invoked by the model
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def model_invocation_allowed?
|
|
52
|
+
!@disable_model_invocation
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if skill runs in a forked subagent context
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def forked_context?
|
|
58
|
+
@context == "fork"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get the slash command for this skill
|
|
62
|
+
# @return [String] e.g., "/explain-code"
|
|
63
|
+
def slash_command
|
|
64
|
+
"/#{identifier}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get the description for context loading
|
|
68
|
+
# Returns the description from frontmatter, or first paragraph of content
|
|
69
|
+
# @return [String]
|
|
70
|
+
def context_description
|
|
71
|
+
@description || extract_first_paragraph
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get all supporting files in the skill directory (excluding SKILL.md)
|
|
75
|
+
# @return [Array<Pathname>]
|
|
76
|
+
def supporting_files
|
|
77
|
+
return [] unless @directory.exist?
|
|
78
|
+
|
|
79
|
+
@directory.children.reject { |p| p.basename.to_s == "SKILL.md" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if this skill has supporting files
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def has_supporting_files?
|
|
85
|
+
supporting_files.any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Process the skill content with argument substitution
|
|
89
|
+
# @param arguments [String] Arguments passed to the skill
|
|
90
|
+
# @param shell_output [Hash] Shell command outputs for !command` syntax (optional)
|
|
91
|
+
# @return [String] Processed content
|
|
92
|
+
def process_content(arguments = "", shell_output: {})
|
|
93
|
+
processed_content = @content.dup
|
|
94
|
+
|
|
95
|
+
# Replace argument placeholders
|
|
96
|
+
processed_content = substitute_arguments(processed_content, arguments)
|
|
97
|
+
|
|
98
|
+
# Replace shell command outputs
|
|
99
|
+
shell_output.each do |command, output|
|
|
100
|
+
placeholder = "!`#{command}`"
|
|
101
|
+
processed_content.gsub!(placeholder, output.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
processed_content
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert to a hash representation
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def to_h
|
|
110
|
+
{
|
|
111
|
+
name: identifier,
|
|
112
|
+
description: context_description,
|
|
113
|
+
directory: @directory.to_s,
|
|
114
|
+
source_path: @source_path.to_s,
|
|
115
|
+
user_invocable: user_invocable?,
|
|
116
|
+
model_invocation_allowed: model_invocation_allowed?,
|
|
117
|
+
forked_context: forked_context?,
|
|
118
|
+
allowed_tools: @allowed_tools,
|
|
119
|
+
argument_hint: @argument_hint,
|
|
120
|
+
content_length: @content.length
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Load content of a supporting file
|
|
125
|
+
# @param filename [String] Relative path from skill directory
|
|
126
|
+
# @return [String, nil] File contents or nil if not found
|
|
127
|
+
def read_supporting_file(filename)
|
|
128
|
+
file_path = @directory.join(filename)
|
|
129
|
+
file_path.exist? ? file_path.read : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def load_skill
|
|
135
|
+
skill_file = @directory.join("SKILL.md")
|
|
136
|
+
|
|
137
|
+
unless skill_file.exist?
|
|
138
|
+
raise Clacky::AgentError, "SKILL.md not found in skill directory: #{@directory}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
content = skill_file.read
|
|
142
|
+
|
|
143
|
+
# Parse frontmatter if present
|
|
144
|
+
if content.start_with?("---")
|
|
145
|
+
parse_frontmatter(content)
|
|
146
|
+
else
|
|
147
|
+
@frontmatter = {}
|
|
148
|
+
@content = content
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Set defaults
|
|
152
|
+
@user_invocable = true if @user_invocable.nil?
|
|
153
|
+
@disable_model_invocation = false if @disable_model_invocation.nil?
|
|
154
|
+
|
|
155
|
+
validate_frontmatter
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_frontmatter(content)
|
|
159
|
+
# Extract frontmatter between first and second "---"
|
|
160
|
+
frontmatter_match = content.match(/^---\n(.*?)\n---/m)
|
|
161
|
+
|
|
162
|
+
unless frontmatter_match
|
|
163
|
+
raise Clacky::AgentError, "Invalid frontmatter format in SKILL.md: missing closing ---"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
yaml_content = frontmatter_match[1]
|
|
167
|
+
@frontmatter = YAML.safe_load(yaml_content) || {}
|
|
168
|
+
|
|
169
|
+
# Extract content after frontmatter
|
|
170
|
+
@content = content[frontmatter_match.end(0)..-1].to_s.strip
|
|
171
|
+
|
|
172
|
+
# Extract fields from frontmatter
|
|
173
|
+
@name = @frontmatter["name"]
|
|
174
|
+
@description = @frontmatter["description"]
|
|
175
|
+
@disable_model_invocation = @frontmatter["disable-model-invocation"]
|
|
176
|
+
@user_invocable = @frontmatter["user-invocable"]
|
|
177
|
+
@allowed_tools = @frontmatter["allowed-tools"]
|
|
178
|
+
@context = @frontmatter["context"]
|
|
179
|
+
@agent_type = @frontmatter["agent"]
|
|
180
|
+
@argument_hint = @frontmatter["argument-hint"]
|
|
181
|
+
@hooks = @frontmatter["hooks"]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_frontmatter
|
|
185
|
+
# Validate name if provided
|
|
186
|
+
if @name
|
|
187
|
+
unless @name.match?(/^[a-z0-9][a-z0-9-]*$/)
|
|
188
|
+
raise Clacky::AgentError,
|
|
189
|
+
"Invalid skill name '#{@name}'. Use lowercase letters, numbers, and hyphens only (max 64 chars)."
|
|
190
|
+
end
|
|
191
|
+
if @name.length > 64
|
|
192
|
+
raise Clacky::AgentError, "Skill name '#{@name}' exceeds 64 characters."
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Validate context
|
|
197
|
+
if @context && @context != "fork"
|
|
198
|
+
raise Clacky::AgentError, "Invalid context '#{@context}'. Only 'fork' is supported."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Validate allowed-tools format
|
|
202
|
+
if @allowed_tools && !@allowed_tools.is_a?(Array)
|
|
203
|
+
raise Clacky::AgentError, "allowed-tools must be an array of tool names"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_first_paragraph
|
|
208
|
+
@content.split(/\n\n/).first.to_s
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def substitute_arguments(content, arguments)
|
|
212
|
+
# Parse arguments as shell words for indexed access
|
|
213
|
+
args_array = arguments.shellsplit
|
|
214
|
+
|
|
215
|
+
# Replace $ARGUMENTS with all arguments
|
|
216
|
+
result = content.gsub("$ARGUMENTS", arguments.to_s)
|
|
217
|
+
|
|
218
|
+
# Replace $ARGUMENTS[N] with specific argument
|
|
219
|
+
result.gsub!(/\$ARGUMENTS\[(\d+)\]/) do
|
|
220
|
+
index = $1.to_i
|
|
221
|
+
args_array[index] || ""
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Replace $N shorthand ($0, $1, etc.)
|
|
225
|
+
result.gsub!(/\$([0-9]+)/) do
|
|
226
|
+
index = $1.to_i
|
|
227
|
+
args_array[index] || ""
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Replace ${CLAUDE_SESSION_ID} with empty string (session not available in current context)
|
|
231
|
+
result.gsub!(/\${CLAUDE_SESSION_ID}/, "")
|
|
232
|
+
|
|
233
|
+
result
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|