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