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_clacky,      # ~/.clacky/skills/
  :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/) 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.



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

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:



147
148
149
# File 'lib/clacky/skill_loader.rb', line 147

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

#all_skillsArray<Skill>

Get all loaded skills

Returns:



140
141
142
# File 'lib/clacky/skill_loader.rb', line 140

def all_skills
  @skills.values
end

#build_skill_content(frontmatter, content) ⇒ Object



393
394
395
396
397
398
399
# File 'lib/clacky/skill_loader.rb', line 393

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



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

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

#countInteger

Get the count of loaded skills

Returns:

  • (Integer)


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

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



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/clacky/skill_loader.rb', line 203

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



271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/clacky/skill_loader.rb', line 271

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


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

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:



154
155
156
# File 'lib/clacky/skill_loader.rb', line 154

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:



161
162
163
# File 'lib/clacky/skill_loader.rb', line 161

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



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

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



76
77
78
79
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
# File 'lib/clacky/skill_loader.rb', line 76

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



126
127
128
129
# File 'lib/clacky/skill_loader.rb', line 126

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:



133
134
135
136
# File 'lib/clacky/skill_loader.rb', line 133

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



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

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_clacky
      Pathname.new(ENV.fetch("HOME", "~")).join(".clacky")
    when :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



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

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


120
121
122
# File 'lib/clacky/skill_loader.rb', line 120

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:



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/clacky/skill_loader.rb', line 245

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:



167
168
169
# File 'lib/clacky/skill_loader.rb', line 167

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