agents_skill_vault 0.1.0 → 0.2.0

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,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ class Vault
5
+ # Module for adding resources to the vault.
6
+ #
7
+ module ResourceAdder
8
+ private
9
+
10
+ # Adds a repository resource, scanning for all skills.
11
+ #
12
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
13
+ # @param label [String, nil] Custom label
14
+ # @return [Array<Resource>] Created resources (one per skill found)
15
+ #
16
+ def add_repository_resource(parsed_url, label:)
17
+ clone_repository(parsed_url)
18
+ skills = SkillScanner.scan_directory(local_repo_path(parsed_url))
19
+
20
+ if skills.empty?
21
+ add_non_skill_repo(parsed_url, label:)
22
+ else
23
+ add_skill_repo_resources(parsed_url, skills, label:)
24
+ end
25
+ end
26
+
27
+ # Adds a folder resource.
28
+ #
29
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
30
+ # @param label [String, nil] Custom label
31
+ # @return [Resource, Array<Resource>] Created resource(s)
32
+ #
33
+ def add_folder_resource(parsed_url, label:)
34
+ setup_repo_folder(parsed_url)
35
+ target_path = File.join(local_repo_path(parsed_url), parsed_url.relative_path)
36
+ skills = SkillScanner.scan_directory(target_path)
37
+
38
+ case skills.length
39
+ when 0 then add_non_skill_folder(parsed_url, label:)
40
+ when 1 then add_single_skill_folder(parsed_url, skills.first, label:, target_path:)
41
+ else add_multi_skill_folder(parsed_url, skills, label:, target_path:)
42
+ end
43
+ end
44
+
45
+ # Adds a file resource.
46
+ #
47
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
48
+ # @param label [String, nil] Custom label
49
+ # @return [Resource] Created resource
50
+ #
51
+ def add_file_resource(parsed_url, label:)
52
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
53
+
54
+ if parsed_url.skill_file?
55
+ add_skill_file(parsed_url, repo_url, label:)
56
+ else
57
+ add_non_skill_file(parsed_url, repo_url, label:)
58
+ end
59
+ end
60
+
61
+ # Creates a resource from parsed URL with optional label.
62
+ #
63
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
64
+ # @param label [String, nil] Custom label
65
+ # @return [Resource] Created resource
66
+ #
67
+ def create_resource(parsed_url, label:)
68
+ label ||= parsed_url.label
69
+
70
+ Resource.new(
71
+ label: label,
72
+ url: "https://github.com/#{parsed_url.username}/#{parsed_url.repo}",
73
+ username: parsed_url.username,
74
+ repo: parsed_url.repo,
75
+ folder: parsed_url.type == :repo ? nil : File.basename(parsed_url.relative_path || ""),
76
+ type: parsed_url.type,
77
+ branch: parsed_url.branch,
78
+ relative_path: parsed_url.relative_path,
79
+ storage_path: storage_path
80
+ )
81
+ end
82
+
83
+ # Clones a repository to the vault.
84
+ #
85
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
86
+ # @return [void]
87
+ #
88
+ def clone_repository(parsed_url)
89
+ target_path = local_repo_path(parsed_url)
90
+ FileUtils.mkdir_p(File.dirname(target_path))
91
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
92
+ GitOperations.clone_repo(repo_url, target_path, branch: parsed_url.branch)
93
+ end
94
+
95
+ # Adds a non-skill repository resource.
96
+ #
97
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
98
+ # @param label [String, nil] Custom label
99
+ # @return [Array<Resource>] Created resources
100
+ #
101
+ def add_non_skill_repo(parsed_url, label:)
102
+ resource = create_resource(parsed_url, label: label || parsed_url.label)
103
+ resource = Resource.from_h(
104
+ resource.to_h.merge(
105
+ validation_status: :not_a_skill,
106
+ is_skill: false
107
+ ),
108
+ storage_path: storage_path
109
+ )
110
+ manifest.add_resource(resource)
111
+ [resource]
112
+ end
113
+
114
+ # Adds skill resources for a repository.
115
+ #
116
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
117
+ # @param skills [Array<Hash>] List of skills found
118
+ # @param label [String, nil] Custom label
119
+ # @return [Array<Resource>] Created resources
120
+ #
121
+ def add_skill_repo_resources(parsed_url, skills, label:)
122
+ target_path = local_repo_path(parsed_url)
123
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
124
+
125
+ skills.map do |skill|
126
+ skill_label = if label
127
+ "#{label}-#{skill[:skill_name]}"
128
+ else
129
+ "#{parsed_url.username}/#{parsed_url.repo}/#{skill[:skill_name]}"
130
+ end
131
+
132
+ resource = build_skill_resource(
133
+ skill_label, repo_url, parsed_url, skill[:folder_path],
134
+ skill[:folder_path], target_path
135
+ )
136
+
137
+ validate_and_update_resource(resource, skill[:folder_path], target_path)
138
+ manifest.add_resource(resource)
139
+ resource
140
+ end
141
+ end
142
+
143
+ # Adds a non-skill folder resource.
144
+ #
145
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
146
+ # @param label [String, nil] Custom label
147
+ # @return [Resource] Created resource
148
+ #
149
+ def add_non_skill_folder(parsed_url, label:)
150
+ skill_name = File.basename(parsed_url.relative_path)
151
+ resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
152
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
153
+
154
+ resource = Resource.new(
155
+ label: resource_label,
156
+ url: repo_url,
157
+ username: parsed_url.username,
158
+ repo: parsed_url.repo,
159
+ folder: parsed_url.relative_path,
160
+ type: :folder,
161
+ branch: parsed_url.branch,
162
+ relative_path: parsed_url.relative_path,
163
+ storage_path: storage_path,
164
+ validation_status: :not_a_skill,
165
+ validation_errors: [],
166
+ skill_name: nil,
167
+ is_skill: false
168
+ )
169
+
170
+ manifest.add_resource(resource)
171
+ resource
172
+ end
173
+
174
+ # Adds a single skill folder resource.
175
+ #
176
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
177
+ # @param skill [Hash] Skill data
178
+ # @param label [String, nil] Custom label
179
+ # @param target_path [String] Target path for validation
180
+ # @return [Resource] Created resource
181
+ #
182
+ def add_single_skill_folder(parsed_url, skill, label:, target_path:)
183
+ skill_name = skill[:skill_name]
184
+ resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
185
+
186
+ skill_relative_path = if skill[:relative_path] == "."
187
+ parsed_url.relative_path
188
+ else
189
+ File.join(parsed_url.relative_path, skill[:relative_path])
190
+ end
191
+
192
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
193
+
194
+ resource = build_skill_resource(
195
+ resource_label, repo_url, parsed_url, parsed_url.relative_path,
196
+ skill_relative_path, target_path, skill[:relative_path]
197
+ )
198
+
199
+ validate_and_update_resource(resource, skill[:relative_path], target_path)
200
+ manifest.add_resource(resource)
201
+ resource
202
+ end
203
+
204
+ # Adds multiple skill folder resources.
205
+ #
206
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
207
+ # @param skills [Array<Hash>] List of skills found
208
+ # @param label [String, nil] Custom label
209
+ # @param target_path [String] Target path for validation
210
+ # @return [Array<Resource>] Created resources
211
+ #
212
+ def add_multi_skill_folder(parsed_url, skills, label:, target_path:)
213
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
214
+
215
+ skills.map do |skill|
216
+ skill_name = skill[:skill_name]
217
+ skill_label = label ? "#{label}/#{skill_name}" : "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
218
+
219
+ skill_relative_path = if skill[:relative_path] == "."
220
+ parsed_url.relative_path
221
+ else
222
+ File.join(parsed_url.relative_path, skill[:relative_path])
223
+ end
224
+
225
+ resource = build_skill_resource(
226
+ skill_label, repo_url, parsed_url, parsed_url.relative_path,
227
+ skill_relative_path, target_path, skill[:relative_path]
228
+ )
229
+
230
+ validate_and_update_resource(resource, skill[:relative_path], target_path)
231
+ manifest.add_resource(resource)
232
+ resource
233
+ end
234
+ end
235
+
236
+ # Adds a skill file resource.
237
+ #
238
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
239
+ # @param repo_url [String] Repository URL
240
+ # @param label [String, nil] Custom label
241
+ # @return [Resource] Created resource
242
+ #
243
+ def add_skill_file(parsed_url, repo_url, label:)
244
+ setup_skill_file_path(parsed_url)
245
+ target_path = File.join(local_repo_path(parsed_url), parsed_url.skill_folder_path)
246
+ result = SkillValidator.validate(File.join(target_path, "SKILL.md"))
247
+
248
+ resource = create_skill_file_resource(parsed_url, repo_url, label, result)
249
+ manifest.add_resource(resource)
250
+ resource
251
+ end
252
+
253
+ # Creates a skill file resource.
254
+ #
255
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
256
+ # @param repo_url [String] Repository URL
257
+ # @param label [String, nil] Custom label
258
+ # @param result [Hash] Validation result
259
+ # @return [Resource] Created resource
260
+ #
261
+ def create_skill_file_resource(parsed_url, repo_url, label, result)
262
+ resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{parsed_url.skill_name}"
263
+
264
+ Resource.new(
265
+ label: resource_label,
266
+ url: repo_url,
267
+ username: parsed_url.username,
268
+ repo: parsed_url.repo,
269
+ folder: parsed_url.skill_folder_path,
270
+ type: :file,
271
+ branch: parsed_url.branch,
272
+ relative_path: parsed_url.skill_folder_path,
273
+ storage_path: storage_path,
274
+ validation_status: result[:valid] ? :valid_skill : :invalid_skill,
275
+ validation_errors: result[:errors],
276
+ skill_name: parsed_url.skill_name,
277
+ is_skill: true
278
+ )
279
+ end
280
+
281
+ # Adds a non-skill file resource.
282
+ #
283
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
284
+ # @param repo_url [String] Repository URL
285
+ # @param label [String, nil] Custom label
286
+ # @return [Resource] Created resource
287
+ #
288
+ def add_non_skill_file(parsed_url, _repo_url, label:)
289
+ parent_path = File.dirname(parsed_url.relative_path)
290
+ setup_non_skill_file_path(parsed_url, parent_path)
291
+
292
+ resource = Resource.new(
293
+ label: label || parsed_url.label,
294
+ url: parsed_url.url,
295
+ username: parsed_url.username,
296
+ repo: parsed_url.repo,
297
+ folder: parent_path,
298
+ type: :file,
299
+ branch: parsed_url.branch,
300
+ relative_path: parsed_url.relative_path,
301
+ storage_path: storage_path,
302
+ validation_status: :not_a_skill,
303
+ validation_errors: [],
304
+ skill_name: nil,
305
+ is_skill: false
306
+ )
307
+
308
+ manifest.add_resource(resource)
309
+ resource
310
+ end
311
+
312
+ # Builds a skill resource from parsed data.
313
+ #
314
+ # @param label [String] Resource label
315
+ # @param repo_url [String] Repository URL
316
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
317
+ # @param folder [String] Folder path
318
+ # @param relative_path [String] Relative path
319
+ # @param target_path [String] Target path for type detection
320
+ # @param skill_relative_path [String, nil] Relative path for skill
321
+ # @return [Resource] Created resource
322
+ #
323
+ def build_skill_resource(label, repo_url, parsed_url, folder, relative_path, _target_path,
324
+ skill_relative_path = nil)
325
+ Resource.new(
326
+ label: label,
327
+ url: repo_url,
328
+ username: parsed_url.username,
329
+ repo: parsed_url.repo,
330
+ folder: folder,
331
+ type: parsed_url.type,
332
+ branch: parsed_url.branch,
333
+ relative_path: relative_path,
334
+ storage_path: storage_path,
335
+ skill_name: skill_relative_path ? label.split("/").last : nil,
336
+ is_skill: true
337
+ )
338
+ end
339
+
340
+ # Validates and updates a resource.
341
+ #
342
+ # @param resource [Resource] Resource to validate
343
+ # @param relative_path [String] Relative path for validation
344
+ # @param target_path [String] Target path
345
+ # @return [Resource] Updated resource
346
+ #
347
+ def validate_and_update_resource(resource, relative_path, target_path)
348
+ skill_file = File.join(target_path, relative_path, "SKILL.md")
349
+ result = SkillValidator.validate(skill_file)
350
+
351
+ Resource.from_h(
352
+ resource.to_h.merge(
353
+ validation_status: result[:valid] ? :valid_skill : :invalid_skill,
354
+ validation_errors: result[:errors]
355
+ ),
356
+ storage_path: storage_path
357
+ )
358
+ end
359
+
360
+ # Returns the local path for a repository.
361
+ #
362
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
363
+ # @return [String] Local path
364
+ #
365
+ def local_repo_path(parsed_url)
366
+ File.join(storage_path, parsed_url.username, parsed_url.repo)
367
+ end
368
+
369
+ # Sets up repository folder for sparse checkout.
370
+ #
371
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
372
+ # @return [void]
373
+ #
374
+ def setup_repo_folder(parsed_url)
375
+ repo_path = local_repo_path(parsed_url)
376
+ FileUtils.mkdir_p(repo_path)
377
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
378
+ paths = [parsed_url.relative_path]
379
+ GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
380
+ end
381
+
382
+ # Sets up skill file path for download.
383
+ #
384
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
385
+ # @return [void]
386
+ #
387
+ def setup_skill_file_path(parsed_url)
388
+ repo_path = local_repo_path(parsed_url)
389
+ FileUtils.mkdir_p(repo_path)
390
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
391
+ paths = [parsed_url.skill_folder_path]
392
+ GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
393
+ end
394
+
395
+ # Sets up non-skill file path for download.
396
+ #
397
+ # @param parsed_url [UrlParser::ParseResult] Parsed URL
398
+ # @param parent_path [String] Parent path
399
+ # @return [void]
400
+ #
401
+ def setup_non_skill_file_path(parsed_url, parent_path)
402
+ repo_path = local_repo_path(parsed_url)
403
+ FileUtils.mkdir_p(repo_path)
404
+ repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
405
+ paths = [parent_path]
406
+ GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
407
+ end
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ class Vault
5
+ # Module for syncing resources in vault.
6
+ #
7
+ module ResourceSyncer
8
+ private
9
+
10
+ # Syncs a resource by pulling latest changes.
11
+ #
12
+ # @param resource [Resource] Resource to sync
13
+ # @return [SyncResult] Sync result
14
+ #
15
+ def sync_resource(resource)
16
+ return SyncResult.new(success: false, error: "Resource has no local path") unless resource.local_path
17
+
18
+ unless Dir.exist?(resource.local_path)
19
+ return SyncResult.new(success: false, error: "Path does not exist: #{resource.local_path}")
20
+ end
21
+
22
+ sync_by_type(resource)
23
+ update_synced_at(resource)
24
+
25
+ SyncResult.new(success: true, changes: true)
26
+ rescue Errors::Error => e
27
+ SyncResult.new(success: false, error: e.message)
28
+ end
29
+
30
+ # Syncs a repository-type resource, re-scanning for skills.
31
+ #
32
+ # @param resource [Resource] The resource to sync
33
+ # @return [void]
34
+ #
35
+ def sync_repository_resource(resource)
36
+ skills = SkillScanner.scan_directory(resource.local_path)
37
+ existing_resources = repo_resources(resource)
38
+
39
+ skills.each do |skill|
40
+ process_skill_sync(resource, skill, existing_resources)
41
+ end
42
+ end
43
+
44
+ # Downloads a resource from GitHub.
45
+ #
46
+ # @param resource [Resource] Resource to download
47
+ # @return [void]
48
+ #
49
+ def download_resource(resource)
50
+ target_path = resource.local_path
51
+ parent_dir = File.dirname(target_path)
52
+ FileUtils.mkdir_p(parent_dir)
53
+
54
+ if resource.type == :repo
55
+ GitOperations.clone_repo(resource.url, target_path, branch: resource.branch)
56
+ else
57
+ paths = [resource.relative_path]
58
+ GitOperations.sparse_checkout(resource.url, target_path, branch: resource.branch, paths: paths)
59
+ end
60
+ end
61
+
62
+ # Syncs resource based on its type.
63
+ #
64
+ # @param resource [Resource] Resource to sync
65
+ # @return [void]
66
+ #
67
+ def sync_by_type(resource)
68
+ GitOperations.pull(resource.local_path)
69
+
70
+ if resource.type == :repo
71
+ sync_repository_resource(resource)
72
+ else
73
+ validate_resource(resource.label)
74
+ end
75
+ end
76
+
77
+ # Updates synced_at timestamp for all repo resources.
78
+ #
79
+ # @param resource [Resource] Resource to update
80
+ # @return [void]
81
+ #
82
+ def update_synced_at(resource)
83
+ repo_resources = list.select { |r| r.repo == resource.repo && r.username == resource.username }
84
+ repo_resources.each do |r|
85
+ updated = Resource.from_h(r.to_h.merge(synced_at: Time.now), storage_path: storage_path)
86
+ manifest.update_resource(updated)
87
+ end
88
+ end
89
+
90
+ # Returns all resources for a repository.
91
+ #
92
+ # @param resource [Resource] Resource to match
93
+ # @return [Array<Resource>] Matching resources
94
+ #
95
+ def repo_resources(resource)
96
+ list.select do |r|
97
+ r.repo == resource.repo && r.username == resource.username && r.type == :repo
98
+ end
99
+ end
100
+
101
+ # Processes skill during sync (add or update).
102
+ #
103
+ # @param resource [Resource] Parent resource
104
+ # @param skill [Hash] Skill data
105
+ # @param existing_resources [Array<Resource>] Existing resources
106
+ # @return [void]
107
+ #
108
+ def process_skill_sync(resource, skill, existing_resources)
109
+ skill_label = "#{resource.username}/#{resource.repo}/#{skill[:skill_name]}"
110
+ existing = existing_resources.find { |r| r.skill_name == skill[:skill_name] }
111
+
112
+ if existing
113
+ revalidate_existing_skill(resource, existing, skill)
114
+ else
115
+ add_new_skill_from_sync(resource, skill, skill_label)
116
+ end
117
+ end
118
+
119
+ # Re-validates an existing skill.
120
+ #
121
+ # @param resource [Resource] Parent resource
122
+ # @param existing [Resource] Existing skill resource
123
+ # @param skill [Hash] Skill data
124
+ # @return [void]
125
+ #
126
+ def revalidate_existing_skill(resource, existing, skill)
127
+ skill_file = File.join(resource.local_path, skill[:folder_path], "SKILL.md")
128
+ result = SkillValidator.validate(skill_file)
129
+
130
+ updated = Resource.from_h(
131
+ existing.to_h.merge(
132
+ validation_status: result[:valid] ? :valid_skill : :invalid_skill,
133
+ validation_errors: result[:errors]
134
+ ),
135
+ storage_path: storage_path
136
+ )
137
+ manifest.update_resource(updated)
138
+ end
139
+
140
+ # Adds a new skill found during sync.
141
+ #
142
+ # @param resource [Resource] Parent resource
143
+ # @param skill [Hash] Skill data
144
+ # @param skill_label [String] Skill label
145
+ # @return [void]
146
+ #
147
+ def add_new_skill_from_sync(resource, skill, skill_label)
148
+ new_resource = create_new_skill_resource(resource, skill, skill_label)
149
+ new_resource = validate_and_update_skill(resource, new_resource, skill)
150
+ manifest.add_resource(new_resource)
151
+ end
152
+
153
+ # Creates a new skill resource.
154
+ #
155
+ # @param resource [Resource] Parent resource
156
+ # @param skill [Hash] Skill data
157
+ # @param skill_label [String] Skill label
158
+ # @return [Resource] Created resource
159
+ #
160
+ def create_new_skill_resource(resource, skill, skill_label)
161
+ Resource.new(
162
+ label: skill_label,
163
+ url: resource.url,
164
+ username: resource.username,
165
+ repo: resource.repo,
166
+ folder: skill[:folder_path],
167
+ type: :repo,
168
+ branch: resource.branch,
169
+ relative_path: skill[:folder_path],
170
+ storage_path: storage_path,
171
+ validation_status: :unvalidated,
172
+ validation_errors: [],
173
+ skill_name: skill[:skill_name],
174
+ is_skill: true
175
+ )
176
+ end
177
+
178
+ # Validates and updates a skill resource.
179
+ #
180
+ # @param resource [Resource] Parent resource
181
+ # @param new_resource [Resource] Skill resource to validate
182
+ # @param skill [Hash] Skill data
183
+ # @return [Resource] Updated resource
184
+ #
185
+ def validate_and_update_skill(resource, new_resource, skill)
186
+ skill_file = File.join(resource.local_path, skill[:folder_path], "SKILL.md")
187
+ result = SkillValidator.validate(skill_file)
188
+
189
+ Resource.from_h(
190
+ new_resource.to_h.merge(
191
+ validation_status: result[:valid] ? :valid_skill : :invalid_skill,
192
+ validation_errors: result[:errors]
193
+ ),
194
+ storage_path: storage_path
195
+ )
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ class Vault
5
+ # Module for validating resources in vault.
6
+ #
7
+ module ResourceValidator
8
+ # Validates a specific resource.
9
+ #
10
+ # Re-validates the skill file and updates the validation status.
11
+ #
12
+ # @param label [String] The label of the resource to validate
13
+ # @return [Resource] The updated resource
14
+ # @raise [Errors::NotFound] if the resource doesn't exist
15
+ #
16
+ def validate_resource(label)
17
+ resource = fetch(label)
18
+
19
+ updated_resource = if resource.is_skill
20
+ validate_skill_resource(resource)
21
+ else
22
+ Resource.from_h(
23
+ resource.to_h.merge(validation_status: :not_a_skill),
24
+ storage_path: storage_path
25
+ )
26
+ end
27
+
28
+ manifest.update_resource(updated_resource)
29
+ updated_resource
30
+ end
31
+
32
+ # Validates all resources in the vault.
33
+ #
34
+ # Re-validates all resources and updates their validation status.
35
+ #
36
+ # @return [Hash] Summary of validation results
37
+ # - :valid [Integer] Count of valid skills
38
+ # - :invalid [Integer] Count of invalid skills
39
+ # - :not_a_skill [Integer] Count of non-skill resources
40
+ # - :unvalidated [Integer] Count of resources that couldn't be validated
41
+ #
42
+ def validate_all
43
+ results = { valid: 0, invalid: 0, not_a_skill: 0, unvalidated: 0 }
44
+
45
+ list.each do |resource|
46
+ validate_resource(resource.label)
47
+
48
+ case resource.validation_status
49
+ when :valid_skill
50
+ results[:valid] += 1
51
+ when :invalid_skill
52
+ results[:invalid] += 1
53
+ when :not_a_skill
54
+ results[:not_a_skill] += 1
55
+ else
56
+ results[:unvalidated] += 1
57
+ end
58
+ end
59
+
60
+ results
61
+ end
62
+
63
+ # Removes all invalid skills from the vault.
64
+ #
65
+ # Deletes local files and removes from manifest for all resources with
66
+ # validation_status == :invalid_skill.
67
+ #
68
+ # @return [Integer] Number of skills removed
69
+ #
70
+ def cleanup_invalid_skills
71
+ invalid_resources = list_invalid_skills
72
+
73
+ invalid_resources.each do |resource|
74
+ FileUtils.rm_rf(resource.local_path) if resource.local_path
75
+ manifest.remove_resource(resource.label)
76
+ end
77
+
78
+ invalid_resources.size
79
+ end
80
+
81
+ private
82
+
83
+ # Validates a skill resource.
84
+ #
85
+ # @param resource [Resource] Skill resource to validate
86
+ # @return [Resource] Updated resource
87
+ #
88
+ def validate_skill_resource(resource)
89
+ skill_file = File.join(resource.local_path, "SKILL.md")
90
+ result = SkillValidator.validate(skill_file)
91
+
92
+ Resource.from_h(
93
+ resource.to_h.merge(
94
+ validation_status: result[:valid] ? :valid_skill : :invalid_skill,
95
+ validation_errors: result[:errors]
96
+ ),
97
+ storage_path: storage_path
98
+ )
99
+ end
100
+ end
101
+ end
102
+ end