ruby_llm-skills 0.3.0 → 0.5.0.pre1
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/CHANGELOG.md +39 -0
- data/README.md +58 -3
- data/lib/ruby_llm/skills/agent_extensions.rb +22 -21
- data/lib/ruby_llm/skills/chat_extensions.rb +7 -10
- data/lib/ruby_llm/skills/marketplace/bundle.rb +367 -0
- data/lib/ruby_llm/skills/marketplace/config.rb +41 -0
- data/lib/ruby_llm/skills/marketplace/fetcher.rb +260 -0
- data/lib/ruby_llm/skills/marketplace/github_repo.rb +94 -0
- data/lib/ruby_llm/skills/marketplace/gitlab_repo.rb +72 -0
- data/lib/ruby_llm/skills/marketplace/http.rb +122 -0
- data/lib/ruby_llm/skills/marketplace/locator.rb +115 -0
- data/lib/ruby_llm/skills/marketplace/lockfile.rb +113 -0
- data/lib/ruby_llm/skills/marketplace/manifest.rb +269 -0
- data/lib/ruby_llm/skills/marketplace/registry.rb +384 -0
- data/lib/ruby_llm/skills/marketplace/tarball.rb +160 -0
- data/lib/ruby_llm/skills/marketplace.rb +79 -0
- data/lib/ruby_llm/skills/railtie.rb +3 -0
- data/lib/ruby_llm/skills/skill_tool.rb +14 -14
- data/lib/ruby_llm/skills/source_detection.rb +30 -0
- data/lib/ruby_llm/skills/tasks/marketplaces.rake +95 -0
- data/lib/ruby_llm/skills/tasks.rb +11 -0
- data/lib/ruby_llm/skills/version.rb +1 -1
- data/lib/ruby_llm/skills.rb +23 -45
- metadata +33 -9
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Skills
|
|
8
|
+
module Marketplace
|
|
9
|
+
# The public surface: marketplaces recorded in a lockfile, their plugins
|
|
10
|
+
# installed under a root directory, and a loader over what is installed.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# marketplaces = RubyLLM::Skills.marketplaces # vendor/skills + skills.lock.json
|
|
14
|
+
# marketplaces.add("EveryInc/compound-writing") # follows the default branch
|
|
15
|
+
# marketplaces.add("typesafe-ai/skills", ref: "v0.5.7") # pinned to a tag
|
|
16
|
+
# marketplaces.plugins("compound-writing").map(&:name)
|
|
17
|
+
# marketplaces.install("compound-writing") # every supported plugin
|
|
18
|
+
# marketplaces.install("typesafe-ai", only: ["typesafe"])
|
|
19
|
+
# marketplaces.install # reproduce the lockfile
|
|
20
|
+
# marketplaces.update # move every unpinned ref forward
|
|
21
|
+
# marketplaces.remove("typesafe-ai")
|
|
22
|
+
# chat.with_skills(marketplaces)
|
|
23
|
+
#
|
|
24
|
+
class Registry
|
|
25
|
+
SCRATCH_PREFIX = ".building-"
|
|
26
|
+
|
|
27
|
+
# One recorded marketplace.
|
|
28
|
+
Record = Data.define(:name, :source, :commit, :plugins) do
|
|
29
|
+
def kind = source.kind
|
|
30
|
+
|
|
31
|
+
def locator = source.locator
|
|
32
|
+
|
|
33
|
+
def ref = source.ref
|
|
34
|
+
|
|
35
|
+
def pinned? = source.pinned?
|
|
36
|
+
|
|
37
|
+
def plugin_names = plugins.keys
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One installed plugin.
|
|
41
|
+
InstalledPlugin = Data.define(:marketplace, :name, :version, :version_kind, :commit, :tree_sha256, :source, :skills, :path) do
|
|
42
|
+
def skills_path
|
|
43
|
+
File.join(path, "skills")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_s
|
|
47
|
+
"#{marketplace}/#{name}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What an install or update did, keyed by "marketplace/plugin".
|
|
52
|
+
Result = Data.define(:installed, :updated, :unchanged, :skipped, :errors) do
|
|
53
|
+
def self.empty
|
|
54
|
+
new(installed: [], updated: [], unchanged: [], skipped: {}, errors: {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def success? = errors.empty?
|
|
58
|
+
|
|
59
|
+
def changed = installed + updated
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :root, :lockfile_path
|
|
63
|
+
|
|
64
|
+
# @param root [String] where plugins are written (default: Marketplace.root)
|
|
65
|
+
# @param lockfile [String] the lockfile path (default: Marketplace.lockfile)
|
|
66
|
+
def initialize(root: Marketplace.root, lockfile: Marketplace.lockfile)
|
|
67
|
+
@root = File.expand_path(root.to_s)
|
|
68
|
+
@lockfile_path = File.expand_path(lockfile.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Records a marketplace and caches its marketplace file; installs nothing.
|
|
72
|
+
#
|
|
73
|
+
# @param locator [String] `owner/repo`, `owner/repo@ref`, a repository URL, a hosted `.json` URL, or a directory
|
|
74
|
+
# @param ref [String, nil] a branch, tag or commit sha to pin the marketplace to
|
|
75
|
+
# @param as [String, nil] the name to record it under (default: the marketplace file's `name`)
|
|
76
|
+
# @return [Record]
|
|
77
|
+
# @raise [FetchError, InvalidManifestError, ArgumentError]
|
|
78
|
+
def add(locator, ref: nil, as: nil)
|
|
79
|
+
source = Locator.parse(locator, ref: ref)
|
|
80
|
+
fetcher = Fetcher.for(source)
|
|
81
|
+
head = fetcher.head
|
|
82
|
+
files = fetcher.catalog_files(head)
|
|
83
|
+
catalog = Manifest.discover(files)
|
|
84
|
+
name = Manifest.presence(as) || catalog.name
|
|
85
|
+
raise ArgumentError, "#{name.inspect} is not a valid marketplace name" unless name.match?(Manifest::NAME_PATTERN)
|
|
86
|
+
|
|
87
|
+
existing = lockfile.marketplace(name)
|
|
88
|
+
if existing && Locator::Source.from_h(existing) != source
|
|
89
|
+
raise ArgumentError, "a marketplace named #{name.inspect} is already added from #{Locator::Source.from_h(existing)}; pass as: to add this one under another name"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
cache_catalog!(name, files)
|
|
93
|
+
lockfile.set_marketplace(name, source.to_h.merge("commit" => head.sha))
|
|
94
|
+
lockfile.save
|
|
95
|
+
reload!
|
|
96
|
+
find(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Array<Record>] every recorded marketplace
|
|
100
|
+
def list
|
|
101
|
+
lockfile.names.map { |name| record(name) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Record, nil]
|
|
105
|
+
def find(name)
|
|
106
|
+
lockfile.marketplace(name) && record(name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Record]
|
|
110
|
+
# @raise [NotFoundError]
|
|
111
|
+
def fetch(name)
|
|
112
|
+
find(name) || raise(NotFoundError, "Marketplace not found: #{name}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The plugins a marketplace lists, from the cached marketplace file.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<Manifest::Entry>]
|
|
118
|
+
def plugins(name)
|
|
119
|
+
catalog(fetch(name)).plugins
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Installs plugins. With a marketplace name, every supported plugin of
|
|
123
|
+
# that marketplace (or +only+ the named ones) at the marketplace's
|
|
124
|
+
# recorded commit. With no name, every plugin in the lockfile at its
|
|
125
|
+
# recorded commit, so a fresh checkout reproduces the same trees.
|
|
126
|
+
#
|
|
127
|
+
# @return [Result]
|
|
128
|
+
# @raise [NotFoundError] for an unknown marketplace or plugin name
|
|
129
|
+
def install(name = nil, only: nil)
|
|
130
|
+
result = Result.empty
|
|
131
|
+
if name.nil?
|
|
132
|
+
list.each { |record| install_plugins(record, record.plugin_names, result, reproduce: true) }
|
|
133
|
+
else
|
|
134
|
+
record = fetch(name)
|
|
135
|
+
wanted = only ? Array(only).map(&:to_s) : supported_plugin_names(record)
|
|
136
|
+
install_plugins(record, wanted, result, reproduce: false)
|
|
137
|
+
end
|
|
138
|
+
finish(result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Moves marketplaces forward: re-resolves each ref, re-reads the
|
|
142
|
+
# marketplace file when the head moved, refetches installed plugins
|
|
143
|
+
# whose source moved, and rewrites the lockfile. A marketplace pinned
|
|
144
|
+
# to a commit never moves.
|
|
145
|
+
#
|
|
146
|
+
# @return [Result]
|
|
147
|
+
def update(name = nil, only: nil)
|
|
148
|
+
result = Result.empty
|
|
149
|
+
records = name ? [fetch(name)] : list
|
|
150
|
+
records.each do |record|
|
|
151
|
+
record = refresh_head!(record)
|
|
152
|
+
wanted = only ? Array(only).map(&:to_s) : record.plugin_names
|
|
153
|
+
install_plugins(record, wanted, result, reproduce: false)
|
|
154
|
+
end
|
|
155
|
+
finish(result)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Deletes a marketplace's directory and lockfile entry.
|
|
159
|
+
def remove(name)
|
|
160
|
+
record = fetch(name)
|
|
161
|
+
FileUtils.rm_rf(marketplace_dir(record.name))
|
|
162
|
+
lockfile.delete_marketplace(record.name)
|
|
163
|
+
lockfile.save
|
|
164
|
+
reload!
|
|
165
|
+
record
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Deletes one plugin's directory and lockfile entry.
|
|
169
|
+
def uninstall(name, plugin)
|
|
170
|
+
record = fetch(name)
|
|
171
|
+
installed = record.plugins[plugin.to_s] || raise(NotFoundError, "Plugin not installed: #{name}/#{plugin}")
|
|
172
|
+
FileUtils.rm_rf(installed.path)
|
|
173
|
+
lockfile.delete_plugin(record.name, plugin)
|
|
174
|
+
lockfile.save
|
|
175
|
+
reload!
|
|
176
|
+
installed
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [Array<InstalledPlugin>] in lockfile order
|
|
180
|
+
def installed
|
|
181
|
+
list.flat_map { |record| record.plugins.values }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A loader over every installed plugin's skills.
|
|
185
|
+
#
|
|
186
|
+
# @return [Loader]
|
|
187
|
+
def loader
|
|
188
|
+
loaders = installed.map { |plugin| FilesystemLoader.new(plugin.skills_path) }
|
|
189
|
+
(loaders.length == 1) ? loaders.first : RubyLLM::Skills.compose(*loaders)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Forget the cached lockfile so the next call re-reads it.
|
|
193
|
+
def reload!
|
|
194
|
+
@lockfile = nil
|
|
195
|
+
self
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def inspect
|
|
199
|
+
"#<#{self.class.name} root=#{root.inspect} lockfile=#{lockfile_path.inspect}>"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def lockfile
|
|
205
|
+
@lockfile ||= Lockfile.load(lockfile_path)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def finish(result)
|
|
209
|
+
lockfile.save
|
|
210
|
+
reload!
|
|
211
|
+
result
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def record(name)
|
|
215
|
+
data = lockfile.marketplace(name)
|
|
216
|
+
source = Locator::Source.from_h(data)
|
|
217
|
+
plugins = lockfile.plugins(name).sort.to_h do |plugin_name, plugin|
|
|
218
|
+
[plugin_name, installed_plugin(name, plugin_name, plugin)]
|
|
219
|
+
end
|
|
220
|
+
Record.new(name: name, source: source, commit: data["commit"], plugins: plugins)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def installed_plugin(marketplace, plugin_name, data)
|
|
224
|
+
InstalledPlugin.new(marketplace: marketplace, name: plugin_name, version: data["version"], version_kind: data["version_kind"],
|
|
225
|
+
commit: data["commit"], tree_sha256: data["tree_sha256"], source: Manifest::PluginSource.from_h(data["source"] || {}),
|
|
226
|
+
skills: Array(data["skills"]), path: plugin_dir(marketplace, plugin_name))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def marketplace_dir(name)
|
|
230
|
+
File.join(root, safe_name(name))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def plugin_dir(marketplace, plugin)
|
|
234
|
+
File.join(marketplace_dir(marketplace), safe_name(plugin))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Names come from marketplace files and the lockfile; only a kebab-case
|
|
238
|
+
# name may become a directory under the root.
|
|
239
|
+
def safe_name(name)
|
|
240
|
+
raise LockfileError, "unsafe name #{name.inspect}" unless name.to_s.match?(Manifest::NAME_PATTERN)
|
|
241
|
+
|
|
242
|
+
name.to_s
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def cache_catalog!(name, files)
|
|
246
|
+
dir = marketplace_dir(name)
|
|
247
|
+
Manifest::PATHS.each { |path| FileUtils.rm_f(File.join(dir, path)) }
|
|
248
|
+
Tarball.write_directory(files, dir)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def catalog(record)
|
|
252
|
+
dir = marketplace_dir(record.name)
|
|
253
|
+
files = Manifest::PATHS.each_with_object({}) do |path, found|
|
|
254
|
+
full = File.join(dir, path)
|
|
255
|
+
found[path] = File.binread(full) if File.file?(full)
|
|
256
|
+
end
|
|
257
|
+
return Manifest.discover(files) unless files.empty?
|
|
258
|
+
|
|
259
|
+
fetcher = Fetcher.for(record.source)
|
|
260
|
+
head = recorded_head(record)
|
|
261
|
+
files = fetcher.catalog_files(head)
|
|
262
|
+
cache_catalog!(record.name, files)
|
|
263
|
+
Manifest.discover(files)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def supported_plugin_names(record)
|
|
267
|
+
catalog(record).plugins.select(&:supported?).map(&:name)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Re-resolves the ref; on a move, re-reads and caches the marketplace file.
|
|
271
|
+
def refresh_head!(record)
|
|
272
|
+
fetcher = Fetcher.for(record.source)
|
|
273
|
+
head = fetcher.head(previous_sha: record.commit)
|
|
274
|
+
return record if head.unchanged? && catalog_cached?(record)
|
|
275
|
+
|
|
276
|
+
cache_catalog!(record.name, fetcher.catalog_files(head))
|
|
277
|
+
lockfile.set_marketplace(record.name, record.source.to_h.merge("commit" => head.sha))
|
|
278
|
+
Record.new(name: record.name, source: record.source, commit: head.sha, plugins: record.plugins)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def catalog_cached?(record)
|
|
282
|
+
Manifest::PATHS.any? { |path| File.file?(File.join(marketplace_dir(record.name), path)) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# A Head standing at the recorded commit, so relative sources read the
|
|
286
|
+
# marketplace tree exactly as it was recorded. A directory marketplace
|
|
287
|
+
# has no history: its head is what the folder holds now.
|
|
288
|
+
def recorded_head(record)
|
|
289
|
+
fetcher = Fetcher.for(record.source)
|
|
290
|
+
return fetcher.head if record.kind == "directory" || record.commit.nil?
|
|
291
|
+
|
|
292
|
+
Fetcher::Head.new(sha: record.commit, etag: nil, unchanged: true)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def install_plugins(record, names, result, reproduce:)
|
|
296
|
+
return if names.empty?
|
|
297
|
+
|
|
298
|
+
catalog = self.catalog(record)
|
|
299
|
+
unknown = names.reject { |plugin_name| catalog.plugin(plugin_name) || record.plugins[plugin_name] }
|
|
300
|
+
raise NotFoundError, "Plugin not found in #{record.name}: #{unknown.join(", ")}" if unknown.any?
|
|
301
|
+
|
|
302
|
+
fetcher = Fetcher.for(record.source)
|
|
303
|
+
head = recorded_head(record)
|
|
304
|
+
names.each do |plugin_name|
|
|
305
|
+
key = "#{record.name}/#{plugin_name}"
|
|
306
|
+
installed = record.plugins[plugin_name]
|
|
307
|
+
entry = catalog.plugin(plugin_name)
|
|
308
|
+
next result.errors[key] = "no longer listed by the marketplace" if entry.nil?
|
|
309
|
+
next result.skipped[key] = "unsupported source (#{entry.source.reason})" unless entry.supported?
|
|
310
|
+
next result.skipped[key] = "relative sources are not available from a hosted marketplace.json" if record.kind == "url" && entry.source.kind == "relative"
|
|
311
|
+
|
|
312
|
+
install_plugin(record, fetcher, head, entry, installed, result, reproduce: reproduce)
|
|
313
|
+
rescue FetchError, InvalidPluginError, InvalidManifestError => e
|
|
314
|
+
result.errors[key] = e.message
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def install_plugin(record, fetcher, head, entry, installed, result, reproduce:)
|
|
319
|
+
key = "#{record.name}/#{entry.name}"
|
|
320
|
+
plugin_source = (reproduce && installed) ? pin_source(entry.source, installed) : entry.source
|
|
321
|
+
sha = fetcher.source_sha(plugin_source, head: head)
|
|
322
|
+
if installed && sha && sha == installed.commit && on_disk?(installed)
|
|
323
|
+
return result.unchanged << key
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
fetched = fetcher.source_tree(plugin_source, head: head)
|
|
327
|
+
if reproduce && installed && plugin_source.kind == "archive" && fetched.sha != installed.commit
|
|
328
|
+
raise FetchError, "#{key}: archive digest #{fetched.sha[0, 12]} does not match the lockfile's #{installed.commit.to_s[0, 12]}"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
bundle = Bundle.new(fetched.files, entry: entry)
|
|
332
|
+
return result.skipped[key] = "no skills, commands or agents to install" if bundle.empty?
|
|
333
|
+
if installed && installed.tree_sha256 == bundle.tree_sha256 && on_disk?(installed)
|
|
334
|
+
lockfile.set_plugin(record.name, entry.name, plugin_data(entry, fetched, bundle))
|
|
335
|
+
return result.unchanged << key
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
target = plugin_dir(record.name, entry.name)
|
|
339
|
+
was_on_disk = File.directory?(target)
|
|
340
|
+
write_tree!(target, bundle)
|
|
341
|
+
lockfile.set_plugin(record.name, entry.name, plugin_data(entry, fetched, bundle))
|
|
342
|
+
(was_on_disk ? result.updated : result.installed) << key
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The lockfile's commit, so a reproduce fetches what was recorded rather than the ref's current head.
|
|
346
|
+
def pin_source(plugin_source, installed)
|
|
347
|
+
return plugin_source unless %w[github gitlab].include?(plugin_source.kind) && installed.commit
|
|
348
|
+
|
|
349
|
+
plugin_source.with(sha: installed.commit)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def on_disk?(installed)
|
|
353
|
+
dir = installed.path
|
|
354
|
+
File.directory?(dir) && Tarball.tree_sha256(Tarball.from_directory(dir)) == installed.tree_sha256
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def plugin_data(entry, fetched, bundle)
|
|
358
|
+
version, kind = if bundle.version
|
|
359
|
+
[bundle.version, bundle.version_kind]
|
|
360
|
+
else
|
|
361
|
+
[fetched.sha, (entry.source.kind == "archive") ? "archive" : "commit"]
|
|
362
|
+
end
|
|
363
|
+
{"version" => version, "version_kind" => kind, "commit" => fetched.sha, "tree_sha256" => bundle.tree_sha256,
|
|
364
|
+
"source" => entry.source.to_h, "skills" => bundle.skill_names}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Written to a scratch directory beside the target and moved into
|
|
368
|
+
# place with one rename, so a failure leaves no partial tree.
|
|
369
|
+
def write_tree!(target, bundle)
|
|
370
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
371
|
+
scratch = File.join(File.dirname(target), "#{SCRATCH_PREFIX}#{File.basename(target)}-#{SecureRandom.hex(4)}")
|
|
372
|
+
FileUtils.mkdir_p(scratch)
|
|
373
|
+
Tarball.write_directory(bundle.tree, scratch)
|
|
374
|
+
FileUtils.rm_rf(target)
|
|
375
|
+
File.rename(scratch, target)
|
|
376
|
+
target
|
|
377
|
+
rescue
|
|
378
|
+
FileUtils.rm_rf(scratch) if scratch && File.exist?(scratch)
|
|
379
|
+
raise
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "pathname"
|
|
9
|
+
|
|
10
|
+
module RubyLLM
|
|
11
|
+
module Skills
|
|
12
|
+
module Marketplace
|
|
13
|
+
# The one tar.gz reader for upstream content: every entry path is
|
|
14
|
+
# cleaned and refused when it escapes (`..`, absolute); anything but a
|
|
15
|
+
# regular file or directory (symlinks, hardlinks, devices, FIFOs) is
|
|
16
|
+
# dropped, never followed or written; the archive, each file and the
|
|
17
|
+
# file count are capped. GitHub and GitLab archives wrap the repository
|
|
18
|
+
# in one root directory, which +strip_root+ removes.
|
|
19
|
+
#
|
|
20
|
+
# Trees are plain hashes of `"relative/path" => bytes`.
|
|
21
|
+
#
|
|
22
|
+
module Tarball
|
|
23
|
+
REGULAR = ["0", "\0"].freeze
|
|
24
|
+
DIRECTORY = "5"
|
|
25
|
+
EXTENDED_HEADERS = %w[g x].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# @param archive [String] tar.gz bytes
|
|
29
|
+
# @param strip_root [Boolean] drop the single wrapping directory
|
|
30
|
+
# @param subdir [String, nil] keep only this directory, re-rooted
|
|
31
|
+
# @return [Hash{String => String}] the files
|
|
32
|
+
# @raise [InvalidPluginError]
|
|
33
|
+
def read(archive, strip_root: true, subdir: nil, max_bytes: config.max_archive_bytes,
|
|
34
|
+
max_file_bytes: config.max_file_bytes, max_files: config.max_files)
|
|
35
|
+
raise InvalidPluginError, "archive is empty" if archive.to_s.empty?
|
|
36
|
+
raise InvalidPluginError, "archive exceeds #{max_bytes} bytes" if archive.bytesize > max_bytes
|
|
37
|
+
|
|
38
|
+
files = {}
|
|
39
|
+
total = 0
|
|
40
|
+
root = nil
|
|
41
|
+
prefix = clean_prefix(subdir)
|
|
42
|
+
Zlib::GzipReader.wrap(StringIO.new(archive)) do |gz|
|
|
43
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
|
44
|
+
tar.each do |entry|
|
|
45
|
+
typeflag = entry.header.typeflag
|
|
46
|
+
next if EXTENDED_HEADERS.include?(typeflag)
|
|
47
|
+
|
|
48
|
+
parts = clean_path(entry.full_name).split("/")
|
|
49
|
+
if strip_root
|
|
50
|
+
root ||= parts.first
|
|
51
|
+
raise InvalidPluginError, "archive has more than one root (#{root.inspect}, #{parts.first.inspect})" unless parts.first == root
|
|
52
|
+
|
|
53
|
+
parts = parts.drop(1)
|
|
54
|
+
end
|
|
55
|
+
next if typeflag == DIRECTORY || entry.directory?
|
|
56
|
+
next unless REGULAR.include?(typeflag) || entry.file?
|
|
57
|
+
next if parts.empty?
|
|
58
|
+
|
|
59
|
+
rel = parts.join("/")
|
|
60
|
+
unless prefix.empty?
|
|
61
|
+
next unless rel.start_with?("#{prefix}/")
|
|
62
|
+
|
|
63
|
+
rel = rel.delete_prefix("#{prefix}/")
|
|
64
|
+
end
|
|
65
|
+
raise InvalidPluginError, "archive file #{entry.full_name.inspect} exceeds #{max_file_bytes} bytes" if entry.size > max_file_bytes
|
|
66
|
+
raise InvalidPluginError, "archive has more than #{max_files} files" if files.size >= max_files
|
|
67
|
+
raise InvalidPluginError, "archive expands past #{max_bytes} bytes" if (total += entry.size) > max_bytes
|
|
68
|
+
|
|
69
|
+
files[rel] = entry.read.to_s.b
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
raise InvalidPluginError, "archive has no files#{" under #{prefix}" unless prefix.empty?}" if files.empty?
|
|
74
|
+
|
|
75
|
+
files
|
|
76
|
+
rescue Zlib::Error, Gem::Package::TarInvalidError => e
|
|
77
|
+
raise InvalidPluginError, "archive is not a valid tar.gz (#{e.class.name.split("::").last})"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `..`, absolute paths, empty names and NUL bytes are refused before
|
|
81
|
+
# any path is joined to a directory.
|
|
82
|
+
def clean_path(name)
|
|
83
|
+
raw = name.to_s.strip
|
|
84
|
+
raise InvalidPluginError, "unsafe archive path #{name.inspect}" if raw.empty? || raw.include?("\0") || raw.start_with?("/") || raw.include?("\\")
|
|
85
|
+
|
|
86
|
+
cleaned = Pathname.new(raw).cleanpath.to_s
|
|
87
|
+
raise InvalidPluginError, "unsafe archive path #{name.inspect}" if cleaned.empty? || cleaned == "." || cleaned == ".." || cleaned.start_with?("../", "/")
|
|
88
|
+
|
|
89
|
+
cleaned
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `{ "path" => bytes }` to tar.gz bytes, entries sorted and the
|
|
93
|
+
# mtime zeroed so the same tree always yields the same archive.
|
|
94
|
+
def write(files, root: nil)
|
|
95
|
+
io = StringIO.new
|
|
96
|
+
Zlib::GzipWriter.wrap(io) do |gz|
|
|
97
|
+
gz.mtime = 0
|
|
98
|
+
Gem::Package::TarWriter.new(gz) do |tar|
|
|
99
|
+
files.sort.each do |path, data|
|
|
100
|
+
full = root ? "#{root}/#{path}" : path
|
|
101
|
+
bytes = data.to_s.b
|
|
102
|
+
tar.add_file_simple(full, 0o644, bytes.bytesize) { |f| f.write(bytes) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
io.string
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The directory as `{ "path" => bytes }` (regular files only).
|
|
110
|
+
def from_directory(dir, max_bytes: config.max_archive_bytes, max_file_bytes: config.max_file_bytes, max_files: config.max_files)
|
|
111
|
+
base = Pathname.new(dir)
|
|
112
|
+
files = {}
|
|
113
|
+
total = 0
|
|
114
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: base.to_s).sort.each do |rel|
|
|
115
|
+
next if rel == "." || rel.end_with?("/.") || rel.split("/").include?("..")
|
|
116
|
+
|
|
117
|
+
full = base.join(rel)
|
|
118
|
+
next if full.symlink? || !full.file?
|
|
119
|
+
raise InvalidPluginError, "#{rel} exceeds #{max_file_bytes} bytes" if full.size > max_file_bytes
|
|
120
|
+
raise InvalidPluginError, "#{dir} has more than #{max_files} files" if files.size >= max_files
|
|
121
|
+
raise InvalidPluginError, "#{dir} holds more than #{max_bytes} bytes" if (total += full.size) > max_bytes
|
|
122
|
+
|
|
123
|
+
files[rel] = full.binread
|
|
124
|
+
end
|
|
125
|
+
files
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Writes the tree under +dir+, creating parents; replaces nothing else.
|
|
129
|
+
def write_directory(files, dir)
|
|
130
|
+
files.each do |path, data|
|
|
131
|
+
target = File.join(dir, path)
|
|
132
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
133
|
+
File.binwrite(target, data)
|
|
134
|
+
end
|
|
135
|
+
dir
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# sha256 over the sorted paths and content hashes: the tree's identity.
|
|
139
|
+
def tree_sha256(files)
|
|
140
|
+
digest = Digest::SHA256.new
|
|
141
|
+
files.sort.each do |path, data|
|
|
142
|
+
digest << path << "\0" << Digest::SHA256.hexdigest(data.to_s) << "\n"
|
|
143
|
+
end
|
|
144
|
+
digest.hexdigest
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def config
|
|
150
|
+
Marketplace.config
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def clean_prefix(subdir)
|
|
154
|
+
subdir.to_s.delete_prefix("./").delete_suffix("/")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "marketplace/config"
|
|
4
|
+
require_relative "marketplace/http"
|
|
5
|
+
require_relative "marketplace/tarball"
|
|
6
|
+
require_relative "marketplace/manifest"
|
|
7
|
+
require_relative "marketplace/locator"
|
|
8
|
+
require_relative "marketplace/github_repo"
|
|
9
|
+
require_relative "marketplace/gitlab_repo"
|
|
10
|
+
require_relative "marketplace/fetcher"
|
|
11
|
+
require_relative "marketplace/bundle"
|
|
12
|
+
require_relative "marketplace/lockfile"
|
|
13
|
+
require_relative "marketplace/registry"
|
|
14
|
+
|
|
15
|
+
module RubyLLM
|
|
16
|
+
module Skills
|
|
17
|
+
# Plugin marketplaces as a skill source.
|
|
18
|
+
#
|
|
19
|
+
# A marketplace is a Claude Code, Codex, or Cursor plugin marketplace
|
|
20
|
+
# (a GitHub repository, GitLab project, hosted marketplace.json, or a
|
|
21
|
+
# local directory). Its plugins are fetched over HTTPS, normalized into
|
|
22
|
+
# the skills/ layout the loaders read, written under a vendor directory,
|
|
23
|
+
# and recorded in a lockfile with their resolved commit and version.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# marketplaces = RubyLLM::Skills.marketplaces
|
|
27
|
+
# marketplaces.add("EveryInc/compound-writing")
|
|
28
|
+
# marketplaces.install("compound-writing")
|
|
29
|
+
# chat.with_skills(marketplaces)
|
|
30
|
+
#
|
|
31
|
+
module Marketplace
|
|
32
|
+
# Base error for the marketplace layer.
|
|
33
|
+
class Error < Skills::Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when an upstream cannot be reached or refuses the request.
|
|
36
|
+
class FetchError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised when a marketplace file is malformed.
|
|
39
|
+
class InvalidManifestError < Error; end
|
|
40
|
+
|
|
41
|
+
# Raised when a plugin's tree breaks the Agent Skills rules or the caps.
|
|
42
|
+
class InvalidPluginError < Error; end
|
|
43
|
+
|
|
44
|
+
# Raised when the lockfile cannot be read.
|
|
45
|
+
class LockfileError < Error; end
|
|
46
|
+
|
|
47
|
+
DEFAULT_ROOT = "vendor/skills"
|
|
48
|
+
DEFAULT_LOCKFILE = "skills.lock.json"
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
attr_writer :root, :lockfile
|
|
52
|
+
|
|
53
|
+
# Where installed plugins live (default: vendor/skills).
|
|
54
|
+
def root
|
|
55
|
+
@root || DEFAULT_ROOT
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Where the lockfile lives (default: skills.lock.json).
|
|
59
|
+
def lockfile
|
|
60
|
+
@lockfile || DEFAULT_LOCKFILE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def config
|
|
64
|
+
@config ||= Config.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @yield [Config] the configuration to change
|
|
68
|
+
def configure
|
|
69
|
+
yield config
|
|
70
|
+
config
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_config!
|
|
74
|
+
@config = Config.new
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -10,6 +10,8 @@ module RubyLLM
|
|
|
10
10
|
class Railtie < ::Rails::Railtie
|
|
11
11
|
initializer "ruby_llm_skills.configure" do
|
|
12
12
|
RubyLLM::Skills.default_path = Rails.root.join("app", "skills").to_s
|
|
13
|
+
RubyLLM::Skills::Marketplace.root = Rails.root.join(RubyLLM::Skills::Marketplace::DEFAULT_ROOT).to_s
|
|
14
|
+
RubyLLM::Skills::Marketplace.lockfile = Rails.root.join(RubyLLM::Skills::Marketplace::DEFAULT_LOCKFILE).to_s
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
# Add app/skills to autoload paths (before initialization)
|
|
@@ -32,6 +34,7 @@ module RubyLLM
|
|
|
32
34
|
# Provide rake tasks
|
|
33
35
|
rake_tasks do
|
|
34
36
|
load File.expand_path("tasks/skills.rake", __dir__)
|
|
37
|
+
load File.expand_path("tasks/marketplaces.rake", __dir__)
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
end
|
|
@@ -27,15 +27,22 @@ module RubyLLM
|
|
|
27
27
|
#
|
|
28
28
|
class SkillTool < RubyLLM::Tool
|
|
29
29
|
description "Execute a skill within the main conversation."
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
parameter :command, type: "string",
|
|
31
|
+
description: "The skill name (e.g., 'pdf' or 'write-poem')"
|
|
32
|
+
parameter :arguments, type: "string", required: false,
|
|
33
|
+
description: "Arguments passed after the command (e.g., '/write-poem about robots' passes 'about robots')"
|
|
34
|
+
parameter :resource, type: "string", required: false,
|
|
35
|
+
description: "Optional resource path to load (e.g., 'scripts/helper.rb', 'references/guide.md')"
|
|
36
36
|
|
|
37
37
|
attr_reader :loader
|
|
38
38
|
|
|
39
|
+
# Tool name for RubyLLM.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] "skill"
|
|
42
|
+
def self.tool_name
|
|
43
|
+
"skill"
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
# Initialize with a skill loader.
|
|
40
47
|
#
|
|
41
48
|
# @param loader [Loader] any loader (FilesystemLoader, ZipLoader, etc.)
|
|
@@ -43,13 +50,6 @@ module RubyLLM
|
|
|
43
50
|
@loader = loader
|
|
44
51
|
end
|
|
45
52
|
|
|
46
|
-
# Tool name for RubyLLM.
|
|
47
|
-
#
|
|
48
|
-
# @return [String] "skill"
|
|
49
|
-
def name
|
|
50
|
-
"skill"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
53
|
# Dynamic description including available skills.
|
|
54
54
|
#
|
|
55
55
|
# @return [String] tool description with embedded skill metadata
|
|
@@ -98,7 +98,7 @@ module RubyLLM
|
|
|
98
98
|
{
|
|
99
99
|
name: name,
|
|
100
100
|
description: description,
|
|
101
|
-
parameters:
|
|
101
|
+
parameters: parameters_schema
|
|
102
102
|
}
|
|
103
103
|
end
|
|
104
104
|
|