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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Skills
|
|
5
|
+
module Marketplace
|
|
6
|
+
# Knobs for fetching marketplaces: caps, the GitHub token, and the
|
|
7
|
+
# guard a marketplace author's URL must pass.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# RubyLLM::Skills::Marketplace.configure do |config|
|
|
11
|
+
# config.github_token = ENV["MARKETPLACE_GITHUB_TOKEN"]
|
|
12
|
+
# config.url_guard = ->(uri) { raise "nope" unless uri.host.end_with?(".example.com") }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
class Config
|
|
16
|
+
MEBIBYTE = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
# Sent to GitHub hosts only; lifts the rate limit and reaches private repositories.
|
|
19
|
+
attr_accessor :github_token
|
|
20
|
+
# One plugin archive (compressed and expanded), one file inside it, files per plugin, skills per plugin.
|
|
21
|
+
attr_accessor :max_archive_bytes, :max_file_bytes, :max_files, :max_skills
|
|
22
|
+
attr_accessor :user_agent
|
|
23
|
+
# Called with each URI hop of a URL a marketplace author supplied
|
|
24
|
+
# (a hosted marketplace.json, an archive source). Raise to refuse;
|
|
25
|
+
# return an IP address string to pin the connection to the address
|
|
26
|
+
# that passed, so a DNS answer cannot change between check and connect.
|
|
27
|
+
attr_accessor :url_guard
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@github_token = ENV.fetch("GITHUB_TOKEN", nil)
|
|
31
|
+
@max_archive_bytes = 64 * MEBIBYTE
|
|
32
|
+
@max_file_bytes = 16 * MEBIBYTE
|
|
33
|
+
@max_files = 2000
|
|
34
|
+
@max_skills = 200
|
|
35
|
+
@user_agent = "ruby_llm-skills/#{RubyLLM::Skills::VERSION}"
|
|
36
|
+
@url_guard = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Skills
|
|
7
|
+
module Marketplace
|
|
8
|
+
# Where a marketplace's bytes come from: one adapter per source kind.
|
|
9
|
+
# +head+ answers "did the upstream move?" cheaply (a commit sha or a
|
|
10
|
+
# tree hash), +catalog_files+ fetches only the marketplace file(s),
|
|
11
|
+
# +source_tree+ fetches one plugin's content (the marketplace's own tree
|
|
12
|
+
# for relative sources, memoized so ten relative plugins cost one
|
|
13
|
+
# download; another repository's archive for github / gitlab sources;
|
|
14
|
+
# a .tar.gz for archive sources). Nothing here runs git.
|
|
15
|
+
#
|
|
16
|
+
module Fetcher
|
|
17
|
+
Head = Data.define(:sha, :etag, :unchanged) do
|
|
18
|
+
def unchanged? = unchanged
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +files+ is the plugin root as `{ path => bytes }`; +sha+ identifies the
|
|
22
|
+
# content (a commit, a tree hash, an archive digest).
|
|
23
|
+
Fetched = Data.define(:files, :sha, :upstream_url)
|
|
24
|
+
|
|
25
|
+
# @param source [Locator::Source]
|
|
26
|
+
# @return [Base]
|
|
27
|
+
def self.for(source)
|
|
28
|
+
case source.kind
|
|
29
|
+
when "github" then Github.new(source)
|
|
30
|
+
when "gitlab" then Gitlab.new(source)
|
|
31
|
+
when "url" then Url.new(source)
|
|
32
|
+
when "directory" then Directory.new(source)
|
|
33
|
+
else raise FetchError, "unknown marketplace kind #{source.kind.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Base
|
|
38
|
+
attr_reader :source
|
|
39
|
+
|
|
40
|
+
def initialize(source)
|
|
41
|
+
@source = source
|
|
42
|
+
@tarballs = {}
|
|
43
|
+
@shas = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def head(previous_sha: nil, previous_etag: nil)
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `{ path => bytes }` for the marketplace files that exist at +head+.
|
|
51
|
+
def catalog_files(head)
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The plugin content for one marketplace entry.
|
|
56
|
+
#
|
|
57
|
+
# @param plugin_source [Manifest::PluginSource]
|
|
58
|
+
# @param head [Head]
|
|
59
|
+
# @return [Fetched]
|
|
60
|
+
def source_tree(plugin_source, head:)
|
|
61
|
+
case plugin_source.kind
|
|
62
|
+
when "relative" then relative_tree(plugin_source, head)
|
|
63
|
+
when "github" then repo_tree(GithubRepo.new(plugin_source.repo), plugin_source)
|
|
64
|
+
when "gitlab" then repo_tree(GitlabRepo.new(plugin_source.repo), plugin_source)
|
|
65
|
+
when "archive" then archive_tree(plugin_source)
|
|
66
|
+
else raise FetchError, "unsupported source (#{plugin_source.reason})"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The commit a source resolves to right now, without downloading it
|
|
71
|
+
# (nil for an archive, whose identity is its digest).
|
|
72
|
+
def source_sha(plugin_source, head:)
|
|
73
|
+
case plugin_source.kind
|
|
74
|
+
when "relative" then head.sha
|
|
75
|
+
when "github" then repo_sha(GithubRepo.new(plugin_source.repo), plugin_source)
|
|
76
|
+
when "gitlab" then repo_sha(GitlabRepo.new(plugin_source.repo), plugin_source)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def relative_tree(plugin_source, head)
|
|
83
|
+
Fetched.new(files: read_tree(head.sha, plugin_source.path), sha: head.sha, upstream_url: tree_url(head.sha, plugin_source.path))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def repo_sha(repo, plugin_source)
|
|
87
|
+
plugin_source.sha || @shas[[repo.class, repo.repo, plugin_source.ref]] ||= repo.commit(plugin_source.ref || repo.default_branch).first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def repo_tree(repo, plugin_source)
|
|
91
|
+
sha = repo_sha(repo, plugin_source)
|
|
92
|
+
Fetched.new(files: Tarball.read(tarball(repo, sha), subdir: plugin_source.path), sha: sha, upstream_url: repo.tree_url(sha, plugin_source.path))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One download per repository commit however many plugins live in it.
|
|
96
|
+
def tarball(repo, sha)
|
|
97
|
+
@tarballs[[repo.class, repo.repo, sha]] ||= repo.tarball(sha)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def archive_tree(plugin_source)
|
|
101
|
+
response = Http.get(plugin_source.url, public: true)
|
|
102
|
+
raise FetchError, "archive returned HTTP #{response.status}" unless response.success?
|
|
103
|
+
|
|
104
|
+
digest = Digest::SHA256.hexdigest(response.body)
|
|
105
|
+
raise FetchError, "archive sha256 mismatch for #{plugin_source.url}" if plugin_source.sha256 && plugin_source.sha256 != digest
|
|
106
|
+
|
|
107
|
+
files = Tarball.read(response.body, strip_root: false)
|
|
108
|
+
Fetched.new(files: strip_single_root(files), sha: digest, upstream_url: plugin_source.url)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
PLUGIN_MARKERS = %w[skills/ commands/ agents/ .claude-plugin/ .codex-plugin/ .cursor-plugin/ SKILL.md plugin.json].freeze
|
|
112
|
+
|
|
113
|
+
# An archive may carry the plugin at its top or one folder down: a
|
|
114
|
+
# single root directory that itself holds a plugin marker (a
|
|
115
|
+
# `skills/` directory, a manifest, a root `SKILL.md`) is a wrapper
|
|
116
|
+
# and is stripped; a root that is the plugin's own `skills/` is not.
|
|
117
|
+
def strip_single_root(files)
|
|
118
|
+
roots = files.keys.map { |path| path.split("/", 2) }
|
|
119
|
+
return files if roots.any? { |parts| parts.size == 1 } || roots.map(&:first).uniq.size != 1
|
|
120
|
+
|
|
121
|
+
root = roots.first.first
|
|
122
|
+
wrapper = PLUGIN_MARKERS.any? do |marker|
|
|
123
|
+
marker.end_with?("/") ? files.keys.any? { |path| path.start_with?("#{root}/#{marker}") } : files.key?("#{root}/#{marker}")
|
|
124
|
+
end
|
|
125
|
+
return files unless wrapper
|
|
126
|
+
|
|
127
|
+
files.transform_keys { |path| path.split("/", 2).last }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def subtree(files, subdir, where)
|
|
131
|
+
prefix = Manifest.presence(subdir)
|
|
132
|
+
return files if prefix.nil?
|
|
133
|
+
|
|
134
|
+
subset = files.select { |path, _| path.start_with?("#{prefix}/") }.transform_keys { |path| path.delete_prefix("#{prefix}/") }
|
|
135
|
+
raise FetchError, "#{where}/#{prefix} has no files" if subset.empty?
|
|
136
|
+
|
|
137
|
+
subset
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A repository over one of the repo clients (GitHub, GitLab).
|
|
142
|
+
class Repository < Base
|
|
143
|
+
def repo
|
|
144
|
+
raise NotImplementedError
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def head(previous_sha: nil, previous_etag: nil)
|
|
148
|
+
ref = source.ref || repo.default_branch
|
|
149
|
+
result = repo.commit(ref, etag: previous_etag)
|
|
150
|
+
return Head.new(sha: previous_sha, etag: previous_etag, unchanged: true) if result == :not_modified
|
|
151
|
+
|
|
152
|
+
sha, etag = result
|
|
153
|
+
Head.new(sha: sha, etag: etag, unchanged: sha == previous_sha)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def catalog_files(head)
|
|
157
|
+
Manifest::PATHS.each_with_object({}) do |path, files|
|
|
158
|
+
body = repo.file(head.sha, path)
|
|
159
|
+
files[path] = body if body
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def read_tree(sha, subdir)
|
|
166
|
+
Tarball.read(tarball(repo, sha), subdir: subdir)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def tree_url(sha, subdir)
|
|
170
|
+
repo.tree_url(sha, subdir)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class Github < Repository
|
|
175
|
+
def repo
|
|
176
|
+
@repo ||= GithubRepo.new(source.locator)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class Gitlab < Repository
|
|
181
|
+
def repo
|
|
182
|
+
@repo ||= GitlabRepo.new(source.locator)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A bare `https://…/marketplace.json`: the file is the whole upstream,
|
|
187
|
+
# so relative sources cannot be fetched (as in Claude Code); github,
|
|
188
|
+
# gitlab and archive entries work.
|
|
189
|
+
class Url < Base
|
|
190
|
+
def head(previous_sha: nil, previous_etag: nil)
|
|
191
|
+
response = fetch(etag: previous_etag)
|
|
192
|
+
return Head.new(sha: previous_sha, etag: previous_etag, unchanged: true) if response.not_modified?
|
|
193
|
+
|
|
194
|
+
@body = response.body
|
|
195
|
+
sha = Digest::SHA256.hexdigest(response.body)
|
|
196
|
+
Head.new(sha: sha, etag: response.etag, unchanged: sha == previous_sha)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def catalog_files(_head)
|
|
200
|
+
{Manifest::PATHS.first => @body || fetch.body}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def fetch(etag: nil)
|
|
206
|
+
response = Http.get(source.locator, public: true, max_bytes: Manifest::MAX_BYTES, etag: etag)
|
|
207
|
+
raise FetchError, "marketplace URL returned HTTP #{response.status}" unless response.success? || response.not_modified?
|
|
208
|
+
|
|
209
|
+
response
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def read_tree(_sha, _subdir)
|
|
213
|
+
raise FetchError, "relative sources are not available from a hosted marketplace.json"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def tree_url(_sha, _subdir)
|
|
217
|
+
source.locator
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# A local folder holding a marketplace file. The tree's sha256 is the
|
|
222
|
+
# head, so an update is a local read that does nothing until the
|
|
223
|
+
# folder changes.
|
|
224
|
+
class Directory < Base
|
|
225
|
+
def root
|
|
226
|
+
dir = File.expand_path(source.locator)
|
|
227
|
+
raise FetchError, "marketplace directory #{source.locator} does not exist" unless File.directory?(dir)
|
|
228
|
+
|
|
229
|
+
dir
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def head(previous_sha: nil, previous_etag: nil)
|
|
233
|
+
@files = nil
|
|
234
|
+
sha = Tarball.tree_sha256(files)
|
|
235
|
+
Head.new(sha: sha, etag: nil, unchanged: sha == previous_sha)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def catalog_files(_head)
|
|
239
|
+
files.slice(*Manifest::PATHS)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def files
|
|
245
|
+
@files ||= Tarball.from_directory(root)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def read_tree(_sha, subdir)
|
|
249
|
+
subtree(files, subdir, source.locator)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# A local folder has no public tree to link to.
|
|
253
|
+
def tree_url(_sha, _subdir)
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Skills
|
|
7
|
+
module Marketplace
|
|
8
|
+
# One GitHub repository over the REST API and its archive hosts: the
|
|
9
|
+
# default branch and a ref's head commit from api.github.com, single
|
|
10
|
+
# files from raw.githubusercontent.com, the tree as a tarball from
|
|
11
|
+
# codeload.github.com. The optional token lifts the rate limit and
|
|
12
|
+
# reaches private repositories; it is sent to these hosts only.
|
|
13
|
+
#
|
|
14
|
+
class GithubRepo
|
|
15
|
+
API = "api.github.com"
|
|
16
|
+
RAW = "raw.githubusercontent.com"
|
|
17
|
+
CODELOAD = "codeload.github.com"
|
|
18
|
+
HOSTS = [API, RAW, CODELOAD].freeze
|
|
19
|
+
SMALL = 16 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
attr_reader :repo
|
|
22
|
+
|
|
23
|
+
def initialize(repo, token: Marketplace.config.github_token)
|
|
24
|
+
@repo = Manifest.normalize_repo(repo)
|
|
25
|
+
raise FetchError, "invalid GitHub repository #{repo.inspect}" unless @repo.match?(Manifest::REPO_PATTERN)
|
|
26
|
+
|
|
27
|
+
@token = Manifest.presence(token)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def default_branch
|
|
31
|
+
Http.json(api("repos/#{repo}")).fetch("default_branch", "main").to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The head commit of +ref+ (a branch, tag or sha): [sha, etag], or
|
|
35
|
+
# :not_modified when the ETag still matches.
|
|
36
|
+
def commit(ref, etag: nil)
|
|
37
|
+
response = api("repos/#{repo}/commits/#{ERB::Util.url_encode(ref)}", etag: etag, allow_not_modified: true)
|
|
38
|
+
return :not_modified if response.not_modified?
|
|
39
|
+
|
|
40
|
+
sha = Http.json(response)["sha"].to_s
|
|
41
|
+
raise FetchError, "#{repo}@#{ref}: no commit sha in the response" unless sha.match?(Manifest::SHA_PATTERN)
|
|
42
|
+
|
|
43
|
+
[sha, response.etag]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One file at +sha+, or nil when it does not exist.
|
|
47
|
+
def file(sha, path)
|
|
48
|
+
response = Http.get("https://#{RAW}/#{repo}/#{sha}/#{path}", hosts: HOSTS, headers: headers, max_bytes: SMALL)
|
|
49
|
+
return nil if response.not_found?
|
|
50
|
+
raise FetchError, "#{repo}: #{path} returned HTTP #{response.status}" unless response.success?
|
|
51
|
+
|
|
52
|
+
response.body
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tarball(sha)
|
|
56
|
+
response = Http.get("https://#{CODELOAD}/#{repo}/tar.gz/#{sha}", hosts: HOSTS, headers: headers)
|
|
57
|
+
raise FetchError, "#{repo}: archive returned HTTP #{response.status}" unless response.success?
|
|
58
|
+
|
|
59
|
+
response.body
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `{ "tag" =>, "url" =>, "name" =>, "published_at" => }` for a tag that has a GitHub release, else nil.
|
|
63
|
+
def release(tag)
|
|
64
|
+
response = api("repos/#{repo}/releases/tags/#{ERB::Util.url_encode(tag)}", allow_not_found: true)
|
|
65
|
+
return nil if response.not_found?
|
|
66
|
+
|
|
67
|
+
data = Http.json(response)
|
|
68
|
+
{"tag" => tag, "url" => data["html_url"].to_s, "name" => data["name"].to_s, "published_at" => data["published_at"].to_s}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tree_url(sha, subdir = nil)
|
|
72
|
+
["https://github.com/#{repo}/tree/#{sha}", Manifest.presence(subdir)].compact.join("/")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def api(path, etag: nil, allow_not_modified: false, allow_not_found: false)
|
|
78
|
+
response = Http.get("https://#{API}/#{path}", hosts: HOSTS, headers: headers.merge("Accept" => "application/vnd.github+json"),
|
|
79
|
+
max_bytes: SMALL, etag: etag)
|
|
80
|
+
return response if response.success? || (allow_not_modified && response.not_modified?) || (allow_not_found && response.not_found?)
|
|
81
|
+
if [403, 429].include?(response.status) && response.headers["x-ratelimit-remaining"].to_s == "0"
|
|
82
|
+
raise FetchError, "GitHub rate limit reached for #{repo}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise FetchError, "#{repo}: GitHub API returned HTTP #{response.status} for #{path.split("/").last}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def headers
|
|
89
|
+
@token ? {"Authorization" => "Bearer #{@token}"} : {}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Skills
|
|
7
|
+
module Marketplace
|
|
8
|
+
# One gitlab.com project over API v4: a ref's head commit, single raw
|
|
9
|
+
# files and the repository archive. Public projects only.
|
|
10
|
+
#
|
|
11
|
+
class GitlabRepo
|
|
12
|
+
HOST = "gitlab.com"
|
|
13
|
+
HOSTS = [HOST].freeze
|
|
14
|
+
SMALL = 16 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
attr_reader :repo
|
|
17
|
+
|
|
18
|
+
def initialize(project)
|
|
19
|
+
@repo = Manifest.normalize_repo(project)
|
|
20
|
+
raise FetchError, "invalid GitLab project #{project.inspect}" unless @repo.match?(Manifest::PROJECT_PATTERN)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def default_branch
|
|
24
|
+
Http.json(api("")).fetch("default_branch", "main").to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def commit(ref, etag: nil)
|
|
28
|
+
response = api("/repository/commits/#{ERB::Util.url_encode(ref)}", etag: etag, allow_not_modified: true)
|
|
29
|
+
return :not_modified if response.not_modified?
|
|
30
|
+
|
|
31
|
+
sha = Http.json(response)["id"].to_s
|
|
32
|
+
raise FetchError, "#{repo}@#{ref}: no commit id in the response" unless sha.match?(Manifest::SHA_PATTERN)
|
|
33
|
+
|
|
34
|
+
[sha, response.etag]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def file(sha, path)
|
|
38
|
+
response = Http.get("#{base}/repository/files/#{ERB::Util.url_encode(path)}/raw?ref=#{ERB::Util.url_encode(sha)}", hosts: HOSTS, max_bytes: SMALL)
|
|
39
|
+
return nil if response.not_found?
|
|
40
|
+
raise FetchError, "#{repo}: #{path} returned HTTP #{response.status}" unless response.success?
|
|
41
|
+
|
|
42
|
+
response.body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tarball(sha)
|
|
46
|
+
response = Http.get("#{base}/repository/archive.tar.gz?sha=#{ERB::Util.url_encode(sha)}", hosts: HOSTS)
|
|
47
|
+
raise FetchError, "#{repo}: archive returned HTTP #{response.status}" unless response.success?
|
|
48
|
+
|
|
49
|
+
response.body
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tree_url(sha, subdir = nil)
|
|
53
|
+
["https://#{HOST}/#{repo}/-/tree/#{sha}", Manifest.presence(subdir)].compact.join("/")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def base
|
|
59
|
+
"https://#{HOST}/api/v4/projects/#{ERB::Util.url_encode(repo)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def api(path, etag: nil, allow_not_modified: false)
|
|
63
|
+
response = Http.get("#{base}#{path}", hosts: HOSTS, max_bytes: SMALL, etag: etag)
|
|
64
|
+
return response if response.success? || (allow_not_modified && response.not_modified?)
|
|
65
|
+
raise FetchError, "GitLab rate limit reached for #{repo}" if response.status == 429
|
|
66
|
+
|
|
67
|
+
raise FetchError, "#{repo}: GitLab API returned HTTP #{response.status}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module Skills
|
|
10
|
+
module Marketplace
|
|
11
|
+
# The one HTTP door for marketplace fetches: HTTPS only, a body cap,
|
|
12
|
+
# redirects followed by hand, conditional requests through ETags.
|
|
13
|
+
#
|
|
14
|
+
# Two admission modes: +hosts:+ for the adapters' own well-known hosts,
|
|
15
|
+
# +public: true+ for a URL a marketplace author supplied, where every
|
|
16
|
+
# hop must pass +Config#url_guard+ when one is configured.
|
|
17
|
+
#
|
|
18
|
+
module Http
|
|
19
|
+
OPEN_TIMEOUT = 10
|
|
20
|
+
READ_TIMEOUT = 120
|
|
21
|
+
MAX_REDIRECTS = 3
|
|
22
|
+
|
|
23
|
+
Response = Data.define(:status, :body, :headers) do
|
|
24
|
+
def success? = status.between?(200, 299)
|
|
25
|
+
|
|
26
|
+
def not_modified? = status == 304
|
|
27
|
+
|
|
28
|
+
def not_found? = status == 404
|
|
29
|
+
|
|
30
|
+
def etag
|
|
31
|
+
value = headers["etag"].to_s
|
|
32
|
+
value.empty? ? nil : value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# @param url [String] an https URL
|
|
38
|
+
# @param hosts [Array<String>] hosts admitted by name
|
|
39
|
+
# @param public [Boolean] admit any https host that passes the url_guard
|
|
40
|
+
# @param headers [Hash] extra request headers
|
|
41
|
+
# @param max_bytes [Integer] abandon the body past this many bytes
|
|
42
|
+
# @param etag [String, nil] sent as If-None-Match
|
|
43
|
+
# @return [Response]
|
|
44
|
+
# @raise [FetchError]
|
|
45
|
+
def get(url, hosts: [], public: false, headers: {}, max_bytes: Marketplace.config.max_archive_bytes, etag: nil)
|
|
46
|
+
uri = parse(url)
|
|
47
|
+
request_headers = {"User-Agent" => Marketplace.config.user_agent}.merge(headers)
|
|
48
|
+
request_headers["If-None-Match"] = etag if etag
|
|
49
|
+
|
|
50
|
+
(MAX_REDIRECTS + 1).times do
|
|
51
|
+
address = admit!(uri, hosts: hosts, public: public)
|
|
52
|
+
response = perform(uri, request_headers, max_bytes, address: address)
|
|
53
|
+
location = response.headers["location"]
|
|
54
|
+
return response unless response.status.between?(300, 399) && location
|
|
55
|
+
|
|
56
|
+
uri = parse(URI.join(uri.to_s, location).to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise FetchError, "#{uri.host}: too many redirects"
|
|
60
|
+
rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError => e
|
|
61
|
+
raise FetchError, "#{uri&.host}: #{e.class.name}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def json(response)
|
|
65
|
+
parsed = JSON.parse(response.body.to_s)
|
|
66
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
raise FetchError, "malformed JSON response"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parse(url)
|
|
74
|
+
URI.parse(url.to_s)
|
|
75
|
+
rescue URI::InvalidURIError
|
|
76
|
+
raise FetchError, "invalid URL #{url.to_s.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The address to connect to (nil: by name) once the hop is admitted.
|
|
80
|
+
def admit!(uri, hosts:, public:)
|
|
81
|
+
host = uri.host.to_s.downcase
|
|
82
|
+
raise FetchError, "refusing #{uri}: not an https URL" unless uri.is_a?(URI::HTTPS) && !host.empty?
|
|
83
|
+
raise FetchError, "refusing #{uri}: URLs with credentials are not allowed" if uri.userinfo
|
|
84
|
+
return nil if hosts.map(&:downcase).include?(host)
|
|
85
|
+
raise FetchError, "refusing #{host.inspect}: not an allowlisted https host" unless public
|
|
86
|
+
|
|
87
|
+
address = Marketplace.config.url_guard&.call(uri)
|
|
88
|
+
address.is_a?(String) ? address : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Streams the body and stops reading the moment it passes the cap,
|
|
92
|
+
# so an upstream that answers with gigabytes never occupies more
|
|
93
|
+
# than the cap in memory. Pinned to +address+ when given, Net::HTTP
|
|
94
|
+
# still names the host for SNI and the certificate check.
|
|
95
|
+
def perform(uri, headers, max_bytes, address: nil)
|
|
96
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
97
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
98
|
+
http.ipaddr = address if address
|
|
99
|
+
http.use_ssl = true
|
|
100
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
101
|
+
http.read_timeout = READ_TIMEOUT
|
|
102
|
+
http.start do
|
|
103
|
+
request = Net::HTTP::Get.new(uri.request_uri, headers)
|
|
104
|
+
http.request(request) do |response|
|
|
105
|
+
response.read_body do |chunk|
|
|
106
|
+
raise FetchError, "#{uri.host}: response exceeds #{max_bytes} bytes" if body.bytesize + chunk.bytesize > max_bytes
|
|
107
|
+
|
|
108
|
+
body << chunk.b
|
|
109
|
+
end
|
|
110
|
+
return Response.new(status: response.code.to_i, body: body, headers: response_headers(response))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def response_headers(response)
|
|
116
|
+
response.each_header.to_h { |key, value| [key.downcase, value] }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|