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.
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)
@@ -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
+ ```
@@ -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