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.
@@ -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("&", "&amp;")
159
+ .gsub("<", "&lt;")
160
+ .gsub(">", "&gt;")
161
+ .gsub('"', "&quot;")
162
+ .gsub("'", "&apos;")
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []