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,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
|