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)
  :extension,          # contributes.skills declared in ext.yml containers
  :global_clacky,      # ~/.clacky/skills/
  :project_clacky,     # .clacky/skills/ (highest priority among plain skills)
  :brand               # ~/.clacky/brand_skills/ (encrypted, license-gated)
].freeze

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/) 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
42
# 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
  @virtual_skill_providers = []  # Objects responding to #virtual_skills (e.g. Mcp::Registry)

  load_all
end

Instance Method Details

#[](identifier) ⇒ Skill?

Get a skill by its identifier

Parameters:

  • identifier (String)

    Skill name or directory name

Returns:



172
173
174
# File 'lib/clacky/skill_loader.rb', line 172

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

#all_skillsArray<Skill>

Get all loaded skills (including virtual skills supplied by attached providers)

Returns:



158
159
160
161
162
163
164
165
166
167
# File 'lib/clacky/skill_loader.rb', line 158

def all_skills
  base = @skills.values
  virtuals = collect_virtual_skills
  return base if virtuals.empty?

  # Real skills always shadow virtuals when names collide — protects against
  # an MCP server accidentally named after a real skill.
  seen = base.map(&:identifier).to_set
  base + virtuals.reject { |s| seen.include?(s.identifier) }
end

#attach_virtual_skill_provider(provider) ⇒ Object

Register an object that supplies virtual (in-memory) skills. The object must respond to #virtual_skills returning an Array. Virtual skills are merged on every #all_skills / #find_by_name call so a registry can add or remove servers at runtime without forcing a full reload.

Parameters:

  • provider (#virtual_skills)


49
50
51
52
# File 'lib/clacky/skill_loader.rb', line 49

def attach_virtual_skill_provider(provider)
  return unless provider.respond_to?(:virtual_skills)
  @virtual_skill_providers << provider unless @virtual_skill_providers.include?(provider)
end

#build_skill_content(frontmatter, content) ⇒ Object



436
437
438
439
440
441
442
# File 'lib/clacky/skill_loader.rb', line 436

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



227
228
229
230
231
232
233
# File 'lib/clacky/skill_loader.rb', line 227

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

#countInteger

Get the count of loaded skills

Returns:

  • (Integer)


210
211
212
# File 'lib/clacky/skill_loader.rb', line 210

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



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/clacky/skill_loader.rb', line 241

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



309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/clacky/skill_loader.rb', line 309

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>)


216
217
218
# File 'lib/clacky/skill_loader.rb', line 216

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:



179
180
181
# File 'lib/clacky/skill_loader.rb', line 179

def find_by_command(command)
  @skills_by_command[command] || collect_virtual_skills.find { |s| s.slash_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:



186
187
188
# File 'lib/clacky/skill_loader.rb', line 186

def find_by_name(name)
  @skills[name] || virtual_skill(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



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/clacky/skill_loader.rb', line 57

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_extension_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_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:



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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/clacky/skill_loader.rb', line 90

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

  activated = @brand_config.activated?

  # 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

    next if encrypted && !activated

    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].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:



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

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_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_skills_from_directory(dir, source_type) ⇒ Object



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/clacky/skill_loader.rb', line 324

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

  source_path = case source_type
  when :global_clacky
    Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
  when :project_clacky
    Pathname.new(@working_dir)
  else
    dir
  end

  skills = []
  dir.children.select(&:directory?).each do |entry|
    if entry.join("SKILL.md").exist?
      # Direct skill directory
      skill = load_single_skill(entry, source_path, entry.basename.to_s, source_type)
      skills << skill if skill
    else
      # Treat as a category directory — scan one level deeper for skills.
      # This allows grouping skills under ~/.clacky/skills/<category>/<skill>/SKILL.md
      # (e.g. openclaw-imports/my-skill/SKILL.md) without changing the loader contract.
      entry.children.select(&:directory?).each do |skill_dir|
        next unless skill_dir.join("SKILL.md").exist?

        skill = load_single_skill(skill_dir, source_path, skill_dir.basename.to_s, source_type)
        skills << skill if skill
      end
    end
  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



222
223
224
# File 'lib/clacky/skill_loader.rb', line 222

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})


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

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:



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/clacky/skill_loader.rb', line 283

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:



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

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