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.
@@ -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
- 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')"
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: params_schema
101
+ parameters: parameters_schema
102
102
  }
103
103
  end
104
104