ruby_llm-skills 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/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +396 -0
- data/lib/generators/skill/skill_generator.rb +60 -0
- data/lib/generators/skill/templates/SKILL.md.tt +21 -0
- data/lib/ruby_llm/skills/chat_extensions.rb +49 -0
- data/lib/ruby_llm/skills/composite_loader.rb +57 -0
- data/lib/ruby_llm/skills/database_loader.rb +147 -0
- data/lib/ruby_llm/skills/error.rb +21 -0
- data/lib/ruby_llm/skills/filesystem_loader.rb +55 -0
- data/lib/ruby_llm/skills/loader.rb +77 -0
- data/lib/ruby_llm/skills/parser.rb +80 -0
- data/lib/ruby_llm/skills/railtie.rb +29 -0
- data/lib/ruby_llm/skills/skill.rb +174 -0
- data/lib/ruby_llm/skills/skill_tool.rb +166 -0
- data/lib/ruby_llm/skills/tasks/skills.rake +110 -0
- data/lib/ruby_llm/skills/validator.rb +108 -0
- data/lib/ruby_llm/skills/version.rb +7 -0
- data/lib/ruby_llm/skills/zip_loader.rb +129 -0
- data/lib/ruby_llm/skills.rb +89 -0
- metadata +66 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Loads skills from database records using duck-typing.
|
|
6
|
+
#
|
|
7
|
+
# Records must respond to either:
|
|
8
|
+
# - Text storage: #name, #description, #content
|
|
9
|
+
# - Binary storage: #name, #description, #data (zip blob)
|
|
10
|
+
#
|
|
11
|
+
# Optional methods: #license, #compatibility, #metadata
|
|
12
|
+
#
|
|
13
|
+
# @example With text content
|
|
14
|
+
# class SkillRecord
|
|
15
|
+
# attr_accessor :name, :description, :content
|
|
16
|
+
# end
|
|
17
|
+
# loader = DatabaseLoader.new(SkillRecord.all)
|
|
18
|
+
#
|
|
19
|
+
# @example With ActiveRecord
|
|
20
|
+
# loader = DatabaseLoader.new(Skill.where(active: true))
|
|
21
|
+
#
|
|
22
|
+
class DatabaseLoader < Loader
|
|
23
|
+
attr_reader :records
|
|
24
|
+
|
|
25
|
+
# Initialize with a collection of records.
|
|
26
|
+
#
|
|
27
|
+
# @param records [Enumerable] collection responding to #each
|
|
28
|
+
def initialize(records)
|
|
29
|
+
super()
|
|
30
|
+
@records = records
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# List all skills from the records.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Skill>] skills from records
|
|
36
|
+
def list
|
|
37
|
+
skills
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reload skills by re-iterating records.
|
|
41
|
+
# Also reloads the records if they respond to #reload.
|
|
42
|
+
#
|
|
43
|
+
# @return [self]
|
|
44
|
+
def reload!
|
|
45
|
+
@records.reload if @records.respond_to?(:reload)
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
def load_all
|
|
52
|
+
@records.filter_map do |record|
|
|
53
|
+
load_skill_from_record(record)
|
|
54
|
+
rescue => e
|
|
55
|
+
warn "Warning: Failed to load skill from record: #{e.message}" if RubyLlm::Skills.logger
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def load_skill_from_record(record)
|
|
63
|
+
if binary_storage?(record)
|
|
64
|
+
load_from_binary(record)
|
|
65
|
+
else
|
|
66
|
+
load_from_text(record)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def binary_storage?(record)
|
|
71
|
+
record.respond_to?(:data) && record.data.present?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_from_text(record)
|
|
75
|
+
validate_text_record!(record)
|
|
76
|
+
|
|
77
|
+
metadata = build_metadata(record)
|
|
78
|
+
metadata["__content__"] = record.content.to_s
|
|
79
|
+
|
|
80
|
+
Skill.new(
|
|
81
|
+
path: "database:#{record_id(record)}",
|
|
82
|
+
metadata: metadata
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_from_binary(record)
|
|
87
|
+
# Extract skill from zip data
|
|
88
|
+
require "zip"
|
|
89
|
+
require "stringio"
|
|
90
|
+
|
|
91
|
+
io = StringIO.new(record.data)
|
|
92
|
+
Zip::File.open_buffer(io) do |zip|
|
|
93
|
+
skill_md_entry = zip.find_entry("SKILL.md")
|
|
94
|
+
raise LoadError, "SKILL.md not found in binary data" unless skill_md_entry
|
|
95
|
+
|
|
96
|
+
content = skill_md_entry.get_input_stream.read
|
|
97
|
+
metadata = Parser.parse_string(content)
|
|
98
|
+
body = Parser.extract_body(content)
|
|
99
|
+
|
|
100
|
+
# Override name/description from record if present
|
|
101
|
+
metadata["name"] = record.name if record.respond_to?(:name) && record.name
|
|
102
|
+
metadata["description"] = record.description if record.respond_to?(:description) && record.description
|
|
103
|
+
metadata["__content__"] = body
|
|
104
|
+
|
|
105
|
+
Skill.new(
|
|
106
|
+
path: "database:#{record_id(record)}",
|
|
107
|
+
metadata: metadata
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
rescue ::LoadError
|
|
111
|
+
raise LoadError, "rubyzip gem required for binary storage. Add 'gem \"rubyzip\"' to your Gemfile."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_text_record!(record)
|
|
115
|
+
raise InvalidSkillError, "Record must respond to #name" unless record.respond_to?(:name)
|
|
116
|
+
raise InvalidSkillError, "Record must respond to #description" unless record.respond_to?(:description)
|
|
117
|
+
raise InvalidSkillError, "Record must respond to #content" unless record.respond_to?(:content)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_metadata(record)
|
|
121
|
+
metadata = {
|
|
122
|
+
"name" => record.name.to_s,
|
|
123
|
+
"description" => record.description.to_s
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
metadata["license"] = record.license.to_s if record.respond_to?(:license) && record.license
|
|
127
|
+
metadata["compatibility"] = record.compatibility.to_s if record.respond_to?(:compatibility) && record.compatibility
|
|
128
|
+
|
|
129
|
+
if record.respond_to?(:skill_metadata) && record.skill_metadata.is_a?(Hash)
|
|
130
|
+
metadata["metadata"] = record.skill_metadata
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
metadata
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def record_id(record)
|
|
137
|
+
if record.respond_to?(:id)
|
|
138
|
+
record.id
|
|
139
|
+
elsif record.respond_to?(:name)
|
|
140
|
+
record.name
|
|
141
|
+
else
|
|
142
|
+
record.object_id
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Base error class for all skills-related errors.
|
|
6
|
+
# Rescue this to catch any error from the gem.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when a skill has invalid structure or content.
|
|
10
|
+
class InvalidSkillError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when a requested skill cannot be found.
|
|
13
|
+
class NotFoundError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when skill loading fails (filesystem, zip, database).
|
|
16
|
+
class LoadError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when YAML frontmatter parsing fails.
|
|
19
|
+
class ParseError < Error; end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Loads skills from a filesystem directory.
|
|
6
|
+
#
|
|
7
|
+
# Scans a directory for subdirectories containing SKILL.md files.
|
|
8
|
+
# Each subdirectory is treated as a skill if it contains a valid SKILL.md.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# loader = FilesystemLoader.new("app/skills")
|
|
12
|
+
# loader.list # => [Skill, Skill, ...]
|
|
13
|
+
#
|
|
14
|
+
class FilesystemLoader < Loader
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
# Initialize with a directory path.
|
|
18
|
+
#
|
|
19
|
+
# @param path [String, Pathname] path to skills directory
|
|
20
|
+
def initialize(path)
|
|
21
|
+
super()
|
|
22
|
+
@path = path.to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# List all skills from the directory.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Skill>] skills found in directory
|
|
28
|
+
def list
|
|
29
|
+
skills
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
def load_all
|
|
35
|
+
return [] unless File.directory?(@path)
|
|
36
|
+
|
|
37
|
+
Dir.glob(File.join(@path, "*", "SKILL.md")).filter_map do |skill_md_path|
|
|
38
|
+
load_skill(skill_md_path)
|
|
39
|
+
rescue ParseError => e
|
|
40
|
+
warn "Warning: Failed to parse #{skill_md_path}: #{e.message}" if RubyLlm::Skills.logger
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def load_skill(skill_md_path)
|
|
48
|
+
skill_dir = File.dirname(skill_md_path)
|
|
49
|
+
metadata = Parser.parse_file(skill_md_path)
|
|
50
|
+
|
|
51
|
+
Skill.new(path: skill_dir, metadata: metadata)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Base class for skill loaders.
|
|
6
|
+
#
|
|
7
|
+
# Loaders are responsible for discovering and loading skills from various sources:
|
|
8
|
+
# - FilesystemLoader: loads from directory structures
|
|
9
|
+
# - ZipLoader: loads from .zip archives
|
|
10
|
+
# - DatabaseLoader: loads from ActiveRecord models
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# loader = FilesystemLoader.new("app/skills")
|
|
14
|
+
# loader.list # => [skill1, skill2, ...]
|
|
15
|
+
# loader.find("name") # => Skill or nil
|
|
16
|
+
# loader.get("name") # => Skill or raises NotFoundError
|
|
17
|
+
#
|
|
18
|
+
class Loader
|
|
19
|
+
# List all skills from this source.
|
|
20
|
+
#
|
|
21
|
+
# @return [Array<Skill>] collection of skills
|
|
22
|
+
def list
|
|
23
|
+
raise NotImplementedError, "#{self.class}#list must be implemented"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Find a skill by name.
|
|
27
|
+
#
|
|
28
|
+
# @param name [String] skill name
|
|
29
|
+
# @return [Skill, nil] skill or nil if not found
|
|
30
|
+
def find(name)
|
|
31
|
+
list.find { |skill| skill.name == name }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get a skill by name, raising if not found.
|
|
35
|
+
#
|
|
36
|
+
# @param name [String] skill name
|
|
37
|
+
# @return [Skill] the found skill
|
|
38
|
+
# @raise [NotFoundError] if skill not found
|
|
39
|
+
def get(name)
|
|
40
|
+
skill = find(name)
|
|
41
|
+
raise NotFoundError, "Skill not found: #{name}" unless skill
|
|
42
|
+
|
|
43
|
+
skill
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if a skill exists.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] skill name
|
|
49
|
+
# @return [Boolean] true if skill exists
|
|
50
|
+
def exists?(name)
|
|
51
|
+
!find(name).nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reload all skills, clearing any cache.
|
|
55
|
+
#
|
|
56
|
+
# @return [self]
|
|
57
|
+
def reload!
|
|
58
|
+
@skills = nil
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
protected
|
|
63
|
+
|
|
64
|
+
# Cache skills from #load_all for performance.
|
|
65
|
+
def skills
|
|
66
|
+
@skills ||= load_all
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Subclasses must implement this to load all skills.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<Skill>] all skills from source
|
|
72
|
+
def load_all
|
|
73
|
+
raise NotImplementedError, "#{self.class}#load_all must be implemented"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RubyLlm
|
|
6
|
+
module Skills
|
|
7
|
+
# Parses SKILL.md files with YAML frontmatter.
|
|
8
|
+
#
|
|
9
|
+
# A valid SKILL.md has the format:
|
|
10
|
+
# ---
|
|
11
|
+
# name: skill-name
|
|
12
|
+
# description: What the skill does
|
|
13
|
+
# ---
|
|
14
|
+
# # Skill content here
|
|
15
|
+
#
|
|
16
|
+
class Parser
|
|
17
|
+
# Regex to match YAML frontmatter between --- delimiters
|
|
18
|
+
FRONTMATTER_REGEX = /\A---\n(.*?)\n---\n?(.*)/m
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Parse a SKILL.md file and extract frontmatter metadata.
|
|
22
|
+
#
|
|
23
|
+
# @param path [String, Pathname] path to SKILL.md file
|
|
24
|
+
# @return [Hash] parsed frontmatter as hash
|
|
25
|
+
# @raise [ParseError] if frontmatter is missing or invalid
|
|
26
|
+
def parse_file(path)
|
|
27
|
+
content = File.read(path)
|
|
28
|
+
parse_string(content)
|
|
29
|
+
rescue Errno::ENOENT
|
|
30
|
+
raise ParseError, "File not found: #{path}"
|
|
31
|
+
rescue Errno::EACCES
|
|
32
|
+
raise ParseError, "Permission denied: #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parse SKILL.md content string and extract frontmatter.
|
|
36
|
+
#
|
|
37
|
+
# @param content [String] full SKILL.md content
|
|
38
|
+
# @return [Hash] parsed frontmatter as hash
|
|
39
|
+
# @raise [ParseError] if frontmatter is missing or invalid
|
|
40
|
+
def parse_string(content)
|
|
41
|
+
match = content.match(FRONTMATTER_REGEX)
|
|
42
|
+
|
|
43
|
+
unless match
|
|
44
|
+
raise ParseError, "Missing YAML frontmatter (must start with ---)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
yaml_content = match[1]
|
|
48
|
+
parse_yaml(yaml_content)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extract the body content (everything after frontmatter).
|
|
52
|
+
#
|
|
53
|
+
# @param content [String] full SKILL.md content
|
|
54
|
+
# @return [String] body content without frontmatter
|
|
55
|
+
def extract_body(content)
|
|
56
|
+
match = content.match(FRONTMATTER_REGEX)
|
|
57
|
+
return "" unless match
|
|
58
|
+
|
|
59
|
+
match[2].to_s.strip
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse_yaml(yaml_content)
|
|
65
|
+
# Use safe_load with permitted classes for security
|
|
66
|
+
YAML.safe_load(
|
|
67
|
+
yaml_content,
|
|
68
|
+
permitted_classes: [Symbol],
|
|
69
|
+
permitted_symbols: [],
|
|
70
|
+
aliases: false
|
|
71
|
+
) || {}
|
|
72
|
+
rescue Psych::SyntaxError => e
|
|
73
|
+
raise ParseError, "Invalid YAML frontmatter: #{e.message}"
|
|
74
|
+
rescue Psych::DisallowedClass => e
|
|
75
|
+
raise ParseError, "Disallowed class in YAML: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Rails integration for RubyLLM::Skills.
|
|
6
|
+
#
|
|
7
|
+
# Sets up the default skills path to Rails.root/app/skills
|
|
8
|
+
# and configures autoloading of skill files.
|
|
9
|
+
#
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
initializer "ruby_llm_skills.configure" do
|
|
12
|
+
RubyLlm::Skills.default_path = Rails.root.join("app", "skills").to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add app/skills to autoload paths
|
|
16
|
+
initializer "ruby_llm_skills.autoload_paths" do |app|
|
|
17
|
+
skills_path = Rails.root.join("app", "skills")
|
|
18
|
+
if skills_path.exist?
|
|
19
|
+
app.config.autoload_paths << skills_path.to_s
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Provide rake tasks
|
|
24
|
+
rake_tasks do
|
|
25
|
+
load File.expand_path("tasks/skills.rake", __dir__)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlm
|
|
4
|
+
module Skills
|
|
5
|
+
# Represents a single skill with its metadata and content.
|
|
6
|
+
#
|
|
7
|
+
# Skills follow a progressive disclosure pattern:
|
|
8
|
+
# - Level 1: Metadata (name, description) - loaded immediately
|
|
9
|
+
# - Level 2: Content (SKILL.md body) - loaded lazily on demand
|
|
10
|
+
# - Level 3: Resources (scripts, references, assets) - loaded lazily
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# skill = Skill.new(
|
|
14
|
+
# path: "app/skills/pdf-report",
|
|
15
|
+
# metadata: { "name" => "pdf-report", "description" => "Generate PDFs" }
|
|
16
|
+
# )
|
|
17
|
+
# skill.name # => "pdf-report" (immediate)
|
|
18
|
+
# skill.content # => loads SKILL.md body (lazy)
|
|
19
|
+
# skill.scripts # => lists script files (lazy)
|
|
20
|
+
#
|
|
21
|
+
class Skill
|
|
22
|
+
attr_reader :path, :metadata
|
|
23
|
+
|
|
24
|
+
# Initialize a skill from parsed metadata.
|
|
25
|
+
#
|
|
26
|
+
# @param path [String] path to skill directory or virtual identifier
|
|
27
|
+
# @param metadata [Hash] parsed YAML frontmatter
|
|
28
|
+
# @param content [String, nil] pre-loaded content (optional)
|
|
29
|
+
def initialize(path:, metadata:, content: nil)
|
|
30
|
+
@path = path.to_s
|
|
31
|
+
@metadata = metadata || {}
|
|
32
|
+
@content = content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] skill name from frontmatter
|
|
36
|
+
def name
|
|
37
|
+
@metadata["name"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] skill description from frontmatter
|
|
41
|
+
def description
|
|
42
|
+
@metadata["description"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] license from frontmatter
|
|
46
|
+
def license
|
|
47
|
+
@metadata["license"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String, nil] compatibility info from frontmatter
|
|
51
|
+
def compatibility
|
|
52
|
+
@metadata["compatibility"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Hash] custom metadata key-value pairs
|
|
56
|
+
def custom_metadata
|
|
57
|
+
@metadata["metadata"] || {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>] list of allowed tools (experimental)
|
|
61
|
+
def allowed_tools
|
|
62
|
+
(@metadata["allowed-tools"] || "").split
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get the full SKILL.md content (body without frontmatter).
|
|
66
|
+
# Content is loaded lazily on first access.
|
|
67
|
+
#
|
|
68
|
+
# @return [String] skill instructions
|
|
69
|
+
def content
|
|
70
|
+
@content ||= load_content
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# List script files in the scripts/ directory.
|
|
74
|
+
# Loaded lazily on first access.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<String>] paths to script files
|
|
77
|
+
def scripts
|
|
78
|
+
@scripts ||= list_resources("scripts")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# List reference files in the references/ directory.
|
|
82
|
+
# Loaded lazily on first access.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<String>] paths to reference files
|
|
85
|
+
def references
|
|
86
|
+
@references ||= list_resources("references")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# List asset files in the assets/ directory.
|
|
90
|
+
# Loaded lazily on first access.
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<String>] paths to asset files
|
|
93
|
+
def assets
|
|
94
|
+
@assets ||= list_resources("assets")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if skill path is a filesystem path (vs database virtual path).
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] true if path points to filesystem
|
|
100
|
+
def filesystem?
|
|
101
|
+
!virtual?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if skill is a virtual/database skill.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if path is a virtual identifier
|
|
107
|
+
def virtual?
|
|
108
|
+
@path.start_with?("database:")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Path to the SKILL.md file.
|
|
112
|
+
#
|
|
113
|
+
# @return [String, nil] path to SKILL.md or nil for virtual skills
|
|
114
|
+
def skill_md_path
|
|
115
|
+
return nil if virtual?
|
|
116
|
+
|
|
117
|
+
File.join(@path, "SKILL.md")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate the skill structure.
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] true if skill is valid
|
|
123
|
+
def valid?
|
|
124
|
+
errors.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get validation errors.
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<String>] list of error messages
|
|
130
|
+
def errors
|
|
131
|
+
@errors ||= Validator.validate(self)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Clear cached content and resources (useful for testing).
|
|
135
|
+
def reload!
|
|
136
|
+
@content = nil
|
|
137
|
+
@scripts = nil
|
|
138
|
+
@references = nil
|
|
139
|
+
@assets = nil
|
|
140
|
+
@errors = nil
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [String] inspection string
|
|
145
|
+
def inspect
|
|
146
|
+
"#<#{self.class.name} name=#{name.inspect} path=#{path.inspect}>"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def load_content
|
|
152
|
+
return @metadata["__content__"] if @metadata["__content__"]
|
|
153
|
+
return "" if virtual?
|
|
154
|
+
|
|
155
|
+
md_path = skill_md_path
|
|
156
|
+
return "" unless md_path && File.exist?(md_path)
|
|
157
|
+
|
|
158
|
+
Parser.extract_body(File.read(md_path))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def list_resources(subdir)
|
|
162
|
+
return [] if virtual?
|
|
163
|
+
|
|
164
|
+
resource_path = File.join(@path, subdir)
|
|
165
|
+
return [] unless File.directory?(resource_path)
|
|
166
|
+
|
|
167
|
+
Dir.glob(File.join(resource_path, "**", "*"))
|
|
168
|
+
.select { |f| File.file?(f) }
|
|
169
|
+
.reject { |f| File.basename(f) == ".keep" }
|
|
170
|
+
.sort
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|