From ba12e8715dc97af9f203b9792e311be59bc67549 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 12:56:44 +0530 Subject: [PATCH 1/3] Align dependency skills with the shared Agent Context specification Signed-off-by: Samuel Williams --- .gitignore | 2 - agent-skills.gemspec | 2 +- bake/agent/skills.rb | 77 ----- context/getting-started.md | 69 ++--- context/index.yaml | 6 +- guides/getting-started/readme.md | 69 ++--- lib/agent/skills/definition.rb | 70 ++++- lib/agent/skills/exclusion.rb | 73 +++++ lib/agent/skills/installer.rb | 360 ++++++++++++----------- lib/agent/skills/registry.rb | 148 +++++----- lib/agent/skills/version.rb | 2 +- readme.md | 142 +-------- specification.md | 256 ++++++++++++---- test/agent/skills/installer.rb | 186 +++++++++++- test/agent/skills/registry.rb | 51 +++- test/fixtures/skill-ownership-index.json | 15 + 16 files changed, 929 insertions(+), 599 deletions(-) delete mode 100644 bake/agent/skills.rb create mode 100644 lib/agent/skills/exclusion.rb create mode 100644 test/fixtures/skill-ownership-index.json diff --git a/.gitignore b/.gitignore index d53e569..b2ec040 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -/agents.md -/.agents/ /.bundle /pkg /gems.locked diff --git a/agent-skills.gemspec b/agent-skills.gemspec index 8140a9d..927dd97 100644 --- a/agent-skills.gemspec +++ b/agent-skills.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |spec| spec.name = "agent-skills" spec.version = Agent::Skills::VERSION - spec.summary = "Install and manage agent skills from Ruby gems." + spec.summary = "Discover and install dependency skills for Agent Context." spec.authors = ["Samuel Williams", "Shopify Inc."] spec.license = "MIT" diff --git a/bake/agent/skills.rb b/bake/agent/skills.rb deleted file mode 100644 index 45fe87c..0000000 --- a/bake/agent/skills.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Shopify Inc. - -require_relative "../../lib/agent/skills/installer" - -include Agent::Skills - -def initialize(context) - super(context) - - @installer = Installer.new(root: context.root) -end - -attr :installer - -# List gems and the skills they provide. -# @parameter gem [String] Optional gem name to inspect. -def list(gem: nil) - if gem - skills = @installer.list_skills(gem) - if skills - puts "Skills provided by gem '#{gem}':" - skills.each do |skill| - puts " #{skill.name} - #{skill.description}" - end - else - puts "No skills found for gem '#{gem}'" - end - else - gems = @installer.find_gems_with_skills - if gems.any? - puts "Gems with skills available:" - gems.each do |gem_information| - names = gem_information[:skills].map(&:name).join(", ") - puts " #{gem_information[:name]} (#{gem_information[:version]}): #{names}" - end - else - puts "No gems with skills found" - end - end -end - -# Show the `SKILL.md` content for a provided skill. -# @parameter gem [String] The provider gem name. -# @parameter skill [String] The skill name. -def show(gem:, skill:) - content = @installer.show_skill(gem, skill) - if content - puts content - else - puts "Skill '#{skill}' not found in gem '#{gem}'" - end -end - -# Install skills from gems into `.agents/skills`. -# @parameter gem [String] Optional gem name to install from. -def install(gem: nil) - if gem - installed = @installer.install_gem_skills(gem) - if installed - puts "Installed #{installed.length} skills from gem '#{gem}':" - installed.each{|skill_name| puts " #{skill_name}"} - else - puts "No skills found for gem '#{gem}'" - end - else - installed = @installer.install_all_skills - if installed.any? - puts "Installed #{installed.length} skills:" - installed.each{|skill_name| puts " #{skill_name}"} - else - puts "No gems with skills found" - end - end -end diff --git a/context/getting-started.md b/context/getting-started.md index 8d3d7a3..ce7b2ce 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,54 +1,57 @@ # Getting Started -`agent-skills` discovers and installs reusable agent skills distributed by Ruby gems. +This guide explains how to install package guidance and skills using `agent-context`. ## Installation -```bash -$ bundle add agent-skills -$ bundle exec bake agent:skills:list -$ bundle exec bake agent:skills:install +```sh +bundle add agent-context +bundle exec bake agent:context:install ``` -## Providing Skills +The installer copies guides to `.agents/context//`, installs skills under `.agents/skills/`, and writes `.agents/context/index.md`. It preserves your repository-owned `agents.md`. Add a stable link to the generated index there and instruct agents to read relevant guides. -Provider gems place one or more skill packages beneath a top-level `skills/` directory: +## Commands -```text -your-gem/ -├── skills/ -│ └── ruby-testing/ -│ ├── SKILL.md -│ ├── references/ -│ └── scripts/ -├── lib/ -└── your-gem.gemspec +```sh +bake agent:context:list +bake agent:context:show --gem async --file getting-started +bake agent:context:install --gem async +bake agent:context:index +bake agent:context:skill:list +bake agent:context:skill:install --gem provider --skill provider-workflow +bake agent:context:skill:show --gem provider --skill provider-workflow ``` -Each `SKILL.md` starts with YAML frontmatter containing a `name` and `description`. The name must match the directory. +The main install command installs ordinary context and skills. The skill commands use installed, package-prefixed names for context-declared skills. The former `agent:skills:*` commands are removed. -## Installing Skills +## Provider Layout -Install every discovered skill: +Put ordinary guides in the packaged gem's top-level `context/` directory. Use a clear heading and first prose sentence. An optional YAML `description` overrides the summary. A provider-authored `index.yaml` can still control ordering, titles, and descriptions. -```bash -$ bake agent:skills:install -``` +To provide a skill, put a Markdown document directly inside `context/`: + +```markdown +--- +type: skill +description: Follow the workflow when setting up this package. +--- -Install from a particular gem: +# Workflow -```bash -$ bake agent:skills:install --gem sus +Follow the setup instructions. ``` -Installed skills are copied to `.agents/skills/`. An ownership registry prevents dependencies from overwriting project-authored skills or skills supplied by another gem. +`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. Additional skill metadata is preserved. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Existing `skills//SKILL.md` bundles retain their declared names. -## Inspecting Skills +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. -```bash -$ bake agent:skills:list -$ bake agent:skills:list --gem sus -$ bake agent:skills:show --gem sus --skill ruby-testing -``` +## Ownership and Version Control + +Generated context and dependency-owned skills are reproducible. Edit their provider sources, then reinstall. Project-owned skills and instructions can live under `.agents/` and remain version controlled. + +Installation maintains local Git exclusions for generated context, ownership files, and exact dependency-installed skill directories. Remove blanket `/.agents/` ignore rules when adopting this layout. + +The shared ownership index records ecosystem, package, and version. Full Ruby refreshes remove stale gem-owned skills and preserve Cargo-owned skills. A selected-skill install leaves other skills intact. Failed skill copying or replacement preserves the previous files and index. Legacy ownership files migrate on successful installation. -Review unfamiliar skills before installing them, especially when they include scripts. +When migrating an old generated `agents.md`, replace its generated dependency listing with a stable link to `.agents/context/index.md`, preserving your project instructions. diff --git a/context/index.yaml b/context/index.yaml index 00941ac..f848d61 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -1,7 +1,7 @@ # Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- -description: Install and manage agent skills from Ruby gems. +description: Discover and install dependency skills for Agent Context. metadata: bug_tracker_uri: https://github.com/socketry/agent-skills/issues changelog_uri: https://github.com/socketry/agent-skills/blob/main/releases.md @@ -11,5 +11,5 @@ metadata: files: - path: getting-started.md title: Getting Started - description: "`agent-skills` discovers and installs reusable agent skills distributed - by Ruby gems." + description: This guide explains how to install package guidance and skills using + `agent-context`. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8d3d7a3..ce7b2ce 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,54 +1,57 @@ # Getting Started -`agent-skills` discovers and installs reusable agent skills distributed by Ruby gems. +This guide explains how to install package guidance and skills using `agent-context`. ## Installation -```bash -$ bundle add agent-skills -$ bundle exec bake agent:skills:list -$ bundle exec bake agent:skills:install +```sh +bundle add agent-context +bundle exec bake agent:context:install ``` -## Providing Skills +The installer copies guides to `.agents/context//`, installs skills under `.agents/skills/`, and writes `.agents/context/index.md`. It preserves your repository-owned `agents.md`. Add a stable link to the generated index there and instruct agents to read relevant guides. -Provider gems place one or more skill packages beneath a top-level `skills/` directory: +## Commands -```text -your-gem/ -├── skills/ -│ └── ruby-testing/ -│ ├── SKILL.md -│ ├── references/ -│ └── scripts/ -├── lib/ -└── your-gem.gemspec +```sh +bake agent:context:list +bake agent:context:show --gem async --file getting-started +bake agent:context:install --gem async +bake agent:context:index +bake agent:context:skill:list +bake agent:context:skill:install --gem provider --skill provider-workflow +bake agent:context:skill:show --gem provider --skill provider-workflow ``` -Each `SKILL.md` starts with YAML frontmatter containing a `name` and `description`. The name must match the directory. +The main install command installs ordinary context and skills. The skill commands use installed, package-prefixed names for context-declared skills. The former `agent:skills:*` commands are removed. -## Installing Skills +## Provider Layout -Install every discovered skill: +Put ordinary guides in the packaged gem's top-level `context/` directory. Use a clear heading and first prose sentence. An optional YAML `description` overrides the summary. A provider-authored `index.yaml` can still control ordering, titles, and descriptions. -```bash -$ bake agent:skills:install -``` +To provide a skill, put a Markdown document directly inside `context/`: + +```markdown +--- +type: skill +description: Follow the workflow when setting up this package. +--- -Install from a particular gem: +# Workflow -```bash -$ bake agent:skills:install --gem sus +Follow the setup instructions. ``` -Installed skills are copied to `.agents/skills/`. An ownership registry prevents dependencies from overwriting project-authored skills or skills supplied by another gem. +`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. Additional skill metadata is preserved. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Existing `skills//SKILL.md` bundles retain their declared names. -## Inspecting Skills +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. -```bash -$ bake agent:skills:list -$ bake agent:skills:list --gem sus -$ bake agent:skills:show --gem sus --skill ruby-testing -``` +## Ownership and Version Control + +Generated context and dependency-owned skills are reproducible. Edit their provider sources, then reinstall. Project-owned skills and instructions can live under `.agents/` and remain version controlled. + +Installation maintains local Git exclusions for generated context, ownership files, and exact dependency-installed skill directories. Remove blanket `/.agents/` ignore rules when adopting this layout. + +The shared ownership index records ecosystem, package, and version. Full Ruby refreshes remove stale gem-owned skills and preserve Cargo-owned skills. A selected-skill install leaves other skills intact. Failed skill copying or replacement preserves the previous files and index. Legacy ownership files migrate on successful installation. -Review unfamiliar skills before installing them, especially when they include scripts. +When migrating an old generated `agents.md`, replace its generated dependency listing with a stable link to `.agents/context/index.md`, preserving your project instructions. diff --git a/lib/agent/skills/definition.rb b/lib/agent/skills/definition.rb index 811262c..15663e3 100644 --- a/lib/agent/skills/definition.rb +++ b/lib/agent/skills/definition.rb @@ -2,35 +2,83 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" module Agent module Skills - # A validated skill discovered in a provider gem. + # Represents a validated dependency skill and its bundled resources. class Definition - # Initialize a skill definition. - # - # @parameter name [String] The declared skill name. - # @parameter description [String] A short description used for skill discovery. - # @parameter path [String] The source directory containing `SKILL.md`. - # @parameter provider_name [String] The source gem name. - # @parameter provider_version [String] The source gem version. - def initialize(name:, description:, path:, provider_name:, provider_version:) + # Initialize a skill from a bundle or a context document. + # @parameter name [String] The installed skill name. + # @parameter description [String] When to use the skill. + # @parameter path [String | Nil] The bundle or resource directory. + # @parameter provider_name [String] The provider gem. + # @parameter provider_version [String] The provider version. + # @parameter source_file [String | Nil] The source context document. + # @parameter document [String | Nil] Generated skill instructions. + def initialize(name:, description:, path:, provider_name:, provider_version:, source_file: nil, document: nil) @name = name @description = description @path = path @provider_name = provider_name @provider_version = provider_version + @source_file = source_file + @document = document end + # @attribute [String] The installed skill name. attr_reader :name + # @attribute [String] When to use the skill. attr_reader :description + # @attribute [String | Nil] The bundle or resource directory. attr_reader :path + # @attribute [String] The provider gem. attr_reader :provider_name + # @attribute [String] The provider version. attr_reader :provider_version + # @attribute [String | Nil] The source context document. + attr_reader :source_file - # The path to the skill's required instruction file. + # @returns [String] The source instruction file. def skill_file - File.join(@path, "SKILL.md") + @source_file || File.join(@path, "SKILL.md") + end + + # @returns [String] The installed skill instructions. + def content + @document || File.read(skill_file) + end + + # Write instructions and resources into a staging directory. + # @parameter destination [String] The staging directory. + def write_to(destination) + FileUtils.mkdir_p(destination) + copy_resources(@path, destination, true) if @path + File.write(File.join(destination, "SKILL.md"), @document) if @document + end + + private + + def copy_resources(source, destination, top_level) + Dir.children(source).sort.each do |name| + from = File.join(source, name) + to = File.join(destination, name) + metadata = File.lstat(from) + if metadata.symlink? || (!metadata.file? && !metadata.directory?) + raise Installer::InvalidSkill, "Skill resources must be regular files or directories: #{from}" + end + if @document && top_level && name.downcase == "skill.md" + raise Installer::InvalidSkill, "SKILL.md is reserved for generated instructions: #{from}" + end + if metadata.directory? + FileUtils.mkdir_p(to) + copy_resources(from, to, false) + else + FileUtils.copy_file(from, to, true) + end + end end end end diff --git a/lib/agent/skills/exclusion.rb b/lib/agent/skills/exclusion.rb new file mode 100644 index 0000000..7d08546 --- /dev/null +++ b/lib/agent/skills/exclusion.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "open3" +require "fileutils" +require "tempfile" + +module Agent + module Skills + # Represents local Git exclusions shared by context and skill installers. + class Exclusion + BEGIN_MARKER = "# BEGIN bake-agent-context" + END_MARKER = "# END bake-agent-context" + + # Prepare exclusions while preserving repository-owned rules. + # @parameter root [String] The consuming project root. + # @parameter names [Array(String)] All dependency-owned skills. + def initialize(root, names) + output, status = Open3.capture2e("git", "rev-parse", "--git-path", "info/exclude", chdir: root) + return unless status.success? + @path = File.expand_path(output.strip, root) + @original = File.exist?(@path) ? File.read(@path) : "" + lines = @original.lines(chomp: true) + begins = lines.each_index.select{|index| lines[index].strip == BEGIN_MARKER} + ends = lines.each_index.select{|index| lines[index].strip == END_MARKER} + unless begins.empty? && ends.empty? + unless begins.length == 1 && ends.length == 1 && begins.first < ends.first + raise Registry::Invalid, "Malformed Agent Context exclusion block: #{@path}" + end + lines.slice!(begins.first..ends.first) + end + lines.pop while lines.last == "" + lines << "" unless lines.empty? + lines.concat([ + BEGIN_MARKER, + "# Generated by Bake Agent Context; do not edit this block manually.", + "/.agents/context/", + "/.agents/skills/#{Registry::FILE_NAME}", + "/.agents/skills/.agent-skills.yaml", + *names.sort.map{|name| "/.agents/skills/#{name}/"}, + END_MARKER, + ]) + @updated = lines.join("\n") + "\n" + rescue Errno::ENOENT + # Installation also works when Git is unavailable: + @path = nil + end + + # Apply the prepared exclusion block. + def apply + write(@updated) if @path && @updated != @original + end + + # Restore exclusions if installation fails. + def restore + write(@original) if @path && @updated != @original + end + + private + + def write(content) + FileUtils.mkdir_p(File.dirname(@path)) + Tempfile.create(".agent-context-exclude-", File.dirname(@path)) do |file| + file.write(content) + file.flush + File.rename(file.path, @path) + end + end + end + end +end diff --git a/lib/agent/skills/installer.rb b/lib/agent/skills/installer.rb index 1db6bb9..e80c6e5 100644 --- a/lib/agent/skills/installer.rb +++ b/lib/agent/skills/installer.rb @@ -2,251 +2,265 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. require "fileutils" require "rubygems" require "yaml" +require "tmpdir" require_relative "definition" require_relative "registry" +require_relative "exclusion" module Agent module Skills - # Discovers and installs skills provided by Ruby gems. + # Represents discovery and installation of skills from resolved gems. class Installer NAME_PATTERN = Registry::NAME_PATTERN - MAXIMUM_NAME_LENGTH = 100 - MAXIMUM_DESCRIPTION_LENGTH = 500 + MAXIMUM_NAME_LENGTH = 64 + MAXIMUM_DESCRIPTION_LENGTH = 1024 - # Base error for skill discovery and installation failures. + # Raised when a skill cannot be installed. class Error < StandardError end - - # Raised when a provider contains an invalid skill. + # Raised when skill metadata or resources are invalid. class InvalidSkill < Error end - - # Raised when a skill would overwrite content owned by another source. + # Raised when a destination has ambiguous or foreign ownership. class Conflict < Error end - # Initialize a skill installer. - # + # Initialize discovery from the project's gem specifications. # @parameter root [String] The consuming project root. - # @parameter specifications [Gem::Specification] The gem specifications to scan. + # @parameter specifications [Enumerable] The resolved gem specifications. def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @skills_path = File.join(@root, ".agents", "skills") - @specifications = specifications + @specifications = specifications.to_a end + # @attribute [String] The consuming project root. attr_reader :root + # @attribute [String] The installed skill directory. attr_reader :skills_path - # Find installed gems which provide one or more valid skills. - # - # @parameter skip_local [Boolean] Whether to skip a gem loaded from the consuming project root. + # Find skill providers. + # @parameter skip_local [bool] Whether to exclude the consuming gem. + # @returns [Array(Hash)] The discovered providers. def find_gems_with_skills(skip_local: true) @specifications.filter_map do |specification| - next if skip_local && same_path?(specification.full_gem_path, @root) - - build_gem_information(specification) + next if skip_local && File.expand_path(specification.full_gem_path) == @root + information = build_gem_information(specification) + information unless information[:skills].empty? end end - # Find a named gem which provides skills. - # - # @parameter gem_name [String] The gem name to find. + # Find a skill provider by gem name. + # @parameter gem_name [String] The provider gem. + # @returns [Hash | Nil] The discovered provider. def find_gem_with_skills(gem_name) - specification = @specifications.find{|candidate| candidate.name == gem_name} + specification = find_specification(gem_name) return unless specification - - build_gem_information(specification) + information = build_gem_information(specification) + information unless information[:skills].empty? end - # List skills provided by a named gem. - # - # @parameter gem_name [String] The gem name. + # List a gem's skills. + # @parameter gem_name [String] The provider gem. + # @returns [Array(Definition) | Nil] The provided skills. def list_skills(gem_name) - gem = find_gem_with_skills(gem_name) - gem && gem[:skills] + find_gem_with_skills(gem_name)&.fetch(:skills) end - # Read the instruction file for a skill provided by a gem. - # - # @parameter gem_name [String] The provider gem name. - # @parameter skill_name [String] The declared skill name. + # Read a skill's generated instruction file. + # @parameter gem_name [String] The provider gem. + # @parameter skill_name [String] The installed skill name. + # @returns [String | Nil] The instructions. def show_skill(gem_name, skill_name) - skills = list_skills(gem_name) - return unless skills - - definition = skills.find{|skill| skill.name == skill_name} - File.read(definition.skill_file) if definition + list_skills(gem_name)&.find{|skill| skill.name == skill_name}&.content end - # Install all skills provided by a named gem. - # - # @parameter gem_name [String] The provider gem name. - # @returns [Array(String), nil] Installed skill names, or `nil` if the gem provides no skills. + # Install or reconcile one provider's skills, including an empty provider. + # @parameter gem_name [String] The provider gem. + # @returns [Array(String) | Nil] Installed names, or nil for a missing gem. def install_gem_skills(gem_name) - gem = find_gem_with_skills(gem_name) - return unless gem - - install_gems([gem]) + return unless find_specification(gem_name) + install(gem: gem_name) end - # Install skills from every discovered gem. - # - # @parameter skip_local [Boolean] Whether to skip a gem loaded from the consuming project root. - # @returns [Array(String)] Installed skill names. + # Install and reconcile skills owned by resolved gems. + # @parameter skip_local [bool] Whether to exclude the consuming gem. + # @returns [Array(String)] The installed skill names. def install_all_skills(skip_local: true) - install_gems(find_gems_with_skills(skip_local: skip_local)) + install(skip_local: skip_local) + end + + # Install selected skills and reconcile only the corresponding ownership scope. + # @parameter gem [String | Nil] An optional provider gem. + # @parameter skill [String | Nil] An optional installed skill name. + # @parameter skip_local [bool] Whether to exclude the consuming gem. + # @returns [Array(String)] The installed skill names. + def install(gem: nil, skill: nil, skip_local: true) + if gem + specification = find_specification(gem) + raise Error, "No gem found: #{gem}" unless specification + providers = [build_gem_information(specification)] + else + providers = find_gems_with_skills(skip_local: skip_local) + end + definitions = providers.flat_map{|provider| provider[:skills]} + if skill + definitions.select!{|definition| definition.name == skill} + raise Error, "No dependency skill found: #{skill}" if definitions.empty? + end + install_definitions(definitions, package: gem, reconcile: !skill) end private + def find_specification(name) + matches = @specifications.select{|specification| specification.name == name} + raise Conflict, "Multiple resolved versions provide gem #{name.inspect}" if matches.length > 1 + matches.first + end + def build_gem_information(specification) - source_path = File.join(specification.full_gem_path, "skills") - return unless Dir.exist?(source_path) - - skills = discover_skills(source_path, specification.name, specification.version.to_s) - return if skills.empty? - - { - name: specification.name, - version: specification.version.to_s, - summary: specification.summary, - metadata: specification.metadata, - path: source_path, - skills: skills, - } - end - - def discover_skills(source_path, provider_name, provider_version) - Dir.children(source_path).sort.filter_map do |entry| - skill_path = File.join(source_path, entry) - next unless File.directory?(skill_path) - - skill_file = File.join(skill_path, "SKILL.md") - next unless File.file?(skill_file) - - metadata = load_skill_metadata(skill_file) - validate_skill_metadata(metadata, entry, skill_file) - - Definition.new( - name: metadata["name"], - description: metadata["description"], - path: skill_path, - provider_name: provider_name, - provider_version: provider_version, - ) + bundle_root = File.join(specification.full_gem_path, "skills") + context_root = File.join(specification.full_gem_path, "context") + skills = discover_bundles(bundle_root, specification) + discover_context_skills(context_root, specification) + {name: specification.name, version: specification.version.to_s, skills: skills.sort_by(&:name)} + end + + def discover_bundles(root, specification) + return [] unless File.directory?(root) + raise InvalidSkill, "Skill provider must be a regular directory: #{root}" if File.symlink?(root) + Dir.children(root).sort.filter_map do |name| + directory = File.join(root, name) + file = File.join(directory, "SKILL.md") + next unless File.directory?(directory) && File.file?(file) + raise InvalidSkill, "Skill bundles cannot be symlinks: #{directory}" if File.symlink?(directory) || File.symlink?(file) + metadata, = parse_document(file, required: true) + validate_metadata(metadata, name, file) + Definition.new(name: name, description: metadata["description"], path: directory, provider_name: specification.name, provider_version: specification.version.to_s) end end - def load_skill_metadata(skill_file) - content = File.read(skill_file) - match = content.match(/\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m) - - unless match - raise InvalidSkill, "Skill must begin with YAML frontmatter: #{skill_file}" + def discover_context_skills(root, specification) + return [] unless File.directory?(root) + raise InvalidSkill, "Context provider must be a regular directory: #{root}" if File.symlink?(root) + skills = [] + files = Dir.glob(File.join(root, "**", "*")).sort_by{|file| [File.dirname(file) == root ? 0 : 1, file]} + files.each do |file| + next if skills.any?{|skill| skill.path && file.start_with?("#{skill.path}/")} + next unless File.file?(file) && File.extname(file).downcase == ".md" && !File.symlink?(file) + metadata, body = parse_document(file) + next unless metadata["type"] + raise InvalidSkill, "Unsupported context type in #{file}: #{metadata["type"].inspect}" unless metadata["type"] == "skill" + raise InvalidSkill, "Skill documents must be directly inside context/: #{file}" unless File.dirname(file) == root + local_name = File.basename(file, File.extname(file)) + installed_name = "#{specification.name.downcase.tr("_", "-")}-#{local_name}" + validate_name(local_name, file) + metadata = metadata.merge("name" => installed_name) + metadata.delete("type") + validate_metadata(metadata, installed_name, file) + assets = File.join(root, local_name) + if File.exist?(assets) || File.symlink?(assets) + raise InvalidSkill, "Skill resources must be a regular directory: #{assets}" unless File.lstat(assets).directory? + else + assets = nil + end + document = "---\n#{YAML.dump(metadata).delete_prefix("---\n")}---\n\n#{body}" + document += "\n" unless document.end_with?("\n") + skills << Definition.new(name: installed_name, description: metadata["description"], path: assets, source_file: file, document: document, provider_name: specification.name, provider_version: specification.version.to_s) end - - metadata = YAML.safe_load(match[1], aliases: false) - unless metadata.is_a?(Hash) - raise InvalidSkill, "Skill frontmatter must be a mapping: #{skill_file}" + skills + end + + def parse_document(file, required: false) + content = File.read(file) + match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) + if !match + raise InvalidSkill, "Skill must begin with YAML frontmatter: #{file}" if required + return [{}, content] end - - metadata + metadata = YAML.safe_load(match[1], aliases: false) + raise InvalidSkill, "Frontmatter must be a mapping: #{file}" unless metadata.is_a?(Hash) + [metadata, content[match.end(0)..].sub(/\A\r?\n/, "")] rescue Psych::Exception => error - raise InvalidSkill, "Invalid skill frontmatter in #{skill_file}: #{error.message}" + raise InvalidSkill, "Invalid YAML frontmatter in #{file}: #{error.message}" end - def validate_skill_metadata(metadata, directory_name, skill_file) - name = metadata["name"] - description = metadata["description"] - + def validate_name(name, file) unless name.is_a?(String) && name.match?(NAME_PATTERN) && name.length <= MAXIMUM_NAME_LENGTH - raise InvalidSkill, "Invalid skill name in #{skill_file}: #{name.inspect}" - end - - unless name == directory_name - raise InvalidSkill, "Skill name #{name.inspect} must match its directory #{directory_name.inspect}" - end - - unless description.is_a?(String) && !description.strip.empty? && description.length <= MAXIMUM_DESCRIPTION_LENGTH - raise InvalidSkill, "Invalid skill description in #{skill_file}" + raise InvalidSkill, "Invalid skill name in #{file}: #{name.inspect}" end end - def install_gems(gems) - definitions = gems.flat_map{|gem| gem[:skills]} - return [] if definitions.empty? - - validate_unique_skills(definitions) - - registry = Registry.new(File.join(@skills_path, Registry::FILE_NAME)) - definitions.each{|definition| validate_destination(definition, registry)} - - stale_skills = gems.flat_map do |gem| - current_names = gem[:skills].map(&:name) - registry.skills_for(gem[:name]).keys - current_names - end - - definitions.each do |definition| - registry.claim(definition.name, definition.provider_name, definition.provider_version) + def validate_metadata(metadata, name, file) + validate_name(metadata["name"], file) + raise InvalidSkill, "Skill name must match its directory: #{file}" unless metadata["name"] == name + description = metadata["description"] + unless description.is_a?(String) && !description.strip.empty? && description.strip.length <= MAXIMUM_DESCRIPTION_LENGTH + raise InvalidSkill, "Invalid skill description in #{file}" end - stale_skills.each{|skill_name| registry.release(skill_name)} - registry.save - - definitions.each{|definition| copy_skill(definition)} - stale_skills.each{|skill_name| remove_skill(skill_name)} - - definitions.map(&:name) + metadata["description"] = description.strip end - def validate_unique_skills(definitions) + def install_definitions(definitions, package:, reconcile:) duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1} - return if duplicates.empty? - - details = duplicates.map do |name, matches| - providers = matches.map(&:provider_name).uniq.join(", ") - "#{name} (#{providers})" + raise Conflict, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? + if File.exist?(@skills_path) || File.symlink?(@skills_path) + raise Conflict, "Skill installation path must be a regular directory: #{@skills_path}" unless File.lstat(@skills_path).directory? end - - raise Conflict, "Multiple gems provide the same skill: #{details.join('; ')}" - end - - def validate_destination(definition, registry) - destination = File.join(@skills_path, definition.name) - owner = registry.owner(definition.name) - - if owner && owner["gem"] != definition.provider_name - raise Conflict, "Skill #{definition.name.inspect} is owned by gem #{owner['gem'].inspect}" - end - - if path_exists?(destination) && !owner - raise Conflict, "Skill #{definition.name.inspect} already exists and is not managed by agent-skills" + registry = Registry.new(File.join(@skills_path, Registry::FILE_NAME)) + definitions.each do |definition| + owner = registry.owner(definition.name) + destination = File.join(@skills_path, definition.name) + if owner && owner.values_at("ecosystem", "package") != ["gem", definition.provider_name] + raise Conflict, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" + end + if (File.exist?(destination) || File.symlink?(destination)) && !owner + raise Conflict, "Skill #{definition.name.inspect} is not managed by Agent Context" + end end - end - - def copy_skill(definition) + names = definitions.map(&:name) + stale = reconcile ? registry.skills_for(package).keys - names : [] + definitions.each{|definition| registry.claim(definition.name, definition.provider_name, definition.provider_version)} + stale.each{|name| registry.release(name)} + exclusion = Exclusion.new(@root, registry.owners.keys) FileUtils.mkdir_p(@skills_path) - destination = File.join(@skills_path, definition.name) - FileUtils.rm_rf(destination) if path_exists?(destination) - FileUtils.cp_r(definition.path, destination, preserve: true) - end - - def remove_skill(skill_name) - destination = File.join(@skills_path, skill_name) - FileUtils.rm_rf(destination) if path_exists?(destination) - end - - def path_exists?(path) - File.exist?(path) || File.symlink?(path) - end - - def same_path?(left, right) - File.expand_path(left) == File.expand_path(right) + Dir.mktmpdir(".agent-context-staging-", @skills_path) do |stage| + staged = File.join(stage, "new") + backups = File.join(stage, "backups") + FileUtils.mkdir_p([staged, backups]) + definitions.each{|definition| definition.write_to(File.join(staged, definition.name))} + changes = [] + legacy = File.join(@skills_path, ".agent-skills.yaml") + begin + exclusion.apply + (names + stale).each do |name| + destination = File.join(@skills_path, name) + backup = File.join(backups, name) + previous = File.exist?(destination) || File.symlink?(destination) + File.rename(destination, backup) if previous + changes << [name, previous] + File.rename(File.join(staged, name), destination) if names.include?(name) + end + File.rename(legacy, File.join(stage, "legacy.yaml")) if File.exist?(legacy) + registry.save + rescue + changes.reverse_each do |name, previous| + destination = File.join(@skills_path, name) + FileUtils.rm_rf(destination) + File.rename(File.join(backups, name), destination) if previous + end + File.rename(File.join(stage, "legacy.yaml"), legacy) if File.exist?(File.join(stage, "legacy.yaml")) + exclusion.restore + raise + end + end + names end end end diff --git a/lib/agent/skills/registry.rb b/lib/agent/skills/registry.rb index 3eda18d..e7ae04a 100644 --- a/lib/agent/skills/registry.rb +++ b/lib/agent/skills/registry.rb @@ -2,116 +2,132 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. require "fileutils" +require "json" require "yaml" +require "tempfile" module Agent module Skills - # Tracks which installed skills are owned by which gems. + # Represents the shared ownership index for dependency-installed skills. class Registry - FILE_NAME = ".agent-skills.yaml" - FORMAT_VERSION = 1 + FILE_NAME = ".agent-context-skills.json" + FORMAT_VERSION = 2 NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ - # Raised when the registry cannot be safely interpreted. + # Raised when ownership cannot be safely interpreted. class Invalid < StandardError end - # Initialize a registry stored at the given path. - # - # @parameter path [String] The registry file path. + # Load shared ownership and import legacy Ruby ownership. + # @parameter path [String] The shared ownership index. def initialize(path) @path = path @data = load_data + import_legacy_ruby end + # @attribute [String] The path of the shared ownership index. attr_reader :path - # Get ownership metadata for a skill. - # - # @parameter skill_name [String] The installed skill name. - def owner(skill_name) - @data["skills"][skill_name] + # @attribute [Hash] The recorded skill owners. + def owners + @data["skills"] end - # Get all skills owned by a gem. - # - # @parameter gem_name [String] The provider gem name. - def skills_for(gem_name) - @data["skills"].select do |_skill_name, owner| - owner["gem"] == gem_name + # Find a skill's owner. + # @parameter name [String] The installed skill name. + # @returns [Hash | Nil] The recorded owner. + def owner(name) + owners[name] + end + + # Find skills owned by an ecosystem and optional package. + # @parameter package [String | Nil] The selected provider. + # @parameter ecosystem [String] The provider ecosystem. + # @returns [Hash] Matching skill owners. + def skills_for(package = nil, ecosystem: "gem") + owners.select do |_name, owner| + owner["ecosystem"] == ecosystem && (!package || owner["package"] == package) end end - # Record ownership of an installed skill. - # - # @parameter skill_name [String] The installed skill name. - # @parameter gem_name [String] The provider gem name. - # @parameter gem_version [String] The provider gem version. - def claim(skill_name, gem_name, gem_version) - @data["skills"][skill_name] = { - "gem" => gem_name, - "version" => gem_version, - } + # Record dependency ownership. + # @parameter name [String] The installed skill name. + # @parameter package [String] The provider package. + # @parameter version [String] The provider version. + # @parameter ecosystem [String] The provider ecosystem. + def claim(name, package, version, ecosystem: "gem") + owners[name] = {"ecosystem" => ecosystem, "package" => package, "version" => version} end - # Remove ownership information for a skill. - # - # @parameter skill_name [String] The installed skill name. - def release(skill_name) - @data["skills"].delete(skill_name) + # Release skill ownership. + # @parameter name [String] The installed skill name. + def release(name) + owners.delete(name) end - # Persist the registry atomically. + # Persist shared ownership using an atomic rename. def save FileUtils.mkdir_p(File.dirname(@path)) - - content = { - "version" => FORMAT_VERSION, - "skills" => @data["skills"].sort.to_h, - }.to_yaml - - temporary_path = "#{@path}.#{Process.pid}.tmp" - File.write(temporary_path, content) - File.rename(temporary_path, @path) - ensure - FileUtils.rm_f(temporary_path) if temporary_path + Tempfile.create([".agent-context-registry-", ".json"], File.dirname(@path)) do |file| + file.write(JSON.pretty_generate({"version" => FORMAT_VERSION, "skills" => owners.sort.to_h}) + "\n") + file.flush + File.rename(file.path, @path) + end end private def load_data - return default_data unless File.exist?(@path) - - data = YAML.safe_load(File.read(@path), aliases: false) - validate_data(data) + return {"version" => FORMAT_VERSION, "skills" => {}} unless File.exist?(@path) || File.symlink?(@path) + raise Invalid, "Skill ownership index must be a regular file: #{@path}" unless File.lstat(@path).file? + data = JSON.parse(File.read(@path)) + unless data.is_a?(Hash) && [1, FORMAT_VERSION].include?(data["version"]) && data["skills"].is_a?(Hash) + raise Invalid, "Invalid skill ownership index: #{@path}" + end + if data["version"] == 1 + data["skills"].each_value{|owner| owner["ecosystem"] = "cargo" if owner.is_a?(Hash)} + end + data["version"] = FORMAT_VERSION + validate_owners(data["skills"]) data - rescue Psych::Exception => error - raise Invalid, "Could not load skill registry #{@path}: #{error.message}" + rescue JSON::ParserError => error + raise Invalid, "Invalid skill ownership index #{@path}: #{error.message}" end - def validate_data(data) - unless data.is_a?(Hash) && data["version"] == FORMAT_VERSION && data["skills"].is_a?(Hash) - raise Invalid, "Invalid skill registry format: #{@path}" + def import_legacy_ruby + legacy_path = File.join(File.dirname(@path), ".agent-skills.yaml") + return unless File.exist?(legacy_path) || File.symlink?(legacy_path) + raise Invalid, "Legacy skill registry must be a regular file: #{legacy_path}" unless File.lstat(legacy_path).file? + legacy = YAML.safe_load_file(legacy_path, aliases: false) + unless legacy.is_a?(Hash) && legacy["version"] == 1 && legacy["skills"].is_a?(Hash) + raise Invalid, "Invalid legacy skill registry: #{legacy_path}" end - - data["skills"].each do |skill_name, owner| - unless skill_name.is_a?(String) && skill_name.match?(NAME_PATTERN) - raise Invalid, "Invalid skill name in registry: #{skill_name.inspect}" - end - + legacy["skills"].each do |name, owner| unless owner.is_a?(Hash) && owner["gem"].is_a?(String) && owner["version"].is_a?(String) - raise Invalid, "Invalid owner for skill #{skill_name.inspect}" + raise Invalid, "Invalid legacy owner for #{name.inspect}" + end + converted = {"ecosystem" => "gem", "package" => owner["gem"], "version" => owner["version"]} + existing = owners[name] + if existing && existing.values_at("ecosystem", "package") != converted.values_at("ecosystem", "package") + raise Invalid, "Conflicting legacy ownership for #{name.inspect}" end + owners[name] ||= converted end + validate_owners(owners) + rescue Psych::Exception => error + raise Invalid, "Invalid legacy skill registry #{legacy_path}: #{error.message}" end - def default_data - { - "version" => FORMAT_VERSION, - "skills" => {}, - } + def validate_owners(owners) + owners.each do |name, owner| + unless name.is_a?(String) && name.match?(NAME_PATTERN) && name.length <= 64 && owner.is_a?(Hash) && owner.values_at("ecosystem", "package", "version").all?{|value| value.is_a?(String) && !value.empty?} + raise Invalid, "Invalid owner for skill #{name.inspect}" + end + end end end end diff --git a/lib/agent/skills/version.rb b/lib/agent/skills/version.rb index 57376c9..4358ffe 100644 --- a/lib/agent/skills/version.rb +++ b/lib/agent/skills/version.rb @@ -6,6 +6,6 @@ module Agent module Skills - VERSION = "0.0.3" + VERSION = "0.1.0" end end diff --git a/readme.md b/readme.md index 6a186f9..44275e2 100644 --- a/readme.md +++ b/readme.md @@ -1,143 +1,23 @@ # Agent Skills -Provides tools for discovering and installing agent skills distributed by Ruby gems. +Provides the Ruby discovery and installation layer used by `agent-context`. -[![Development Status](https://github.com/socketry/agent-skills/workflows/Test/badge.svg)](https://github.com/socketry/agent-skills/actions?workflow=Test) +Run package commands through `agent-context`: -## Overview - -Ruby gems can provide reusable agent workflows in a top-level `skills/` directory. `agent-skills` discovers those packages and installs them into the consuming project's `.agents/skills/` directory. - -Installed skills are copied into the project so they can be used independently of the provider gem's installation path. - -## Quick Start - -Add the gem to your project: - -``` bash -$ bundle add agent-skills +```sh +bake agent:context:install +bake agent:context:skill:list +bake agent:context:skill:install --gem provider --skill provider-workflow +bake agent:context:skill:show --gem provider --skill provider-workflow ``` -List available skills: - -``` bash -$ bundle exec bake agent:skills:list -``` - -Install all available skills: - -``` bash -$ bundle exec bake agent:skills:install -``` - -Install skills from a specific gem: - -``` bash -$ bundle exec bake agent:skills:install --gem sus -``` - -## Providing Skills in a Gem - -Create a top-level `skills/` directory containing one directory per skill: - -``` text -your-gem/ -├── skills/ -│ └── ruby-testing/ -│ ├── SKILL.md -│ ├── scripts/ -│ ├── references/ -│ └── assets/ -├── lib/ -└── your-gem.gemspec -``` - -Each `SKILL.md` begins with YAML frontmatter: - -``` markdown ---- -name: ruby-testing -description: Test Ruby projects using the project's configured test framework. ---- - -# Ruby Testing - -Read the project instructions and run the narrowest relevant tests first. -``` - -The declared `name` must match its containing directory. Ensure `skills/**/*` is included in the gem's packaged files. - -## Installation Safety - -The installer records gem-owned skills in `.agents/skills/.agent-skills.yaml`. +The `agent:skills:*` commands are removed. - - Existing project-authored skills are never overwritten. - - Different gems cannot install the same skill name. - - A gem can update or remove only skills previously recorded as belonging to that gem. - - Invalid `SKILL.md` metadata stops installation with an error. +Providers may publish `context/.md` documents with `type: skill` and a non-empty `description`, plus resources under `context//`. Installed names are package-prefixed. Existing `skills//SKILL.md` bundles retain their declared names. Both formats use the 64-character name and 1,024-character description limits and preserve extra metadata. -Skills may include scripts and operational instructions. Review the skills supplied by dependencies before installing them. +The shared ownership index at `.agents/skills/.agent-context-skills.json` records ecosystem, package, and version. Ruby installation migrates legacy ownership, stages replacements, preserves project-owned and Cargo-owned skills, and reconciles empty or removed gem providers. Only generated paths are excluded through local Git rules. -The generated `.agents/` directory should be excluded from version control. Run installation during project setup and in CI when skills are required. - -## Commands - -``` bash -# List every gem which provides skills: -bake agent:skills:list - -# List skills from one gem: -bake agent:skills:list --gem sus - -# Show a skill's SKILL.md: -bake agent:skills:show --gem sus --skill ruby-testing - -# Install all discovered skills: -bake agent:skills:install - -# Install skills from one gem: -bake agent:skills:install --gem sus -``` - -## Context - -This gem provides contextual documentation in `context/` for use with [`agent-context`](https://github.com/socketry/agent-context). - -## Releases - -Please see the [project releases](https://socketry.github.io/agent-skills/releases/index) for all releases. - -### v0.0.0 - - - Initial release. - - Discover and validate skills provided by Ruby gems. - - Install project-scoped skills with ownership and collision protection. - -## Contributing - -We welcome contributions to this project. - -1. Fork the repository. -2. Create your feature branch (`git checkout -b my-new-feature`). -3. Commit your changes (`git commit -am 'Add some feature.'`). -4. Push to the branch (`git push origin my-new-feature`). -5. Create a new pull request. - -### Running Tests - -To run the test suite: - -``` bash -$ bundle exec sus -``` - -### Making Releases - -To make a new release: - -``` bash -$ bundle exec bake gem:release:patch # or minor or major -``` +See [the packaging specification](specification.md) and [license.md](license.md). ### Developer Certificate of Origin diff --git a/specification.md b/specification.md index a30728e..b0ac8a5 100644 --- a/specification.md +++ b/specification.md @@ -1,101 +1,231 @@ -# Agent Skills Packaging Specification +# Agent Context Specification -## 1. Purpose +## 1. Introduction -This specification defines how Ruby gems can distribute reusable agent skills and how consuming projects install those skills. +### 1.1 Purpose -The packaging convention complements the Agent Skills format: it describes transport through Ruby gems, ownership, and installation without redefining the internal semantics of a skill. +Agent Context is a language-agnostic specification for providing and consuming contextual information from software packages to assist AI agents and other automated tools. This specification defines a standardized way for package authors to include supplementary documentation, examples, migration guides, and other contextual information that can be programmatically discovered and utilized. -## 2. Provider Layout +### 1.2 Problem Statement -A provider gem MUST place skills in a top-level `skills/` directory. Each immediate child directory represents one skill and MUST contain `SKILL.md`. +AI agents and automated tools working with software projects often lack access to the rich contextual information that package authors provide. While packages may have extensive documentation, examples, and best practices, this information is typically scattered across various sources and formats, making it difficult for automated tools to discover and utilize effectively. -```text -package-root/ -├── skills/ -│ ├── first-skill/ -│ │ ├── SKILL.md -│ │ └── references/ -│ └── second-skill/ -│ ├── SKILL.md -│ └── scripts/ -├── lib/ -└── package.gemspec -``` +### 1.3 Goals + +- **Standardization**: Define a consistent structure for contextual information across different programming languages and ecosystems. +- **Discoverability**: Enable automated tools to programmatically find and access contextual information. +- **Separation of Concerns**: Clearly separate provider context from consumer context. +- **Extensibility**: Allow for future enhancements while maintaining backward compatibility. + +## 2. Core Concepts + +### 2.1 Context Provider + +A **Context Provider** is any software package, library, or module that includes contextual information in its distribution. Context providers: + +- Include a `context/` directory in their package root. +- Provide structured documentation and examples. +- Follow the file format specifications defined in this document. +- Version their context alongside their code. + +### 2.2 Context Consumer + +A **Context Consumer** is any project or tool that utilizes contextual information from its dependencies. Context consumers: + +- Install context from their dependencies into a `.agents/context/` directory. +- May install skill-marked context documents into a `.agents/skills/` directory. +- Use tools to discover and access available context. +- Apply context based on file patterns and metadata. + +### 2.3 Context Files + +**Context Files** are structured documents that contain supplementary information about a package. They typically include: -The provider MUST include the complete `skills/` tree in the packaged gem. +- Documentation beyond basic API references. +- Configuration examples and templates. +- Migration guides between versions. +- Performance optimization tips. +- Security considerations. +- Troubleshooting guides. -## 3. Skill Metadata +### 2.4 Context Installation -`SKILL.md` MUST begin with YAML frontmatter containing: +**Context Installation** is the process of copying context files from dependencies into a consumer's local context directory, making them available for use by tools and AI agents. -- `name`: A lowercase, hyphen-separated identifier. -- `description`: A concise explanation of when the skill applies. +## 3. Directory Structure -The declared `name` MUST match the name of the skill directory. +### 3.1 Provider Directory: `context/` -Additional metadata MAY be present and is preserved as part of the installed skill. +Context providers MUST include a `context/` directory in their package root. This directory: -## 4. Consumer Layout +- **Location**: Must be at the top level of the package (same level as main source directories). +- **Purpose**: Contains all contextual information provided by the package. +- **Distribution**: Must be included in the package's distribution artifacts. +- **Versioning**: Must be versioned alongside the package code. -Skills are installed into `.agents/skills/` at the consuming project root: +Example structure: -```text +``` +package-root/ +├── context/ +│ ├── getting-started.md +│ ├── configuration.md +│ ├── troubleshooting.md +│ └── migration/ +│ └── v2-to-v3.md +├── src/ +└── package.json +``` + +### 3.2 Consumer Directory: `.agents/context/` + +Context consumers SHOULD create a `.agents/context/` directory in their project root to store installed context. This directory: + +- **Location**: Must be at the project root (typically where package manifests are located). +- **Purpose**: Contains context files copied from dependencies. +- **Organization**: Must organize context by package name in subdirectories. +- **Exclusion**: The generated `.agents/context/` directory SHOULD be excluded from version control. A Git integration SHOULD add local exclusions to `.git/info/exclude` so projects do not need to commit rules for generated files. Other project-owned files may live directly under `.agents/`. +- **Transient Nature**: Should contain only reproducible content that can be regenerated from installed packages and MUST NOT contain unique or modified files. + +Example structure: + +``` project-root/ -└── .agents/ - └── skills/ - ├── first-skill/ - ├── second-skill/ - └── .agent-skills.yaml +├── .agents/context/ +│ ├── package-a/ +│ │ ├── getting-started.md +│ │ └── configuration.md +│ └── package-b/ +│ └── troubleshooting.md +├── src/ +└── package.json ``` -Skills are installed directly beneath `.agents/skills/` because skill names form a shared project-level namespace. +### 3.3 Directory Separation Rationale + +The separation between `context/` and `.agents/context/` serves several purposes: + +- **Ownership**: `context/` is controlled by the project itself, `.agents/context/` contains external dependencies. +- **Isolation**: Prevents conflicts between different packages' context files. +- **Discoverability**: Makes it easy to find context for specific packages. +- **Maintenance**: Allows independent management of provided vs. consumed context. -## 5. Discovery +## 4. Context File Format -An installer discovers provider gems by: +### 4.1 File Extensions -1. Enumerating installed Ruby gem specifications. -2. Finding top-level `skills/` directories. -3. Finding immediate child directories containing `SKILL.md`. -4. Parsing and validating required frontmatter. +Context files SHOULD use the following extensions: -Directories without `SKILL.md` are not considered skills. +- **`.md`**: Markdown files (primary format). +- **`.txt`**: Plain text files. +- **`.yaml`** or **`.yml`**: YAML configuration files. +- **`.json`**: JSON configuration files. -## 6. Installation +### 4.2 Markdown Context Files -Installation MUST copy the entire skill directory and preserve its internal structure. +Markdown files are the primary format for context files. They: -Before modifying the destination, the installer MUST verify that: +- MUST use valid Markdown syntax. +- SHOULD be named descriptively (e.g., `getting-started.md`, `configuration.md`). +- SHOULD include clear section headers for organization. -- No other provider in the same operation supplies the same skill name. -- An existing destination is recorded as belonging to the same provider. -- A project-authored or otherwise unmanaged destination will not be overwritten. +### 4.3 File Naming Conventions -When a provider is reinstalled, skills formerly owned by that provider but no longer present MAY be removed. +Context files SHOULD follow these naming conventions: -## 7. Ownership Registry +- Use lowercase with hyphens for word separation. +- Be descriptive and specific. +- Group related files in subdirectories when appropriate. -The `.agents/skills/.agent-skills.yaml` file records installed skill ownership. Its initial format is: +Common file names: -```yaml -version: 1 -skills: - ruby-testing: - gem: sus - version: 1.0.0 +- `getting-started.md` +- `configuration.md` +- `troubleshooting.md` +- `performance.md` +- `security.md` +- `migration-guide.md` + +### 4.4 Skill Documents + +A context provider may distribute an Agent Skill as a Markdown document directly inside `context/`. The document MUST use YAML front matter with `type: skill` and a non-empty `description`. Its filename, without the `.md` extension, is the local skill name. A directory with the same name MAY contain skill resources. Consumers MUST prefix the local name with the lowercase provider package name (with underscores normalized to hyphens) and a hyphen to produce the installed skill name. Installed names MUST use lowercase ASCII letters, digits, and single hyphens, contain 1–64 characters, and begin and end with a letter or digit. Descriptions MUST contain 1–1,024 characters after trimming whitespace. Consumers MUST validate these fields and preserve additional skill metadata; generated installed names take precedence over a source `name` field. + +A consumer MAY also accept legacy `skills//SKILL.md` bundles, preserving their declared names and complete contents. Such bundles MUST meet the same metadata limits. + +Consumers that support skills MUST install the document as `.agents/skills/-/SKILL.md`, with the installed name and `description` in its front matter. Files in the matching resource directory MUST be copied into that skill directory. Skill documents and their resources MUST NOT also be copied into `.agents/context/` or included in the generated context index. + +Dependency-installed skill directories SHOULD be excluded from version control, while project-owned skills in `.agents/skills/` SHOULD remain trackable. Git integrations SHOULD add exact dependency-installed skill paths to `.git/info/exclude` rather than ignore the entire `.agents/skills/` directory. They SHOULD mark generated entries with comments and preserve user-authored rules outside that marked section. Any generated skill ownership registry SHOULD also be excluded from version control. + +## 5. Discovery and Installation + +### 5.1 Discovery Process + +Tools MUST be able to discover context by: + +1. **Package Scanning**: Examining installed packages for `context/` directories. +2. **Metadata Extraction**: Reading package manifests to identify context-providing packages. +3. **File Enumeration**: Listing available context files for each package. + +The discovery process SHOULD integrate with the target language's package management system to: + +- Locate installed packages and their installation paths. +- Read package metadata to identify packages that provide context. +- Enumerate available context files within discovered packages. + +### 5.2 Installation Process + +Context installation MUST follow these principles: + +1. **Copy Strategy**: Context files SHOULD be copied rather than symlinked. +2. **Namespace Isolation**: Each package's ordinary context MUST be installed in its own subdirectory. +3. **Preserve Structure**: The internal structure of ordinary context files MUST be preserved. +4. **Skill Separation**: Skill documents and their resources MUST be installed as skills only, not duplicated as ordinary context. + +### 5.3 Installation Algorithm + +``` +FOR each package with context: + FOR each file in package/context/: + IF file is a skill document or belongs to a skill resource directory: + IF the consumer supports skills: + INSTALL the document as .agents/skills/skill-name/SKILL.md + COPY its matching resource directory into that skill directory + ELSE: + SKIP it + ELSE: + COPY it into .agents/context/package-name/, preserving its relative path +END ``` -The registry MUST NOT claim ownership of pre-existing project-authored skills. +## 6. Generated Index and Repository Ownership -## 8. Security +Consumers SHOULD generate `.agents/context/index.md` with links relative to that file. Context installation MUST NOT create or modify the repository owner's `agents.md`. Owners can link to the generated index and instruct agents to read relevant installed guidance. -Skills can contain scripts and instructions which cause agents to execute commands or modify files. Installation SHOULD therefore be an explicit project action. +Ordinary documents need no front matter. Consumers SHOULD use a document's first heading as its title (falling back to the filename) and its first prose sentence as its description. A non-empty YAML `description` takes precedence over prose. Unrelated ordinary-document metadata MUST be tolerated. Skill resource files are opaque assets and MUST NOT be parsed as context documents. + +A provider MAY supply `context/index.yaml` with `description` and a `files` sequence containing `path`, `title`, and `description`. Supporting consumers SHOULD preserve explicit ordering and overrides, omit missing or skill-only paths, and append unlisted ordinary documents. Fallback ordering is getting-started, overview, usage, configuration, migration, troubleshooting, debugging, then alphabetical order. + +## 7. Shared Skill Ownership Index + +Consumers MUST record dependency-installed skill ownership in `.agents/skills/.agent-context-skills.json`: + +```json +{ + "version": 2, + "skills": { + "provider-workflow": { + "ecosystem": "cargo", + "package": "provider", + "version": "1.0.0" + } + } +} +``` -Consumers SHOULD review provider skills and dependency changes before installation. Installers MUST reject ambiguous ownership rather than selecting a provider implicitly. +Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. Each installer MUST preserve foreign ecosystem entries. A full refresh reconciles only its ecosystem; a package refresh reconciles only that ecosystem and package; installing one named skill MUST preserve other owned skills. Providers removed from the dependency graph and providers that become empty MUST be included in full reconciliation. -## 9. Version Control +Existing unowned destinations and destinations owned by another ecosystem or package MUST NOT be replaced. Installers MUST validate the selected sources and collisions, stage skill files, and preserve the previous directories and ownership index when copying or committing a replacement fails. Shared index writes MUST use atomic replacement. -Projects SHOULD exclude the generated `.agents/` directory from version control and run installation as part of setup and CI when skills are required. +Version-one JSON ownership has implicit Cargo ownership and MUST migrate to version two when rewritten. Ruby consumers SHOULD import the legacy `.agent-skills.yaml` registry, rejecting ownership conflicts. The legacy Ruby registry MUST be retired after a successful migration so removed skills cannot be re-imported. -Installed skills and the ownership registry MUST contain only reproducible content which can be regenerated from installed packages. +Git integrations SHOULD maintain one marked exclusion block covering generated context, ownership files, and exact dependency-owned skill directories from all ecosystems. They MUST preserve user rules and leave repository-owned instructions and skills trackable. diff --git a/test/agent/skills/installer.rb b/test/agent/skills/installer.rb index 7f2fc8a..404221f 100644 --- a/test/agent/skills/installer.rb +++ b/test/agent/skills/installer.rb @@ -100,11 +100,12 @@ def write_skill(root, name, description:, directory_name: name, body: "# Instruc it "records ownership in the registry" do installer.install_gem_skills("fake-gem") - registry_path = File.join(consumer_root, ".agents", "skills", ".agent-skills.yaml") + registry_path = File.join(consumer_root, ".agents", "skills", Agent::Skills::Registry::FILE_NAME) registry = Agent::Skills::Registry.new(registry_path) expect(registry.owner("ruby-testing")).to be == { - "gem" => "fake-gem", + "ecosystem" => "gem", + "package" => "fake-gem", "version" => "1.0.0", } end @@ -185,4 +186,185 @@ def write_skill(root, name, description:, directory_name: name, body: "# Instruc end.to raise_exception(Agent::Skills::Installer::InvalidSkill) end end + it "installs context-declared skills with metadata and opaque resources" do + context = File.join(provider_root, "context") + FileUtils.mkdir_p(File.join(context, "workflow/references")) + File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\nlicense: MIT\nmetadata:\n author: Provider\n---\n\n# Workflow\n") + File.write(File.join(context, "workflow/references/data.md"), "---\nnot valid: [yaml\n---\n") + expect(installer.install_gem_skills("fake-gem").sort).to be == ["fake-gem-workflow", "ruby-testing"] + content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) + expect(content).to be(:include?, "name: fake-gem-workflow") + expect(content).to be(:include?, "license: MIT") + expect(content).to be(:include?, "author: Provider") + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow/references/data.md")) + end + + it "removes a provider's last skill and cleans disappeared gem providers" do + installer.install_all_skills + FileUtils.rm_rf(File.join(provider_root, "skills")) + expect(installer.install_gem_skills("fake-gem")).to be == [] + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) + write_skill(provider_root, "ruby-testing", description: "Test projects.") + installer.install_all_skills + subject.new(root: consumer_root, specifications: []).install_all_skills + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) + end + + it "preserves Cargo skills during a full Ruby refresh" do + registry_path = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) + registry = Agent::Skills::Registry.new(registry_path) + registry.claim("cargo-workflow", "fake-gem", "2.0.0", ecosystem: "cargo") + registry.save + cargo = File.join(consumer_root, ".agents/skills/cargo-workflow/SKILL.md") + FileUtils.mkdir_p(File.dirname(cargo)) + File.write(cargo, "Cargo instructions") + installer.install_all_skills + expect(File.read(cargo)).to be == "Cargo instructions" + expect(Agent::Skills::Registry.new(registry_path).owner("cargo-workflow")["ecosystem"]).to be == "cargo" + end + + it "refuses a matching name owned by Cargo even when the package names match" do + registry = Agent::Skills::Registry.new(File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME)) + registry.claim("ruby-testing", "fake-gem", "1.0.0", ecosystem: "cargo") + registry.save + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::Conflict) + end + + it "preserves the previous skills and registry when resource copying fails" do + installer.install_all_skills + registry_path = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) + previous = File.read(registry_path) + destination = File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md") + instructions = File.read(destination) + write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") + File.symlink("missing", File.join(provider_root, "skills/ruby-testing/broken")) + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + expect(File.read(registry_path)).to be == previous + expect(File.read(destination)).to be == instructions + end + + it "preserves unselected skills when installing one named skill" do + write_skill(provider_root, "documentation", description: "Write docs.") + installer.install_all_skills + FileUtils.rm_rf(File.join(provider_root, "skills/documentation")) + installer.install(skill: "ruby-testing") + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/documentation/SKILL.md")) + end + + it "accepts the common metadata limits and rejects overlong descriptions" do + write_skill(provider_root, "a" * 64, description: "d" * 1024) + expect(installer.list_skills("fake-gem").length).to be == 2 + write_skill(provider_root, "a" * 64, description: "d" * 1025) + expect do + installer.list_skills("fake-gem") + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + end + + it "migrates legacy Ruby ownership without resurrecting removed skills" do + installer.install_all_skills + shared = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) + File.delete(shared) + legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") + File.write(legacy, {"version" => 1, "skills" => {"ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml) + FileUtils.rm_rf(File.join(provider_root, "skills")) + installer.install_all_skills + installer.install_all_skills + expect(File).not.to be(:exist?, legacy) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) + expect(Agent::Skills::Registry.new(shared).owners).to be == {} + end + + it "rejects nested skill declarations outside a skill resource directory" do + context = File.join(provider_root, "context/nested") + FileUtils.mkdir_p(context) + File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) + end + + it "trims descriptions before enforcing the shared character limit" do + context = File.join(provider_root, "context") + FileUtils.mkdir_p(context) + metadata = {"type" => "skill", "description" => " #{"é" * 1024} "} + File.write(File.join(context, "workflow.md"), "#{metadata.to_yaml}---\n\n# Workflow\n") + installer.install_all_skills + content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) + expect(installer.list_skills("fake-gem").first.description).to be == "é" * 1024 + expect(content).not.to be(:include?, " é") + end + + it "restores skills, legacy ownership, and exclusions when the registry commit fails" do + system("git", "init", "--quiet", consumer_root, exception: true) + write_skill(provider_root, "documentation", description: "Write docs.") + installer.install_all_skills + registry = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) + exclude = File.join(consumer_root, ".git/info/exclude") + previous_registry = File.read(registry) + previous_exclude = File.read(exclude) + destination = File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md") + previous_instructions = File.read(destination) + legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") + legacy_content = {"version" => 1, "skills" => {"ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml + File.write(legacy, legacy_content) + write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") + FileUtils.rm_rf(File.join(provider_root, "skills/documentation")) + mock(File).before(:rename) do |_source, target| + raise Errno::EACCES, "Injected registry failure" if target == registry + end + expect do + installer.install_all_skills + end.to raise_exception(Errno::EACCES) + expect(File.read(registry)).to be == previous_registry + expect(File.read(exclude)).to be == previous_exclude + expect(File.read(destination)).to be == previous_instructions + expect(File.read(legacy)).to be == legacy_content + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/documentation/SKILL.md")) + end + + it "keeps plain guides out of skills and rejects invalid context skill metadata" do + context = File.join(provider_root, "context") + FileUtils.mkdir_p(context) + guide = File.join(context, "guide.md") + File.write(guide, "# Plain Guide\n\nOrdinary guidance.\n") + expect(installer.list_skills("fake-gem").map(&:name)).to be == ["ruby-testing"] + File.write(guide, "---\ntype: skill\ndescription: [invalid\n---\n") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + write_skill(provider_root, "Bad_Name", description: "Invalid name.") + File.delete(guide) + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + end + + it "refuses a reserved SKILL.md resource before changing installed skills" do + installer.install_all_skills + context = File.join(provider_root, "context") + FileUtils.mkdir_p(File.join(context, "workflow")) + File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\n---\n") + File.write(File.join(context, "workflow/SKILL.md"), "Conflicting instructions") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow")) + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md")) + end + + it "works without Git and refuses malformed generated exclusion blocks" do + mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} + expect(installer.install_all_skills).to be == ["ruby-testing"] + mock(Open3).clear + system("git", "init", "--quiet", consumer_root, exception: true) + File.write(File.join(consumer_root, ".git/info/exclude"), "# BEGIN bake-agent-context\n") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Skills::Registry::Invalid) + end end diff --git a/test/agent/skills/registry.rb b/test/agent/skills/registry.rb index cc27e31..0837093 100644 --- a/test/agent/skills/registry.rb +++ b/test/agent/skills/registry.rb @@ -8,7 +8,7 @@ describe Agent::Skills::Registry do let(:temporary_directory) {Dir.mktmpdir} - let(:registry_path) {File.join(temporary_directory, ".agent-skills.yaml")} + let(:registry_path) {File.join(temporary_directory, Agent::Skills::Registry::FILE_NAME)} def around yield @@ -23,7 +23,8 @@ def around reloaded = subject.new(registry_path) expect(reloaded.owner("ruby-testing")).to be == { - "gem" => "sus", + "ecosystem" => "gem", + "package" => "sus", "version" => "1.0.0", } end @@ -45,10 +46,54 @@ def around end it "rejects an invalid registry" do - File.write(registry_path, {"version" => 99, "skills" => {}}.to_yaml) + File.write(registry_path, {"version" => 99, "skills" => {}}.to_json) expect do subject.new(registry_path) end.to raise_exception(Agent::Skills::Registry::Invalid) end + it "imports version-one Cargo ownership into the shared format" do + File.write(registry_path, {"version" => 1, "skills" => {"cargo-workflow" => {"package" => "provider", "version" => "1.0.0"}}}.to_json) + registry = subject.new(registry_path) + expect(registry.owner("cargo-workflow")).to be == {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"} + registry.save + expect(JSON.parse(File.read(registry_path))["version"]).to be == 2 + end + + it "reads and rewrites the portable ownership fixture without changing its owners" do + fixture = File.expand_path("../../fixtures/skill-ownership-index.json", __dir__) + File.write(registry_path, File.read(fixture)) + registry = subject.new(registry_path) + expected = JSON.parse(File.read(fixture)) + registry.save + expect(JSON.parse(File.read(registry_path))).to be == expected + end + + it "rejects malformed JSON and invalid shared owner names" do + ["{invalid json}", {"version" => 2, "skills" => {"a" * 65 => {"ecosystem" => "gem", "package" => "provider", "version" => "1.0.0"}}}.to_json].each do |content| + File.write(registry_path, content) + expect do + subject.new(registry_path) + end.to raise_exception(subject::Invalid) + end + end + + it "rejects malformed and conflicting legacy Ruby ownership" do + legacy = File.join(temporary_directory, ".agent-skills.yaml") + [ + "invalid: [yaml", + {"version" => 99, "skills" => {}}.to_yaml, + {"version" => 1, "skills" => {"workflow" => {"gem" => nil, "version" => "1.0.0"}}}.to_yaml, + ].each do |content| + File.write(legacy, content) + expect do + subject.new(registry_path) + end.to raise_exception(subject::Invalid) + end + File.write(registry_path, {"version" => 2, "skills" => {"workflow" => {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"}}}.to_json) + File.write(legacy, {"version" => 1, "skills" => {"workflow" => {"gem" => "provider", "version" => "1.0.0"}}}.to_yaml) + expect do + subject.new(registry_path) + end.to raise_exception(subject::Invalid) + end end diff --git a/test/fixtures/skill-ownership-index.json b/test/fixtures/skill-ownership-index.json new file mode 100644 index 0000000..191fc51 --- /dev/null +++ b/test/fixtures/skill-ownership-index.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "skills": { + "cargo-workflow": { + "ecosystem": "cargo", + "package": "provider", + "version": "1.0.0" + }, + "ruby-testing": { + "ecosystem": "gem", + "package": "fake-gem", + "version": "1.0.0" + } + } +} From d3b19dda319311e8da11f95430d0b7398ccd8112 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 13:19:48 +0530 Subject: [PATCH 2/3] Deprecate agent-skills in favor of agent-context Signed-off-by: Samuel Williams --- .gitignore | 2 + agent-skills.gemspec | 2 +- bake/agent/skills.rb | 77 +++++ context/getting-started.md | 44 +-- context/index.yaml | 5 +- guides/getting-started/readme.md | 44 +-- lib/agent/skills/definition.rb | 70 +---- lib/agent/skills/exclusion.rb | 73 ----- lib/agent/skills/installer.rb | 360 +++++++++++------------ lib/agent/skills/registry.rb | 148 +++++----- lib/agent/skills/version.rb | 2 +- readme.md | 31 +- specification.md | 230 +-------------- test/agent/skills/installer.rb | 186 +----------- test/agent/skills/registry.rb | 51 +--- test/fixtures/skill-ownership-index.json | 15 - 16 files changed, 380 insertions(+), 960 deletions(-) create mode 100644 bake/agent/skills.rb delete mode 100644 lib/agent/skills/exclusion.rb delete mode 100644 test/fixtures/skill-ownership-index.json diff --git a/.gitignore b/.gitignore index b2ec040..d53e569 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/agents.md +/.agents/ /.bundle /pkg /gems.locked diff --git a/agent-skills.gemspec b/agent-skills.gemspec index 927dd97..686503f 100644 --- a/agent-skills.gemspec +++ b/agent-skills.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |spec| spec.name = "agent-skills" spec.version = Agent::Skills::VERSION - spec.summary = "Discover and install dependency skills for Agent Context." + spec.summary = "Deprecated: use agent-context to discover and install agent skills." spec.authors = ["Samuel Williams", "Shopify Inc."] spec.license = "MIT" diff --git a/bake/agent/skills.rb b/bake/agent/skills.rb new file mode 100644 index 0000000..45fe87c --- /dev/null +++ b/bake/agent/skills.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require_relative "../../lib/agent/skills/installer" + +include Agent::Skills + +def initialize(context) + super(context) + + @installer = Installer.new(root: context.root) +end + +attr :installer + +# List gems and the skills they provide. +# @parameter gem [String] Optional gem name to inspect. +def list(gem: nil) + if gem + skills = @installer.list_skills(gem) + if skills + puts "Skills provided by gem '#{gem}':" + skills.each do |skill| + puts " #{skill.name} - #{skill.description}" + end + else + puts "No skills found for gem '#{gem}'" + end + else + gems = @installer.find_gems_with_skills + if gems.any? + puts "Gems with skills available:" + gems.each do |gem_information| + names = gem_information[:skills].map(&:name).join(", ") + puts " #{gem_information[:name]} (#{gem_information[:version]}): #{names}" + end + else + puts "No gems with skills found" + end + end +end + +# Show the `SKILL.md` content for a provided skill. +# @parameter gem [String] The provider gem name. +# @parameter skill [String] The skill name. +def show(gem:, skill:) + content = @installer.show_skill(gem, skill) + if content + puts content + else + puts "Skill '#{skill}' not found in gem '#{gem}'" + end +end + +# Install skills from gems into `.agents/skills`. +# @parameter gem [String] Optional gem name to install from. +def install(gem: nil) + if gem + installed = @installer.install_gem_skills(gem) + if installed + puts "Installed #{installed.length} skills from gem '#{gem}':" + installed.each{|skill_name| puts " #{skill_name}"} + else + puts "No skills found for gem '#{gem}'" + end + else + installed = @installer.install_all_skills + if installed.any? + puts "Installed #{installed.length} skills:" + installed.each{|skill_name| puts " #{skill_name}"} + else + puts "No gems with skills found" + end + end +end diff --git a/context/getting-started.md b/context/getting-started.md index ce7b2ce..da51447 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,40 +1,26 @@ # Getting Started -This guide explains how to install package guidance and skills using `agent-context`. +This guide explains how to migrate from the deprecated `agent-skills` gem to `agent-context`. ## Installation -```sh -bundle add agent-context -bundle exec bake agent:context:install -``` - -The installer copies guides to `.agents/context//`, installs skills under `.agents/skills/`, and writes `.agents/context/index.md`. It preserves your repository-owned `agents.md`. Add a stable link to the generated index there and instruct agents to read relevant guides. - -## Commands +Replace `agent-skills` with `agent-context` in your Gemfile, then run: ```sh -bake agent:context:list -bake agent:context:show --gem async --file getting-started -bake agent:context:install --gem async -bake agent:context:index -bake agent:context:skill:list -bake agent:context:skill:install --gem provider --skill provider-workflow -bake agent:context:skill:show --gem provider --skill provider-workflow +bundle install +bundle exec bake agent:context:install ``` -The main install command installs ordinary context and skills. The skill commands use installed, package-prefixed names for context-declared skills. The former `agent:skills:*` commands are removed. +`agent-context` includes skill discovery and installation directly. Use `agent:context:skill:list`, `agent:context:skill:show`, and `agent:context:skill:install` in place of the former `agent:skills:*` commands. -## Provider Layout +## Provider Migration -Put ordinary guides in the packaged gem's top-level `context/` directory. Use a clear heading and first prose sentence. An optional YAML `description` overrides the summary. A provider-authored `index.yaml` can still control ordering, titles, and descriptions. - -To provide a skill, put a Markdown document directly inside `context/`: +Move `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the YAML front matter, and move companion resources to `context//`. Include `context/**/*` in the gem's packaged files. Installed skill names become package-prefixed, matching the Rust implementation. ```markdown --- type: skill -description: Follow the workflow when setting up this package. +description: Run the workflow when setting up this package. --- # Workflow @@ -42,16 +28,6 @@ description: Follow the workflow when setting up this package. Follow the setup instructions. ``` -`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. Additional skill metadata is preserved. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Existing `skills//SKILL.md` bundles retain their declared names. - -Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. - -## Ownership and Version Control - -Generated context and dependency-owned skills are reproducible. Edit their provider sources, then reinstall. Project-owned skills and instructions can live under `.agents/` and remain version controlled. - -Installation maintains local Git exclusions for generated context, ownership files, and exact dependency-installed skill directories. Remove blanket `/.agents/` ignore rules when adopting this layout. - -The shared ownership index records ecosystem, package, and version. Full Ruby refreshes remove stale gem-owned skills and preserve Cargo-owned skills. A selected-skill install leaves other skills intact. Failed skill copying or replacement preserves the previous files and index. Legacy ownership files migrate on successful installation. +Existing installation ownership from `.agent-skills.yaml` migrates automatically. A full refresh removes stale gem-owned skills and preserves project-owned and Cargo-owned skills. -When migrating an old generated `agents.md`, replace its generated dependency listing with a stable link to `.agents/context/index.md`, preserving your project instructions. +See the [Agent Context documentation](https://github.com/socketry/agent-context) for the supported commands and provider format. diff --git a/context/index.yaml b/context/index.yaml index f848d61..6560d46 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -1,7 +1,7 @@ # Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- -description: Discover and install dependency skills for Agent Context. +description: Deprecated agent skills gem; use agent-context for discovery and installation. metadata: bug_tracker_uri: https://github.com/socketry/agent-skills/issues changelog_uri: https://github.com/socketry/agent-skills/blob/main/releases.md @@ -11,5 +11,4 @@ metadata: files: - path: getting-started.md title: Getting Started - description: This guide explains how to install package guidance and skills using - `agent-context`. + description: This guide explains how to migrate from the deprecated agent-skills gem to agent-context. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index ce7b2ce..da51447 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,40 +1,26 @@ # Getting Started -This guide explains how to install package guidance and skills using `agent-context`. +This guide explains how to migrate from the deprecated `agent-skills` gem to `agent-context`. ## Installation -```sh -bundle add agent-context -bundle exec bake agent:context:install -``` - -The installer copies guides to `.agents/context//`, installs skills under `.agents/skills/`, and writes `.agents/context/index.md`. It preserves your repository-owned `agents.md`. Add a stable link to the generated index there and instruct agents to read relevant guides. - -## Commands +Replace `agent-skills` with `agent-context` in your Gemfile, then run: ```sh -bake agent:context:list -bake agent:context:show --gem async --file getting-started -bake agent:context:install --gem async -bake agent:context:index -bake agent:context:skill:list -bake agent:context:skill:install --gem provider --skill provider-workflow -bake agent:context:skill:show --gem provider --skill provider-workflow +bundle install +bundle exec bake agent:context:install ``` -The main install command installs ordinary context and skills. The skill commands use installed, package-prefixed names for context-declared skills. The former `agent:skills:*` commands are removed. +`agent-context` includes skill discovery and installation directly. Use `agent:context:skill:list`, `agent:context:skill:show`, and `agent:context:skill:install` in place of the former `agent:skills:*` commands. -## Provider Layout +## Provider Migration -Put ordinary guides in the packaged gem's top-level `context/` directory. Use a clear heading and first prose sentence. An optional YAML `description` overrides the summary. A provider-authored `index.yaml` can still control ordering, titles, and descriptions. - -To provide a skill, put a Markdown document directly inside `context/`: +Move `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the YAML front matter, and move companion resources to `context//`. Include `context/**/*` in the gem's packaged files. Installed skill names become package-prefixed, matching the Rust implementation. ```markdown --- type: skill -description: Follow the workflow when setting up this package. +description: Run the workflow when setting up this package. --- # Workflow @@ -42,16 +28,6 @@ description: Follow the workflow when setting up this package. Follow the setup instructions. ``` -`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. Additional skill metadata is preserved. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Existing `skills//SKILL.md` bundles retain their declared names. - -Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. - -## Ownership and Version Control - -Generated context and dependency-owned skills are reproducible. Edit their provider sources, then reinstall. Project-owned skills and instructions can live under `.agents/` and remain version controlled. - -Installation maintains local Git exclusions for generated context, ownership files, and exact dependency-installed skill directories. Remove blanket `/.agents/` ignore rules when adopting this layout. - -The shared ownership index records ecosystem, package, and version. Full Ruby refreshes remove stale gem-owned skills and preserve Cargo-owned skills. A selected-skill install leaves other skills intact. Failed skill copying or replacement preserves the previous files and index. Legacy ownership files migrate on successful installation. +Existing installation ownership from `.agent-skills.yaml` migrates automatically. A full refresh removes stale gem-owned skills and preserves project-owned and Cargo-owned skills. -When migrating an old generated `agents.md`, replace its generated dependency listing with a stable link to `.agents/context/index.md`, preserving your project instructions. +See the [Agent Context documentation](https://github.com/socketry/agent-context) for the supported commands and provider format. diff --git a/lib/agent/skills/definition.rb b/lib/agent/skills/definition.rb index 15663e3..811262c 100644 --- a/lib/agent/skills/definition.rb +++ b/lib/agent/skills/definition.rb @@ -2,83 +2,35 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. -# Copyright, 2026, by Samuel Williams. - -require "fileutils" module Agent module Skills - # Represents a validated dependency skill and its bundled resources. + # A validated skill discovered in a provider gem. class Definition - # Initialize a skill from a bundle or a context document. - # @parameter name [String] The installed skill name. - # @parameter description [String] When to use the skill. - # @parameter path [String | Nil] The bundle or resource directory. - # @parameter provider_name [String] The provider gem. - # @parameter provider_version [String] The provider version. - # @parameter source_file [String | Nil] The source context document. - # @parameter document [String | Nil] Generated skill instructions. - def initialize(name:, description:, path:, provider_name:, provider_version:, source_file: nil, document: nil) + # Initialize a skill definition. + # + # @parameter name [String] The declared skill name. + # @parameter description [String] A short description used for skill discovery. + # @parameter path [String] The source directory containing `SKILL.md`. + # @parameter provider_name [String] The source gem name. + # @parameter provider_version [String] The source gem version. + def initialize(name:, description:, path:, provider_name:, provider_version:) @name = name @description = description @path = path @provider_name = provider_name @provider_version = provider_version - @source_file = source_file - @document = document end - # @attribute [String] The installed skill name. attr_reader :name - # @attribute [String] When to use the skill. attr_reader :description - # @attribute [String | Nil] The bundle or resource directory. attr_reader :path - # @attribute [String] The provider gem. attr_reader :provider_name - # @attribute [String] The provider version. attr_reader :provider_version - # @attribute [String | Nil] The source context document. - attr_reader :source_file - # @returns [String] The source instruction file. + # The path to the skill's required instruction file. def skill_file - @source_file || File.join(@path, "SKILL.md") - end - - # @returns [String] The installed skill instructions. - def content - @document || File.read(skill_file) - end - - # Write instructions and resources into a staging directory. - # @parameter destination [String] The staging directory. - def write_to(destination) - FileUtils.mkdir_p(destination) - copy_resources(@path, destination, true) if @path - File.write(File.join(destination, "SKILL.md"), @document) if @document - end - - private - - def copy_resources(source, destination, top_level) - Dir.children(source).sort.each do |name| - from = File.join(source, name) - to = File.join(destination, name) - metadata = File.lstat(from) - if metadata.symlink? || (!metadata.file? && !metadata.directory?) - raise Installer::InvalidSkill, "Skill resources must be regular files or directories: #{from}" - end - if @document && top_level && name.downcase == "skill.md" - raise Installer::InvalidSkill, "SKILL.md is reserved for generated instructions: #{from}" - end - if metadata.directory? - FileUtils.mkdir_p(to) - copy_resources(from, to, false) - else - FileUtils.copy_file(from, to, true) - end - end + File.join(@path, "SKILL.md") end end end diff --git a/lib/agent/skills/exclusion.rb b/lib/agent/skills/exclusion.rb deleted file mode 100644 index 7d08546..0000000 --- a/lib/agent/skills/exclusion.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "open3" -require "fileutils" -require "tempfile" - -module Agent - module Skills - # Represents local Git exclusions shared by context and skill installers. - class Exclusion - BEGIN_MARKER = "# BEGIN bake-agent-context" - END_MARKER = "# END bake-agent-context" - - # Prepare exclusions while preserving repository-owned rules. - # @parameter root [String] The consuming project root. - # @parameter names [Array(String)] All dependency-owned skills. - def initialize(root, names) - output, status = Open3.capture2e("git", "rev-parse", "--git-path", "info/exclude", chdir: root) - return unless status.success? - @path = File.expand_path(output.strip, root) - @original = File.exist?(@path) ? File.read(@path) : "" - lines = @original.lines(chomp: true) - begins = lines.each_index.select{|index| lines[index].strip == BEGIN_MARKER} - ends = lines.each_index.select{|index| lines[index].strip == END_MARKER} - unless begins.empty? && ends.empty? - unless begins.length == 1 && ends.length == 1 && begins.first < ends.first - raise Registry::Invalid, "Malformed Agent Context exclusion block: #{@path}" - end - lines.slice!(begins.first..ends.first) - end - lines.pop while lines.last == "" - lines << "" unless lines.empty? - lines.concat([ - BEGIN_MARKER, - "# Generated by Bake Agent Context; do not edit this block manually.", - "/.agents/context/", - "/.agents/skills/#{Registry::FILE_NAME}", - "/.agents/skills/.agent-skills.yaml", - *names.sort.map{|name| "/.agents/skills/#{name}/"}, - END_MARKER, - ]) - @updated = lines.join("\n") + "\n" - rescue Errno::ENOENT - # Installation also works when Git is unavailable: - @path = nil - end - - # Apply the prepared exclusion block. - def apply - write(@updated) if @path && @updated != @original - end - - # Restore exclusions if installation fails. - def restore - write(@original) if @path && @updated != @original - end - - private - - def write(content) - FileUtils.mkdir_p(File.dirname(@path)) - Tempfile.create(".agent-context-exclude-", File.dirname(@path)) do |file| - file.write(content) - file.flush - File.rename(file.path, @path) - end - end - end - end -end diff --git a/lib/agent/skills/installer.rb b/lib/agent/skills/installer.rb index e80c6e5..1db6bb9 100644 --- a/lib/agent/skills/installer.rb +++ b/lib/agent/skills/installer.rb @@ -2,265 +2,251 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. -# Copyright, 2026, by Samuel Williams. require "fileutils" require "rubygems" require "yaml" -require "tmpdir" require_relative "definition" require_relative "registry" -require_relative "exclusion" module Agent module Skills - # Represents discovery and installation of skills from resolved gems. + # Discovers and installs skills provided by Ruby gems. class Installer NAME_PATTERN = Registry::NAME_PATTERN - MAXIMUM_NAME_LENGTH = 64 - MAXIMUM_DESCRIPTION_LENGTH = 1024 + MAXIMUM_NAME_LENGTH = 100 + MAXIMUM_DESCRIPTION_LENGTH = 500 - # Raised when a skill cannot be installed. + # Base error for skill discovery and installation failures. class Error < StandardError end - # Raised when skill metadata or resources are invalid. + + # Raised when a provider contains an invalid skill. class InvalidSkill < Error end - # Raised when a destination has ambiguous or foreign ownership. + + # Raised when a skill would overwrite content owned by another source. class Conflict < Error end - # Initialize discovery from the project's gem specifications. + # Initialize a skill installer. + # # @parameter root [String] The consuming project root. - # @parameter specifications [Enumerable] The resolved gem specifications. + # @parameter specifications [Gem::Specification] The gem specifications to scan. def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @skills_path = File.join(@root, ".agents", "skills") - @specifications = specifications.to_a + @specifications = specifications end - # @attribute [String] The consuming project root. attr_reader :root - # @attribute [String] The installed skill directory. attr_reader :skills_path - # Find skill providers. - # @parameter skip_local [bool] Whether to exclude the consuming gem. - # @returns [Array(Hash)] The discovered providers. + # Find installed gems which provide one or more valid skills. + # + # @parameter skip_local [Boolean] Whether to skip a gem loaded from the consuming project root. def find_gems_with_skills(skip_local: true) @specifications.filter_map do |specification| - next if skip_local && File.expand_path(specification.full_gem_path) == @root - information = build_gem_information(specification) - information unless information[:skills].empty? + next if skip_local && same_path?(specification.full_gem_path, @root) + + build_gem_information(specification) end end - # Find a skill provider by gem name. - # @parameter gem_name [String] The provider gem. - # @returns [Hash | Nil] The discovered provider. + # Find a named gem which provides skills. + # + # @parameter gem_name [String] The gem name to find. def find_gem_with_skills(gem_name) - specification = find_specification(gem_name) + specification = @specifications.find{|candidate| candidate.name == gem_name} return unless specification - information = build_gem_information(specification) - information unless information[:skills].empty? + + build_gem_information(specification) end - # List a gem's skills. - # @parameter gem_name [String] The provider gem. - # @returns [Array(Definition) | Nil] The provided skills. + # List skills provided by a named gem. + # + # @parameter gem_name [String] The gem name. def list_skills(gem_name) - find_gem_with_skills(gem_name)&.fetch(:skills) + gem = find_gem_with_skills(gem_name) + gem && gem[:skills] end - # Read a skill's generated instruction file. - # @parameter gem_name [String] The provider gem. - # @parameter skill_name [String] The installed skill name. - # @returns [String | Nil] The instructions. + # Read the instruction file for a skill provided by a gem. + # + # @parameter gem_name [String] The provider gem name. + # @parameter skill_name [String] The declared skill name. def show_skill(gem_name, skill_name) - list_skills(gem_name)&.find{|skill| skill.name == skill_name}&.content + skills = list_skills(gem_name) + return unless skills + + definition = skills.find{|skill| skill.name == skill_name} + File.read(definition.skill_file) if definition end - # Install or reconcile one provider's skills, including an empty provider. - # @parameter gem_name [String] The provider gem. - # @returns [Array(String) | Nil] Installed names, or nil for a missing gem. + # Install all skills provided by a named gem. + # + # @parameter gem_name [String] The provider gem name. + # @returns [Array(String), nil] Installed skill names, or `nil` if the gem provides no skills. def install_gem_skills(gem_name) - return unless find_specification(gem_name) - install(gem: gem_name) + gem = find_gem_with_skills(gem_name) + return unless gem + + install_gems([gem]) end - # Install and reconcile skills owned by resolved gems. - # @parameter skip_local [bool] Whether to exclude the consuming gem. - # @returns [Array(String)] The installed skill names. + # Install skills from every discovered gem. + # + # @parameter skip_local [Boolean] Whether to skip a gem loaded from the consuming project root. + # @returns [Array(String)] Installed skill names. def install_all_skills(skip_local: true) - install(skip_local: skip_local) - end - - # Install selected skills and reconcile only the corresponding ownership scope. - # @parameter gem [String | Nil] An optional provider gem. - # @parameter skill [String | Nil] An optional installed skill name. - # @parameter skip_local [bool] Whether to exclude the consuming gem. - # @returns [Array(String)] The installed skill names. - def install(gem: nil, skill: nil, skip_local: true) - if gem - specification = find_specification(gem) - raise Error, "No gem found: #{gem}" unless specification - providers = [build_gem_information(specification)] - else - providers = find_gems_with_skills(skip_local: skip_local) - end - definitions = providers.flat_map{|provider| provider[:skills]} - if skill - definitions.select!{|definition| definition.name == skill} - raise Error, "No dependency skill found: #{skill}" if definitions.empty? - end - install_definitions(definitions, package: gem, reconcile: !skill) + install_gems(find_gems_with_skills(skip_local: skip_local)) end private - def find_specification(name) - matches = @specifications.select{|specification| specification.name == name} - raise Conflict, "Multiple resolved versions provide gem #{name.inspect}" if matches.length > 1 - matches.first - end - def build_gem_information(specification) - bundle_root = File.join(specification.full_gem_path, "skills") - context_root = File.join(specification.full_gem_path, "context") - skills = discover_bundles(bundle_root, specification) + discover_context_skills(context_root, specification) - {name: specification.name, version: specification.version.to_s, skills: skills.sort_by(&:name)} - end - - def discover_bundles(root, specification) - return [] unless File.directory?(root) - raise InvalidSkill, "Skill provider must be a regular directory: #{root}" if File.symlink?(root) - Dir.children(root).sort.filter_map do |name| - directory = File.join(root, name) - file = File.join(directory, "SKILL.md") - next unless File.directory?(directory) && File.file?(file) - raise InvalidSkill, "Skill bundles cannot be symlinks: #{directory}" if File.symlink?(directory) || File.symlink?(file) - metadata, = parse_document(file, required: true) - validate_metadata(metadata, name, file) - Definition.new(name: name, description: metadata["description"], path: directory, provider_name: specification.name, provider_version: specification.version.to_s) + source_path = File.join(specification.full_gem_path, "skills") + return unless Dir.exist?(source_path) + + skills = discover_skills(source_path, specification.name, specification.version.to_s) + return if skills.empty? + + { + name: specification.name, + version: specification.version.to_s, + summary: specification.summary, + metadata: specification.metadata, + path: source_path, + skills: skills, + } + end + + def discover_skills(source_path, provider_name, provider_version) + Dir.children(source_path).sort.filter_map do |entry| + skill_path = File.join(source_path, entry) + next unless File.directory?(skill_path) + + skill_file = File.join(skill_path, "SKILL.md") + next unless File.file?(skill_file) + + metadata = load_skill_metadata(skill_file) + validate_skill_metadata(metadata, entry, skill_file) + + Definition.new( + name: metadata["name"], + description: metadata["description"], + path: skill_path, + provider_name: provider_name, + provider_version: provider_version, + ) end end - def discover_context_skills(root, specification) - return [] unless File.directory?(root) - raise InvalidSkill, "Context provider must be a regular directory: #{root}" if File.symlink?(root) - skills = [] - files = Dir.glob(File.join(root, "**", "*")).sort_by{|file| [File.dirname(file) == root ? 0 : 1, file]} - files.each do |file| - next if skills.any?{|skill| skill.path && file.start_with?("#{skill.path}/")} - next unless File.file?(file) && File.extname(file).downcase == ".md" && !File.symlink?(file) - metadata, body = parse_document(file) - next unless metadata["type"] - raise InvalidSkill, "Unsupported context type in #{file}: #{metadata["type"].inspect}" unless metadata["type"] == "skill" - raise InvalidSkill, "Skill documents must be directly inside context/: #{file}" unless File.dirname(file) == root - local_name = File.basename(file, File.extname(file)) - installed_name = "#{specification.name.downcase.tr("_", "-")}-#{local_name}" - validate_name(local_name, file) - metadata = metadata.merge("name" => installed_name) - metadata.delete("type") - validate_metadata(metadata, installed_name, file) - assets = File.join(root, local_name) - if File.exist?(assets) || File.symlink?(assets) - raise InvalidSkill, "Skill resources must be a regular directory: #{assets}" unless File.lstat(assets).directory? - else - assets = nil - end - document = "---\n#{YAML.dump(metadata).delete_prefix("---\n")}---\n\n#{body}" - document += "\n" unless document.end_with?("\n") - skills << Definition.new(name: installed_name, description: metadata["description"], path: assets, source_file: file, document: document, provider_name: specification.name, provider_version: specification.version.to_s) - end - skills - end - - def parse_document(file, required: false) - content = File.read(file) - match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) - if !match - raise InvalidSkill, "Skill must begin with YAML frontmatter: #{file}" if required - return [{}, content] + def load_skill_metadata(skill_file) + content = File.read(skill_file) + match = content.match(/\A---\s*\n(.*?)\n---\s*(?:\n|\z)/m) + + unless match + raise InvalidSkill, "Skill must begin with YAML frontmatter: #{skill_file}" end + metadata = YAML.safe_load(match[1], aliases: false) - raise InvalidSkill, "Frontmatter must be a mapping: #{file}" unless metadata.is_a?(Hash) - [metadata, content[match.end(0)..].sub(/\A\r?\n/, "")] + unless metadata.is_a?(Hash) + raise InvalidSkill, "Skill frontmatter must be a mapping: #{skill_file}" + end + + metadata rescue Psych::Exception => error - raise InvalidSkill, "Invalid YAML frontmatter in #{file}: #{error.message}" + raise InvalidSkill, "Invalid skill frontmatter in #{skill_file}: #{error.message}" end - def validate_name(name, file) + def validate_skill_metadata(metadata, directory_name, skill_file) + name = metadata["name"] + description = metadata["description"] + unless name.is_a?(String) && name.match?(NAME_PATTERN) && name.length <= MAXIMUM_NAME_LENGTH - raise InvalidSkill, "Invalid skill name in #{file}: #{name.inspect}" + raise InvalidSkill, "Invalid skill name in #{skill_file}: #{name.inspect}" + end + + unless name == directory_name + raise InvalidSkill, "Skill name #{name.inspect} must match its directory #{directory_name.inspect}" + end + + unless description.is_a?(String) && !description.strip.empty? && description.length <= MAXIMUM_DESCRIPTION_LENGTH + raise InvalidSkill, "Invalid skill description in #{skill_file}" end end - def validate_metadata(metadata, name, file) - validate_name(metadata["name"], file) - raise InvalidSkill, "Skill name must match its directory: #{file}" unless metadata["name"] == name - description = metadata["description"] - unless description.is_a?(String) && !description.strip.empty? && description.strip.length <= MAXIMUM_DESCRIPTION_LENGTH - raise InvalidSkill, "Invalid skill description in #{file}" + def install_gems(gems) + definitions = gems.flat_map{|gem| gem[:skills]} + return [] if definitions.empty? + + validate_unique_skills(definitions) + + registry = Registry.new(File.join(@skills_path, Registry::FILE_NAME)) + definitions.each{|definition| validate_destination(definition, registry)} + + stale_skills = gems.flat_map do |gem| + current_names = gem[:skills].map(&:name) + registry.skills_for(gem[:name]).keys - current_names + end + + definitions.each do |definition| + registry.claim(definition.name, definition.provider_name, definition.provider_version) end - metadata["description"] = description.strip + stale_skills.each{|skill_name| registry.release(skill_name)} + registry.save + + definitions.each{|definition| copy_skill(definition)} + stale_skills.each{|skill_name| remove_skill(skill_name)} + + definitions.map(&:name) end - def install_definitions(definitions, package:, reconcile:) + def validate_unique_skills(definitions) duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1} - raise Conflict, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? - if File.exist?(@skills_path) || File.symlink?(@skills_path) - raise Conflict, "Skill installation path must be a regular directory: #{@skills_path}" unless File.lstat(@skills_path).directory? + return if duplicates.empty? + + details = duplicates.map do |name, matches| + providers = matches.map(&:provider_name).uniq.join(", ") + "#{name} (#{providers})" end - registry = Registry.new(File.join(@skills_path, Registry::FILE_NAME)) - definitions.each do |definition| - owner = registry.owner(definition.name) - destination = File.join(@skills_path, definition.name) - if owner && owner.values_at("ecosystem", "package") != ["gem", definition.provider_name] - raise Conflict, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" - end - if (File.exist?(destination) || File.symlink?(destination)) && !owner - raise Conflict, "Skill #{definition.name.inspect} is not managed by Agent Context" - end + + raise Conflict, "Multiple gems provide the same skill: #{details.join('; ')}" + end + + def validate_destination(definition, registry) + destination = File.join(@skills_path, definition.name) + owner = registry.owner(definition.name) + + if owner && owner["gem"] != definition.provider_name + raise Conflict, "Skill #{definition.name.inspect} is owned by gem #{owner['gem'].inspect}" end - names = definitions.map(&:name) - stale = reconcile ? registry.skills_for(package).keys - names : [] - definitions.each{|definition| registry.claim(definition.name, definition.provider_name, definition.provider_version)} - stale.each{|name| registry.release(name)} - exclusion = Exclusion.new(@root, registry.owners.keys) - FileUtils.mkdir_p(@skills_path) - Dir.mktmpdir(".agent-context-staging-", @skills_path) do |stage| - staged = File.join(stage, "new") - backups = File.join(stage, "backups") - FileUtils.mkdir_p([staged, backups]) - definitions.each{|definition| definition.write_to(File.join(staged, definition.name))} - changes = [] - legacy = File.join(@skills_path, ".agent-skills.yaml") - begin - exclusion.apply - (names + stale).each do |name| - destination = File.join(@skills_path, name) - backup = File.join(backups, name) - previous = File.exist?(destination) || File.symlink?(destination) - File.rename(destination, backup) if previous - changes << [name, previous] - File.rename(File.join(staged, name), destination) if names.include?(name) - end - File.rename(legacy, File.join(stage, "legacy.yaml")) if File.exist?(legacy) - registry.save - rescue - changes.reverse_each do |name, previous| - destination = File.join(@skills_path, name) - FileUtils.rm_rf(destination) - File.rename(File.join(backups, name), destination) if previous - end - File.rename(File.join(stage, "legacy.yaml"), legacy) if File.exist?(File.join(stage, "legacy.yaml")) - exclusion.restore - raise - end + + if path_exists?(destination) && !owner + raise Conflict, "Skill #{definition.name.inspect} already exists and is not managed by agent-skills" end - names + end + + def copy_skill(definition) + FileUtils.mkdir_p(@skills_path) + destination = File.join(@skills_path, definition.name) + FileUtils.rm_rf(destination) if path_exists?(destination) + FileUtils.cp_r(definition.path, destination, preserve: true) + end + + def remove_skill(skill_name) + destination = File.join(@skills_path, skill_name) + FileUtils.rm_rf(destination) if path_exists?(destination) + end + + def path_exists?(path) + File.exist?(path) || File.symlink?(path) + end + + def same_path?(left, right) + File.expand_path(left) == File.expand_path(right) end end end diff --git a/lib/agent/skills/registry.rb b/lib/agent/skills/registry.rb index e7ae04a..3eda18d 100644 --- a/lib/agent/skills/registry.rb +++ b/lib/agent/skills/registry.rb @@ -2,132 +2,116 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. -# Copyright, 2026, by Samuel Williams. require "fileutils" -require "json" require "yaml" -require "tempfile" module Agent module Skills - # Represents the shared ownership index for dependency-installed skills. + # Tracks which installed skills are owned by which gems. class Registry - FILE_NAME = ".agent-context-skills.json" - FORMAT_VERSION = 2 + FILE_NAME = ".agent-skills.yaml" + FORMAT_VERSION = 1 NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ - # Raised when ownership cannot be safely interpreted. + # Raised when the registry cannot be safely interpreted. class Invalid < StandardError end - # Load shared ownership and import legacy Ruby ownership. - # @parameter path [String] The shared ownership index. + # Initialize a registry stored at the given path. + # + # @parameter path [String] The registry file path. def initialize(path) @path = path @data = load_data - import_legacy_ruby end - # @attribute [String] The path of the shared ownership index. attr_reader :path - # @attribute [Hash] The recorded skill owners. - def owners - @data["skills"] + # Get ownership metadata for a skill. + # + # @parameter skill_name [String] The installed skill name. + def owner(skill_name) + @data["skills"][skill_name] end - # Find a skill's owner. - # @parameter name [String] The installed skill name. - # @returns [Hash | Nil] The recorded owner. - def owner(name) - owners[name] - end - - # Find skills owned by an ecosystem and optional package. - # @parameter package [String | Nil] The selected provider. - # @parameter ecosystem [String] The provider ecosystem. - # @returns [Hash] Matching skill owners. - def skills_for(package = nil, ecosystem: "gem") - owners.select do |_name, owner| - owner["ecosystem"] == ecosystem && (!package || owner["package"] == package) + # Get all skills owned by a gem. + # + # @parameter gem_name [String] The provider gem name. + def skills_for(gem_name) + @data["skills"].select do |_skill_name, owner| + owner["gem"] == gem_name end end - # Record dependency ownership. - # @parameter name [String] The installed skill name. - # @parameter package [String] The provider package. - # @parameter version [String] The provider version. - # @parameter ecosystem [String] The provider ecosystem. - def claim(name, package, version, ecosystem: "gem") - owners[name] = {"ecosystem" => ecosystem, "package" => package, "version" => version} + # Record ownership of an installed skill. + # + # @parameter skill_name [String] The installed skill name. + # @parameter gem_name [String] The provider gem name. + # @parameter gem_version [String] The provider gem version. + def claim(skill_name, gem_name, gem_version) + @data["skills"][skill_name] = { + "gem" => gem_name, + "version" => gem_version, + } end - # Release skill ownership. - # @parameter name [String] The installed skill name. - def release(name) - owners.delete(name) + # Remove ownership information for a skill. + # + # @parameter skill_name [String] The installed skill name. + def release(skill_name) + @data["skills"].delete(skill_name) end - # Persist shared ownership using an atomic rename. + # Persist the registry atomically. def save FileUtils.mkdir_p(File.dirname(@path)) - Tempfile.create([".agent-context-registry-", ".json"], File.dirname(@path)) do |file| - file.write(JSON.pretty_generate({"version" => FORMAT_VERSION, "skills" => owners.sort.to_h}) + "\n") - file.flush - File.rename(file.path, @path) - end + + content = { + "version" => FORMAT_VERSION, + "skills" => @data["skills"].sort.to_h, + }.to_yaml + + temporary_path = "#{@path}.#{Process.pid}.tmp" + File.write(temporary_path, content) + File.rename(temporary_path, @path) + ensure + FileUtils.rm_f(temporary_path) if temporary_path end private def load_data - return {"version" => FORMAT_VERSION, "skills" => {}} unless File.exist?(@path) || File.symlink?(@path) - raise Invalid, "Skill ownership index must be a regular file: #{@path}" unless File.lstat(@path).file? - data = JSON.parse(File.read(@path)) - unless data.is_a?(Hash) && [1, FORMAT_VERSION].include?(data["version"]) && data["skills"].is_a?(Hash) - raise Invalid, "Invalid skill ownership index: #{@path}" - end - if data["version"] == 1 - data["skills"].each_value{|owner| owner["ecosystem"] = "cargo" if owner.is_a?(Hash)} - end - data["version"] = FORMAT_VERSION - validate_owners(data["skills"]) + return default_data unless File.exist?(@path) + + data = YAML.safe_load(File.read(@path), aliases: false) + validate_data(data) data - rescue JSON::ParserError => error - raise Invalid, "Invalid skill ownership index #{@path}: #{error.message}" + rescue Psych::Exception => error + raise Invalid, "Could not load skill registry #{@path}: #{error.message}" end - def import_legacy_ruby - legacy_path = File.join(File.dirname(@path), ".agent-skills.yaml") - return unless File.exist?(legacy_path) || File.symlink?(legacy_path) - raise Invalid, "Legacy skill registry must be a regular file: #{legacy_path}" unless File.lstat(legacy_path).file? - legacy = YAML.safe_load_file(legacy_path, aliases: false) - unless legacy.is_a?(Hash) && legacy["version"] == 1 && legacy["skills"].is_a?(Hash) - raise Invalid, "Invalid legacy skill registry: #{legacy_path}" + def validate_data(data) + unless data.is_a?(Hash) && data["version"] == FORMAT_VERSION && data["skills"].is_a?(Hash) + raise Invalid, "Invalid skill registry format: #{@path}" end - legacy["skills"].each do |name, owner| - unless owner.is_a?(Hash) && owner["gem"].is_a?(String) && owner["version"].is_a?(String) - raise Invalid, "Invalid legacy owner for #{name.inspect}" + + data["skills"].each do |skill_name, owner| + unless skill_name.is_a?(String) && skill_name.match?(NAME_PATTERN) + raise Invalid, "Invalid skill name in registry: #{skill_name.inspect}" end - converted = {"ecosystem" => "gem", "package" => owner["gem"], "version" => owner["version"]} - existing = owners[name] - if existing && existing.values_at("ecosystem", "package") != converted.values_at("ecosystem", "package") - raise Invalid, "Conflicting legacy ownership for #{name.inspect}" + + unless owner.is_a?(Hash) && owner["gem"].is_a?(String) && owner["version"].is_a?(String) + raise Invalid, "Invalid owner for skill #{skill_name.inspect}" end - owners[name] ||= converted end - validate_owners(owners) - rescue Psych::Exception => error - raise Invalid, "Invalid legacy skill registry #{legacy_path}: #{error.message}" end - def validate_owners(owners) - owners.each do |name, owner| - unless name.is_a?(String) && name.match?(NAME_PATTERN) && name.length <= 64 && owner.is_a?(Hash) && owner.values_at("ecosystem", "package", "version").all?{|value| value.is_a?(String) && !value.empty?} - raise Invalid, "Invalid owner for skill #{name.inspect}" - end - end + def default_data + { + "version" => FORMAT_VERSION, + "skills" => {}, + } end end end diff --git a/lib/agent/skills/version.rb b/lib/agent/skills/version.rb index 4358ffe..57376c9 100644 --- a/lib/agent/skills/version.rb +++ b/lib/agent/skills/version.rb @@ -6,6 +6,6 @@ module Agent module Skills - VERSION = "0.1.0" + VERSION = "0.0.3" end end diff --git a/readme.md b/readme.md index 44275e2..d4bc180 100644 --- a/readme.md +++ b/readme.md @@ -1,23 +1,34 @@ # Agent Skills -Provides the Ruby discovery and installation layer used by `agent-context`. +`agent-skills` is deprecated. Skill discovery and installation are now part of [`agent-context`](https://github.com/socketry/agent-context), using the same context metadata format as [`bake-agent-context-rust`](https://github.com/socketry/bake-agent-context-rust). -Run package commands through `agent-context`: +## Migration + +Replace `agent-skills` with `agent-context` in your Gemfile and run `bundle install`. Replace `agent:skills:*` commands with `agent:context:skill:*`: ```sh -bake agent:context:install -bake agent:context:skill:list -bake agent:context:skill:install --gem provider --skill provider-workflow -bake agent:context:skill:show --gem provider --skill provider-workflow +bundle exec bake agent:context:install +bundle exec bake agent:context:skill:list +bundle exec bake agent:context:skill:show --gem provider --skill provider-workflow +bundle exec bake agent:context:skill:install --gem provider --skill provider-workflow ``` -The `agent:skills:*` commands are removed. +Providers should move `skills//SKILL.md` to `context/.md`, add `type: skill` to its YAML front matter, and move companion resources to `context//`. Installed names become package-prefixed. Ensure `context/**/*` is included in the gem's packaged files. + +```markdown +--- +type: skill +description: Run the provider's workflow when setting up the package. +--- -Providers may publish `context/.md` documents with `type: skill` and a non-empty `description`, plus resources under `context//`. Installed names are package-prefixed. Existing `skills//SKILL.md` bundles retain their declared names. Both formats use the 64-character name and 1,024-character description limits and preserve extra metadata. +# Workflow + +Follow these instructions. +``` -The shared ownership index at `.agents/skills/.agent-context-skills.json` records ecosystem, package, and version. Ruby installation migrates legacy ownership, stages replacements, preserves project-owned and Cargo-owned skills, and reconciles empty or removed gem providers. Only generated paths are excluded through local Git rules. +The `agent-context` installer imports ownership from `.agents/skills/.agent-skills.yaml` into `.agents/skills/.agent-context-skills.json` and removes obsolete gem-owned skills during refresh. Project-owned skills remain protected. -See [the packaging specification](specification.md) and [license.md](license.md). +See the [Agent Context documentation](https://github.com/socketry/agent-context/blob/main/guides/getting-started/readme.md), [portable specification](https://github.com/socketry/agent-context/blob/main/specification.md), and [license](license.md). ### Developer Certificate of Origin diff --git a/specification.md b/specification.md index b0ac8a5..c309eea 100644 --- a/specification.md +++ b/specification.md @@ -1,231 +1,3 @@ # Agent Context Specification -## 1. Introduction - -### 1.1 Purpose - -Agent Context is a language-agnostic specification for providing and consuming contextual information from software packages to assist AI agents and other automated tools. This specification defines a standardized way for package authors to include supplementary documentation, examples, migration guides, and other contextual information that can be programmatically discovered and utilized. - -### 1.2 Problem Statement - -AI agents and automated tools working with software projects often lack access to the rich contextual information that package authors provide. While packages may have extensive documentation, examples, and best practices, this information is typically scattered across various sources and formats, making it difficult for automated tools to discover and utilize effectively. - -### 1.3 Goals - -- **Standardization**: Define a consistent structure for contextual information across different programming languages and ecosystems. -- **Discoverability**: Enable automated tools to programmatically find and access contextual information. -- **Separation of Concerns**: Clearly separate provider context from consumer context. -- **Extensibility**: Allow for future enhancements while maintaining backward compatibility. - -## 2. Core Concepts - -### 2.1 Context Provider - -A **Context Provider** is any software package, library, or module that includes contextual information in its distribution. Context providers: - -- Include a `context/` directory in their package root. -- Provide structured documentation and examples. -- Follow the file format specifications defined in this document. -- Version their context alongside their code. - -### 2.2 Context Consumer - -A **Context Consumer** is any project or tool that utilizes contextual information from its dependencies. Context consumers: - -- Install context from their dependencies into a `.agents/context/` directory. -- May install skill-marked context documents into a `.agents/skills/` directory. -- Use tools to discover and access available context. -- Apply context based on file patterns and metadata. - -### 2.3 Context Files - -**Context Files** are structured documents that contain supplementary information about a package. They typically include: - -- Documentation beyond basic API references. -- Configuration examples and templates. -- Migration guides between versions. -- Performance optimization tips. -- Security considerations. -- Troubleshooting guides. - -### 2.4 Context Installation - -**Context Installation** is the process of copying context files from dependencies into a consumer's local context directory, making them available for use by tools and AI agents. - -## 3. Directory Structure - -### 3.1 Provider Directory: `context/` - -Context providers MUST include a `context/` directory in their package root. This directory: - -- **Location**: Must be at the top level of the package (same level as main source directories). -- **Purpose**: Contains all contextual information provided by the package. -- **Distribution**: Must be included in the package's distribution artifacts. -- **Versioning**: Must be versioned alongside the package code. - -Example structure: - -``` -package-root/ -├── context/ -│ ├── getting-started.md -│ ├── configuration.md -│ ├── troubleshooting.md -│ └── migration/ -│ └── v2-to-v3.md -├── src/ -└── package.json -``` - -### 3.2 Consumer Directory: `.agents/context/` - -Context consumers SHOULD create a `.agents/context/` directory in their project root to store installed context. This directory: - -- **Location**: Must be at the project root (typically where package manifests are located). -- **Purpose**: Contains context files copied from dependencies. -- **Organization**: Must organize context by package name in subdirectories. -- **Exclusion**: The generated `.agents/context/` directory SHOULD be excluded from version control. A Git integration SHOULD add local exclusions to `.git/info/exclude` so projects do not need to commit rules for generated files. Other project-owned files may live directly under `.agents/`. -- **Transient Nature**: Should contain only reproducible content that can be regenerated from installed packages and MUST NOT contain unique or modified files. - -Example structure: - -``` -project-root/ -├── .agents/context/ -│ ├── package-a/ -│ │ ├── getting-started.md -│ │ └── configuration.md -│ └── package-b/ -│ └── troubleshooting.md -├── src/ -└── package.json -``` - -### 3.3 Directory Separation Rationale - -The separation between `context/` and `.agents/context/` serves several purposes: - -- **Ownership**: `context/` is controlled by the project itself, `.agents/context/` contains external dependencies. -- **Isolation**: Prevents conflicts between different packages' context files. -- **Discoverability**: Makes it easy to find context for specific packages. -- **Maintenance**: Allows independent management of provided vs. consumed context. - -## 4. Context File Format - -### 4.1 File Extensions - -Context files SHOULD use the following extensions: - -- **`.md`**: Markdown files (primary format). -- **`.txt`**: Plain text files. -- **`.yaml`** or **`.yml`**: YAML configuration files. -- **`.json`**: JSON configuration files. - -### 4.2 Markdown Context Files - -Markdown files are the primary format for context files. They: - -- MUST use valid Markdown syntax. -- SHOULD be named descriptively (e.g., `getting-started.md`, `configuration.md`). -- SHOULD include clear section headers for organization. - -### 4.3 File Naming Conventions - -Context files SHOULD follow these naming conventions: - -- Use lowercase with hyphens for word separation. -- Be descriptive and specific. -- Group related files in subdirectories when appropriate. - -Common file names: - -- `getting-started.md` -- `configuration.md` -- `troubleshooting.md` -- `performance.md` -- `security.md` -- `migration-guide.md` - -### 4.4 Skill Documents - -A context provider may distribute an Agent Skill as a Markdown document directly inside `context/`. The document MUST use YAML front matter with `type: skill` and a non-empty `description`. Its filename, without the `.md` extension, is the local skill name. A directory with the same name MAY contain skill resources. Consumers MUST prefix the local name with the lowercase provider package name (with underscores normalized to hyphens) and a hyphen to produce the installed skill name. Installed names MUST use lowercase ASCII letters, digits, and single hyphens, contain 1–64 characters, and begin and end with a letter or digit. Descriptions MUST contain 1–1,024 characters after trimming whitespace. Consumers MUST validate these fields and preserve additional skill metadata; generated installed names take precedence over a source `name` field. - -A consumer MAY also accept legacy `skills//SKILL.md` bundles, preserving their declared names and complete contents. Such bundles MUST meet the same metadata limits. - -Consumers that support skills MUST install the document as `.agents/skills/-/SKILL.md`, with the installed name and `description` in its front matter. Files in the matching resource directory MUST be copied into that skill directory. Skill documents and their resources MUST NOT also be copied into `.agents/context/` or included in the generated context index. - -Dependency-installed skill directories SHOULD be excluded from version control, while project-owned skills in `.agents/skills/` SHOULD remain trackable. Git integrations SHOULD add exact dependency-installed skill paths to `.git/info/exclude` rather than ignore the entire `.agents/skills/` directory. They SHOULD mark generated entries with comments and preserve user-authored rules outside that marked section. Any generated skill ownership registry SHOULD also be excluded from version control. - -## 5. Discovery and Installation - -### 5.1 Discovery Process - -Tools MUST be able to discover context by: - -1. **Package Scanning**: Examining installed packages for `context/` directories. -2. **Metadata Extraction**: Reading package manifests to identify context-providing packages. -3. **File Enumeration**: Listing available context files for each package. - -The discovery process SHOULD integrate with the target language's package management system to: - -- Locate installed packages and their installation paths. -- Read package metadata to identify packages that provide context. -- Enumerate available context files within discovered packages. - -### 5.2 Installation Process - -Context installation MUST follow these principles: - -1. **Copy Strategy**: Context files SHOULD be copied rather than symlinked. -2. **Namespace Isolation**: Each package's ordinary context MUST be installed in its own subdirectory. -3. **Preserve Structure**: The internal structure of ordinary context files MUST be preserved. -4. **Skill Separation**: Skill documents and their resources MUST be installed as skills only, not duplicated as ordinary context. - -### 5.3 Installation Algorithm - -``` -FOR each package with context: - FOR each file in package/context/: - IF file is a skill document or belongs to a skill resource directory: - IF the consumer supports skills: - INSTALL the document as .agents/skills/skill-name/SKILL.md - COPY its matching resource directory into that skill directory - ELSE: - SKIP it - ELSE: - COPY it into .agents/context/package-name/, preserving its relative path -END -``` - -## 6. Generated Index and Repository Ownership - -Consumers SHOULD generate `.agents/context/index.md` with links relative to that file. Context installation MUST NOT create or modify the repository owner's `agents.md`. Owners can link to the generated index and instruct agents to read relevant installed guidance. - -Ordinary documents need no front matter. Consumers SHOULD use a document's first heading as its title (falling back to the filename) and its first prose sentence as its description. A non-empty YAML `description` takes precedence over prose. Unrelated ordinary-document metadata MUST be tolerated. Skill resource files are opaque assets and MUST NOT be parsed as context documents. - -A provider MAY supply `context/index.yaml` with `description` and a `files` sequence containing `path`, `title`, and `description`. Supporting consumers SHOULD preserve explicit ordering and overrides, omit missing or skill-only paths, and append unlisted ordinary documents. Fallback ordering is getting-started, overview, usage, configuration, migration, troubleshooting, debugging, then alphabetical order. - -## 7. Shared Skill Ownership Index - -Consumers MUST record dependency-installed skill ownership in `.agents/skills/.agent-context-skills.json`: - -```json -{ - "version": 2, - "skills": { - "provider-workflow": { - "ecosystem": "cargo", - "package": "provider", - "version": "1.0.0" - } - } -} -``` - -Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. Each installer MUST preserve foreign ecosystem entries. A full refresh reconciles only its ecosystem; a package refresh reconciles only that ecosystem and package; installing one named skill MUST preserve other owned skills. Providers removed from the dependency graph and providers that become empty MUST be included in full reconciliation. - -Existing unowned destinations and destinations owned by another ecosystem or package MUST NOT be replaced. Installers MUST validate the selected sources and collisions, stage skill files, and preserve the previous directories and ownership index when copying or committing a replacement fails. Shared index writes MUST use atomic replacement. - -Version-one JSON ownership has implicit Cargo ownership and MUST migrate to version two when rewritten. Ruby consumers SHOULD import the legacy `.agent-skills.yaml` registry, rejecting ownership conflicts. The legacy Ruby registry MUST be retired after a successful migration so removed skills cannot be re-imported. - -Git integrations SHOULD maintain one marked exclusion block covering generated context, ownership files, and exact dependency-owned skill directories from all ecosystems. They MUST preserve user rules and leave repository-owned instructions and skills trackable. +`agent-skills` is deprecated. The maintained [Agent Context specification](https://github.com/socketry/agent-context/blob/main/specification.md) defines context documents, metadata-declared skills, and the shared installation ownership index. Use [`agent-context`](https://github.com/socketry/agent-context) for Ruby projects and [`bake-agent-context-rust`](https://github.com/socketry/bake-agent-context-rust) for Cargo projects. diff --git a/test/agent/skills/installer.rb b/test/agent/skills/installer.rb index 404221f..7f2fc8a 100644 --- a/test/agent/skills/installer.rb +++ b/test/agent/skills/installer.rb @@ -100,12 +100,11 @@ def write_skill(root, name, description:, directory_name: name, body: "# Instruc it "records ownership in the registry" do installer.install_gem_skills("fake-gem") - registry_path = File.join(consumer_root, ".agents", "skills", Agent::Skills::Registry::FILE_NAME) + registry_path = File.join(consumer_root, ".agents", "skills", ".agent-skills.yaml") registry = Agent::Skills::Registry.new(registry_path) expect(registry.owner("ruby-testing")).to be == { - "ecosystem" => "gem", - "package" => "fake-gem", + "gem" => "fake-gem", "version" => "1.0.0", } end @@ -186,185 +185,4 @@ def write_skill(root, name, description:, directory_name: name, body: "# Instruc end.to raise_exception(Agent::Skills::Installer::InvalidSkill) end end - it "installs context-declared skills with metadata and opaque resources" do - context = File.join(provider_root, "context") - FileUtils.mkdir_p(File.join(context, "workflow/references")) - File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\nlicense: MIT\nmetadata:\n author: Provider\n---\n\n# Workflow\n") - File.write(File.join(context, "workflow/references/data.md"), "---\nnot valid: [yaml\n---\n") - expect(installer.install_gem_skills("fake-gem").sort).to be == ["fake-gem-workflow", "ruby-testing"] - content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) - expect(content).to be(:include?, "name: fake-gem-workflow") - expect(content).to be(:include?, "license: MIT") - expect(content).to be(:include?, "author: Provider") - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow/references/data.md")) - end - - it "removes a provider's last skill and cleans disappeared gem providers" do - installer.install_all_skills - FileUtils.rm_rf(File.join(provider_root, "skills")) - expect(installer.install_gem_skills("fake-gem")).to be == [] - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) - write_skill(provider_root, "ruby-testing", description: "Test projects.") - installer.install_all_skills - subject.new(root: consumer_root, specifications: []).install_all_skills - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) - end - - it "preserves Cargo skills during a full Ruby refresh" do - registry_path = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) - registry = Agent::Skills::Registry.new(registry_path) - registry.claim("cargo-workflow", "fake-gem", "2.0.0", ecosystem: "cargo") - registry.save - cargo = File.join(consumer_root, ".agents/skills/cargo-workflow/SKILL.md") - FileUtils.mkdir_p(File.dirname(cargo)) - File.write(cargo, "Cargo instructions") - installer.install_all_skills - expect(File.read(cargo)).to be == "Cargo instructions" - expect(Agent::Skills::Registry.new(registry_path).owner("cargo-workflow")["ecosystem"]).to be == "cargo" - end - - it "refuses a matching name owned by Cargo even when the package names match" do - registry = Agent::Skills::Registry.new(File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME)) - registry.claim("ruby-testing", "fake-gem", "1.0.0", ecosystem: "cargo") - registry.save - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::Conflict) - end - - it "preserves the previous skills and registry when resource copying fails" do - installer.install_all_skills - registry_path = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) - previous = File.read(registry_path) - destination = File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md") - instructions = File.read(destination) - write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") - File.symlink("missing", File.join(provider_root, "skills/ruby-testing/broken")) - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - expect(File.read(registry_path)).to be == previous - expect(File.read(destination)).to be == instructions - end - - it "preserves unselected skills when installing one named skill" do - write_skill(provider_root, "documentation", description: "Write docs.") - installer.install_all_skills - FileUtils.rm_rf(File.join(provider_root, "skills/documentation")) - installer.install(skill: "ruby-testing") - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/documentation/SKILL.md")) - end - - it "accepts the common metadata limits and rejects overlong descriptions" do - write_skill(provider_root, "a" * 64, description: "d" * 1024) - expect(installer.list_skills("fake-gem").length).to be == 2 - write_skill(provider_root, "a" * 64, description: "d" * 1025) - expect do - installer.list_skills("fake-gem") - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - end - - it "migrates legacy Ruby ownership without resurrecting removed skills" do - installer.install_all_skills - shared = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) - File.delete(shared) - legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") - File.write(legacy, {"version" => 1, "skills" => {"ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml) - FileUtils.rm_rf(File.join(provider_root, "skills")) - installer.install_all_skills - installer.install_all_skills - expect(File).not.to be(:exist?, legacy) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) - expect(Agent::Skills::Registry.new(shared).owners).to be == {} - end - - it "rejects nested skill declarations outside a skill resource directory" do - context = File.join(provider_root, "context/nested") - FileUtils.mkdir_p(context) - File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing")) - end - - it "trims descriptions before enforcing the shared character limit" do - context = File.join(provider_root, "context") - FileUtils.mkdir_p(context) - metadata = {"type" => "skill", "description" => " #{"é" * 1024} "} - File.write(File.join(context, "workflow.md"), "#{metadata.to_yaml}---\n\n# Workflow\n") - installer.install_all_skills - content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) - expect(installer.list_skills("fake-gem").first.description).to be == "é" * 1024 - expect(content).not.to be(:include?, " é") - end - - it "restores skills, legacy ownership, and exclusions when the registry commit fails" do - system("git", "init", "--quiet", consumer_root, exception: true) - write_skill(provider_root, "documentation", description: "Write docs.") - installer.install_all_skills - registry = File.join(consumer_root, ".agents/skills", Agent::Skills::Registry::FILE_NAME) - exclude = File.join(consumer_root, ".git/info/exclude") - previous_registry = File.read(registry) - previous_exclude = File.read(exclude) - destination = File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md") - previous_instructions = File.read(destination) - legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") - legacy_content = {"version" => 1, "skills" => {"ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml - File.write(legacy, legacy_content) - write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") - FileUtils.rm_rf(File.join(provider_root, "skills/documentation")) - mock(File).before(:rename) do |_source, target| - raise Errno::EACCES, "Injected registry failure" if target == registry - end - expect do - installer.install_all_skills - end.to raise_exception(Errno::EACCES) - expect(File.read(registry)).to be == previous_registry - expect(File.read(exclude)).to be == previous_exclude - expect(File.read(destination)).to be == previous_instructions - expect(File.read(legacy)).to be == legacy_content - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/documentation/SKILL.md")) - end - - it "keeps plain guides out of skills and rejects invalid context skill metadata" do - context = File.join(provider_root, "context") - FileUtils.mkdir_p(context) - guide = File.join(context, "guide.md") - File.write(guide, "# Plain Guide\n\nOrdinary guidance.\n") - expect(installer.list_skills("fake-gem").map(&:name)).to be == ["ruby-testing"] - File.write(guide, "---\ntype: skill\ndescription: [invalid\n---\n") - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - write_skill(provider_root, "Bad_Name", description: "Invalid name.") - File.delete(guide) - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - end - - it "refuses a reserved SKILL.md resource before changing installed skills" do - installer.install_all_skills - context = File.join(provider_root, "context") - FileUtils.mkdir_p(File.join(context, "workflow")) - File.write(File.join(context, "workflow.md"), "---\ntype: skill\ndescription: Run workflow.\n---\n") - File.write(File.join(context, "workflow/SKILL.md"), "Conflicting instructions") - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow")) - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/ruby-testing/SKILL.md")) - end - - it "works without Git and refuses malformed generated exclusion blocks" do - mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} - expect(installer.install_all_skills).to be == ["ruby-testing"] - mock(Open3).clear - system("git", "init", "--quiet", consumer_root, exception: true) - File.write(File.join(consumer_root, ".git/info/exclude"), "# BEGIN bake-agent-context\n") - expect do - installer.install_all_skills - end.to raise_exception(Agent::Skills::Registry::Invalid) - end end diff --git a/test/agent/skills/registry.rb b/test/agent/skills/registry.rb index 0837093..cc27e31 100644 --- a/test/agent/skills/registry.rb +++ b/test/agent/skills/registry.rb @@ -8,7 +8,7 @@ describe Agent::Skills::Registry do let(:temporary_directory) {Dir.mktmpdir} - let(:registry_path) {File.join(temporary_directory, Agent::Skills::Registry::FILE_NAME)} + let(:registry_path) {File.join(temporary_directory, ".agent-skills.yaml")} def around yield @@ -23,8 +23,7 @@ def around reloaded = subject.new(registry_path) expect(reloaded.owner("ruby-testing")).to be == { - "ecosystem" => "gem", - "package" => "sus", + "gem" => "sus", "version" => "1.0.0", } end @@ -46,54 +45,10 @@ def around end it "rejects an invalid registry" do - File.write(registry_path, {"version" => 99, "skills" => {}}.to_json) + File.write(registry_path, {"version" => 99, "skills" => {}}.to_yaml) expect do subject.new(registry_path) end.to raise_exception(Agent::Skills::Registry::Invalid) end - it "imports version-one Cargo ownership into the shared format" do - File.write(registry_path, {"version" => 1, "skills" => {"cargo-workflow" => {"package" => "provider", "version" => "1.0.0"}}}.to_json) - registry = subject.new(registry_path) - expect(registry.owner("cargo-workflow")).to be == {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"} - registry.save - expect(JSON.parse(File.read(registry_path))["version"]).to be == 2 - end - - it "reads and rewrites the portable ownership fixture without changing its owners" do - fixture = File.expand_path("../../fixtures/skill-ownership-index.json", __dir__) - File.write(registry_path, File.read(fixture)) - registry = subject.new(registry_path) - expected = JSON.parse(File.read(fixture)) - registry.save - expect(JSON.parse(File.read(registry_path))).to be == expected - end - - it "rejects malformed JSON and invalid shared owner names" do - ["{invalid json}", {"version" => 2, "skills" => {"a" * 65 => {"ecosystem" => "gem", "package" => "provider", "version" => "1.0.0"}}}.to_json].each do |content| - File.write(registry_path, content) - expect do - subject.new(registry_path) - end.to raise_exception(subject::Invalid) - end - end - - it "rejects malformed and conflicting legacy Ruby ownership" do - legacy = File.join(temporary_directory, ".agent-skills.yaml") - [ - "invalid: [yaml", - {"version" => 99, "skills" => {}}.to_yaml, - {"version" => 1, "skills" => {"workflow" => {"gem" => nil, "version" => "1.0.0"}}}.to_yaml, - ].each do |content| - File.write(legacy, content) - expect do - subject.new(registry_path) - end.to raise_exception(subject::Invalid) - end - File.write(registry_path, {"version" => 2, "skills" => {"workflow" => {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"}}}.to_json) - File.write(legacy, {"version" => 1, "skills" => {"workflow" => {"gem" => "provider", "version" => "1.0.0"}}}.to_yaml) - expect do - subject.new(registry_path) - end.to raise_exception(subject::Invalid) - end end diff --git a/test/fixtures/skill-ownership-index.json b/test/fixtures/skill-ownership-index.json deleted file mode 100644 index 191fc51..0000000 --- a/test/fixtures/skill-ownership-index.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": 2, - "skills": { - "cargo-workflow": { - "ecosystem": "cargo", - "package": "provider", - "version": "1.0.0" - }, - "ruby-testing": { - "ecosystem": "gem", - "package": "fake-gem", - "version": "1.0.0" - } - } -} From c1406269e5747fa1ff846072b5a2c4eaafe710fc Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 17:43:28 +0530 Subject: [PATCH 3/3] Direct users to agent-context for dependency skills Signed-off-by: Samuel Williams --- context/getting-started.md | 25 +++---------------------- context/index.yaml | 5 +++-- guides/getting-started/readme.md | 25 +++---------------------- readme.md | 23 +++-------------------- 4 files changed, 12 insertions(+), 66 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index da51447..bd96ee7 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,33 +1,14 @@ # Getting Started -This guide explains how to migrate from the deprecated `agent-skills` gem to `agent-context`. +`agent-skills` is deprecated. Use `agent-context` to install dependency guidance and skills for Ruby projects. ## Installation -Replace `agent-skills` with `agent-context` in your Gemfile, then run: +Add `agent-context` to your project and install dependency guidance and skills: ```sh -bundle install +bundle add agent-context bundle exec bake agent:context:install ``` -`agent-context` includes skill discovery and installation directly. Use `agent:context:skill:list`, `agent:context:skill:show`, and `agent:context:skill:install` in place of the former `agent:skills:*` commands. - -## Provider Migration - -Move `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the YAML front matter, and move companion resources to `context//`. Include `context/**/*` in the gem's packaged files. Installed skill names become package-prefixed, matching the Rust implementation. - -```markdown ---- -type: skill -description: Run the workflow when setting up this package. ---- - -# Workflow - -Follow the setup instructions. -``` - -Existing installation ownership from `.agent-skills.yaml` migrates automatically. A full refresh removes stale gem-owned skills and preserves project-owned and Cargo-owned skills. - See the [Agent Context documentation](https://github.com/socketry/agent-context) for the supported commands and provider format. diff --git a/context/index.yaml b/context/index.yaml index 6560d46..4b796c3 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -1,7 +1,7 @@ # Automatically generated context index for Utopia::Project guides. # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. --- -description: Deprecated agent skills gem; use agent-context for discovery and installation. +description: 'Deprecated: use agent-context to discover and install agent skills.' metadata: bug_tracker_uri: https://github.com/socketry/agent-skills/issues changelog_uri: https://github.com/socketry/agent-skills/blob/main/releases.md @@ -11,4 +11,5 @@ metadata: files: - path: getting-started.md title: Getting Started - description: This guide explains how to migrate from the deprecated agent-skills gem to agent-context. + description: "`agent-skills` is deprecated. Use `agent-context` to install dependency + guidance and skills for Ruby projects." diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index da51447..bd96ee7 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,33 +1,14 @@ # Getting Started -This guide explains how to migrate from the deprecated `agent-skills` gem to `agent-context`. +`agent-skills` is deprecated. Use `agent-context` to install dependency guidance and skills for Ruby projects. ## Installation -Replace `agent-skills` with `agent-context` in your Gemfile, then run: +Add `agent-context` to your project and install dependency guidance and skills: ```sh -bundle install +bundle add agent-context bundle exec bake agent:context:install ``` -`agent-context` includes skill discovery and installation directly. Use `agent:context:skill:list`, `agent:context:skill:show`, and `agent:context:skill:install` in place of the former `agent:skills:*` commands. - -## Provider Migration - -Move `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the YAML front matter, and move companion resources to `context//`. Include `context/**/*` in the gem's packaged files. Installed skill names become package-prefixed, matching the Rust implementation. - -```markdown ---- -type: skill -description: Run the workflow when setting up this package. ---- - -# Workflow - -Follow the setup instructions. -``` - -Existing installation ownership from `.agent-skills.yaml` migrates automatically. A full refresh removes stale gem-owned skills and preserves project-owned and Cargo-owned skills. - See the [Agent Context documentation](https://github.com/socketry/agent-context) for the supported commands and provider format. diff --git a/readme.md b/readme.md index d4bc180..aa68bb1 100644 --- a/readme.md +++ b/readme.md @@ -2,32 +2,15 @@ `agent-skills` is deprecated. Skill discovery and installation are now part of [`agent-context`](https://github.com/socketry/agent-context), using the same context metadata format as [`bake-agent-context-rust`](https://github.com/socketry/bake-agent-context-rust). -## Migration +## Agent Context -Replace `agent-skills` with `agent-context` in your Gemfile and run `bundle install`. Replace `agent:skills:*` commands with `agent:context:skill:*`: +Add `agent-context` to your project and install dependency guidance and skills: ```sh +bundle add agent-context bundle exec bake agent:context:install -bundle exec bake agent:context:skill:list -bundle exec bake agent:context:skill:show --gem provider --skill provider-workflow -bundle exec bake agent:context:skill:install --gem provider --skill provider-workflow ``` -Providers should move `skills//SKILL.md` to `context/.md`, add `type: skill` to its YAML front matter, and move companion resources to `context//`. Installed names become package-prefixed. Ensure `context/**/*` is included in the gem's packaged files. - -```markdown ---- -type: skill -description: Run the provider's workflow when setting up the package. ---- - -# Workflow - -Follow these instructions. -``` - -The `agent-context` installer imports ownership from `.agents/skills/.agent-skills.yaml` into `.agents/skills/.agent-context-skills.json` and removes obsolete gem-owned skills during refresh. Project-owned skills remain protected. - See the [Agent Context documentation](https://github.com/socketry/agent-context/blob/main/guides/getting-started/readme.md), [portable specification](https://github.com/socketry/agent-context/blob/main/specification.md), and [license](license.md). ### Developer Certificate of Origin