openclacky 0.6.1 → 0.6.3

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.
@@ -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::Error, "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::Error, "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::Error,
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::Error, "Skill name '#{@name}' exceeds 64 characters."
193
+ end
194
+ end
195
+
196
+ # Validate context
197
+ if @context && @context != "fork"
198
+ raise Clacky::Error, "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::Error, "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
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+ require "clacky"
6
+
7
+ module Clacky
8
+ # Loader and registry for skills.
9
+ # Discovers skills from multiple locations and provides lookup functionality.
10
+ class SkillLoader
11
+ # Skill discovery locations (in priority order: lower index = lower priority)
12
+ LOCATIONS = [
13
+ :default, # gem's built-in default skills (lowest priority)
14
+ :global_claude, # ~/.claude/skills/ (compatibility)
15
+ :global_clacky, # ~/.clacky/skills/
16
+ :project_claude, # .claude/skills/ (project-level compatibility)
17
+ :project_clacky # .clacky/skills/ (highest priority)
18
+ ].freeze
19
+
20
+ # Initialize the skill loader and automatically load all skills
21
+ # @param working_dir [String] Current working directory for project-level discovery
22
+ def initialize(working_dir = nil)
23
+ @working_dir = working_dir || Dir.pwd
24
+ @skills = {} # Map identifier -> Skill
25
+ @skills_by_command = {} # Map slash_command -> Skill
26
+ @errors = [] # Store loading errors
27
+ @loaded_from = {} # Track which location each skill was loaded from
28
+
29
+ # Automatically load all skills on initialization
30
+ load_all
31
+ end
32
+
33
+ # Load all skills from configured locations
34
+ # Clears previously loaded skills before loading to ensure idempotency
35
+ # @return [Array<Skill>] Loaded skills
36
+ def load_all
37
+ # Clear existing skills to ensure idempotent reloading
38
+ clear
39
+
40
+ load_default_skills
41
+ load_global_claude_skills
42
+ load_global_clacky_skills
43
+ load_project_claude_skills
44
+ load_project_clacky_skills
45
+
46
+ all_skills
47
+ end
48
+
49
+ # Load skills from ~/.claude/skills/ (lowest priority, compatibility)
50
+ # @return [Array<Skill>]
51
+ def load_global_claude_skills
52
+ global_claude_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".claude", "skills")
53
+ load_skills_from_directory(global_claude_dir, :global_claude)
54
+ end
55
+
56
+ # Load skills from ~/.clacky/skills/ (user global)
57
+ # @return [Array<Skill>]
58
+ def load_global_clacky_skills
59
+ global_clacky_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills")
60
+ load_skills_from_directory(global_clacky_dir, :global_clacky)
61
+ end
62
+
63
+ # Load skills from .claude/skills/ (project-level compatibility)
64
+ # @return [Array<Skill>]
65
+ def load_project_claude_skills
66
+ project_claude_dir = Pathname.new(@working_dir).join(".claude", "skills")
67
+ load_skills_from_directory(project_claude_dir, :project_claude)
68
+ end
69
+
70
+ # Load skills from .clacky/skills/ (project-level, highest priority)
71
+ # @return [Array<Skill>]
72
+ def load_project_clacky_skills
73
+ project_clacky_dir = Pathname.new(@working_dir).join(".clacky", "skills")
74
+ load_skills_from_directory(project_clacky_dir, :project_clacky)
75
+ end
76
+
77
+ # Load skills from nested .claude/skills/ directories (monorepo support)
78
+ # @return [Array<Skill>]
79
+ def load_nested_project_skills
80
+ working_path = Pathname.new(@working_dir)
81
+
82
+ # Find all nested .claude/skills/ directories
83
+ nested_dirs = []
84
+ begin
85
+ Dir.glob("**/.claude/skills/", base: @working_dir).each do |relative_path|
86
+ nested_dirs << working_path.join(relative_path)
87
+ end
88
+ rescue ArgumentError
89
+ # Skip if working_dir contains special characters
90
+ end
91
+
92
+ # Filter out the main project .claude/skills/ (already loaded)
93
+ main_project_skills = working_path.join(".claude", "skills").realpath
94
+
95
+ nested_dirs.each do |dir|
96
+ next if dir.realpath == main_project_skills
97
+
98
+ # Determine the source path for priority resolution
99
+ # Use the parent directory of .claude as the source
100
+ source_path = dir.parent
101
+
102
+ # Determine skill identifier based on relative path from working_dir
103
+ relative_to_working = dir.relative_path_from(working_path).to_s
104
+ skill_name = relative_to_working.gsub(".claude/skills/", "").gsub("/", "-")
105
+
106
+ load_single_skill(dir, source_path, skill_name)
107
+ end
108
+ end
109
+
110
+ # Get all loaded skills
111
+ # @return [Array<Skill>]
112
+ def all_skills
113
+ @skills.values
114
+ end
115
+
116
+ # Get a skill by its identifier
117
+ # @param identifier [String] Skill name or directory name
118
+ # @return [Skill, nil]
119
+ def [](identifier)
120
+ @skills[identifier]
121
+ end
122
+
123
+ # Find a skill by its slash command
124
+ # @param command [String] e.g., "/explain-code"
125
+ # @return [Skill, nil]
126
+ def find_by_command(command)
127
+ @skills_by_command[command]
128
+ end
129
+
130
+ # Get skills that can be invoked by user
131
+ # @return [Array<Skill>]
132
+ def user_invocable_skills
133
+ all_skills.select(&:user_invocable?)
134
+ end
135
+
136
+ # Get the count of loaded skills
137
+ # @return [Integer]
138
+ def count
139
+ @skills.size
140
+ end
141
+
142
+ # Get loading errors
143
+ # @return [Array<String>]
144
+ def errors
145
+ @errors.dup
146
+ end
147
+
148
+ # Get the source location for each loaded skill
149
+ # @return [Hash{String => Symbol}] Map of skill identifier to source location
150
+ def loaded_from
151
+ @loaded_from.dup
152
+ end
153
+
154
+ # Clear loaded skills and errors
155
+ def clear
156
+ @skills.clear
157
+ @skills_by_command.clear
158
+ @errors.clear
159
+ end
160
+
161
+ # Create a new skill directory and SKILL.md file
162
+ # @param name [String] Skill name (will be used for directory and slash command)
163
+ # @param content [String] Skill content (SKILL.md body)
164
+ # @param description [String] Skill description
165
+ # @param location [Symbol] Where to create: :global or :project
166
+ # @return [Skill] The created skill
167
+ def create_skill(name, content, description = nil, location: :global)
168
+ # Validate name
169
+ unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
170
+ raise Clacky::Error,
171
+ "Invalid skill name '#{name}'. Use lowercase letters, numbers, and hyphens only."
172
+ end
173
+
174
+ # Determine directory path
175
+ skill_dir = case location
176
+ when :global
177
+ Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills", name)
178
+ when :project
179
+ Pathname.new(@working_dir).join(".clacky", "skills", name)
180
+ else
181
+ raise Clacky::Error, "Unknown skill location: #{location}"
182
+ end
183
+
184
+ # Create directory if it doesn't exist
185
+ FileUtils.mkdir_p(skill_dir)
186
+
187
+ # Build frontmatter
188
+ frontmatter = { "name" => name, "description" => description }
189
+
190
+ # Write SKILL.md
191
+ skill_content = build_skill_content(frontmatter, content)
192
+ skill_file = skill_dir.join("SKILL.md")
193
+ skill_file.write(skill_content)
194
+
195
+ # Load the newly created skill
196
+ source_type = case location
197
+ when :global then :global_clacky
198
+ when :project then :project_clacky
199
+ else :global_clacky
200
+ end
201
+ load_single_skill(skill_dir, skill_dir, name, source_type)
202
+ end
203
+
204
+ # Delete a skill
205
+ # @param name [String] Skill name
206
+ # @return [Boolean] True if deleted, false if not found
207
+ def delete_skill(name)
208
+ skill = @skills[name]
209
+ return false unless skill
210
+
211
+ # Remove from registry
212
+ @skills.delete(name)
213
+ @skills_by_command.delete(skill.slash_command)
214
+
215
+ # Delete directory
216
+ FileUtils.rm_rf(skill.directory)
217
+
218
+ true
219
+ end
220
+
221
+ private
222
+
223
+ def load_skills_from_directory(dir, source_type)
224
+ return [] unless dir.exist?
225
+
226
+ skills = []
227
+ dir.children.select(&:directory?).each do |skill_dir|
228
+ source_path = case source_type
229
+ when :global_claude
230
+ Pathname.new(ENV.fetch("HOME", "~")).join(".claude")
231
+ when :global_clacky
232
+ Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
233
+ when :project_claude, :project_clacky
234
+ Pathname.new(@working_dir)
235
+ else
236
+ skill_dir
237
+ end
238
+
239
+ skill_name = skill_dir.basename.to_s
240
+ skill = load_single_skill(skill_dir, source_path, skill_name, source_type)
241
+ skills << skill if skill
242
+ end
243
+ skills
244
+ end
245
+
246
+ def load_single_skill(skill_dir, source_path, skill_name, source_type)
247
+ skill = Skill.new(skill_dir, source_path: source_path)
248
+
249
+ # Check for duplicate names
250
+ existing = @skills[skill.identifier]
251
+ if existing
252
+ # Skip duplicate (lower priority)
253
+ existing_source = @loaded_from[skill.identifier]
254
+ priority_order = [:global_claude, :global_clacky, :project_claude, :project_clacky]
255
+
256
+ if priority_order.index(source_type) > priority_order.index(existing_source)
257
+ # Replace with higher priority skill
258
+ @skills.delete(existing.identifier)
259
+ @skills_by_command.delete(existing.slash_command)
260
+ @loaded_from.delete(existing.identifier)
261
+ else
262
+ @errors << "Skipping duplicate skill '#{skill.identifier}' at #{skill_dir}"
263
+ return nil
264
+ end
265
+ end
266
+
267
+ # Register skill
268
+ @skills[skill.identifier] = skill
269
+ @skills_by_command[skill.slash_command] = skill
270
+ @loaded_from[skill.identifier] = source_type
271
+
272
+ skill
273
+ rescue Clacky::Error => e
274
+ @errors << "Error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
275
+ nil
276
+ rescue StandardError => e
277
+ @errors << "Unexpected error loading skill '#{skill_name}' from #{skill_dir}: #{e.message}"
278
+ nil
279
+ end
280
+
281
+ def build_skill_content(frontmatter, content)
282
+ yaml = frontmatter
283
+ .reject { |_, v| v.nil? || v.to_s.empty? }
284
+ .to_yaml(line_width: 80)
285
+
286
+ "---\n#{yaml}---\n\n#{content}"
287
+ end
288
+
289
+ # Load default skills from gem's default_skills directory
290
+ private def load_default_skills
291
+ # Get the gem's lib directory
292
+ gem_lib_dir = File.expand_path("../", __dir__)
293
+ default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
294
+
295
+ return unless Dir.exist?(default_skills_dir)
296
+
297
+ # Load each skill directory
298
+ Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
299
+ skill_dir = File.dirname(skill_file)
300
+ skill_name = File.basename(skill_dir)
301
+
302
+ begin
303
+ skill = Skill.new(Pathname.new(skill_dir))
304
+
305
+ # Check for duplicates (higher priority skills override)
306
+ if @skills.key?(skill.identifier)
307
+ next # Skip if already loaded from higher priority location
308
+ end
309
+
310
+ # Register skill
311
+ @skills[skill.identifier] = skill
312
+ @skills_by_command[skill.slash_command] = skill
313
+ @loaded_from[skill.identifier] = :default
314
+ rescue StandardError => e
315
+ @errors << "Failed to load default skill #{skill_name}: #{e.message}"
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end