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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Loads skills from database records using duck-typing.
6
+ #
7
+ # Records must respond to either:
8
+ # - Text storage: #name, #description, #content
9
+ # - Binary storage: #name, #description, #data (zip blob)
10
+ #
11
+ # Optional methods: #license, #compatibility, #metadata
12
+ #
13
+ # @example With text content
14
+ # class SkillRecord
15
+ # attr_accessor :name, :description, :content
16
+ # end
17
+ # loader = DatabaseLoader.new(SkillRecord.all)
18
+ #
19
+ # @example With ActiveRecord
20
+ # loader = DatabaseLoader.new(Skill.where(active: true))
21
+ #
22
+ class DatabaseLoader < Loader
23
+ attr_reader :records
24
+
25
+ # Initialize with a collection of records.
26
+ #
27
+ # @param records [Enumerable] collection responding to #each
28
+ def initialize(records)
29
+ super()
30
+ @records = records
31
+ end
32
+
33
+ # List all skills from the records.
34
+ #
35
+ # @return [Array<Skill>] skills from records
36
+ def list
37
+ skills
38
+ end
39
+
40
+ # Reload skills by re-iterating records.
41
+ # Also reloads the records if they respond to #reload.
42
+ #
43
+ # @return [self]
44
+ def reload!
45
+ @records.reload if @records.respond_to?(:reload)
46
+ super
47
+ end
48
+
49
+ protected
50
+
51
+ def load_all
52
+ @records.filter_map do |record|
53
+ load_skill_from_record(record)
54
+ rescue => e
55
+ warn "Warning: Failed to load skill from record: #{e.message}" if RubyLlm::Skills.logger
56
+ nil
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def load_skill_from_record(record)
63
+ if binary_storage?(record)
64
+ load_from_binary(record)
65
+ else
66
+ load_from_text(record)
67
+ end
68
+ end
69
+
70
+ def binary_storage?(record)
71
+ record.respond_to?(:data) && record.data.present?
72
+ end
73
+
74
+ def load_from_text(record)
75
+ validate_text_record!(record)
76
+
77
+ metadata = build_metadata(record)
78
+ metadata["__content__"] = record.content.to_s
79
+
80
+ Skill.new(
81
+ path: "database:#{record_id(record)}",
82
+ metadata: metadata
83
+ )
84
+ end
85
+
86
+ def load_from_binary(record)
87
+ # Extract skill from zip data
88
+ require "zip"
89
+ require "stringio"
90
+
91
+ io = StringIO.new(record.data)
92
+ Zip::File.open_buffer(io) do |zip|
93
+ skill_md_entry = zip.find_entry("SKILL.md")
94
+ raise LoadError, "SKILL.md not found in binary data" unless skill_md_entry
95
+
96
+ content = skill_md_entry.get_input_stream.read
97
+ metadata = Parser.parse_string(content)
98
+ body = Parser.extract_body(content)
99
+
100
+ # Override name/description from record if present
101
+ metadata["name"] = record.name if record.respond_to?(:name) && record.name
102
+ metadata["description"] = record.description if record.respond_to?(:description) && record.description
103
+ metadata["__content__"] = body
104
+
105
+ Skill.new(
106
+ path: "database:#{record_id(record)}",
107
+ metadata: metadata
108
+ )
109
+ end
110
+ rescue ::LoadError
111
+ raise LoadError, "rubyzip gem required for binary storage. Add 'gem \"rubyzip\"' to your Gemfile."
112
+ end
113
+
114
+ def validate_text_record!(record)
115
+ raise InvalidSkillError, "Record must respond to #name" unless record.respond_to?(:name)
116
+ raise InvalidSkillError, "Record must respond to #description" unless record.respond_to?(:description)
117
+ raise InvalidSkillError, "Record must respond to #content" unless record.respond_to?(:content)
118
+ end
119
+
120
+ def build_metadata(record)
121
+ metadata = {
122
+ "name" => record.name.to_s,
123
+ "description" => record.description.to_s
124
+ }
125
+
126
+ metadata["license"] = record.license.to_s if record.respond_to?(:license) && record.license
127
+ metadata["compatibility"] = record.compatibility.to_s if record.respond_to?(:compatibility) && record.compatibility
128
+
129
+ if record.respond_to?(:skill_metadata) && record.skill_metadata.is_a?(Hash)
130
+ metadata["metadata"] = record.skill_metadata
131
+ end
132
+
133
+ metadata
134
+ end
135
+
136
+ def record_id(record)
137
+ if record.respond_to?(:id)
138
+ record.id
139
+ elsif record.respond_to?(:name)
140
+ record.name
141
+ else
142
+ record.object_id
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Base error class for all skills-related errors.
6
+ # Rescue this to catch any error from the gem.
7
+ class Error < StandardError; end
8
+
9
+ # Raised when a skill has invalid structure or content.
10
+ class InvalidSkillError < Error; end
11
+
12
+ # Raised when a requested skill cannot be found.
13
+ class NotFoundError < Error; end
14
+
15
+ # Raised when skill loading fails (filesystem, zip, database).
16
+ class LoadError < Error; end
17
+
18
+ # Raised when YAML frontmatter parsing fails.
19
+ class ParseError < Error; end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Loads skills from a filesystem directory.
6
+ #
7
+ # Scans a directory for subdirectories containing SKILL.md files.
8
+ # Each subdirectory is treated as a skill if it contains a valid SKILL.md.
9
+ #
10
+ # @example
11
+ # loader = FilesystemLoader.new("app/skills")
12
+ # loader.list # => [Skill, Skill, ...]
13
+ #
14
+ class FilesystemLoader < Loader
15
+ attr_reader :path
16
+
17
+ # Initialize with a directory path.
18
+ #
19
+ # @param path [String, Pathname] path to skills directory
20
+ def initialize(path)
21
+ super()
22
+ @path = path.to_s
23
+ end
24
+
25
+ # List all skills from the directory.
26
+ #
27
+ # @return [Array<Skill>] skills found in directory
28
+ def list
29
+ skills
30
+ end
31
+
32
+ protected
33
+
34
+ def load_all
35
+ return [] unless File.directory?(@path)
36
+
37
+ Dir.glob(File.join(@path, "*", "SKILL.md")).filter_map do |skill_md_path|
38
+ load_skill(skill_md_path)
39
+ rescue ParseError => e
40
+ warn "Warning: Failed to parse #{skill_md_path}: #{e.message}" if RubyLlm::Skills.logger
41
+ nil
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def load_skill(skill_md_path)
48
+ skill_dir = File.dirname(skill_md_path)
49
+ metadata = Parser.parse_file(skill_md_path)
50
+
51
+ Skill.new(path: skill_dir, metadata: metadata)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Base class for skill loaders.
6
+ #
7
+ # Loaders are responsible for discovering and loading skills from various sources:
8
+ # - FilesystemLoader: loads from directory structures
9
+ # - ZipLoader: loads from .zip archives
10
+ # - DatabaseLoader: loads from ActiveRecord models
11
+ #
12
+ # @example
13
+ # loader = FilesystemLoader.new("app/skills")
14
+ # loader.list # => [skill1, skill2, ...]
15
+ # loader.find("name") # => Skill or nil
16
+ # loader.get("name") # => Skill or raises NotFoundError
17
+ #
18
+ class Loader
19
+ # List all skills from this source.
20
+ #
21
+ # @return [Array<Skill>] collection of skills
22
+ def list
23
+ raise NotImplementedError, "#{self.class}#list must be implemented"
24
+ end
25
+
26
+ # Find a skill by name.
27
+ #
28
+ # @param name [String] skill name
29
+ # @return [Skill, nil] skill or nil if not found
30
+ def find(name)
31
+ list.find { |skill| skill.name == name }
32
+ end
33
+
34
+ # Get a skill by name, raising if not found.
35
+ #
36
+ # @param name [String] skill name
37
+ # @return [Skill] the found skill
38
+ # @raise [NotFoundError] if skill not found
39
+ def get(name)
40
+ skill = find(name)
41
+ raise NotFoundError, "Skill not found: #{name}" unless skill
42
+
43
+ skill
44
+ end
45
+
46
+ # Check if a skill exists.
47
+ #
48
+ # @param name [String] skill name
49
+ # @return [Boolean] true if skill exists
50
+ def exists?(name)
51
+ !find(name).nil?
52
+ end
53
+
54
+ # Reload all skills, clearing any cache.
55
+ #
56
+ # @return [self]
57
+ def reload!
58
+ @skills = nil
59
+ self
60
+ end
61
+
62
+ protected
63
+
64
+ # Cache skills from #load_all for performance.
65
+ def skills
66
+ @skills ||= load_all
67
+ end
68
+
69
+ # Subclasses must implement this to load all skills.
70
+ #
71
+ # @return [Array<Skill>] all skills from source
72
+ def load_all
73
+ raise NotImplementedError, "#{self.class}#load_all must be implemented"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubyLlm
6
+ module Skills
7
+ # Parses SKILL.md files with YAML frontmatter.
8
+ #
9
+ # A valid SKILL.md has the format:
10
+ # ---
11
+ # name: skill-name
12
+ # description: What the skill does
13
+ # ---
14
+ # # Skill content here
15
+ #
16
+ class Parser
17
+ # Regex to match YAML frontmatter between --- delimiters
18
+ FRONTMATTER_REGEX = /\A---\n(.*?)\n---\n?(.*)/m
19
+
20
+ class << self
21
+ # Parse a SKILL.md file and extract frontmatter metadata.
22
+ #
23
+ # @param path [String, Pathname] path to SKILL.md file
24
+ # @return [Hash] parsed frontmatter as hash
25
+ # @raise [ParseError] if frontmatter is missing or invalid
26
+ def parse_file(path)
27
+ content = File.read(path)
28
+ parse_string(content)
29
+ rescue Errno::ENOENT
30
+ raise ParseError, "File not found: #{path}"
31
+ rescue Errno::EACCES
32
+ raise ParseError, "Permission denied: #{path}"
33
+ end
34
+
35
+ # Parse SKILL.md content string and extract frontmatter.
36
+ #
37
+ # @param content [String] full SKILL.md content
38
+ # @return [Hash] parsed frontmatter as hash
39
+ # @raise [ParseError] if frontmatter is missing or invalid
40
+ def parse_string(content)
41
+ match = content.match(FRONTMATTER_REGEX)
42
+
43
+ unless match
44
+ raise ParseError, "Missing YAML frontmatter (must start with ---)"
45
+ end
46
+
47
+ yaml_content = match[1]
48
+ parse_yaml(yaml_content)
49
+ end
50
+
51
+ # Extract the body content (everything after frontmatter).
52
+ #
53
+ # @param content [String] full SKILL.md content
54
+ # @return [String] body content without frontmatter
55
+ def extract_body(content)
56
+ match = content.match(FRONTMATTER_REGEX)
57
+ return "" unless match
58
+
59
+ match[2].to_s.strip
60
+ end
61
+
62
+ private
63
+
64
+ def parse_yaml(yaml_content)
65
+ # Use safe_load with permitted classes for security
66
+ YAML.safe_load(
67
+ yaml_content,
68
+ permitted_classes: [Symbol],
69
+ permitted_symbols: [],
70
+ aliases: false
71
+ ) || {}
72
+ rescue Psych::SyntaxError => e
73
+ raise ParseError, "Invalid YAML frontmatter: #{e.message}"
74
+ rescue Psych::DisallowedClass => e
75
+ raise ParseError, "Disallowed class in YAML: #{e.message}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Rails integration for RubyLLM::Skills.
6
+ #
7
+ # Sets up the default skills path to Rails.root/app/skills
8
+ # and configures autoloading of skill files.
9
+ #
10
+ class Railtie < ::Rails::Railtie
11
+ initializer "ruby_llm_skills.configure" do
12
+ RubyLlm::Skills.default_path = Rails.root.join("app", "skills").to_s
13
+ end
14
+
15
+ # Add app/skills to autoload paths
16
+ initializer "ruby_llm_skills.autoload_paths" do |app|
17
+ skills_path = Rails.root.join("app", "skills")
18
+ if skills_path.exist?
19
+ app.config.autoload_paths << skills_path.to_s
20
+ end
21
+ end
22
+
23
+ # Provide rake tasks
24
+ rake_tasks do
25
+ load File.expand_path("tasks/skills.rake", __dir__)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Represents a single skill with its metadata and content.
6
+ #
7
+ # Skills follow a progressive disclosure pattern:
8
+ # - Level 1: Metadata (name, description) - loaded immediately
9
+ # - Level 2: Content (SKILL.md body) - loaded lazily on demand
10
+ # - Level 3: Resources (scripts, references, assets) - loaded lazily
11
+ #
12
+ # @example
13
+ # skill = Skill.new(
14
+ # path: "app/skills/pdf-report",
15
+ # metadata: { "name" => "pdf-report", "description" => "Generate PDFs" }
16
+ # )
17
+ # skill.name # => "pdf-report" (immediate)
18
+ # skill.content # => loads SKILL.md body (lazy)
19
+ # skill.scripts # => lists script files (lazy)
20
+ #
21
+ class Skill
22
+ attr_reader :path, :metadata
23
+
24
+ # Initialize a skill from parsed metadata.
25
+ #
26
+ # @param path [String] path to skill directory or virtual identifier
27
+ # @param metadata [Hash] parsed YAML frontmatter
28
+ # @param content [String, nil] pre-loaded content (optional)
29
+ def initialize(path:, metadata:, content: nil)
30
+ @path = path.to_s
31
+ @metadata = metadata || {}
32
+ @content = content
33
+ end
34
+
35
+ # @return [String] skill name from frontmatter
36
+ def name
37
+ @metadata["name"]
38
+ end
39
+
40
+ # @return [String] skill description from frontmatter
41
+ def description
42
+ @metadata["description"]
43
+ end
44
+
45
+ # @return [String, nil] license from frontmatter
46
+ def license
47
+ @metadata["license"]
48
+ end
49
+
50
+ # @return [String, nil] compatibility info from frontmatter
51
+ def compatibility
52
+ @metadata["compatibility"]
53
+ end
54
+
55
+ # @return [Hash] custom metadata key-value pairs
56
+ def custom_metadata
57
+ @metadata["metadata"] || {}
58
+ end
59
+
60
+ # @return [Array<String>] list of allowed tools (experimental)
61
+ def allowed_tools
62
+ (@metadata["allowed-tools"] || "").split
63
+ end
64
+
65
+ # Get the full SKILL.md content (body without frontmatter).
66
+ # Content is loaded lazily on first access.
67
+ #
68
+ # @return [String] skill instructions
69
+ def content
70
+ @content ||= load_content
71
+ end
72
+
73
+ # List script files in the scripts/ directory.
74
+ # Loaded lazily on first access.
75
+ #
76
+ # @return [Array<String>] paths to script files
77
+ def scripts
78
+ @scripts ||= list_resources("scripts")
79
+ end
80
+
81
+ # List reference files in the references/ directory.
82
+ # Loaded lazily on first access.
83
+ #
84
+ # @return [Array<String>] paths to reference files
85
+ def references
86
+ @references ||= list_resources("references")
87
+ end
88
+
89
+ # List asset files in the assets/ directory.
90
+ # Loaded lazily on first access.
91
+ #
92
+ # @return [Array<String>] paths to asset files
93
+ def assets
94
+ @assets ||= list_resources("assets")
95
+ end
96
+
97
+ # Check if skill path is a filesystem path (vs database virtual path).
98
+ #
99
+ # @return [Boolean] true if path points to filesystem
100
+ def filesystem?
101
+ !virtual?
102
+ end
103
+
104
+ # Check if skill is a virtual/database skill.
105
+ #
106
+ # @return [Boolean] true if path is a virtual identifier
107
+ def virtual?
108
+ @path.start_with?("database:")
109
+ end
110
+
111
+ # Path to the SKILL.md file.
112
+ #
113
+ # @return [String, nil] path to SKILL.md or nil for virtual skills
114
+ def skill_md_path
115
+ return nil if virtual?
116
+
117
+ File.join(@path, "SKILL.md")
118
+ end
119
+
120
+ # Validate the skill structure.
121
+ #
122
+ # @return [Boolean] true if skill is valid
123
+ def valid?
124
+ errors.empty?
125
+ end
126
+
127
+ # Get validation errors.
128
+ #
129
+ # @return [Array<String>] list of error messages
130
+ def errors
131
+ @errors ||= Validator.validate(self)
132
+ end
133
+
134
+ # Clear cached content and resources (useful for testing).
135
+ def reload!
136
+ @content = nil
137
+ @scripts = nil
138
+ @references = nil
139
+ @assets = nil
140
+ @errors = nil
141
+ self
142
+ end
143
+
144
+ # @return [String] inspection string
145
+ def inspect
146
+ "#<#{self.class.name} name=#{name.inspect} path=#{path.inspect}>"
147
+ end
148
+
149
+ private
150
+
151
+ def load_content
152
+ return @metadata["__content__"] if @metadata["__content__"]
153
+ return "" if virtual?
154
+
155
+ md_path = skill_md_path
156
+ return "" unless md_path && File.exist?(md_path)
157
+
158
+ Parser.extract_body(File.read(md_path))
159
+ end
160
+
161
+ def list_resources(subdir)
162
+ return [] if virtual?
163
+
164
+ resource_path = File.join(@path, subdir)
165
+ return [] unless File.directory?(resource_path)
166
+
167
+ Dir.glob(File.join(resource_path, "**", "*"))
168
+ .select { |f| File.file?(f) }
169
+ .reject { |f| File.basename(f) == ".keep" }
170
+ .sort
171
+ end
172
+ end
173
+ end
174
+ end