ruby_llm-skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +396 -0
- data/lib/generators/skill/skill_generator.rb +60 -0
- data/lib/generators/skill/templates/SKILL.md.tt +21 -0
- data/lib/ruby_llm/skills/chat_extensions.rb +49 -0
- data/lib/ruby_llm/skills/composite_loader.rb +57 -0
- data/lib/ruby_llm/skills/database_loader.rb +147 -0
- data/lib/ruby_llm/skills/error.rb +21 -0
- data/lib/ruby_llm/skills/filesystem_loader.rb +55 -0
- data/lib/ruby_llm/skills/loader.rb +77 -0
- data/lib/ruby_llm/skills/parser.rb +80 -0
- data/lib/ruby_llm/skills/railtie.rb +29 -0
- data/lib/ruby_llm/skills/skill.rb +174 -0
- data/lib/ruby_llm/skills/skill_tool.rb +166 -0
- data/lib/ruby_llm/skills/tasks/skills.rake +110 -0
- data/lib/ruby_llm/skills/validator.rb +108 -0
- data/lib/ruby_llm/skills/version.rb +7 -0
- data/lib/ruby_llm/skills/zip_loader.rb +129 -0
- data/lib/ruby_llm/skills.rb +89 -0
- metadata +66 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# A RubyLLM Tool that enables progressive skill loading.
|
|
6
|
+
#
|
|
7
|
+
# This tool is the key integration point for LLM skill discovery.
|
|
8
|
+
# It embeds skill metadata (name + description) in its description,
|
|
9
|
+
# allowing the LLM to discover available skills. When invoked,
|
|
10
|
+
# it returns the full skill content.
|
|
11
|
+
#
|
|
12
|
+
# Progressive Disclosure Pattern:
|
|
13
|
+
# 1. Skill metadata is always visible in the tool description (~100 tokens/skill)
|
|
14
|
+
# 2. Full skill content is loaded on-demand when the tool is called (~5k tokens)
|
|
15
|
+
# 3. Resources (scripts, references) can be loaded separately as needed
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# loader = RubyLlm::Skills.from_directory("app/skills")
|
|
19
|
+
# skill_tool = RubyLlm::Skills::SkillTool.new(loader)
|
|
20
|
+
#
|
|
21
|
+
# chat.with_tools(skill_tool)
|
|
22
|
+
# chat.ask("Help me generate a PDF report")
|
|
23
|
+
# # LLM sees available skills, calls skill_tool with name="pdf-report"
|
|
24
|
+
# # Tool returns full SKILL.md content for LLM to follow
|
|
25
|
+
#
|
|
26
|
+
class SkillTool
|
|
27
|
+
attr_reader :loader
|
|
28
|
+
|
|
29
|
+
# Initialize with a skill loader.
|
|
30
|
+
#
|
|
31
|
+
# @param loader [Loader] any loader (FilesystemLoader, ZipLoader, etc.)
|
|
32
|
+
def initialize(loader)
|
|
33
|
+
@loader = loader
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tool name for RubyLLM.
|
|
37
|
+
#
|
|
38
|
+
# @return [String] "skill"
|
|
39
|
+
def name
|
|
40
|
+
"skill"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Dynamic description including available skills.
|
|
44
|
+
#
|
|
45
|
+
# @return [String] tool description with embedded skill metadata
|
|
46
|
+
def description
|
|
47
|
+
skills_xml = build_skills_xml
|
|
48
|
+
<<~DESC.strip
|
|
49
|
+
Load a skill to get specialized instructions for a task.
|
|
50
|
+
|
|
51
|
+
Use this tool when the user's request matches one of the available skills.
|
|
52
|
+
The tool returns the full skill instructions that you should follow.
|
|
53
|
+
|
|
54
|
+
#{skills_xml}
|
|
55
|
+
DESC
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parameter schema for the tool.
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash] JSON Schema for parameters
|
|
61
|
+
def parameters
|
|
62
|
+
{
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
skill_name: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description: "The name of the skill to load (from the available skills list)"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: ["skill_name"]
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Execute the tool to load a skill's content.
|
|
75
|
+
#
|
|
76
|
+
# @param skill_name [String] name of skill to load
|
|
77
|
+
# @return [String] skill content or error message
|
|
78
|
+
def call(skill_name:)
|
|
79
|
+
skill = @loader.find(skill_name)
|
|
80
|
+
|
|
81
|
+
unless skill
|
|
82
|
+
available = @loader.list.map(&:name).join(", ")
|
|
83
|
+
return "Skill '#{skill_name}' not found. Available skills: #{available}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
build_skill_response(skill)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Alternative execute method name for RubyLLM compatibility.
|
|
90
|
+
def execute(skill_name:)
|
|
91
|
+
call(skill_name: skill_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Convert to RubyLLM Tool-compatible format.
|
|
95
|
+
#
|
|
96
|
+
# @return [Hash] tool definition for RubyLLM
|
|
97
|
+
def to_tool_definition
|
|
98
|
+
{
|
|
99
|
+
name: name,
|
|
100
|
+
description: description,
|
|
101
|
+
parameters: parameters
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def build_skills_xml
|
|
108
|
+
skills = @loader.list
|
|
109
|
+
|
|
110
|
+
return "<available_skills>\nNo skills available.\n</available_skills>" if skills.empty?
|
|
111
|
+
|
|
112
|
+
xml_parts = ["<available_skills>"]
|
|
113
|
+
|
|
114
|
+
skills.each do |skill|
|
|
115
|
+
xml_parts << " <skill>"
|
|
116
|
+
xml_parts << " <name>#{escape_xml(skill.name)}</name>"
|
|
117
|
+
xml_parts << " <description>#{escape_xml(skill.description)}</description>"
|
|
118
|
+
xml_parts << " </skill>"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
xml_parts << "</available_skills>"
|
|
122
|
+
xml_parts.join("\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_skill_response(skill)
|
|
126
|
+
parts = []
|
|
127
|
+
parts << "# Skill: #{skill.name}"
|
|
128
|
+
parts << ""
|
|
129
|
+
parts << skill.content
|
|
130
|
+
parts << ""
|
|
131
|
+
|
|
132
|
+
# Include resource information if available
|
|
133
|
+
if skill.scripts.any?
|
|
134
|
+
parts << "## Available Scripts"
|
|
135
|
+
skill.scripts.each { |s| parts << "- #{File.basename(s)}" }
|
|
136
|
+
parts << ""
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if skill.references.any?
|
|
140
|
+
parts << "## Available References"
|
|
141
|
+
skill.references.each { |r| parts << "- #{File.basename(r)}" }
|
|
142
|
+
parts << ""
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if skill.assets.any?
|
|
146
|
+
parts << "## Available Assets"
|
|
147
|
+
skill.assets.each { |a| parts << "- #{File.basename(a)}" }
|
|
148
|
+
parts << ""
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parts.join("\n").strip
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def escape_xml(text)
|
|
155
|
+
return "" if text.nil?
|
|
156
|
+
|
|
157
|
+
text.to_s
|
|
158
|
+
.gsub("&", "&")
|
|
159
|
+
.gsub("<", "<")
|
|
160
|
+
.gsub(">", ">")
|
|
161
|
+
.gsub('"', """)
|
|
162
|
+
.gsub("'", "'")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :skills do
|
|
4
|
+
desc "List all available skills"
|
|
5
|
+
task list: :environment do
|
|
6
|
+
loader = RubyLlm::Skills.from_directory
|
|
7
|
+
skills = loader.list
|
|
8
|
+
|
|
9
|
+
if skills.empty?
|
|
10
|
+
puts "No skills found in #{RubyLlm::Skills.default_path}"
|
|
11
|
+
next
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
puts "Available skills (#{skills.count}):"
|
|
15
|
+
puts ""
|
|
16
|
+
|
|
17
|
+
skills.each do |skill|
|
|
18
|
+
status = skill.valid? ? "✓" : "✗"
|
|
19
|
+
puts " #{status} #{skill.name}"
|
|
20
|
+
puts " #{skill.description}"
|
|
21
|
+
puts ""
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Validate all skills"
|
|
26
|
+
task validate: :environment do
|
|
27
|
+
loader = RubyLlm::Skills.from_directory
|
|
28
|
+
skills = loader.list
|
|
29
|
+
|
|
30
|
+
if skills.empty?
|
|
31
|
+
puts "No skills found in #{RubyLlm::Skills.default_path}"
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
valid_count = 0
|
|
36
|
+
invalid_count = 0
|
|
37
|
+
|
|
38
|
+
skills.each do |skill|
|
|
39
|
+
if skill.valid?
|
|
40
|
+
valid_count += 1
|
|
41
|
+
puts "✓ #{skill.name}"
|
|
42
|
+
else
|
|
43
|
+
invalid_count += 1
|
|
44
|
+
puts "✗ #{skill.name}"
|
|
45
|
+
skill.errors.each do |error|
|
|
46
|
+
puts " - #{error}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
puts ""
|
|
52
|
+
puts "#{valid_count} valid, #{invalid_count} invalid"
|
|
53
|
+
|
|
54
|
+
exit(1) if invalid_count > 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "Show details of a specific skill"
|
|
58
|
+
task :show, [:name] => :environment do |_, args|
|
|
59
|
+
name = args[:name]
|
|
60
|
+
unless name
|
|
61
|
+
puts "Usage: rake skills:show[skill-name]"
|
|
62
|
+
exit(1)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
loader = RubyLlm::Skills.from_directory
|
|
66
|
+
skill = loader.find(name)
|
|
67
|
+
|
|
68
|
+
unless skill
|
|
69
|
+
puts "Skill '#{name}' not found"
|
|
70
|
+
exit(1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "Name: #{skill.name}"
|
|
74
|
+
puts "Description: #{skill.description}"
|
|
75
|
+
puts "License: #{skill.license || "(none)"}"
|
|
76
|
+
puts "Compatibility: #{skill.compatibility || "(none)"}"
|
|
77
|
+
puts "Path: #{skill.path}"
|
|
78
|
+
puts "Valid: #{skill.valid? ? "yes" : "no"}"
|
|
79
|
+
|
|
80
|
+
unless skill.errors.empty?
|
|
81
|
+
puts ""
|
|
82
|
+
puts "Errors:"
|
|
83
|
+
skill.errors.each { |e| puts " - #{e}" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if skill.custom_metadata.any?
|
|
87
|
+
puts ""
|
|
88
|
+
puts "Metadata:"
|
|
89
|
+
skill.custom_metadata.each { |k, v| puts " #{k}: #{v}" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if skill.scripts.any?
|
|
93
|
+
puts ""
|
|
94
|
+
puts "Scripts:"
|
|
95
|
+
skill.scripts.each { |s| puts " - #{File.basename(s)}" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if skill.references.any?
|
|
99
|
+
puts ""
|
|
100
|
+
puts "References:"
|
|
101
|
+
skill.references.each { |r| puts " - #{File.basename(r)}" }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if skill.assets.any?
|
|
105
|
+
puts ""
|
|
106
|
+
puts "Assets:"
|
|
107
|
+
skill.assets.each { |a| puts " - #{File.basename(a)}" }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Validates skill structure according to the Agent Skills specification.
|
|
6
|
+
#
|
|
7
|
+
# Validation rules based on agentskills.io specification:
|
|
8
|
+
# - name: required, max 64 chars, lowercase + hyphens only, no leading/trailing hyphens
|
|
9
|
+
# - description: required, max 1024 chars
|
|
10
|
+
# - license: optional, max 128 chars
|
|
11
|
+
# - compatibility: optional, max 500 chars
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# errors = Validator.validate(skill)
|
|
15
|
+
# puts errors # => [] if valid, or list of error messages
|
|
16
|
+
#
|
|
17
|
+
class Validator
|
|
18
|
+
NAME_MAX_LENGTH = 64
|
|
19
|
+
DESCRIPTION_MAX_LENGTH = 1024
|
|
20
|
+
LICENSE_MAX_LENGTH = 128
|
|
21
|
+
COMPATIBILITY_MAX_LENGTH = 500
|
|
22
|
+
NAME_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Validate a skill and return all errors.
|
|
26
|
+
#
|
|
27
|
+
# @param skill [Skill] skill to validate
|
|
28
|
+
# @return [Array<String>] list of error messages (empty if valid)
|
|
29
|
+
def validate(skill)
|
|
30
|
+
errors = []
|
|
31
|
+
validate_name(skill, errors)
|
|
32
|
+
validate_description(skill, errors)
|
|
33
|
+
validate_license(skill, errors)
|
|
34
|
+
validate_compatibility(skill, errors)
|
|
35
|
+
validate_path_name_match(skill, errors)
|
|
36
|
+
errors
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if a skill is valid.
|
|
40
|
+
#
|
|
41
|
+
# @param skill [Skill] skill to validate
|
|
42
|
+
# @return [Boolean] true if skill passes validation
|
|
43
|
+
def valid?(skill)
|
|
44
|
+
validate(skill).empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_name(skill, errors)
|
|
50
|
+
name = skill.name
|
|
51
|
+
|
|
52
|
+
if name.nil? || name.empty?
|
|
53
|
+
errors << "name is required"
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if name.length > NAME_MAX_LENGTH
|
|
58
|
+
errors << "name exceeds maximum length of #{NAME_MAX_LENGTH} characters"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless name.match?(NAME_PATTERN)
|
|
62
|
+
errors << "name must be lowercase letters, numbers, and single hyphens (no leading/trailing hyphens)"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_description(skill, errors)
|
|
67
|
+
description = skill.description
|
|
68
|
+
|
|
69
|
+
if description.nil? || description.empty?
|
|
70
|
+
errors << "description is required"
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if description.length > DESCRIPTION_MAX_LENGTH
|
|
75
|
+
errors << "description exceeds maximum length of #{DESCRIPTION_MAX_LENGTH} characters"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_license(skill, errors)
|
|
80
|
+
license = skill.license
|
|
81
|
+
return if license.nil? || license.empty?
|
|
82
|
+
|
|
83
|
+
if license.length > LICENSE_MAX_LENGTH
|
|
84
|
+
errors << "license exceeds maximum length of #{LICENSE_MAX_LENGTH} characters"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_compatibility(skill, errors)
|
|
89
|
+
compatibility = skill.compatibility
|
|
90
|
+
return if compatibility.nil? || compatibility.empty?
|
|
91
|
+
|
|
92
|
+
if compatibility.length > COMPATIBILITY_MAX_LENGTH
|
|
93
|
+
errors << "compatibility exceeds maximum length of #{COMPATIBILITY_MAX_LENGTH} characters"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_path_name_match(skill, errors)
|
|
98
|
+
return if skill.virtual?
|
|
99
|
+
|
|
100
|
+
dir_name = File.basename(skill.path)
|
|
101
|
+
return if skill.name == dir_name
|
|
102
|
+
|
|
103
|
+
errors << "name '#{skill.name}' does not match directory name '#{dir_name}'"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module Skills
|
|
7
|
+
# Loads skills from a ZIP archive.
|
|
8
|
+
#
|
|
9
|
+
# The archive should contain skill directories at the root level,
|
|
10
|
+
# each with a SKILL.md file.
|
|
11
|
+
#
|
|
12
|
+
# Structure:
|
|
13
|
+
# archive.zip
|
|
14
|
+
# ├── skill-one/
|
|
15
|
+
# │ ├── SKILL.md
|
|
16
|
+
# │ └── scripts/
|
|
17
|
+
# └── skill-two/
|
|
18
|
+
# └── SKILL.md
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# loader = ZipLoader.new("skills.zip")
|
|
22
|
+
# loader.list # => [Skill, Skill, ...]
|
|
23
|
+
#
|
|
24
|
+
class ZipLoader < Loader
|
|
25
|
+
attr_reader :path
|
|
26
|
+
|
|
27
|
+
# Initialize with path to zip file.
|
|
28
|
+
#
|
|
29
|
+
# @param path [String] path to .zip archive
|
|
30
|
+
# @raise [LoadError] if file doesn't exist
|
|
31
|
+
def initialize(path)
|
|
32
|
+
super()
|
|
33
|
+
@path = path.to_s
|
|
34
|
+
raise LoadError, "Zip file not found: #{@path}" unless File.exist?(@path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# List all skills from the archive.
|
|
38
|
+
#
|
|
39
|
+
# @return [Array<Skill>] skills found in archive
|
|
40
|
+
def list
|
|
41
|
+
skills
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Read content of a file within a skill's directory.
|
|
45
|
+
#
|
|
46
|
+
# @param skill_name [String] name of the skill
|
|
47
|
+
# @param relative_path [String] path relative to skill directory
|
|
48
|
+
# @return [String, nil] file content or nil if not found
|
|
49
|
+
def read_file(skill_name, relative_path)
|
|
50
|
+
entry_path = "#{skill_name}/#{relative_path}"
|
|
51
|
+
read_zip_entry(entry_path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
def load_all
|
|
57
|
+
loaded_skills = []
|
|
58
|
+
|
|
59
|
+
Zip::File.open(@path) do |zip|
|
|
60
|
+
skill_dirs = find_skill_directories(zip)
|
|
61
|
+
|
|
62
|
+
skill_dirs.each do |skill_dir|
|
|
63
|
+
skill = load_skill_from_zip(zip, skill_dir)
|
|
64
|
+
loaded_skills << skill if skill
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
loaded_skills
|
|
69
|
+
rescue Zip::Error => e
|
|
70
|
+
raise LoadError, "Failed to read zip archive: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def find_skill_directories(zip)
|
|
76
|
+
zip.entries
|
|
77
|
+
.select { |e| e.name.end_with?("/SKILL.md") }
|
|
78
|
+
.map { |e| File.dirname(e.name) }
|
|
79
|
+
.reject { |d| d.include?("/") } # Only top-level skills
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def load_skill_from_zip(zip, skill_dir)
|
|
83
|
+
skill_md_path = "#{skill_dir}/SKILL.md"
|
|
84
|
+
entry = zip.find_entry(skill_md_path)
|
|
85
|
+
return nil unless entry
|
|
86
|
+
|
|
87
|
+
content = entry.get_input_stream.read
|
|
88
|
+
metadata = Parser.parse_string(content)
|
|
89
|
+
body = Parser.extract_body(content)
|
|
90
|
+
|
|
91
|
+
# Store content in metadata for virtual skill
|
|
92
|
+
metadata["__content__"] = body
|
|
93
|
+
|
|
94
|
+
# Store resource lists
|
|
95
|
+
metadata["__scripts__"] = list_resources(zip, skill_dir, "scripts")
|
|
96
|
+
metadata["__references__"] = list_resources(zip, skill_dir, "references")
|
|
97
|
+
metadata["__assets__"] = list_resources(zip, skill_dir, "assets")
|
|
98
|
+
|
|
99
|
+
Skill.new(
|
|
100
|
+
path: "zip:#{@path}:#{skill_dir}",
|
|
101
|
+
metadata: metadata
|
|
102
|
+
)
|
|
103
|
+
rescue ParseError => e
|
|
104
|
+
warn "Warning: Failed to parse #{skill_md_path}: #{e.message}" if RubyLlm::Skills.logger
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def list_resources(zip, skill_dir, subdir)
|
|
109
|
+
prefix = "#{skill_dir}/#{subdir}/"
|
|
110
|
+
zip.entries
|
|
111
|
+
.select { |e| e.name.start_with?(prefix) && !e.directory? }
|
|
112
|
+
.map { |e| e.name.sub(prefix, "") }
|
|
113
|
+
.reject { |f| f == ".keep" }
|
|
114
|
+
.sort
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_zip_entry(entry_path)
|
|
118
|
+
Zip::File.open(@path) do |zip|
|
|
119
|
+
entry = zip.find_entry(entry_path)
|
|
120
|
+
return nil unless entry
|
|
121
|
+
|
|
122
|
+
entry.get_input_stream.read
|
|
123
|
+
end
|
|
124
|
+
rescue Zip::Error
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skills/version"
|
|
4
|
+
require_relative "skills/error"
|
|
5
|
+
require_relative "skills/parser"
|
|
6
|
+
require_relative "skills/validator"
|
|
7
|
+
require_relative "skills/skill"
|
|
8
|
+
require_relative "skills/loader"
|
|
9
|
+
require_relative "skills/filesystem_loader"
|
|
10
|
+
require_relative "skills/composite_loader"
|
|
11
|
+
require_relative "skills/skill_tool"
|
|
12
|
+
require_relative "skills/chat_extensions"
|
|
13
|
+
|
|
14
|
+
# Load Rails integration when Rails is available
|
|
15
|
+
require_relative "skills/railtie" if defined?(Rails::Railtie)
|
|
16
|
+
|
|
17
|
+
module RubyLlm
|
|
18
|
+
module Skills
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :default_path, :logger
|
|
21
|
+
|
|
22
|
+
# Load skills from a filesystem directory.
|
|
23
|
+
#
|
|
24
|
+
# @param path [String] path to skills directory (defaults to default_path)
|
|
25
|
+
# @return [FilesystemLoader] loader for the directory
|
|
26
|
+
# @example
|
|
27
|
+
# RubyLlm::Skills.from_directory("app/skills")
|
|
28
|
+
def from_directory(path = default_path)
|
|
29
|
+
FilesystemLoader.new(path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load a single skill from a directory.
|
|
33
|
+
#
|
|
34
|
+
# @param path [String] path to skill directory (containing SKILL.md)
|
|
35
|
+
# @return [Skill] the loaded skill
|
|
36
|
+
# @raise [LoadError] if SKILL.md not found
|
|
37
|
+
# @example
|
|
38
|
+
# RubyLlm::Skills.load("app/skills/my-skill")
|
|
39
|
+
def load(path)
|
|
40
|
+
skill_md = File.join(path, "SKILL.md")
|
|
41
|
+
raise LoadError, "SKILL.md not found in #{path}" unless File.exist?(skill_md)
|
|
42
|
+
|
|
43
|
+
metadata = Parser.parse_file(skill_md)
|
|
44
|
+
Skill.new(path: path, metadata: metadata)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Load skills from a zip archive.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] path to .zip file
|
|
50
|
+
# @return [ZipLoader] loader for the archive
|
|
51
|
+
# @raise [LoadError] if rubyzip not available
|
|
52
|
+
# @example
|
|
53
|
+
# RubyLlm::Skills.from_zip("skills.zip")
|
|
54
|
+
def from_zip(path)
|
|
55
|
+
require_relative "skills/zip_loader"
|
|
56
|
+
ZipLoader.new(path)
|
|
57
|
+
rescue ::LoadError
|
|
58
|
+
raise LoadError, "rubyzip gem required for zip support. Add 'gem \"rubyzip\"' to your Gemfile."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Load skills from database records.
|
|
62
|
+
#
|
|
63
|
+
# @param records [ActiveRecord::Relation, Array] collection of skill records
|
|
64
|
+
# @return [DatabaseLoader] loader for the records
|
|
65
|
+
# @example
|
|
66
|
+
# RubyLlm::Skills.from_database(Skill.where(active: true))
|
|
67
|
+
def from_database(records)
|
|
68
|
+
require_relative "skills/database_loader"
|
|
69
|
+
DatabaseLoader.new(records)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Create a composite loader from multiple sources.
|
|
73
|
+
#
|
|
74
|
+
# @param loaders [Array<Loader>] loaders to combine
|
|
75
|
+
# @return [CompositeLoader] combined loader
|
|
76
|
+
# @example
|
|
77
|
+
# RubyLlm::Skills.compose(
|
|
78
|
+
# RubyLlm::Skills.from_directory("app/skills"),
|
|
79
|
+
# RubyLlm::Skills.from_database(Skill.all)
|
|
80
|
+
# )
|
|
81
|
+
def compose(*loaders)
|
|
82
|
+
require_relative "skills/composite_loader"
|
|
83
|
+
CompositeLoader.new(loaders)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
self.default_path = "app/skills"
|
|
88
|
+
end
|
|
89
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_llm-skills
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kieran Klaassen
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-01-16 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Load, validate, and integrate Agent Skills with RubyLLM. Supports the
|
|
13
|
+
open Agent Skills specification for progressive skill discovery and loading from
|
|
14
|
+
filesystem, zip archives, and databases.
|
|
15
|
+
email:
|
|
16
|
+
- kieranklaassen@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/generators/skill/skill_generator.rb
|
|
25
|
+
- lib/generators/skill/templates/SKILL.md.tt
|
|
26
|
+
- lib/ruby_llm/skills.rb
|
|
27
|
+
- lib/ruby_llm/skills/chat_extensions.rb
|
|
28
|
+
- lib/ruby_llm/skills/composite_loader.rb
|
|
29
|
+
- lib/ruby_llm/skills/database_loader.rb
|
|
30
|
+
- lib/ruby_llm/skills/error.rb
|
|
31
|
+
- lib/ruby_llm/skills/filesystem_loader.rb
|
|
32
|
+
- lib/ruby_llm/skills/loader.rb
|
|
33
|
+
- lib/ruby_llm/skills/parser.rb
|
|
34
|
+
- lib/ruby_llm/skills/railtie.rb
|
|
35
|
+
- lib/ruby_llm/skills/skill.rb
|
|
36
|
+
- lib/ruby_llm/skills/skill_tool.rb
|
|
37
|
+
- lib/ruby_llm/skills/tasks/skills.rake
|
|
38
|
+
- lib/ruby_llm/skills/validator.rb
|
|
39
|
+
- lib/ruby_llm/skills/version.rb
|
|
40
|
+
- lib/ruby_llm/skills/zip_loader.rb
|
|
41
|
+
homepage: https://github.com/kieranklaassen/ruby_llm-skills
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata:
|
|
45
|
+
homepage_uri: https://github.com/kieranklaassen/ruby_llm-skills
|
|
46
|
+
source_code_uri: https://github.com/kieranklaassen/ruby_llm-skills
|
|
47
|
+
changelog_uri: https://github.com/kieranklaassen/ruby_llm-skills/blob/main/CHANGELOG.md
|
|
48
|
+
rubygems_mfa_required: 'true'
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: 3.1.0
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 3.6.2
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Agent Skills extension for RubyLLM
|
|
66
|
+
test_files: []
|