ruby_llm-skills 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +77 -316
- data/lib/generators/skill/USAGE +29 -0
- data/lib/generators/skill/skill_generator.rb +14 -5
- data/lib/generators/skill/templates/SKILL.md.tt +43 -7
- data/lib/ruby_llm/skills/agent_extensions.rb +148 -0
- data/lib/ruby_llm/skills/chat_extensions.rb +85 -32
- data/lib/ruby_llm/skills/composite_loader.rb +1 -1
- data/lib/ruby_llm/skills/database_loader.rb +20 -91
- data/lib/ruby_llm/skills/error.rb +1 -1
- data/lib/ruby_llm/skills/filesystem_loader.rb +43 -10
- data/lib/ruby_llm/skills/loader.rb +1 -1
- data/lib/ruby_llm/skills/parser.rb +1 -1
- data/lib/ruby_llm/skills/railtie.rb +14 -5
- data/lib/ruby_llm/skills/skill.rb +21 -6
- data/lib/ruby_llm/skills/skill_tool.rb +82 -40
- data/lib/ruby_llm/skills/validator.rb +1 -1
- data/lib/ruby_llm/skills/version.rb +2 -2
- data/lib/ruby_llm/skills.rb +60 -24
- metadata +20 -5
- data/lib/ruby_llm/skills/zip_loader.rb +0 -129
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
4
6
|
module Skills
|
|
5
7
|
# A RubyLLM Tool that enables progressive skill loading.
|
|
6
8
|
#
|
|
@@ -15,15 +17,23 @@ module RubyLlm
|
|
|
15
17
|
# 3. Resources (scripts, references) can be loaded separately as needed
|
|
16
18
|
#
|
|
17
19
|
# @example Basic usage
|
|
18
|
-
# loader =
|
|
19
|
-
# skill_tool =
|
|
20
|
+
# loader = RubyLLM::Skills.from_directory("app/skills")
|
|
21
|
+
# skill_tool = RubyLLM::Skills::SkillTool.new(loader)
|
|
20
22
|
#
|
|
21
23
|
# chat.with_tools(skill_tool)
|
|
22
24
|
# chat.ask("Help me generate a PDF report")
|
|
23
25
|
# # LLM sees available skills, calls skill_tool with name="pdf-report"
|
|
24
26
|
# # Tool returns full SKILL.md content for LLM to follow
|
|
25
27
|
#
|
|
26
|
-
class SkillTool
|
|
28
|
+
class SkillTool < RubyLLM::Tool
|
|
29
|
+
description "Execute a skill within the main conversation."
|
|
30
|
+
param :command, type: "string",
|
|
31
|
+
desc: "The skill name (e.g., 'pdf' or 'write-poem')"
|
|
32
|
+
param :arguments, type: "string", required: false,
|
|
33
|
+
desc: "Arguments passed after the command (e.g., '/write-poem about robots' passes 'about robots')"
|
|
34
|
+
param :resource, type: "string", required: false,
|
|
35
|
+
desc: "Optional resource path to load (e.g., 'scripts/helper.rb', 'references/guide.md')"
|
|
36
|
+
|
|
27
37
|
attr_reader :loader
|
|
28
38
|
|
|
29
39
|
# Initialize with a skill loader.
|
|
@@ -44,51 +54,41 @@ module RubyLlm
|
|
|
44
54
|
#
|
|
45
55
|
# @return [String] tool description with embedded skill metadata
|
|
46
56
|
def description
|
|
57
|
+
base_description = self.class.description
|
|
47
58
|
skills_xml = build_skills_xml
|
|
48
59
|
<<~DESC.strip
|
|
49
|
-
|
|
60
|
+
#{base_description}
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
When to use this tool:
|
|
63
|
+
- When the user's message starts with "/" followed by a skill name (e.g., "/write-poem about robots"), invoke this tool with that command and pass any text after the command name as arguments
|
|
64
|
+
- When the user's request matches one of the available skills below
|
|
65
|
+
|
|
66
|
+
Call with command (and optional arguments) to get the full skill instructions.
|
|
67
|
+
Call with command and resource to load a specific file (script, reference, or asset).
|
|
53
68
|
|
|
54
69
|
#{skills_xml}
|
|
55
70
|
DESC
|
|
56
71
|
end
|
|
57
72
|
|
|
58
|
-
#
|
|
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.
|
|
73
|
+
# Execute the tool to load a skill's content or a specific resource.
|
|
75
74
|
#
|
|
76
|
-
# @param
|
|
77
|
-
# @
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
# @param command [String] name of skill to load
|
|
76
|
+
# @param arguments [String, nil] optional arguments passed with the command
|
|
77
|
+
# @param resource [String, nil] optional resource path within the skill
|
|
78
|
+
# @return [String] skill content, resource content, or error message
|
|
79
|
+
def execute(command:, arguments: nil, resource: nil)
|
|
80
|
+
skill = @loader.find(command)
|
|
80
81
|
|
|
81
82
|
unless skill
|
|
82
83
|
available = @loader.list.map(&:name).join(", ")
|
|
83
|
-
return "Skill '#{
|
|
84
|
+
return "Skill '#{command}' not found. Available skills: #{available}"
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
call(skill_name: skill_name)
|
|
87
|
+
if resource
|
|
88
|
+
load_resource(skill, resource)
|
|
89
|
+
else
|
|
90
|
+
build_skill_response(skill, arguments: arguments)
|
|
91
|
+
end
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# Convert to RubyLLM Tool-compatible format.
|
|
@@ -98,7 +98,7 @@ module RubyLlm
|
|
|
98
98
|
{
|
|
99
99
|
name: name,
|
|
100
100
|
description: description,
|
|
101
|
-
parameters:
|
|
101
|
+
parameters: params_schema
|
|
102
102
|
}
|
|
103
103
|
end
|
|
104
104
|
|
|
@@ -122,29 +122,41 @@ module RubyLlm
|
|
|
122
122
|
xml_parts.join("\n")
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
def build_skill_response(skill)
|
|
125
|
+
def build_skill_response(skill, arguments: nil)
|
|
126
126
|
parts = []
|
|
127
127
|
parts << "# Skill: #{skill.name}"
|
|
128
|
+
if arguments && !arguments.strip.empty?
|
|
129
|
+
parts << "# Arguments: #{arguments}"
|
|
130
|
+
end
|
|
128
131
|
parts << ""
|
|
129
132
|
parts << skill.content
|
|
130
133
|
parts << ""
|
|
131
134
|
|
|
132
135
|
# Include resource information if available
|
|
136
|
+
has_resources = skill.scripts.any? || skill.references.any? || skill.assets.any?
|
|
137
|
+
|
|
133
138
|
if skill.scripts.any?
|
|
134
139
|
parts << "## Available Scripts"
|
|
135
|
-
skill.scripts.each { |s| parts << "-
|
|
140
|
+
skill.scripts.each { |s| parts << "- scripts/#{File.basename(s)}" }
|
|
136
141
|
parts << ""
|
|
137
142
|
end
|
|
138
143
|
|
|
139
144
|
if skill.references.any?
|
|
140
145
|
parts << "## Available References"
|
|
141
|
-
skill.references.each { |r| parts << "-
|
|
146
|
+
skill.references.each { |r| parts << "- references/#{File.basename(r)}" }
|
|
142
147
|
parts << ""
|
|
143
148
|
end
|
|
144
149
|
|
|
145
150
|
if skill.assets.any?
|
|
146
151
|
parts << "## Available Assets"
|
|
147
|
-
skill.assets.each { |a| parts << "-
|
|
152
|
+
skill.assets.each { |a| parts << "- assets/#{File.basename(a)}" }
|
|
153
|
+
parts << ""
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if has_resources
|
|
157
|
+
parts << "---"
|
|
158
|
+
parts << "To load a resource, call this tool again with resource parameter."
|
|
159
|
+
parts << "Example: command=\"#{skill.name}\", resource=\"scripts/example.rb\""
|
|
148
160
|
parts << ""
|
|
149
161
|
end
|
|
150
162
|
|
|
@@ -161,6 +173,36 @@ module RubyLlm
|
|
|
161
173
|
.gsub('"', """)
|
|
162
174
|
.gsub("'", "'")
|
|
163
175
|
end
|
|
176
|
+
|
|
177
|
+
def load_resource(skill, resource_path)
|
|
178
|
+
return "Cannot load resources from virtual skills" if skill.virtual?
|
|
179
|
+
|
|
180
|
+
# Prevent path traversal
|
|
181
|
+
if resource_path.include?("..") || resource_path.start_with?("/")
|
|
182
|
+
return "Invalid resource path: #{resource_path}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
full_path = File.join(skill.path, resource_path)
|
|
186
|
+
|
|
187
|
+
unless File.exist?(full_path)
|
|
188
|
+
available = list_available_resources(skill)
|
|
189
|
+
return "Resource '#{resource_path}' not found in skill '#{skill.name}'. Available: #{available}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
unless File.file?(full_path)
|
|
193
|
+
return "Resource '#{resource_path}' is not a file"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
content = File.read(full_path)
|
|
197
|
+
"# Resource: #{resource_path}\n\n#{content}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def list_available_resources(skill)
|
|
201
|
+
resources = skill.scripts + skill.references + skill.assets
|
|
202
|
+
return "none" if resources.empty?
|
|
203
|
+
|
|
204
|
+
resources.map { |r| r.sub("#{skill.path}/", "") }.join(", ")
|
|
205
|
+
end
|
|
164
206
|
end
|
|
165
207
|
end
|
|
166
208
|
end
|
data/lib/ruby_llm/skills.rb
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# RubyLLM 1.12.0 Agent uses `delegate` but doesn't require ActiveSupport.
|
|
4
|
+
# Provide a minimal fallback for plain Ruby environments.
|
|
5
|
+
unless Module.method_defined?(:delegate)
|
|
6
|
+
class Module
|
|
7
|
+
def delegate(*methods, to:, prefix: nil, allow_nil: false, private: false, **_options)
|
|
8
|
+
target_method = to.to_sym
|
|
9
|
+
|
|
10
|
+
methods.each do |method_name|
|
|
11
|
+
delegated_method = delegated_method_name(method_name, target_method, prefix)
|
|
12
|
+
|
|
13
|
+
define_method(delegated_method) do |*args, **kwargs, &block|
|
|
14
|
+
target = public_send(target_method)
|
|
15
|
+
if target.nil?
|
|
16
|
+
return nil if allow_nil
|
|
17
|
+
|
|
18
|
+
raise NoMethodError,
|
|
19
|
+
"#{self.class}##{delegated_method} delegated to ##{target_method}, but ##{target_method} is nil"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if kwargs.empty?
|
|
23
|
+
target.public_send(method_name, *args, &block)
|
|
24
|
+
else
|
|
25
|
+
target.public_send(method_name, *args, **kwargs, &block)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private delegated_method if binding.local_variable_get(:private)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def delegated_method_name(method_name, target_method, prefix)
|
|
36
|
+
case prefix
|
|
37
|
+
when true
|
|
38
|
+
:"#{target_method}_#{method_name}"
|
|
39
|
+
when String, Symbol
|
|
40
|
+
:"#{prefix}_#{method_name}"
|
|
41
|
+
else
|
|
42
|
+
method_name
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
require "ruby_llm"
|
|
49
|
+
|
|
3
50
|
require_relative "skills/version"
|
|
4
51
|
require_relative "skills/error"
|
|
5
52
|
require_relative "skills/parser"
|
|
@@ -7,24 +54,27 @@ require_relative "skills/validator"
|
|
|
7
54
|
require_relative "skills/skill"
|
|
8
55
|
require_relative "skills/loader"
|
|
9
56
|
require_relative "skills/filesystem_loader"
|
|
10
|
-
require_relative "skills/composite_loader"
|
|
11
|
-
require_relative "skills/skill_tool"
|
|
12
57
|
require_relative "skills/chat_extensions"
|
|
58
|
+
require_relative "skills/agent_extensions"
|
|
13
59
|
|
|
14
60
|
# Load Rails integration when Rails is available
|
|
15
61
|
require_relative "skills/railtie" if defined?(Rails::Railtie)
|
|
16
62
|
|
|
17
|
-
|
|
63
|
+
# Extend RubyLLM::Chat with skill methods
|
|
64
|
+
RubyLLM::Chat.include(RubyLLM::Skills::ChatExtensions)
|
|
65
|
+
RubyLLM::Agent.include(RubyLLM::Skills::AgentExtensions)
|
|
66
|
+
|
|
67
|
+
module RubyLLM
|
|
18
68
|
module Skills
|
|
19
69
|
class << self
|
|
20
|
-
attr_accessor :default_path
|
|
70
|
+
attr_accessor :default_path
|
|
21
71
|
|
|
22
72
|
# Load skills from a filesystem directory.
|
|
23
73
|
#
|
|
24
74
|
# @param path [String] path to skills directory (defaults to default_path)
|
|
25
75
|
# @return [FilesystemLoader] loader for the directory
|
|
26
76
|
# @example
|
|
27
|
-
#
|
|
77
|
+
# RubyLLM::Skills.from_directory("app/skills")
|
|
28
78
|
def from_directory(path = default_path)
|
|
29
79
|
FilesystemLoader.new(path)
|
|
30
80
|
end
|
|
@@ -35,7 +85,7 @@ module RubyLlm
|
|
|
35
85
|
# @return [Skill] the loaded skill
|
|
36
86
|
# @raise [LoadError] if SKILL.md not found
|
|
37
87
|
# @example
|
|
38
|
-
#
|
|
88
|
+
# RubyLLM::Skills.load("app/skills/my-skill")
|
|
39
89
|
def load(path)
|
|
40
90
|
skill_md = File.join(path, "SKILL.md")
|
|
41
91
|
raise LoadError, "SKILL.md not found in #{path}" unless File.exist?(skill_md)
|
|
@@ -44,26 +94,12 @@ module RubyLlm
|
|
|
44
94
|
Skill.new(path: path, metadata: metadata)
|
|
45
95
|
end
|
|
46
96
|
|
|
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
97
|
# Load skills from database records.
|
|
62
98
|
#
|
|
63
99
|
# @param records [ActiveRecord::Relation, Array] collection of skill records
|
|
64
100
|
# @return [DatabaseLoader] loader for the records
|
|
65
101
|
# @example
|
|
66
|
-
#
|
|
102
|
+
# RubyLLM::Skills.from_database(Skill.where(active: true))
|
|
67
103
|
def from_database(records)
|
|
68
104
|
require_relative "skills/database_loader"
|
|
69
105
|
DatabaseLoader.new(records)
|
|
@@ -74,9 +110,9 @@ module RubyLlm
|
|
|
74
110
|
# @param loaders [Array<Loader>] loaders to combine
|
|
75
111
|
# @return [CompositeLoader] combined loader
|
|
76
112
|
# @example
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
113
|
+
# RubyLLM::Skills.compose(
|
|
114
|
+
# RubyLLM::Skills.from_directory("app/skills"),
|
|
115
|
+
# RubyLLM::Skills.from_database(Skill.all)
|
|
80
116
|
# )
|
|
81
117
|
def compose(*loaders)
|
|
82
118
|
require_relative "skills/composite_loader"
|
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_llm-skills
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kieran Klaassen
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
11
|
-
dependencies:
|
|
10
|
+
date: 2026-02-17 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ruby_llm
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.12'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.12'
|
|
12
26
|
description: Load, validate, and integrate Agent Skills with RubyLLM. Supports the
|
|
13
27
|
open Agent Skills specification for progressive skill discovery and loading from
|
|
14
28
|
filesystem, zip archives, and databases.
|
|
@@ -21,9 +35,11 @@ files:
|
|
|
21
35
|
- CHANGELOG.md
|
|
22
36
|
- LICENSE.txt
|
|
23
37
|
- README.md
|
|
38
|
+
- lib/generators/skill/USAGE
|
|
24
39
|
- lib/generators/skill/skill_generator.rb
|
|
25
40
|
- lib/generators/skill/templates/SKILL.md.tt
|
|
26
41
|
- lib/ruby_llm/skills.rb
|
|
42
|
+
- lib/ruby_llm/skills/agent_extensions.rb
|
|
27
43
|
- lib/ruby_llm/skills/chat_extensions.rb
|
|
28
44
|
- lib/ruby_llm/skills/composite_loader.rb
|
|
29
45
|
- lib/ruby_llm/skills/database_loader.rb
|
|
@@ -37,7 +53,6 @@ files:
|
|
|
37
53
|
- lib/ruby_llm/skills/tasks/skills.rake
|
|
38
54
|
- lib/ruby_llm/skills/validator.rb
|
|
39
55
|
- lib/ruby_llm/skills/version.rb
|
|
40
|
-
- lib/ruby_llm/skills/zip_loader.rb
|
|
41
56
|
homepage: https://github.com/kieranklaassen/ruby_llm-skills
|
|
42
57
|
licenses:
|
|
43
58
|
- MIT
|
|
@@ -53,7 +68,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
53
68
|
requirements:
|
|
54
69
|
- - ">="
|
|
55
70
|
- !ruby/object:Gem::Version
|
|
56
|
-
version: 3.
|
|
71
|
+
version: 3.2.0
|
|
57
72
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
73
|
requirements:
|
|
59
74
|
- - ">="
|
|
@@ -1,129 +0,0 @@
|
|
|
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
|