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,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module AgentsSkillVault
6
+ # Validates SKILL.md files against the Agent Skills specification.
7
+ #
8
+ # Checks for required fields, field formats, and constraints.
9
+ #
10
+ # @example Validate a skill file
11
+ # result = SkillValidator.validate("/path/to/SKILL.md")
12
+ # if result[:valid]
13
+ # puts "Valid skill: #{result[:skill_data][:name]}"
14
+ # else
15
+ # puts "Errors: #{result[:errors].join(', ')}"
16
+ # end
17
+ #
18
+ class SkillValidator
19
+ REQUIRED_FIELDS = %w[name description].freeze
20
+ NAME_MAX_LENGTH = 64
21
+ NAME_MIN_LENGTH = 1
22
+ DESCRIPTION_MAX_LENGTH = 1024
23
+ DESCRIPTION_MIN_LENGTH = 1
24
+ COMPATIBILITY_MAX_LENGTH = 500
25
+ NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
26
+
27
+ # Validates a SKILL.md file.
28
+ #
29
+ # @param skill_file_path [String] Path to the SKILL.md file
30
+ # @return [Hash] Validation result with keys:
31
+ # - :valid [Boolean] Whether the skill is valid
32
+ # - :errors [Array<String>] List of validation errors
33
+ # - :skill_data [Hash] Parsed skill data (name, description, license, etc.)
34
+ #
35
+ # @raise [ArgumentError] if skill_file_path is nil or empty
36
+ #
37
+ def self.validate(skill_file_path)
38
+ raise ArgumentError, "skill_file_path cannot be nil" if skill_file_path.nil?
39
+ raise ArgumentError, "skill_file_path cannot be empty" if skill_file_path.empty?
40
+
41
+ errors = []
42
+
43
+ # Check file exists and is readable
44
+ unless File.exist?(skill_file_path)
45
+ return { valid: false, errors: ["File does not exist: #{skill_file_path}"], skill_data: {} }
46
+ end
47
+
48
+ unless File.readable?(skill_file_path)
49
+ return { valid: false, errors: ["File is not readable: #{skill_file_path}"], skill_data: {} }
50
+ end
51
+
52
+ content = File.read(skill_file_path)
53
+
54
+ # Parse YAML frontmatter
55
+ frontmatter, = extract_frontmatter(content)
56
+ if frontmatter.nil?
57
+ errors << "No YAML frontmatter found (missing '---' delimiters)"
58
+ return { valid: false, errors:, skill_data: {} }
59
+ end
60
+
61
+ # Parse YAML
62
+ parsed_data = parse_yaml(frontmatter, errors)
63
+ return { valid: false, errors:, skill_data: {} } if parsed_data.nil?
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
+ }
86
+
87
+ { valid: errors.empty?, errors:, skill_data: }
88
+ end
89
+
90
+ # Extracts YAML frontmatter from content.
91
+ #
92
+ # @param content [String] The full content of the file
93
+ # @return [Array<String, String>] Frontmatter and body, or [nil, nil] if not found
94
+ #
95
+ def self.extract_frontmatter(content)
96
+ return [nil, nil] unless content.start_with?("---")
97
+
98
+ parts = content.split(/^---\s*$/)
99
+ return [nil, nil] unless parts.length >= 2
100
+
101
+ [parts[1].strip, parts[2..]&.join("---")]
102
+ end
103
+
104
+ # Parses YAML string.
105
+ #
106
+ # @param yaml_string [String] The YAML string to parse
107
+ # @param errors [Array] Array to append errors to
108
+ # @return [Hash, nil] Parsed data, or nil if parsing fails
109
+ #
110
+ def self.parse_yaml(yaml_string, errors)
111
+ YAML.safe_load(yaml_string)
112
+ rescue Psych::SyntaxError => e
113
+ errors << "Invalid YAML syntax: #{e.message}"
114
+ nil
115
+ end
116
+
117
+ # Validates that all required fields are present.
118
+ #
119
+ # @param data [Hash] Parsed YAML data
120
+ # @param errors [Array] Array to append errors to
121
+ #
122
+ def self.validate_required_fields(data, errors)
123
+ REQUIRED_FIELDS.each do |field|
124
+ errors << "Required field '#{field}' is missing" unless data[field]
125
+ end
126
+ end
127
+
128
+ # Validates the name field.
129
+ #
130
+ # @param data [Hash] Parsed YAML data
131
+ # @param errors [Array] Array to append errors to
132
+ #
133
+ def self.validate_name(data, errors)
134
+ name = data["name"]
135
+
136
+ if name.empty?
137
+ errors << "Field 'name' cannot be empty"
138
+ return
139
+ end
140
+
141
+ errors << "Field 'name' exceeds maximum length of #{NAME_MAX_LENGTH} characters" if name.length > NAME_MAX_LENGTH
142
+
143
+ errors << "Field 'name' must be at least #{NAME_MIN_LENGTH} character" if name.length < NAME_MIN_LENGTH
144
+
145
+ return if name.match?(NAME_PATTERN)
146
+
147
+ errors << "Field 'name' must contain only lowercase letters, numbers, and hyphens (no consecutive or leading/trailing hyphens)"
148
+ end
149
+
150
+ # Validates the description field.
151
+ #
152
+ # @param data [Hash] Parsed YAML data
153
+ # @param errors [Array] Array to append errors to
154
+ #
155
+ def self.validate_description(data, errors)
156
+ description = data["description"]
157
+
158
+ if description.empty?
159
+ errors << "Field 'description' cannot be empty"
160
+ return
161
+ end
162
+
163
+ if description.length > DESCRIPTION_MAX_LENGTH
164
+ errors << "Field 'description' exceeds maximum length of #{DESCRIPTION_MAX_LENGTH} characters"
165
+ end
166
+
167
+ return unless description.length < DESCRIPTION_MIN_LENGTH
168
+
169
+ errors << "Field 'description' must be at least #{DESCRIPTION_MIN_LENGTH} character"
170
+ end
171
+
172
+ # Validates optional fields if present.
173
+ #
174
+ # @param data [Hash] Parsed YAML data
175
+ # @param errors [Array] Array to append errors to
176
+ #
177
+ def self.validate_optional_fields(data, errors)
178
+ # Validate compatibility field if present
179
+ if data["compatibility"]
180
+ compatibility = data["compatibility"]
181
+ if compatibility.length > COMPATIBILITY_MAX_LENGTH
182
+ errors << "Field 'compatibility' exceeds maximum length of #{COMPATIBILITY_MAX_LENGTH} characters"
183
+ end
184
+ end
185
+
186
+ # Validate metadata field if present
187
+ if data["metadata"]
188
+ metadata = data["metadata"]
189
+ errors << "Field 'metadata' must be a hash/object" unless metadata.is_a?(Hash)
190
+ end
191
+
192
+ # Validate allowed-tools field if present
193
+ return unless data["allowed-tools"]
194
+
195
+ allowed_tools = data["allowed-tools"]
196
+ return if allowed_tools.is_a?(String)
197
+
198
+ errors << "Field 'allowed-tools' must be a string"
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ # Immutable result object representing the outcome of a sync operation.
5
+ #
6
+ # Uses Ruby 3.2+ Data.define for a clean, immutable value object.
7
+ #
8
+ # @example Successful sync with changes
9
+ # result = SyncResult.new(success: true, changes: true)
10
+ # result.success? # => true
11
+ # result.changes? # => true
12
+ #
13
+ # @example Failed sync
14
+ # result = SyncResult.new(success: false, error: "Network error")
15
+ # result.success? # => false
16
+ # result.error # => "Network error"
17
+ #
18
+ # @!attribute [r] success
19
+ # @return [Boolean] Whether the sync operation succeeded
20
+ #
21
+ # @!attribute [r] changes
22
+ # @return [Boolean] Whether the sync resulted in changes (default: false)
23
+ #
24
+ # @!attribute [r] error
25
+ # @return [String, nil] Error message if sync failed (default: nil)
26
+ #
27
+ SyncResult = Data.define(:success, :changes, :error) do
28
+ # Creates a new SyncResult with sensible defaults.
29
+ #
30
+ # @param success [Boolean] Whether the sync succeeded
31
+ # @param changes [Boolean] Whether changes occurred (default: false)
32
+ # @param error [String, nil] Error message if failed (default: nil)
33
+ #
34
+ def initialize(success:, changes: false, error: nil)
35
+ super
36
+ end
37
+
38
+ # @return [Boolean] Whether the sync operation succeeded
39
+ def success? = success
40
+
41
+ # @return [Boolean] Whether the sync resulted in changes
42
+ def changes? = changes
43
+ end
44
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "addressable/uri"
4
+
5
+ module AgentsSkillVault
6
+ # Parses GitHub URLs into their component parts.
7
+ #
8
+ # Supports repository URLs, folder URLs (tree), and file URLs (blob).
9
+ #
10
+ # @example Parse a repository URL
11
+ # result = UrlParser.parse("https://github.com/user/repo")
12
+ # result.username # => "user"
13
+ # result.repo # => "repo"
14
+ # result.type # => :repo
15
+ #
16
+ # @example Parse a folder URL
17
+ # result = UrlParser.parse("https://github.com/user/repo/tree/main/lib/skills")
18
+ # result.type # => :folder
19
+ # result.relative_path # => "lib/skills"
20
+ #
21
+ class UrlParser
22
+ GITHUB_HOST = "github.com"
23
+
24
+ # Data object for parsed path segments
25
+ PathSegmentsData = Data.define(:type, :branch, :relative_path, :skill_name, :skill_folder_path)
26
+
27
+ # Result object returned by UrlParser.parse
28
+ #
29
+ # Contains the parsed components of a GitHub URL.
30
+ #
31
+ class ParseResult
32
+ # @return [String] GitHub username or organization
33
+ attr_reader :username
34
+
35
+ # @return [String] Repository name
36
+ attr_reader :repo
37
+
38
+ # @return [String] Branch name (defaults to "main")
39
+ attr_reader :branch
40
+
41
+ # @return [String, nil] Path within the repository (for folder/file URLs)
42
+ attr_reader :relative_path
43
+
44
+ # @return [Symbol] Type of URL (:repo, :folder, or :file)
45
+ attr_reader :type
46
+
47
+ # @return [String, nil] The name of the skill (for SKILL.md file URLs)
48
+ attr_reader :skill_name
49
+
50
+ # @return [String, nil] The full path to the skill folder (for SKILL.md file URLs)
51
+ attr_reader :skill_folder_path
52
+
53
+ # Creates a new ParseResult.
54
+ #
55
+ # @param username [String] GitHub username
56
+ # @param repo [String] Repository name
57
+ # @param type [Symbol] URL type (:repo, :folder, or :file)
58
+ # @param branch [String] Branch name (default: "main")
59
+ # @param relative_path [String, nil] Path within repository
60
+ # @param skill_name [String, nil] The name of the skill (for SKILL.md files)
61
+ # @param skill_folder_path [String, nil] Full path to skill folder (for SKILL.md files)
62
+ #
63
+ def initialize(username:, repo:, type:, branch: "main", relative_path: nil, skill_name: nil,
64
+ skill_folder_path: nil)
65
+ @username = username
66
+ @repo = repo
67
+ @branch = branch
68
+ @relative_path = relative_path
69
+ @type = type
70
+ @skill_name = skill_name
71
+ @skill_folder_path = skill_folder_path
72
+ end
73
+
74
+ # Generates a default label for this URL.
75
+ #
76
+ # For repositories: "username/repo"
77
+ # For SKILL.md files: "username/skill-name"
78
+ # For other folders/files: "username/last-path-component"
79
+ #
80
+ # @return [String] Generated label
81
+ #
82
+ def label
83
+ if type == :repo
84
+ "#{username}/#{repo}"
85
+ elsif is_skill_file?
86
+ "#{username}/#{repo}/#{skill_name}"
87
+ else
88
+ parts = relative_path&.split("/") || []
89
+ folder = parts.last
90
+ "#{username}/#{repo}/#{folder}"
91
+ end
92
+ end
93
+
94
+ # Checks if this URL points to a SKILL.md file.
95
+ #
96
+ # @return [Boolean] true if this is a SKILL.md file URL
97
+ #
98
+ def is_skill_file?
99
+ type == :file && skill_name && !skill_name.empty?
100
+ end
101
+ end
102
+
103
+ # Parses a GitHub URL into its components.
104
+ #
105
+ # Uses Addressable::URI for robust URL parsing with proper encoding/decoding.
106
+ #
107
+ # @param url [String] A GitHub URL (repository, tree, or blob)
108
+ # @return [ParseResult] Parsed URL components
109
+ # @raise [Errors::InvalidUrl] if the URL is not a valid GitHub URL
110
+ #
111
+ # @example Parse different URL types
112
+ # UrlParser.parse("https://github.com/user/repo")
113
+ # UrlParser.parse("https://github.com/user/repo/tree/main/folder")
114
+ # UrlParser.parse("https://github.com/user/repo/blob/main/file.rb")
115
+ #
116
+ # @example Parse URL with encoded characters
117
+ # result = UrlParser.parse("https://github.com/user/repo/tree/main/folder%20name")
118
+ # result.relative_path # => "folder name"
119
+ #
120
+ def self.parse(url)
121
+ uri = Addressable::URI.parse(url)
122
+ validate_github_url!(uri, url)
123
+
124
+ segments = uri.path.sub(%r{^/}, "").split("/").map { |s| Addressable::URI.unencode(s) }
125
+ username = segments[0]
126
+ repo = segments[1]
127
+
128
+ path_data = parse_path_segments(segments)
129
+
130
+ ParseResult.new(
131
+ username: username,
132
+ repo: repo,
133
+ branch: path_data.branch,
134
+ relative_path: path_data.relative_path,
135
+ type: path_data.type,
136
+ skill_name: path_data.skill_name,
137
+ skill_folder_path: path_data.skill_folder_path
138
+ )
139
+ end
140
+
141
+ # Validates that the URI is a GitHub URL with http or https scheme.
142
+ #
143
+ # @param uri [Addressable::URI, nil] The parsed URI
144
+ # @param original_url [String] The original URL string for error messages
145
+ # @raise [Errors::InvalidUrl] if the URL is not a valid GitHub URL
146
+ #
147
+ private_class_method def self.validate_github_url!(uri, original_url)
148
+ return if uri&.host == GITHUB_HOST && %w[http https].include?(uri&.scheme)
149
+
150
+ raise Errors::InvalidUrl, "Invalid GitHub URL: #{original_url}. Expected format: https://github.com/username/repo[/tree/branch/path]"
151
+ end
152
+
153
+ # Parses URL path segments to determine URL type, branch, and relative path.
154
+ #
155
+ # @param segments [Array<String>] The split path segments
156
+ # @return [PathSegmentsData] Structured data with type, branch, and relative_path
157
+ #
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
163
+
164
+ 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
202
+ else
203
+ PathSegmentsData.new(type: :repo, branch: "main", relative_path: nil, skill_name: nil, skill_folder_path: nil)
204
+ end
205
+ end
206
+ end
207
+ end