ruby_llm-skills 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39bffc3152e2a10d3f8000006e50d45748c30d6a04a71189ff0d52c61ada7984
4
- data.tar.gz: 57a3d68ee3b3a1f47a9bbe7b18facc4ae52a12608d275428c37a02769d0a78db
3
+ metadata.gz: 676c58cfd47ff3e3bbf9a8eeac47658c56f2537d189eef7f2743740b8c44afb8
4
+ data.tar.gz: b54d6857a8340b9aab46a37e6385296acd6190987ef816172f96b265a4378d72
5
5
  SHA512:
6
- metadata.gz: 6aa5b23f9bcaaafeb00b073e031645e72671414eaa744c06eafbd6d66ff23d8023c4b384b5240a42013ae07c5a36b562c3b849b6f57c5ceee5bcc88ee7dfdfde
7
- data.tar.gz: d1d4a88688857c09a4399dfcb336ea2b1191ba792689b27c796a7c803005900006b52a772656be3c1a6a6c21814bd3783392af538e4c8c2ca94103b399781487
6
+ metadata.gz: 22da0df8891691181c0db718da07e3b44ad1ca1cbd04d3991350e5f7fff9996390493a5f047833ff978b8798f447172cfc7f7ddbdffe1871054e61cea40709f0
7
+ data.tar.gz: 0a48577bdb85af0d6a1216857fbdb9e4389a2121f271bdc48cff4f35324be2854563b8934d62064667fd1b829959e3e595c05d8be7bd5475b7d6797a36cf992d
data/README.md CHANGED
@@ -2,15 +2,12 @@
2
2
 
3
3
  Agent Skills for [RubyLLM](https://github.com/crmne/ruby_llm). Teach your AI how to do things your way.
4
4
 
5
- Skills are folders of instructions, scripts, and resources that extend LLM capabilities for specialized tasks. This gem implements the [Agent Skills specification](https://agentskills.io/specification) for RubyLLM.
6
-
7
5
  [![Gem Version](https://badge.fury.io/rb/ruby_llm-skills.svg)](https://badge.fury.io/rb/ruby_llm-skills)
8
6
  [![CI](https://github.com/kieranklaassen/ruby_llm-skills/actions/workflows/ci.yml/badge.svg)](https://github.com/kieranklaassen/ruby_llm-skills/actions)
7
+ [![Compound Engineered](https://img.shields.io/badge/Compound-Engineered-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
9
8
 
10
9
  ## Installation
11
10
 
12
- Add this line to your application's Gemfile:
13
-
14
11
  ```ruby
15
12
  gem "ruby_llm-skills"
16
13
  ```
@@ -19,378 +16,91 @@ gem "ruby_llm-skills"
19
16
 
20
17
  ```ruby
21
18
  chat = RubyLLM.chat
22
- chat.with_skills # Load skills from app/skills
23
- chat.ask "Create a PDF report from this data"
24
- ```
25
-
26
- That's it. Skills are discovered automatically and injected into the system prompt.
27
-
28
- ## How It Works
29
-
30
- Skills follow a three-level progressive disclosure pattern:
31
-
32
- 1. **Metadata** - Name and description loaded at startup (~100 tokens per skill)
33
- 2. **Instructions** - Full SKILL.md loaded when skill triggers
34
- 3. **Resources** - Scripts and references loaded on demand
35
-
36
- This keeps context lean while making capabilities available.
37
-
38
- ## Configuration
39
-
40
- ```ruby
41
- RubyLLM::Skills.default_path = "lib/skills" # Default: app/skills
42
- RubyLLM::Skills.logger = Rails.logger # Default: nil
43
- ```
44
-
45
- ## Loading Skills
46
-
47
- ### From Filesystem (Default)
48
-
49
- ```ruby
50
- # Load from default path (app/skills)
51
19
  chat.with_skills
52
-
53
- # Load from specific directory
54
- chat.with_skills(from: "lib/skills")
55
-
56
- # Load specific skills only
57
- chat.with_skills(only: [:pdf_report, :data_analysis])
58
- ```
59
-
60
- ### From Multiple Sources
61
-
62
- Pass an array to load from multiple locations:
63
-
64
- ```ruby
65
- chat.with_skills(from: [
66
- "app/skills", # Directory
67
- "extras/skills.zip", # Zip file
68
- current_user.skills # ActiveRecord relation
69
- ])
70
- ```
71
-
72
- Sources are loaded in order. Later skills with the same name override earlier ones.
73
-
74
- ### From Database
75
-
76
- Store complete skills in your database for per-user customization. Two storage formats are supported:
77
-
78
- **Option A: Store as text (SKILL.md content)**
79
-
80
- ```ruby
81
- # Migration
82
- create_table :skills do |t|
83
- t.string :name, null: false
84
- t.text :description, null: false
85
- t.text :content, null: false # Full SKILL.md content
86
- t.references :user
87
- t.timestamps
88
- end
89
-
90
- # Model
91
- class Skill < ApplicationRecord
92
- belongs_to :user, optional: true
93
-
94
- validates :name, format: { with: /\A[a-z0-9-]+\z/ }
95
- end
96
-
97
- # Create a skill
98
- current_user.skills.create!(
99
- name: "my-workflow",
100
- description: "Custom workflow for data processing",
101
- content: <<~SKILL
102
- # My Workflow
103
-
104
- ## Steps
105
- 1. Load the data
106
- 2. Process with custom rules
107
- 3. Export results
108
- SKILL
109
- )
110
- ```
111
-
112
- **Option B: Store as binary (zip file)**
113
-
114
- For skills with scripts, references, or assets:
115
-
116
- ```ruby
117
- # Migration
118
- create_table :skills do |t|
119
- t.string :name, null: false
120
- t.text :description, null: false
121
- t.binary :data, null: false # Zip file blob
122
- t.references :user
123
- t.timestamps
124
- end
125
-
126
- # Model
127
- class Skill < ApplicationRecord
128
- belongs_to :user, optional: true
129
- end
130
-
131
- # Upload a skill zip
132
- skill_zip = File.read("my-skill.zip")
133
- current_user.skills.create!(
134
- name: "pdf-report",
135
- description: "Generate PDF reports with charts",
136
- data: skill_zip
137
- )
138
- ```
139
-
140
- **Loading database skills**
141
-
142
- ```ruby
143
- # Combine app skills with user's custom skills
144
- chat.with_skills(from: [
145
- "app/skills", # Base skills from filesystem
146
- current_user.skills # User's skills from database
147
- ])
148
- ```
149
-
150
- The gem detects the storage format automatically:
151
- - Records with `content` field → parsed as SKILL.md text
152
- - Records with `data` field → extracted as zip
153
-
154
- ### From Zip Files
155
-
156
- ```ruby
157
- chat.with_skills(from: "skills.zip")
158
- chat.with_skills(from: ["core.zip", "custom.zip"])
159
- ```
160
-
161
- ### Source Detection
162
-
163
- The gem auto-detects source type:
164
-
165
- | Input | Type |
166
- |-------|------|
167
- | String ending in `/` or directory path | Filesystem |
168
- | String ending in `.zip` | Zip file |
169
- | ActiveRecord relation or array of objects | Database |
170
-
171
- ## Rails Integration
172
-
173
- Skills load automatically via Railtie. No configuration needed.
174
-
175
- ```ruby
176
- # app/skills/ is scanned at boot
177
- # Skills available on any RubyLLM chat
178
-
179
- class ReportsController < ApplicationController
180
- def create
181
- chat = RubyLLM.chat
182
- chat.with_skills # Already has app/skills loaded
183
- chat.ask "Generate quarterly report from #{@data}"
184
- end
185
- end
186
- ```
187
-
188
- ### Per-User Skills with ActiveRecord
189
-
190
- ```ruby
191
- class User < ApplicationRecord
192
- has_many :skills
193
- end
194
-
195
- class Skill < ApplicationRecord
196
- belongs_to :user, optional: true
197
-
198
- validates :name, presence: true,
199
- format: { with: /\A[a-z0-9-]+\z/ },
200
- length: { maximum: 64 }
201
- validates :description, presence: true,
202
- length: { maximum: 1024 }
203
- validates :content, presence: true
204
- end
205
- ```
206
-
207
- ## Skill Discovery
208
-
209
- Skills are injected into the system prompt as available tools:
210
-
211
- ```xml
212
- <available_skills>
213
- <skill>
214
- <name>pdf-report</name>
215
- <description>Generate PDF reports with charts...</description>
216
- <location>app/skills/pdf-report</location>
217
- </skill>
218
- </available_skills>
219
- ```
220
-
221
- When the LLM determines a skill is relevant, it reads the full `SKILL.md` into context.
222
-
223
- ## API Reference
224
-
225
- ### RubyLLM::Skills
226
-
227
- ```ruby
228
- RubyLLM::Skills.default_path # Get/set default skills directory
229
- RubyLLM::Skills.logger # Get/set logger
230
- RubyLLM::Skills.load(from:) # Load skills from path/database/zip
231
- RubyLLM::Skills.validate(skill) # Validate skill structure
232
- ```
233
-
234
- ### RubyLLM::Skills::Skill
235
-
236
- ```ruby
237
- skill = RubyLLM::Skills.find("pdf-report")
238
-
239
- skill.name # "pdf-report"
240
- skill.description # "Generate PDF reports..."
241
- skill.content # Full SKILL.md content
242
- skill.path # Filesystem path
243
- skill.metadata # Parsed frontmatter hash
244
- skill.references # Array of reference files
245
- skill.scripts # Array of script files
246
- skill.assets # Array of asset files
247
- skill.valid? # Validates structure
248
- ```
249
-
250
- ### Chat Integration
251
-
252
- ```ruby
253
- chat = RubyLLM.chat
254
-
255
- chat.with_skills # Load default skills
256
- chat.with_skills(only: [:name]) # Load specific skills
257
- chat.with_skills(except: [:name]) # Exclude skills
258
- chat.with_skills(from: records) # Load from database
259
- chat.skills # List loaded skills
260
- chat.skill_metadata # Get metadata for prompt
20
+ chat.ask "Create a PDF report from this data"
261
21
  ```
262
22
 
263
- ## Validation
23
+ The LLM discovers skills, calls the skill tool, and gets instructions.
264
24
 
265
- Validate skills match the specification:
25
+ ## Usage
266
26
 
267
27
  ```ruby
268
- skill = RubyLLM::Skills.find("my-skill")
269
- skill.valid? # => true/false
270
- skill.errors # => ["name contains uppercase"]
271
-
272
- # Validate all skills
273
- RubyLLM::Skills.validate_all
274
- # => { valid: [...], invalid: [...] }
28
+ chat.with_skills # app/skills (default)
29
+ chat.with_skills("lib/skills") # custom path
30
+ chat.with_skills("app/skills", "app/commands") # multiple paths
31
+ chat.with_skills("app/skills", user.skills) # with database records
275
32
  ```
276
33
 
277
- ## Provider Support
278
-
279
- Skills work with any RubyLLM provider. The skill metadata is injected into the system prompt, so any model that supports system prompts can use skills.
280
-
281
- Tested with: OpenAI, Anthropic, Google Gemini, AWS Bedrock, Ollama.
282
-
283
- ## Comparison with MCP
284
-
285
- | Feature | Skills | MCP |
286
- |---------|--------|-----|
287
- | Execution | Prompt-based | Tool-based |
288
- | Setup | Drop in folder | Server config |
289
- | Context | Progressive disclosure | Always available |
290
- | Best for | Domain knowledge | External integrations |
291
-
292
- Use skills for specialized instructions. Use [ruby_llm-mcp](https://github.com/patvice/ruby_llm-mcp) for external tool integrations. They compose well together.
293
-
294
34
  ## Creating Skills
295
35
 
296
- Create a folder with a `SKILL.md` file:
297
-
298
36
  ```
299
37
  app/skills/
300
38
  └── pdf-report/
301
39
  ├── SKILL.md
302
40
  ├── scripts/
303
- │ └── generate.rb
304
41
  └── references/
305
- └── templates.md
306
42
  ```
307
43
 
308
- The `SKILL.md` requires YAML frontmatter:
44
+ SKILL.md requires frontmatter:
309
45
 
310
- ```yaml
46
+ ```markdown
311
47
  ---
312
48
  name: pdf-report
313
- description: Generate PDF reports with charts and tables. Use when asked to create reports, export data to PDF, or generate printable documents.
49
+ description: Generate PDF reports. Use when asked to create reports or export to PDF.
314
50
  ---
315
51
 
316
52
  # PDF Report Generator
317
53
 
318
- ## Quick Start
319
-
320
- Use the bundled script for generation:
321
-
322
- ```bash
323
- ruby scripts/generate.rb --input data.json --output report.pdf
54
+ Instructions here...
324
55
  ```
325
56
 
326
- ## Guidelines
327
-
328
- - Always include page numbers
329
- - Use company logo from assets/
330
- ```
331
-
332
- ### Frontmatter Fields
333
-
334
- | Field | Required | Description |
335
- |-------|----------|-------------|
336
- | `name` | Yes | Lowercase, hyphens only. Max 64 chars. |
337
- | `description` | Yes | What it does AND when to use it. Max 1024 chars. |
338
- | `license` | No | License identifier |
339
- | `compatibility` | No | Environment requirements |
340
- | `metadata` | No | Custom key-value pairs |
57
+ ## Slash Commands
341
58
 
342
- ### Skill Directories
59
+ Single-file skills work as commands:
343
60
 
344
61
  ```
345
- skill-name/
346
- ├── SKILL.md # Required - instructions
347
- ├── scripts/ # Optional - executable code
348
- ├── references/ # Optional - additional docs
349
- └── assets/ # Optional - templates, images
62
+ app/commands/
63
+ ├── write-poem.md
64
+ └── review-code.md
350
65
  ```
351
66
 
352
- ### Best Practices
67
+ ```ruby
68
+ chat.with_skills("app/skills", "app/commands")
69
+ chat.ask "/write-poem about robots"
70
+ ```
353
71
 
354
- **Keep SKILL.md under 500 lines.** Move detailed content to `references/`.
72
+ ## Database Skills
355
73
 
356
- **Write good descriptions.** Include both what the skill does AND when to use it:
74
+ Store skills or commands in your database:
357
75
 
358
- ```yaml
359
- # Good
360
- description: Extract text and tables from PDF files. Use when working with PDFs, forms, or document extraction.
76
+ ```ruby
77
+ create_table :skills do |t|
78
+ t.string :name, null: false
79
+ t.text :description, null: false
80
+ t.text :content, null: false # SKILL.md body
81
+ t.references :user
82
+ t.timestamps
83
+ end
361
84
 
362
- # Bad
363
- description: PDF helper.
85
+ chat.with_skills(user.skills)
86
+ chat.ask "/my-command args" # works as command too
364
87
  ```
365
88
 
366
- **Use scripts for deterministic operations.** Scripts execute without loading into context.
89
+ Records must respond to `#name`, `#description`, and `#content`. For skills with scripts/references, use filesystem skills.
367
90
 
368
- **One level of references.** Avoid deeply nested file chains.
91
+ ## Rails
369
92
 
370
- ## Development
93
+ Default path auto-configured to `Rails.root/app/skills`.
371
94
 
372
95
  ```bash
373
- git clone https://github.com/kieranklaassen/ruby_llm-skills.git
374
- cd ruby_llm-skills
375
- bundle install
376
- bundle exec rake test
96
+ rails generate skill pdf-report --description "Generate PDF reports"
377
97
  ```
378
98
 
379
- ## Contributing
380
-
381
- 1. Fork it
382
- 2. Create your feature branch (`git checkout -b my-feature`)
383
- 3. Commit your changes (`git commit -am 'Add feature'`)
384
- 4. Push to the branch (`git push origin my-feature`)
385
- 5. Create a Pull Request
386
-
387
- ## License
388
-
389
- MIT License. See [LICENSE](LICENSE) for details.
390
-
391
99
  ## Resources
392
100
 
393
101
  - [Agent Skills Specification](https://agentskills.io/specification)
394
- - [Anthropic Skills Repository](https://github.com/anthropics/skills)
395
102
  - [RubyLLM](https://github.com/crmne/ruby_llm)
396
- - [RubyLLM::MCP](https://github.com/patvice/ruby_llm-mcp)
103
+
104
+ ## License
105
+
106
+ MIT
@@ -1,49 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "skill_tool"
4
+
3
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)
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
+
24
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) { s.respond_to?(:to_a) && s.first&.respond_to?(:name) && s.first.respond_to?(:content) }
45
+ RubyLlm::Skills.from_database(source)
46
+ else
47
+ source
48
+ end
41
49
  end
42
50
  end
43
- end
44
- end
45
51
 
46
- # Extend RubyLLM::Chat if available
47
- if defined?(RubyLlm::Chat)
48
- RubyLlm::Chat.include(RubyLlm::Skills::ChatExtensions)
52
+ # Simple wrapper that filters skills by name.
53
+ class FilteredLoader
54
+ def initialize(loader, only)
55
+ @loader = loader
56
+ @only = Array(only).map(&:to_s)
57
+ end
58
+
59
+ def list
60
+ @loader.list.select { |s| @only.include?(s.name) }
61
+ end
62
+
63
+ def find(name)
64
+ return nil unless @only.include?(name.to_s)
65
+ @loader.find(name)
66
+ end
67
+
68
+ def get(name)
69
+ raise NotFoundError, "Skill not found: #{name}" unless @only.include?(name.to_s)
70
+ @loader.get(name)
71
+ end
72
+
73
+ def exists?(name)
74
+ @only.include?(name.to_s) && @loader.exists?(name)
75
+ end
76
+
77
+ def reload!
78
+ @loader.reload!
79
+ self
80
+ end
81
+ end
82
+
83
+ # Extensions for ActiveRecord models using acts_as_chat.
84
+ module ActiveRecordExtensions
85
+ def with_skills(*sources, only: nil)
86
+ to_llm.with_skills(*sources, only: only)
87
+ self
88
+ end
89
+ end
90
+ end
49
91
  end
@@ -2,45 +2,26 @@
2
2
 
3
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
@@ -4,12 +4,18 @@ 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
@@ -20,6 +20,15 @@ module RubyLlm
20
20
  end
21
21
  end
22
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
29
+ end
30
+ end
31
+
23
32
  # Provide rake tasks
24
33
  rake_tasks do
25
34
  load File.expand_path("tasks/skills.rake", __dir__)
@@ -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
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruby_llm"
4
+
3
5
  module RubyLlm
4
6
  module Skills
5
7
  # A RubyLLM Tool that enables progressive skill loading.
@@ -23,7 +25,15 @@ module RubyLlm
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLlm
4
4
  module Skills
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruby_llm"
4
+
3
5
  require_relative "skills/version"
4
6
  require_relative "skills/error"
5
7
  require_relative "skills/parser"
@@ -7,17 +9,18 @@ require_relative "skills/validator"
7
9
  require_relative "skills/skill"
8
10
  require_relative "skills/loader"
9
11
  require_relative "skills/filesystem_loader"
10
- require_relative "skills/composite_loader"
11
- require_relative "skills/skill_tool"
12
12
  require_relative "skills/chat_extensions"
13
13
 
14
14
  # Load Rails integration when Rails is available
15
15
  require_relative "skills/railtie" if defined?(Rails::Railtie)
16
16
 
17
+ # Extend RubyLLM::Chat with skill methods
18
+ RubyLLM::Chat.include(RubyLlm::Skills::ChatExtensions)
19
+
17
20
  module RubyLlm
18
21
  module Skills
19
22
  class << self
20
- attr_accessor :default_path, :logger
23
+ attr_accessor :default_path
21
24
 
22
25
  # Load skills from a filesystem directory.
23
26
  #
@@ -44,20 +47,6 @@ module RubyLlm
44
47
  Skill.new(path: path, metadata: metadata)
45
48
  end
46
49
 
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
50
  # Load skills from database records.
62
51
  #
63
52
  # @param records [ActiveRecord::Relation, Array] collection of skill records
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 2026-01-16 00:00:00.000000000 Z
11
- dependencies: []
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.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.10'
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.
@@ -37,7 +51,6 @@ files:
37
51
  - lib/ruby_llm/skills/tasks/skills.rake
38
52
  - lib/ruby_llm/skills/validator.rb
39
53
  - lib/ruby_llm/skills/version.rb
40
- - lib/ruby_llm/skills/zip_loader.rb
41
54
  homepage: https://github.com/kieranklaassen/ruby_llm-skills
42
55
  licenses:
43
56
  - MIT
@@ -53,7 +66,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
53
66
  requirements:
54
67
  - - ">="
55
68
  - !ruby/object:Gem::Version
56
- version: 3.1.0
69
+ version: 3.2.0
57
70
  required_rubygems_version: !ruby/object:Gem::Requirement
58
71
  requirements:
59
72
  - - ">="
@@ -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