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 +4 -4
- data/README.md +41 -331
- data/lib/ruby_llm/skills/chat_extensions.rb +72 -30
- data/lib/ruby_llm/skills/database_loader.rb +19 -90
- data/lib/ruby_llm/skills/filesystem_loader.rb +42 -9
- data/lib/ruby_llm/skills/railtie.rb +9 -0
- data/lib/ruby_llm/skills/skill.rb +20 -5
- data/lib/ruby_llm/skills/skill_tool.rb +79 -37
- data/lib/ruby_llm/skills/version.rb +1 -1
- data/lib/ruby_llm/skills.rb +6 -17
- metadata +17 -4
- data/lib/ruby_llm/skills/zip_loader.rb +0 -129
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 676c58cfd47ff3e3bbf9a8eeac47658c56f2537d189eef7f2743740b8c44afb8
|
|
4
|
+
data.tar.gz: b54d6857a8340b9aab46a37e6385296acd6190987ef816172f96b265a4378d72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/ruby_llm-skills)
|
|
8
6
|
[](https://github.com/kieranklaassen/ruby_llm-skills/actions)
|
|
7
|
+
[](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
|
-
|
|
23
|
+
The LLM discovers skills, calls the skill tool, and gets instructions.
|
|
264
24
|
|
|
265
|
-
|
|
25
|
+
## Usage
|
|
266
26
|
|
|
267
27
|
```ruby
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
44
|
+
SKILL.md requires frontmatter:
|
|
309
45
|
|
|
310
|
-
```
|
|
46
|
+
```markdown
|
|
311
47
|
---
|
|
312
48
|
name: pdf-report
|
|
313
|
-
description: Generate PDF reports
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
59
|
+
Single-file skills work as commands:
|
|
343
60
|
|
|
344
61
|
```
|
|
345
|
-
|
|
346
|
-
├──
|
|
347
|
-
|
|
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
|
-
|
|
67
|
+
```ruby
|
|
68
|
+
chat.with_skills("app/skills", "app/commands")
|
|
69
|
+
chat.ask "/write-poem about robots"
|
|
70
|
+
```
|
|
353
71
|
|
|
354
|
-
|
|
72
|
+
## Database Skills
|
|
355
73
|
|
|
356
|
-
|
|
74
|
+
Store skills or commands in your database:
|
|
357
75
|
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
85
|
+
chat.with_skills(user.skills)
|
|
86
|
+
chat.ask "/my-command args" # works as command too
|
|
364
87
|
```
|
|
365
88
|
|
|
366
|
-
|
|
89
|
+
Records must respond to `#name`, `#description`, and `#content`. For skills with scripts/references, use filesystem skills.
|
|
367
90
|
|
|
368
|
-
|
|
91
|
+
## Rails
|
|
369
92
|
|
|
370
|
-
|
|
93
|
+
Default path auto-configured to `Rails.root/app/skills`.
|
|
371
94
|
|
|
372
95
|
```bash
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
8
|
-
#
|
|
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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
22
|
+
# Add skills to this chat.
|
|
17
23
|
#
|
|
18
|
-
# @param
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
5
|
+
# Loads skills from database records.
|
|
6
6
|
#
|
|
7
|
-
# Records must respond to
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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 "
|
|
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
|
-
|
|
64
|
-
load_from_binary(record)
|
|
65
|
-
else
|
|
66
|
-
load_from_text(record)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
44
|
+
validate_record!(record)
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
metadata = {
|
|
47
|
+
"name" => record.name.to_s,
|
|
48
|
+
"description" => record.description.to_s,
|
|
49
|
+
"__content__" => record.content.to_s
|
|
50
|
+
}
|
|
73
51
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
51
|
+
load_directory_skill(skill_md_path)
|
|
39
52
|
rescue ParseError => e
|
|
40
|
-
|
|
53
|
+
log_warning("Failed to parse #{skill_md_path}: #{e.message}")
|
|
41
54
|
nil
|
|
42
55
|
end
|
|
43
56
|
end
|
|
44
57
|
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
60
|
+
#{base_description}
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
#
|
|
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
|
|
77
|
-
# @
|
|
78
|
-
|
|
79
|
-
|
|
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 '#{
|
|
84
|
+
return "Skill '#{command}' not found. Available skills: #{available}"
|
|
84
85
|
end
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
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 << "-
|
|
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 << "-
|
|
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 << "-
|
|
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('"', """)
|
|
162
174
|
.gsub("'", "'")
|
|
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
|
data/lib/ruby_llm/skills.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|