agent_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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 57798b894d8e2eff5fba30de74130a813e7ae2bdc6599021e3f3ff610a9a9908
4
+ data.tar.gz: 38f5cda0c7ee6714794ae177bd575c066f27f0d3f1738408b39d9b09dc17ef75
5
+ SHA512:
6
+ metadata.gz: 3668a7b89dc969da83ae0dbf6eddf7355ae1df69ba4d784fa9946605e9bd2d189f9269b12da21e990b28705c27b2247807a6e82b5f63b9a700f86535183c5d01
7
+ data.tar.gz: a3e154ddb1665f9aff7cf90d921414c9b0fa2a1541d90b35eade04ae82c744e9cb7398d29a65208f643adb038f96bb45b8dd19e2c4de68e1a0e49155dc3ea0dc
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-01-29
11
+
12
+ ### Added
13
+
14
+ - `Skill` class for parsing SKILL.md files with YAML frontmatter
15
+ - `Validator` class for validating skills against agentskills.io specification
16
+ - `Generator` class for scaffolding new skills
17
+ - `Loader` class for discovering skills from filesystem paths
18
+ - `Packager` class for creating and extracting .skill bundles
19
+ - CLI tool with commands: `new`, `validate`, `list`, `info`, `pack`, `unpack`, `version`
20
+ - Support for `scripts/`, `references/`, and `assets/` directories
21
+ - `to_prompt_xml` method for LLM prompt injection
22
+ - GitHub Actions CI workflow
23
+
24
+ ---
25
+
26
+ [Unreleased]: https://github.com/rubyonai/agent_skills/compare/v0.1.0...HEAD
27
+ [0.1.0]: https://github.com/rubyonai/agent_skills/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nagendra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # Agent Skills
2
+
3
+ [![CI](https://github.com/rubyonai/agent_skills/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rubyonai/agent_skills/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/agent_skills.svg)](https://badge.fury.io/rb/agent_skills)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Ruby implementation of the [Agent Skills](https://agentskills.io) open standard — a simple format for giving AI agents new capabilities.
8
+
9
+ ## What are Agent Skills?
10
+
11
+ Agent Skills are portable folders of instructions that AI agents can load to perform specialized tasks. The format is supported by **Claude, GitHub Copilot, Cursor, VS Code**, and [26+ other tools](https://agentskills.io).
12
+
13
+ ```
14
+ my-skill/
15
+ ├── SKILL.md # Instructions for the agent (required)
16
+ ├── scripts/ # Executable code (optional)
17
+ ├── references/ # Supporting docs (optional)
18
+ └── assets/ # Templates, data files (optional)
19
+ ```
20
+
21
+ ## Installation
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'agent_skills'
27
+ ```
28
+
29
+ Or install directly:
30
+
31
+ ```bash
32
+ gem install agent_skills
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ### Create a skill
38
+
39
+ ```bash
40
+ agent-skills new my-skill -d "Description of what this skill does"
41
+ ```
42
+
43
+ ### Validate a skill
44
+
45
+ ```bash
46
+ agent-skills validate ./my-skill
47
+ ```
48
+
49
+ ### Package for distribution
50
+
51
+ ```bash
52
+ agent-skills pack ./my-skill
53
+ # Creates: my-skill.skill
54
+ ```
55
+
56
+ ## CLI Commands
57
+
58
+ | Command | Description |
59
+ |---------|-------------|
60
+ | `agent-skills new NAME -d DESC` | Create a new skill |
61
+ | `agent-skills validate PATH` | Validate skill against spec |
62
+ | `agent-skills list` | List discovered skills |
63
+ | `agent-skills info PATH` | Show skill details |
64
+ | `agent-skills pack PATH` | Package into .skill file |
65
+ | `agent-skills unpack FILE` | Extract a .skill file |
66
+
67
+ ## Ruby API
68
+
69
+ ```ruby
70
+ require 'agent_skills'
71
+
72
+ # Load and parse a skill
73
+ skill = AgentSkills::Skill.load('./my-skill')
74
+ skill.name # => "my-skill"
75
+ skill.description # => "What it does..."
76
+
77
+ # Validate against spec
78
+ validator = AgentSkills::Validator.new(skill)
79
+ validator.valid? # => true
80
+ validator.errors # => []
81
+
82
+ # Discover skills from paths
83
+ loader = AgentSkills::Loader.new(paths: ['./skills'])
84
+ loader.discover
85
+ loader['my-skill'] # => #<AgentSkills::Skill>
86
+
87
+ # Generate prompt XML for LLM injection
88
+ skill.to_prompt_xml
89
+ # => "<skill name=\"my-skill\"><description>...</description>...</skill>"
90
+
91
+ # Create a new skill programmatically
92
+ AgentSkills::Generator.create(
93
+ path: './skills',
94
+ name: 'my-skill',
95
+ description: 'What this skill does'
96
+ )
97
+
98
+ # Package and distribute
99
+ AgentSkills::Packager.pack('./my-skill') # => "my-skill.skill"
100
+ AgentSkills::Packager.unpack('my-skill.skill', output: './extracted')
101
+ ```
102
+
103
+ ## SKILL.md Format
104
+
105
+ ```markdown
106
+ ---
107
+ name: my-skill
108
+ description: What this skill does and when to use it.
109
+ license: MIT # optional
110
+ compatibility: Requires Python 3.x # optional
111
+ ---
112
+
113
+ # My Skill
114
+
115
+ Instructions for the agent go here...
116
+ ```
117
+
118
+ See the [full specification](https://agentskills.io/specification) for details.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ git clone https://github.com/rubyonai/agent_skills.git
124
+ cd agent_skills
125
+ bundle install
126
+ bundle exec rspec
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/rubyonai/agent_skills).
132
+
133
+ ## License
134
+
135
+ MIT License. See [LICENSE](LICENSE) for details.
136
+
137
+ ## Resources
138
+
139
+ - [Agent Skills Specification](https://agentskills.io)
140
+ - [Example Skills](https://github.com/anthropics/skills)
data/RELEASING.md ADDED
@@ -0,0 +1,95 @@
1
+ # Releasing
2
+
3
+ This document describes how to release a new version of the `agent_skills` gem.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. Push access to the GitHub repository
8
+ 2. RubyGems account with ownership of `agent_skills` gem
9
+ 3. `RUBYGEMS_API_KEY` secret configured in GitHub repository settings
10
+
11
+ ## Setup (One-time)
12
+
13
+ ### Configure RubyGems API Key
14
+
15
+ 1. Get your API key from https://rubygems.org/profile/api_keys
16
+ 2. Add it to GitHub: Repository → Settings → Secrets → Actions → New secret
17
+ - Name: `RUBYGEMS_API_KEY`
18
+ - Value: Your API key
19
+
20
+ ## Release Process
21
+
22
+ ### 1. Update version
23
+
24
+ Edit `lib/agent_skills/version.rb`:
25
+
26
+ ```ruby
27
+ module AgentSkills
28
+ VERSION = "0.2.0" # Update this
29
+ end
30
+ ```
31
+
32
+ ### 2. Update CHANGELOG
33
+
34
+ Add release notes to `CHANGELOG.md`:
35
+
36
+ ```markdown
37
+ ## [0.2.0] - YYYY-MM-DD
38
+
39
+ ### Added
40
+ - New feature X
41
+
42
+ ### Fixed
43
+ - Bug fix Y
44
+ ```
45
+
46
+ ### 3. Commit and tag
47
+
48
+ ```bash
49
+ git add lib/agent_skills/version.rb CHANGELOG.md
50
+ git commit -m "Release v0.2.0"
51
+ git tag v0.2.0
52
+ git push origin main --tags
53
+ ```
54
+
55
+ ### 4. Automated release
56
+
57
+ Pushing the tag triggers the release workflow which:
58
+ - Runs tests
59
+ - Builds the gem
60
+ - Creates a GitHub Release
61
+ - Publishes to RubyGems
62
+
63
+ ## Manual Release (if needed)
64
+
65
+ ```bash
66
+ # Build
67
+ gem build agent_skills.gemspec
68
+
69
+ # Test locally
70
+ gem install ./agent_skills-0.2.0.gem
71
+
72
+ # Publish
73
+ gem push agent_skills-0.2.0.gem
74
+ ```
75
+
76
+ ## Rake Tasks
77
+
78
+ ```bash
79
+ # Build gem
80
+ bundle exec rake build
81
+
82
+ # Install locally
83
+ bundle exec rake install
84
+
85
+ # Release (build, tag, push gem)
86
+ bundle exec rake release
87
+ ```
88
+
89
+ ## Version Guidelines
90
+
91
+ Follow [Semantic Versioning](https://semver.org/):
92
+
93
+ - **MAJOR** (1.0.0): Breaking API changes
94
+ - **MINOR** (0.2.0): New features, backward compatible
95
+ - **PATCH** (0.1.1): Bug fixes, backward compatible
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
11
+
12
+ desc "Run all tests and checks"
13
+ task ci: %i[spec rubocop]
14
+
15
+ desc "Open an IRB session with the gem loaded"
16
+ task :console do
17
+ require "irb"
18
+ require "agent_skills"
19
+ ARGV.clear
20
+ IRB.start(__FILE__)
21
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/agent_skills/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "agent_skills"
7
+ spec.version = AgentSkills::VERSION
8
+ spec.authors = ["rubyonai"]
9
+ spec.email = ["your-email@example.com"]
10
+
11
+ spec.summary = "Ruby implementation of the Agent Skills open standard"
12
+ spec.description = <<~DESC
13
+ Parse, validate, create, package, and load Agent Skills in Ruby.
14
+ Agent Skills is an open format (by Anthropic) for giving AI agents
15
+ new capabilities through structured instructions, scripts, and resources.
16
+ DESC
17
+ spec.homepage = "https://github.com/rubyonai/agent_skills"
18
+ spec.license = "MIT"
19
+ spec.required_ruby_version = ">= 3.0.0"
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/rubyonai/agent_skills"
23
+ spec.metadata["changelog_uri"] = "https://github.com/rubyonai/agent_skills/blob/main/CHANGELOG.md"
24
+ spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/agent_skills"
25
+ spec.metadata["rubygems_mfa_required"] = "true"
26
+
27
+ # Specify which files should be added to the gem
28
+ spec.files = Dir.chdir(__dir__) do
29
+ `git ls-files -z`.split("\x0").reject do |f|
30
+ (File.expand_path(f) == __FILE__) ||
31
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .circleci appveyor Gemfile])
32
+ end
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ # Runtime dependencies
39
+ spec.add_dependency "rubyzip", "~> 2.3"
40
+ spec.add_dependency "thor", "~> 1.3"
41
+ end
data/exe/agent-skills ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "agent_skills"
5
+ require "agent_skills/cli"
6
+
7
+ AgentSkills::CLI.start(ARGV)
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module AgentSkills
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ desc "new NAME", "Create a new skill"
12
+ option :description, aliases: "-d", required: true, desc: "Skill description"
13
+ option :path, aliases: "-p", default: ".", desc: "Output directory"
14
+ option :scripts, type: :boolean, default: false, desc: "Include scripts directory"
15
+ option :references, type: :boolean, default: false, desc: "Include references directory"
16
+ option :assets, type: :boolean, default: false, desc: "Include assets directory"
17
+ def new(name)
18
+ path = Generator.create(
19
+ path: options[:path],
20
+ name: name,
21
+ description: options[:description],
22
+ with_scripts: options[:scripts],
23
+ with_references: options[:references],
24
+ with_assets: options[:assets]
25
+ )
26
+
27
+ say "Created skill at #{path}/", :green
28
+ say " SKILL.md"
29
+ say " scripts/" if options[:scripts]
30
+ say " references/" if options[:references]
31
+ say " assets/" if options[:assets]
32
+ end
33
+
34
+ desc "validate PATH", "Validate a skill against the spec"
35
+ def validate(path)
36
+ skill = Skill.load(path)
37
+ validator = Validator.new(skill)
38
+
39
+ if validator.valid?
40
+ say "#{skill.name} is valid", :green
41
+ else
42
+ say "#{skill.name} has errors:", :red
43
+ validator.errors.each { |e| say " - #{e}", :red }
44
+ exit 1
45
+ end
46
+ rescue NotFoundError, ParseError => e
47
+ say "Error: #{e.message}", :red
48
+ exit 1
49
+ end
50
+
51
+ desc "list", "List discovered skills"
52
+ option :path, aliases: "-p", type: :array, desc: "Paths to search"
53
+ def list
54
+ paths = options[:path] || Loader::DEFAULT_PATHS
55
+ loader = Loader.new(paths: paths)
56
+ skills = loader.discover
57
+
58
+ if skills.empty?
59
+ say "No skills found in: #{paths.join(', ')}", :yellow
60
+ return
61
+ end
62
+
63
+ say "Found #{skills.size} skill(s):\n\n"
64
+
65
+ skills.each do |name, skill|
66
+ say name, :green
67
+ say " #{truncate(skill.description, 60)}"
68
+ say " Path: #{skill.path}" if skill.path
69
+ say ""
70
+ end
71
+ end
72
+
73
+ desc "info PATH", "Show detailed skill information"
74
+ def info(path)
75
+ skill = Skill.load(path)
76
+
77
+ say "Name: ", :green, false
78
+ say skill.name
79
+ say "Description: ", :green, false
80
+ say skill.description
81
+ say "Path: ", :green, false
82
+ say skill.path || "(none)"
83
+
84
+ if skill.license
85
+ say "License: ", :green, false
86
+ say skill.license
87
+ end
88
+
89
+ if skill.compatibility
90
+ say "Compat: ", :green, false
91
+ say skill.compatibility
92
+ end
93
+
94
+ unless skill.scripts.empty?
95
+ say "Scripts: ", :green, false
96
+ say skill.scripts.map { |s| File.basename(s) }.join(", ")
97
+ end
98
+
99
+ unless skill.references.empty?
100
+ say "References: ", :green, false
101
+ say skill.references.map { |r| File.basename(r) }.join(", ")
102
+ end
103
+
104
+ # Validation status
105
+ validator = Validator.new(skill)
106
+ say "Valid: ", :green, false
107
+ say validator.valid? ? "Yes" : "No (#{validator.errors.join(', ')})"
108
+ rescue NotFoundError, ParseError => e
109
+ say "Error: #{e.message}", :red
110
+ exit 1
111
+ end
112
+
113
+ desc "pack PATH", "Package a skill into a .skill file"
114
+ option :output, aliases: "-o", desc: "Output file path"
115
+ def pack(path)
116
+ output = Packager.pack(path, output: options[:output])
117
+ say "Created #{output}", :green
118
+ rescue NotFoundError, ParseError, ValidationError => e
119
+ say "Error: #{e.message}", :red
120
+ exit 1
121
+ end
122
+
123
+ desc "unpack FILE", "Extract a .skill file"
124
+ option :output, aliases: "-o", default: ".", desc: "Output directory"
125
+ def unpack(file)
126
+ extracted = Packager.unpack(file, output: options[:output])
127
+ say "Extracted to #{extracted}", :green
128
+ rescue NotFoundError => e
129
+ say "Error: #{e.message}", :red
130
+ exit 1
131
+ end
132
+
133
+ desc "version", "Show version"
134
+ def version
135
+ say "agent_skills #{VERSION}"
136
+ end
137
+
138
+ private
139
+
140
+ def truncate(text, length)
141
+ return text if text.length <= length
142
+
143
+ "#{text[0, length - 3]}..."
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentSkills
4
+ class Error < StandardError; end
5
+
6
+ class NotFoundError < Error; end
7
+
8
+ class ParseError < Error; end
9
+
10
+ class ValidationError < Error; end
11
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module AgentSkills
6
+ class Generator
7
+ SKILL_TEMPLATE = <<~SKILL
8
+ ---
9
+ name: %<name>s
10
+ description: %<description>s
11
+ ---
12
+
13
+ # %<title>s
14
+
15
+ ## Instructions
16
+
17
+ [Add step-by-step instructions here]
18
+
19
+ ## Examples
20
+
21
+ ### Input
22
+ [Example input]
23
+
24
+ ### Output
25
+ [Expected output]
26
+
27
+ ## Guidelines
28
+
29
+ - [Add guidelines here]
30
+ SKILL
31
+
32
+ attr_reader :path, :name, :description, :options
33
+
34
+ def initialize(path:, name:, description:, **options)
35
+ @path = path
36
+ @name = name
37
+ @description = description
38
+ @options = options
39
+ end
40
+
41
+ def self.create(path:, name:, description:, **options)
42
+ new(path: path, name: name, description: description, **options).create
43
+ end
44
+
45
+ def create
46
+ validate_inputs!
47
+ create_directories
48
+ create_skill_md
49
+ skill_path
50
+ end
51
+
52
+ private
53
+
54
+ def validate_inputs!
55
+ raise ArgumentError, "name is required" if @name.nil? || @name.empty?
56
+ raise ArgumentError, "description is required" if @description.nil? || @description.empty?
57
+ end
58
+
59
+ def skill_path
60
+ File.join(@path, @name)
61
+ end
62
+
63
+ def create_directories
64
+ FileUtils.mkdir_p(skill_path)
65
+ FileUtils.mkdir_p(File.join(skill_path, "scripts")) if @options[:with_scripts]
66
+ FileUtils.mkdir_p(File.join(skill_path, "references")) if @options[:with_references]
67
+ FileUtils.mkdir_p(File.join(skill_path, "assets")) if @options[:with_assets]
68
+ end
69
+
70
+ def create_skill_md
71
+ content = format(
72
+ SKILL_TEMPLATE,
73
+ name: @name,
74
+ description: @description,
75
+ title: titleize(@name)
76
+ )
77
+
78
+ File.write(File.join(skill_path, "SKILL.md"), content)
79
+ end
80
+
81
+ def titleize(name)
82
+ name.split("-").map(&:capitalize).join(" ")
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentSkills
4
+ class Loader
5
+ DEFAULT_PATHS = [
6
+ File.expand_path("~/.config/claude/skills"),
7
+ ".claude/skills",
8
+ "skills"
9
+ ].freeze
10
+
11
+ attr_reader :paths, :skills
12
+
13
+ def initialize(paths: DEFAULT_PATHS)
14
+ @paths = Array(paths)
15
+ @skills = {}
16
+ end
17
+
18
+ def discover
19
+ @skills = {}
20
+
21
+ @paths.each do |base_path|
22
+ expanded = File.expand_path(base_path)
23
+ next unless File.directory?(expanded)
24
+
25
+ discover_in_path(expanded)
26
+ end
27
+
28
+ @skills
29
+ end
30
+
31
+ def [](name)
32
+ @skills[name]
33
+ end
34
+
35
+ def count
36
+ @skills.size
37
+ end
38
+
39
+ def each(&block)
40
+ @skills.each(&block)
41
+ end
42
+
43
+ def find_relevant(query)
44
+ return [] if query.nil? || query.empty?
45
+
46
+ keywords = query.downcase.split(/\s+/)
47
+
48
+ @skills.values.select do |skill|
49
+ text = "#{skill.name} #{skill.description}".downcase
50
+ keywords.any? { |keyword| text.include?(keyword) }
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def discover_in_path(base_path)
57
+ Dir.glob(File.join(base_path, "*", "SKILL.md")).each do |skill_md|
58
+ skill_dir = File.dirname(skill_md)
59
+
60
+ begin
61
+ skill = Skill.load(skill_dir)
62
+ @skills[skill.name] = skill
63
+ rescue Error => e
64
+ warn "Warning: Failed to load skill at #{skill_dir}: #{e.message}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "fileutils"
5
+
6
+ module AgentSkills
7
+ class Packager
8
+ SKILL_EXTENSION = ".skill"
9
+
10
+ def self.pack(skill_path, output: nil)
11
+ new(skill_path).pack(output: output)
12
+ end
13
+
14
+ def self.unpack(skill_file, output:)
15
+ new(nil).unpack(skill_file, output: output)
16
+ end
17
+
18
+ def initialize(skill_path)
19
+ @skill_path = skill_path
20
+ end
21
+
22
+ def pack(output: nil)
23
+ validate_skill!
24
+
25
+ skill = Skill.load(@skill_path)
26
+ Validator.validate!(skill)
27
+
28
+ output_file = output || "#{skill.name}#{SKILL_EXTENSION}"
29
+
30
+ create_zip(output_file)
31
+ output_file
32
+ end
33
+
34
+ def unpack(skill_file, output:)
35
+ raise NotFoundError, "Skill file not found: #{skill_file}" unless File.exist?(skill_file)
36
+
37
+ FileUtils.mkdir_p(output)
38
+
39
+ Zip::File.open(skill_file) do |zip|
40
+ zip.each do |entry|
41
+ dest = File.join(output, entry.name)
42
+ FileUtils.mkdir_p(File.dirname(dest))
43
+ entry.extract(dest) { true } # overwrite existing
44
+ end
45
+ end
46
+
47
+ # Return the extracted skill directory
48
+ skill_dirs = Dir.glob(File.join(output, "*", "SKILL.md")).map { |f| File.dirname(f) }
49
+ skill_dirs.first || output
50
+ end
51
+
52
+ private
53
+
54
+ def validate_skill!
55
+ raise NotFoundError, "Skill path not found: #{@skill_path}" unless File.directory?(@skill_path)
56
+
57
+ skill_md = File.join(@skill_path, "SKILL.md")
58
+ raise NotFoundError, "SKILL.md not found in #{@skill_path}" unless File.exist?(skill_md)
59
+ end
60
+
61
+ def create_zip(output_file)
62
+ File.delete(output_file) if File.exist?(output_file)
63
+
64
+ Zip::File.open(output_file, Zip::File::CREATE) do |zip|
65
+ add_directory_to_zip(zip, @skill_path, File.basename(@skill_path))
66
+ end
67
+ end
68
+
69
+ def add_directory_to_zip(zip, dir_path, base_name)
70
+ Dir.glob(File.join(dir_path, "**", "*")).each do |file|
71
+ next if File.directory?(file)
72
+
73
+ relative_path = file.sub("#{dir_path}/", "")
74
+ zip_path = File.join(base_name, relative_path)
75
+ zip.add(zip_path, file)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module AgentSkills
6
+ class Skill
7
+ FRONTMATTER_REGEX = /\A---\s*\n(.+?)\n---\s*\n(.*)/m
8
+
9
+ attr_reader :path, :name, :description, :license, :compatibility,
10
+ :metadata, :allowed_tools, :body
11
+
12
+ def initialize(path:, name:, description:, body:, license: nil,
13
+ compatibility: nil, metadata: {}, allowed_tools: [])
14
+ @path = path
15
+ @name = name
16
+ @description = description
17
+ @body = body
18
+ @license = license
19
+ @compatibility = compatibility
20
+ @metadata = metadata
21
+ @allowed_tools = allowed_tools
22
+ end
23
+
24
+ def self.load(skill_path)
25
+ skill_md_path = File.join(skill_path, "SKILL.md")
26
+
27
+ unless File.exist?(skill_md_path)
28
+ raise NotFoundError, "SKILL.md not found in #{skill_path}"
29
+ end
30
+
31
+ content = File.read(skill_md_path, encoding: "UTF-8")
32
+ parse(content, skill_path)
33
+ end
34
+
35
+ def self.parse(content, path = nil)
36
+ match = content.match(FRONTMATTER_REGEX)
37
+
38
+ unless match
39
+ raise ParseError, "Invalid SKILL.md format: missing YAML frontmatter"
40
+ end
41
+
42
+ frontmatter = YAML.safe_load(match[1], symbolize_names: true)
43
+ body = match[2].strip
44
+
45
+ new(
46
+ path: path,
47
+ name: frontmatter[:name],
48
+ description: frontmatter[:description],
49
+ license: frontmatter[:license],
50
+ compatibility: frontmatter[:compatibility],
51
+ metadata: frontmatter[:metadata] || {},
52
+ allowed_tools: parse_allowed_tools(frontmatter[:"allowed-tools"]),
53
+ body: body
54
+ )
55
+ end
56
+
57
+ def scripts
58
+ return [] unless @path
59
+
60
+ Dir.glob(File.join(@path, "scripts", "*")).select { |f| File.file?(f) }
61
+ end
62
+
63
+ def references
64
+ return [] unless @path
65
+
66
+ Dir.glob(File.join(@path, "references", "*.md"))
67
+ end
68
+
69
+ def assets
70
+ return [] unless @path
71
+
72
+ Dir.glob(File.join(@path, "assets", "*")).select { |f| File.file?(f) }
73
+ end
74
+
75
+ def to_prompt_xml
76
+ <<~XML.strip
77
+ <skill name="#{@name}">
78
+ <description>#{@description}</description>
79
+ <instructions>
80
+ #{@body}
81
+ </instructions>
82
+ </skill>
83
+ XML
84
+ end
85
+
86
+ def to_h
87
+ {
88
+ name: @name,
89
+ description: @description,
90
+ license: @license,
91
+ compatibility: @compatibility,
92
+ metadata: @metadata,
93
+ allowed_tools: @allowed_tools,
94
+ body: @body
95
+ }.compact
96
+ end
97
+
98
+ private
99
+
100
+ def self.parse_allowed_tools(value)
101
+ return [] if value.nil?
102
+
103
+ value.to_s.split(/\s+/)
104
+ end
105
+
106
+ private_class_method :parse_allowed_tools
107
+ end
108
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentSkills
4
+ class Validator
5
+ NAME_REGEX = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
6
+ MAX_NAME_LENGTH = 64
7
+ MAX_DESCRIPTION_LENGTH = 1024
8
+ MAX_COMPATIBILITY_LENGTH = 500
9
+
10
+ attr_reader :skill, :errors
11
+
12
+ def initialize(skill)
13
+ @skill = skill
14
+ @errors = []
15
+ end
16
+
17
+ def valid?
18
+ @errors = []
19
+ validate_name
20
+ validate_description
21
+ validate_compatibility
22
+ validate_directory_match
23
+ @errors.empty?
24
+ end
25
+
26
+ def self.validate!(skill)
27
+ validator = new(skill)
28
+ return skill if validator.valid?
29
+
30
+ raise ValidationError, validator.errors.join(", ")
31
+ end
32
+
33
+ private
34
+
35
+ def validate_name
36
+ name = @skill.name.to_s
37
+
38
+ if name.empty?
39
+ @errors << "name is required"
40
+ return
41
+ end
42
+
43
+ if name.length > MAX_NAME_LENGTH
44
+ @errors << "name must be #{MAX_NAME_LENGTH} characters or less"
45
+ end
46
+
47
+ unless name.match?(NAME_REGEX)
48
+ @errors << "name must contain only lowercase letters, numbers, and hyphens"
49
+ end
50
+
51
+ if name.start_with?("-") || name.end_with?("-")
52
+ @errors << "name cannot start or end with a hyphen"
53
+ end
54
+
55
+ if name.include?("--")
56
+ @errors << "name cannot contain consecutive hyphens"
57
+ end
58
+ end
59
+
60
+ def validate_description
61
+ description = @skill.description.to_s
62
+
63
+ if description.empty?
64
+ @errors << "description is required"
65
+ return
66
+ end
67
+
68
+ if description.length > MAX_DESCRIPTION_LENGTH
69
+ @errors << "description must be #{MAX_DESCRIPTION_LENGTH} characters or less"
70
+ end
71
+ end
72
+
73
+ def validate_compatibility
74
+ compatibility = @skill.compatibility.to_s
75
+ return if compatibility.empty?
76
+
77
+ if compatibility.length > MAX_COMPATIBILITY_LENGTH
78
+ @errors << "compatibility must be #{MAX_COMPATIBILITY_LENGTH} characters or less"
79
+ end
80
+ end
81
+
82
+ def validate_directory_match
83
+ return unless @skill.path
84
+
85
+ dir_name = File.basename(@skill.path)
86
+ return if dir_name == @skill.name
87
+
88
+ @errors << "name '#{@skill.name}' must match directory name '#{dir_name}'"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentSkills
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent_skills/version"
4
+ require_relative "agent_skills/errors"
5
+ require_relative "agent_skills/skill"
6
+ require_relative "agent_skills/validator"
7
+ require_relative "agent_skills/generator"
8
+ require_relative "agent_skills/loader"
9
+ require_relative "agent_skills/packager"
10
+
11
+ module AgentSkills
12
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agent_skills
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rubyonai
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rubyzip
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: thor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ description: |
42
+ Parse, validate, create, package, and load Agent Skills in Ruby.
43
+ Agent Skills is an open format (by Anthropic) for giving AI agents
44
+ new capabilities through structured instructions, scripts, and resources.
45
+ email:
46
+ - your-email@example.com
47
+ executables:
48
+ - agent-skills
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - ".rspec"
53
+ - CHANGELOG.md
54
+ - LICENSE
55
+ - README.md
56
+ - RELEASING.md
57
+ - Rakefile
58
+ - agent_skills.gemspec
59
+ - exe/agent-skills
60
+ - lib/agent_skills.rb
61
+ - lib/agent_skills/cli.rb
62
+ - lib/agent_skills/errors.rb
63
+ - lib/agent_skills/generator.rb
64
+ - lib/agent_skills/loader.rb
65
+ - lib/agent_skills/packager.rb
66
+ - lib/agent_skills/skill.rb
67
+ - lib/agent_skills/validator.rb
68
+ - lib/agent_skills/version.rb
69
+ homepage: https://github.com/rubyonai/agent_skills
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ homepage_uri: https://github.com/rubyonai/agent_skills
74
+ source_code_uri: https://github.com/rubyonai/agent_skills
75
+ changelog_uri: https://github.com/rubyonai/agent_skills/blob/main/CHANGELOG.md
76
+ documentation_uri: https://rubydoc.info/gems/agent_skills
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.0.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.5.22
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Ruby implementation of the Agent Skills open standard
97
+ test_files: []