Class: Octo::Skill
- Inherits:
-
Object
- Object
- Octo::Skill
- Defined in:
- lib/octo/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
- DESCRIPTION_MAX_CHARS =
Maximum length for a skill’s description when injected into the system prompt. Descriptions longer than this are truncated to protect the token budget — a good description is a trigger hint, not a tutorial. Authors still see their full description via ‘skill.description`; only the system-prompt rendering is truncated.
Anthropic’s hard limit is 1024, but empirically ~300 chars is enough for reliable triggering (including trigger-phrase lists); longer content belongs in the SKILL.md body.
300
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.
-
#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 ⇒ Symbol?
Source location of this skill — set by SkillLoader after registration.
-
#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.
-
#disabled? ⇒ Boolean
Check if this skill is disabled (disable-model-invocation: true).
-
#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) ⇒ 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: {}) ⇒ 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).
-
#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) ⇒ Skill
Returns a new instance of Skill.
78 79 80 81 82 83 84 85 86 |
# File 'lib/octo/skill.rb', line 78 def initialize(directory, source_path: nil) @directory = Pathname.new(directory) @source_path = source_path ? Pathname.new(source_path) : @directory @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/octo/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/octo/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/octo/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/octo/skill.rb', line 36 def auto_summarize @auto_summarize end |
#content ⇒ Object (readonly)
Returns the value of attribute content.
33 34 35 |
# File 'lib/octo/skill.rb', line 33 def content @content end |
#context ⇒ Object (readonly)
Returns the value of attribute context.
35 36 37 |
# File 'lib/octo/skill.rb', line 35 def context @context end |
#description ⇒ Object (readonly)
Returns the value of attribute description.
33 34 35 |
# File 'lib/octo/skill.rb', line 33 def description @description end |
#description_zh ⇒ Object (readonly)
Returns the value of attribute description_zh.
33 34 35 |
# File 'lib/octo/skill.rb', line 33 def description_zh @description_zh end |
#directory ⇒ Object (readonly)
Returns the value of attribute directory.
32 33 34 |
# File 'lib/octo/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/octo/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/octo/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/octo/skill.rb', line 36 def fork_agent @fork_agent end |
#frontmatter ⇒ Object (readonly)
Returns the value of attribute frontmatter.
32 33 34 |
# File 'lib/octo/skill.rb', line 32 def frontmatter @frontmatter end |
#hooks ⇒ Object (readonly)
Returns the value of attribute hooks.
35 36 37 |
# File 'lib/octo/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.
53 54 55 |
# File 'lib/octo/skill.rb', line 53 def invalid @invalid end |
#invalid_reason ⇒ String? (readonly)
Human-readable reason why the skill is invalid (nil when valid).
57 58 59 |
# File 'lib/octo/skill.rb', line 57 def invalid_reason @invalid_reason end |
#model ⇒ Object (readonly)
Returns the value of attribute model.
36 37 38 |
# File 'lib/octo/skill.rb', line 36 def model @model end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
33 34 35 |
# File 'lib/octo/skill.rb', line 33 def name @name end |
#name_zh ⇒ Object (readonly)
Returns the value of attribute name_zh.
33 34 35 |
# File 'lib/octo/skill.rb', line 33 def name_zh @name_zh end |
#source ⇒ Symbol?
Source location of this skill — set by SkillLoader after registration. One of: :default, :global_claude, :global_octo, :project_claude, :project_octo
41 42 43 |
# File 'lib/octo/skill.rb', line 41 def source @source end |
#source_path ⇒ Object (readonly)
Returns the value of attribute source_path.
32 33 34 |
# File 'lib/octo/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/octo/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.
46 47 48 |
# File 'lib/octo/skill.rb', line 46 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.
134 135 136 137 138 139 140 141 142 |
# File 'lib/octo/skill.rb', line 134 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.
149 150 151 152 |
# File 'lib/octo/skill.rb', line 149 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
126 127 128 |
# File 'lib/octo/skill.rb', line 126 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), hard-capped at DESCRIPTION_MAX_CHARS so a single overlong skill can’t blow up the system prompt. Truncation is marked with an ellipsis.
176 177 178 179 180 181 |
# File 'lib/octo/skill.rb', line 176 def context_description raw = @description || extract_first_paragraph return raw if raw.nil? || raw.length <= DESCRIPTION_MAX_CHARS raw[0, DESCRIPTION_MAX_CHARS - 1] + "…" end |
#disabled? ⇒ Boolean
Check if this skill is disabled (disable-model-invocation: true)
61 62 63 |
# File 'lib/octo/skill.rb', line 61 def disabled? @disable_model_invocation == 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).
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/octo/skill.rb', line 441 def (content, context) # Shell-style ${VAR} substitution from ENV — handles variables like # ${OCTO_SERVER_PORT}, ${OCTO_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
431 432 433 |
# File 'lib/octo/skill.rb', line 431 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
120 121 122 |
# File 'lib/octo/skill.rb', line 120 def forbidden_tools_list @forbidden_tools || [] end |
#fork_agent? ⇒ Boolean
Check if this skill should fork a subagent
108 109 110 |
# File 'lib/octo/skill.rb', line 108 def fork_agent? @fork_agent == true end |
#has_supporting_files? ⇒ Boolean
Check if this skill has any supporting files/scripts beyond SKILL.md.
202 203 204 205 206 |
# File 'lib/octo/skill.rb', line 202 def has_supporting_files? return false unless @directory.exist? supporting_files.any? end |
#has_warnings? ⇒ Boolean
71 72 73 |
# File 'lib/octo/skill.rb', line 71 def has_warnings? @warnings&.any? end |
#identifier ⇒ String
Get the skill identifier (uses name from frontmatter or directory name)
90 91 92 |
# File 'lib/octo/skill.rb', line 90 def identifier @name || @directory.basename.to_s end |
#invalid? ⇒ Boolean
66 67 68 |
# File 'lib/octo/skill.rb', line 66 def invalid? @invalid == true end |
#load_skill ⇒ Object
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/octo/skill.rb', line 307 def load_skill skill_file = @directory.join("SKILL.md") unless skill_file.exist? raise Octo::AgentError, "SKILL.md not found in skill directory: #{@directory}" end content = skill_file.read parse_frontmatter(content) # 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
102 103 104 |
# File 'lib/octo/skill.rb', line 102 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.
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 361 362 363 |
# File 'lib/octo/skill.rb', line 333 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: {}) ⇒ String
Process the skill content with argument substitution and template expansion
215 216 217 218 219 220 221 222 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 271 272 273 274 275 276 |
# File 'lib/octo/skill.rb', line 215 def process_content(shell_output: {}, template_context: {}) processed_content = @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. effective_files = supporting_files.map { |p| p.relative_path_from(@directory).to_s } 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 (`#{@directory}`):\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 # Environment hint: if the skill references ${OCTO_SERVER_HOST/PORT} but # those vars were not injected (bare-CLI mode without a running server), # the `${...}` placeholders will survive expansion as literal text. In that # case append a non-fatal note so the LLM knows the skill's HTTP callbacks # will not work, without blocking the skill entirely (the user may still # want to read instructions, explore files, etc.). if processed_content.match?(/\$\{OCTO_SERVER_(HOST|PORT)\}/) processed_content += <<~HINT --- > ⚠️ **Environment note (auto-injected)**: this skill calls back into the > Octo HTTP server (via `${OCTO_SERVER_HOST}` / `${OCTO_SERVER_PORT}`), > but those variables are **not set** in the current process. That means > no local Octo server was detected. > > Any `curl http://${OCTO_SERVER_HOST}:...` command in the steps above > will fail with a DNS/connection error. Before running those steps you > should either: > > 1. Ask the user to start the server in another terminal: `octo server` > (then retry — the CLI auto-detects it via `/tmp/octo-master-*.pid`), or > 2. If the task can be completed without the server API, skip those steps > and tell the user which parts require the server. > > This is an informational hint, not an error. Proceed with judgment. HINT end processed_content end |
#read_supporting_file(filename) ⇒ String?
Load content of a supporting file
301 302 303 304 |
# File 'lib/octo/skill.rb', line 301 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.
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 |
# File 'lib/octo/skill.rb', line 387 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 --- if @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 # Both name and directory slug are invalid (e.g. contains dots from version suffix). # Record a warning but keep the skill usable — do not mark as invalid. @warnings << "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 = dir_slug end end else # No name in frontmatter — check the directory slug itself. # Non-conforming names (e.g. version-suffixed dirs like "test-runner-1.0.0") # are allowed with a warning rather than being rejected outright. unless valid_slug.call(dir_slug) @warnings << "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
156 157 158 |
# File 'lib/octo/skill.rb', line 156 def slash_command "/#{identifier}" end |
#subagent_model ⇒ String?
Get the model to use for the subagent (if fork_agent is true)
114 115 116 |
# File 'lib/octo/skill.rb', line 114 def subagent_model @model end |
#supporting_files ⇒ Array<Pathname>
Get all supporting files in the skill directory (excluding SKILL.md)
185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/octo/skill.rb', line 185 def supporting_files return [] unless @directory.exist? 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
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/octo/skill.rb', line 280 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: @content&.length } end |
#user_invocable? ⇒ Boolean
Check if skill can be invoked by user via slash command
96 97 98 |
# File 'lib/octo/skill.rb', line 96 def user_invocable? @user_invocable.nil? || @user_invocable end |