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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
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 = RubyLlm::Skills.from_directory("app/skills")
19
- # skill_tool = RubyLlm::Skills::SkillTool.new(loader)
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
- Load a skill to get specialized instructions for a task.
60
+ #{base_description}
50
61
 
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.
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
- # 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.
73
+ # Execute the tool to load a skill's content or a specific resource.
75
74
  #
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)
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 '#{skill_name}' not found. Available skills: #{available}"
84
+ return "Skill '#{command}' not found. Available skills: #{available}"
84
85
  end
85
86
 
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)
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: 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 << "- #{File.basename(s)}" }
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 << "- #{File.basename(r)}" }
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 << "- #{File.basename(a)}" }
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('"', "&quot;")
162
174
  .gsub("'", "&apos;")
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
5
  # Validates skill structure according to the Agent Skills specification.
6
6
  #
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -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
- module RubyLlm
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, :logger
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
- # RubyLlm::Skills.from_directory("app/skills")
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
- # RubyLlm::Skills.load("app/skills/my-skill")
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
- # RubyLlm::Skills.from_database(Skill.where(active: true))
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
- # RubyLlm::Skills.compose(
78
- # RubyLlm::Skills.from_directory("app/skills"),
79
- # RubyLlm::Skills.from_database(Skill.all)
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.1.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-01-16 00:00:00.000000000 Z
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.1.0
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