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.
@@ -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