Class: Clacky::Skill
- Inherits:
-
Object
- Object
- Clacky::Skill
- 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
-
#agent_type ⇒ Object
readonly
Returns the value of attribute agent_type.
-
#allowed_tools ⇒ Object
readonly
Returns the value of attribute allowed_tools.
-
#argument_hint ⇒ Object
readonly
Returns the value of attribute argument_hint.
-
#auto_summarize ⇒ Object
readonly
Returns the value of attribute auto_summarize.
-
#brand_config ⇒ Object
readonly
Returns the value of attribute brand_config.
-
#brand_skill ⇒ Object
readonly
Returns the value of attribute brand_skill.
-
#content ⇒ Object
readonly
Returns the value of attribute content.
-
#context ⇒ Object
readonly
Returns the value of attribute context.
-
#description ⇒ Object
readonly
Returns the value of attribute description.
-
#description_zh ⇒ Object
readonly
Returns the value of attribute description_zh.
-
#directory ⇒ Object
readonly
Returns the value of attribute directory.
-
#disable_model_invocation ⇒ Object
readonly
Returns the value of attribute disable_model_invocation.
-
#forbidden_tools ⇒ Object
readonly
Returns the value of attribute forbidden_tools.
-
#fork_agent ⇒ Object
readonly
Returns the value of attribute fork_agent.
-
#frontmatter ⇒ Object
readonly
Returns the value of attribute frontmatter.
-
#hooks ⇒ Object
readonly
Returns the value of attribute hooks.
-
#invalid ⇒ Boolean
readonly
When true the skill has an unrecoverable metadata problem (e.g. directory name is itself an invalid slug).
-
#invalid_reason ⇒ String?
readonly
Human-readable reason why the skill is invalid (nil when valid).
-
#model ⇒ Object
readonly
Returns the value of attribute model.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#name_zh ⇒ Object
readonly
Returns the value of attribute name_zh.
-
#source_path ⇒ Object
readonly
Returns the value of attribute source_path.
-
#user_invocable ⇒ Object
readonly
Returns the value of attribute user_invocable.
-
#warnings ⇒ Array<String>
readonly
Warnings accumulated during load (e.g. name was invalid and fell back to dir name).
Instance Method Summary collapse
-
#agents_scope ⇒ Array<String>
Get the agent scope for this skill.
-
#allowed_for_agent?(profile_name) ⇒ Boolean
Check if this skill is allowed for the given agent profile name.
-
#auto_summarize? ⇒ Boolean
Check if subagent should auto-summarize results.
-
#context_description ⇒ String
Get the description for context loading Returns the description from frontmatter, or first paragraph of content.
-
#decrypted_content ⇒ String
Decrypt and return the raw skill content.
-
#disabled? ⇒ Boolean
Check if this skill is disabled (disable-model-invocation: true).
-
#encrypted? ⇒ Boolean
Returns true when this skill’s content is stored encrypted on disk.
-
#expand_templates(content, context) ⇒ String
Expand <%= key %> template placeholders via ERB.
- #extract_first_paragraph ⇒ Object
-
#forbidden_tools_list ⇒ Array<String>
Get the list of forbidden tools for the subagent.
-
#fork_agent? ⇒ Boolean
Check if this skill should fork a subagent.
-
#has_supporting_files? ⇒ Boolean
Check if this skill has any supporting files/scripts beyond SKILL.md.
- #has_warnings? ⇒ Boolean
-
#identifier ⇒ String
Get the skill identifier (uses name from frontmatter or directory name).
-
#initialize(directory, source_path: nil, brand_skill: false, brand_config: nil, cached_metadata: nil) ⇒ Skill
constructor
A new instance of Skill.
- #invalid? ⇒ Boolean
- #load_skill ⇒ Object
-
#model_invocation_allowed? ⇒ Boolean
Check if skill can be automatically invoked by the model.
-
#parse_frontmatter(content) ⇒ Object
Parse content that may or may not have YAML frontmatter.
-
#process_content(shell_output: {}, template_context: {}, script_dir: nil) ⇒ String
Process the skill content with argument substitution and template expansion.
-
#read_supporting_file(filename) ⇒ String?
Load content of a supporting file.
-
#sanitize_frontmatter ⇒ Object
Sanitize and auto-correct frontmatter fields instead of raising on bad data.
-
#slash_command ⇒ String
Get the slash command for this skill.
-
#subagent_model ⇒ String?
Get the model to use for the subagent (if fork_agent is true).
-
#supporting_files ⇒ Array<Pathname>
Get all supporting files in the skill directory (excluding SKILL.md) Return plain-text supporting files for this skill (non-encrypted skills only).
-
#to_h ⇒ Hash
Convert to a hash representation.
-
#user_invocable? ⇒ Boolean
Check if skill can be invoked by user via slash command.
Constructor Details
#initialize(directory, source_path: nil, brand_skill: false, brand_config: nil, cached_metadata: nil) ⇒ Skill
Returns a new instance of Skill.
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_type ⇒ Object (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_tools ⇒ Object (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_hint ⇒ Object (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_summarize ⇒ Object (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_config ⇒ Object (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_skill ⇒ Object (readonly)
Returns the value of attribute brand_skill.
37 38 39 |
# File 'lib/clacky/skill.rb', line 37 def brand_skill @brand_skill end |
#content ⇒ Object (readonly)
Returns the value of attribute content.
33 34 35 |
# File 'lib/clacky/skill.rb', line 33 def content @content end |
#context ⇒ Object (readonly)
Returns the value of attribute context.
35 36 37 |
# File 'lib/clacky/skill.rb', line 35 def context @context end |
#description ⇒ Object (readonly)
Returns the value of attribute description.
33 34 35 |
# File 'lib/clacky/skill.rb', line 33 def description @description end |
#description_zh ⇒ Object (readonly)
Returns the value of attribute description_zh.
33 34 35 |
# File 'lib/clacky/skill.rb', line 33 def description_zh @description_zh end |
#directory ⇒ Object (readonly)
Returns the value of attribute directory.
32 33 34 |
# File 'lib/clacky/skill.rb', line 32 def directory @directory end |
#disable_model_invocation ⇒ Object (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_tools ⇒ Object (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_agent ⇒ Object (readonly)
Returns the value of attribute fork_agent.
36 37 38 |
# File 'lib/clacky/skill.rb', line 36 def fork_agent @fork_agent end |
#frontmatter ⇒ Object (readonly)
Returns the value of attribute frontmatter.
32 33 34 |
# File 'lib/clacky/skill.rb', line 32 def frontmatter @frontmatter end |
#hooks ⇒ Object (readonly)
Returns the value of attribute hooks.
35 36 37 |
# File 'lib/clacky/skill.rb', line 35 def hooks @hooks end |
#invalid ⇒ Boolean (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.
49 50 51 |
# File 'lib/clacky/skill.rb', line 49 def invalid @invalid end |
#invalid_reason ⇒ String? (readonly)
Human-readable reason why the skill is invalid (nil when valid).
53 54 55 |
# File 'lib/clacky/skill.rb', line 53 def invalid_reason @invalid_reason end |
#model ⇒ Object (readonly)
Returns the value of attribute model.
36 37 38 |
# File 'lib/clacky/skill.rb', line 36 def model @model end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
33 34 35 |
# File 'lib/clacky/skill.rb', line 33 def name @name end |
#name_zh ⇒ Object (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_path ⇒ Object (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_invocable ⇒ Object (readonly)
Returns the value of attribute user_invocable.
34 35 36 |
# File 'lib/clacky/skill.rb', line 34 def user_invocable @user_invocable end |
#warnings ⇒ Array<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.
42 43 44 |
# File 'lib/clacky/skill.rb', line 42 def warnings @warnings end |
Instance Method Details
#agents_scope ⇒ Array<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.
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.
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
133 134 135 |
# File 'lib/clacky/skill.rb', line 133 def auto_summarize? @auto_summarize != false end |
#context_description ⇒ String
Get the description for context loading Returns the description from frontmatter, or first paragraph of content
170 171 172 |
# File 'lib/clacky/skill.rb', line 170 def context_description @description || extract_first_paragraph end |
#decrypted_content ⇒ String
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.
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)
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.
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).
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 (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_paragraph ⇒ Object
525 526 527 |
# File 'lib/clacky/skill.rb', line 525 def extract_first_paragraph @content.split(/\n\n/).first.to_s end |
#forbidden_tools_list ⇒ Array<String>
Get the list of forbidden tools for the subagent
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
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.
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
67 68 69 |
# File 'lib/clacky/skill.rb', line 67 def has_warnings? @warnings&.any? end |
#identifier ⇒ String
Get the skill identifier (uses name from frontmatter or directory name)
97 98 99 |
# File 'lib/clacky/skill.rb', line 97 def identifier @name || @directory.basename.to_s end |
#invalid? ⇒ Boolean
62 63 64 |
# File 'lib/clacky/skill.rb', line 62 def invalid? @invalid == true end |
#load_skill ⇒ Object
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
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.}. 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
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 = (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
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_frontmatter ⇒ Object
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_command ⇒ String
Get the slash command for this skill
163 164 165 |
# File 'lib/clacky/skill.rb', line 163 def slash_command "/#{identifier}" end |
#subagent_model ⇒ String?
Get the model to use for the subagent (if fork_agent is true)
121 122 123 |
# File 'lib/clacky/skill.rb', line 121 def subagent_model @model end |
#supporting_files ⇒ Array<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?).
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_h ⇒ Hash
Convert to a hash representation
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
103 104 105 |
# File 'lib/clacky/skill.rb', line 103 def user_invocable? @user_invocable.nil? || @user_invocable end |