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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '048ac6f6a76a7fd48994d208799b967754b25ecc3959acd56d44f5ab707f9f8d'
4
- data.tar.gz: 268827da6d73359d9451b159b1abe68dea7881419df6b473cceeb2d8c74bc073
3
+ metadata.gz: 0cec1aca2ab163ce3eca337510effe09497b32a46992f0f5fe9f8ef5eb9ef629
4
+ data.tar.gz: 29c764164144d9fe80b7b77ba418a6f264202bc8ba6872f4d9e4e1640ca52615
5
5
  SHA512:
6
- metadata.gz: febe501ab666f7e1cdf06e043ab44305ea065ffe561508b6fdcb9a959a90eabad8d2cb9fcc90384536eaf0871f73f2e52c3c9e93b8d883fbe8823274f547f0f3
7
- data.tar.gz: 73226c3f1eb42ba6073dbfdcccb9f334896e078ca04ab1814260feb4218d432a83cf92817a32bb6c1abe847cf7fcd0098259aa2bab5886ce933d2c258d4ae8f1
6
+ metadata.gz: d125b1e44c94858c097cc4aaf24a81d019eb7d25fd9180c2957d2a5f9c43a6b541b6d4fbd46008b65017ea9de93f157609345e9918f597f97795fd816b5fe66c
7
+ data.tar.gz: 64aae5fd59daf2bf7500cee110a51fbd7a993cb9bf6f725a54df7934d2966ee07114a93fa1f7a32a99572a52f9394e1c1a8a721cdb78a0c8ac8c21fe03388c2e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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
+
28
+ ## [0.4.0.pre1] - 2026-09-10
29
+
30
+ ### Changed
31
+
32
+ - **Breaking:** requires RubyLLM 2.0 (`ruby_llm >= 2.0.0.rc2, < 3`); 1.x users should stay on 0.3.x
33
+ - `SkillTool` declares parameters with the 2.0 `parameter`/`description:` DSL, exposes `parameters_schema`, and sets its name through `tool_name`
34
+ - `Chat#with_skills` registers the skill tool via `Chat#with_tools` (2.0 removed `Chat#with_tool`)
35
+ - `AgentExtensions` hooks the 2.0 `Agent.apply_configuration(chat, input_values:, persist_instructions:)` signature and applies skills to the chat or `acts_as_chat` record it receives
36
+ - Integration tests pin `openai_protocol = :chat_completions` and match VCR cassettes on method and URI so the recorded 1.x interactions replay under 2.0
37
+
38
+ ### Removed
39
+
40
+ - Global `Module#delegate` fallback and its compatibility test (2.0's `Agent` uses `Forwardable`)
41
+ - Dependency on the removed `Agent.llm_chat_for` hook
42
+
43
+ ### Fixed
44
+
45
+ - StandardRB `Layout/EmptyLinesAroundModuleBody` offense in `chat_extensions.rb` that failed CI
46
+
8
47
  ## [0.3.0] - 2026-02-17
9
48
 
10
49
  ### Added
data/README.md CHANGED
@@ -9,9 +9,12 @@ 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-skills"
12
+ gem "ruby_llm", "2.0.0.rc3"
13
+ gem "ruby_llm-skills", "0.5.0.pre1"
13
14
  ```
14
15
 
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
+
15
18
  ## Quick Start
16
19
 
17
20
  ```ruby
@@ -31,7 +34,7 @@ chat.with_skills("app/skills", "app/commands") # multiple paths
31
34
  chat.with_skills("app/skills", user.skills) # with database records
32
35
  ```
33
36
 
34
- ### With RubyLLM::Agent (v1.12+)
37
+ ### With RubyLLM::Agent
35
38
 
36
39
  ```ruby
37
40
  class SupportAgent < RubyLLM::Agent
@@ -89,6 +92,58 @@ chat.with_skills("app/skills", "app/commands")
89
92
  chat.ask "/write-poem about robots"
90
93
  ```
91
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
+
92
147
  ## Database Skills
93
148
 
94
149
  Store skills or commands in your database:
@@ -127,7 +182,7 @@ bin/setup
127
182
  ### Running Tests
128
183
 
129
184
  ```bash
130
- bundle exec rake test # Unit tests (151 tests)
185
+ bundle exec rake test # Unit tests (300+ tests)
131
186
  bundle exec rake test_rails # Rails integration tests (25+ tests)
132
187
  bundle exec rake test_all # Both
133
188
  bundle exec rake # Tests + linting
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "source_detection"
4
+
3
5
  module RubyLLM
4
6
  module Skills
5
7
  # Extensions for RubyLLM::Agent to enable declarative skill configuration.
@@ -16,9 +18,11 @@ module RubyLLM
16
18
  # end
17
19
  #
18
20
  module AgentExtensions
19
- REQUIRED_AGENT_SINGLETON_METHODS = %i[apply_configuration runtime_context llm_chat_for].freeze
21
+ REQUIRED_AGENT_SINGLETON_METHODS = %i[apply_configuration runtime_context].freeze
20
22
 
21
23
  module ClassMethods
24
+ include SourceDetection
25
+
22
26
  def self.extended(base)
23
27
  base.instance_variable_set(:@skill_sources, nil)
24
28
  base.instance_variable_set(:@skill_only, nil)
@@ -59,20 +63,12 @@ module RubyLLM
59
63
  def flatten_skill_sources(source)
60
64
  return [] if source.nil?
61
65
  return [source] if source.is_a?(String)
62
- return [source] if loader_source?(source)
63
- return [source] if database_collection_source?(source)
66
+ return [source] if loader_source?(source) || marketplace_source?(source)
67
+ return source.empty? ? [] : [source] if database_collection_source?(source)
64
68
  return source.flat_map { |item| flatten_skill_sources(item) } if source.is_a?(Array)
65
69
 
66
70
  [source]
67
71
  end
68
-
69
- def loader_source?(source)
70
- source.respond_to?(:list) && source.respond_to?(:find)
71
- end
72
-
73
- def database_collection_source?(source)
74
- source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
75
- end
76
72
  end
77
73
 
78
74
  module InstanceMethods
@@ -88,16 +84,21 @@ module RubyLLM
88
84
  end
89
85
 
90
86
  module ConfigurationPatch
91
- private
92
-
93
- def apply_configuration(chat_object, **kwargs)
87
+ # RubyLLM 2.0 passes either a RubyLLM::Chat or an acts_as_chat record.
88
+ # Both respond to #with_skills (ChatExtensions / ActiveRecordExtensions),
89
+ # so skills apply directly to whatever the agent configured.
90
+ #
91
+ # apply_configuration is a :nodoc: hook; **options forwards any keyword a
92
+ # later 2.x release adds so the patch fails at super, not before it.
93
+ def apply_configuration(chat, input_values: {}, **options)
94
94
  super
95
- input_values = kwargs[:input_values] || {}
96
- runtime = runtime_context(chat: chat_object, inputs: input_values)
97
- apply_skills(llm_chat_for(chat_object), runtime)
95
+ runtime = runtime_context(chat: chat, inputs: input_values)
96
+ apply_skills(chat, runtime)
98
97
  end
99
98
 
100
- def apply_skills(llm_chat, runtime)
99
+ private
100
+
101
+ def apply_skills(chat, runtime)
101
102
  config = skills
102
103
  sources = config[:sources]
103
104
  return if sources.nil?
@@ -112,7 +113,7 @@ module RubyLLM
112
113
  return if normalized_sources.empty?
113
114
 
114
115
  validate_skill_sources!(normalized_sources)
115
- llm_chat.with_skills(*normalized_sources, only: config[:only])
116
+ chat.with_skills(*normalized_sources, only: config[:only])
116
117
  end
117
118
 
118
119
  def validate_skill_sources!(sources)
@@ -121,11 +122,11 @@ module RubyLLM
121
122
 
122
123
  invalid_types = invalid_sources.map { |source| source.class.name || source.class.to_s }.uniq.join(", ")
123
124
  raise ArgumentError,
124
- "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."
125
126
  end
126
127
 
127
128
  def valid_skill_source?(source)
128
- 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)
129
130
  end
130
131
  end
131
132
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "source_detection"
3
4
  require_relative "skill_tool"
4
5
 
5
6
  module RubyLLM
@@ -19,6 +20,8 @@ module RubyLLM
19
20
  # chat.with_skills(only: [:pdf_report])
20
21
  #
21
22
  module ChatExtensions
23
+ include SourceDetection
24
+
22
25
  # Add skills to this chat.
23
26
  #
24
27
  # @param sources [Array] skill sources - auto-detects type (directory, zip, collection)
@@ -32,7 +35,7 @@ module RubyLLM
32
35
  loader = FilteredLoader.new(loader, only) if only
33
36
 
34
37
  skill_tool = RubyLLM::Skills::SkillTool.new(loader)
35
- with_tool(skill_tool)
38
+ with_tools(skill_tool)
36
39
  end
37
40
 
38
41
  private
@@ -41,23 +44,17 @@ module RubyLLM
41
44
  case source
42
45
  when String
43
46
  RubyLLM::Skills.from_directory(source)
47
+ when ->(s) { marketplace_source?(s) }
48
+ source.loader
44
49
  when ->(s) { database_collection_source?(s) }
45
50
  RubyLLM::Skills.from_database(source)
46
51
  when ->(s) { loader_source?(s) }
47
52
  source
48
53
  else
49
54
  raise ArgumentError,
50
- "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."
51
56
  end
52
57
  end
53
-
54
- def loader_source?(source)
55
- source.respond_to?(:list) && source.respond_to?(:find)
56
- end
57
-
58
- def database_collection_source?(source)
59
- source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
60
- end
61
58
  end
62
59
 
63
60
  # Simple wrapper that filters skills by name.
@@ -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