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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 39bffc3152e2a10d3f8000006e50d45748c30d6a04a71189ff0d52c61ada7984
4
+ data.tar.gz: 57a3d68ee3b3a1f47a9bbe7b18facc4ae52a12608d275428c37a02769d0a78db
5
+ SHA512:
6
+ metadata.gz: 6aa5b23f9bcaaafeb00b073e031645e72671414eaa744c06eafbd6d66ff23d8023c4b384b5240a42013ae07c5a36b562c3b849b6f57c5ceee5bcc88ee7dfdfde
7
+ data.tar.gz: d1d4a88688857c09a4399dfcb336ea2b1191ba792689b27c796a7c803005900006b52a772656be3c1a6a6c21814bd3783392af538e4c8c2ca94103b399781487
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-01-15
9
+
10
+ ### Added
11
+
12
+ - Initial release with full Agent Skills specification support
13
+ - `Parser` - YAML frontmatter parsing with safe_load
14
+ - `Skill` - Lazy loading for content and resources
15
+ - `Validator` - Agent Skills spec validation rules
16
+ - `FilesystemLoader` - Directory-based skill loading
17
+ - `ZipLoader` - Archive-based skill loading (optional rubyzip dependency)
18
+ - `DatabaseLoader` - Duck-typed record loading (text or binary storage)
19
+ - `CompositeLoader` - Multi-source skill combination
20
+ - `SkillTool` - RubyLLM tool with progressive disclosure via dynamic description
21
+ - `ChatExtensions` - `with_skills()` and `with_skill_loader()` convenience methods
22
+ - Rails integration with Railtie, generator, and rake tasks
23
+ - Comprehensive test suite (142 tests)
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Kieran Klaassen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,396 @@
1
+ # RubyLLM::Skills
2
+
3
+ Agent Skills for [RubyLLM](https://github.com/crmne/ruby_llm). Teach your AI how to do things your way.
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
+ [![Gem Version](https://badge.fury.io/rb/ruby_llm-skills.svg)](https://badge.fury.io/rb/ruby_llm-skills)
8
+ [![CI](https://github.com/kieranklaassen/ruby_llm-skills/actions/workflows/ci.yml/badge.svg)](https://github.com/kieranklaassen/ruby_llm-skills/actions)
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem "ruby_llm-skills"
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```ruby
21
+ 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
+ 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
261
+ ```
262
+
263
+ ## Validation
264
+
265
+ Validate skills match the specification:
266
+
267
+ ```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: [...] }
275
+ ```
276
+
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
+ ## Creating Skills
295
+
296
+ Create a folder with a `SKILL.md` file:
297
+
298
+ ```
299
+ app/skills/
300
+ └── pdf-report/
301
+ ├── SKILL.md
302
+ ├── scripts/
303
+ │ └── generate.rb
304
+ └── references/
305
+ └── templates.md
306
+ ```
307
+
308
+ The `SKILL.md` requires YAML frontmatter:
309
+
310
+ ```yaml
311
+ ---
312
+ 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.
314
+ ---
315
+
316
+ # PDF Report Generator
317
+
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
324
+ ```
325
+
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 |
341
+
342
+ ### Skill Directories
343
+
344
+ ```
345
+ skill-name/
346
+ ├── SKILL.md # Required - instructions
347
+ ├── scripts/ # Optional - executable code
348
+ ├── references/ # Optional - additional docs
349
+ └── assets/ # Optional - templates, images
350
+ ```
351
+
352
+ ### Best Practices
353
+
354
+ **Keep SKILL.md under 500 lines.** Move detailed content to `references/`.
355
+
356
+ **Write good descriptions.** Include both what the skill does AND when to use it:
357
+
358
+ ```yaml
359
+ # Good
360
+ description: Extract text and tables from PDF files. Use when working with PDFs, forms, or document extraction.
361
+
362
+ # Bad
363
+ description: PDF helper.
364
+ ```
365
+
366
+ **Use scripts for deterministic operations.** Scripts execute without loading into context.
367
+
368
+ **One level of references.** Avoid deeply nested file chains.
369
+
370
+ ## Development
371
+
372
+ ```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
377
+ ```
378
+
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
+ ## Resources
392
+
393
+ - [Agent Skills Specification](https://agentskills.io/specification)
394
+ - [Anthropic Skills Repository](https://github.com/anthropics/skills)
395
+ - [RubyLLM](https://github.com/crmne/ruby_llm)
396
+ - [RubyLLM::MCP](https://github.com/patvice/ruby_llm-mcp)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ class SkillGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ class_option :description, type: :string, default: "Description of what this skill does"
9
+ class_option :license, type: :string, default: nil
10
+ class_option :scripts, type: :boolean, default: false
11
+ class_option :references, type: :boolean, default: false
12
+ class_option :assets, type: :boolean, default: false
13
+
14
+ def create_skill_directory
15
+ empty_directory skill_path
16
+ end
17
+
18
+ def create_skill_md
19
+ template "SKILL.md.tt", File.join(skill_path, "SKILL.md")
20
+ end
21
+
22
+ def create_scripts_directory
23
+ return unless options[:scripts]
24
+
25
+ empty_directory File.join(skill_path, "scripts")
26
+ create_file File.join(skill_path, "scripts", ".keep")
27
+ end
28
+
29
+ def create_references_directory
30
+ return unless options[:references]
31
+
32
+ empty_directory File.join(skill_path, "references")
33
+ create_file File.join(skill_path, "references", ".keep")
34
+ end
35
+
36
+ def create_assets_directory
37
+ return unless options[:assets]
38
+
39
+ empty_directory File.join(skill_path, "assets")
40
+ create_file File.join(skill_path, "assets", ".keep")
41
+ end
42
+
43
+ private
44
+
45
+ def skill_path
46
+ File.join("app", "skills", skill_name)
47
+ end
48
+
49
+ def skill_name
50
+ file_name.downcase.tr("_", "-").gsub(/[^a-z0-9-]/, "")
51
+ end
52
+
53
+ def skill_description
54
+ options[:description]
55
+ end
56
+
57
+ def skill_license
58
+ options[:license]
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: <%= skill_name %>
3
+ description: <%= skill_description %>
4
+ <% if skill_license -%>
5
+ license: <%= skill_license %>
6
+ <% end -%>
7
+ ---
8
+
9
+ # <%= skill_name.split("-").map(&:capitalize).join(" ") %>
10
+
11
+ Instructions for using this skill.
12
+
13
+ ## When to Use
14
+
15
+ Use this skill when...
16
+
17
+ ## Steps
18
+
19
+ 1. First step
20
+ 2. Second step
21
+ 3. Third step
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Extensions for RubyLLM::Chat to enable skill integration.
6
+ #
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
+ #
10
+ # @example
11
+ # chat = RubyLlm.chat
12
+ # chat.with_skills("app/skills")
13
+ # chat.ask("Generate a PDF report")
14
+ #
15
+ module ChatExtensions
16
+ # Add skills from a directory to this chat.
17
+ #
18
+ # @param path [String] path to skills directory
19
+ # @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)
25
+ with_tool(skill_tool)
26
+ end
27
+
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)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ # Extend RubyLLM::Chat if available
47
+ if defined?(RubyLlm::Chat)
48
+ RubyLlm::Chat.include(RubyLlm::Skills::ChatExtensions)
49
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlm
4
+ module Skills
5
+ # Combines multiple loaders into a single source.
6
+ #
7
+ # Skills are searched in order, with earlier loaders taking precedence.
8
+ # This allows layering skills from multiple sources (filesystem, database, etc).
9
+ #
10
+ # @example
11
+ # composite = CompositeLoader.new([
12
+ # FilesystemLoader.new("app/skills"),
13
+ # DatabaseLoader.new(Skill.all)
14
+ # ])
15
+ # composite.list # => skills from all loaders
16
+ #
17
+ class CompositeLoader < Loader
18
+ attr_reader :loaders
19
+
20
+ # Initialize with an array of loaders.
21
+ #
22
+ # @param loaders [Array<Loader>] loaders to combine
23
+ def initialize(loaders)
24
+ super()
25
+ @loaders = loaders
26
+ end
27
+
28
+ # List all skills from all loaders.
29
+ # Skills are deduplicated by name, with earlier loaders taking precedence.
30
+ #
31
+ # @return [Array<Skill>] combined list of skills
32
+ def list
33
+ skills
34
+ end
35
+
36
+ # Reload all loaders.
37
+ #
38
+ # @return [self]
39
+ def reload!
40
+ @loaders.each(&:reload!)
41
+ super
42
+ end
43
+
44
+ protected
45
+
46
+ def load_all
47
+ seen = {}
48
+ @loaders.flat_map(&:list).each_with_object([]) do |skill, result|
49
+ next if seen[skill.name]
50
+
51
+ seen[skill.name] = true
52
+ result << skill
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end