Class: Octo::SkillLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/octo/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_octo,      # ~/.octo/skills/
  :project_octo      # .octo/skills/ (highest priority)
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(working_dir:) ⇒ 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 (.octo/skills/) are not loaded, making the loader project-agnostic (used by WebUI server).



22
23
24
25
26
27
28
29
30
# File 'lib/octo/skill_loader.rb', line 22

def initialize(working_dir:)
  @working_dir  = working_dir
  @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:



75
76
77
# File 'lib/octo/skill_loader.rb', line 75

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

#all_skillsArray<Skill>

Get all loaded skills

Returns:



68
69
70
# File 'lib/octo/skill_loader.rb', line 68

def all_skills
  @skills.values
end

#build_skill_content(frontmatter, content) ⇒ Object



298
299
300
301
302
303
304
# File 'lib/octo/skill_loader.rb', line 298

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



118
119
120
121
122
123
# File 'lib/octo/skill_loader.rb', line 118

def clear
  @skills.clear
  @skills_by_command.clear
  @loaded_from.clear
  @errors.clear
end

#countInteger

Get the count of loaded skills

Returns:

  • (Integer)


101
102
103
# File 'lib/octo/skill_loader.rb', line 101

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



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/octo/skill_loader.rb', line 131

def create_skill(name, content, description = nil, location: :global)
  # Validate name
  unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
    raise Octo::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(".octo", "skills", name)
  when :project
    Pathname.new(@working_dir).join(".octo", "skills", name)
  else
    raise Octo::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_octo
  when :project then :project_octo
  else :global_octo
  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



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

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


107
108
109
# File 'lib/octo/skill_loader.rb', line 107

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:



82
83
84
# File 'lib/octo/skill_loader.rb', line 82

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:



89
90
91
# File 'lib/octo/skill_loader.rb', line 89

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



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/octo/skill_loader.rb', line 35

def load_all
  # Clear existing skills to ensure idempotent reloading
  clear

  load_default_skills
  load_global_octo_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_octo_skills
  end

  all_skills
end

#load_global_octo_skillsArray<Skill>

Load skills from ~/.octo/skills/ (user global)

Returns:



54
55
56
57
# File 'lib/octo/skill_loader.rb', line 54

def load_global_octo_skills
  global_octo_dir = Pathname.new(ENV.fetch("HOME", "~")).join(".octo", "skills")
  load_skills_from_directory(global_octo_dir, :global_octo)
end

#load_project_octo_skillsArray<Skill>

Load skills from .octo/skills/ (project-level, highest priority)

Returns:



61
62
63
64
# File 'lib/octo/skill_loader.rb', line 61

def load_project_octo_skills
  project_octo_dir = Pathname.new(@working_dir).join(".octo", "skills")
  load_skills_from_directory(project_octo_dir, :project_octo)
end

#load_skills_from_directory(dir, source_type) ⇒ Object



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
239
240
241
242
243
244
245
# File 'lib/octo/skill_loader.rb', line 214

def load_skills_from_directory(dir, source_type)
  return [] unless dir.exist?

  source_path = case source_type
  when :global_octo
    Pathname.new(ENV.fetch("HOME", "~")).join(".octo")
  when :project_octo
    Pathname.new(@working_dir)
  else
    dir
  end

  skills = []
  dir.children.select(&:directory?).each do |entry|
    if entry.join("SKILL.md").exist?
      # Direct skill directory
      skill = load_single_skill(entry, source_path, entry.basename.to_s, source_type)
      skills << skill if skill
    else
      # Treat as a category directory — scan one level deeper for skills.
      # This allows grouping skills under ~/.octo/skills/<category>/<skill>/SKILL.md
      # (e.g. openclaw-imports/my-skill/SKILL.md) without changing the loader contract.
      entry.children.select(&:directory?).each do |skill_dir|
        next unless skill_dir.join("SKILL.md").exist?

        skill = load_single_skill(skill_dir, source_path, skill_dir.basename.to_s, source_type)
        skills << skill if skill
      end
    end
  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



113
114
115
# File 'lib/octo/skill_loader.rb', line 113

def loaded_from
  @loaded_from.dup
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:



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/octo/skill_loader.rb', line 173

def toggle_skill(name, enabled:)
  skill = @skills[name]
  raise Octo::AgentError, "Skill not found: #{name}" unless skill
  raise Octo::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:



95
96
97
# File 'lib/octo/skill_loader.rb', line 95

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