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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module RubyLLM
6
+ module Skills
7
+ module Marketplace
8
+ # What a person types to name a marketplace: `owner/repo`, `owner/repo@ref`,
9
+ # a github.com or gitlab.com repository URL (with an optional `/tree/<ref>`),
10
+ # an https:// URL ending in `.json` (a hosted marketplace file), or a
11
+ # local directory.
12
+ #
13
+ module Locator
14
+ # A marketplace upstream: +kind+ is "github", "gitlab", "url" or "directory".
15
+ Source = Data.define(:kind, :locator, :ref) do
16
+ def to_h
17
+ {"kind" => kind, "locator" => locator, "ref" => ref}.compact
18
+ end
19
+
20
+ def self.from_h(hash)
21
+ hash = hash.to_h.transform_keys(&:to_s)
22
+ new(kind: hash["kind"], locator: hash["locator"], ref: hash["ref"])
23
+ end
24
+
25
+ # Pinned when the ref is a full commit sha.
26
+ def pinned? = ref.to_s.match?(Manifest::SHA_PATTERN)
27
+
28
+ def to_s
29
+ ref ? "#{locator}@#{ref}" : locator
30
+ end
31
+ end
32
+
33
+ HOSTS = {"github.com" => "github", "gitlab.com" => "gitlab"}.freeze
34
+ MAX_LENGTH = 500
35
+ REF_PATTERN = %r{\A\w[\w./-]{0,199}\z}
36
+
37
+ class << self
38
+ # @param text [String]
39
+ # @param ref [String, nil] a branch, tag or sha; overrides an `@ref` suffix
40
+ # @return [Source]
41
+ # @raise [ArgumentError]
42
+ def parse(text, ref: nil)
43
+ text = text.to_s.strip
44
+ raise ArgumentError, "marketplace locator is empty" if text.empty?
45
+ raise ArgumentError, "marketplace locator is too long" if text.length > MAX_LENGTH
46
+
47
+ source = if text.match?(%r{\Ahttps?://}i)
48
+ from_url(text)
49
+ elsif text.start_with?("./", "../", "/") || File.directory?(text)
50
+ Source.new(kind: "directory", locator: text, ref: nil)
51
+ else
52
+ from_shorthand(text)
53
+ end
54
+ ref = Manifest.presence(ref)
55
+ return source unless ref
56
+ raise ArgumentError, "a #{source.kind} marketplace has no ref" unless %w[github gitlab].include?(source.kind)
57
+ raise ArgumentError, "invalid ref #{ref.inspect}" unless ref.match?(REF_PATTERN)
58
+
59
+ Source.new(kind: source.kind, locator: source.locator, ref: ref)
60
+ end
61
+
62
+ private
63
+
64
+ def from_shorthand(text)
65
+ repo, ref = text.split("@", 2)
66
+ valid_ref = ref.nil? || ref.match?(REF_PATTERN)
67
+ raise ArgumentError, "invalid marketplace locator #{text.inspect}" unless repo.match?(Manifest::REPO_PATTERN) && valid_ref
68
+
69
+ Source.new(kind: "github", locator: repo, ref: ref)
70
+ end
71
+
72
+ def from_url(text)
73
+ uri = URI.parse(text)
74
+ raise ArgumentError, "marketplace URLs must be https" unless uri.scheme&.downcase == "https" && Manifest.presence(uri.host)
75
+ raise ArgumentError, "marketplace URLs must not carry credentials" if uri.userinfo
76
+
77
+ kind = HOSTS[uri.host.downcase]
78
+ return Source.new(kind: "url", locator: text, ref: nil) if kind.nil? && uri.path.end_with?(".json")
79
+ raise ArgumentError, "unsupported marketplace host #{uri.host.inspect}" if kind.nil?
80
+
81
+ segments = uri.path.split("/").reject(&:empty?)
82
+ depth = repo_depth(kind, segments)
83
+ raise ArgumentError, "invalid repository URL #{text.inspect}" if depth < 2 || segments.size < depth
84
+
85
+ repo = segments.first(depth).join("/").delete_suffix(".git")
86
+ pattern = (kind == "gitlab") ? Manifest::PROJECT_PATTERN : Manifest::REPO_PATTERN
87
+ raise ArgumentError, "invalid repository URL #{text.inspect}" unless repo.match?(pattern)
88
+
89
+ ref = ref_from_segments(segments, depth)
90
+ Source.new(kind: kind, locator: repo, ref: Manifest.presence(ref))
91
+ rescue URI::InvalidURIError
92
+ raise ArgumentError, "invalid marketplace URL #{text.inspect}"
93
+ end
94
+
95
+ # `/tree/<ref>` on GitHub, `/-/tree/<ref>` on GitLab.
96
+ def ref_from_segments(segments, depth)
97
+ marker = segments[depth]
98
+ return nil unless %w[tree -].include?(marker)
99
+
100
+ segments.drop((marker == "-") ? depth + 2 : depth + 1).join("/")
101
+ end
102
+
103
+ # Two segments name a GitHub repository; on GitLab everything before
104
+ # the `/-/` (or a legacy `/tree/`) separator, because a project there
105
+ # lives under nested groups.
106
+ def repo_depth(kind, segments)
107
+ return 2 unless kind == "gitlab"
108
+
109
+ segments.index { |segment| %w[- tree].include?(segment) } || segments.size
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module RubyLLM
7
+ module Skills
8
+ module Marketplace
9
+ # The record of every added marketplace and installed plugin, with the
10
+ # commit and tree each resolved to, so `install` reproduces the same
11
+ # trees on another machine. JSON, keys sorted, one trailing newline.
12
+ #
13
+ # {
14
+ # "version": 1,
15
+ # "marketplaces": {
16
+ # "compound-writing": {
17
+ # "kind": "github", "locator": "EveryInc/compound-writing", "ref": "main",
18
+ # "commit": "…",
19
+ # "plugins": {
20
+ # "compound-writing": {
21
+ # "version": "2.4.1", "version_kind": "manifest", "commit": "…",
22
+ # "tree_sha256": "…", "source": { "kind": "relative", "path": "" },
23
+ # "skills": ["cw-draft", "…"]
24
+ # }
25
+ # }
26
+ # }
27
+ # }
28
+ # }
29
+ #
30
+ class Lockfile
31
+ VERSION = 1
32
+
33
+ attr_reader :path, :marketplaces
34
+
35
+ # @param path [String] the lockfile; a missing file is an empty lockfile
36
+ # @raise [LockfileError]
37
+ def self.load(path)
38
+ return new(path) unless File.exist?(path)
39
+
40
+ data = JSON.parse(File.read(path))
41
+ raise LockfileError, "#{path} is not a JSON object" unless data.is_a?(Hash)
42
+ raise LockfileError, "#{path} has lockfile version #{data["version"].inspect}; this gem reads version #{VERSION}" unless data["version"] == VERSION
43
+
44
+ marketplaces = data["marketplaces"]
45
+ raise LockfileError, "#{path} has no marketplaces object" unless marketplaces.is_a?(Hash)
46
+
47
+ marketplaces.each do |name, entry|
48
+ raise LockfileError, "#{path}: marketplace #{name.inspect} is not an object" unless entry.is_a?(Hash)
49
+ raise LockfileError, "#{path}: marketplace #{name.inspect} has no plugins object" unless entry.fetch("plugins", {}).is_a?(Hash)
50
+ end
51
+ new(path, marketplaces)
52
+ rescue JSON::ParserError => e
53
+ raise LockfileError, "#{path} is not valid JSON (#{e.message[0, 80]})"
54
+ end
55
+
56
+ def initialize(path, marketplaces = {})
57
+ @path = path.to_s
58
+ @marketplaces = marketplaces
59
+ end
60
+
61
+ def marketplace(name)
62
+ @marketplaces[name.to_s]
63
+ end
64
+
65
+ def names
66
+ @marketplaces.keys.sort
67
+ end
68
+
69
+ def plugins(name)
70
+ marketplace(name)&.fetch("plugins", nil) || {}
71
+ end
72
+
73
+ def plugin(name, plugin)
74
+ plugins(name)[plugin.to_s]
75
+ end
76
+
77
+ # Records a marketplace, keeping its plugins unless +data+ carries some.
78
+ def set_marketplace(name, data)
79
+ existing = marketplace(name) || {}
80
+ plugins = data.fetch("plugins", existing.fetch("plugins", {}))
81
+ @marketplaces[name.to_s] = data.except("plugins").merge("plugins" => plugins)
82
+ end
83
+
84
+ def delete_marketplace(name)
85
+ @marketplaces.delete(name.to_s)
86
+ end
87
+
88
+ def set_plugin(name, plugin, data)
89
+ set_marketplace(name, {}) unless marketplace(name)
90
+ @marketplaces[name.to_s]["plugins"][plugin.to_s] = data
91
+ end
92
+
93
+ def delete_plugin(name, plugin)
94
+ plugins(name).delete(plugin.to_s)
95
+ end
96
+
97
+ # Marketplaces and plugins sorted by name; each entry keeps its field order.
98
+ def to_h
99
+ marketplaces = @marketplaces.sort.to_h do |name, data|
100
+ [name, data.merge("plugins" => data.fetch("plugins", {}).sort.to_h)]
101
+ end
102
+ {"version" => VERSION, "marketplaces" => marketplaces}
103
+ end
104
+
105
+ def save
106
+ FileUtils.mkdir_p(File.dirname(@path))
107
+ File.write(@path, "#{JSON.pretty_generate(to_h)}\n")
108
+ self
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "pathname"
6
+
7
+ module RubyLLM
8
+ module Skills
9
+ module Marketplace
10
+ # The marketplace file. Claude Code's `.claude-plugin/marketplace.json`
11
+ # is the native shape; Codex's `.agents/plugins/marketplace.json`
12
+ # (`source: {source: "local", path}`, `interface.displayName`) and
13
+ # Cursor's `.cursor-plugin/marketplace.json` (path-string / `{path}`
14
+ # sources) share `name` / `owner` / `plugins[]{name, source}`.
15
+ #
16
+ # Every `source` variant becomes a PluginSource: `relative` (inside the
17
+ # marketplace repository), `github` / `gitlab` (also from `url` and
18
+ # `git-subdir` on those hosts), `archive` (a .tar.gz), or `unsupported`
19
+ # with the reason people see (`npm`, `command`, zip, other hosts).
20
+ #
21
+ module Manifest
22
+ PATHS = %w[.claude-plugin/marketplace.json .agents/plugins/marketplace.json .cursor-plugin/marketplace.json].freeze
23
+ SHAPES = {
24
+ ".claude-plugin/marketplace.json" => "claude",
25
+ ".agents/plugins/marketplace.json" => "codex",
26
+ ".cursor-plugin/marketplace.json" => "cursor"
27
+ }.freeze
28
+ NAME_PATTERN = /\A[a-z0-9]+(?:[-._][a-z0-9]+)*\z/i
29
+ REPO_PATTERN = %r{\A[\w.-]+/[\w.-]+\z}
30
+ PROJECT_PATTERN = %r{\A[\w.-]+(?:/[\w.-]+)+\z}
31
+ SHA_PATTERN = /\A[0-9a-f]{40}\z/
32
+ # plugin.json fields a marketplace entry may carry (strict: false makes the entry the plugin).
33
+ OVERRIDE_FIELDS = %w[skills commands agents hooks mcpServers lspServers outputStyles].freeze
34
+ MAX_BYTES = 10 * 1024 * 1024
35
+
36
+ # The parsed marketplace file. +renames+ is the file's `{ "old" => "new" }`
37
+ # map of plugins renamed (or, with a nil value, removed) since earlier versions.
38
+ Catalog = Data.define(:name, :display_name, :owner, :description, :version, :plugin_root, :renames, :plugins, :shape, :path, :raw) do
39
+ def plugin(name)
40
+ plugins.find { |entry| entry.name == name.to_s }
41
+ end
42
+
43
+ def to_h
44
+ {"name" => name, "display_name" => display_name, "owner" => owner, "description" => description,
45
+ "version" => version, "plugin_root" => plugin_root, "renames" => renames, "shape" => shape, "path" => path}
46
+ end
47
+ end
48
+
49
+ # One `plugins[]` entry.
50
+ Entry = Data.define(:name, :display_name, :description, :version, :category, :tags, :strict, :source, :overrides, :raw) do
51
+ def strict? = strict
52
+
53
+ def supported? = source.supported?
54
+
55
+ def to_h
56
+ {"name" => name, "displayName" => display_name, "description" => description, "version" => version,
57
+ "category" => category, "tags" => tags, "strict" => strict, "source" => source.to_h}.compact
58
+ end
59
+ end
60
+
61
+ # Where an entry's content comes from.
62
+ PluginSource = Data.define(:kind, :path, :repo, :ref, :sha, :url, :sha256, :reason) do
63
+ def supported? = kind != "unsupported"
64
+
65
+ def to_h
66
+ {"kind" => kind, "path" => path, "repo" => repo, "ref" => ref, "sha" => sha, "url" => url, "sha256" => sha256, "reason" => reason}.compact
67
+ end
68
+
69
+ def self.from_h(hash)
70
+ hash = hash.to_h.transform_keys(&:to_s)
71
+ new(kind: hash["kind"], path: hash["path"], repo: hash["repo"], ref: hash["ref"], sha: hash["sha"],
72
+ url: hash["url"], sha256: hash["sha256"], reason: hash["reason"])
73
+ end
74
+
75
+ def self.unsupported(reason, raw = nil)
76
+ url = raw.is_a?(Hash) ? Manifest.presence(raw["url"]) : nil
77
+ new(kind: "unsupported", path: nil, repo: nil, ref: nil, sha: nil, url: url, sha256: nil, reason: reason)
78
+ end
79
+ end
80
+
81
+ class << self
82
+ # The marketplace file among +files+ (`{ path => bytes }`), in discovery order.
83
+ def discover(files)
84
+ path = PATHS.find { |candidate| files.key?(candidate) }
85
+ raise InvalidManifestError, "no marketplace file (looked for #{PATHS.join(", ")})" if path.nil?
86
+
87
+ parse(files.fetch(path), shape: SHAPES.fetch(path), path: path)
88
+ end
89
+
90
+ def parse(json, shape: "claude", path: PATHS.first)
91
+ raise InvalidManifestError, "marketplace file exceeds #{MAX_BYTES} bytes" if json.is_a?(String) && json.bytesize > MAX_BYTES
92
+
93
+ data = json.is_a?(Hash) ? json : JSON.parse(json.to_s.dup.force_encoding(Encoding::UTF_8))
94
+ raise InvalidManifestError, "marketplace file is not a JSON object" unless data.is_a?(Hash)
95
+
96
+ name = data["name"].to_s.strip
97
+ raise InvalidManifestError, "marketplace name is required" if name.empty?
98
+ raise InvalidManifestError, "marketplace name #{name.inspect} is not kebab-case" unless name.match?(NAME_PATTERN)
99
+ raise InvalidManifestError, "marketplace has no plugins[]" unless data["plugins"].is_a?(Array)
100
+
101
+ metadata = data["metadata"].is_a?(Hash) ? data["metadata"] : {}
102
+ plugin_root = normalize_relative(metadata["pluginRoot"].to_s, allow_blank: true)
103
+ plugins = data["plugins"].each_with_index.map { |entry, index| entry(entry, index, plugin_root: plugin_root) }
104
+ duplicates = plugins.map(&:name).tally.select { |_, count| count > 1 }.keys
105
+ raise InvalidManifestError, "duplicate plugin names #{duplicates.inspect}" if duplicates.any?
106
+
107
+ display_name = presence(data.dig("interface", "displayName")) || presence(data["displayName"]) || name
108
+ Catalog.new(name: name, display_name: display_name, owner: data["owner"].is_a?(Hash) ? data["owner"] : {},
109
+ description: presence(data["description"] || metadata["description"]),
110
+ version: presence(data["version"] || metadata["version"]),
111
+ plugin_root: plugin_root, renames: renames(data["renames"]), plugins: plugins, shape: shape, path: path, raw: data)
112
+ rescue JSON::ParserError => e
113
+ raise InvalidManifestError, "marketplace file is not valid JSON (#{e.message[0, 80]})"
114
+ end
115
+
116
+ def entry(raw, index, plugin_root: nil)
117
+ raise InvalidManifestError, "plugins[#{index}] is not an object" unless raw.is_a?(Hash)
118
+
119
+ name = raw["name"].to_s.strip
120
+ raise InvalidManifestError, "plugins[#{index}] has no name" if name.empty?
121
+ raise InvalidManifestError, "plugin name #{name.inspect} is not kebab-case" unless name.match?(NAME_PATTERN)
122
+
123
+ Entry.new(name: name, display_name: presence(raw["displayName"]), description: presence(raw["description"]),
124
+ version: presence(raw["version"]), category: presence(raw["category"]), tags: Array(raw["tags"]).map(&:to_s),
125
+ strict: raw["strict"] != false, source: source(raw["source"], plugin_root: plugin_root),
126
+ overrides: raw.slice(*OVERRIDE_FIELDS), raw: raw)
127
+ end
128
+
129
+ def source(raw, plugin_root: nil)
130
+ case raw
131
+ when String then relative(raw, plugin_root: plugin_root)
132
+ when Hash then hash_source(raw, plugin_root: plugin_root)
133
+ else PluginSource.unsupported("source_missing")
134
+ end
135
+ end
136
+
137
+ def relative(path, plugin_root: nil)
138
+ cleaned = normalize_relative(path, allow_blank: true)
139
+ return PluginSource.unsupported("unsafe_path") if cleaned.nil?
140
+
141
+ if presence(plugin_root) && !path.to_s.start_with?("./", ".")
142
+ cleaned = [plugin_root, cleaned].reject { |part| part.to_s.empty? }.join("/")
143
+ end
144
+ PluginSource.new(kind: "relative", path: cleaned, repo: nil, ref: nil, sha: nil, url: nil, sha256: nil, reason: nil)
145
+ end
146
+
147
+ def github(repo, ref: nil, sha: nil, subdir: nil, raw: nil)
148
+ repo_source("github", normalize_repo(repo), REPO_PATTERN, ref: ref, sha: sha, subdir: subdir, raw: raw)
149
+ end
150
+
151
+ def gitlab(project, ref: nil, sha: nil, subdir: nil, raw: nil)
152
+ repo_source("gitlab", normalize_repo(project), PROJECT_PATTERN, ref: ref, sha: sha, subdir: subdir, raw: raw)
153
+ end
154
+
155
+ # `https://github.com/o/r[.git]`, `git@github.com:o/r.git`, `owner/repo`
156
+ # and their gitlab.com twins; any other host is unsupported.
157
+ def git_url(url, ref: nil, sha: nil, subdir: nil, raw: nil)
158
+ text = url.to_s.strip
159
+ return github(text, ref: ref, sha: sha, subdir: subdir, raw: raw) if text.match?(REPO_PATTERN) && !text.include?(":")
160
+
161
+ host, path = split_git_url(text)
162
+ case host
163
+ when "github.com" then github(path, ref: ref, sha: sha, subdir: subdir, raw: raw)
164
+ when "gitlab.com" then gitlab(path, ref: ref, sha: sha, subdir: subdir, raw: raw)
165
+ else PluginSource.unsupported(host ? "unsupported_host" : "invalid_url", raw)
166
+ end
167
+ end
168
+
169
+ def archive(url, sha256: nil, raw: nil)
170
+ reason = archive_problem(url, sha256)
171
+ return PluginSource.unsupported(reason, raw) if reason
172
+
173
+ PluginSource.new(kind: "archive", path: "", repo: nil, ref: nil, sha: nil, url: URI.parse(url.to_s.strip).to_s,
174
+ sha256: presence(sha256)&.downcase, reason: nil)
175
+ end
176
+
177
+ # A relative path inside the marketplace repo: "./x", "x", "./" and "."
178
+ # are accepted; anything escaping the root is nil.
179
+ def normalize_relative(path, allow_blank:)
180
+ cleaned = clean_relative(path.to_s.strip)
181
+ return cleaned unless cleaned == "."
182
+
183
+ allow_blank ? "" : nil
184
+ end
185
+
186
+ # `owner/repo`, with a leading slash, trailing slash or `.git` removed.
187
+ def normalize_repo(value)
188
+ value.to_s.strip.delete_prefix("/").delete_suffix("/").delete_suffix(".git")
189
+ end
190
+
191
+ def presence(value)
192
+ text = value.to_s.strip
193
+ text.empty? ? nil : text
194
+ end
195
+
196
+ # `{ "old" => "new" }`, keeping only well-formed names; a nil value marks a removal.
197
+ def renames(raw)
198
+ return {} unless raw.is_a?(Hash)
199
+
200
+ raw.each_with_object({}) do |(old, new), out|
201
+ valid_new = new.nil? || new.to_s.match?(NAME_PATTERN)
202
+ next unless old.to_s.match?(NAME_PATTERN) && valid_new
203
+
204
+ out[old.to_s] = new&.to_s
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ def hash_source(raw, plugin_root:)
211
+ kind = raw["source"].to_s
212
+ case kind
213
+ when "" then raw.key?("path") ? relative(raw["path"].to_s, plugin_root: plugin_root) : PluginSource.unsupported("source_missing", raw)
214
+ when "local" then relative(raw["path"].to_s, plugin_root: plugin_root)
215
+ when "github" then github(raw["repo"].to_s, ref: raw["ref"], sha: raw["sha"], raw: raw)
216
+ when "url" then git_url(raw["url"].to_s, ref: raw["ref"], sha: raw["sha"], raw: raw)
217
+ when "git-subdir" then git_url(raw["url"].to_s, ref: raw["ref"], sha: raw["sha"], subdir: raw["path"].to_s, raw: raw)
218
+ when "archive" then archive(raw["url"].to_s, sha256: raw["sha256"], raw: raw)
219
+ else PluginSource.unsupported("#{presence(kind) || "unknown"}_source", raw)
220
+ end
221
+ end
222
+
223
+ def repo_source(kind, repo, pattern, ref:, sha:, subdir:, raw:)
224
+ return PluginSource.unsupported("invalid_repo", raw) unless repo.match?(pattern)
225
+
226
+ pin = presence(sha)
227
+ return PluginSource.unsupported("invalid_sha", raw) if pin && !pin.match?(SHA_PATTERN)
228
+
229
+ path = presence(subdir) ? normalize_relative(subdir, allow_blank: false) : ""
230
+ return PluginSource.unsupported("unsafe_path", raw) if path.nil?
231
+
232
+ PluginSource.new(kind: kind, path: path, repo: repo, ref: presence(ref), sha: pin, url: nil, sha256: nil, reason: nil)
233
+ end
234
+
235
+ def split_git_url(text)
236
+ if (ssh = text.match(/\Agit@([\w.-]+):(.+)\z/))
237
+ return [ssh[1].downcase, ssh[2]]
238
+ end
239
+
240
+ uri = URI.parse(text)
241
+ return [nil, nil] unless uri.is_a?(URI::HTTPS) && presence(uri.host)
242
+
243
+ [uri.host.downcase, uri.path.to_s]
244
+ rescue URI::InvalidURIError
245
+ [nil, nil]
246
+ end
247
+
248
+ def archive_problem(url, sha256)
249
+ uri = URI.parse(url.to_s.strip)
250
+ return "invalid_url" unless uri.is_a?(URI::HTTPS) && presence(uri.host)
251
+ return "zip_archive" unless uri.path.to_s.match?(/\.(tar\.gz|tgz)\z/i)
252
+
253
+ "invalid_sha256" if presence(sha256) && !sha256.to_s.match?(/\A[0-9a-f]{64}\z/i)
254
+ rescue URI::InvalidURIError
255
+ "invalid_url"
256
+ end
257
+
258
+ # nil when the path is absolute, escapes the root or carries unsafe bytes.
259
+ def clean_relative(text)
260
+ return nil if text.match?(%r{\A/|\\|\0})
261
+
262
+ cleaned = text.empty? ? "." : Pathname.new(text).cleanpath.to_s
263
+ cleaned unless cleaned == ".." || cleaned.start_with?("../")
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end