Class: Clacky::SkillLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/skill_loader.rb

Overview

Loader and registry for skills. Discovers skills from multiple locations and provides lookup functionality.

Constant Summary collapse

LOCATIONS =

Skill discovery locations (in priority order: lower index = lower priority)

[
  :default,            # gem's built-in default skills (lowest priority)
  :global_claude,      # ~/.claude/skills/ (compatibility)
  :global_clacky,      # ~/.clacky/skills/
  :project_claude,     # .claude/skills/ (project-level compatibility)
  :project_clacky,     # .clacky/skills/ (highest priority among plain skills)
  :brand               # ~/.clacky/brand_skills/ (encrypted, license-gated)
].freeze
MAX_SKILLS =

Maximum number of skills that can be loaded in total. When exceeded, a warning is recorded in @errors and extra skills are dropped. This prevents runaway memory usage and excessively long system prompts.

50

Instance Method Summary collapse

Constructor Details

#initialize(working_dir:, brand_config:) ⇒ SkillLoader

Initialize the skill loader and automatically load all skills

Parameters:

  • working_dir (String, nil)

    Current working directory for project-level discovery. When nil, project-level skills (.clacky/skills/, .claude/skills/) are not loaded, making the loader project-agnostic (used by WebUI server).

  • brand_config (Clacky::BrandConfig, nil)

    Optional brand config used to decrypt brand skills. When nil, brand skills are silently skipped.



32
33
34
35
36
37
38
39
40
41
# File 'lib/clacky/skill_loader.rb', line 32

def initialize(working_dir:, brand_config:)
  @working_dir  = working_dir
  @brand_config = brand_config
  @skills = {}            # Map identifier -> Skill
  @skills_by_command = {} # Map slash_command -> Skill
  @errors = []            # Store loading errors
  @loaded_from = {}       # Track which location each skill was loaded from

  load_all
end

Instance Method Details

#[](identifier) ⇒ Skill?

Get a skill by its identifier

Parameters:

  • identifier (String)

    Skill name or directory name

Returns:



198
199
200
# File 'lib/clacky/skill_loader.rb', line 198

def [](identifier)
  @skills[identifier]
end

#all_skillsArray<Skill>

Get all loaded skills

Returns:



191
192
193
# File 'lib/clacky/skill_loader.rb', line 191

def all_skills
  @skills.values
end

#build_skill_content(frontmatter, content) ⇒ Object



446
447
448
449
450
451
452
# File 'lib/clacky/skill_loader.rb', line 446

def build_skill_content(frontmatter, content)
  yaml = frontmatter
    .reject { |_, v| v.nil? || v.to_s.empty? }
    .to_yaml(line_width: 80)

  "---\n#{yaml}---\n\n#{content}"
end

#clearObject

Clear loaded skills and errors



241
242
243
244
245
246
# File 'lib/clacky/skill_loader.rb', line 241

def clear
  @skills.clear
  @skills_by_command.clear
  @errors.clear
  @shadowed_by_local = {}
end

#countInteger

Get the count of loaded skills

Returns:

  • (Integer)


224
225
226
# File 'lib/clacky/skill_loader.rb', line 224

def count
  @skills.size
end

#create_skill(name, content, description = nil, location: :global) ⇒ Skill

Create a new skill directory and SKILL.md file

Parameters:

  • name (String)

    Skill name (will be used for directory and slash command)

  • content (String)

    Skill content (SKILL.md body)

  • description (String) (defaults to: nil)

    Skill description

  • location (Symbol) (defaults to: :global)

    Where to create: :global or :project

Returns:

  • (Skill)

    The created skill



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/clacky/skill_loader.rb', line 254

def create_skill(name, content, description = nil, location: :global)
  # Validate name
  unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
    raise Clacky::AgentError,
      "Invalid skill name '#{name}'. Use lowercase letters, numbers, and hyphens only."
  end

  # Determine directory path
  skill_dir = case location
  when :global
    Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills", name)
  when :project
    Pathname.new(@working_dir).join(".clacky", "skills", name)
  else
    raise Clacky::AgentError, "Unknown skill location: #{location}"
  end

  # Create directory if it doesn't exist
  FileUtils.mkdir_p(skill_dir)

  # Build frontmatter
  frontmatter = { "name" => name, "description" => description }

  # Write SKILL.md
  skill_content = build_skill_content(frontmatter, content)
  skill_file = skill_dir.join("SKILL.md")
  skill_file.write(skill_content)

  # Load the newly created skill
  source_type = case location
  when :global then :global_clacky
  when :project then :project_clacky
  else :global_clacky
  end
  load_single_skill(skill_dir, skill_dir, name, source_type)
end

#delete_skill(name) ⇒ Boolean

Delete a skill

Parameters:

  • name (String)

    Skill name

Returns:

  • (Boolean)

    True if deleted, false if not found



322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/clacky/skill_loader.rb', line 322

def delete_skill(name)
  skill = @skills[name]
  return false unless skill

  # Remove from registry
  @skills.delete(name)
  @skills_by_command.delete(skill.slash_command)

  # Delete directory
  FileUtils.rm_rf(skill.directory)

  true
end

#errorsArray<String>

Get loading errors

Returns:

  • (Array<String>)


230
231
232
# File 'lib/clacky/skill_loader.rb', line 230

def errors
  @errors.dup
end

#find_by_command(command) ⇒ Skill?

Find a skill by its slash command

Parameters:

  • command (String)

    e.g., “/explain-code”

Returns:



205
206
207
# File 'lib/clacky/skill_loader.rb', line 205

def find_by_command(command)
  @skills_by_command[command]
end

#find_by_name(name) ⇒ Skill?

Find a skill by its name (identifier)

Parameters:

  • name (String)

    Skill identifier (e.g., “code-explorer”, “pptx”)

Returns:



212
213
214
# File 'lib/clacky/skill_loader.rb', line 212

def find_by_name(name)
  @skills[name]
end

#load_allArray<Skill>

Load all skills from configured locations Clears previously loaded skills before loading to ensure idempotency

Returns:

  • (Array<Skill>)

    Loaded skills



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/clacky/skill_loader.rb', line 46

def load_all
  # Always refresh brand_config from disk so newly installed/activated brand
  # skills are visible even if this SkillLoader was created before the change.
  @brand_config = Clacky::BrandConfig.load

  # Clear existing skills to ensure idempotent reloading
  clear

  load_default_skills
  load_global_claude_skills
  load_global_clacky_skills
  
  # Only load project-level skills when working_dir is explicitly provided.
  # When nil (e.g. WebUI server mode), skip project skills to keep the loader
  # project-agnostic and only expose global skills.
  if @working_dir
    load_project_claude_skills
    load_project_clacky_skills
  end
  
  load_brand_skills

  all_skills
end

#load_brand_skillsArray<Skill>

Load brand skills from ~/.clacky/brand_skills/ Supports both encrypted (SKILL.md.enc) and plain (SKILL.md) brand skills. Encrypted skills require a BrandConfig with an activated license to decrypt.

Local plain skills (global_clacky / project_clacky) shadow same-named brand skills — the local version takes priority. This is intentional: creators who have a local SKILL.md for a skill they also publish should always run their own (editable, up-to-date) copy rather than the encrypted distribution copy.

Returns:



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/clacky/skill_loader.rb', line 80

def load_brand_skills
  return [] unless @brand_config&.activated?
  return [] if ENV["CLACKY_TEST"] == "1"

  # Use brand_config#brand_skills_dir so the path respects CONFIG_DIR,
  # which is important for test isolation via stub_const.
  brand_skills_dir = Pathname.new(@brand_config.brand_skills_dir)
  return [] unless brand_skills_dir.exist?

  # Read brand_skills.json once — provides cached name/description so we
  # can skip decrypting each skill's .enc file just to read its frontmatter.
   = @brand_config.installed_brand_skills

  skills = []
  brand_skills_dir.children.select(&:directory?).each do |skill_dir|
    # Support both encrypted (.enc) and plain brand skills
    encrypted = skill_dir.join("SKILL.md.enc").exist?
    plain     = skill_dir.join("SKILL.md").exist?
    next unless encrypted || plain

    skill_name = skill_dir.basename.to_s

    # Skip brand skill when a local plain skill with the same name is already
    # loaded (global_clacky or project_clacky). The local copy shadows it.
    if @skills[skill_name] && %i[global_clacky project_clacky project_claude global_claude].include?(@loaded_from[skill_name])
      @shadowed_by_local ||= {}
      @shadowed_by_local[skill_name] = @loaded_from[skill_name]
      next
    end

    # Pass cached_metadata for all brand skills (encrypted or plain).
    # brand_skills.json stores sanitized slugs, so this prevents sanitize_frontmatter
    # from flagging human-readable names like "Antique Identifier" as invalid.
     = [skill_name]
    skill = load_single_brand_skill(skill_dir, skill_name, encrypted: encrypted, cached_metadata: )
    skills << skill if skill
  end
  skills
end

#load_global_clacky_skillsArray<Skill>

Load skills from ~/.clacky/skills/ (user global)

Returns:



137
138
139
140
# File 'lib/clacky/skill_loader.rb', line 137

def load_global_clacky_skills
  global_clacky_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".clacky", "skills")
  load_skills_from_directory(global_clacky_dir, :global_clacky)
end

#load_global_claude_skillsArray<Skill>

Load skills from ~/.claude/skills/ (lowest priority, compatibility)

Returns:



130
131
132
133
# File 'lib/clacky/skill_loader.rb', line 130

def load_global_claude_skills
  global_claude_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".claude", "skills")
  load_skills_from_directory(global_claude_dir, :global_claude)
end

#load_nested_project_skillsArray<Skill>

Load skills from nested .claude/skills/ directories (monorepo support)

Returns:



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/clacky/skill_loader.rb', line 158

def load_nested_project_skills
  working_path = Pathname.new(@working_dir)

  # Find all nested .claude/skills/ directories
  nested_dirs = []
  begin
    Dir.glob("**/.claude/skills/", base: @working_dir).each do |relative_path|
      nested_dirs << working_path.join(relative_path)
    end
  rescue ArgumentError
    # Skip if working_dir contains special characters
  end

  # Filter out the main project .claude/skills/ (already loaded)
  main_project_skills = working_path.join(".claude", "skills").realpath

  nested_dirs.each do |dir|
    next if dir.realpath == main_project_skills

    # Determine the source path for priority resolution
    # Use the parent directory of .claude as the source
    source_path = dir.parent

    # Determine skill identifier based on relative path from working_dir
    relative_to_working = dir.relative_path_from(working_path).to_s
    skill_name = relative_to_working.gsub(".claude/skills/", "").gsub("/", "-")

    load_single_skill(dir, source_path, skill_name)
  end
end

#load_project_clacky_skillsArray<Skill>

Load skills from .clacky/skills/ (project-level, highest priority)

Returns:



151
152
153
154
# File 'lib/clacky/skill_loader.rb', line 151

def load_project_clacky_skills
  project_clacky_dir = Pathname.new(@working_dir).join(".clacky", "skills")
  load_skills_from_directory(project_clacky_dir, :project_clacky)
end

#load_project_claude_skillsArray<Skill>

Load skills from .claude/skills/ (project-level compatibility)

Returns:



144
145
146
147
# File 'lib/clacky/skill_loader.rb', line 144

def load_project_claude_skills
  project_claude_dir = Pathname.new(@working_dir).join(".claude", "skills")
  load_skills_from_directory(project_claude_dir, :project_claude)
end

#load_skills_from_directory(dir, source_type) ⇒ Object



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/clacky/skill_loader.rb', line 337

def load_skills_from_directory(dir, source_type)
  return [] unless dir.exist?

  skills = []
  dir.children.select(&:directory?).each do |skill_dir|
    source_path = case source_type
    when :global_claude
      Pathname.new(ENV.fetch("HOME", "~")).join(".claude")
    when :global_clacky
      Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
    when :project_claude, :project_clacky
      Pathname.new(@working_dir)
    else
      skill_dir
    end

    skill_name = skill_dir.basename.to_s
    skill = load_single_skill(skill_dir, source_path, skill_name, source_type)
    skills << skill if skill
  end
  skills
end

#loaded_fromHash{String => Symbol}

Get the source location for each loaded skill

Returns:

  • (Hash{String => Symbol})

    Map of skill identifier to source location



236
237
238
# File 'lib/clacky/skill_loader.rb', line 236

def loaded_from
  @loaded_from.dup
end

#shadowed_by_localHash{String => Symbol}

Returns a hash of skill names that are shadowed by a local plain skill. e.g. { “commit” => :global_clacky } means brand “commit” is overridden by the user’s own ~/.clacky/skills/commit/ copy.

Returns:

  • (Hash{String => Symbol})


124
125
126
# File 'lib/clacky/skill_loader.rb', line 124

def shadowed_by_local
  @shadowed_by_local || {}
end

#toggle_skill(name, enabled:) ⇒ Skill

Toggle a skill’s disable-model-invocation field in its SKILL.md. System skills (source: :default) cannot be toggled — raises AgentError.

Parameters:

  • name (String)

    Skill identifier

  • enabled (Boolean)

    true = enable, false = disable

Returns:

  • (Skill)

    The reloaded skill

Raises:



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/clacky/skill_loader.rb', line 296

def toggle_skill(name, enabled:)
  skill = @skills[name]
  raise Clacky::AgentError, "Skill not found: #{name}" unless skill
  raise Clacky::AgentError, "Cannot toggle system skill: #{name}" if @loaded_from[name] == :default

  skill_file = skill.directory.join("SKILL.md")
  fm = (skill.frontmatter || {}).dup

  if enabled
    fm["disable-model-invocation"] = false
  else
    fm["disable-model-invocation"] = true
  end

  skill_file.write(build_skill_content(fm, skill.content))

  # Reload into registry
  reloaded = Skill.new(skill.directory, source_path: skill.source_path)
  @skills[reloaded.identifier] = reloaded
  @skills_by_command[reloaded.slash_command] = reloaded
  reloaded
end

#user_invocable_skillsArray<Skill>

Get skills that can be invoked by user

Returns:



218
219
220
# File 'lib/clacky/skill_loader.rb', line 218

def user_invocable_skills
  all_skills.select(&:user_invocable?)
end