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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b05e0650894ce571b50930b4aac065e83d6581483a0598f16e013438b6072b90
4
- data.tar.gz: d4eb75579062ff3f3b40b3c4a86afc73a5ff09a032507c4197ee84719eb328c4
3
+ metadata.gz: b339666f25ca1e000fff3b11d41645fa5bbab46427c90c80c7cae4b12642558e
4
+ data.tar.gz: 72692513935d521f5e478775eacd31a1e3cc79850f2b1381756671345ff0c06e
5
5
  SHA512:
6
- metadata.gz: 83157f20b44848507c3704957254d7fa7498054aeafdfdff1c3d1e59f465f2e2bee67b3c9b4f9aa35b2723e56331479695b5ac936378ea21ca9303ef14693d33
7
- data.tar.gz: 4735da42f45aebb580afd1cb9cd205bf09260b6e80e8196f8afb5d9626edd2004a2c6c3397efe55763b2847719b556d7ecf8acc0c003baaddc3c27ae933f2b31
6
+ metadata.gz: c4fe6bd713ef790c672b153cf57b4297dbad448d52c2534a6ed50ee685a4d568c451edd66f774b00f1af502a79417d39d6bd1f605901e42ec732630302d85d6d
7
+ data.tar.gz: c53947f7a8f7aaea9ca83b1b382d18dfa238149110d4c93b71aefb3599b96cbebdfd18fdc7dbe6885a508747de3c5433a2da1f035004e946b616a88b8d43f13d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-01-29
4
+
5
+ ### Breaking Changes
6
+
7
+ - Renamed `UrlParser::ParseResult#is_skill_file?` to `skill_file?` (follows Ruby naming conventions)
8
+
9
+ ### Refactoring
10
+
11
+ - Decomposed `Vault` class into 4 included modules:
12
+ - `Vault::ResourceAdder` - resource addition logic
13
+ - `Vault::ResourceSyncer` - resource sync logic
14
+ - `Vault::ResourceValidator` - resource validation logic
15
+ - `Vault::ManifestOperations` - manifest import/export operations
16
+ - Reduced `Vault` class from 450 to ~150 lines
17
+ - Extracted helper methods to keep all methods under 20 lines
18
+ - Reduced `Resource#initialize` parameter count from 15 to 11 using `**validation_attrs` splat
19
+ - Simplified `Resource#==` method using `equality_attributes` helper
20
+ - Extracted `Resource.from_h` logic into `extract_core_attrs` and `extract_validation_attrs` helpers
21
+ - Refactored `SkillValidator.validate` method by extracting `build_skill_data` and `validate_fields` helpers
22
+ - Split `UrlParser.parse_path_segments` into smaller methods: `parse_tree_segments`, `parse_blob_segments`, `default_path_data`, `parse_skill_file_segments`
23
+
24
+ ### Code Quality
25
+
26
+ - All 33 RuboCop offenses resolved (0 remaining)
27
+ - All methods under 20 lines (excl. tests)
28
+ - All classes under 150 lines (excl. tests)
29
+ - Reduced parameter counts to meet style guidelines
30
+
31
+ ## [0.1.0] - 2026-01-25
32
+
3
33
  ## [0.1.0] - 2026-01-25
4
34
 
5
35
  ### Features
@@ -92,7 +92,7 @@ module AgentsSkillVault
92
92
  # @param resource [Resource] The resource with updated attributes
93
93
  # @return [Boolean] true if the resource was found and updated, false otherwise
94
94
  #
95
- def update_resource(resource)
95
+ def update_resource(resource) # rubocop:disable Naming/PredicateMethod
96
96
  data = load
97
97
  data[:resources] ||= []
98
98
  index = data[:resources].find_index { |r| r[:label] == resource.label }
@@ -78,14 +78,14 @@ module AgentsSkillVault
78
78
  # @param relative_path [String, nil] Path relative to repository root
79
79
  # @param added_at [Time, nil] When added (defaults to now)
80
80
  # @param synced_at [Time, nil] When last synced (defaults to now)
81
- # @param validation_status [Symbol, nil] Validation status
82
- # @param validation_errors [Array, nil] Validation errors
83
- # @param skill_name [String, nil] The name of the skill
84
- # @param is_skill [Boolean, nil] Whether this is a skill
81
+ # @param validation_attrs [Hash] Validation-related attributes
82
+ # @option validation_attrs [Symbol] :validation_status Validation status (default: :unvalidated)
83
+ # @option validation_attrs [Array] :validation_errors Validation errors (default: [])
84
+ # @option validation_attrs [String] :skill_name The name of the skill (default: nil)
85
+ # @option validation_attrs [Boolean] :is_skill Whether this is a skill (default: derived from skill_name)
85
86
  #
86
87
  def initialize(label:, url:, username:, repo:, type:, storage_path:, folder: nil, branch: nil,
87
- relative_path: nil, added_at: nil, synced_at: nil, validation_status: :unvalidated,
88
- validation_errors: [], skill_name: nil, is_skill: nil)
88
+ relative_path: nil, added_at: nil, synced_at: nil, **validation_attrs)
89
89
  @label = label
90
90
  @url = url
91
91
  @username = username
@@ -97,10 +97,10 @@ module AgentsSkillVault
97
97
  @added_at = added_at || Time.now
98
98
  @synced_at = synced_at || Time.now
99
99
  @storage_path = storage_path
100
- @validation_status = validation_status
101
- @validation_errors = validation_errors || []
102
- @skill_name = skill_name
103
- @is_skill = is_skill.nil? ? !skill_name.nil? : is_skill
100
+ @validation_status = validation_attrs.fetch(:validation_status, :unvalidated)
101
+ @validation_errors = validation_attrs.fetch(:validation_errors, [])
102
+ @skill_name = validation_attrs[:skill_name]
103
+ @is_skill = validation_attrs.fetch(:is_skill, !@skill_name.nil?)
104
104
  end
105
105
 
106
106
  # Returns the local filesystem path where the resource is stored.
@@ -155,18 +155,16 @@ module AgentsSkillVault
155
155
  def ==(other)
156
156
  return false unless other.is_a?(Resource)
157
157
 
158
- label == other.label &&
159
- url == other.url &&
160
- username == other.username &&
161
- repo == other.repo &&
162
- folder == other.folder &&
163
- type == other.type &&
164
- branch == other.branch &&
165
- relative_path == other.relative_path &&
166
- validation_status == other.validation_status &&
167
- validation_errors == other.validation_errors &&
168
- skill_name == other.skill_name &&
169
- is_skill == other.is_skill
158
+ equality_attributes.all? { |attr| public_send(attr) == other.public_send(attr) }
159
+ end
160
+
161
+ # Returns list of attributes used for equality comparison.
162
+ #
163
+ # @return [Array<Symbol>] Attribute names to compare
164
+ #
165
+ def equality_attributes
166
+ %i[label url username repo folder type branch relative_path
167
+ validation_status validation_errors skill_name is_skill]
170
168
  end
171
169
 
172
170
  # Creates a Resource from a hash.
@@ -182,19 +180,16 @@ module AgentsSkillVault
182
180
  # Resource.from_h({ label: "user/repo", ... }, storage_path: "/vault")
183
181
  #
184
182
  def self.from_h(hash, storage_path: nil)
185
- # Handle backward compatibility: if validation_status is missing, default to :unvalidated
186
- validation_status = hash[:validation_status] || hash["validation_status"] || :unvalidated
187
- validation_status = validation_status.to_sym if validation_status.is_a?(String)
188
-
189
- # Handle backward compatibility: if is_skill is missing, derive from skill_name
190
- is_skill = hash[:is_skill] || hash["is_skill"]
191
- skill_name = hash[:skill_name] || hash["skill_name"]
192
- is_skill ||= !skill_name.nil?
193
-
194
- # Handle backward compatibility: if validation_errors is missing, default to empty array
195
- validation_errors = hash[:validation_errors] || hash["validation_errors"] || []
183
+ new(**extract_core_attrs(hash), **extract_validation_attrs(hash), storage_path:)
184
+ end
196
185
 
197
- new(
186
+ # Extracts core resource attributes from a hash.
187
+ #
188
+ # @param hash [Hash] Resource attributes as a hash
189
+ # @return [Hash] Core attributes with proper defaults
190
+ #
191
+ private_class_method def self.extract_core_attrs(hash)
192
+ {
198
193
  label: hash[:label] || hash["label"],
199
194
  url: hash[:url] || hash["url"],
200
195
  username: hash[:username] || hash["username"],
@@ -202,15 +197,37 @@ module AgentsSkillVault
202
197
  folder: hash[:folder] || hash["folder"],
203
198
  type: (hash[:type] || hash["type"]).to_sym,
204
199
  branch: hash[:branch] || hash["branch"],
205
- relative_path: hash[:relative_path] || hash["relative_path"] || hash["path_in_repo"] || hash["path_in_repo"],
200
+ relative_path: extract_relative_path(hash),
206
201
  added_at: parse_time(hash[:added_at] || hash["added_at"]),
207
- synced_at: parse_time(hash[:synced_at] || hash["synced_at"]),
208
- validation_status:,
209
- validation_errors:,
210
- skill_name:,
211
- is_skill:,
212
- storage_path:
213
- )
202
+ synced_at: parse_time(hash[:synced_at] || hash["synced_at"])
203
+ }
204
+ end
205
+
206
+ # Extracts relative path with backward compatibility.
207
+ #
208
+ # @param hash [Hash] Resource attributes
209
+ # @return [String, nil] Relative path
210
+ #
211
+ private_class_method def self.extract_relative_path(hash)
212
+ hash[:relative_path] || hash["relative_path"] || hash["path_in_repo"] || hash["path_in_repo"]
213
+ end
214
+
215
+ # Extracts validation-related attributes from a hash with backward compatibility.
216
+ #
217
+ # @param hash [Hash] Resource attributes as a hash
218
+ # @return [Hash] Validation attributes with proper defaults
219
+ #
220
+ private_class_method def self.extract_validation_attrs(hash)
221
+ validation_status = hash[:validation_status] || hash["validation_status"] || :unvalidated
222
+ validation_status = validation_status.to_sym if validation_status.is_a?(String)
223
+
224
+ is_skill = hash[:is_skill] || hash["is_skill"]
225
+ skill_name = hash[:skill_name] || hash["skill_name"]
226
+ is_skill ||= !skill_name.nil?
227
+
228
+ validation_errors = hash[:validation_errors] || hash["validation_errors"] || []
229
+
230
+ { validation_status:, skill_name:, is_skill:, validation_errors: }
214
231
  end
215
232
 
216
233
  # Parses a time value from various formats.
@@ -62,29 +62,9 @@ module AgentsSkillVault
62
62
  parsed_data = parse_yaml(frontmatter, errors)
63
63
  return { valid: false, errors:, skill_data: {} } if parsed_data.nil?
64
64
 
65
- # Validate required fields
66
- validate_required_fields(parsed_data, errors)
67
-
68
- # Validate name field
69
- validate_name(parsed_data, errors) if parsed_data["name"]
70
-
71
- # Validate description field
72
- validate_description(parsed_data, errors) if parsed_data["description"]
73
-
74
- # Validate optional fields if present
75
- validate_optional_fields(parsed_data, errors)
76
-
77
- # Build skill data hash
78
- skill_data = {
79
- name: parsed_data["name"],
80
- description: parsed_data["description"],
81
- license: parsed_data["license"],
82
- compatibility: parsed_data["compatibility"],
83
- metadata: parsed_data["metadata"],
84
- allowed_tools: parsed_data["allowed-tools"]
85
- }
65
+ validate_fields(parsed_data, errors)
86
66
 
87
- { valid: errors.empty?, errors:, skill_data: }
67
+ { valid: errors.empty?, errors:, skill_data: build_skill_data(parsed_data) }
88
68
  end
89
69
 
90
70
  # Extracts YAML frontmatter from content.
@@ -144,7 +124,8 @@ module AgentsSkillVault
144
124
 
145
125
  return if name.match?(NAME_PATTERN)
146
126
 
147
- errors << "Field 'name' must contain only lowercase letters, numbers, and hyphens (no consecutive or leading/trailing hyphens)"
127
+ errors << "Field 'name' must contain only lowercase letters, numbers, and hyphens " \
128
+ "(no consecutive or leading/trailing hyphens)"
148
129
  end
149
130
 
150
131
  # Validates the description field.
@@ -197,5 +178,33 @@ module AgentsSkillVault
197
178
 
198
179
  errors << "Field 'allowed-tools' must be a string"
199
180
  end
181
+
182
+ # Validates all fields for the parsed data.
183
+ #
184
+ # @param data [Hash] Parsed YAML data
185
+ # @param errors [Array] Array to append errors to
186
+ #
187
+ def self.validate_fields(data, errors)
188
+ validate_required_fields(data, errors)
189
+ validate_name(data, errors) if data["name"]
190
+ validate_description(data, errors) if data["description"]
191
+ validate_optional_fields(data, errors)
192
+ end
193
+
194
+ # Builds skill data hash from parsed YAML.
195
+ #
196
+ # @param data [Hash] Parsed YAML data
197
+ # @return [Hash] Skill data with symbolized keys
198
+ #
199
+ def self.build_skill_data(data)
200
+ {
201
+ name: data["name"],
202
+ description: data["description"],
203
+ license: data["license"],
204
+ compatibility: data["compatibility"],
205
+ metadata: data["metadata"],
206
+ allowed_tools: data["allowed-tools"]
207
+ }
208
+ end
200
209
  end
201
210
  end
@@ -82,7 +82,7 @@ module AgentsSkillVault
82
82
  def label
83
83
  if type == :repo
84
84
  "#{username}/#{repo}"
85
- elsif is_skill_file?
85
+ elsif skill_file?
86
86
  "#{username}/#{repo}/#{skill_name}"
87
87
  else
88
88
  parts = relative_path&.split("/") || []
@@ -95,7 +95,7 @@ module AgentsSkillVault
95
95
  #
96
96
  # @return [Boolean] true if this is a SKILL.md file URL
97
97
  #
98
- def is_skill_file?
98
+ def skill_file?
99
99
  type == :file && skill_name && !skill_name.empty?
100
100
  end
101
101
  end
@@ -156,52 +156,81 @@ module AgentsSkillVault
156
156
  # @return [PathSegmentsData] Structured data with type, branch, and relative_path
157
157
  #
158
158
  private_class_method def self.parse_path_segments(segments)
159
- if segments.length <= 2
160
- return PathSegmentsData.new(type: :repo, branch: "main", relative_path: nil, skill_name: nil,
161
- skill_folder_path: nil)
162
- end
159
+ return default_path_data if segments.length <= 2
163
160
 
164
161
  case segments[2]
165
- when "tree"
166
- branch = segments[3] || "main"
167
- path_parts = segments[4..] || []
168
- if path_parts.empty?
169
- PathSegmentsData.new(type: :repo, branch: branch, relative_path: nil, skill_name: nil, skill_folder_path: nil)
170
- else
171
- PathSegmentsData.new(type: :folder, branch: branch, relative_path: path_parts.join("/"), skill_name: nil,
172
- skill_folder_path: nil)
173
- end
174
- when "blob"
175
- branch = segments[3] || "main"
176
- path_parts = segments[4..] || []
177
- relative_path = path_parts.join("/")
178
-
179
- # Check if this is a SKILL.md file
180
- filename = path_parts.last
181
- if filename == "SKILL.md"
182
- # Extract skill name from parent folder
183
- if path_parts.length >= 2
184
- skill_name = path_parts[-2]
185
- skill_folder_path = path_parts[0..-2].join("/")
186
- else
187
- # SKILL.md is at root, use repo name as skill name
188
- skill_name = segments[1]
189
- skill_folder_path = "."
190
- end
191
-
192
- PathSegmentsData.new(
193
- type: :file,
194
- branch: branch,
195
- relative_path:,
196
- skill_name:,
197
- skill_folder_path:
198
- )
199
- else
200
- PathSegmentsData.new(type: :file, branch: branch, relative_path:, skill_name: nil, skill_folder_path: nil)
201
- end
162
+ when "tree" then parse_tree_segments(segments)
163
+ when "blob" then parse_blob_segments(segments)
164
+ else default_path_data
165
+ end
166
+ end
167
+
168
+ # Returns the default PathSegmentsData for a repository URL.
169
+ #
170
+ # @return [PathSegmentsData] Default path data
171
+ #
172
+ private_class_method def self.default_path_data
173
+ PathSegmentsData.new(type: :repo, branch: "main", relative_path: nil, skill_name: nil, skill_folder_path: nil)
174
+ end
175
+
176
+ # Parses tree (folder) URL segments.
177
+ #
178
+ # @param segments [Array<String>] The split path segments
179
+ # @return [PathSegmentsData] Path data for tree URLs
180
+ #
181
+ private_class_method def self.parse_tree_segments(segments)
182
+ branch = segments[3] || "main"
183
+ path_parts = segments[4..] || []
184
+ if path_parts.empty?
185
+ PathSegmentsData.new(type: :repo, branch: branch, relative_path: nil, skill_name: nil, skill_folder_path: nil)
202
186
  else
203
- PathSegmentsData.new(type: :repo, branch: "main", relative_path: nil, skill_name: nil, skill_folder_path: nil)
187
+ PathSegmentsData.new(type: :folder, branch: branch, relative_path: path_parts.join("/"), skill_name: nil,
188
+ skill_folder_path: nil)
204
189
  end
205
190
  end
191
+
192
+ # Parses blob (file) URL segments.
193
+ #
194
+ # @param segments [Array<String>] The split path segments
195
+ # @return [PathSegmentsData] Path data for blob URLs
196
+ #
197
+ private_class_method def self.parse_blob_segments(segments)
198
+ branch = segments[3] || "main"
199
+ path_parts = segments[4..] || []
200
+ relative_path = path_parts.join("/")
201
+ filename = path_parts.last
202
+
203
+ if filename == "SKILL.md"
204
+ parse_skill_file_segments(segments, branch, path_parts, relative_path)
205
+ else
206
+ PathSegmentsData.new(type: :file, branch: branch, relative_path:, skill_name: nil, skill_folder_path: nil)
207
+ end
208
+ end
209
+
210
+ # Parses SKILL.md file segments to extract skill name and folder path.
211
+ #
212
+ # @param segments [Array<String>] The split path segments
213
+ # @param branch [String] Branch name
214
+ # @param path_parts [Array<String>] Path parts after branch
215
+ # @param relative_path [String] Full relative path
216
+ # @return [PathSegmentsData] Path data for SKILL.md files
217
+ #
218
+ private_class_method def self.parse_skill_file_segments(segments, branch, path_parts, relative_path)
219
+ if path_parts.length >= 2
220
+ skill_name = path_parts[-2]
221
+ skill_folder_path = path_parts[0..-2].join("/")
222
+ else
223
+ skill_name = segments[1]
224
+ skill_folder_path = "."
225
+ end
226
+
227
+ PathSegmentsData.new(
228
+ type: :file,
229
+ branch: branch,
230
+ relative_path:,
231
+ skill_name:,
232
+ skill_folder_path:
233
+ )
234
+ end
206
235
  end
207
236
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ class Vault
5
+ # Module for manifest operations in vault.
6
+ #
7
+ module ManifestOperations
8
+ # Exports manifest to a file for backup or sharing.
9
+ #
10
+ # @param export_path [String] Path where manifest should be exported
11
+ # @return [void]
12
+ #
13
+ # @example
14
+ # vault.export_manifest("/backups/manifest.json")
15
+ #
16
+ def export_manifest(export_path)
17
+ FileUtils.cp(@manifest_path, export_path)
18
+ end
19
+
20
+ # Imports and merges a manifest from another vault.
21
+ #
22
+ # Resources from imported manifest are merged with existing resources.
23
+ # If a label exists in both, imported resource overwrites existing one.
24
+ # Note: This only updates manifest; files are not downloaded automatically.
25
+ #
26
+ # @param import_path [String] Path to manifest file to import
27
+ # @return [void]
28
+ #
29
+ # @example
30
+ # vault.import_manifest("/shared/manifest.json")
31
+ # vault.redownload_all # Download imported resources
32
+ #
33
+ def import_manifest(import_path)
34
+ imported_data = JSON.parse(File.read(import_path), symbolize_names: true)
35
+ current_data = manifest.load
36
+
37
+ merged_resources = merge_resources(current_data[:resources] || [], imported_data[:resources] || [])
38
+
39
+ manifest.save(version: Manifest::VERSION, resources: merged_resources)
40
+ end
41
+
42
+ # Re-downloads all resources from scratch.
43
+ #
44
+ # Deletes local files and performs a fresh clone/checkout for each resource.
45
+ # Useful for recovering from corruption or ensuring a clean state.
46
+ #
47
+ # @return [void]
48
+ #
49
+ def redownload_all
50
+ list.each do |resource|
51
+ FileUtils.rm_rf(resource.local_path) if resource.local_path
52
+ download_resource(resource)
53
+ updated_resource = Resource.from_h(resource.to_h.merge(synced_at: Time.now), storage_path: storage_path)
54
+ manifest.update_resource(updated_resource)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Merges current and imported resources.
61
+ #
62
+ # @param current_resources [Array<Hash>] Current resource hashes
63
+ # @param imported_resources [Array<Hash>] Imported resource hashes
64
+ # @return [Array<Hash>] Merged resources
65
+ #
66
+ def merge_resources(current_resources, imported_resources)
67
+ merged = current_resources.dup
68
+
69
+ imported_resources.each do |imported|
70
+ existing_index = merged.find_index { |r| r[:label] == imported[:label] }
71
+
72
+ if existing_index
73
+ merged[existing_index] = imported
74
+ else
75
+ merged << imported
76
+ end
77
+ end
78
+
79
+ merged
80
+ end
81
+ end
82
+ end
83
+ end