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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +557 -122
- data/lib/clacky/cli.rb +431 -3
- 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/file_reader.rb +245 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +53 -17
- data/lib/clacky/tools/shell.rb +109 -5
- 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 +279 -141
- data/lib/clacky/ui2/layout_manager.rb +147 -67
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +3 -3
- data/lib/clacky/ui2/themes/minimal_theme.rb +3 -3
- data/lib/clacky/ui2/ui_controller.rb +80 -29
- data/lib/clacky/ui2.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +7 -2
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/utils/file_processor.rb +201 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +249 -0
- data/scripts/uninstall.sh +146 -0
- metadata +10 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
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::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
|