Class: Clacky::Skill

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

Overview

Represents a skill with its metadata and content. A skill is defined by a SKILL.md file with optional YAML frontmatter.

Constant Summary collapse

FRONTMATTER_FIELDS =

Frontmatter fields that are recognized

%w[
  name
  name_zh
  description
  description_zh
  disable-model-invocation
  user-invocable
  allowed-tools
  context
  agent
  argument-hint
  hooks
  fork_agent
  model
  forbidden_tools
  auto_summarize

].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(directory, source_path: nil, brand_skill: false, brand_config: nil, cached_metadata: nil) ⇒ Skill

Returns a new instance of Skill.

Parameters:

  • directory (Pathname, String)

    Path to the skill directory

  • source_path (Pathname, String, nil) (defaults to: nil)

    Optional source path for priority resolution

  • brand_skill (Boolean) (defaults to: false)

    When true, content is loaded from an encrypted SKILL.md.enc file via BrandConfig#decrypt_skill_content at invoke time. The on-disk file is never read as plain text.

  • brand_config (BrandConfig, nil) (defaults to: nil)

    Required when brand_skill is true.

  • cached_metadata (Hash, nil) (defaults to: nil)

    Pre-loaded name/description from brand_skills.json. When provided for brand skills, avoids decrypting the file at load time. Expected keys: “name”, “description”.



81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/clacky/skill.rb', line 81

def initialize(directory, source_path: nil, brand_skill: false, brand_config: nil, cached_metadata: nil)
  @directory       = Pathname.new(directory)
  @source_path     = source_path ? Pathname.new(source_path) : @directory
  @brand_skill     = brand_skill
  @brand_config    = brand_config
  @cached_metadata = 
  @encrypted       = false
  @warnings        = []
  @invalid         = false
  @invalid_reason  = nil

  load_skill
end

Instance Attribute Details

#agent_typeObject (readonly)

Returns the value of attribute agent_type.



35
36
37
# File 'lib/clacky/skill.rb', line 35

def agent_type
  @agent_type
end

#allowed_toolsObject (readonly)

Returns the value of attribute allowed_tools.



35
36
37
# File 'lib/clacky/skill.rb', line 35

def allowed_tools
  @allowed_tools
end

#argument_hintObject (readonly)

Returns the value of attribute argument_hint.



35
36
37
# File 'lib/clacky/skill.rb', line 35

def argument_hint
  @argument_hint
end

#auto_summarizeObject (readonly)

Returns the value of attribute auto_summarize.



36
37
38
# File 'lib/clacky/skill.rb', line 36

def auto_summarize
  @auto_summarize
end

#brand_configObject (readonly)

Returns the value of attribute brand_config.



37
38
39
# File 'lib/clacky/skill.rb', line 37

def brand_config
  @brand_config
end

#brand_skillObject (readonly)

Returns the value of attribute brand_skill.



37
38
39
# File 'lib/clacky/skill.rb', line 37

def brand_skill
  @brand_skill
end

#contentObject (readonly)

Returns the value of attribute content.



33
34
35
# File 'lib/clacky/skill.rb', line 33

def content
  @content
end

#contextObject (readonly)

Returns the value of attribute context.



35
36
37
# File 'lib/clacky/skill.rb', line 35

def context
  @context
end

#descriptionObject (readonly)

Returns the value of attribute description.



33
34
35
# File 'lib/clacky/skill.rb', line 33

def description
  @description
end

#description_zhObject (readonly)

Returns the value of attribute description_zh.



33
34
35
# File 'lib/clacky/skill.rb', line 33

def description_zh
  @description_zh
end

#directoryObject (readonly)

Returns the value of attribute directory.



32
33
34
# File 'lib/clacky/skill.rb', line 32

def directory
  @directory
end

#disable_model_invocationObject (readonly)

Returns the value of attribute disable_model_invocation.



34
35
36
# File 'lib/clacky/skill.rb', line 34

def disable_model_invocation
  @disable_model_invocation
end

#forbidden_toolsObject (readonly)

Returns the value of attribute forbidden_tools.



36
37
38
# File 'lib/clacky/skill.rb', line 36

def forbidden_tools
  @forbidden_tools
end

#fork_agentObject (readonly)

Returns the value of attribute fork_agent.



36
37
38
# File 'lib/clacky/skill.rb', line 36

def fork_agent
  @fork_agent
end

#frontmatterObject (readonly)

Returns the value of attribute frontmatter.



32
33
34
# File 'lib/clacky/skill.rb', line 32

def frontmatter
  @frontmatter
end

#hooksObject (readonly)

Returns the value of attribute hooks.



35
36
37
# File 'lib/clacky/skill.rb', line 35

def hooks
  @hooks
end

#invalidBoolean (readonly)

When true the skill has an unrecoverable metadata problem (e.g. directory name is itself an invalid slug). The skill is still registered so it can be shown in the UI (greyed-out with an explanation), but it is excluded from the system prompt and slash command dispatch.

Returns:

  • (Boolean)


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

def invalid
  @invalid
end

#invalid_reasonString? (readonly)

Human-readable reason why the skill is invalid (nil when valid).

Returns:

  • (String, nil)


53
54
55
# File 'lib/clacky/skill.rb', line 53

def invalid_reason
  @invalid_reason
end

#modelObject (readonly)

Returns the value of attribute model.



36
37
38
# File 'lib/clacky/skill.rb', line 36

def model
  @model
end

#nameObject (readonly)

Returns the value of attribute name.



33
34
35
# File 'lib/clacky/skill.rb', line 33

def name
  @name
end

#name_zhObject (readonly)

Returns the value of attribute name_zh.



33
34
35
# File 'lib/clacky/skill.rb', line 33

def name_zh
  @name_zh
end

#source_pathObject (readonly)

Returns the value of attribute source_path.



32
33
34
# File 'lib/clacky/skill.rb', line 32

def source_path
  @source_path
end

#user_invocableObject (readonly)

Returns the value of attribute user_invocable.



34
35
36
# File 'lib/clacky/skill.rb', line 34

def user_invocable
  @user_invocable
end

#warningsArray<String> (readonly)

Warnings accumulated during load (e.g. name was invalid and fell back to dir name). Non-empty means the skill loaded but something was auto-corrected.

Returns:

  • (Array<String>)


42
43
44
# File 'lib/clacky/skill.rb', line 42

def warnings
  @warnings
end

Instance Method Details

#agents_scopeArray<String>

Get the agent scope for this skill. Parsed from the ‘agent:` frontmatter field. Returns an array of agent names, or [“all”] if not specified.

Returns:

  • (Array<String>)


141
142
143
144
145
146
147
148
149
# File 'lib/clacky/skill.rb', line 141

def agents_scope
  return ["all"] if @agent_type.nil?

  case @agent_type
  when "all" then ["all"]
  when Array then @agent_type.map(&:to_s)
  else [@agent_type.to_s]
  end
end

#allowed_for_agent?(profile_name) ⇒ Boolean

Check if this skill is allowed for the given agent profile name. Returns true when the skill’s ‘agent:` field is “all” (default) or includes the given profile name.

Parameters:

  • profile_name (String)

    e.g. “coding”, “general”

Returns:

  • (Boolean)


156
157
158
159
# File 'lib/clacky/skill.rb', line 156

def allowed_for_agent?(profile_name)
  scope = agents_scope
  scope.include?("all") || scope.include?(profile_name.to_s)
end

#auto_summarize?Boolean

Check if subagent should auto-summarize results

Returns:

  • (Boolean)


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

def auto_summarize?
  @auto_summarize != false
end

#context_descriptionString

Get the description for context loading Returns the description from frontmatter, or first paragraph of content

Returns:

  • (String)


170
171
172
# File 'lib/clacky/skill.rb', line 170

def context_description
  @description || extract_first_paragraph
end

#decrypted_contentString

Decrypt and return the raw skill content.

For brand skills the content lives in SKILL.md.enc and is decrypted in memory via BrandConfig#decrypt_skill_content — it is never written to disk as plain text.

For regular skills this is identical to reading @content directly.

Returns:

  • (String)

    Plain-text SKILL.md body (without frontmatter)

Raises:

  • (RuntimeError)

    If the brand_config is missing or decryption fails



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/clacky/skill.rb', line 316

def decrypted_content
  return @content unless encrypted?

  raise "brand_config is required to decrypt brand skill '#{identifier}'" unless @brand_config

  enc_path = @directory.join("SKILL.md.enc").to_s
  raw = @brand_config.decrypt_skill_content(enc_path)

  # Strip frontmatter from the decrypted bytes so callers get only the body
  if raw.start_with?("---")
    fm_match = raw.match(/\A---\n.*?\n---\n*/m)
    fm_match ? raw[fm_match.end(0)..].strip : raw
  else
    raw
  end
end

#disabled?Boolean

Check if this skill is disabled (disable-model-invocation: true)

Returns:

  • (Boolean)


57
58
59
# File 'lib/clacky/skill.rb', line 57

def disabled?
  @disable_model_invocation == true
end

#encrypted?Boolean

Returns true when this skill’s content is stored encrypted on disk.

Returns:

  • (Boolean)


302
303
304
# File 'lib/clacky/skill.rb', line 302

def encrypted?
  @encrypted == true
end

#expand_templates(content, context) ⇒ String

Expand <%= key %> template placeholders via ERB. context is a Hash<String|Symbol, String|Proc> — Proc values are called lazily. Unknown bindings raise no error; ERB just leaves them blank (nil.to_s).

Parameters:

  • content (String)
  • context (Hash)

Returns:

  • (String)


535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# File 'lib/clacky/skill.rb', line 535

def expand_templates(content, context)
  # Shell-style ${VAR} substitution from ENV — handles variables like
  # ${CLACKY_SERVER_PORT}, ${CLACKY_SERVER_HOST} used in SKILL.md files.
  # Unknown variables are left as-is (no substitution).
  content = content.gsub(/\$\{([A-Z_][A-Z0-9_]*)\}/) { ENV[$1] || $& }

  return content if context.nil? || context.empty?

  # Build a lightweight binding that exposes each context key as a local method
  scope = Object.new
  context.each do |key, value|
    resolved = value.respond_to?(:call) ? value.call : value
    scope.define_singleton_method(key.to_s) { resolved.to_s }
    scope.define_singleton_method(key.to_sym) { resolved.to_s }
  end

  require "erb"
  ERB.new(content, trim_mode: "-").result(scope.instance_eval { binding })
rescue => e
  # If ERB fails (e.g. unknown variable), return content as-is
  content
end

#extract_first_paragraphObject



525
526
527
# File 'lib/clacky/skill.rb', line 525

def extract_first_paragraph
  @content.split(/\n\n/).first.to_s
end

#forbidden_tools_listArray<String>

Get the list of forbidden tools for the subagent

Returns:

  • (Array<String>)


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

def forbidden_tools_list
  @forbidden_tools || []
end

#fork_agent?Boolean

Check if this skill should fork a subagent

Returns:

  • (Boolean)


115
116
117
# File 'lib/clacky/skill.rb', line 115

def fork_agent?
  @fork_agent == true
end

#has_supporting_files?Boolean

Check if this skill has any supporting files/scripts beyond SKILL.md. For encrypted skills, checks for .enc files that are not SKILL.md.enc or the manifest. For plain skills, checks for any files other than SKILL.md.

Returns:

  • (Boolean)


200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/clacky/skill.rb', line 200

def has_supporting_files?
  return false unless @directory.exist?

  if encrypted?
    Dir.glob(File.join(@directory.to_s, "**", "*.enc")).any? do |f|
      base = File.basename(f)
      base != "SKILL.md.enc" && base != "MANIFEST.enc.json"
    end
  else
    supporting_files.any?
  end
end

#has_warnings?Boolean

Returns:

  • (Boolean)


67
68
69
# File 'lib/clacky/skill.rb', line 67

def has_warnings?
  @warnings&.any?
end

#identifierString

Get the skill identifier (uses name from frontmatter or directory name)

Returns:

  • (String)


97
98
99
# File 'lib/clacky/skill.rb', line 97

def identifier
  @name || @directory.basename.to_s
end

#invalid?Boolean

Returns:

  • (Boolean)


62
63
64
# File 'lib/clacky/skill.rb', line 62

def invalid?
  @invalid == true
end

#load_skillObject



334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/clacky/skill.rb', line 334

def load_skill
  if @brand_skill
    load_brand_skill
  else
    load_plain_skill
  end

  # Set defaults
  @user_invocable = true if @user_invocable.nil?
  @disable_model_invocation = false if @disable_model_invocation.nil?

  sanitize_frontmatter
end

#model_invocation_allowed?Boolean

Check if skill can be automatically invoked by the model

Returns:

  • (Boolean)


109
110
111
# File 'lib/clacky/skill.rb', line 109

def model_invocation_allowed?
  !@disable_model_invocation
end

#parse_frontmatter(content) ⇒ Object

Parse content that may or may not have YAML frontmatter. This method is lenient: bad frontmatter format or YAML errors just produce warnings rather than raising — the raw text becomes the skill content instead.



422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/clacky/skill.rb', line 422

def parse_frontmatter(content)
  frontmatter_match = content.match(/\A---\n(.*?)\n---[ \t]*\n?/m)

  if frontmatter_match
    yaml_content = frontmatter_match[1]

    begin
      @frontmatter = YAML.safe_load(yaml_content) || {}
    rescue Psych::Exception => e
      # Bad YAML — treat whole file as plain content, record warning
      @warnings << "Could not parse YAML frontmatter: #{e.message}. Treating file as plain content."
      @frontmatter = {}
      @content = content
      extract_fields_from_frontmatter
      return
    end

    @content = content[frontmatter_match.end(0)..-1].to_s.strip
  else
    # No valid frontmatter block — treat everything as content (no YAML at all,
    # or an unclosed --- block).  We record a warning only if it looked like the
    # author tried to write frontmatter but made a mistake.
    if content.start_with?("---")
      @warnings << "Frontmatter block started with '---' but no closing '---' was found. Treating file as plain content."
    end
    @frontmatter = {}
    @content = content
  end

  extract_fields_from_frontmatter
end

#process_content(shell_output: {}, template_context: {}, script_dir: nil) ⇒ String

Process the skill content with argument substitution and template expansion

Parameters:

  • arguments (String)

    Arguments passed to the skill

  • shell_output (Hash) (defaults to: {})

    Shell command outputs for !command` syntax (optional)

  • template_context (Hash) (defaults to: {})

    Named values for <%= key %> template expansion (optional)

  • script_dir (String, nil) (defaults to: nil)

    When provided, the Supporting Files block uses this directory instead of @directory. Used by SkillManager to point the LLM at the tmpdir containing decrypted scripts for encrypted brand skills.

Returns:

  • (String)

    Processed content



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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
# File 'lib/clacky/skill.rb', line 223

def process_content(shell_output: {}, template_context: {}, script_dir: nil)
  # For brand skills, decrypt content in memory at invoke time.
  # For plain skills, use the already-loaded @content.
  processed_content = decrypted_content.dup

  # Expand <%= key %> templates
  processed_content = expand_templates(processed_content, template_context)

  # Replace shell command outputs
  shell_output.each do |command, output|
    placeholder = "!`#{command}`"
    processed_content.gsub!(placeholder, output.to_s)
  end

  # Append supporting files list if any exist.
  # When script_dir is given (encrypted skill with decrypted tmpdir), use that
  # directory for both the path label and the file listing so the LLM sees real
  # paths it can actually execute.
  effective_dir = script_dir || @directory.to_s
  effective_files = if script_dir && Dir.exist?(script_dir)
    gitignore_path = Utils::FileIgnoreHelper.find_gitignore(script_dir)
    gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
    Dir.glob(File.join(script_dir, "**", "*"))
       .reject { |f| File.directory?(f) }
       .reject { |f| Utils::FileIgnoreHelper.should_ignore_file?(f, script_dir, gitignore) }
       .map { |f| f.sub("#{script_dir}/", "") }
       .sort
  else
    supporting_files.map { |p| p.relative_path_from(@directory).to_s }
  end

  if effective_files.any?
    max_files = 20
    truncated = effective_files.length > max_files
    listed_files = effective_files.first(max_files)

    processed_content += "\n\n## Supporting Files\n\n"
    processed_content += "The following files are available in this skill's directory (`#{effective_dir}`):\n\n"
    listed_files.each do |file|
      processed_content += "- `#{file}`\n"
    end
    if truncated
      processed_content += "\n_(#{effective_files.length - max_files} more files not shown)_\n"
    end
  end

  processed_content
end

#read_supporting_file(filename) ⇒ String?

Load content of a supporting file

Parameters:

  • filename (String)

    Relative path from skill directory

Returns:

  • (String, nil)

    File contents or nil if not found



295
296
297
298
# File 'lib/clacky/skill.rb', line 295

def read_supporting_file(filename)
  file_path = @directory.join(filename)
  file_path.exist? ? file_path.read : nil
end

#sanitize_frontmatterObject

Sanitize and auto-correct frontmatter fields instead of raising on bad data. Skills should always load — invalid fields are corrected with a warning, or the skill is marked @invalid so the UI can display it greyed-out.



476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/clacky/skill.rb', line 476

def sanitize_frontmatter
  dir_slug = @directory.basename.to_s
  valid_slug = ->(s) { s.to_s.match?(/\A[a-z0-9][a-z0-9_-]*\z/) }

  # --- name ---
  # Brand skills loaded via cached_metadata have their name pre-sanitized by
  # record_installed_skill (brand_config.rb) — skip slug validation for them.
  # The frontmatter name (e.g. "Antique Identifier") is the human-readable display
  # name and should not be treated as a slug.
  if @cached_metadata
    @name ||= dir_slug
  elsif @name
    name_invalid = !valid_slug.call(@name) || @name.length > 64

    if name_invalid
      if valid_slug.call(dir_slug)
        # Recoverable: fall back to directory name, record a warning
        @warnings << "Invalid name '#{@name}' in metadata; using directory name '#{dir_slug}' instead."
        @name = dir_slug
      else
        # Unrecoverable: both name and directory slug are invalid — mark skill as invalid
        @invalid        = true
        @invalid_reason = "Invalid skill name '#{@name}' and directory name '#{dir_slug}' is also not a valid slug. " \
                          "Expected lowercase letters, numbers, and hyphens (e.g. 'my-skill')."
        @name = nil
      end
    end
  else
    # No name in frontmatter — check the directory slug itself
    unless valid_slug.call(dir_slug)
      @invalid        = true
      @invalid_reason = "Directory name '#{dir_slug}' is not a valid skill slug. " \
                        "Expected lowercase letters, numbers, and hyphens (e.g. 'my-skill')."
    end
  end

  # --- forbidden_tools ---
  if @forbidden_tools && !@forbidden_tools.is_a?(Array)
    @warnings << "forbidden_tools must be an array; ignoring value: #{@forbidden_tools.inspect}"
    @forbidden_tools = nil
  end

  # --- allowed-tools ---
  if @allowed_tools && !@allowed_tools.is_a?(Array)
    @warnings << "allowed-tools must be an array; ignoring value: #{@allowed_tools.inspect}"
    @allowed_tools = nil
  end
end

#slash_commandString

Get the slash command for this skill

Returns:

  • (String)

    e.g., “/explain-code”



163
164
165
# File 'lib/clacky/skill.rb', line 163

def slash_command
  "/#{identifier}"
end

#subagent_modelString?

Get the model to use for the subagent (if fork_agent is true)

Returns:

  • (String, nil)


121
122
123
# File 'lib/clacky/skill.rb', line 121

def subagent_model
  @model
end

#supporting_filesArray<Pathname>

Get all supporting files in the skill directory (excluding SKILL.md) Return plain-text supporting files for this skill (non-encrypted skills only). For encrypted skills this always returns [] — the decrypted files live in a tmpdir created by SkillManager at invoke time (see has_supporting_scripts?).

Returns:

  • (Array<Pathname>)
  • (Array<Pathname>)


180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/clacky/skill.rb', line 180

def supporting_files
  return [] unless @directory.exist?
  return [] if encrypted?

  dir = @directory.to_s
  gitignore_path = Utils::FileIgnoreHelper.find_gitignore(dir)
  gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil

  Dir.glob(File.join(dir, "**", "*"))
     .reject { |f| File.directory?(f) }
     .reject { |f| File.basename(f) == "SKILL.md" }
     .reject { |f| Utils::FileIgnoreHelper.should_ignore_file?(f, dir, gitignore) }
     .map { |f| Pathname.new(f) }
     .sort
end

#to_hHash

Convert to a hash representation

Returns:

  • (Hash)


274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/clacky/skill.rb', line 274

def to_h
  {
    name: identifier,
    name_zh: @name_zh,
    description: context_description,
    directory: @directory.to_s,
    source_path: @source_path.to_s,
    user_invocable: user_invocable?,
    model_invocation_allowed: model_invocation_allowed?,
    fork_agent: fork_agent?,
    subagent_model: @model,
    forbidden_tools: @forbidden_tools,
    allowed_tools: @allowed_tools,
    argument_hint: @argument_hint,
    content_length: encrypted? ? nil : @content&.length
  }
end

#user_invocable?Boolean

Check if skill can be invoked by user via slash command

Returns:

  • (Boolean)


103
104
105
# File 'lib/clacky/skill.rb', line 103

def user_invocable?
  @user_invocable.nil? || @user_invocable
end