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.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Skills
5
+ # Extensions for RubyLLM::Agent to enable declarative skill configuration.
6
+ #
7
+ # @example Static skills
8
+ # class SupportAgent < RubyLLM::Agent
9
+ # skills "app/skills", only: [:faq]
10
+ # end
11
+ #
12
+ # @example Dynamic skills
13
+ # class WorkspaceAgent < RubyLLM::Agent
14
+ # inputs :workspace
15
+ # skills { [workspace.skill_collection] }
16
+ # end
17
+ #
18
+ module AgentExtensions
19
+ REQUIRED_AGENT_SINGLETON_METHODS = %i[apply_configuration runtime_context llm_chat_for].freeze
20
+
21
+ module ClassMethods
22
+ def self.extended(base)
23
+ base.instance_variable_set(:@skill_sources, nil)
24
+ base.instance_variable_set(:@skill_only, nil)
25
+ end
26
+
27
+ def inherited(subclass)
28
+ super
29
+ subclass.instance_variable_set(:@skill_sources, @skill_sources.is_a?(Proc) ? @skill_sources : @skill_sources&.dup)
30
+ subclass.instance_variable_set(:@skill_only, @skill_only&.dup)
31
+ end
32
+
33
+ # Declare skill sources for this agent class.
34
+ #
35
+ # Called with no arguments, returns the current configuration.
36
+ # Called with sources or a block, sets the configuration.
37
+ #
38
+ # @param sources [Array] skill sources
39
+ # @param only [Array<Symbol, String>, nil] include only these skills
40
+ # @return [Hash] current configuration when called as a getter
41
+ def skills(*sources, only: nil, &block)
42
+ if sources.empty? && only.nil? && !block_given?
43
+ return {
44
+ sources: @skill_sources.is_a?(Proc) ? @skill_sources : @skill_sources&.dup,
45
+ only: @skill_only&.dup
46
+ }
47
+ end
48
+
49
+ @skill_sources = block_given? ? block : normalize_skill_sources(sources)
50
+ @skill_only = only&.dup
51
+ end
52
+
53
+ private
54
+
55
+ def normalize_skill_sources(raw_sources)
56
+ flatten_skill_sources(raw_sources).compact
57
+ end
58
+
59
+ def flatten_skill_sources(source)
60
+ return [] if source.nil?
61
+ return [source] if source.is_a?(String)
62
+ return [source] if loader_source?(source)
63
+ return [source] if database_collection_source?(source)
64
+ return source.flat_map { |item| flatten_skill_sources(item) } if source.is_a?(Array)
65
+
66
+ [source]
67
+ end
68
+
69
+ def loader_source?(source)
70
+ source.respond_to?(:list) && source.respond_to?(:find)
71
+ end
72
+
73
+ def database_collection_source?(source)
74
+ source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
75
+ end
76
+ end
77
+
78
+ module InstanceMethods
79
+ # Add skills to this agent instance at runtime.
80
+ #
81
+ # @param sources [Array] skill sources
82
+ # @param only [Array<Symbol, String>, nil] include only these skills
83
+ # @return [self] for chaining
84
+ def with_skills(*sources, only: nil)
85
+ chat.with_skills(*sources, only: only)
86
+ self
87
+ end
88
+ end
89
+
90
+ module ConfigurationPatch
91
+ private
92
+
93
+ def apply_configuration(chat_object, **kwargs)
94
+ super
95
+ input_values = kwargs[:input_values] || {}
96
+ runtime = runtime_context(chat: chat_object, inputs: input_values)
97
+ apply_skills(llm_chat_for(chat_object), runtime)
98
+ end
99
+
100
+ def apply_skills(llm_chat, runtime)
101
+ config = skills
102
+ sources = config[:sources]
103
+ return if sources.nil?
104
+
105
+ resolved_sources = if sources.is_a?(Proc)
106
+ runtime.instance_exec(&sources)
107
+ else
108
+ sources
109
+ end
110
+
111
+ normalized_sources = normalize_skill_sources(resolved_sources)
112
+ return if normalized_sources.empty?
113
+
114
+ validate_skill_sources!(normalized_sources)
115
+ llm_chat.with_skills(*normalized_sources, only: config[:only])
116
+ end
117
+
118
+ def validate_skill_sources!(sources)
119
+ invalid_sources = sources.reject { |source| valid_skill_source?(source) }
120
+ return if invalid_sources.empty?
121
+
122
+ invalid_types = invalid_sources.map { |source| source.class.name || source.class.to_s }.uniq.join(", ")
123
+ raise ArgumentError,
124
+ "Invalid skill source(s): #{invalid_types}. Expected String path, Loader, or record collection."
125
+ end
126
+
127
+ def valid_skill_source?(source)
128
+ source.is_a?(String) || loader_source?(source) || database_collection_source?(source)
129
+ end
130
+ end
131
+
132
+ def self.included(base)
133
+ missing_methods = REQUIRED_AGENT_SINGLETON_METHODS.reject do |method_name|
134
+ base.singleton_class.private_method_defined?(method_name) || base.singleton_class.method_defined?(method_name)
135
+ end
136
+
137
+ if missing_methods.any?
138
+ raise LoadError,
139
+ "RubyLLM::Agent is missing required methods for ruby_llm-skills integration: #{missing_methods.join(", ")}"
140
+ end
141
+
142
+ base.extend(ClassMethods)
143
+ base.include(InstanceMethods)
144
+ base.singleton_class.prepend(ConfigurationPatch)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -1,49 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ require_relative "skill_tool"
4
+
5
+ module RubyLLM
4
6
  module Skills
5
7
  # Extensions for RubyLLM::Chat to enable skill integration.
6
8
  #
7
- # These methods are added to RubyLLM::Chat when ruby_llm-skills is loaded,
8
- # providing a convenient API for adding skills to conversations.
9
+ # @example Default (app/skills)
10
+ # chat.with_skills
11
+ #
12
+ # @example Custom path
13
+ # chat.with_skills("lib/skills")
9
14
  #
10
- # @example
11
- # chat = RubyLlm.chat
12
- # chat.with_skills("app/skills")
13
- # chat.ask("Generate a PDF report")
15
+ # @example Multiple sources (auto-detected)
16
+ # chat.with_skills("app/skills", "app/commands", user.skills)
17
+ #
18
+ # @example Filter skills
19
+ # chat.with_skills(only: [:pdf_report])
14
20
  #
15
21
  module ChatExtensions
16
- # Add skills from a directory to this chat.
22
+ # Add skills to this chat.
17
23
  #
18
- # @param path [String] path to skills directory
24
+ # @param sources [Array] skill sources - auto-detects type (directory, zip, collection)
25
+ # @param only [Array<Symbol, String>, nil] include only these skills
19
26
  # @return [self] for chaining
20
- # @example
21
- # chat.with_skills("app/skills")
22
- def with_skills(path = RubyLlm::Skills.default_path)
23
- loader = RubyLlm::Skills.from_directory(path)
24
- skill_tool = RubyLlm::Skills::SkillTool.new(loader)
27
+ def with_skills(*sources, only: nil)
28
+ sources = [RubyLLM::Skills.default_path] if sources.empty?
29
+ loaders = sources.map { |s| to_loader(s) }
30
+
31
+ loader = (loaders.length == 1) ? loaders.first : RubyLLM::Skills.compose(*loaders)
32
+ loader = FilteredLoader.new(loader, only) if only
33
+
34
+ skill_tool = RubyLLM::Skills::SkillTool.new(loader)
25
35
  with_tool(skill_tool)
26
36
  end
27
37
 
28
- # Add skills from a loader to this chat.
29
- #
30
- # @param loader [Loader] any skill loader
31
- # @return [self] for chaining
32
- # @example
33
- # loader = RubyLlm::Skills.compose(
34
- # RubyLlm::Skills.from_directory("app/skills"),
35
- # RubyLlm::Skills.from_database(Skill.all)
36
- # )
37
- # chat.with_skill_loader(loader)
38
- def with_skill_loader(loader)
39
- skill_tool = RubyLlm::Skills::SkillTool.new(loader)
40
- with_tool(skill_tool)
38
+ private
39
+
40
+ def to_loader(source)
41
+ case source
42
+ when String
43
+ RubyLLM::Skills.from_directory(source)
44
+ when ->(s) { database_collection_source?(s) }
45
+ RubyLLM::Skills.from_database(source)
46
+ when ->(s) { loader_source?(s) }
47
+ source
48
+ else
49
+ raise ArgumentError,
50
+ "Invalid skill source: #{source.class}. Expected String path, Loader, or record collection."
51
+ end
52
+ end
53
+
54
+ def loader_source?(source)
55
+ source.respond_to?(:list) && source.respond_to?(:find)
56
+ end
57
+
58
+ def database_collection_source?(source)
59
+ source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
41
60
  end
42
61
  end
43
- end
44
- end
45
62
 
46
- # Extend RubyLLM::Chat if available
47
- if defined?(RubyLlm::Chat)
48
- RubyLlm::Chat.include(RubyLlm::Skills::ChatExtensions)
63
+ # Simple wrapper that filters skills by name.
64
+ class FilteredLoader
65
+ def initialize(loader, only)
66
+ @loader = loader
67
+ @only = Array(only).map(&:to_s)
68
+ end
69
+
70
+ def list
71
+ @loader.list.select { |s| @only.include?(s.name) }
72
+ end
73
+
74
+ def find(name)
75
+ return nil unless @only.include?(name.to_s)
76
+ @loader.find(name)
77
+ end
78
+
79
+ def get(name)
80
+ raise NotFoundError, "Skill not found: #{name}" unless @only.include?(name.to_s)
81
+ @loader.get(name)
82
+ end
83
+
84
+ def exists?(name)
85
+ @only.include?(name.to_s) && @loader.exists?(name)
86
+ end
87
+
88
+ def reload!
89
+ @loader.reload!
90
+ self
91
+ end
92
+ end
93
+
94
+ # Extensions for ActiveRecord models using acts_as_chat.
95
+ module ActiveRecordExtensions
96
+ def with_skills(*sources, only: nil)
97
+ to_llm.with_skills(*sources, only: only)
98
+ self
99
+ end
100
+ end
101
+ end
49
102
  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
  # Combines multiple loaders into a single source.
6
6
  #
@@ -1,46 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
- # Loads skills from database records using duck-typing.
5
+ # Loads skills from database records.
6
6
  #
7
- # Records must respond to either:
8
- # - Text storage: #name, #description, #content
9
- # - Binary storage: #name, #description, #data (zip blob)
7
+ # Records must respond to: #name, #description, #content
8
+ # Optional: #license, #compatibility, #skill_metadata
10
9
  #
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))
10
+ # @example
11
+ # loader = DatabaseLoader.new(Skill.where(user: current_user))
21
12
  #
22
13
  class DatabaseLoader < Loader
23
14
  attr_reader :records
24
15
 
25
- # Initialize with a collection of records.
26
- #
27
- # @param records [Enumerable] collection responding to #each
28
16
  def initialize(records)
29
17
  super()
30
18
  @records = records
31
19
  end
32
20
 
33
- # List all skills from the records.
34
- #
35
- # @return [Array<Skill>] skills from records
36
21
  def list
37
22
  skills
38
23
  end
39
24
 
40
- # Reload skills by re-iterating records.
41
- # Also reloads the records if they respond to #reload.
42
- #
43
- # @return [self]
44
25
  def reload!
45
26
  @records.reload if @records.respond_to?(:reload)
46
27
  super
@@ -52,7 +33,7 @@ module RubyLlm
52
33
  @records.filter_map do |record|
53
34
  load_skill_from_record(record)
54
35
  rescue => e
55
- warn "Warning: Failed to load skill from record: #{e.message}" if RubyLlm::Skills.logger
36
+ warn "Failed to load skill from record: #{e.message}"
56
37
  nil
57
38
  end
58
39
  end
@@ -60,22 +41,20 @@ module RubyLlm
60
41
  private
61
42
 
62
43
  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
44
+ validate_record!(record)
69
45
 
70
- def binary_storage?(record)
71
- record.respond_to?(:data) && record.data.present?
72
- end
46
+ metadata = {
47
+ "name" => record.name.to_s,
48
+ "description" => record.description.to_s,
49
+ "__content__" => record.content.to_s
50
+ }
73
51
 
74
- def load_from_text(record)
75
- validate_text_record!(record)
52
+ metadata["license"] = record.license.to_s if record.respond_to?(:license) && record.license
53
+ metadata["compatibility"] = record.compatibility.to_s if record.respond_to?(:compatibility) && record.compatibility
76
54
 
77
- metadata = build_metadata(record)
78
- metadata["__content__"] = record.content.to_s
55
+ if record.respond_to?(:skill_metadata) && record.skill_metadata.is_a?(Hash)
56
+ metadata["metadata"] = record.skill_metadata
57
+ end
79
58
 
80
59
  Skill.new(
81
60
  path: "database:#{record_id(record)}",
@@ -83,64 +62,14 @@ module RubyLlm
83
62
  )
84
63
  end
85
64
 
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)
65
+ def validate_record!(record)
115
66
  raise InvalidSkillError, "Record must respond to #name" unless record.respond_to?(:name)
116
67
  raise InvalidSkillError, "Record must respond to #description" unless record.respond_to?(:description)
117
68
  raise InvalidSkillError, "Record must respond to #content" unless record.respond_to?(:content)
118
69
  end
119
70
 
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
71
  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
72
+ record.respond_to?(:id) ? record.id : record.name
144
73
  end
145
74
  end
146
75
  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
  # Base error class for all skills-related errors.
6
6
  # Rescue this to catch any error from the gem.
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
5
  # Loads skills from a filesystem directory.
6
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.
7
+ # Supports two formats:
8
+ # 1. Directory skills: subdirectories containing SKILL.md files
9
+ # 2. Single-file commands: .md files with frontmatter at the root level
9
10
  #
10
- # @example
11
- # loader = FilesystemLoader.new("app/skills")
12
- # loader.list # => [Skill, Skill, ...]
11
+ # @example Directory skills
12
+ # app/skills/
13
+ # └── pdf-report/
14
+ # └── SKILL.md
15
+ #
16
+ # @example Single-file commands
17
+ # app/commands/
18
+ # └── write-poem.md
13
19
  #
14
20
  class FilesystemLoader < Loader
15
21
  attr_reader :path
@@ -34,22 +40,49 @@ module RubyLlm
34
40
  def load_all
35
41
  return [] unless File.directory?(@path)
36
42
 
43
+ directory_skills + single_file_skills
44
+ end
45
+
46
+ private
47
+
48
+ # Load skills from subdirectories containing SKILL.md
49
+ def directory_skills
37
50
  Dir.glob(File.join(@path, "*", "SKILL.md")).filter_map do |skill_md_path|
38
- load_skill(skill_md_path)
51
+ load_directory_skill(skill_md_path)
39
52
  rescue ParseError => e
40
- warn "Warning: Failed to parse #{skill_md_path}: #{e.message}" if RubyLlm::Skills.logger
53
+ log_warning("Failed to parse #{skill_md_path}: #{e.message}")
41
54
  nil
42
55
  end
43
56
  end
44
57
 
45
- private
58
+ # Load single-file .md commands from root level
59
+ def single_file_skills
60
+ Dir.glob(File.join(@path, "*.md")).filter_map do |md_path|
61
+ load_single_file_skill(md_path)
62
+ rescue ParseError => e
63
+ log_warning("Failed to parse #{md_path}: #{e.message}")
64
+ nil
65
+ end
66
+ end
46
67
 
47
- def load_skill(skill_md_path)
68
+ def load_directory_skill(skill_md_path)
48
69
  skill_dir = File.dirname(skill_md_path)
49
70
  metadata = Parser.parse_file(skill_md_path)
50
71
 
51
72
  Skill.new(path: skill_dir, metadata: metadata)
52
73
  end
74
+
75
+ def load_single_file_skill(md_path)
76
+ metadata = Parser.parse_file(md_path)
77
+
78
+ # For single-file skills, the path is the file itself
79
+ # They are virtual in that they have no resources
80
+ Skill.new(path: md_path, metadata: metadata, virtual: true)
81
+ end
82
+
83
+ def log_warning(message)
84
+ warn(message)
85
+ end
53
86
  end
54
87
  end
55
88
  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
  # Base class for skill loaders.
6
6
  #
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
- module RubyLlm
5
+ module RubyLLM
6
6
  module Skills
7
7
  # Parses SKILL.md files with YAML frontmatter.
8
8
  #
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
5
  # Rails integration for RubyLLM::Skills.
6
6
  #
@@ -9,14 +9,23 @@ module RubyLlm
9
9
  #
10
10
  class Railtie < ::Rails::Railtie
11
11
  initializer "ruby_llm_skills.configure" do
12
- RubyLlm::Skills.default_path = Rails.root.join("app", "skills").to_s
12
+ RubyLLM::Skills.default_path = Rails.root.join("app", "skills").to_s
13
13
  end
14
14
 
15
- # Add app/skills to autoload paths
16
- initializer "ruby_llm_skills.autoload_paths" do |app|
15
+ # Add app/skills to autoload paths (before initialization)
16
+ config.before_configuration do |app|
17
17
  skills_path = Rails.root.join("app", "skills")
18
18
  if skills_path.exist?
19
- app.config.autoload_paths << skills_path.to_s
19
+ app.config.autoload_paths += [skills_path.to_s]
20
+ end
21
+ end
22
+
23
+ # Extend acts_as_chat models with skill methods
24
+ initializer "ruby_llm_skills.active_record" do
25
+ ActiveSupport.on_load(:active_record) do
26
+ if defined?(RubyLLM::ActiveRecord::ChatMethods)
27
+ RubyLLM::ActiveRecord::ChatMethods.include(RubyLLM::Skills::ActiveRecordExtensions)
28
+ end
20
29
  end
21
30
  end
22
31
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLlm
3
+ module RubyLLM
4
4
  module Skills
5
5
  # Represents a single skill with its metadata and content.
6
6
  #
@@ -23,13 +23,15 @@ module RubyLlm
23
23
 
24
24
  # Initialize a skill from parsed metadata.
25
25
  #
26
- # @param path [String] path to skill directory or virtual identifier
26
+ # @param path [String] path to skill directory, file, or virtual identifier
27
27
  # @param metadata [Hash] parsed YAML frontmatter
28
28
  # @param content [String, nil] pre-loaded content (optional)
29
- def initialize(path:, metadata:, content: nil)
29
+ # @param virtual [Boolean] force virtual mode (no filesystem access)
30
+ def initialize(path:, metadata:, content: nil, virtual: false)
30
31
  @path = path.to_s
31
32
  @metadata = metadata || {}
32
33
  @content = content
34
+ @virtual = virtual
33
35
  end
34
36
 
35
37
  # @return [String] skill name from frontmatter
@@ -101,11 +103,11 @@ module RubyLlm
101
103
  !virtual?
102
104
  end
103
105
 
104
- # Check if skill is a virtual/database skill.
106
+ # Check if skill is a virtual/database skill (no filesystem resources).
105
107
  #
106
- # @return [Boolean] true if path is a virtual identifier
108
+ # @return [Boolean] true if skill has no filesystem resources
107
109
  def virtual?
108
- @path.start_with?("database:")
110
+ @virtual || @path.start_with?("database:")
109
111
  end
110
112
 
111
113
  # Path to the SKILL.md file.
@@ -150,14 +152,27 @@ module RubyLlm
150
152
 
151
153
  def load_content
152
154
  return @metadata["__content__"] if @metadata["__content__"]
155
+
156
+ # For single-file skills, path is the .md file itself
157
+ if single_file?
158
+ return "" unless File.exist?(@path)
159
+ return Parser.extract_body(File.read(@path))
160
+ end
161
+
162
+ # For virtual skills (database), no filesystem content
153
163
  return "" if virtual?
154
164
 
165
+ # For directory skills, load from SKILL.md
155
166
  md_path = skill_md_path
156
167
  return "" unless md_path && File.exist?(md_path)
157
168
 
158
169
  Parser.extract_body(File.read(md_path))
159
170
  end
160
171
 
172
+ def single_file?
173
+ @path.end_with?(".md") && File.file?(@path)
174
+ end
175
+
161
176
  def list_resources(subdir)
162
177
  return [] if virtual?
163
178