Class: LittleGhost::Skills::Catalog

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/little_ghost/skills/catalog.rb

Overview

A Catalog lets an agent discover focused instructions without putting every skill in its prompt. The model sees short descriptions first and can load a skill's full instructions when the task calls for them.

catalog = LittleGhost::Skills::Catalog.new(paths: ["app/skills"])
catalog.names # => ["refund_policy", "search_orders"]
catalog.discovery_prompt.include?("refund_policy") # => true
catalog.tool # a LittleGhost::Tool that loads full instructions on demand

Each immediate child directory may contain one SKILL.md with YAML front matter. Symbolic-link escapes, unsafe names, oversized files, and invalid YAML are rejected or skipped before instructions reach a model. Optional resource listings are limited by count and depth.

Security and trust

Configured roots and their contents are fully trusted instruction sources. The allowed-tools field is metadata shown to the model, not an authorization boundary. Applications must enforce tool access separately and keep skill roots non-user-writable.

Constant Summary collapse

DEFAULT_MAX_SKILLS =

:nodoc:

1_000
DEFAULT_MAX_FILE_BYTES =

:nodoc:

1_000_000
DEFAULT_MAX_RESOURCE_FILES =

:nodoc:

20
MAX_RESOURCE_DEPTH =

:nodoc:

3
RESOURCE_DIRECTORIES =

:nodoc:

%w[scripts references assets].freeze
SAFE_NAME_PATTERN =

:nodoc:

/\A[a-zA-Z0-9_-]+\z/

Instance Method Summary collapse

Constructor Details

#initialize(paths:, max_skills: DEFAULT_MAX_SKILLS, max_file_bytes: DEFAULT_MAX_FILE_BYTES, max_resource_files: DEFAULT_MAX_RESOURCE_FILES, only: nil, resource_root: nil) ⇒ Catalog

Loads valid skills immediately using the supplied safety limits.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/little_ghost/skills/catalog.rb', line 43

def initialize(
  paths:,
  max_skills: DEFAULT_MAX_SKILLS,
  max_file_bytes: DEFAULT_MAX_FILE_BYTES,
  max_resource_files: DEFAULT_MAX_RESOURCE_FILES,
  only: nil,
  resource_root: nil
)
  @paths = PathSet.new(paths)
  @max_skills = positive_integer(max_skills, :max_skills)
  @max_file_bytes = positive_integer(max_file_bytes, :max_file_bytes)
  @max_resource_files = positive_integer(max_resource_files, :max_resource_files)
  @only = Array(only).map(&:to_s).freeze if only
  @resource_root = canonical_resource_root(resource_root)
  @skills = load_skills
end

Instance Method Details

#discovery_promptObject

Produces the escaped, metadata-only prompt used for discovery.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/little_ghost/skills/catalog.rb', line 76

def discovery_prompt
  return "" if @skills.empty?

  lines = ["<available_skills>"]
  @skills.each_value do |skill|
    lines.concat([
      "<skill>",
      "<name>#{ERB::Util.html_escape(skill.name)}</name>",
      "<description>#{ERB::Util.html_escape(skill.description)}</description>",
      "<location>#{ERB::Util.html_escape(skill.path)}</location>",
      "</skill>"
    ])
  end
  lines << "</available_skills>"
  lines.join("\n")
end

#each(&block) ⇒ Object

Yields each Skill in lookup order.



61
62
63
# File 'lib/little_ghost/skills/catalog.rb', line 61

def each(&block)
  @skills.each_value(&block)
end

#fetch(name) ⇒ Object

Finds the named Skill or raises ConfigurationError.



66
67
68
# File 'lib/little_ghost/skills/catalog.rb', line 66

def fetch(name)
  @skills.fetch(name.to_s) { raise ConfigurationError, "Unknown skill: #{name}" }
end

#format(skill) ⇒ Object

Formats one Skill, including allowed tools, compatibility, and bounded resource paths.



119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/little_ghost/skills/catalog.rb', line 119

def format(skill)
  parts = [skill.instructions]
   = []
   << "Allowed tools: #{skill.allowed_tools.join(", ")}" unless skill.allowed_tools.empty?
   << "Compatibility: #{skill.compatibility}" if skill.compatibility
   << "Location: #{skill.path}"
  parts << "\n---\n#{.join("\n")}" unless .empty?
  resources = skill_resources(skill)
  unless resources.empty?
    parts << "\nAvailable resources:\n#{resources.map { |path| "  #{path}" }.join("\n")}"
  end
  parts.join("\n")
end

#namesObject

Lists immutable skill names in lookup order.



71
72
73
# File 'lib/little_ghost/skills/catalog.rb', line 71

def names
  @skills.keys.freeze
end

#toolObject

Exposes full instructions on demand through a skills Tool.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/little_ghost/skills/catalog.rb', line 94

def tool
  catalog = self
  Tool.define(
    name: "skills",
    description: <<~DESCRIPTION.strip,
      Activate a skill to load its full instructions.

      Use this tool to load the complete instructions for a skill listed in
      the available_skills section of your system prompt.
    DESCRIPTION
    input_schema: {
      type: "object",
      properties: {skill_name: {type: "string", description: "Name of the skill to activate."}},
      required: ["skill_name"],
      additionalProperties: false
    }
  ) do |input|
    catalog.format(catalog.fetch(input.fetch("skill_name")))
  rescue ConfigurationError => error
    raise ToolError, error.message
  end
end