agents_skill_vault 0.1.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.
- checksums.yaml +7 -0
- data/AGENTS.md +93 -0
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +1 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +64 -0
- data/LICENSE +201 -0
- data/README.md +268 -0
- data/Rakefile +12 -0
- data/lib/agents_skill_vault/errors/base.rb +7 -0
- data/lib/agents_skill_vault/errors/duplicate_label.rb +9 -0
- data/lib/agents_skill_vault/errors/git_not_installed.rb +9 -0
- data/lib/agents_skill_vault/errors/git_version.rb +9 -0
- data/lib/agents_skill_vault/errors/invalid_url.rb +9 -0
- data/lib/agents_skill_vault/errors/not_found.rb +9 -0
- data/lib/agents_skill_vault/errors.rb +6 -0
- data/lib/agents_skill_vault/git_operations.rb +148 -0
- data/lib/agents_skill_vault/manifest.rb +138 -0
- data/lib/agents_skill_vault/resource.rb +235 -0
- data/lib/agents_skill_vault/skill_scanner.rb +74 -0
- data/lib/agents_skill_vault/skill_validator.rb +201 -0
- data/lib/agents_skill_vault/sync_result.rb +44 -0
- data/lib/agents_skill_vault/url_parser.rb +207 -0
- data/lib/agents_skill_vault/vault.rb +789 -0
- data/lib/agents_skill_vault/version.rb +5 -0
- data/lib/agents_skill_vault.rb +8 -0
- data/scripts/manual_test.rb +174 -0
- data/sig/agents_skill_vault.rbs +4 -0
- metadata +115 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module AgentsSkillVault
|
|
7
|
+
# Main interface for managing a vault of GitHub resources.
|
|
8
|
+
#
|
|
9
|
+
# A Vault stores cloned repositories and folders from GitHub in a local directory,
|
|
10
|
+
# tracking them via a manifest file. Resources can be synced, queried, and managed.
|
|
11
|
+
#
|
|
12
|
+
# @example Create a vault and add resources
|
|
13
|
+
# vault = AgentsSkillVault::Vault.new(storage_path: "~/.skills")
|
|
14
|
+
# vault.add("https://github.com/user/repo")
|
|
15
|
+
# vault.add("https://github.com/user/repo/tree/main/skills/my-skill", label: "my-skill")
|
|
16
|
+
#
|
|
17
|
+
# @example Query and sync resources
|
|
18
|
+
# vault.filter_by_username("user") # => [Resource, ...]
|
|
19
|
+
# vault.fetch("user/repo") # => Resource (raises if not found)
|
|
20
|
+
# vault.sync("user/repo") # => SyncResult
|
|
21
|
+
#
|
|
22
|
+
class Vault
|
|
23
|
+
# @return [String] The absolute path to the vault's storage directory
|
|
24
|
+
attr_reader :storage_path
|
|
25
|
+
|
|
26
|
+
# @return [Manifest] The manifest managing resource metadata
|
|
27
|
+
attr_reader :manifest
|
|
28
|
+
|
|
29
|
+
# Creates a new Vault instance.
|
|
30
|
+
#
|
|
31
|
+
# @param storage_path [String] Path to the directory where resources will be stored
|
|
32
|
+
# @param manifest_file [String] Name of the manifest file (default: "manifest.json")
|
|
33
|
+
# @raise [Errors::GitNotInstalled] if git is not installed
|
|
34
|
+
# @raise [Errors::GitVersion] if git version is below 2.25.0
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# vault = Vault.new(storage_path: "/path/to/vault")
|
|
38
|
+
#
|
|
39
|
+
def initialize(storage_path:, manifest_file: "manifest.json")
|
|
40
|
+
@storage_path = File.expand_path(storage_path)
|
|
41
|
+
FileUtils.mkdir_p(@storage_path)
|
|
42
|
+
@manifest_path = File.join(@storage_path, manifest_file)
|
|
43
|
+
@manifest = Manifest.new(path: @manifest_path)
|
|
44
|
+
@manifest.save(default_manifest_data) unless File.exist?(@manifest_path)
|
|
45
|
+
|
|
46
|
+
GitOperations.check_git_available!
|
|
47
|
+
GitOperations.check_git_version!
|
|
48
|
+
|
|
49
|
+
# Auto-validate unvalidated resources if manifest exists
|
|
50
|
+
validate_all if File.exist?(@manifest_path) && !list_unvalidated.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Adds a new resource from a GitHub URL.
|
|
54
|
+
#
|
|
55
|
+
# Clones the repository or performs a sparse checkout for folders/files,
|
|
56
|
+
# validates skills if present, then adds the resource to the manifest.
|
|
57
|
+
#
|
|
58
|
+
# For repositories, scans for all SKILL.md files and creates separate entries for each.
|
|
59
|
+
# For folders and files, validates if they contain a SKILL.md.
|
|
60
|
+
#
|
|
61
|
+
# @param url [String] GitHub URL (repository, folder, or file)
|
|
62
|
+
# @param label [String, nil] Custom label for the resource; auto-generated if nil
|
|
63
|
+
# @return [Resource, Array<Resource>] The newly created resource(s)
|
|
64
|
+
# @raise [Errors::InvalidUrl] if URL is not a valid GitHub URL
|
|
65
|
+
# @raise [Errors::DuplicateLabel] if a resource with the same label already exists
|
|
66
|
+
#
|
|
67
|
+
# @example Add a full repository with skills
|
|
68
|
+
# resources = vault.add("https://github.com/user/repo")
|
|
69
|
+
#
|
|
70
|
+
# @example Add a specific folder with skill
|
|
71
|
+
# resource = vault.add("https://github.com/user/repo/tree/main/skills/my-skill")
|
|
72
|
+
#
|
|
73
|
+
# @example Add a SKILL.md file
|
|
74
|
+
# resource = vault.add("https://github.com/user/repo/blob/main/skills/my-skill/SKILL.md")
|
|
75
|
+
#
|
|
76
|
+
def add(url, label: nil)
|
|
77
|
+
parsed_url = UrlParser.parse(url)
|
|
78
|
+
|
|
79
|
+
case parsed_url.type
|
|
80
|
+
when :repo
|
|
81
|
+
add_repository_resource(parsed_url, label: label)
|
|
82
|
+
when :folder
|
|
83
|
+
add_folder_resource(parsed_url, label: label)
|
|
84
|
+
when :file
|
|
85
|
+
add_file_resource(parsed_url, label: label)
|
|
86
|
+
else
|
|
87
|
+
raise Errors::InvalidUrl, "Unknown URL type: #{parsed_url.type}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Lists all resources in the vault.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Resource>] All resources currently tracked in the manifest
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# vault.list.each { |r| puts r.label }
|
|
97
|
+
#
|
|
98
|
+
def list
|
|
99
|
+
manifest.resources.map { |r| Resource.from_h(r, storage_path: storage_path) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Filters resources by GitHub username.
|
|
103
|
+
#
|
|
104
|
+
# @param username [String] The GitHub username to filter by
|
|
105
|
+
# @return [Array<Resource>] Resources owned by the specified user
|
|
106
|
+
#
|
|
107
|
+
# @example
|
|
108
|
+
# vault.filter_by_username("octocat")
|
|
109
|
+
# # => [Resource(label: "octocat/repo1"), Resource(label: "octocat/repo2")]
|
|
110
|
+
#
|
|
111
|
+
def filter_by_username(username)
|
|
112
|
+
list.select { |r| r.username == username }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Filters resources by repository name.
|
|
116
|
+
#
|
|
117
|
+
# @param repo_name [String] The repository name to filter by
|
|
118
|
+
# @return [Array<Resource>] Resources from repositories with the specified name
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# vault.filter_by_repo("dotfiles")
|
|
122
|
+
#
|
|
123
|
+
def filter_by_repo(repo_name)
|
|
124
|
+
list.select { |r| r.repo == repo_name }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Lists all valid skills.
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<Resource>] Resources with validation_status == :valid_skill
|
|
130
|
+
#
|
|
131
|
+
def list_valid_skills
|
|
132
|
+
list.select { |r| r.validation_status == :valid_skill }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Lists all invalid skills.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<Resource>] Resources with validation_status == :invalid_skill
|
|
138
|
+
#
|
|
139
|
+
def list_invalid_skills
|
|
140
|
+
list.select { |r| r.validation_status == :invalid_skill }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Lists all non-skill resources.
|
|
144
|
+
#
|
|
145
|
+
# @return [Array<Resource>] Resources with validation_status == :not_a_skill
|
|
146
|
+
#
|
|
147
|
+
def list_non_skills
|
|
148
|
+
list.select { |r| r.validation_status == :not_a_skill }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Lists all unvalidated resources.
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<Resource>] Resources with validation_status == :unvalidated
|
|
154
|
+
#
|
|
155
|
+
def list_unvalidated
|
|
156
|
+
list.select { |r| r.validation_status == :unvalidated }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Filters resources by skill name.
|
|
160
|
+
#
|
|
161
|
+
# @param name [String] The skill name to filter by
|
|
162
|
+
# @return [Array<Resource>] Resources matching the skill name
|
|
163
|
+
#
|
|
164
|
+
def filter_by_skill_name(name)
|
|
165
|
+
list.select { |r| r.skill_name == name }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Finds a resource by its label without raising an error.
|
|
169
|
+
#
|
|
170
|
+
# @param label [String] The unique label of the resource
|
|
171
|
+
# @return [Resource, nil] The resource if found, nil otherwise
|
|
172
|
+
#
|
|
173
|
+
# @example
|
|
174
|
+
# resource = vault.find_by_label("user/repo")
|
|
175
|
+
# puts resource&.local_path
|
|
176
|
+
#
|
|
177
|
+
def find_by_label(label)
|
|
178
|
+
manifest.find_resource(label)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Retrieves a resource by its label, raising an error if not found.
|
|
182
|
+
#
|
|
183
|
+
# @param label [String] The unique label of the resource
|
|
184
|
+
# @return [Resource] The resource with the specified label
|
|
185
|
+
# @raise [Errors::NotFound] if no resource with the label exists
|
|
186
|
+
#
|
|
187
|
+
# @example
|
|
188
|
+
# resource = vault.fetch("user/repo")
|
|
189
|
+
# puts resource.local_path
|
|
190
|
+
#
|
|
191
|
+
def fetch(label)
|
|
192
|
+
find_by_label(label) || raise(Errors::NotFound, "Resource '#{label}' not found in vault at #{storage_path}. " \
|
|
193
|
+
"Available labels: #{list.map(&:label).join(", ").then do |s|
|
|
194
|
+
s.empty? ? "(none)" : s
|
|
195
|
+
end}")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Syncs a resource by pulling the latest changes from GitHub.
|
|
199
|
+
#
|
|
200
|
+
# For repository-type resources, re-scans for new skills and re-validates existing ones.
|
|
201
|
+
# For folder and file-type resources, re-validates the skill if present.
|
|
202
|
+
#
|
|
203
|
+
# @param label [String] The label of the resource to sync
|
|
204
|
+
# @return [SyncResult] Result indicating success/failure and whether changes occurred
|
|
205
|
+
# @raise [Errors::NotFound] if the resource doesn't exist
|
|
206
|
+
#
|
|
207
|
+
# @example
|
|
208
|
+
# result = vault.sync("user/repo")
|
|
209
|
+
# puts "Synced successfully" if result.success?
|
|
210
|
+
#
|
|
211
|
+
def sync(label)
|
|
212
|
+
resource = fetch(label)
|
|
213
|
+
sync_resource(resource)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Syncs all resources in the vault.
|
|
217
|
+
#
|
|
218
|
+
# @return [Hash{String => SyncResult}] Map of resource labels to their sync results
|
|
219
|
+
#
|
|
220
|
+
# @example
|
|
221
|
+
# results = vault.sync_all
|
|
222
|
+
# results.each do |label, result|
|
|
223
|
+
# puts "#{label}: #{result.success? ? 'OK' : result.error}"
|
|
224
|
+
# end
|
|
225
|
+
#
|
|
226
|
+
def sync_all
|
|
227
|
+
results = {}
|
|
228
|
+
list.each do |resource|
|
|
229
|
+
results[resource.label] = sync_resource(resource)
|
|
230
|
+
end
|
|
231
|
+
results
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Removes a resource from the vault.
|
|
235
|
+
#
|
|
236
|
+
# @param label [String] The label of the resource to remove
|
|
237
|
+
# @param delete_files [Boolean] Whether to also delete the local files (default: false)
|
|
238
|
+
# @raise [Errors::NotFound] if the resource doesn't exist
|
|
239
|
+
#
|
|
240
|
+
# @example Remove from manifest only (keep files)
|
|
241
|
+
# vault.remove("user/repo")
|
|
242
|
+
#
|
|
243
|
+
# @example Remove from manifest and delete files
|
|
244
|
+
# vault.remove("user/repo", delete_files: true)
|
|
245
|
+
#
|
|
246
|
+
def remove(label, delete_files: false)
|
|
247
|
+
resource = fetch(label)
|
|
248
|
+
|
|
249
|
+
FileUtils.rm_rf(resource.local_path) if delete_files && resource.local_path
|
|
250
|
+
|
|
251
|
+
manifest.remove_resource(label)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Validates a specific resource.
|
|
255
|
+
#
|
|
256
|
+
# Re-validates the skill file and updates the validation status.
|
|
257
|
+
#
|
|
258
|
+
# @param label [String] The label of the resource to validate
|
|
259
|
+
# @return [Resource] The updated resource
|
|
260
|
+
# @raise [Errors::NotFound] if the resource doesn't exist
|
|
261
|
+
#
|
|
262
|
+
def validate_resource(label)
|
|
263
|
+
resource = fetch(label)
|
|
264
|
+
|
|
265
|
+
if resource.is_skill
|
|
266
|
+
skill_file = File.join(resource.local_path, "SKILL.md")
|
|
267
|
+
result = SkillValidator.validate(skill_file)
|
|
268
|
+
|
|
269
|
+
updated_resource = Resource.from_h(
|
|
270
|
+
resource.to_h.merge(
|
|
271
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
272
|
+
validation_errors: result[:errors]
|
|
273
|
+
),
|
|
274
|
+
storage_path: storage_path
|
|
275
|
+
)
|
|
276
|
+
else
|
|
277
|
+
updated_resource = Resource.from_h(
|
|
278
|
+
resource.to_h.merge(validation_status: :not_a_skill),
|
|
279
|
+
storage_path: storage_path
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
manifest.update_resource(updated_resource)
|
|
284
|
+
updated_resource
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Validates all resources in the vault.
|
|
288
|
+
#
|
|
289
|
+
# Re-validates all resources and updates their validation status.
|
|
290
|
+
#
|
|
291
|
+
# @return [Hash] Summary of validation results
|
|
292
|
+
# - :valid [Integer] Count of valid skills
|
|
293
|
+
# - :invalid [Integer] Count of invalid skills
|
|
294
|
+
# - :not_a_skill [Integer] Count of non-skill resources
|
|
295
|
+
# - :unvalidated [Integer] Count of resources that couldn't be validated
|
|
296
|
+
#
|
|
297
|
+
def validate_all
|
|
298
|
+
results = { valid: 0, invalid: 0, not_a_skill: 0, unvalidated: 0 }
|
|
299
|
+
|
|
300
|
+
list.each do |resource|
|
|
301
|
+
validate_resource(resource.label)
|
|
302
|
+
|
|
303
|
+
case resource.validation_status
|
|
304
|
+
when :valid_skill
|
|
305
|
+
results[:valid] += 1
|
|
306
|
+
when :invalid_skill
|
|
307
|
+
results[:invalid] += 1
|
|
308
|
+
when :not_a_skill
|
|
309
|
+
results[:not_a_skill] += 1
|
|
310
|
+
else
|
|
311
|
+
results[:unvalidated] += 1
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
results
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Removes all invalid skills from the vault.
|
|
319
|
+
#
|
|
320
|
+
# Deletes local files and removes from manifest for all resources with
|
|
321
|
+
# validation_status == :invalid_skill.
|
|
322
|
+
#
|
|
323
|
+
# @return [Integer] Number of skills removed
|
|
324
|
+
#
|
|
325
|
+
def cleanup_invalid_skills
|
|
326
|
+
invalid_resources = list_invalid_skills
|
|
327
|
+
|
|
328
|
+
invalid_resources.each do |resource|
|
|
329
|
+
FileUtils.rm_rf(resource.local_path) if resource.local_path
|
|
330
|
+
manifest.remove_resource(resource.label)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
invalid_resources.size
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Re-downloads all resources from scratch.
|
|
337
|
+
#
|
|
338
|
+
# Deletes local files and performs a fresh clone/checkout for each resource.
|
|
339
|
+
# Useful for recovering from corruption or ensuring a clean state.
|
|
340
|
+
#
|
|
341
|
+
# @return [void]
|
|
342
|
+
#
|
|
343
|
+
def redownload_all
|
|
344
|
+
list.each do |resource|
|
|
345
|
+
FileUtils.rm_rf(resource.local_path) if resource.local_path
|
|
346
|
+
download_resource(resource)
|
|
347
|
+
updated_resource = Resource.from_h(resource.to_h.merge(synced_at: Time.now), storage_path: storage_path)
|
|
348
|
+
manifest.update_resource(updated_resource)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Exports the manifest to a file for backup or sharing.
|
|
353
|
+
#
|
|
354
|
+
# @param export_path [String] Path where the manifest should be exported
|
|
355
|
+
# @return [void]
|
|
356
|
+
#
|
|
357
|
+
# @example
|
|
358
|
+
# vault.export_manifest("/backups/manifest.json")
|
|
359
|
+
#
|
|
360
|
+
def export_manifest(export_path)
|
|
361
|
+
FileUtils.cp(@manifest_path, export_path)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Imports and merges a manifest from another vault.
|
|
365
|
+
#
|
|
366
|
+
# Resources from the imported manifest are merged with existing resources.
|
|
367
|
+
# If a label exists in both, the imported resource overwrites the existing one.
|
|
368
|
+
# Note: This only updates the manifest; files are not downloaded automatically.
|
|
369
|
+
#
|
|
370
|
+
# @param import_path [String] Path to the manifest file to import
|
|
371
|
+
# @return [void]
|
|
372
|
+
#
|
|
373
|
+
# @example
|
|
374
|
+
# vault.import_manifest("/shared/manifest.json")
|
|
375
|
+
# vault.redownload_all # Download the imported resources
|
|
376
|
+
#
|
|
377
|
+
def import_manifest(import_path)
|
|
378
|
+
imported_data = JSON.parse(File.read(import_path), symbolize_names: true)
|
|
379
|
+
current_data = manifest.load
|
|
380
|
+
|
|
381
|
+
merged_resources = merge_resources(current_data[:resources] || [], imported_data[:resources] || [])
|
|
382
|
+
|
|
383
|
+
manifest.save(version: Manifest::VERSION, resources: merged_resources)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
private
|
|
387
|
+
|
|
388
|
+
def default_manifest_data
|
|
389
|
+
{ version: Manifest::VERSION, resources: [] }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Adds a repository resource, scanning for all skills.
|
|
393
|
+
#
|
|
394
|
+
# @param parsed_url [UrlParser::ParseResult] Parsed URL
|
|
395
|
+
# @param label [String, nil] Custom label
|
|
396
|
+
# @return [Array<Resource>] Created resources (one per skill found)
|
|
397
|
+
#
|
|
398
|
+
def add_repository_resource(parsed_url, label:)
|
|
399
|
+
target_path = File.join(storage_path, parsed_url.username, parsed_url.repo)
|
|
400
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
401
|
+
|
|
402
|
+
repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
|
|
403
|
+
GitOperations.clone_repo(repo_url, target_path, branch: parsed_url.branch)
|
|
404
|
+
|
|
405
|
+
skills = SkillScanner.scan_directory(target_path)
|
|
406
|
+
|
|
407
|
+
if skills.empty?
|
|
408
|
+
# No skills found, create single resource for repo
|
|
409
|
+
resource = create_resource(parsed_url, label: label || parsed_url.label)
|
|
410
|
+
resource = Resource.from_h(
|
|
411
|
+
resource.to_h.merge(
|
|
412
|
+
validation_status: :not_a_skill,
|
|
413
|
+
is_skill: false
|
|
414
|
+
),
|
|
415
|
+
storage_path: storage_path
|
|
416
|
+
)
|
|
417
|
+
manifest.add_resource(resource)
|
|
418
|
+
return [resource]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Create resource for each skill found
|
|
422
|
+
skills.map do |skill|
|
|
423
|
+
skill_label = label ? "#{label}-#{skill[:skill_name]}" : "#{parsed_url.username}/#{parsed_url.repo}/#{skill[:skill_name]}"
|
|
424
|
+
|
|
425
|
+
resource = Resource.new(
|
|
426
|
+
label: skill_label,
|
|
427
|
+
url: repo_url,
|
|
428
|
+
username: parsed_url.username,
|
|
429
|
+
repo: parsed_url.repo,
|
|
430
|
+
folder: skill[:folder_path],
|
|
431
|
+
type: :repo,
|
|
432
|
+
branch: parsed_url.branch,
|
|
433
|
+
relative_path: skill[:folder_path],
|
|
434
|
+
storage_path: storage_path,
|
|
435
|
+
validation_status: :unvalidated,
|
|
436
|
+
validation_errors: [],
|
|
437
|
+
skill_name: skill[:skill_name],
|
|
438
|
+
is_skill: true
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
skill_file = File.join(target_path, skill[:folder_path], "SKILL.md")
|
|
442
|
+
result = SkillValidator.validate(skill_file)
|
|
443
|
+
|
|
444
|
+
resource = Resource.from_h(
|
|
445
|
+
resource.to_h.merge(
|
|
446
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
447
|
+
validation_errors: result[:errors]
|
|
448
|
+
),
|
|
449
|
+
storage_path: storage_path
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
manifest.add_resource(resource)
|
|
453
|
+
resource
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Adds a folder resource.
|
|
458
|
+
#
|
|
459
|
+
# @param parsed_url [UrlParser::ParseResult] Parsed URL
|
|
460
|
+
# @param label [String, nil] Custom label
|
|
461
|
+
# @return [Resource] Created resource
|
|
462
|
+
#
|
|
463
|
+
def add_folder_resource(parsed_url, label:)
|
|
464
|
+
repo_path = File.join(storage_path, parsed_url.username, parsed_url.repo)
|
|
465
|
+
target_path = File.join(repo_path, parsed_url.relative_path)
|
|
466
|
+
FileUtils.mkdir_p(repo_path)
|
|
467
|
+
|
|
468
|
+
repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
|
|
469
|
+
paths = [parsed_url.relative_path]
|
|
470
|
+
GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
|
|
471
|
+
|
|
472
|
+
skills = SkillScanner.scan_directory(target_path)
|
|
473
|
+
|
|
474
|
+
if skills.empty?
|
|
475
|
+
skill_name = File.basename(parsed_url.relative_path)
|
|
476
|
+
resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
|
|
477
|
+
|
|
478
|
+
resource = Resource.new(
|
|
479
|
+
label: resource_label,
|
|
480
|
+
url: repo_url,
|
|
481
|
+
username: parsed_url.username,
|
|
482
|
+
repo: parsed_url.repo,
|
|
483
|
+
folder: parsed_url.relative_path,
|
|
484
|
+
type: :folder,
|
|
485
|
+
branch: parsed_url.branch,
|
|
486
|
+
relative_path: parsed_url.relative_path,
|
|
487
|
+
storage_path: storage_path,
|
|
488
|
+
validation_status: :not_a_skill,
|
|
489
|
+
validation_errors: [],
|
|
490
|
+
skill_name: nil,
|
|
491
|
+
is_skill: false
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
manifest.add_resource(resource)
|
|
495
|
+
resource
|
|
496
|
+
elsif skills.length == 1
|
|
497
|
+
skill = skills.first
|
|
498
|
+
skill_name = skill[:skill_name]
|
|
499
|
+
resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
|
|
500
|
+
|
|
501
|
+
skill_relative_path = if skill[:relative_path] == "."
|
|
502
|
+
parsed_url.relative_path
|
|
503
|
+
else
|
|
504
|
+
File.join(
|
|
505
|
+
parsed_url.relative_path, skill[:relative_path]
|
|
506
|
+
)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
resource = Resource.new(
|
|
510
|
+
label: resource_label,
|
|
511
|
+
url: repo_url,
|
|
512
|
+
username: parsed_url.username,
|
|
513
|
+
repo: parsed_url.repo,
|
|
514
|
+
folder: parsed_url.relative_path,
|
|
515
|
+
type: :folder,
|
|
516
|
+
branch: parsed_url.branch,
|
|
517
|
+
relative_path: skill_relative_path,
|
|
518
|
+
storage_path: storage_path,
|
|
519
|
+
skill_name: skill_name,
|
|
520
|
+
is_skill: true
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
skill_file = File.join(target_path, skill[:relative_path], "SKILL.md")
|
|
524
|
+
result = SkillValidator.validate(skill_file)
|
|
525
|
+
|
|
526
|
+
resource = Resource.from_h(
|
|
527
|
+
resource.to_h.merge(
|
|
528
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
529
|
+
validation_errors: result[:errors]
|
|
530
|
+
),
|
|
531
|
+
storage_path: storage_path
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
manifest.add_resource(resource)
|
|
535
|
+
resource
|
|
536
|
+
else
|
|
537
|
+
skills.map do |skill|
|
|
538
|
+
skill_name = skill[:skill_name]
|
|
539
|
+
skill_label = label ? "#{label}/#{skill_name}" : "#{parsed_url.username}/#{parsed_url.repo}/#{skill_name}"
|
|
540
|
+
|
|
541
|
+
skill_relative_path = if skill[:relative_path] == "."
|
|
542
|
+
parsed_url.relative_path
|
|
543
|
+
else
|
|
544
|
+
File.join(
|
|
545
|
+
parsed_url.relative_path, skill[:relative_path]
|
|
546
|
+
)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
resource = Resource.new(
|
|
550
|
+
label: skill_label,
|
|
551
|
+
url: repo_url,
|
|
552
|
+
username: parsed_url.username,
|
|
553
|
+
repo: parsed_url.repo,
|
|
554
|
+
folder: parsed_url.relative_path,
|
|
555
|
+
type: :folder,
|
|
556
|
+
branch: parsed_url.branch,
|
|
557
|
+
relative_path: skill_relative_path,
|
|
558
|
+
storage_path: storage_path,
|
|
559
|
+
skill_name: skill_name,
|
|
560
|
+
is_skill: true
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
skill_file = File.join(target_path, skill[:relative_path], "SKILL.md")
|
|
564
|
+
result = SkillValidator.validate(skill_file)
|
|
565
|
+
|
|
566
|
+
resource = Resource.from_h(
|
|
567
|
+
resource.to_h.merge(
|
|
568
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
569
|
+
validation_errors: result[:errors]
|
|
570
|
+
),
|
|
571
|
+
storage_path: storage_path
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
manifest.add_resource(resource)
|
|
575
|
+
resource
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Adds a file resource.
|
|
581
|
+
#
|
|
582
|
+
# @param parsed_url [UrlParser::ParseResult] Parsed URL
|
|
583
|
+
# @param label [String, nil] Custom label
|
|
584
|
+
# @return [Resource] Created resource
|
|
585
|
+
#
|
|
586
|
+
def add_file_resource(parsed_url, label:)
|
|
587
|
+
repo_url = "https://github.com/#{parsed_url.username}/#{parsed_url.repo}"
|
|
588
|
+
|
|
589
|
+
if parsed_url.is_skill_file?
|
|
590
|
+
# This is a SKILL.md file, download the parent folder
|
|
591
|
+
repo_path = File.join(storage_path, parsed_url.username, parsed_url.repo)
|
|
592
|
+
target_path = File.join(repo_path, parsed_url.skill_folder_path)
|
|
593
|
+
FileUtils.mkdir_p(repo_path)
|
|
594
|
+
|
|
595
|
+
paths = [parsed_url.skill_folder_path]
|
|
596
|
+
GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
|
|
597
|
+
|
|
598
|
+
skill_file = File.join(target_path, "SKILL.md")
|
|
599
|
+
result = SkillValidator.validate(skill_file)
|
|
600
|
+
|
|
601
|
+
resource_label = label || "#{parsed_url.username}/#{parsed_url.repo}/#{parsed_url.skill_name}"
|
|
602
|
+
resource = Resource.new(
|
|
603
|
+
label: resource_label,
|
|
604
|
+
url: repo_url,
|
|
605
|
+
username: parsed_url.username,
|
|
606
|
+
repo: parsed_url.repo,
|
|
607
|
+
folder: parsed_url.skill_folder_path,
|
|
608
|
+
type: :file,
|
|
609
|
+
branch: parsed_url.branch,
|
|
610
|
+
relative_path: parsed_url.skill_folder_path,
|
|
611
|
+
storage_path: storage_path,
|
|
612
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
613
|
+
validation_errors: result[:errors],
|
|
614
|
+
skill_name: parsed_url.skill_name,
|
|
615
|
+
is_skill: true
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
manifest.add_resource(resource)
|
|
619
|
+
resource
|
|
620
|
+
else
|
|
621
|
+
# Non-SKILL.md file, download the parent folder
|
|
622
|
+
parent_path = File.dirname(parsed_url.relative_path)
|
|
623
|
+
repo_path = File.join(storage_path, parsed_url.username, parsed_url.repo)
|
|
624
|
+
FileUtils.mkdir_p(repo_path)
|
|
625
|
+
|
|
626
|
+
paths = [parent_path]
|
|
627
|
+
GitOperations.sparse_checkout(repo_url, repo_path, branch: parsed_url.branch, paths: paths)
|
|
628
|
+
|
|
629
|
+
resource = Resource.new(
|
|
630
|
+
label: label || parsed_url.label,
|
|
631
|
+
url: parsed_url.url,
|
|
632
|
+
username: parsed_url.username,
|
|
633
|
+
repo: parsed_url.repo,
|
|
634
|
+
folder: parent_path,
|
|
635
|
+
type: :file,
|
|
636
|
+
branch: parsed_url.branch,
|
|
637
|
+
relative_path: parsed_url.relative_path,
|
|
638
|
+
storage_path: storage_path,
|
|
639
|
+
validation_status: :not_a_skill,
|
|
640
|
+
validation_errors: [],
|
|
641
|
+
skill_name: nil,
|
|
642
|
+
is_skill: false
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
manifest.add_resource(resource)
|
|
646
|
+
resource
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def create_resource(parsed_url, label:)
|
|
651
|
+
label ||= parsed_url.label
|
|
652
|
+
|
|
653
|
+
Resource.new(
|
|
654
|
+
label: label,
|
|
655
|
+
url: "https://github.com/#{parsed_url.username}/#{parsed_url.repo}",
|
|
656
|
+
username: parsed_url.username,
|
|
657
|
+
repo: parsed_url.repo,
|
|
658
|
+
folder: parsed_url.type == :repo ? nil : File.basename(parsed_url.relative_path || ""),
|
|
659
|
+
type: parsed_url.type,
|
|
660
|
+
branch: parsed_url.branch,
|
|
661
|
+
relative_path: parsed_url.relative_path,
|
|
662
|
+
storage_path: storage_path
|
|
663
|
+
)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def download_resource(resource)
|
|
667
|
+
target_path = resource.local_path
|
|
668
|
+
parent_dir = File.dirname(target_path)
|
|
669
|
+
FileUtils.mkdir_p(parent_dir)
|
|
670
|
+
|
|
671
|
+
if resource.type == :repo
|
|
672
|
+
GitOperations.clone_repo(resource.url, target_path, branch: resource.branch)
|
|
673
|
+
else
|
|
674
|
+
paths = [resource.relative_path]
|
|
675
|
+
GitOperations.sparse_checkout(resource.url, target_path, branch: resource.branch, paths: paths)
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def sync_resource(resource)
|
|
680
|
+
return SyncResult.new(success: false, error: "Resource has no local path") unless resource.local_path
|
|
681
|
+
|
|
682
|
+
unless Dir.exist?(resource.local_path)
|
|
683
|
+
return SyncResult.new(success: false,
|
|
684
|
+
error: "Path does not exist: #{resource.local_path}")
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
GitOperations.pull(resource.local_path)
|
|
688
|
+
|
|
689
|
+
# For repo type resources, re-scan for skills and re-validate
|
|
690
|
+
if resource.type == :repo
|
|
691
|
+
sync_repository_resource(resource)
|
|
692
|
+
else
|
|
693
|
+
# For folder/file type resources, just re-validate
|
|
694
|
+
validate_resource(resource.label)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Update synced_at for all resources from this repo
|
|
698
|
+
repo_resources = list.select { |r| r.repo == resource.repo && r.username == resource.username }
|
|
699
|
+
repo_resources.each do |r|
|
|
700
|
+
updated = Resource.from_h(r.to_h.merge(synced_at: Time.now), storage_path: storage_path)
|
|
701
|
+
manifest.update_resource(updated)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
SyncResult.new(success: true, changes: true)
|
|
705
|
+
rescue Errors::Error => e
|
|
706
|
+
SyncResult.new(success: false, error: e.message)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Syncs a repository-type resource, re-scanning for skills.
|
|
710
|
+
#
|
|
711
|
+
# @param resource [Resource] The resource to sync
|
|
712
|
+
#
|
|
713
|
+
def sync_repository_resource(resource)
|
|
714
|
+
skills = SkillScanner.scan_directory(resource.local_path)
|
|
715
|
+
|
|
716
|
+
# Get existing skills for this repo
|
|
717
|
+
existing_resources = list.select do |r|
|
|
718
|
+
r.repo == resource.repo && r.username == resource.username && r.type == :repo
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Process each found skill
|
|
722
|
+
skills.each do |skill|
|
|
723
|
+
skill_label = "#{resource.username}/#{resource.repo}/#{skill[:skill_name]}"
|
|
724
|
+
existing = existing_resources.find { |r| r.skill_name == skill[:skill_name] }
|
|
725
|
+
|
|
726
|
+
if existing
|
|
727
|
+
# Re-validate existing skill
|
|
728
|
+
skill_file = File.join(resource.local_path, skill[:folder_path], "SKILL.md")
|
|
729
|
+
result = SkillValidator.validate(skill_file)
|
|
730
|
+
|
|
731
|
+
updated = Resource.from_h(
|
|
732
|
+
existing.to_h.merge(
|
|
733
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
734
|
+
validation_errors: result[:errors]
|
|
735
|
+
),
|
|
736
|
+
storage_path: storage_path
|
|
737
|
+
)
|
|
738
|
+
manifest.update_resource(updated)
|
|
739
|
+
else
|
|
740
|
+
# Add new skill
|
|
741
|
+
new_resource = Resource.new(
|
|
742
|
+
label: skill_label,
|
|
743
|
+
url: resource.url,
|
|
744
|
+
username: resource.username,
|
|
745
|
+
repo: resource.repo,
|
|
746
|
+
folder: skill[:folder_path],
|
|
747
|
+
type: :repo,
|
|
748
|
+
branch: resource.branch,
|
|
749
|
+
relative_path: skill[:folder_path],
|
|
750
|
+
storage_path: storage_path,
|
|
751
|
+
validation_status: :unvalidated,
|
|
752
|
+
validation_errors: [],
|
|
753
|
+
skill_name: skill[:skill_name],
|
|
754
|
+
is_skill: true
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
skill_file = File.join(resource.local_path, skill[:folder_path], "SKILL.md")
|
|
758
|
+
result = SkillValidator.validate(skill_file)
|
|
759
|
+
|
|
760
|
+
new_resource = Resource.from_h(
|
|
761
|
+
new_resource.to_h.merge(
|
|
762
|
+
validation_status: result[:valid] ? :valid_skill : :invalid_skill,
|
|
763
|
+
validation_errors: result[:errors]
|
|
764
|
+
),
|
|
765
|
+
storage_path: storage_path
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
manifest.add_resource(new_resource)
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def merge_resources(current_resources, imported_resources)
|
|
774
|
+
merged = current_resources.dup
|
|
775
|
+
|
|
776
|
+
imported_resources.each do |imported|
|
|
777
|
+
existing_index = merged.find_index { |r| r[:label] == imported[:label] }
|
|
778
|
+
|
|
779
|
+
if existing_index
|
|
780
|
+
merged[existing_index] = imported
|
|
781
|
+
else
|
|
782
|
+
merged << imported
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
merged
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
end
|