Class: Octo::Skill

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(directory, source_path: 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



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_typeObject (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_toolsObject (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_hintObject (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_summarizeObject (readonly)

Returns the value of attribute auto_summarize.



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

def auto_summarize
  @auto_summarize
end

#contentObject (readonly)

Returns the value of attribute content.



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

def content
  @content
end

#contextObject (readonly)

Returns the value of attribute context.



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

def context
  @context
end

#descriptionObject (readonly)

Returns the value of attribute description.



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

def description
  @description
end

#description_zhObject (readonly)

Returns the value of attribute description_zh.



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

def description_zh
  @description_zh
end

#directoryObject (readonly)

Returns the value of attribute directory.



32
33
34
# File 'lib/octo/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/octo/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/octo/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/octo/skill.rb', line 36

def fork_agent
  @fork_agent
end

#frontmatterObject (readonly)

Returns the value of attribute frontmatter.



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

def frontmatter
  @frontmatter
end

#hooksObject (readonly)

Returns the value of attribute hooks.



35
36
37
# File 'lib/octo/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)


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

def invalid
  @invalid
end

#invalid_reasonString? (readonly)

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

Returns:

  • (String, nil)


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

def invalid_reason
  @invalid_reason
end

#modelObject (readonly)

Returns the value of attribute model.



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

def model
  @model
end

#nameObject (readonly)

Returns the value of attribute name.



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

def name
  @name
end

#name_zhObject (readonly)

Returns the value of attribute name_zh.



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

def name_zh
  @name_zh
end

#sourceSymbol?

Source location of this skill — set by SkillLoader after registration. One of: :default, :global_claude, :global_octo, :project_claude, :project_octo

Returns:

  • (Symbol, nil)


41
42
43
# File 'lib/octo/skill.rb', line 41

def source
  @source
end

#source_pathObject (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_invocableObject (readonly)

Returns the value of attribute user_invocable.



34
35
36
# File 'lib/octo/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>)


46
47
48
# File 'lib/octo/skill.rb', line 46

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


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.

Parameters:

  • profile_name (String)

    e.g. “coding”, “general”

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


126
127
128
# File 'lib/octo/skill.rb', line 126

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), hard-capped at DESCRIPTION_MAX_CHARS so a single overlong skill can’t blow up the system prompt. Truncation is marked with an ellipsis.

Returns:

  • (String)


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)

Returns:

  • (Boolean)


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

Parameters:

  • content (String)
  • context (Hash)

Returns:

  • (String)


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 expand_templates(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_paragraphObject



431
432
433
# File 'lib/octo/skill.rb', line 431

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


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

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


71
72
73
# File 'lib/octo/skill.rb', line 71

def has_warnings?
  @warnings&.any?
end

#identifierString

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

Returns:

  • (String)


90
91
92
# File 'lib/octo/skill.rb', line 90

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

#invalid?Boolean

Returns:

  • (Boolean)


66
67
68
# File 'lib/octo/skill.rb', line 66

def invalid?
  @invalid == true
end

#load_skillObject



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

Returns:

  • (Boolean)


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.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: {}) ⇒ 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)

Returns:

  • (String)

    Processed content



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 = 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.
  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

Parameters:

  • filename (String)

    Relative path from skill directory

Returns:

  • (String, nil)

    File contents or nil if not found



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



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_commandString

Get the slash command for this skill

Returns:

  • (String)

    e.g., “/explain-code”



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

def slash_command
  "/#{identifier}"
end

#subagent_modelString?

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

Returns:

  • (String, nil)


114
115
116
# File 'lib/octo/skill.rb', line 114

def subagent_model
  @model
end

#supporting_filesArray<Pathname>

Get all supporting files in the skill directory (excluding SKILL.md)

Returns:

  • (Array<Pathname>)


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_hHash

Convert to a hash representation

Returns:

  • (Hash)


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

Returns:

  • (Boolean)


96
97
98
# File 'lib/octo/skill.rb', line 96

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