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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AgentsSkillVault
6
+ module Errors
7
+ class DuplicateLabel < Error; end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AgentsSkillVault
6
+ module Errors
7
+ class GitNotInstalled < Error; end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AgentsSkillVault
6
+ module Errors
7
+ class GitVersion < Error; end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AgentsSkillVault
6
+ module Errors
7
+ class InvalidUrl < Error; end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AgentsSkillVault
6
+ module Errors
7
+ class NotFound < Error; end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ module Errors
5
+ end
6
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AgentsSkillVault
6
+ # Provides Git operations for cloning, syncing, and managing repositories.
7
+ #
8
+ # All methods are class methods that execute git commands via the shell.
9
+ # Requires Git 2.25.0+ for sparse checkout support.
10
+ #
11
+ # @example Clone a repository
12
+ # GitOperations.clone_repo("https://github.com/user/repo", "/path/to/target")
13
+ #
14
+ # @example Sparse checkout for a specific folder
15
+ # GitOperations.sparse_checkout(
16
+ # "https://github.com/user/repo",
17
+ # "/path/to/target",
18
+ # branch: "main",
19
+ # paths: ["lib/skills"]
20
+ # )
21
+ #
22
+ class GitOperations
23
+ # Minimum required Git version for sparse checkout support
24
+ MIN_VERSION = "2.25.0"
25
+
26
+ class << self
27
+ # Checks if git is installed and available in PATH.
28
+ #
29
+ # @raise [Errors::GitNotInstalled] if git is not available
30
+ # @return [void]
31
+ #
32
+ def check_git_available!
33
+ return if git_installed?
34
+
35
+ raise Errors::GitNotInstalled, "Git is not installed or not available in PATH"
36
+ end
37
+
38
+ # Checks if the installed git version meets minimum requirements.
39
+ #
40
+ # @raise [Errors::GitVersion] if git version is below 2.25.0
41
+ # @return [void]
42
+ #
43
+ def check_git_version!
44
+ version = git_version
45
+ return unless version < Gem::Version.new(MIN_VERSION)
46
+
47
+ raise Errors::GitVersion, "Git version #{version} is too old. Minimum required: #{MIN_VERSION}"
48
+ end
49
+
50
+ # Clones a git repository.
51
+ #
52
+ # @param url [String] The repository URL to clone
53
+ # @param target_path [String] Local path where the repository will be cloned
54
+ # @param branch [String, nil] Specific branch to clone (optional)
55
+ # @return [void]
56
+ # @raise [Error] if the clone command fails
57
+ #
58
+ # @example Clone with default branch
59
+ # GitOperations.clone_repo("https://github.com/user/repo", "/local/path")
60
+ #
61
+ # @example Clone specific branch
62
+ # GitOperations.clone_repo("https://github.com/user/repo", "/local/path", branch: "develop")
63
+ #
64
+ def clone_repo(url, target_path, branch: nil)
65
+ branch_flag = branch ? "--branch #{branch}" : ""
66
+ run_command("git clone #{branch_flag} #{url} #{target_path}")
67
+ end
68
+
69
+ # Performs a sparse checkout to clone only specific paths.
70
+ #
71
+ # Uses modern git sparse-checkout commands to download only the specified
72
+ # directories or files from the repository (requires Git 2.25.0+).
73
+ #
74
+ # @param url [String] The repository URL
75
+ # @param target_path [String] Local path for the checkout
76
+ # @param branch [String] Branch to checkout
77
+ # @param paths [Array<String>] Paths within the repository to include
78
+ # @return [void]
79
+ # @raise [Error] if any git command fails
80
+ #
81
+ # @example Checkout a single folder
82
+ # GitOperations.sparse_checkout(
83
+ # "https://github.com/user/repo",
84
+ # "/local/path",
85
+ # branch: "main",
86
+ # paths: ["lib/skills/my-skill"]
87
+ # )
88
+ #
89
+ def sparse_checkout(url, target_path, branch:, paths:)
90
+ run_command("git init #{target_path}")
91
+ Dir.chdir(target_path) do
92
+ run_command("git remote add origin #{url}")
93
+ run_command("git sparse-checkout init --no-cone")
94
+ run_command("git sparse-checkout set #{paths.join(" ")}")
95
+ run_command("git fetch origin #{branch}")
96
+ run_command("git checkout #{branch}")
97
+ end
98
+ end
99
+
100
+ # Pulls the latest changes from the remote.
101
+ #
102
+ # Performs a fetch and hard reset to match the remote branch.
103
+ #
104
+ # @param path [String] Path to the local repository
105
+ # @return [void]
106
+ # @raise [Error] if the pull command fails
107
+ #
108
+ def pull(path)
109
+ Dir.chdir(path) do
110
+ run_command("git fetch origin")
111
+ run_command("git reset --hard origin/$(git branch --show-current)")
112
+ end
113
+ end
114
+
115
+ # Gets the current branch name of a repository.
116
+ #
117
+ # @param path [String] Path to the local repository
118
+ # @return [String] The current branch name
119
+ #
120
+ def current_branch(path)
121
+ Dir.chdir(path) do
122
+ stdout, = run_command("git branch --show-current")
123
+ stdout.strip
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def git_installed?
130
+ system("which git > /dev/null 2>&1")
131
+ end
132
+
133
+ def git_version
134
+ stdout, = run_command("git --version")
135
+ version_string = stdout.match(/git version (\d+\.\d+\.\d+)/)[1]
136
+ Gem::Version.new(version_string)
137
+ end
138
+
139
+ def run_command(command)
140
+ stdout, stderr, status = Open3.capture3(command)
141
+
142
+ raise Error, "Command failed: #{command}\n#{stderr}" unless status.success?
143
+
144
+ [stdout, stderr]
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentsSkillVault
6
+ # Manages the JSON manifest file that tracks all resources in a vault.
7
+ #
8
+ # The manifest stores metadata about each resource including labels, URLs,
9
+ # and sync timestamps. It persists to disk as a JSON file.
10
+ #
11
+ # @example
12
+ # manifest = Manifest.new(path: "/vault/manifest.json")
13
+ # manifest.add_resource(resource)
14
+ # manifest.find_resource("user/repo")
15
+ #
16
+ class Manifest
17
+ # Current manifest file format version
18
+ VERSION = "1.0"
19
+
20
+ # @return [String] Absolute path to the manifest JSON file
21
+ attr_reader :path
22
+
23
+ # Creates a new Manifest instance.
24
+ #
25
+ # @param path [String] Path to the manifest JSON file
26
+ #
27
+ def initialize(path:)
28
+ @path = path
29
+ end
30
+
31
+ # Loads the manifest data from disk.
32
+ #
33
+ # @return [Hash] The manifest data with :version and :resources keys
34
+ #
35
+ def load
36
+ return default_manifest unless File.exist?(path)
37
+
38
+ JSON.parse(File.read(path), symbolize_names: true)
39
+ end
40
+
41
+ # Saves manifest data to disk.
42
+ #
43
+ # @param data [Hash] The manifest data to save
44
+ # @return [void]
45
+ #
46
+ def save(data)
47
+ File.write(path, JSON.pretty_generate(data))
48
+ end
49
+
50
+ # Returns all resource hashes from the manifest.
51
+ #
52
+ # @return [Array<Hash>] Array of resource attribute hashes
53
+ #
54
+ def resources
55
+ load[:resources] || []
56
+ end
57
+
58
+ # Adds a new resource to the manifest.
59
+ #
60
+ # @param resource [Resource] The resource to add
61
+ # @raise [Errors::DuplicateLabel] if a resource with the same label exists
62
+ # @return [void]
63
+ #
64
+ def add_resource(resource)
65
+ data = load
66
+ data[:resources] ||= []
67
+
68
+ if data[:resources].any? { |r| r[:label] == resource.label }
69
+ raise Errors::DuplicateLabel, "Resource with label '#{resource.label}' already exists. " \
70
+ "Use a custom label with: add(url, label: 'custom-label')"
71
+ end
72
+
73
+ data[:resources] << resource.to_h
74
+ save(data)
75
+ end
76
+
77
+ # Removes a resource from the manifest by label.
78
+ #
79
+ # Does nothing if the label doesn't exist (no error raised).
80
+ #
81
+ # @param label [String] The label of the resource to remove
82
+ # @return [void]
83
+ #
84
+ def remove_resource(label)
85
+ data = load
86
+ data[:resources]&.reject! { |r| r[:label] == label }
87
+ save(data)
88
+ end
89
+
90
+ # Updates an existing resource in the manifest.
91
+ #
92
+ # @param resource [Resource] The resource with updated attributes
93
+ # @return [Boolean] true if the resource was found and updated, false otherwise
94
+ #
95
+ def update_resource(resource)
96
+ data = load
97
+ data[:resources] ||= []
98
+ index = data[:resources].find_index { |r| r[:label] == resource.label }
99
+
100
+ return false unless index
101
+
102
+ data[:resources][index] = resource.to_h
103
+ save(data)
104
+ true
105
+ end
106
+
107
+ # Finds a resource by its label.
108
+ #
109
+ # @param label [String] The label to search for
110
+ # @param storage_path [String, nil] Storage path to set on the returned Resource
111
+ # @return [Resource, nil] The resource if found, nil otherwise
112
+ #
113
+ def find_resource(label, storage_path: nil)
114
+ hash = resources.find { |r| r[:label] == label }
115
+ return nil unless hash
116
+
117
+ Resource.from_h(hash, storage_path: storage_path || storage_path_from_manifest)
118
+ end
119
+
120
+ # Clears all resources from the manifest.
121
+ #
122
+ # @return [void]
123
+ #
124
+ def clear
125
+ save(default_manifest)
126
+ end
127
+
128
+ private
129
+
130
+ def default_manifest
131
+ { version: VERSION, resources: [] }
132
+ end
133
+
134
+ def storage_path_from_manifest
135
+ File.dirname(path)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module AgentsSkillVault
6
+ # Represents a GitHub resource tracked in the vault.
7
+ #
8
+ # A Resource can be a full repository, a folder within a repository,
9
+ # or a single file. It tracks metadata like the source URL, local path,
10
+ # and sync timestamps.
11
+ #
12
+ # @example Create a repository resource
13
+ # resource = Resource.new(
14
+ # label: "user/repo",
15
+ # url: "https://github.com/user/repo",
16
+ # username: "user",
17
+ # repo: "repo",
18
+ # type: :repo,
19
+ # storage_path: "/path/to/vault"
20
+ # )
21
+ #
22
+ class Resource
23
+ # @return [String] Unique identifier for this resource in the vault
24
+ attr_reader :label
25
+
26
+ # @return [String] GitHub URL of the repository
27
+ attr_reader :url
28
+
29
+ # @return [String] GitHub username/organization that owns the repository
30
+ attr_reader :username
31
+
32
+ # @return [String] Name of the repository
33
+ attr_reader :repo
34
+
35
+ # @return [String, nil] Folder name for folder/file resources
36
+ attr_reader :folder
37
+
38
+ # @return [Symbol] Type of resource (:repo, :folder, or :file)
39
+ attr_reader :type
40
+
41
+ # @return [String] Git branch name
42
+ attr_reader :branch
43
+
44
+ # @return [String, nil] Path relative to repository root (for folder/file resources)
45
+ attr_reader :relative_path
46
+
47
+ # @return [Time] When the resource was added to the vault
48
+ attr_reader :added_at
49
+
50
+ # @return [Time] When the resource was last synced
51
+ attr_reader :synced_at
52
+
53
+ # @return [String, nil] Base path where resources are stored
54
+ attr_reader :storage_path
55
+
56
+ # @return [Symbol] Validation status (:valid_skill, :invalid_skill, :not_a_skill, :unvalidated)
57
+ attr_reader :validation_status
58
+
59
+ # @return [Array<String>] Validation errors (for invalid skills)
60
+ attr_reader :validation_errors
61
+
62
+ # @return [String, nil] The name of the skill (nil for non-skill resources)
63
+ attr_reader :skill_name
64
+
65
+ # @return [Boolean] Whether this resource is a skill
66
+ attr_reader :is_skill
67
+
68
+ # Creates a new Resource instance.
69
+ #
70
+ # @param label [String] Unique identifier for this resource
71
+ # @param url [String] GitHub URL of the repository
72
+ # @param username [String] GitHub username/organization
73
+ # @param repo [String] Repository name
74
+ # @param type [Symbol] Resource type (:repo, :folder, or :file)
75
+ # @param storage_path [String, nil] Base path for local storage
76
+ # @param folder [String, nil] Folder name for non-repo resources
77
+ # @param branch [String, nil] Git branch (defaults to "main")
78
+ # @param relative_path [String, nil] Path relative to repository root
79
+ # @param added_at [Time, nil] When added (defaults to now)
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
85
+ #
86
+ 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)
89
+ @label = label
90
+ @url = url
91
+ @username = username
92
+ @repo = repo
93
+ @folder = folder
94
+ @type = type
95
+ @branch = branch || "main"
96
+ @relative_path = relative_path
97
+ @added_at = added_at || Time.now
98
+ @synced_at = synced_at || Time.now
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
104
+ end
105
+
106
+ # Returns the local filesystem path where the resource is stored.
107
+ #
108
+ # @return [String, nil] Absolute path to the resource, or nil if no storage_path
109
+ #
110
+ # @example
111
+ # resource.local_path
112
+ # # => "/path/to/vault/user/repo"
113
+ #
114
+ def local_path
115
+ return nil unless storage_path
116
+
117
+ if type == :repo
118
+ File.join(storage_path, username, repo)
119
+ else
120
+ File.join(storage_path, username, repo, relative_path || folder || "")
121
+ end
122
+ end
123
+
124
+ # Converts the resource to a hash for serialization.
125
+ #
126
+ # @return [Hash] Resource attributes as a hash
127
+ #
128
+ def to_h
129
+ {
130
+ label: label,
131
+ url: url,
132
+ username: username,
133
+ repo: repo,
134
+ folder: folder,
135
+ type: type,
136
+ branch: branch,
137
+ relative_path: relative_path,
138
+ added_at: added_at.iso8601,
139
+ synced_at: synced_at.iso8601,
140
+ validation_status: validation_status,
141
+ validation_errors: validation_errors,
142
+ skill_name: skill_name,
143
+ is_skill: is_skill
144
+ }
145
+ end
146
+
147
+ # Compares two resources for equality.
148
+ #
149
+ # Two resources are equal if they have the same label, URL, username,
150
+ # repo, folder, type, branch, and relative_path.
151
+ #
152
+ # @param other [Object] The object to compare with
153
+ # @return [Boolean] true if resources are equal
154
+ #
155
+ def ==(other)
156
+ return false unless other.is_a?(Resource)
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
170
+ end
171
+
172
+ # Creates a Resource from a hash.
173
+ #
174
+ # Supports both symbol and string keys for compatibility with
175
+ # different JSON parsing options.
176
+ #
177
+ # @param hash [Hash] Resource attributes as a hash
178
+ # @param storage_path [String, nil] Base path for local storage
179
+ # @return [Resource] A new Resource instance
180
+ #
181
+ # @example
182
+ # Resource.from_h({ label: "user/repo", ... }, storage_path: "/vault")
183
+ #
184
+ 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"] || []
196
+
197
+ new(
198
+ label: hash[:label] || hash["label"],
199
+ url: hash[:url] || hash["url"],
200
+ username: hash[:username] || hash["username"],
201
+ repo: hash[:repo] || hash["repo"],
202
+ folder: hash[:folder] || hash["folder"],
203
+ type: (hash[:type] || hash["type"]).to_sym,
204
+ branch: hash[:branch] || hash["branch"],
205
+ relative_path: hash[:relative_path] || hash["relative_path"] || hash["path_in_repo"] || hash["path_in_repo"],
206
+ 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
+ )
214
+ end
215
+
216
+ # Parses a time value from various formats.
217
+ #
218
+ # @param time_value [String, Time, nil] The time value to parse
219
+ # @return [Time, nil] Parsed Time object, or nil if input was nil
220
+ # @raise [ArgumentError] if time_value is not a valid type
221
+ #
222
+ def self.parse_time(time_value)
223
+ case time_value
224
+ when nil
225
+ nil
226
+ when String
227
+ Time.iso8601(time_value)
228
+ when Time
229
+ time_value
230
+ else
231
+ raise ArgumentError, "Invalid time value: #{time_value.inspect}"
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsSkillVault
4
+ # Scans directories for SKILL.md files.
5
+ #
6
+ # Recursively searches for SKILL.md files and extracts metadata
7
+ # including the skill name and folder path.
8
+ #
9
+ # @example Scan a directory for skills
10
+ # skills = SkillScanner.scan_directory("/path/to/repo")
11
+ # skills.each do |skill|
12
+ # puts "Found skill: #{skill[:skill_name]} at #{skill[:relative_path]}"
13
+ # end
14
+ #
15
+ class SkillScanner
16
+ SKILL_FILE_NAME = "SKILL.md"
17
+
18
+ # Scans a directory for all SKILL.md files.
19
+ #
20
+ # @param base_path [String] The base directory to scan
21
+ # @return [Array<Hash>] Array of skill metadata with keys:
22
+ # - :relative_path [String] Path relative to base_path
23
+ # - :skill_folder [String] Full folder path containing SKILL.md
24
+ # - :skill_name [String] The name of the skill (parent folder name)
25
+ # - :folder_path [String] Alias for skill_folder
26
+ #
27
+ # @raise [ArgumentError] if base_path is nil or doesn't exist
28
+ #
29
+ def self.scan_directory(base_path)
30
+ raise ArgumentError, "base_path cannot be nil" if base_path.nil?
31
+ raise ArgumentError, "base_path does not exist: #{base_path}" unless Dir.exist?(base_path)
32
+
33
+ skills = []
34
+ scan_recursive(base_path, base_path, skills)
35
+ skills
36
+ end
37
+
38
+ # Recursively scans directory for SKILL.md files.
39
+ #
40
+ # @param current_dir [String] Current directory being scanned
41
+ # @param base_path [String] Original base path for relative path calculation
42
+ # @param skills [Array] Array to collect found skills
43
+ #
44
+ def self.scan_recursive(current_dir, base_path, skills)
45
+ # Check if current directory has a SKILL.md file
46
+ skill_file = File.join(current_dir, SKILL_FILE_NAME)
47
+ skills << extract_skill_metadata(skill_file, base_path) if File.exist?(skill_file)
48
+
49
+ # Scan subdirectories
50
+ Dir.glob(File.join(current_dir, "*")).each do |entry|
51
+ scan_recursive(entry, base_path, skills) if File.directory?(entry)
52
+ end
53
+ end
54
+
55
+ # Extracts skill metadata from a SKILL.md file path.
56
+ #
57
+ # @param skill_file_path [String] Full path to SKILL.md file
58
+ # @param base_path [String] Base path for calculating relative paths
59
+ # @return [Hash] Skill metadata
60
+ #
61
+ def self.extract_skill_metadata(skill_file_path, base_path)
62
+ skill_dir = File.dirname(skill_file_path)
63
+ skill_name = File.basename(skill_dir)
64
+ relative_path = Pathname.new(skill_dir).relative_path_from(Pathname.new(base_path)).to_s
65
+
66
+ {
67
+ relative_path:,
68
+ skill_folder: relative_path,
69
+ skill_name:,
70
+ folder_path: relative_path
71
+ }
72
+ end
73
+ end
74
+ end