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



441
442
443
444
445
446
447
# File 'lib/clacky/skill_loader.rb', line 441

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



232
233
234
235
236
237
238
# File 'lib/clacky/skill_loader.rb', line 232

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)


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

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



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
277
278
279
280
281
# File 'lib/clacky/skill_loader.rb', line 246

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



314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/clacky/skill_loader.rb', line 314

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


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

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



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
356
357
358
359
360
# File 'lib/clacky/skill_loader.rb', line 329

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



227
228
229
# File 'lib/clacky/skill_loader.rb', line 227

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:



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/clacky/skill_loader.rb', line 288

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_skills(profile = nil) ⇒ Array<Skill>

Get skills that can be invoked by user, optionally filtered by an agent profile (profile-level disabled_skills + the skill's own agent: declaration). Centralizes profile filtering so consumers never have to assemble their own select chains.

Parameters:

Returns:



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

def user_invocable_skills(profile = nil)
  skills = all_skills.select(&:user_invocable?)
  profile ? skills.select { |s| profile.skill_allowed?(s) } : skills
end