ruby_llm-skills 0.4.0.pre1 → 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 +20 -0
- data/README.md +56 -4
- data/lib/ruby_llm/skills/agent_extensions.rb +3 -3
- data/lib/ruby_llm/skills/chat_extensions.rb +3 -1
- 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/source_detection.rb +5 -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 -0
- metadata +19 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cec1aca2ab163ce3eca337510effe09497b32a46992f0f5fe9f8ef5eb9ef629
|
|
4
|
+
data.tar.gz: 29c764164144d9fe80b7b77ba418a6f264202bc8ba6872f4d9e4e1640ca52615
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d125b1e44c94858c097cc4aaf24a81d019eb7d25fd9180c2957d2a5f9c43a6b541b6d4fbd46008b65017ea9de93f157609345e9918f597f97795fd816b5fe66c
|
|
7
|
+
data.tar.gz: 64aae5fd59daf2bf7500cee110a51fbd7a993cb9bf6f725a54df7934d2966ee07114a93fa1f7a32a99572a52f9394e1c1a8a721cdb78a0c8ac8c21fe03388c2e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0.pre1] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
Prerelease, like the 0.4.0 line, while RubyLLM 2.0 is at `2.0.0.rc3`; RubyLLM 1.x users stay on 0.3.x.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Plugin marketplaces as a skill source: `RubyLLM::Skills.marketplaces` returns a `Marketplace::Registry` that adds a Claude Code, Codex or Cursor marketplace from `owner/repo`, `owner/repo@ref`, a github.com or gitlab.com URL, a hosted `marketplace.json` URL or a local directory, lists its plugins, installs all or selected plugins under `vendor/skills/<marketplace>/<plugin>/`, updates and removes them
|
|
15
|
+
- `skills.lock.json` records every marketplace (kind, locator, ref, resolved commit) and plugin (version, commit, tree sha256, source, skills); `Registry#install` with no arguments reproduces the lockfile at the recorded commits, and a marketplace pinned to a commit sha never moves on `update`
|
|
16
|
+
- Plugin normalization: `skills/<name>/**` validated by the Agent Skills rules, `commands/*.md` as single-file skills, `agents/**/*.md` kept, plugin-level references relocated under `<skill>/.plugin/`, hooks, MCP, LSP, executables and workflows dropped, with caps on archive size, file size, file count and skill count
|
|
17
|
+
- `RubyLLM::Skills.from_marketplaces` loader; `Chat#with_skills` and `Agent.skills` accept a registry as a source
|
|
18
|
+
- `RubyLLM::Skills::Marketplace.configure` for the GitHub token, fetch caps and a `url_guard` hook for marketplace-author URLs
|
|
19
|
+
- Rake tasks `skills:marketplaces:{add,list,plugins,install,update,remove}`, loaded by the Railtie or via `require "ruby_llm/skills/tasks"`
|
|
20
|
+
|
|
21
|
+
## [0.4.0.pre2] - 2026-09-14
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Updated the RubyLLM 2.0 dependency floor and development locks from `2.0.0.rc2` to `2.0.0.rc3`
|
|
26
|
+
- Verified the Agent Skills integration against RubyLLM 2.0.0.rc3; its Agent, Chat, and Tool APIs remain compatible with the 0.4 port
|
|
27
|
+
|
|
8
28
|
## [0.4.0.pre1] - 2026-09-10
|
|
9
29
|
|
|
10
30
|
### Changed
|
data/README.md
CHANGED
|
@@ -9,11 +9,11 @@ Agent Skills for [RubyLLM](https://github.com/crmne/ruby_llm). Teach your AI how
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem "ruby_llm", "2.0.0.
|
|
13
|
-
gem "ruby_llm-skills", "0.
|
|
12
|
+
gem "ruby_llm", "2.0.0.rc3"
|
|
13
|
+
gem "ruby_llm-skills", "0.5.0.pre1"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Requires RubyLLM 2.0 (`>= 2.0.0.
|
|
16
|
+
Requires RubyLLM 2.0 (`>= 2.0.0.rc3`). If you are still on RubyLLM 1.x, stay on `ruby_llm-skills ~> 0.3.0` and follow the [RubyLLM 2.0 upgrade guide](https://rubyllm.com/next/upgrading/) before upgrading both gems together.
|
|
17
17
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
@@ -92,6 +92,58 @@ chat.with_skills("app/skills", "app/commands")
|
|
|
92
92
|
chat.ask "/write-poem about robots"
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Marketplaces
|
|
96
|
+
|
|
97
|
+
Instead of adding skills one by one, add a whole plugin marketplace. The gem reads Claude Code (`.claude-plugin/marketplace.json`), Codex (`.agents/plugins/marketplace.json`) and Cursor (`.cursor-plugin/marketplace.json`) marketplaces from a GitHub repository, a GitLab project, a hosted `marketplace.json` URL or a local directory, fetches plugins over HTTPS (never `git`), and normalizes each one into the `skills/` layout the loaders read.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
marketplaces = RubyLLM::Skills.marketplaces # vendor/skills + skills.lock.json
|
|
101
|
+
|
|
102
|
+
marketplaces.add("EveryInc/compound-writing") # follows the default branch
|
|
103
|
+
marketplaces.add("typesafe-ai/skills", ref: "v0.5.7") # pinned to a tag (or a commit sha)
|
|
104
|
+
marketplaces.add("https://example.com/marketplace.json")
|
|
105
|
+
|
|
106
|
+
marketplaces.plugins("compound-writing").map(&:name) # what the marketplace lists
|
|
107
|
+
marketplaces.install("compound-writing") # every supported plugin
|
|
108
|
+
marketplaces.install("typesafe-ai", only: ["typesafe"])
|
|
109
|
+
|
|
110
|
+
marketplaces.update # move unpinned marketplaces to their ref's head
|
|
111
|
+
marketplaces.remove("typesafe-ai")
|
|
112
|
+
|
|
113
|
+
chat.with_skills("app/skills", marketplaces) # a registry is a skill source
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Plugins land in `vendor/skills/<marketplace>/<plugin>/` with their `skills/` (directory skills plus `commands/*.md` as single-file skills) and `agents/`. Plugin-level files a skill references through `../../x` or `${CLAUDE_PLUGIN_ROOT}/x` are copied under `<skill>/.plugin/` so the skill tool can serve them. Hooks, MCP and LSP configuration, executables and workflows are never installed.
|
|
117
|
+
|
|
118
|
+
`skills.lock.json` records every marketplace with its kind, locator, ref and resolved commit, and every plugin with its version (the plugin manifest's, else the entry's, else the commit), commit, tree hash, source and skill names. Commit both, and `RubyLLM::Skills.marketplaces.install` (no arguments) reproduces the same trees on another machine at the recorded commits. A marketplace added with a commit sha as `ref` never moves on `update`; a branch or tag ref does.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
RubyLLM::Skills.from_marketplaces # a loader over the installed plugins
|
|
122
|
+
RubyLLM::Skills.marketplaces(root: "lib/skills/vendor", lockfile: "lib/skills/skills.lock.json")
|
|
123
|
+
|
|
124
|
+
RubyLLM::Skills::Marketplace.configure do |config|
|
|
125
|
+
config.github_token = ENV["GITHUB_TOKEN"] # the default; lifts the API rate limit
|
|
126
|
+
config.max_archive_bytes = 64 * 1024 * 1024 # per plugin archive; also max_file_bytes, max_files, max_skills
|
|
127
|
+
config.url_guard = ->(uri) { ... } # called per hop for URLs a marketplace author supplied
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`install` and `update` return a result with `installed`, `updated`, `unchanged`, `skipped` (unsupported sources, with the reason) and `errors` (per plugin); they raise only for an unknown marketplace or plugin name and for a marketplace that cannot be fetched at all.
|
|
132
|
+
|
|
133
|
+
The same operations are Rake tasks, loaded by the Railtie or with `require "ruby_llm/skills/tasks"` in a plain Rakefile:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rake skills:marketplaces:add[EveryInc/compound-writing]
|
|
137
|
+
rake skills:marketplaces:add[typesafe-ai/skills,v0.5.7]
|
|
138
|
+
rake skills:marketplaces:list
|
|
139
|
+
rake skills:marketplaces:plugins[compound-writing]
|
|
140
|
+
rake skills:marketplaces:install[compound-writing]
|
|
141
|
+
rake skills:marketplaces:install[compound-writing,cw-draft cw-line-edit]
|
|
142
|
+
rake skills:marketplaces:install # reproduce skills.lock.json
|
|
143
|
+
rake skills:marketplaces:update
|
|
144
|
+
rake skills:marketplaces:remove[compound-writing]
|
|
145
|
+
```
|
|
146
|
+
|
|
95
147
|
## Database Skills
|
|
96
148
|
|
|
97
149
|
Store skills or commands in your database:
|
|
@@ -130,7 +182,7 @@ bin/setup
|
|
|
130
182
|
### Running Tests
|
|
131
183
|
|
|
132
184
|
```bash
|
|
133
|
-
bundle exec rake test # Unit tests (
|
|
185
|
+
bundle exec rake test # Unit tests (300+ tests)
|
|
134
186
|
bundle exec rake test_rails # Rails integration tests (25+ tests)
|
|
135
187
|
bundle exec rake test_all # Both
|
|
136
188
|
bundle exec rake # Tests + linting
|
|
@@ -63,7 +63,7 @@ module RubyLLM
|
|
|
63
63
|
def flatten_skill_sources(source)
|
|
64
64
|
return [] if source.nil?
|
|
65
65
|
return [source] if source.is_a?(String)
|
|
66
|
-
return [source] if loader_source?(source)
|
|
66
|
+
return [source] if loader_source?(source) || marketplace_source?(source)
|
|
67
67
|
return source.empty? ? [] : [source] if database_collection_source?(source)
|
|
68
68
|
return source.flat_map { |item| flatten_skill_sources(item) } if source.is_a?(Array)
|
|
69
69
|
|
|
@@ -122,11 +122,11 @@ module RubyLLM
|
|
|
122
122
|
|
|
123
123
|
invalid_types = invalid_sources.map { |source| source.class.name || source.class.to_s }.uniq.join(", ")
|
|
124
124
|
raise ArgumentError,
|
|
125
|
-
"Invalid skill source(s): #{invalid_types}. Expected String path, Loader, or record collection."
|
|
125
|
+
"Invalid skill source(s): #{invalid_types}. Expected String path, Loader, marketplace registry, or record collection."
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def valid_skill_source?(source)
|
|
129
|
-
source.is_a?(String) || loader_source?(source) || database_collection_source?(source)
|
|
129
|
+
source.is_a?(String) || loader_source?(source) || marketplace_source?(source) || database_collection_source?(source)
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
@@ -44,13 +44,15 @@ module RubyLLM
|
|
|
44
44
|
case source
|
|
45
45
|
when String
|
|
46
46
|
RubyLLM::Skills.from_directory(source)
|
|
47
|
+
when ->(s) { marketplace_source?(s) }
|
|
48
|
+
source.loader
|
|
47
49
|
when ->(s) { database_collection_source?(s) }
|
|
48
50
|
RubyLLM::Skills.from_database(source)
|
|
49
51
|
when ->(s) { loader_source?(s) }
|
|
50
52
|
source
|
|
51
53
|
else
|
|
52
54
|
raise ArgumentError,
|
|
53
|
-
"Invalid skill source: #{source.class}. Expected String path, Loader, or record collection."
|
|
55
|
+
"Invalid skill source: #{source.class}. Expected String path, Loader, marketplace registry, or record collection."
|
|
54
56
|
end
|
|
55
57
|
end
|
|
56
58
|
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Skills
|
|
9
|
+
module Marketplace
|
|
10
|
+
# One plugin's root tree, normalized into what the loaders read:
|
|
11
|
+
# `skills/<name>/**` validated by the Agent Skills rules (frontmatter
|
|
12
|
+
# `name` matches the directory, `description` present), `commands/*.md`
|
|
13
|
+
# rewritten to `skills/<name>.md` (single-file skills), `agents/**/*.md`
|
|
14
|
+
# kept verbatim, and plugin-level files a skill reaches through
|
|
15
|
+
# `../../x` or `${CLAUDE_PLUGIN_ROOT}/x` copied under `<skill>/.plugin/`
|
|
16
|
+
# with the reference rewritten. Hooks, MCP and LSP configuration,
|
|
17
|
+
# executables and everything else are dropped. The tree's sha256 is the
|
|
18
|
+
# plugin's integrity pin.
|
|
19
|
+
#
|
|
20
|
+
class Bundle
|
|
21
|
+
MANIFEST_PATHS = %w[.claude-plugin/plugin.json .codex-plugin/plugin.json .cursor-plugin/plugin.json].freeze
|
|
22
|
+
AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/"
|
|
23
|
+
SUPPORT_ROOT = ".plugin"
|
|
24
|
+
# Marketplace-entry display fields that win over plugin.json (Claude Code's rule).
|
|
25
|
+
ENTRY_DISPLAY_FIELDS = %w[displayName description author homepage repository license keywords category tags].freeze
|
|
26
|
+
ROOT_REFERENCE = %r{(?:\$\{(?:CLAUDE_)?PLUGIN_ROOT\}|<plugin-root>)/(\w[\w./-]*)}
|
|
27
|
+
RELATIVE_REFERENCE = %r{(?<![\w./-])((?:\.\./)+\w[\w./-]*)}
|
|
28
|
+
|
|
29
|
+
# One skill the plugin contributes; +kind+ is "skill" or "command".
|
|
30
|
+
Skill = Data.define(:name, :description, :kind, :path, :frontmatter, :files) do
|
|
31
|
+
def command? = kind == "command"
|
|
32
|
+
|
|
33
|
+
# Where the skill lives in the normalized tree.
|
|
34
|
+
def tree_path
|
|
35
|
+
command? ? "skills/#{name}.md" : "skills/#{name}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# +files+ is the input tree as given (paths without a leading "./"); +tree+ the normalized output.
|
|
40
|
+
attr_reader :files, :name, :manifest, :manifest_path, :manifest_version, :entry, :tree, :skills, :agents,
|
|
41
|
+
:unsupported, :unresolved_references, :tree_sha256
|
|
42
|
+
|
|
43
|
+
# @param files [Hash{String => String}] the plugin root as `{ path => bytes }`
|
|
44
|
+
# @param entry [Manifest::Entry, nil] the marketplace entry, when there is one
|
|
45
|
+
# @param plugin_name [String, nil] a name to use when neither manifest nor entry has one
|
|
46
|
+
# @raise [InvalidPluginError]
|
|
47
|
+
def initialize(files, entry: nil, plugin_name: nil)
|
|
48
|
+
@files = files.transform_keys { |path| path.to_s.delete_prefix("./") }
|
|
49
|
+
@entry = entry
|
|
50
|
+
@manifest, @manifest_path, @manifest_version = discover_manifest
|
|
51
|
+
@name = (plugin_name || entry&.name || manifest["name"]).to_s.strip
|
|
52
|
+
raise InvalidPluginError, "plugin has no name (no manifest, no marketplace entry)" if @name.empty?
|
|
53
|
+
raise InvalidPluginError, "plugin name #{@name.inspect} is not kebab-case" unless @name.match?(Manifest::NAME_PATTERN)
|
|
54
|
+
|
|
55
|
+
@tree = {}
|
|
56
|
+
@consumed = []
|
|
57
|
+
@unsupported = []
|
|
58
|
+
@unresolved_references = []
|
|
59
|
+
@skills = collect_skills + collect_commands
|
|
60
|
+
check_skill_names!
|
|
61
|
+
@agents = collect_agents
|
|
62
|
+
relocate_references
|
|
63
|
+
@tree_sha256 = Tarball.tree_sha256(@tree)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The plugin manifest's `version`, else the marketplace entry's, else nil.
|
|
67
|
+
def version
|
|
68
|
+
manifest_version || entry&.version
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# "manifest" or "entry" when a version is declared, else nil.
|
|
72
|
+
def version_kind
|
|
73
|
+
return "manifest" if manifest_version
|
|
74
|
+
"entry" if entry&.version
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def display_name
|
|
78
|
+
Manifest.presence(manifest["displayName"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def description
|
|
82
|
+
Manifest.presence(manifest["description"])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skill_names
|
|
86
|
+
skills.map(&:name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Nothing the loaders could serve.
|
|
90
|
+
def empty?
|
|
91
|
+
skills.empty? && agents.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Input paths that did not make it into the tree.
|
|
95
|
+
def dropped
|
|
96
|
+
@files.keys - @consumed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Writes the tree under +dir+, replacing whatever was there.
|
|
100
|
+
def write!(dir)
|
|
101
|
+
FileUtils.rm_rf(dir)
|
|
102
|
+
Tarball.write_directory(tree, dir)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# --- manifest -----------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
# [manifest, path, version]: +version+ is the plugin manifest's own
|
|
110
|
+
# (nil for a strict:false entry, whose version is the entry's).
|
|
111
|
+
def discover_manifest
|
|
112
|
+
if entry && !entry.strict?
|
|
113
|
+
return [entry.raw.except("source", "strict").merge(entry.overrides), nil, nil]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
root = json_file("plugin.json")
|
|
117
|
+
data, path = if root.is_a?(Hash) && root["$schema"].to_s.start_with?(AGENT_PLUGINS_SCHEMA)
|
|
118
|
+
[root, "plugin.json"]
|
|
119
|
+
else
|
|
120
|
+
found = MANIFEST_PATHS.find { |candidate| @files.key?(candidate) }
|
|
121
|
+
[found ? json_file(found) : {}, found]
|
|
122
|
+
end
|
|
123
|
+
raise InvalidPluginError, "#{path} is not a JSON object" unless data.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
[merge_entry(data), path, Manifest.presence(data["version"])]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def merge_entry(data)
|
|
129
|
+
return data unless entry
|
|
130
|
+
|
|
131
|
+
merged = data.merge(entry.raw.slice(*ENTRY_DISPLAY_FIELDS).reject { |_, value| blank?(value) })
|
|
132
|
+
merged["version"] = Manifest.presence(data["version"]) || entry.version if Manifest.presence(data["version"]) || entry.version
|
|
133
|
+
merged["skills"] = Array(data["skills"]) + Array(entry.overrides["skills"]) if entry.overrides.key?("skills")
|
|
134
|
+
%w[commands agents].each { |key| merged[key] = entry.overrides[key] if entry.overrides.key?(key) }
|
|
135
|
+
merged
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def json_file(path)
|
|
139
|
+
raw = @files[path]
|
|
140
|
+
return nil if raw.nil?
|
|
141
|
+
|
|
142
|
+
JSON.parse(text!(raw, path))
|
|
143
|
+
rescue JSON::ParserError => e
|
|
144
|
+
raise InvalidPluginError, "#{path} is not valid JSON (#{e.message[0, 60]})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def text!(raw, path)
|
|
148
|
+
text = raw.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
149
|
+
raise InvalidPluginError, "#{path} is not UTF-8 text" unless text.valid_encoding?
|
|
150
|
+
|
|
151
|
+
text
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A manifest path ("./skills/", "./custom/x.md", ".") to tree-relative, or raises when it escapes.
|
|
155
|
+
def manifest_paths(value)
|
|
156
|
+
Array(value).filter_map do |raw|
|
|
157
|
+
cleaned = Manifest.normalize_relative(raw.to_s, allow_blank: true)
|
|
158
|
+
raise InvalidPluginError, "manifest path #{raw.inspect} escapes the plugin root" if cleaned.nil?
|
|
159
|
+
|
|
160
|
+
cleaned
|
|
161
|
+
end.uniq
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- skills --------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def collect_skills
|
|
167
|
+
locations = (["skills"] + manifest_paths(manifest["skills"])).uniq
|
|
168
|
+
skills = locations.flat_map { |location| skills_at(location) }
|
|
169
|
+
skills << root_skill if @files.key?("SKILL.md") && skills.none? { |skill| skill.path == "" }
|
|
170
|
+
skills.each do |skill|
|
|
171
|
+
skill.files.each { |rel, data| @tree["skills/#{skill.name}/#{rel}"] = data }
|
|
172
|
+
@consumed.concat(skill.files.keys.map { |rel| skill.path.empty? ? rel : "#{skill.path}/#{rel}" })
|
|
173
|
+
end
|
|
174
|
+
skills
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def skills_at(location)
|
|
178
|
+
return [] if location.empty?
|
|
179
|
+
return [skill_from_dir(location)] if @files.key?("#{location}/SKILL.md")
|
|
180
|
+
|
|
181
|
+
dirs = @files.keys.filter_map do |path|
|
|
182
|
+
next unless path.start_with?("#{location}/") && path.count("/") >= location.count("/") + 2
|
|
183
|
+
|
|
184
|
+
path.delete_prefix("#{location}/").split("/").first
|
|
185
|
+
end
|
|
186
|
+
dirs.uniq.sort.filter_map { |dir| skill_from_dir("#{location}/#{dir}") if @files.key?("#{location}/#{dir}/SKILL.md") }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def skill_from_dir(dir)
|
|
190
|
+
files = @files.select { |path, _| path.start_with?("#{dir}/") }.transform_keys { |path| path.delete_prefix("#{dir}/") }
|
|
191
|
+
frontmatter = frontmatter!(files.fetch("SKILL.md"), "#{dir}/SKILL.md")
|
|
192
|
+
skill_name = frontmatter["name"].to_s.strip
|
|
193
|
+
directory = File.basename(dir)
|
|
194
|
+
unless normalize_directory(skill_name) == normalize_directory(directory)
|
|
195
|
+
raise InvalidPluginError, "#{dir}: frontmatter name #{skill_name.inspect} does not match its directory #{directory.inspect}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
build_skill(skill_name, frontmatter, files, kind: "skill", path: dir)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def root_skill
|
|
202
|
+
files = @files.select { |path, _| path == "SKILL.md" || path.start_with?("scripts/", "references/", "assets/") }
|
|
203
|
+
frontmatter = frontmatter!(files.fetch("SKILL.md"), "SKILL.md")
|
|
204
|
+
build_skill(Manifest.presence(frontmatter["name"]) || name, frontmatter, files, kind: "skill", path: "")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_skill(skill_name, frontmatter, files, kind:, path:)
|
|
208
|
+
validate_skill_name!(skill_name, path)
|
|
209
|
+
description = frontmatter["description"].to_s.strip
|
|
210
|
+
where = path.empty? ? "SKILL.md" : path
|
|
211
|
+
raise InvalidPluginError, "#{where}: description is required" if description.empty?
|
|
212
|
+
raise InvalidPluginError, "#{where}: description exceeds #{Validator::DESCRIPTION_MAX_LENGTH} characters" if description.length > Validator::DESCRIPTION_MAX_LENGTH
|
|
213
|
+
|
|
214
|
+
Skill.new(name: skill_name, description: description, kind: kind, path: path, frontmatter: frontmatter, files: files)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_skill_name!(skill_name, where)
|
|
218
|
+
return if skill_name.match?(Validator::NAME_PATTERN) && skill_name.length <= Validator::NAME_MAX_LENGTH
|
|
219
|
+
|
|
220
|
+
raise InvalidPluginError, "#{where}: skill name #{skill_name.inspect} must be lowercase letters, digits and single hyphens (1-#{Validator::NAME_MAX_LENGTH} chars)"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Over skills and the commands that became skills: the cap and the names are one set.
|
|
224
|
+
def check_skill_names!
|
|
225
|
+
max = Marketplace.config.max_skills
|
|
226
|
+
raise InvalidPluginError, "plugin has more than #{max} skills" if @skills.size > max
|
|
227
|
+
|
|
228
|
+
duplicates = @skills.map(&:name).tally.select { |_, count| count > 1 }.keys
|
|
229
|
+
raise InvalidPluginError, "duplicate skill names #{duplicates.inspect}" if duplicates.any?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# --- commands ------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def collect_commands
|
|
235
|
+
markdown_files_at(manifest.key?("commands") ? manifest_paths(manifest["commands"]) : ["commands"]).filter_map do |path|
|
|
236
|
+
command_from_file(path)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def markdown_files_at(locations)
|
|
241
|
+
locations.flat_map do |location|
|
|
242
|
+
next [location] if @files.key?(location) && location.end_with?(".md")
|
|
243
|
+
|
|
244
|
+
@files.keys.select { |path| path.start_with?("#{location}/") && path.end_with?(".md") }.sort
|
|
245
|
+
end.uniq
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# A command keeps its body; only `name` is pinned to the file name so
|
|
249
|
+
# `/name` resolves. Without frontmatter or a description the loaders
|
|
250
|
+
# cannot list it, so it is listed as unsupported and dropped.
|
|
251
|
+
def command_from_file(path)
|
|
252
|
+
command_name = path.delete_suffix(".md").split("/").drop(1).join("-").downcase.tr("_", "-")
|
|
253
|
+
command_name = File.basename(path, ".md").downcase.tr("_", "-") if command_name.empty?
|
|
254
|
+
text = text!(@files.fetch(path), path)
|
|
255
|
+
frontmatter, body = split_frontmatter(text)
|
|
256
|
+
if frontmatter.nil? || frontmatter["description"].to_s.strip.empty?
|
|
257
|
+
@unsupported << {"name" => command_name, "path" => path, "reason" => frontmatter.nil? ? "missing_frontmatter" : "missing_description"}
|
|
258
|
+
return nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
validate_skill_name!(command_name, path)
|
|
262
|
+
pinned = frontmatter.merge("name" => command_name)
|
|
263
|
+
rewritten = "---\n#{YAML.dump(pinned).delete_prefix("---\n")}---\n#{body}"
|
|
264
|
+
@tree["skills/#{command_name}.md"] = rewritten
|
|
265
|
+
@consumed << path
|
|
266
|
+
build_skill(command_name, pinned, {"#{command_name}.md" => rewritten}, kind: "command", path: path)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# --- agents --------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def collect_agents
|
|
272
|
+
markdown_files_at(manifest.key?("agents") ? manifest_paths(manifest["agents"]) : ["agents"]).map do |path|
|
|
273
|
+
rel = "agents/#{path.split("/").drop(1).join("/")}"
|
|
274
|
+
rel = "agents/#{File.basename(path)}" if rel == "agents/"
|
|
275
|
+
@tree[rel] = @files.fetch(path)
|
|
276
|
+
@consumed << path
|
|
277
|
+
rel
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# --- references ----------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
# A skill that reads a plugin-level file (`../../references/x.md`,
|
|
284
|
+
# `${CLAUDE_PLUGIN_ROOT}/defaults/`) gets a copy under `.plugin/` and
|
|
285
|
+
# the text rewritten, so the skill tool (skill-root only, no `..`) can
|
|
286
|
+
# serve it. Anything that resolves to nothing is left as text and listed.
|
|
287
|
+
def relocate_references
|
|
288
|
+
@skills.each do |skill|
|
|
289
|
+
next if skill.command?
|
|
290
|
+
|
|
291
|
+
skill.files.each_key do |rel|
|
|
292
|
+
next unless rel.end_with?(".md")
|
|
293
|
+
|
|
294
|
+
tree_path = "skills/#{skill.name}/#{rel}"
|
|
295
|
+
text = text!(@tree.fetch(tree_path), tree_path)
|
|
296
|
+
rewritten = text.gsub(ROOT_REFERENCE) { relocate(skill, rel, Regexp.last_match(1)) || Regexp.last_match(0) }
|
|
297
|
+
rewritten = rewritten.gsub(RELATIVE_REFERENCE) { relocate_relative(skill, rel, Regexp.last_match(1)) || Regexp.last_match(0) }
|
|
298
|
+
@tree[tree_path] = rewritten if rewritten != text
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def relocate_relative(skill, rel, reference)
|
|
304
|
+
return nil if skill.path.empty?
|
|
305
|
+
|
|
306
|
+
source_dir = File.dirname(File.join(skill.path, rel))
|
|
307
|
+
target = File.expand_path(reference, "/#{source_dir}").delete_prefix("/")
|
|
308
|
+
return nil if target.empty? || target == skill.path || target.start_with?("#{skill.path}/") || target.start_with?("../")
|
|
309
|
+
|
|
310
|
+
relocate(skill, rel, target)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Copies +plugin_path+ (a file, or every file under it) into the skill's
|
|
314
|
+
# support root; returns the rewritten reference relative to +rel+'s directory.
|
|
315
|
+
def relocate(skill, rel, plugin_path)
|
|
316
|
+
trimmed = plugin_path.sub(/[.,;:)\]'"`]+\z/, "")
|
|
317
|
+
trailing = plugin_path[trimmed.length..]
|
|
318
|
+
segments = trimmed.split("/")
|
|
319
|
+
candidates = segments.length.downto(1).map { |n| segments.first(n).join("/") }
|
|
320
|
+
matched = candidates.find { |candidate| @files.key?(candidate) || @files.keys.any? { |path| path.start_with?("#{candidate}/") } }
|
|
321
|
+
if matched.nil?
|
|
322
|
+
@unresolved_references << {"skill" => skill.name, "reference" => plugin_path}
|
|
323
|
+
return nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@files.select { |path, _| path == matched || path.start_with?("#{matched}/") }.each do |path, data|
|
|
327
|
+
@tree["skills/#{skill.name}/#{SUPPORT_ROOT}/#{path}"] = data
|
|
328
|
+
@consumed << path
|
|
329
|
+
end
|
|
330
|
+
rest = trimmed.delete_prefix(matched)
|
|
331
|
+
from = File.dirname(rel)
|
|
332
|
+
relative = Pathname.new("#{SUPPORT_ROOT}/#{matched}").relative_path_from(Pathname.new((from == ".") ? "" : from).cleanpath).to_s
|
|
333
|
+
"#{relative}#{rest}#{trailing}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# --- frontmatter ---------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def frontmatter!(raw, path)
|
|
339
|
+
frontmatter, _body = split_frontmatter(text!(raw, path))
|
|
340
|
+
raise InvalidPluginError, "#{path}: missing YAML frontmatter" if frontmatter.nil?
|
|
341
|
+
|
|
342
|
+
frontmatter
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# [hash, body] when the text opens with a frontmatter block, else [nil, text].
|
|
346
|
+
def split_frontmatter(text)
|
|
347
|
+
return [nil, text] unless text.match?(Parser::FRONTMATTER_REGEX)
|
|
348
|
+
|
|
349
|
+
data = Parser.parse_string(text)
|
|
350
|
+
return [nil, text] unless data.is_a?(Hash)
|
|
351
|
+
|
|
352
|
+
[data.transform_keys(&:to_s), Parser.extract_body(text)]
|
|
353
|
+
rescue ParseError => e
|
|
354
|
+
raise InvalidPluginError, "invalid YAML frontmatter (#{e.message[0, 60]})"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def normalize_directory(value)
|
|
358
|
+
value.to_s.strip.downcase.tr("_", "-")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def blank?(value)
|
|
362
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
@@ -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
|