From 9a64df01217430e9053c082f52ec317ea086664c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 12:56:44 +0530 Subject: [PATCH 01/37] Align context and skill installation with the portable specification Signed-off-by: Samuel Williams --- .cursor/rules/agent.mdc | 2 +- .github/copilot-instructions.md | 21 +- .gitignore | 2 - agent-context.gemspec | 4 +- bake/agent/context.rb | 36 +-- bake/agent/context/skill.rb | 32 +++ context/getting-started.md | 189 ++----------- context/index.yaml | 8 +- guides/getting-started/readme.md | 189 ++----------- lib/agent/context/document.rb | 69 +++++ lib/agent/context/index.rb | 383 ++++++-------------------- lib/agent/context/installer.rb | 138 +++++----- readme.md | 193 ++++--------- specification.md | 65 ++++- test/agent/context/index.rb | 450 +++++++------------------------ test/agent/context/installer.rb | 2 +- test/agent/context/skills.rb | 84 ++++++ test/agent/context/tasks.rb | 69 +++++ 18 files changed, 674 insertions(+), 1262 deletions(-) create mode 100644 bake/agent/context/skill.rb create mode 100644 lib/agent/context/document.rb create mode 100644 test/agent/context/skills.rb create mode 100644 test/agent/context/tasks.rb diff --git a/.cursor/rules/agent.mdc b/.cursor/rules/agent.mdc index 9e19237..b6a580a 100644 --- a/.cursor/rules/agent.mdc +++ b/.cursor/rules/agent.mdc @@ -1,4 +1,4 @@ --- alwaysApply: true --- -Read the `agent.md` file in the project root directory for detailed context relating to this project and external dependencies. +Follow repository-owned instructions in `agents.md` when present. Read relevant guidance from `.agents/context/index.md`. Run `bundle exec bake agent:context:install` if generated guidance is missing. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1de68b2..fe3199b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,20 +1,5 @@ -# GitHub Copilot Instructions +# Agent Instructions -## Use Agent Context +Follow repository-owned instructions in `agents.md`, if present. Read relevant dependency guidance from `.agents/context/index.md`. If generated guidance is missing, run `bundle exec bake agent:context:install`. -When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. - -If the file does not exist, you will need to install it, by running the following command: - -```bash -$ bundle install -$ bundle exec bake agent:context:install -``` - -This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. - -## Ignoring Files - -The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. - -While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. +Package guides belong in `context/`; repository-only instructions and project-owned skills belong in `.agents/`. Installation preserves `agents.md` and maintains local Git exclusions for generated files. diff --git a/.gitignore b/.gitignore index ac40d2d..e9771eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -/agents.md -/.agents/ /.context /.bundle /pkg diff --git a/agent-context.gemspec b/agent-context.gemspec index 3c0c134..efb0f64 100644 --- a/agent-context.gemspec +++ b/agent-context.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |spec| spec.name = "agent-context" spec.version = Agent::Context::VERSION - spec.summary = "Install and manage context files from Ruby gems." + spec.summary = "Install and manage context files and skills from Ruby gems." spec.authors = ["Samuel Williams", "Shopify Inc."] spec.license = "MIT" @@ -28,4 +28,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "bake", ">= 0.23" + spec.add_dependency "agent-skills", "~> 0.1" + spec.add_dependency "rdoc", ">= 6" end diff --git a/bake/agent/context.rb b/bake/agent/context.rb index 6586085..bfa6c15 100644 --- a/bake/agent/context.rb +++ b/bake/agent/context.rb @@ -56,33 +56,17 @@ def show(gem:, file:) end end -# Install context files from gems into the current project. -# @parameter gem [String] Optional specific gem name to install context from. +# Install ordinary context and skills, then refresh the generated index. +# @parameter gem [String | Nil] An optional provider gem. def install(gem: nil) - if gem - if @installer.install_gem_context(gem) - puts "Installed context from gem '#{gem}'" - else - puts "No context found for gem '#{gem}'" - end - else - installed = @installer.install_all_context - if installed.any? - puts "Installed context from #{installed.length} gems:" - installed.each{|gem_name| puts "\t#{gem_name}"} - else - puts "No gems with context found" - end - end - - # Update agents.md after installing context - index = Agent::Context::Index.new(@installer.context_path) - index.update_agents_md + installed = @installer.install(gem: gem) + puts "Installed context from: #{installed[:context].join(", ")}" unless installed[:context].empty? + puts "Installed skills: #{installed[:skills].join(", ")}" unless installed[:skills].empty? + puts "Updated .agents/context/index.md" end -# Update or create AGENTS.md in the project root with context section -# This follows the AGENTS.md specification for agentic coding tools -def agents_md(path = "agents.md") - index = Agent::Context::Index.new(@installer.context_path) - index.update_agents_md(path) +# Refresh the generated index of installed context. +def index + Agent::Context::Index.new(@installer.context_path).update_index + puts "Updated .agents/context/index.md" end diff --git a/bake/agent/context/skill.rb b/bake/agent/context/skill.rb new file mode 100644 index 0000000..c56f272 --- /dev/null +++ b/bake/agent/context/skill.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "agent/skills/installer" + +# List dependency skills from all gems or one provider. +# @parameter gem [String | Nil] An optional provider gem. +def list(gem: nil) + installer = Agent::Skills::Installer.new(root: context.root) + skills = gem ? Array(installer.list_skills(gem)) : installer.find_gems_with_skills.flat_map{|provider| provider[:skills]} + skills.each{|skill| puts "#{skill.name} (#{skill.provider_name}@#{skill.provider_version}) — #{skill.description}"} + puts "No dependency skills found" if skills.empty? +end + +# Show a dependency skill's installed instructions. +# @parameter gem [String] The provider gem. +# @parameter skill [String] The installed skill name. +def show(gem:, skill:) + content = Agent::Skills::Installer.new(root: context.root).show_skill(gem, skill) + raise ArgumentError, "No skill #{skill.inspect} in gem #{gem.inspect}" unless content + puts content +end + +# Install dependency skills and reconcile only the selected scope. +# @parameter gem [String | Nil] An optional provider gem. +# @parameter skill [String | Nil] An optional installed skill name. +def install(gem: nil, skill: nil) + names = Agent::Skills::Installer.new(root: context.root).install(gem: gem, skill: skill) + puts names.empty? ? "No dependency skills were installed" : "Installed skills: #{names.join(", ")}" +end diff --git a/context/getting-started.md b/context/getting-started.md index 29e3bed..ce7b2ce 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,190 +1,57 @@ # Getting Started -This guide explains how to use `agent-context`, a tool for discovering and installing contextual information from Ruby gems to help AI agents. - -## Overview - -`agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` directory. +This guide explains how to install package guidance and skills using `agent-context`. ## Installation -Add the gem to your project: - -```bash -$ bundle add agent-context +```sh +bundle add agent-context +bundle exec bake agent:context:install ``` -Then install agent context files: - -```bash -$ 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 -```bash -# See what context is available +```sh bake agent:context:list - -# Install all available context -bake agent:context:install - -# Install context from a specific gem +bake agent:context:show --gem async --file getting-started bake agent:context:install --gem async - -# See what context files a gem provides -bake agent:context:list --gem async - -# View a specific context file -bake agent:context:show --gem async --file thread-safety -``` - -## Understanding context/ vs .agents/context/ - -**Important distinction:** -- **`context/`** (no dot) = Directory in gems that contains context files to share. -- **`.agents/context/`** (with dot) = Directory in your project where context gets installed. - -### What happens when you install context? - -When you run `bake agent:context:install`, the tool: - -1. Scans all installed gems for `context/` directories (in the gem's root). -2. Creates a `.agents/context/` directory in your current project. -3. Copies context files organized by gem name. - -For example: -``` -your-project/ -├── .agents/context/ # ← Installed context (with dot) -│ ├── async/ # ← From the 'async' gem's context/ directory -│ │ ├── thread-safety.md -│ │ └── performance.md -│ └── rack/ # ← From the 'rack' gem's context/ directory -│ └── middleware.md -├── lib/ -└── Gemfile -``` - -Meanwhile, in the gems themselves: -``` -async-gem/ -├── context/ # ← Source context (no dot) -│ ├── thread-safety.md -│ └── performance.md -├── lib/ -└── async.gemspec -``` - -## Using Context (For Gem Users) - -### Why use this? - -- **Discover hidden documentation** that gems provide. -- **Get practical examples** and guidance. -- **Understand best practices** from gem authors. -- **Access migration guides** and troubleshooting tips. - -### Key Points for Users - -- Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). -- The `.agents/context/` directory is where installed context lives in your project. -- Ignore the generated `.agents/` directory in version control. -- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. - -## Providing Context (For Gem Authors) - -### How to provide context in your gem - -#### 1. Create a `context/` directory - -In your gem's root directory, create a `context/` folder (no dot): - +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 ``` -your-gem/ -├── context/ # ← Source context (no dot) - this is what you create -│ ├── getting-started.md -│ ├── configuration.md -│ └── troubleshooting.md -├── lib/ -└── your-gem.gemspec -``` - -**Important:** This is different from `.agents/context/` (with dot) which is where context gets installed in user projects. -#### 2. Add context files +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. -Create files with helpful information for users of your gem. Common types include: +## Provider Layout -- **getting-started.md** - Quick start guide for using your gem. -- **configuration.md** - Configuration options and examples. -- **troubleshooting.md** - Common issues and solutions. -- **migration.md** - Migration guides between versions. -- **performance.md** - Performance tips and best practices. -- **security.md** - Security considerations. +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. -**Focus on the agent experience:** These files should help AI agents understand how to use your gem effectively, not document your gem's internal APIs. - -#### 3. Document your context - -Add a section to your gem's README: +To provide a skill, put a Markdown document directly inside `context/`: ```markdown -## Context +--- +type: skill +description: Follow the workflow when setting up this package. +--- -This gem provides additional context files that can be installed using `bake agent:context:install`. +# Workflow -Available context files: -- `getting-started.md` - Quick start guide. -- `configuration.md` - Configuration options. -- `troubleshooting.md` - Common issues and solutions. +Follow the setup instructions. ``` -#### 4. File format and content guidelines - -Context files can be in any format, but `.md` is commonly used for documentation. The content should be: - -- **Practical** - Include real examples and working code. -- **Focused** - One topic per file. -- **Clear** - Easy to understand and follow. -- **Actionable** - Provide specific guidance and next steps. -- **Agent-focused** - Help AI agents understand how to use your gem effectively. - -### Key Points for Gem Authors - -- Create a `context/` directory (no dot) in your gem's root. -- Put helpful guides for users of your gem there. -- Focus on practical usage, not API documentation. - -## Example Context Files - -For examples of well-structured context files, see the existing files in this directory: -- `usage.md` - Shows how to use the tool (this file). -- `examples.md` - Demonstrates practical usage scenarios. - -## Key Differences from API Documentation - -Context files are NOT the same as API documentation: - -- **Context files**: Help agents accomplish tasks ("How do I configure authentication?"). -- **API documentation**: Document methods and classes ("Method `authenticate` returns Boolean"). +`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. -Context files should answer questions like: -- "How do I get started?". -- "How do I configure this for production?". -- "What do I do when X goes wrong?". -- "How do I migrate from version Y to Z?". +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. -## Testing Your Context +## Ownership and Version Control -Before publishing, test your context files: +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. -1. Have an AI agent try to follow your getting-started guide. -2. Check that all code examples actually work. -3. Ensure the files are focused and don't try to cover too much. -4. Verify that they complement rather than duplicate your main documentation. +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. -## Summary +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. -- **`context/`** = source (in gems). -- **`.agents/context/`** = destination (in your project). +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 4d2aa8e..c856e9d 100644 --- a/context/index.yaml +++ b/context/index.yaml @@ -1,13 +1,15 @@ # 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 context files from Ruby gems. +description: Install and manage context files and skills from Ruby gems. metadata: + bug_tracker_uri: https://github.com/ioquatix/agent-context/issues + changelog_uri: https://github.com/ioquatix/agent-context/blob/main/releases.md documentation_uri: https://ioquatix.github.io/agent-context/ funding_uri: https://github.com/sponsors/ioquatix/ source_code_uri: https://github.com/ioquatix/agent-context.git files: - path: getting-started.md title: Getting Started - description: This guide explains how to use `agent-context`, a tool for discovering - and installing contextual information from Ruby gems to help AI agents. + 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 29e3bed..ce7b2ce 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,190 +1,57 @@ # Getting Started -This guide explains how to use `agent-context`, a tool for discovering and installing contextual information from Ruby gems to help AI agents. - -## Overview - -`agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` directory. +This guide explains how to install package guidance and skills using `agent-context`. ## Installation -Add the gem to your project: - -```bash -$ bundle add agent-context +```sh +bundle add agent-context +bundle exec bake agent:context:install ``` -Then install agent context files: - -```bash -$ 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 -```bash -# See what context is available +```sh bake agent:context:list - -# Install all available context -bake agent:context:install - -# Install context from a specific gem +bake agent:context:show --gem async --file getting-started bake agent:context:install --gem async - -# See what context files a gem provides -bake agent:context:list --gem async - -# View a specific context file -bake agent:context:show --gem async --file thread-safety -``` - -## Understanding context/ vs .agents/context/ - -**Important distinction:** -- **`context/`** (no dot) = Directory in gems that contains context files to share. -- **`.agents/context/`** (with dot) = Directory in your project where context gets installed. - -### What happens when you install context? - -When you run `bake agent:context:install`, the tool: - -1. Scans all installed gems for `context/` directories (in the gem's root). -2. Creates a `.agents/context/` directory in your current project. -3. Copies context files organized by gem name. - -For example: -``` -your-project/ -├── .agents/context/ # ← Installed context (with dot) -│ ├── async/ # ← From the 'async' gem's context/ directory -│ │ ├── thread-safety.md -│ │ └── performance.md -│ └── rack/ # ← From the 'rack' gem's context/ directory -│ └── middleware.md -├── lib/ -└── Gemfile -``` - -Meanwhile, in the gems themselves: -``` -async-gem/ -├── context/ # ← Source context (no dot) -│ ├── thread-safety.md -│ └── performance.md -├── lib/ -└── async.gemspec -``` - -## Using Context (For Gem Users) - -### Why use this? - -- **Discover hidden documentation** that gems provide. -- **Get practical examples** and guidance. -- **Understand best practices** from gem authors. -- **Access migration guides** and troubleshooting tips. - -### Key Points for Users - -- Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). -- The `.agents/context/` directory is where installed context lives in your project. -- Ignore the generated `.agents/` directory in version control. -- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. - -## Providing Context (For Gem Authors) - -### How to provide context in your gem - -#### 1. Create a `context/` directory - -In your gem's root directory, create a `context/` folder (no dot): - +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 ``` -your-gem/ -├── context/ # ← Source context (no dot) - this is what you create -│ ├── getting-started.md -│ ├── configuration.md -│ └── troubleshooting.md -├── lib/ -└── your-gem.gemspec -``` - -**Important:** This is different from `.agents/context/` (with dot) which is where context gets installed in user projects. -#### 2. Add context files +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. -Create files with helpful information for users of your gem. Common types include: +## Provider Layout -- **getting-started.md** - Quick start guide for using your gem. -- **configuration.md** - Configuration options and examples. -- **troubleshooting.md** - Common issues and solutions. -- **migration.md** - Migration guides between versions. -- **performance.md** - Performance tips and best practices. -- **security.md** - Security considerations. +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. -**Focus on the agent experience:** These files should help AI agents understand how to use your gem effectively, not document your gem's internal APIs. - -#### 3. Document your context - -Add a section to your gem's README: +To provide a skill, put a Markdown document directly inside `context/`: ```markdown -## Context +--- +type: skill +description: Follow the workflow when setting up this package. +--- -This gem provides additional context files that can be installed using `bake agent:context:install`. +# Workflow -Available context files: -- `getting-started.md` - Quick start guide. -- `configuration.md` - Configuration options. -- `troubleshooting.md` - Common issues and solutions. +Follow the setup instructions. ``` -#### 4. File format and content guidelines - -Context files can be in any format, but `.md` is commonly used for documentation. The content should be: - -- **Practical** - Include real examples and working code. -- **Focused** - One topic per file. -- **Clear** - Easy to understand and follow. -- **Actionable** - Provide specific guidance and next steps. -- **Agent-focused** - Help AI agents understand how to use your gem effectively. - -### Key Points for Gem Authors - -- Create a `context/` directory (no dot) in your gem's root. -- Put helpful guides for users of your gem there. -- Focus on practical usage, not API documentation. - -## Example Context Files - -For examples of well-structured context files, see the existing files in this directory: -- `usage.md` - Shows how to use the tool (this file). -- `examples.md` - Demonstrates practical usage scenarios. - -## Key Differences from API Documentation - -Context files are NOT the same as API documentation: - -- **Context files**: Help agents accomplish tasks ("How do I configure authentication?"). -- **API documentation**: Document methods and classes ("Method `authenticate` returns Boolean"). +`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. -Context files should answer questions like: -- "How do I get started?". -- "How do I configure this for production?". -- "What do I do when X goes wrong?". -- "How do I migrate from version Y to Z?". +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. -## Testing Your Context +## Ownership and Version Control -Before publishing, test your context files: +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. -1. Have an AI agent try to follow your getting-started guide. -2. Check that all code examples actually work. -3. Ensure the files are focused and don't try to cover too much. -4. Verify that they complement rather than duplicate your main documentation. +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. -## Summary +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. -- **`context/`** = source (in gems). -- **`.agents/context/`** = destination (in your project). +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/context/document.rb b/lib/agent/context/document.rb new file mode 100644 index 0000000..f383a07 --- /dev/null +++ b/lib/agent/context/document.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "rdoc" +require "rdoc/markdown" +require "rdoc/markup/to_html" +require "cgi" +require "yaml" + +module Agent + module Context + # Represents metadata extracted from an ordinary context document. + class Document + CANONICAL_ORDER = %w[getting-started overview usage configuration migration troubleshooting debugging].freeze + + # Initialize a document from its source path. + # @parameter path [String] The Markdown source file. + def initialize(path) + @path = path + content = File.read(path) + @metadata = {} + if match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) + @metadata = YAML.safe_load(match[1], aliases: false) + raise ArgumentError, "Context frontmatter must be a mapping: #{path}" unless @metadata.is_a?(Hash) + content = content[match.end(0)..] + end + @parts = RDoc::Markdown.parse(content).parts + end + + # @returns [String] The first heading or a filename-derived title. + def title + heading = @parts.find{|part| part.is_a?(RDoc::Markup::Heading) && !part.text.strip.empty?} + heading ? plain_text(heading.text) : File.basename(@path, File.extname(@path)).tr("-", " ") + end + + # @returns [String | Nil] Explicit metadata or the first prose sentence. + def description + explicit = @metadata["description"] + raise ArgumentError, "Context description must be a string: #{@path}" if explicit && !explicit.is_a?(String) + return explicit.strip if explicit && !explicit.strip.empty? + paragraph = @parts.find{|part| part.is_a?(RDoc::Markup::Paragraph)} + return unless paragraph + plain_text(paragraph.text).split(/(?<=[.!?])\s+/, 2).first + end + + # @returns [bool] Whether this document declares a skill. + def skill? + @metadata["type"] == "skill" + end + + # Compute stable document ordering. + # @parameter path [String] The relative document path. + # @returns [Array] Canonical position, filename, and relative path. + def self.order(path) + name = File.basename(path, File.extname(path)).downcase + [CANONICAL_ORDER.index(name) || CANONICAL_ORDER.length, name, path] + end + + private + + def plain_text(text) + html = RDoc::Markup::ToHtml.new(nil).to_html(text) + CGI.unescapeHTML(html.gsub(/<[^>]+>/, "")).gsub(/\s+/, " ").strip + end + end + end +end diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index 16ae55f..d5b45be 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -1,331 +1,110 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2025, by Samuel Williams. +# Copyright, 2025-2026, by Samuel Williams. # Copyright, 2025, by Shopify Inc. -require_relative "version" -require_relative "paths" require "fileutils" require "pathname" require "yaml" +require "uri" +require "tempfile" +require "agent/skills/registry" +require "agent/skills/exclusion" +require_relative "paths" +require_relative "document" -# @namespace module Agent - # @namespace module Context - # Represents an index for managing and generating agents.md files from context files. - # - # This class provides functionality to update or create AGENTS.md files following - # the AGENTS.md specification for agentic coding tools. It can parse existing - # agents.md files, update the context section, and generate new files when needed. + # Represents the generated index of installed dependency guidance. class Index - # Initialize a new index instance. - # @parameter context_path [String] The directory containing installed context. - # @parameter context_link_path [String] The path used for links from `agents.md`. - def initialize(context_path = CONTEXT_PATH, context_link_path: CONTEXT_PATH) - @context_path = context_path - @context_link_path = context_link_path - end - - attr :context_path - attr :context_link_path - - # Update or create an AGENTS.md file in the project root with context section - # This follows the AGENTS.md specification for agentic coding tools - def update_agents_md(agents_md_path = "agents.md") - context_content = generate_context_section - - if File.exist?(agents_md_path) - update_existing_agents_md(agents_md_path, context_content) - else - create_new_agents_md(agents_md_path, context_content) + # Initialize an index for installed context. + # @parameter context_path [String] The installed context directory. + def initialize(context_path = CONTEXT_PATH) + @context_path = File.expand_path(context_path) + end + + # @attribute [String] The installed context directory. + attr_reader :context_path + + # Write the generated index and update local Git exclusions. + def update_index + content = generate_index + FileUtils.mkdir_p(@context_path) + Tempfile.create(".agent-context-index-", @context_path) do |file| + file.write(content) + file.flush + File.rename(file.path, File.join(@context_path, "index.md")) end - - Console.debug("Updated agents.md: #{agents_md_path}") - end - - # Generate just the context section content (without top-level headers) - def generate_context_section - sections = [] - - sections << "This section provides links to documentation from installed packages. It is automatically generated and may be updated by running `bake agent:context:install`." - sections << "" - sections << "**Important:** Before performing any code, documentation, or analysis tasks, always read and apply the full content of any relevant documentation referenced in the following sections. These context files contain authoritative standards and best practices for documentation, code style, and project-specific workflows. **Do not proceed with any actions until you have read and incorporated the guidance from relevant context files.**" - sections << "" - sections << "**Setup Instructions:** If the referenced files are not present or if dependencies have been updated, run `bake agent:context:install` to install the latest context files." - sections << "" - - gem_contexts = collect_gem_contexts - - if gem_contexts.empty? - sections << "No context files found. Run `bake agent:context:install` to install context from gems." - sections << "" - else - gem_contexts.each do |gem_name, files| - sections << "### #{gem_name}" - sections << "" - - # Get gem directory and load index - gem_directory = File.join(@context_path, gem_name) - index = load_gem_index(gem_name, gem_directory) - - # Add gem description from index - if index["description"] - sections << index["description"] - sections << "" - end - - # Use files from index if available, otherwise fall back to parsing - if index["files"] && !index["files"].empty? - index["files"].each do |file_info| - link_path = File.join(@context_link_path, gem_name, file_info["path"]) - sections << "#### [#{file_info['title']}](#{link_path})" - sections << "" - sections << file_info["description"] if file_info["description"] && !file_info["description"].empty? - sections << "" - end + root = File.dirname(File.dirname(@context_path)) + registry = Skills::Registry.new(File.join(root, ".agents", "skills", Skills::Registry::FILE_NAME)) + Skills::Exclusion.new(root, registry.owners.keys).apply + end + + # Render links relative to the generated index, preserving custom provider metadata. + # @returns [String] The generated Markdown index. + def generate_index + sections = [ + "# Context Index", "", + "This index links to guidance installed from resolved gem dependencies. It is generated by `bake agent:context:install` and can be refreshed with `bake agent:context:index`.", "", + "Before working on a package, read the relevant context files below. If these files are missing, run `bake agent:context:install`.", "", + ] + providers = Dir.exist?(@context_path) ? Dir.children(@context_path).sort.select{|name| File.directory?(File.join(@context_path, name)) && !File.symlink?(File.join(@context_path, name))} : [] + found = false + providers.each do |name| + directory = File.join(@context_path, name) + files = Dir.glob(File.join(directory, "**", "*")).select{|path| File.file?(path) && !File.symlink?(path) && File.extname(path).downcase == ".md"} + documents = {} + skill_roots = [] + files.sort_by{|path| [File.dirname(path) == directory ? 0 : 1, path]}.each do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(directory)).to_s + next if skill_roots.any?{|skill| relative.start_with?("#{skill}/")} + document = Document.new(path) + if document.skill? + skill_roots << relative.delete_suffix(File.extname(relative)) else - # Fallback to parsing files directly - files.each do |file_path| - if File.exist?(file_path) - title, description = extract_content(file_path) - relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@context_path)) - link_path = File.join(@context_link_path, relative_path.to_s) - sections << "#### [#{title}](#{link_path})" - sections << "" - sections << description if description && !description.empty? - sections << "" - end - end + documents[relative] = document end end - end - - sections.join("\n") - end - - private - - def update_existing_agents_md(agents_md_path, context_content) - content = File.read(agents_md_path) - - # Find the # Agent heading - agent_heading_line = find_agent_heading_line(content) - - if agent_heading_line - # Find or create the ## Context section - context_section = find_context_section_under_agent(content, agent_heading_line) - - if context_section - # Replace existing context section - updated_content = replace_context_section(content, context_section, context_content) - else - # Insert new context section after agent heading - updated_content = insert_context_section_after_agent(content, agent_heading_line, context_content) - end - else - # No # Agent heading found, prepend it with context - updated_content = prepend_agent_with_context(content, context_content) - end - - # Write the updated content back to file - File.write(agents_md_path, updated_content) - end - - def create_new_agents_md(agents_md_path, context_content) - content = [ - "# Agent", - "", - "## Context", - "", - context_content, - ].join("\n") - File.write(agents_md_path, content) - end - - def find_agent_heading_line(content) - lines = content.lines - lines.each_with_index do |line, index| - if line.strip.start_with?("# ") && line.strip.downcase == "# agent" - return index - end - end - nil - end - - def find_context_section_under_agent(content, agent_line_index) - lines = content.lines - - # Look for ## Context after the agent heading - (agent_line_index + 1).upto(lines.length - 1) do |index| - line = lines[index] - if line.strip == "## Context" - return index - elsif line.strip.start_with?("# ") && line.strip != "# Agent" - # We've hit another top-level heading, stop searching - break + next if documents.empty? + found = true + metadata = load_gem_index(name, directory) + sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || "Context files for #{name}"), ""]) + listed = [] + Array(metadata["files"]).each do |entry| + next unless entry.is_a?(Hash) && documents.key?(entry["path"]) && !listed.include?(entry["path"]) + append_document(sections, name, entry["path"], documents.fetch(entry["path"]), entry) + listed << entry["path"] end - end - - nil - end - - def replace_context_section(content, context_line_index, context_content) - lines = content.lines - - # Find the end of the context section - end_index = find_section_end(lines, context_line_index, 2) - - # Build the new content - new_lines = [] - new_lines.concat(lines[0...context_line_index]) - new_lines << "## Context\n" - new_lines << "\n" - new_lines.concat(context_content.lines) - new_lines.concat(lines[end_index..-1]) - - new_lines.join - end - - def insert_context_section_after_agent(content, agent_line_index, context_content) - lines = content.lines - - # Build the new content - new_lines = [] - new_lines.concat(lines[0..agent_line_index]) - new_lines << "\n" - new_lines << "## Context\n" - new_lines << "\n" - new_lines.concat(context_content.lines) - new_lines.concat(lines[agent_line_index + 1..-1]) - - new_lines.join - end - - def prepend_agent_with_context(content, context_content) - agent_context = [ - "# Agent", - "", - "## Context", - "", - context_content, - "" - ].join("\n") - agent_context + content - end - - def find_section_end(lines, start_index, heading_level) - index = start_index + 1 - - while index < lines.length - line = lines[index] - - if line.strip.start_with?("#") - level = line.strip.match(/^(#+)/)[1].length - if level <= heading_level - break - end + (documents.keys - listed).sort_by{|path| Document.order(path)}.each do |path| + append_document(sections, name, path, documents.fetch(path)) end - - index += 1 end - - index + sections << "No context files are installed." unless found + sections.pop while sections.last == "" + sections.join("\n") + "\n" end - def collect_gem_contexts - gem_contexts = {} - - return gem_contexts unless Dir.exist?(@context_path) - - Dir.glob(File.join(@context_path, "*")).each do |gem_directory| - next unless File.directory?(gem_directory) - gem_name = File.basename(gem_directory) - - markdown_files = Dir.glob(File.join(gem_directory, "**", "*.md")).sort - gem_contexts[gem_name] = markdown_files if markdown_files.any? - end - - gem_contexts - end + private - # Load a gem's index file - def load_gem_index(gem_name, gem_directory) - index_path = File.join(gem_directory, "index.yaml") - - if File.exist?(index_path) - YAML.load_file(index_path) - else - # Return a fallback index if no index.yaml exists - { - "description" => "Context files for #{gem_name}", - "files" => [] - } - end - rescue => error - Console.debug("Error loading index for #{gem_name}: #{error.message}") - # Return a fallback index - { - "description" => "Context files for #{gem_name}", - "files" => [] - } + def load_gem_index(name, directory) + path = File.join(directory, "index.yaml") + metadata = File.file?(path) ? YAML.safe_load_file(path, aliases: false) : {} + metadata.is_a?(Hash) ? metadata : {} + rescue Psych::Exception + {} end - def extract_content(file_path) - content = File.read(file_path) - lines = content.lines.map(&:strip) - - title = extract_title(lines) - description = extract_description(lines) - - [title, description] + def append_document(sections, package, path, document, metadata = {}) + title = metadata["title"] || document.title + description = metadata["description"] || document.description + link = URI::DEFAULT_PARSER.escape("#{package}/#{path}", /[^a-zA-Z0-9\-._~\/]/) + sections.concat(["### [#{escape(title)}](#{link})", ""]) + sections.concat([escape(description), ""]) if description && !description.empty? end - def extract_title(lines) - # Look for the first markdown header - header_line = lines.find{|line| line.start_with?("#")} - if header_line - # Remove markdown header syntax and clean up - header_line.sub(/^#+\s*/, "").strip - else - # If no header found, use a default - "Documentation" - end - end - - def extract_description(lines) - # Skip empty lines and headers to find the first paragraph - content_start = false - description_lines = [] - - lines.each do |line| - # Skip headers - next if line.start_with?("#") - - # Skip empty lines until we find content - if !content_start && line.empty? - next - end - - # Mark that we've found content - content_start = true - - # If we hit an empty line after finding content, we've reached the end of the first paragraph - if line.empty? - break - end - - description_lines << line - end - - # Join the lines and truncate if too long - description = description_lines.join(" ").strip - if description.length > 197 - description = description[0..196] + "..." - end - - description + def escape(text) + text.to_s.gsub(/\s+/, " ").gsub(/([\\`*_\[\]<>])/){"\\#{$1}"} end end end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index f03654a..a68cffd 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -8,8 +8,12 @@ require "fileutils" require "pathname" require "yaml" +require "tmpdir" require_relative "paths" +require_relative "document" +require_relative "index" +require "agent/skills/installer" module Agent module Context @@ -35,7 +39,8 @@ class Installer def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @context_path = File.join(@root, CONTEXT_PATH) - @specifications = specifications + @specifications = specifications.to_a + @skills = Agent::Skills::Installer.new(root: @root, specifications: @specifications) end attr_reader :root @@ -89,7 +94,10 @@ def list_context_files(gem_name) gem = find_gem_with_context(gem_name) return nil unless gem - Dir.glob(File.join(gem[:path], "**/*")).select{|f| File.file?(f)} + skill_paths = Array(@skills.list_skills(gem_name)).flat_map{|skill| skill.source_file ? [skill.source_file, skill.path].compact : []} + Dir.glob(File.join(gem[:path], "**/*")).select do |file| + File.file?(file) && !File.symlink?(file) && !skill_paths.any?{|path| file == path || file.start_with?("#{path}/")} + end end # Show content of a specific context file. @@ -97,17 +105,15 @@ def show_context_file(gem_name, file_name) gem = find_gem_with_context(gem_name) return nil unless gem - # Try to find the file with or without extension: - possible_paths = [ - File.join(gem[:path], file_name), - File.join(gem[:path], "#{file_name}.md"), - File.join(gem[:path], "#{file_name}.md") - ] - - file_path = possible_paths.find{|path| File.exist?(path)} - return nil unless file_path - - File.read(file_path) + requested = Pathname.new(file_name) + if requested.absolute? || requested.each_filename.any?{|part| part == ".."} + raise ArgumentError, "Context file must be a relative path inside context/" + end + candidates = [File.join(gem[:path], file_name)] + candidates << "#{candidates.first}.md" if File.extname(file_name).empty? + available = list_context_files(gem_name) + path = candidates.find{|candidate| available.include?(candidate)} + path ? File.read(path) : nil end # Install context from a specific gem. @@ -115,18 +121,29 @@ def install_gem_context(gem_name) gem = find_gem_with_context(gem_name) return false unless gem + files = list_context_files(gem_name) target_path = File.join(@context_path, gem_name) - - # Remove old package directory if it exists to ensure clean install - FileUtils.rm_rf(target_path) if Dir.exist?(target_path) - - FileUtils.mkdir_p(target_path) - - # Copy all files from the gem's context directory: - FileUtils.cp_r(File.join(gem[:path], "."), target_path) - - # Generate index.yaml if it doesn't exist, passing the full gem hash - ensure_gem_index(gem, target_path) + FileUtils.mkdir_p(@context_path) + Dir.mktmpdir(".agent-context-staging-", @context_path) do |stage| + fresh = File.join(stage, "new") + backup = File.join(stage, "old") + FileUtils.mkdir_p(fresh) + files.each do |source| + relative = Pathname.new(source).relative_path_from(Pathname.new(gem[:path])).to_s + destination = File.join(fresh, relative) + FileUtils.mkdir_p(File.dirname(destination)) + FileUtils.copy_file(source, destination, true) + end + ensure_gem_index(gem, fresh) + previous = File.exist?(target_path) || File.symlink?(target_path) + File.rename(target_path, backup) if previous + begin + File.rename(fresh, target_path) + rescue + File.rename(backup, target_path) if previous + raise + end + end true end @@ -145,6 +162,23 @@ def install_all_context(skip_local: true) installed end + # Install context and skills and refresh the generated index. + # @parameter gem [String | Nil] An optional provider gem. + # @returns [Hash(Symbol, Array(String))] The installed provider and skill names. + def install(gem: nil) + providers = gem ? [find_gem_with_context(gem)].compact : find_gems_with_context + # Read ordinary document metadata before modifying installed skills: + providers.each do |provider| + list_context_files(provider[:name]).each do |path| + Document.new(path).description if File.extname(path).downcase == ".md" + end + end + installed_skills = @skills.install(gem: gem) + installed_context = gem ? (install_gem_context(gem) ? [gem] : []) : install_all_context + Index.new(@context_path).update_index + {context: installed_context, skills: installed_skills} + end + private # Generate a dynamic index from gemspec when no index.yaml is present @@ -188,13 +222,11 @@ def ensure_gem_index(gem, gem_directory) # Write the generated index File.write(index_path, index.to_yaml) - Console.debug("Generated dynamic index for #{gem[:name]}: #{index_path}") end # Load and return the index - YAML.load_file(index_path) + YAML.safe_load_file(index_path, aliases: false) rescue => error - Console.debug("Error generating index for #{gem[:name]}: #{error.message}") # Return a fallback index { "description" => gem[:summary] || "Context files for #{gem[:name]}", @@ -204,56 +236,8 @@ def ensure_gem_index(gem, gem_directory) end def extract_content(file_path) - content = File.read(file_path) - lines = content.lines.map(&:strip) - - title = extract_title(lines) - description = extract_description(lines) - - [title, description] - end - - def extract_title(lines) - # Look for the first markdown header - header_line = lines.find{|line| line.start_with?("#")} - if header_line - # Remove markdown header syntax and clean up - header_line.sub(/^#+\s*/, "").strip - else - # If no header found, use a default - "Documentation" - end - end - - def extract_description(lines) - # Skip empty lines and headers to find the first paragraph - content_start = false - description_lines = [] - - lines.each do |line| - # Skip headers - next if line.start_with?("#") - - # Skip empty lines until we find content - if !content_start && line.empty? - next - end - - # Mark that we've found content - content_start = true - - # If we hit an empty line after finding content, we've reached the end of the first paragraph - if line.empty? - break - end - - description_lines << line - end - - # Join the lines and truncate if too long - description = description_lines.join(" ").strip - - description + document = Document.new(file_path) + [document.title, document.description] end end end diff --git a/readme.md b/readme.md index abf3c65..ccddd52 100644 --- a/readme.md +++ b/readme.md @@ -1,178 +1,79 @@ # Agent::Context -Provides tools for installing and managing context files from Ruby gems for AI agents, and generating `agents.md` files following the specification. +Discover and install practical guidance and skills shipped by Ruby gems. -[![Development Status](https://github.com/socketry/agent-context/workflows/Test/badge.svg)](https://github.com/socketry/agent-context/actions?workflow=Test) +## Installation -## Overview - -This gem allows you to install and manage context files from other gems. Gems can provide context files in a `context/` directory in their root, which can contain documentation, configuration examples, migration guides, and other contextual information for AI agents. - -When you install context from gems, they are placed in the `.agents/context/` directory and an `agents.md` file is generated or updated to provide a comprehensive overview for AI agents. - -## Quick Start - -Add the gem to your project and install context from all available gems: - -``` bash -$ bundle add agent-context -$ bake agent:context:install +```sh +bundle add agent-context +bundle exec bake agent:context:install ``` -This workflow: - - - Adds the `agent-context` gem to your project. - - Installs context files from all gems into `.agents/context/`. - - Generates or updates `agents.md` with a comprehensive overview. - - Follows the specification for agentic coding tools. - -## Context - -This gem provides its own context files in the `context/` directory, including: - - - `usage.md` - Comprehensive guide for using and providing context files. - -When you install context from other gems, they will be placed in the `.agents/context/` directory and referenced in `agents.md`. - -## Usage - -Please see the [project documentation](https://ioquatix.github.io/agent-context/) for more details. - - - [Getting Started](https://ioquatix.github.io/agent-context/guides/getting-started/index) - This guide explains how to use `agent-context`, a tool for discovering and installing contextual information from Ruby gems to help AI agents. - -### Installation - -Add the `agent-context` gem to your project: - -``` bash -$ bundle add agent-context +Installation copies ordinary context into `.agents/context//`, installs dependency skills into `.agents/skills/`, and generates `.agents/context/index.md`. Repository-owned `agents.md` is preserved. + +## Commands + +```sh +bake agent:context:list +bake agent:context:list --gem async +bake agent:context:show --gem async --file getting-started +bake agent:context:install +bake agent:context:install --gem async +bake agent:context:index +bake agent:context:skill:list +bake agent:context:skill:show --gem provider --skill provider-workflow +bake agent:context:skill:install +bake agent:context:skill:install --gem provider --skill provider-workflow ``` -### Commands +The former `agent:skills:*` commands are removed. Skills use the `agent:context:skill:*` namespace. -#### Install Context (Primary Command) +## Agent Instructions and Version Control -Install context from all available gems and update `agents.md`: +Add a stable link to `.agents/context/index.md` in your repository-owned `agents.md`, together with instructions to read relevant installed guides. Run installation after changing dependencies. When migrating from an older generated `agents.md`, replace its generated dependency listing with this link while retaining project instructions. -``` bash -$ bake agent:context:install -``` +Installation maintains a marked block in the local Git exclude file, discovered through `git rev-parse --git-path info/exclude`. It excludes generated context, ownership files, and exact dependency-installed skill directories. Project-owned instructions and skills under `.agents/` remain trackable. Remove any blanket `/.agents/` rule from your project's `.gitignore` when adopting this layout. Re-run installation in each checkout. -Install context from a specific gem: +## Providing Context and Skills -``` bash -$ bake agent:context:install --gem async -``` +Include a top-level `context/` directory in your packaged gem. Write focused guides with a clear heading and first sentence. Optional YAML `description` overrides the prose summary; other document metadata is tolerated. -#### List available context +A root-level context document can declare a skill: -List all gems that have context available: +```markdown +--- +type: skill +description: Run the provider's workflow when preparing a new project. +license: MIT +--- -``` bash -$ bake agent:context:list -``` - -List context files for a specific gem: +# Workflow -``` bash -$ bake agent:context:list --gem async +Follow these instructions. ``` -#### Show context content - -Show the content of a specific context file: - -``` bash -$ bake agent:context:show --gem async --file thread-safety -``` +`context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Skill sources and resources are excluded from ordinary context and its index. Installed names have a 64-character limit; descriptions have a 1,024-character limit. Additional skill metadata is preserved. -## Version Control +Existing `skills//SKILL.md` bundles are also supported through the `agent-skills` installation library. Their declared names are preserved. Ensure both `context/**/*` and any `skills/**/*` are included in your gem's file list. -The `.agents/` directory contains generated files and should be excluded from version control. The generated `agents.md` index should be committed: +Providers can retain `context/index.yaml` to control guide ordering and metadata. Explicit entries take precedence, missing and skill-only entries are skipped, and unlisted guides are appended. - - `agents.md` is user-facing documentation that should be versioned. - - `.agents/context/` can be restored by running `bake agent:context:install`. - - Ignoring the entire `.agents/` directory covers other generated agent resources too. +## Ownership and Updates -## Providing Context in Your Gem +Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes preserve Cargo-owned entries and project-owned directories. Skill replacements are staged; failed copying or committing preserves the previous installation. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. -To provide context files in your gem, create a `context/` directory in your gem's root: +Legacy Ruby YAML ownership and version-one Cargo JSON ownership migrate to the shared format. Ruby's legacy ownership file is retired after successful installation. - your-gem/ - ├── context/ - │ ├── getting-started.md - │ ├── usage.md - │ ├── configuration.md - │ └── index.yaml (optional) - ├── lib/ - └── your-gem.gemspec +See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). -### Optional: Custom Index File +## Development -You can provide a custom `index.yaml` file to control ordering and metadata: - -``` yaml -description: "Your gem description from gemspec" -version: "1.0.0" -files: - - path: getting-started.md - title: "Getting Started" - description: "Quick start guide" - - path: usage.md - title: "Usage Guide" - description: "Detailed usage instructions" -``` - -If no `index.yaml` is provided, one will be generated automatically from your gemspec and markdown files. - -## Releases - -Please see the [project releases](https://ioquatix.github.io/agent-context/releases/index) for all releases. - -### v0.3.0 - - - Rename `agent.md` -\> `agents.md`. - -### v0.2.0 - - - Don't limit description length. - -## See Also - - - [Bake](https://github.com/ioquatix/bake) — The bake task execution tool. - -### Gems With Context Files - - - [Async](https://github.com/socketry/async) - - [Decode](https://github.com/ioquatix/decode) - - [Falcon](https:///github.com/socketry/falcon) - - [Sus](https://github.com/socketry/sus) - -## 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 +```sh +bundle exec sus +bundle exec rubocop ``` -### Making Releases - -To make a new release: - -``` bash -$ bundle exec bake gem:release:patch # or minor or major -``` +See [releases.md](releases.md) and [license.md](license.md). ### Developer Certificate of Origin diff --git a/specification.md b/specification.md index afac15c..b0ac8a5 100644 --- a/specification.md +++ b/specification.md @@ -33,6 +33,7 @@ A **Context Provider** is any software package, library, or module that includes 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. @@ -63,6 +64,7 @@ Context providers MUST include a `context/` directory in their package root. Thi - **Versioning**: Must be versioned alongside the package code. Example structure: + ``` package-root/ ├── context/ @@ -82,10 +84,11 @@ Context consumers SHOULD create a `.agents/context/` directory in their project - **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/` directory SHOULD be excluded from version control (e.g., in `.gitignore`). +- **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/ @@ -135,6 +138,7 @@ Context files SHOULD follow these naming conventions: - Group related files in subdirectories when appropriate. Common file names: + - `getting-started.md` - `configuration.md` - `troubleshooting.md` @@ -142,6 +146,16 @@ Common file names: - `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 @@ -163,14 +177,55 @@ The discovery process SHOULD integrate with the target language's package manage Context installation MUST follow these principles: 1. **Copy Strategy**: Context files SHOULD be copied rather than symlinked. -2. **Namespace Isolation**: Each package's context MUST be installed in its own subdirectory. -3. **Preserve Structure**: The internal structure of the `context/` directory MUST be preserved. +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: - CREATE directory .agents/context/package-name/ - COPY all files recursively from package/context/ to .agents/context/package-name/ + 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. diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index cb4c5c3..e29441a 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -1,375 +1,109 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2025, by Samuel Williams. +# Copyright, 2025-2026, by Samuel Williams. # Copyright, 2025, by Shopify Inc. require "agent/context/index" require "tmpdir" -require "fileutils" describe Agent::Context::Index do + let(:directory) {Dir.mktmpdir} + let(:context_path) {File.join(directory, ".agents", "context")} + let(:index) {subject.new(context_path)} + def around + FileUtils.mkdir_p(context_path) + yield + ensure + FileUtils.rm_rf(directory) + end + + def write(path, content) + target = File.join(context_path, path) + FileUtils.mkdir_p(File.dirname(target)) + File.write(target, content) + end + + it "generates an empty index without creating agents.md" do + index.update_index + expect(File.read(File.join(context_path, "index.md"))).to be(:include?, "No context files are installed.") + expect(File).not.to be(:exist?, File.join(directory, "agents.md")) + end + + it "preserves repository-owned agent instructions exactly" do + agents = File.join(directory, "agents.md") + File.write(agents, "# Agent\n\n## Context\n\nHandwritten guidance.\n") + index.update_index + expect(File.read(agents)).to be == "# Agent\n\n## Context\n\nHandwritten guidance.\n" + end + + it "extracts a real heading and first prose sentence while skipping code" do + write("example/guide.md", "```ruby\n# False heading\n```\n\n# Real Title\n\nFirst sentence. Second sentence.\n") + content = index.generate_index + expect(content).to be(:include?, "### [Real Title](example/guide.md)") + expect(content).to be(:include?, "First sentence.") + expect(content).not.to be(:include?, "Second sentence.") + expect(content).not.to be(:include?, "False heading") + end + + it "prefers custom index ordering and metadata and appends unlisted guides" do + write("example/getting-started.md", "# Getting Started\n\nDefault description.") + write("example/configuration.md", "---\ndescription: Front matter description.\ntitle: Unrelated metadata\n---\n\n# Configuration\n\nIgnored prose.") + write("example/unlisted.md", "# Unlisted\n") + write("example/index.yaml", {"description" => "Provider description", "files" => [{"path" => "configuration.md", "title" => "Custom Configuration", "description" => "Custom description."}, {"path" => "missing.md"}]}.to_yaml) + content = index.generate_index + expect(content).to be(:include?, "Provider description") + expect(content).to be(:include?, "Custom description.") + expect(content.index("Custom Configuration")).to be < content.index("Getting Started") + expect(content).to be(:include?, "Unlisted") + expect(content).not.to be(:include?, "missing.md") + end + + it "uses front matter descriptions and filename-derived fallback titles" do + write("example/notes-file.md", "---\ndescription: Explicit summary.\nlayout: guide\n---\n\nOrdinary prose.") + expect(index.generate_index).to be(:include?, "### [notes file](example/notes-file.md)") + expect(index.generate_index).to be(:include?, "Explicit summary.") + end + + it "escapes titles and percent-encodes relative links" do + write("example/a [file] #.md", "# [Title](https://example.test)\n\nUse safely.") + content = index.generate_index + expect(content).to be(:include?, "(example/a%20%5Bfile%5D%20%23.md)") + expect(content).not.to be(:include?, "](https://example.test)") + end - with "AGENT.md functionality" do - let(:temporary_directory) {Dir.mktmpdir} - let(:context_path) {File.join(temporary_directory, ".agents", "context")} - let(:agent_md_path) {File.join(temporary_directory, "agent.md")} - - def around - FileUtils.mkdir_p(context_path) - yield - ensure - FileUtils.rm_rf(temporary_directory) if Dir.exist?(temporary_directory) - end - - it "creates new AGENT.md when file doesn't exist" do - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - expect(File.exist?(agent_md_path)).to be == true - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "No context files found") - end - - it "updates existing AGENT.md with context section" do - # Create an existing AGENT.md - existing_content = <<~MARKDOWN - # Agent - - This is an existing AGENT.md file. - - ## Build & Commands - - Run `bake test` to run tests. - MARKDOWN - - File.write(agent_md_path, existing_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "## Build & Commands") - expect(content).to be(:include?, "No context files found") - end - - it "replaces existing context section" do - # Create an existing AGENT.md with context section - existing_content = <<~MARKDOWN - # Agent - - ## Context - - Old context content here. - - ## Build & Commands - - Run `bake test` to run tests. - MARKDOWN - - File.write(agent_md_path, existing_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "## Build & Commands") - expect(content).to be(:include?, "No context files found") - expect(content).not.to be(:include?, "Old context content here") - end - - it "handles AGENT.md without # Agent heading" do - # Create an existing file without # Agent heading - existing_content = <<~MARKDOWN - # Project Documentation - - This is a project without an Agent heading. - - ## Build & Commands - - Run `bake test` to run tests. - MARKDOWN - - File.write(agent_md_path, existing_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "# Project Documentation") - end - - it "generates context section with gem content" do - # Create a mock gem context - gem_context_path = File.join(context_path, "example_gem") - FileUtils.mkdir_p(gem_context_path) - - readme_content = <<~MARKDOWN - # Example Gem - - This is an example gem that provides context for AI agents. - MARKDOWN - - File.write(File.join(gem_context_path, "README.md"), readme_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "### example_gem") - expect(content).to be(:include?, "#### [Example Gem](.agents/context/example_gem/README.md)") - expect(content).to be(:include?, "This is an example gem that provides context for AI agents") - end - - it "uses index.yaml when available" do - # Create a mock gem context with index.yaml - gem_context_path = File.join(context_path, "example_gem") - FileUtils.mkdir_p(gem_context_path) - - # Create index.yaml - index_content = { - "description" => "A test gem with custom index", - "files" => [ - { - "path" => "getting-started.md", - "title" => "Getting Started Guide", - "description" => "Custom description from index" - } - ] - } - File.write(File.join(gem_context_path, "index.yaml"), index_content.to_yaml) - - # Create the actual file - File.write(File.join(gem_context_path, "getting-started.md"), "# Getting Started\n\nSome content.") - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "### example_gem") - expect(content).to be(:include?, "A test gem with custom index") - expect(content).to be(:include?, "#### [Getting Started Guide](.agents/context/example_gem/getting-started.md)") - expect(content).to be(:include?, "Custom description from index") - end - - it "handles context section with other top-level headings" do - # Create an existing AGENT.md with context section and other headings - existing_content = <<~MARKDOWN - # Agent - - ## Context - - Old context content here. - - ## Another Section - - Some other content. - - ## Build & Commands - - Run `bake test` to run tests. - MARKDOWN - - File.write(agent_md_path, existing_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content).to be(:include?, "# Agent") - expect(content).to be(:include?, "## Context") - expect(content).to be(:include?, "## Another Section") - expect(content).to be(:include?, "## Build & Commands") - expect(content).to be(:include?, "No context files found") - expect(content).not.to be(:include?, "Old context content here") - end - - it "only finds context sections under the Agent heading" do - existing_content = <<~MARKDOWN - # Agent - - Agent instructions. - - # Project - - ## Context - - Project context. - MARKDOWN - - File.write(agent_md_path, existing_content) - - index = Agent::Context::Index.new(context_path) - index.update_agents_md(agent_md_path) - - content = File.read(agent_md_path) - expect(content.scan("## Context").length).to be == 2 - expect(content).to be(:include?, "Project context.") - expect(content).to be(:include?, "No context files found") - end + it "excludes skill documents and resources even when a custom index lists them" do + write("example/workflow.md", "---\ntype: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") + write("example/workflow/references/checklist.md", "---\ninvalid: [yaml\n---\n\n# Checklist\n") + write("example/guide.md", "# Guide\n") + write("example/index.yaml", {"files" => [{"path" => "workflow.md"}, {"path" => "workflow/references/checklist.md"}]}.to_yaml) + content = index.generate_index + expect(content).to be(:include?, "Guide") + expect(content).not.to be(:include?, "Workflow") + expect(content).not.to be(:include?, "Checklist") end - with "title and description extraction" do - let(:temporary_directory) {Dir.mktmpdir} - let(:context_path) {File.join(temporary_directory, ".agents", "context")} - - def around - FileUtils.mkdir_p(context_path) - yield - ensure - FileUtils.rm_rf(temporary_directory) if Dir.exist?(temporary_directory) - end - - it "extracts title from markdown header" do - index = Agent::Context::Index.new(context_path) - lines = [ - "# My Great Title", - "", - "This is the description paragraph.", - "It continues here.", - "", - "## Another section" - ] - - title = index.send(:extract_title, lines) - expect(title).to be == "My Great Title" - end - - it "extracts description from first paragraph" do - index = Agent::Context::Index.new(context_path) - lines = [ - "# Title", - "", - "This is the first paragraph.", - "It continues on this line.", - "", - "This is the second paragraph." - ] - - description = index.send(:extract_description, lines) - expect(description).to be == "This is the first paragraph. It continues on this line." - end - - it "truncates long descriptions" do - index = Agent::Context::Index.new(context_path) - long_line = "This is a very long description that should be truncated because it exceeds the maximum length limit that we have set for descriptions in the index to keep things readable and concise for users who want to understand what each context file contains and decide whether it's relevant to them." - lines = ["# Title", "", long_line] - - description = index.send(:extract_description, lines) - expect(description).to be(:include?, "...") - expect(description.length).to be <= 200 - end - - it "handles files without headers" do - index = Agent::Context::Index.new(context_path) - lines = [ - "This is content without a header.", - "It continues here.", - "", - "More content." - ] - - title = index.send(:extract_title, lines) - expect(title).to be == "Documentation" - end - - it "handles description that ends with empty line" do - index = Agent::Context::Index.new(context_path) - lines = [ - "# Title", - "", - "This is the description.", - "", - "## Another section" - ] - - description = index.send(:extract_description, lines) - expect(description).to be == "This is the description." - end - - it "handles description that continues to end of file" do - index = Agent::Context::Index.new(context_path) - lines = [ - "# Title", - "", - "This is the description.", - "It continues to the end." - ] - - description = index.send(:extract_description, lines) - expect(description).to be == "This is the description. It continues to the end." - end + it "falls back when provider index YAML is malformed" do + write("example/guide.md", "# Guide\n") + write("example/index.yaml", "invalid: yaml: [") + expect(index.generate_index).to be(:include?, "Guide") end - with "index.yaml handling" do - let(:temporary_directory) {Dir.mktmpdir} - let(:context_path) {File.join(temporary_directory, ".agents", "context")} - - def around - FileUtils.mkdir_p(context_path) - yield - ensure - FileUtils.rm_rf(temporary_directory) if Dir.exist?(temporary_directory) - end - - it "loads valid index.yaml file" do - gem_context_path = File.join(context_path, "test_gem") - FileUtils.mkdir_p(gem_context_path) - - index_content = { - "description" => "Test gem description", - "files" => [ - { - "path" => "readme.md", - "title" => "README", - "description" => "Test description" - } - ] - } - File.write(File.join(gem_context_path, "index.yaml"), index_content.to_yaml) - - index = Agent::Context::Index.new(context_path) - result = index.send(:load_gem_index, "test_gem", gem_context_path) - - expect(result["description"]).to be == "Test gem description" - expect(result["files"].length).to be == 1 - expect(result["files"].first["title"]).to be == "README" - end - - it "handles missing index.yaml gracefully" do - gem_context_path = File.join(context_path, "test_gem") - FileUtils.mkdir_p(gem_context_path) - - index = Agent::Context::Index.new(context_path) - result = index.send(:load_gem_index, "test_gem", gem_context_path) - - expect(result["description"]).to be == "Context files for test_gem" - expect(result["files"]).to be == [] - end - - it "handles corrupted index.yaml gracefully" do - gem_context_path = File.join(context_path, "test_gem") - FileUtils.mkdir_p(gem_context_path) - - # Write invalid YAML - File.write(File.join(gem_context_path, "index.yaml"), "invalid: yaml: content: [") - - index = Agent::Context::Index.new(context_path) - result = index.send(:load_gem_index, "test_gem", gem_context_path) - - expect(result["description"]).to be == "Context files for test_gem" - expect(result["files"]).to be == [] - end + it "maintains local exclusions without hiding project-owned instructions or skills" do + system("git", "init", "--quiet", directory, exception: true) + exclude = File.join(directory, ".git", "info", "exclude") + File.write(exclude, "user-rule\n") + registry = Agent::Skills::Registry.new(File.join(directory, ".agents", "skills", Agent::Skills::Registry::FILE_NAME)) + registry.claim("cargo-workflow", "provider", "1.0", ecosystem: "cargo") + registry.save + index.update_index + first = File.read(exclude) + index.update_index + expect(File.read(exclude)).to be == first + expect(first).to be(:include?, "user-rule") + expect(first).to be(:include?, "/.agents/skills/cargo-workflow/") + expect(first.lines).not.to be(:include?, "/.agents/\n") + expect(first.lines).not.to be(:include?, "/.agents/skills/\n") end end diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 763032f..b9ac76f 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -159,7 +159,7 @@ def around index = YAML.load_file(index_path) notes = index["files"].find{|file| file["path"] == "notes.md"} - expect(notes["title"]).to be == "Documentation" + expect(notes["title"]).to be == "notes" expect(notes["description"]).to be == "First paragraph." end end diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb new file mode 100644 index 0000000..c5e95a3 --- /dev/null +++ b/test/agent/context/skills.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "agent/context" +require "tmpdir" + +describe Agent::Context::Installer do + let(:directory) {Dir.mktmpdir} + let(:provider) {File.join(directory, "provider")} + let(:consumer) {File.join(directory, "consumer")} + let(:specification) do + specification = Gem::Specification.new do |spec| + spec.name = "provider" + spec.version = "1.0.0" + spec.summary = "Provider guidance." + end + specification.instance_variable_set(:@full_gem_path, provider) + specification + end + let(:installer) {subject.new(root: consumer, specifications: [specification])} + + def around + FileUtils.mkdir_p([File.join(provider, "context/workflow/references"), consumer]) + File.write(File.join(provider, "context/guide.md"), "# Guide\n\nUse the provider.") + File.write(File.join(provider, "context/workflow.md"), "---\ntype: skill\ndescription: Run the workflow.\nlicense: MIT\n---\n\n# Workflow\n") + File.write(File.join(provider, "context/workflow/references/checklist.md"), "# Checklist\n") + yield + ensure + FileUtils.rm_rf(directory) + end + + it "installs context and skills without duplicating skill documents or assets" do + agents = File.join(consumer, "agents.md") + File.write(agents, "Repository-owned instructions") + result = installer.install + expect(result).to be == {context: ["provider"], skills: ["provider-workflow"]} + expect(File.read(agents)).to be == "Repository-owned instructions" + expect(File).to be(:exist?, File.join(consumer, ".agents/context/provider/guide.md")) + expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/provider/workflow.md")) + skill_root = File.join(consumer, ".agents/skills/provider-workflow") + expect(File.read(File.join(skill_root, "SKILL.md"))).to be(:include?, "license: MIT") + expect(File).to be(:exist?, File.join(skill_root, "references/checklist.md")) + expect(File.read(File.join(consumer, ".agents/context/index.md"))).not.to be(:include?, "Workflow") + end + + it "excludes skill instructions and resources from listing and showing context" do + expect(installer.list_context_files("provider").map{|path| File.basename(path)}).to be == ["guide.md"] + expect(installer.show_context_file("provider", "workflow")).to be_nil + expect(installer.show_context_file("provider", "workflow/references/checklist.md")).to be_nil + expect do + installer.show_context_file("provider", "../secret") + end.to raise_exception(ArgumentError) + end + + it "validates ordinary metadata before modifying installed skills" do + File.write(File.join(provider, "context/guide.md"), "---\ndescription: [broken\n---\n\n# Guide") + expect do + installer.install + end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow")) + end + + it "handles a provider containing only skills" do + File.delete(File.join(provider, "context/guide.md")) + installer.install(gem: "provider") + expect(File.read(File.join(consumer, ".agents/context/index.md"))).to be(:include?, "No context files are installed.") + expect(File).to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) + end + it "restores ordinary context when replacing a provider fails" do + installer.install + destination = File.join(consumer, ".agents/context/provider") + previous = File.read(File.join(destination, "guide.md")) + File.write(File.join(provider, "context/guide.md"), "# Updated\n") + mock(File).before(:rename) do |source, target| + raise Errno::EACCES, "Injected context failure" if File.basename(source) == "new" && target == destination + end + expect do + installer.install_gem_context("provider") + end.to raise_exception(Errno::EACCES) + expect(File.read(File.join(destination, "guide.md"))).to be == previous + end +end diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb new file mode 100644 index 0000000..146ba76 --- /dev/null +++ b/test/agent/context/tasks.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake" +require "tmpdir" + +describe "Agent Context tasks" do + it "exposes context and skill tasks without the old command namespace" do + Dir.mktmpdir do |root| + context = Bake::Context.load(root) + %w[agent:context:list agent:context:show agent:context:install agent:context:index agent:context:skill:list agent:context:skill:show agent:context:skill:install].each do |name| + expect(context.lookup(name)).to be_truthy + end + expect(context.lookup("agent:context:agents_md")).to be_nil + expect(context.lookup("agent:skills:install")).to be_nil + context.call("agent:context:index") + expect(File).to be(:exist?, File.join(root, ".agents/context/index.md")) + expect(File).not.to be(:exist?, File.join(root, "agents.md")) + end + end + + it "runs context and skill tasks against a resolved provider" do + Dir.mktmpdir do |root| + provider = File.join(root, "provider") + FileUtils.mkdir_p(File.join(provider, "context")) + File.write(File.join(provider, "context/guide.md"), "# Provider Guide\n\nUse the package.\n") + File.write(File.join(provider, "context/workflow.md"), "---\ntype: skill\ndescription: Run the workflow.\n---\n\n# Workflow\n") + specification = Gem::Specification.new do |spec| + spec.name = "provider" + spec.version = "1.0.0" + spec.summary = "Provider guidance." + end + specification.instance_variable_set(:@full_gem_path, provider) + mock(Gem::Specification).replace(:to_a){[specification]} + output = [] + mock($stdout).replace(:puts){|*messages| output.concat(messages)} + context = Bake::Context.load(root) + context.call("agent:context:list") + context.call("agent:context:list", "--gem", "provider") + context.call("agent:context:list", "--gem", "missing") + context.call("agent:context:show", "--gem", "provider", "--file", "guide") + context.call("agent:context:show", "--gem", "provider", "--file", "missing") + context.call("agent:context:skill:list") + context.call("agent:context:skill:list", "--gem", "provider") + context.call("agent:context:skill:show", "--gem", "provider", "--skill", "provider-workflow") + expect do + context.call("agent:context:skill:show", "--gem", "provider", "--skill", "missing") + end.to raise_exception(ArgumentError) + context.call("agent:context:skill:install", "--gem", "provider", "--skill", "provider-workflow") + context.call("agent:context:install", "--gem", "provider") + expect(output.join("\n")).to be(:include?, "Provider Guide") + expect(output.join("\n")).to be(:include?, "provider-workflow (provider@1.0.0)") + expect(output.join("\n")).to be(:include?, "Installed skills: provider-workflow") + expect(File).to be(:exist?, File.join(root, ".agents/context/index.md")) + expect(File).to be(:exist?, File.join(root, ".agents/skills/provider-workflow/SKILL.md")) + FileUtils.rm_rf(File.join(provider, "context")) + context.call("agent:context:list") + context.call("agent:context:skill:list") + context.call("agent:context:skill:install") + context.call("agent:context:install") + expect(output).to be(:include?, "No gems with context found") + expect(output).to be(:include?, "No dependency skills found") + expect(output).to be(:include?, "No dependency skills were installed") + expect(File).not.to be(:exist?, File.join(root, ".agents/skills/provider-workflow")) + end + end +end From d0bfa63c8e3f1afb9280f6f7866a72a1c0b18d1f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 13:19:17 +0530 Subject: [PATCH 02/37] Fold metadata-declared skills into agent-context Signed-off-by: Samuel Williams --- agent-context.gemspec | 1 - bake/agent/context/skill.rb | 8 +- context/getting-started.md | 6 +- guides/getting-started/readme.md | 6 +- lib/agent/context.rb | 1 + lib/agent/context/index.rb | 4 +- lib/agent/context/installer.rb | 6 +- lib/agent/context/skills.rb | 16 + lib/agent/context/skills/definition.rb | 82 +++++ lib/agent/context/skills/exclusion.rb | 75 +++++ lib/agent/context/skills/installer.rb | 250 +++++++++++++++ lib/agent/context/skills/registry.rb | 136 ++++++++ readme.md | 4 +- specification.md | 2 - test/agent/context/index.rb | 2 +- test/agent/context/skills.rb | 2 +- test/agent/context/skills/installer.rb | 383 +++++++++++++++++++++++ test/agent/context/skills/registry.rb | 99 ++++++ test/fixtures/skill-ownership-index.json | 15 + 19 files changed, 1080 insertions(+), 18 deletions(-) create mode 100644 lib/agent/context/skills.rb create mode 100644 lib/agent/context/skills/definition.rb create mode 100644 lib/agent/context/skills/exclusion.rb create mode 100644 lib/agent/context/skills/installer.rb create mode 100644 lib/agent/context/skills/registry.rb create mode 100644 test/agent/context/skills/installer.rb create mode 100644 test/agent/context/skills/registry.rb create mode 100644 test/fixtures/skill-ownership-index.json diff --git a/agent-context.gemspec b/agent-context.gemspec index efb0f64..60cd84d 100644 --- a/agent-context.gemspec +++ b/agent-context.gemspec @@ -28,6 +28,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "bake", ">= 0.23" - spec.add_dependency "agent-skills", "~> 0.1" spec.add_dependency "rdoc", ">= 6" end diff --git a/bake/agent/context/skill.rb b/bake/agent/context/skill.rb index c56f272..265438f 100644 --- a/bake/agent/context/skill.rb +++ b/bake/agent/context/skill.rb @@ -3,12 +3,12 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "agent/skills/installer" +require "agent/context/skills/installer" # List dependency skills from all gems or one provider. # @parameter gem [String | Nil] An optional provider gem. def list(gem: nil) - installer = Agent::Skills::Installer.new(root: context.root) + installer = Agent::Context::Skills::Installer.new(root: context.root) skills = gem ? Array(installer.list_skills(gem)) : installer.find_gems_with_skills.flat_map{|provider| provider[:skills]} skills.each{|skill| puts "#{skill.name} (#{skill.provider_name}@#{skill.provider_version}) — #{skill.description}"} puts "No dependency skills found" if skills.empty? @@ -18,7 +18,7 @@ def list(gem: nil) # @parameter gem [String] The provider gem. # @parameter skill [String] The installed skill name. def show(gem:, skill:) - content = Agent::Skills::Installer.new(root: context.root).show_skill(gem, skill) + content = Agent::Context::Skills::Installer.new(root: context.root).show_skill(gem, skill) raise ArgumentError, "No skill #{skill.inspect} in gem #{gem.inspect}" unless content puts content end @@ -27,6 +27,6 @@ def show(gem:, skill:) # @parameter gem [String | Nil] An optional provider gem. # @parameter skill [String | Nil] An optional installed skill name. def install(gem: nil, skill: nil) - names = Agent::Skills::Installer.new(root: context.root).install(gem: gem, skill: skill) + names = Agent::Context::Skills::Installer.new(root: context.root).install(gem: gem, skill: skill) puts names.empty? ? "No dependency skills were installed" : "Installed skills: #{names.join(", ")}" end diff --git a/context/getting-started.md b/context/getting-started.md index ce7b2ce..307e951 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -25,6 +25,10 @@ bake agent:context:skill:show --gem provider --skill provider-workflow 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. +## Migrating from Agent Skills + +Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. + ## Provider Layout 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. @@ -42,7 +46,7 @@ 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. +`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. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index ce7b2ce..307e951 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -25,6 +25,10 @@ bake agent:context:skill:show --gem provider --skill provider-workflow 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. +## Migrating from Agent Skills + +Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. + ## Provider Layout 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. @@ -42,7 +46,7 @@ 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. +`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. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. diff --git a/lib/agent/context.rb b/lib/agent/context.rb index 873070e..125808f 100644 --- a/lib/agent/context.rb +++ b/lib/agent/context.rb @@ -6,6 +6,7 @@ require_relative "context/version" require_relative "context/paths" +require_relative "context/skills" require_relative "context/installer" require_relative "context/index" diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index d5b45be..30b46ce 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -9,8 +9,8 @@ require "yaml" require "uri" require "tempfile" -require "agent/skills/registry" -require "agent/skills/exclusion" +require "agent/context/skills/registry" +require "agent/context/skills/exclusion" require_relative "paths" require_relative "document" diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index a68cffd..3088e09 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -13,7 +13,7 @@ require_relative "paths" require_relative "document" require_relative "index" -require "agent/skills/installer" +require "agent/context/skills/installer" module Agent module Context @@ -40,7 +40,7 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @context_path = File.join(@root, CONTEXT_PATH) @specifications = specifications.to_a - @skills = Agent::Skills::Installer.new(root: @root, specifications: @specifications) + @skills = Agent::Context::Skills::Installer.new(root: @root, specifications: @specifications) end attr_reader :root @@ -94,7 +94,7 @@ def list_context_files(gem_name) gem = find_gem_with_context(gem_name) return nil unless gem - skill_paths = Array(@skills.list_skills(gem_name)).flat_map{|skill| skill.source_file ? [skill.source_file, skill.path].compact : []} + skill_paths = Array(@skills.list_skills(gem_name)).flat_map{|skill| [skill.source_file, skill.path].compact} Dir.glob(File.join(gem[:path], "**/*")).select do |file| File.file?(file) && !File.symlink?(file) && !skill_paths.any?{|path| file == path || file.start_with?("#{path}/")} end diff --git a/lib/agent/context/skills.rb b/lib/agent/context/skills.rb new file mode 100644 index 0000000..9a027a4 --- /dev/null +++ b/lib/agent/context/skills.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "skills/definition" +require_relative "skills/registry" +require_relative "skills/installer" + +module Agent + module Context + # Tools for discovering and installing dependency skills from Ruby gems. + module Skills + end + end +end diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb new file mode 100644 index 0000000..521e316 --- /dev/null +++ b/lib/agent/context/skills/definition.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" + +module Agent + module Context + module Skills + # Represents a dependency skill declared by a context document and its resources. + class Definition + # Initialize a skill from a context document. + # @parameter name [String] The installed skill name. + # @parameter description [String] When to use the skill. + # @parameter path [String | Nil] The companion resource directory. + # @parameter provider_name [String] The provider gem. + # @parameter provider_version [String] The provider version. + # @parameter source_file [String] The source context document. + # @parameter document [String] Generated skill instructions. + def initialize(name:, description:, path:, provider_name:, provider_version:, source_file:, document:) + @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 companion 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] The source context document. + attr_reader :source_file + + # @returns [String] The installed skill instructions. + def content + @document + 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) + 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 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 + end +end diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb new file mode 100644 index 0000000..cb9d43e --- /dev/null +++ b/lib/agent/context/skills/exclusion.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "open3" +require "fileutils" +require "tempfile" + +module Agent + module Context + 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 +end diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb new file mode 100644 index 0000000..0e1311c --- /dev/null +++ b/lib/agent/context/skills/installer.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +# 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 Context + module Skills + # Represents discovery and installation of skills from resolved gems. + class Installer + NAME_PATTERN = Registry::NAME_PATTERN + MAXIMUM_NAME_LENGTH = 64 + MAXIMUM_DESCRIPTION_LENGTH = 1024 + + # Raised when a skill cannot be installed. + class Error < StandardError + end + # Raised when skill metadata or resources are invalid. + class InvalidSkill < Error + end + # Raised when a destination has ambiguous or foreign ownership. + class Conflict < Error + end + + # Initialize discovery from the project's gem specifications. + # @parameter root [String] The consuming project root. + # @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.to_a + 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. + 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? + end + end + + # 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 = find_specification(gem_name) + return unless specification + information = build_gem_information(specification) + information unless information[:skills].empty? + end + + # List a gem's skills. + # @parameter gem_name [String] The provider gem. + # @returns [Array(Definition) | Nil] The provided skills. + def list_skills(gem_name) + find_gem_with_skills(gem_name)&.fetch(: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. + def show_skill(gem_name, skill_name) + list_skills(gem_name)&.find{|skill| skill.name == skill_name}&.content + 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. + def install_gem_skills(gem_name) + return unless find_specification(gem_name) + install(gem: gem_name) + 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. + 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) + 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) + context_root = File.join(specification.full_gem_path, "context") + skills = discover_context_skills(context_root, specification) + {name: specification.name, version: specification.version.to_s, skills: skills.sort_by(&:name)} + 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, 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) + content = File.read(file) + match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) + return [{}, content] unless match + 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 YAML frontmatter in #{file}: #{error.message}" + end + + 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 #{file}: #{name.inspect}" + end + end + + def validate_metadata(metadata, file) + validate_name(metadata["name"], file) + 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 + metadata["description"] = description.strip + end + + def install_definitions(definitions, package:, reconcile:) + 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? + 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 + 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 + end + names + end + end + end + end +end diff --git a/lib/agent/context/skills/registry.rb b/lib/agent/context/skills/registry.rb new file mode 100644 index 0000000..b5b497e --- /dev/null +++ b/lib/agent/context/skills/registry.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +# 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 Context + module Skills + # Represents the shared ownership index for dependency-installed skills. + class Registry + FILE_NAME = ".agent-context-skills.json" + FORMAT_VERSION = 2 + NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ + + # Raised when ownership cannot be safely interpreted. + class Invalid < StandardError + end + + # 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 + + # @attribute [Hash] The recorded skill owners. + def owners + @data["skills"] + 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) + 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} + end + + # Release skill ownership. + # @parameter name [String] The installed skill name. + def release(name) + owners.delete(name) + end + + # Persist shared ownership using an atomic rename. + 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 + 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"]) + data + rescue JSON::ParserError => error + raise Invalid, "Invalid skill ownership index #{@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}" + 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}" + 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 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 + end +end diff --git a/readme.md b/readme.md index ccddd52..fb4c7e0 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ bake agent:context:skill:install bake agent:context:skill:install --gem provider --skill provider-workflow ``` -The former `agent:skills:*` commands are removed. Skills use the `agent:context:skill:*` namespace. +`agent-context` includes skill discovery and installation directly; the separate `agent-skills` gem is deprecated and is no longer needed. Remove it from your Gemfile. Replace the former `agent:skills:*` commands with `agent:context:skill:*`. ## Agent Instructions and Version Control @@ -54,7 +54,7 @@ Follow these instructions. `context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Skill sources and resources are excluded from ordinary context and its index. Installed names have a 64-character limit; descriptions have a 1,024-character limit. Additional skill metadata is preserved. -Existing `skills//SKILL.md` bundles are also supported through the `agent-skills` installation library. Their declared names are preserved. Ensure both `context/**/*` and any `skills/**/*` are included in your gem's file list. +Skills are discovered only through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. To migrate a `skills//SKILL.md` bundle, move its instructions to `context/.md`, add `type: skill` to the front matter, and move its resources to `context//`. Installed skill names become package-prefixed. Providers can retain `context/index.yaml` to control guide ordering and metadata. Explicit entries take precedence, missing and skill-only entries are skipped, and unlisted guides are appended. diff --git a/specification.md b/specification.md index b0ac8a5..cc94551 100644 --- a/specification.md +++ b/specification.md @@ -150,8 +150,6 @@ Common file names: 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. diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index e29441a..ff091f1 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -94,7 +94,7 @@ def write(path, content) system("git", "init", "--quiet", directory, exception: true) exclude = File.join(directory, ".git", "info", "exclude") File.write(exclude, "user-rule\n") - registry = Agent::Skills::Registry.new(File.join(directory, ".agents", "skills", Agent::Skills::Registry::FILE_NAME)) + registry = Agent::Context::Skills::Registry.new(File.join(directory, ".agents", "skills", Agent::Context::Skills::Registry::FILE_NAME)) registry.claim("cargo-workflow", "provider", "1.0", ecosystem: "cargo") registry.save index.update_index diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index c5e95a3..f5ceb7b 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -58,7 +58,7 @@ def around File.write(File.join(provider, "context/guide.md"), "---\ndescription: [broken\n---\n\n# Guide") expect do installer.install - end.to raise_exception(Agent::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) expect(File).not.to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow")) end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb new file mode 100644 index 0000000..55792d3 --- /dev/null +++ b/test/agent/context/skills/installer.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require "agent/context/skills/installer" +require "tmpdir" + +describe Agent::Context::Skills::Installer do + let(:consumer_root) {Dir.mktmpdir} + let(:provider_root) {Dir.mktmpdir} + let(:specifications) {[build_specification("fake-gem", "1.0.0", provider_root)]} + let(:installer) {subject.new(root: consumer_root, specifications: specifications)} + + def around + write_skill(provider_root, "ruby-testing", description: "Test Ruby projects with Sus.") + yield + ensure + FileUtils.rm_rf(consumer_root) + FileUtils.rm_rf(provider_root) + end + + def build_specification(name, version, root) + specification = Gem::Specification.new do |spec| + spec.name = name + spec.version = version + spec.summary = "A gem providing agent skills." + end + specification.instance_variable_set(:@full_gem_path, root) + specification + end + + def write_skill(root, name, description:, body: "# Instructions\n\nFollow these instructions.\n") + directory = File.join(root, "context") + FileUtils.mkdir_p(directory) + metadata = {"type" => "skill", "description" => description} + File.write(File.join(directory, "#{name}.md"), "#{metadata.to_yaml}---\n\n#{body}") + end + + it "finds gems which provide skills" do + gems = installer.find_gems_with_skills + + expect(gems.length).to be == 1 + expect(gems.first[:name]).to be == "fake-gem" + expect(gems.first[:skills].map(&:name)).to be == ["fake-gem-ruby-testing"] + end + + it "lists skills provided by a gem" do + skills = installer.list_skills("fake-gem") + + expect(skills.length).to be == 1 + expect(skills.first).to have_attributes( + name: be == "fake-gem-ruby-testing", + description: be == "Test Ruby projects with Sus." + ) + end + + it "shows a skill instruction file" do + content = installer.show_skill("fake-gem", "fake-gem-ruby-testing") + + expect(content).to be(:include?, "name: fake-gem-ruby-testing") + expect(content).to be(:include?, "# Instructions") + end + + it "returns nil for a gem without skills" do + expect(installer.find_gem_with_skills("missing-gem")).to be_nil + expect(installer.install_gem_skills("missing-gem")).to be_nil + end + + it "ignores legacy skill bundles and discovers only context metadata" do + FileUtils.mkdir_p(File.join(provider_root, "skills", "legacy")) + File.write(File.join(provider_root, "skills/legacy/SKILL.md"), "---\nname: legacy\ndescription: Legacy instructions.\n---\n") + + expect(installer.list_skills("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] + File.delete(File.join(provider_root, "context/ruby-testing.md")) + expect(installer.find_gems_with_skills).to be == [] + end + + it "skips a gem loaded from the consuming project by default" do + write_skill(consumer_root, "local-development", description: "Develop the local gem.") + local_specification = build_specification("local-gem", "1.0.0", consumer_root) + local_installer = subject.new(root: consumer_root, specifications: [local_specification]) + + expect(local_installer.find_gems_with_skills).to be == [] + expect(local_installer.find_gems_with_skills(skip_local: false).map{|gem| gem[:name]}).to be == ["local-gem"] + end + + with "installation" do + it "installs skills relative to the consuming project root" do + installed = installer.install_gem_skills("fake-gem") + destination = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") + + expect(installed).to be == ["fake-gem-ruby-testing"] + expect(File).to be(:exist?, File.join(destination, "SKILL.md")) + end + + it "records ownership in the registry" do + installer.install_gem_skills("fake-gem") + registry_path = File.join(consumer_root, ".agents", "skills", Agent::Context::Skills::Registry::FILE_NAME) + registry = Agent::Context::Skills::Registry.new(registry_path) + + expect(registry.owner("fake-gem-ruby-testing")).to be == { + "ecosystem" => "gem", + "package" => "fake-gem", + "version" => "1.0.0", + } + end + + it "updates a skill owned by the same gem" do + installer.install_gem_skills("fake-gem") + write_skill(provider_root, "ruby-testing", description: "Updated description.", body: "# Updated\n") + installer.install_gem_skills("fake-gem") + + content = File.read(File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing", "SKILL.md")) + expect(content).to be(:include?, "# Updated") + end + + it "removes skills no longer provided by the same gem" do + write_skill(provider_root, "documentation", description: "Write documentation.") + installer.install_gem_skills("fake-gem") + File.delete(File.join(provider_root, "context", "documentation.md")) + installer.install_gem_skills("fake-gem") + + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-documentation")) + end + + it "refuses to overwrite an unmanaged skill" do + destination = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") + FileUtils.mkdir_p(destination) + File.write(File.join(destination, "SKILL.md"), "project-owned") + + expect do + installer.install_gem_skills("fake-gem") + end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + + expect(File.read(File.join(destination, "SKILL.md"))).to be == "project-owned" + end + + it "refuses duplicate skill names from different gems before installation" do + second_root = Dir.mktmpdir + write_skill(second_root, "ruby-testing", description: "Another implementation.") + duplicate_specifications = specifications + [build_specification("fake_gem", "1.0.0", second_root)] + duplicate_installer = subject.new(root: consumer_root, specifications: duplicate_specifications) + + expect do + duplicate_installer.install_all_skills + end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing")) + ensure + FileUtils.rm_rf(second_root) if second_root + end + + it "installs uniquely named skills from multiple gems" do + second_root = Dir.mktmpdir + write_skill(second_root, "documentation", description: "Write project documentation.") + all_specifications = specifications + [build_specification("other-gem", "2.0.0", second_root)] + all_installer = subject.new(root: consumer_root, specifications: all_specifications) + + expect(all_installer.install_all_skills.sort).to be == ["fake-gem-ruby-testing", "other-gem-documentation"] + expect(File).to be(:exist?, File.join(consumer_root, ".agents", "skills", "other-gem-documentation", "SKILL.md")) + ensure + FileUtils.rm_rf(second_root) if second_root + end + end + + with "validation" do + it "uses the package-prefixed filename instead of a source name" do + file = File.join(provider_root, "context/ruby-testing.md") + File.write(file, "---\ntype: skill\nname: Different_Name\ndescription: Test projects.\n---\n\n# Instructions\n") + expect(installer.show_skill("fake-gem", "fake-gem-ruby-testing")).to be(:include?, "name: fake-gem-ruby-testing") + end + + it "does not discover documents without skill metadata" do + File.write(File.join(provider_root, "context/ruby-testing.md"), "# Plain guidance\n") + expect(installer.find_gems_with_skills).to be == [] + 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-ruby-testing", "fake-gem-workflow"] + 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, "context")) + expect(installer.install_gem_skills("fake-gem")).to be == [] + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-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/fake-gem-ruby-testing")) + end + + it "preserves Cargo skills during a full Ruby refresh" do + registry_path = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) + registry = Agent::Context::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::Context::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::Context::Skills::Registry.new(File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME)) + registry.claim("fake-gem-ruby-testing", "fake-gem", "1.0.0", ecosystem: "cargo") + registry.save + expect do + installer.install_all_skills + end.to raise_exception(Agent::Context::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::Context::Skills::Registry::FILE_NAME) + previous = File.read(registry_path) + destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") + instructions = File.read(destination) + write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") + FileUtils.mkdir_p(File.join(provider_root, "context/ruby-testing")) + File.symlink("missing", File.join(provider_root, "context/ruby-testing/broken")) + expect do + installer.install_all_skills + end.to raise_exception(Agent::Context::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 + File.delete(File.join(provider_root, "context/documentation.md")) + installer.install(skill: "fake-gem-ruby-testing") + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-documentation/SKILL.md")) + end + + it "accepts the common metadata limits and rejects overlong descriptions" do + write_skill(provider_root, "a" * 55, description: "d" * 1024) + expect(installer.list_skills("fake-gem").length).to be == 2 + expect(installer.list_skills("fake-gem").first.name.length).to be == 64 + write_skill(provider_root, "a" * 55, description: "d" * 1025) + expect do + installer.list_skills("fake-gem") + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end + + it "includes the package prefix when validating installed name length" do + write_skill(provider_root, "a" * 56, description: "Run workflow.") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) + end + + it "replaces legacy owned bundle names with context-declared skill names" do + legacy_root = File.join(consumer_root, ".agents/skills/ruby-testing") + FileUtils.mkdir_p(legacy_root) + File.write(File.join(legacy_root, "SKILL.md"), "Legacy instructions") + 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) + expect(installer.install_gem_skills("fake-gem")).to be == ["fake-gem-ruby-testing"] + expect(File).not.to be(:exist?, legacy_root) + expect(File).not.to be(:exist?, legacy) + expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) + end + + it "migrates legacy Ruby ownership without resurrecting removed skills" do + installer.install_all_skills + shared = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) + File.delete(shared) + legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") + File.write(legacy, {"version" => 1, "skills" => {"fake-gem-ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml) + FileUtils.rm_rf(File.join(provider_root, "context")) + 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/fake-gem-ruby-testing")) + expect(Agent::Context::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::Context::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-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").find{|skill| skill.name == "fake-gem-workflow"}.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::Context::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/fake-gem-ruby-testing/SKILL.md") + previous_instructions = File.read(destination) + legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") + legacy_content = {"version" => 1, "skills" => {"fake-gem-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") + File.delete(File.join(provider_root, "context/documentation.md")) + 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/fake-gem-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 == ["fake-gem-ruby-testing"] + File.write(guide, "---\ntype: skill\ndescription: [invalid\n---\n") + expect do + installer.install_all_skills + end.to raise_exception(Agent::Context::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::Context::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::Context::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/fake-gem-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 == ["fake-gem-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::Context::Skills::Registry::Invalid) + end +end diff --git a/test/agent/context/skills/registry.rb b/test/agent/context/skills/registry.rb new file mode 100644 index 0000000..cfdeba3 --- /dev/null +++ b/test/agent/context/skills/registry.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require "agent/context/skills/registry" +require "tmpdir" + +describe Agent::Context::Skills::Registry do + let(:temporary_directory) {Dir.mktmpdir} + let(:registry_path) {File.join(temporary_directory, Agent::Context::Skills::Registry::FILE_NAME)} + + def around + yield + ensure + FileUtils.rm_rf(temporary_directory) + end + + it "records and reloads skill ownership" do + registry = subject.new(registry_path) + registry.claim("ruby-testing", "sus", "1.0.0") + registry.save + + reloaded = subject.new(registry_path) + expect(reloaded.owner("ruby-testing")).to be == { + "ecosystem" => "gem", + "package" => "sus", + "version" => "1.0.0", + } + end + + it "lists skills owned by a gem" do + registry = subject.new(registry_path) + registry.claim("ruby-testing", "sus", "1.0.0") + registry.claim("http-server", "falcon", "2.0.0") + + expect(registry.skills_for("sus").keys).to be == ["ruby-testing"] + end + + it "releases ownership" do + registry = subject.new(registry_path) + registry.claim("ruby-testing", "sus", "1.0.0") + registry.release("ruby-testing") + + expect(registry.owner("ruby-testing")).to be_nil + end + + it "rejects an invalid registry" do + File.write(registry_path, {"version" => 99, "skills" => {}}.to_json) + + expect do + subject.new(registry_path) + end.to raise_exception(Agent::Context::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 60994a250b782afdadc6a73f85a4841aa22c2bfb Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 13:27:12 +0530 Subject: [PATCH 03/37] Use Markly for context document metadata extraction Signed-off-by: Samuel Williams --- agent-context.gemspec | 2 +- lib/agent/context/document.rb | 20 ++++++++------------ test/agent/context/index.rb | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/agent-context.gemspec b/agent-context.gemspec index 60cd84d..3447001 100644 --- a/agent-context.gemspec +++ b/agent-context.gemspec @@ -28,5 +28,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "bake", ">= 0.23" - spec.add_dependency "rdoc", ">= 6" + spec.add_dependency "markly", "~> 0.13" end diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index f383a07..3fe729a 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -3,10 +3,7 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "rdoc" -require "rdoc/markdown" -require "rdoc/markup/to_html" -require "cgi" +require "markly" require "yaml" module Agent @@ -26,13 +23,13 @@ def initialize(path) raise ArgumentError, "Context frontmatter must be a mapping: #{path}" unless @metadata.is_a?(Hash) content = content[match.end(0)..] end - @parts = RDoc::Markdown.parse(content).parts + @parts = Markly.parse(content).to_a end # @returns [String] The first heading or a filename-derived title. def title - heading = @parts.find{|part| part.is_a?(RDoc::Markup::Heading) && !part.text.strip.empty?} - heading ? plain_text(heading.text) : File.basename(@path, File.extname(@path)).tr("-", " ") + heading = @parts.find{|part| part.type == :header && !plain_text(part).empty?} + heading ? plain_text(heading) : File.basename(@path, File.extname(@path)).tr("-", " ") end # @returns [String | Nil] Explicit metadata or the first prose sentence. @@ -40,9 +37,9 @@ def description explicit = @metadata["description"] raise ArgumentError, "Context description must be a string: #{@path}" if explicit && !explicit.is_a?(String) return explicit.strip if explicit && !explicit.strip.empty? - paragraph = @parts.find{|part| part.is_a?(RDoc::Markup::Paragraph)} + paragraph = @parts.find{|part| part.type == :paragraph} return unless paragraph - plain_text(paragraph.text).split(/(?<=[.!?])\s+/, 2).first + plain_text(paragraph).split(/(?<=[.!?])\s+/, 2).first end # @returns [bool] Whether this document declares a skill. @@ -60,9 +57,8 @@ def self.order(path) private - def plain_text(text) - html = RDoc::Markup::ToHtml.new(nil).to_html(text) - CGI.unescapeHTML(html.gsub(/<[^>]+>/, "")).gsub(/\s+/, " ").strip + def plain_text(node) + node.to_plaintext.gsub(/\s+/, " ").strip end end end diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index ff091f1..3befbe3 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -60,6 +60,23 @@ def write(path, content) expect(content).not.to be(:include?, "missing.md") end + it "extracts readable titles and summaries from inline Markdown" do + write("example/guide.md", "# [Guide](https://example.test) for **Ruby**\n\nUse **bold**, `code`, and HTML & text.\nSecond sentence.\n") + content = index.generate_index + expect(content).to be(:include?, "### [Guide for Ruby](example/guide.md)") + expect(content).to be(:include?, "Use bold, code, and HTML & text.") + expect(content).not.to be(:include?, "Second sentence.") + end + + it "skips nested headings and paragraphs when extracting document metadata" do + write("example/guide.md", "> # Quoted title\n>\n> Quoted summary.\n\n- Listed summary.\n\n#\n\n# Guide\n\nActual summary.\n") + content = index.generate_index + expect(content).to be(:include?, "### [Guide](example/guide.md)") + expect(content).to be(:include?, "Actual summary.") + expect(content).not.to be(:include?, "Quoted") + expect(content).not.to be(:include?, "Listed") + end + it "uses front matter descriptions and filename-derived fallback titles" do write("example/notes-file.md", "---\ndescription: Explicit summary.\nlayout: guide\n---\n\nOrdinary prose.") expect(index.generate_index).to be(:include?, "### [notes file](example/notes-file.md)") From f3b771cfbcf095760b9b8bd792a77ba1b72c1f38 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 15:20:13 +0530 Subject: [PATCH 04/37] Remove Cursor agent rule Signed-off-by: Samuel Williams --- .cursor/rules/agent.mdc | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .cursor/rules/agent.mdc diff --git a/.cursor/rules/agent.mdc b/.cursor/rules/agent.mdc deleted file mode 100644 index b6a580a..0000000 --- a/.cursor/rules/agent.mdc +++ /dev/null @@ -1,4 +0,0 @@ ---- -alwaysApply: true ---- -Follow repository-owned instructions in `agents.md` when present. Read relevant guidance from `.agents/context/index.md`. Run `bundle exec bake agent:context:install` if generated guidance is missing. From 75fa06c3ab05086caa1c262ed294a2de0c5713d7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 15:20:33 +0530 Subject: [PATCH 05/37] Preserve existing Copilot instructions Signed-off-by: Samuel Williams --- .github/copilot-instructions.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fe3199b..1de68b2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,20 @@ -# Agent Instructions +# GitHub Copilot Instructions -Follow repository-owned instructions in `agents.md`, if present. Read relevant dependency guidance from `.agents/context/index.md`. If generated guidance is missing, run `bundle exec bake agent:context:install`. +## Use Agent Context -Package guides belong in `context/`; repository-only instructions and project-owned skills belong in `.agents/`. Installation preserves `agents.md` and maintains local Git exclusions for generated files. +When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. + +If the file does not exist, you will need to install it, by running the following command: + +```bash +$ bundle install +$ bundle exec bake agent:context:install +``` + +This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. + +## Ignoring Files + +The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. + +While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. From 330e83807a8d34363c8c0ca9f208e790ebf9437d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 16:08:30 +0530 Subject: [PATCH 06/37] Share native Markly front matter parsing across context and skills Signed-off-by: Samuel Williams --- agent-context.gemspec | 2 +- lib/agent/context/document.rb | 30 ++++++++---- lib/agent/context/skills/installer.rb | 17 +++---- test/agent/context/document.rb | 66 +++++++++++++++++++++++++++ test/agent/context/skills.rb | 17 +++++++ 5 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 test/agent/context/document.rb diff --git a/agent-context.gemspec b/agent-context.gemspec index 3447001..3950bed 100644 --- a/agent-context.gemspec +++ b/agent-context.gemspec @@ -28,5 +28,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "bake", ">= 0.23" - spec.add_dependency "markly", "~> 0.13" + spec.add_dependency "markly", "~> 0.16" end diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 3fe729a..5428183 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -5,25 +5,37 @@ require "markly" require "yaml" +require "date" module Agent module Context - # Represents metadata extracted from an ordinary context document. + # Represents a context document parsed into Markdown and metadata. class Document CANONICAL_ORDER = %w[getting-started overview usage configuration migration troubleshooting debugging].freeze + # Raised when context front matter cannot be safely interpreted. + class Invalid < ArgumentError + end + # Initialize a document from its source path. # @parameter path [String] The Markdown source file. def initialize(path) @path = path - content = File.read(path) - @metadata = {} - if match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) - @metadata = YAML.safe_load(match[1], aliases: false) - raise ArgumentError, "Context frontmatter must be a mapping: #{path}" unless @metadata.is_a?(Hash) - content = content[match.end(0)..] - end - @parts = Markly.parse(content).to_a + @content = File.read(path) + @parts = Markly.parse(@content, flags: Markly::FRONT_MATTER).to_a + @front_matter = @parts.first if @parts.first&.type == :front_matter + @metadata = @front_matter ? YAML.safe_load(@front_matter.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) : {} + raise Invalid, "Context frontmatter must be a mapping: #{path}" unless @metadata.is_a?(Hash) + rescue Psych::Exception => error + raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" + end + + # @attribute [Hash] Decoded context metadata. + attr_reader :metadata + + # @returns [String] The original Markdown body after the front matter node. + def body + @front_matter ? @content.lines.drop(@front_matter.source_position[:end_line]).join : @content end # @returns [String] The first heading or a filename-derived title. diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index 0e1311c..3a9ad65 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -12,6 +12,7 @@ require_relative "definition" require_relative "registry" require_relative "exclusion" +require_relative "../document" module Agent module Context @@ -140,7 +141,8 @@ def discover_context_skills(root, specification) 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) + source = parse_document(file) + metadata = source.metadata 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 @@ -156,7 +158,7 @@ def discover_context_skills(root, specification) else assets = nil end - document = "---\n#{YAML.dump(metadata).delete_prefix("---\n")}---\n\n#{body}" + document = "---\n#{YAML.dump(metadata).delete_prefix("---\n")}---\n\n#{source.body.sub(/\A\r?\n/, "")}" 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 @@ -164,14 +166,9 @@ def discover_context_skills(root, specification) end def parse_document(file) - content = File.read(file) - match = content.match(/\A---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\z)/m) - return [{}, content] unless match - 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 YAML frontmatter in #{file}: #{error.message}" + Document.new(file) + rescue Document::Invalid => error + raise InvalidSkill, error.message end def validate_name(name, file) diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb new file mode 100644 index 0000000..171f7f4 --- /dev/null +++ b/test/agent/context/document.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "agent/context/document" +require "tmpdir" +require "fileutils" + +describe Agent::Context::Document do + let(:directory) {Dir.mktmpdir} + let(:path) {File.join(directory, "guide.md")} + let(:document) {subject.new(path)} + + def around + yield + ensure + FileUtils.rm_rf(directory) + end + + it "reads native front matter with a YAML format hint and standard dates" do + File.write(path, "--- yaml\ntitle: Metadata title\ndate: 2026-10-09\nupdated: 2026-10-09T12:30:00Z\n---\n\n# Actual Title\n\nFirst sentence. Second sentence.\n") + expect(document.metadata["date"]).to be == Date.new(2026, 10, 9) + expect(document.metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) + expect(document.title).to be == "Actual Title" + expect(document.description).to be == "First sentence." + end + + it "preserves the body bytes and source file when front matter uses CRLF" do + body = "\r\n# Guide\r\n\r\nUse [reference][guide].\r\n\r\n~~~yaml\r\n---\r\nexample: unchanged\r\n---\r\n~~~\r\n\r\n[guide]: https://example.test\r\n" + content = "---\ndescription: Explicit summary.\n---\n".gsub("\n", "\r\n") + body + File.binwrite(path, content) + expect(document.body).to be == body + expect(document.description).to be == "Explicit summary." + expect(File.binread(path)).to be == content + end + + it "keeps later thematic breaks and code examples in the body" do + content = "# Guide\n\n---\n\n~~~yaml\n---\ntype: skill\n---\n~~~\n" + File.write(path, content) + expect(document.metadata).to be == {} + expect(document.body).to be == content + expect(document).not.to be(:skill?) + end + + it "accepts empty front matter" do + File.write(path, "---\n---\n# Guide\n") + expect(document.metadata).to be == {} + expect(document.title).to be == "Guide" + expect(document.body).to be == "# Guide\n" + end + + it "rejects non-mapping metadata with the source path" do + File.write(path, "---\n- invalid\n---\n# Guide\n") + expect do + document + end.to raise_exception(subject::Invalid, message: be(:include?, path)) + end + + it "rejects arbitrary Ruby objects in YAML metadata" do + File.write(path, "---\nobject: !ruby/object:Object {}\n---\n# Guide\n") + expect do + document + end.to raise_exception(subject::Invalid, message: be(:include?, path)) + end +end diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index f5ceb7b..d1c830c 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -62,6 +62,23 @@ def around expect(File).not.to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow")) end + it "installs native front matter and preserves dates and skill body formatting" do + guide = "--- yaml\ndate: 2026-10-09\nlayout: guide\n---\n\n# Guide\n\nUse the provider.\n" + File.write(File.join(provider, "context/guide.md"), guide) + body = "# Workflow\r\n\r\nUse [reference][guide].\r\n\r\n~~~yaml\r\n---\r\nexample: unchanged\r\n---\r\n~~~\r\n\r\n[guide]: https://example.test\r\n" + File.binwrite(File.join(provider, "context/workflow.md"), "--- yaml\r\ntype: skill\r\nname: ignored\r\ndescription: Run the workflow.\r\ndate: 2026-10-09\r\nupdated: 2026-10-09T12:30:00Z\r\n---\r\n\r\n#{body}") + expect(installer.install).to be == {context: ["provider"], skills: ["provider-workflow"]} + expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == guide + instructions = File.binread(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) + expect(instructions).to be(:end_with?, body) + metadata = Agent::Context::Document.new(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")).metadata + expect(metadata["name"]).to be == "provider-workflow" + expect(metadata).not.to be(:key?, "type") + expect(metadata["date"]).to be == Date.new(2026, 10, 9) + expect(metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) + expect(File.read(File.join(consumer, ".agents/context/index.md"))).to be(:include?, "Use the provider.") + end + it "handles a provider containing only skills" do File.delete(File.join(provider, "context/guide.md")) installer.install(gem: "provider") From e3aee032de278273961a56e0b2a2de1936ee2058 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 16:18:11 +0530 Subject: [PATCH 07/37] Generate the Markdown index directly and simplify skills installation Signed-off-by: Samuel Williams --- context/getting-started.md | 200 ++++++++++++++++++++++-- guides/getting-started/readme.md | 200 ++++++++++++++++++++++-- lib/agent/context/index.rb | 11 +- lib/agent/context/installer.rb | 74 +-------- lib/agent/context/skills/installer.rb | 15 -- readme.md | 207 +++++++++++++++++++++---- test/agent/context/installer.rb | 44 +++--- test/agent/context/skills/installer.rb | 72 ++++----- 8 files changed, 612 insertions(+), 211 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 307e951..00eaf1e 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -2,36 +2,173 @@ This guide explains how to install package guidance and skills using `agent-context`. +## Overview + +`agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` directory. + ## Installation -```sh -bundle add agent-context -bundle exec bake agent:context:install +Add the gem to your project: + +```bash +$ bundle add agent-context ``` -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. +Then install agent context files: + +```bash +$ bundle exec bake agent:context:install +``` ## Commands -```sh +```bash +# See what context is available bake agent:context:list -bake agent:context:show --gem async --file getting-started + +# Install all available context +bake agent:context:install + +# Install context from a specific gem bake agent:context:install --gem async + +# See what context files a gem provides +bake agent:context:list --gem async + +# View a specific context file +bake agent:context:show --gem async --file thread-safety + +# Refresh the generated index bake agent:context:index + +# List, show, or install dependency skills 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 +bake agent:context:skill:install --gem provider --skill provider-workflow ``` -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. +## Understanding context/ vs .agents/context/ -## Migrating from Agent Skills +**Important distinction:** +- **`context/`** (no dot) = Directory in gems that contains context files to share. +- **`.agents/context/`** (with dot) = Directory in your project where context gets installed. -Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. +### What happens when you install context? + +When you run `bake agent:context:install`, the tool: -## Provider Layout +1. Scans resolved project gems for `context/` directories (in the gem's root). +2. Creates a `.agents/context/` directory in your current project. +3. Copies ordinary context files organized by gem name. +4. Installs metadata-declared skills and their resources into `.agents/skills/`. +5. Generates `.agents/context/index.md`, preserving repository-owned `agents.md`. Add a stable link to the generated index in your repository instructions. -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. +For example: +``` +your-project/ +├── .agents/context/ # ← Installed context (with dot) +│ ├── async/ # ← From the 'async' gem's context/ directory +│ │ ├── thread-safety.md +│ │ └── performance.md +│ └── rack/ # ← From the 'rack' gem's context/ directory +│ └── middleware.md +├── lib/ +└── Gemfile +``` + +Meanwhile, in the gems themselves: +``` +async-gem/ +├── context/ # ← Source context (no dot) +│ ├── thread-safety.md +│ └── performance.md +├── lib/ +└── async.gemspec +``` + +## Using Context (For Gem Users) + +### Why use this? + +- **Discover hidden documentation** that gems provide. +- **Get practical examples** and guidance. +- **Understand best practices** from gem authors. +- **Access migration guides** and troubleshooting tips. + +### Key Points for Users + +- Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). +- The `.agents/context/` directory is where installed context lives in your project. +- Installation maintains local Git exclusions for generated context and dependency-owned skills. Project-owned instructions and skills under `.agents/` remain trackable; remove blanket `/.agents/` ignore rules. +- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. + +## Providing Context (For Gem Authors) + +### How to provide context in your gem + +#### 1. Create a `context/` directory + +In your gem's root directory, create a `context/` folder (no dot): + +``` +your-gem/ +├── context/ # ← Source context (no dot) - this is what you create +│ ├── getting-started.md +│ ├── configuration.md +│ └── troubleshooting.md +├── lib/ +└── your-gem.gemspec +``` + +**Important:** This is different from `.agents/context/` (with dot) which is where context gets installed in user projects. + +#### 2. Add context files + +Create files with helpful information for users of your gem. Common types include: + +- **getting-started.md** - Quick start guide for using your gem. +- **configuration.md** - Configuration options and examples. +- **troubleshooting.md** - Common issues and solutions. +- **migration.md** - Migration guides between versions. +- **performance.md** - Performance tips and best practices. +- **security.md** - Security considerations. + +**Focus on the agent experience:** These files should help AI agents understand how to use your gem effectively, not document your gem's internal APIs. + +#### 3. Document your context + +Add a section to your gem's README: + +```markdown +## Context + +This gem provides additional context files that can be installed using `bake agent:context:install`. + +Available context files: +- `getting-started.md` - Quick start guide. +- `configuration.md` - Configuration options. +- `troubleshooting.md` - Common issues and solutions. +``` + +#### 4. File format and content guidelines + +Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. A provider-authored `index.yaml` can control ordering, titles, and descriptions; installation copies it unchanged and does not generate one. + +Context files can be in any format, but `.md` is commonly used for documentation. The content should be: + +- **Practical** - Include real examples and working code. +- **Focused** - One topic per file. +- **Clear** - Easy to understand and follow. +- **Actionable** - Provide specific guidance and next steps. +- **Agent-focused** - Help AI agents understand how to use your gem effectively. + +### Key Points for Gem Authors + +- Create a `context/` directory (no dot) in your gem's root. +- Put helpful guides for users of your gem there. +- Focus on practical usage, not API documentation. + +## Providing Skills To provide a skill, put a Markdown document directly inside `context/`: @@ -50,12 +187,41 @@ Follow the setup instructions. 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 +## Migrating from Agent Skills + +Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. + +Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. 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. + +## Example Context Files + +For examples of well-structured context files, see the existing files in this directory: +- `usage.md` - Shows how to use the tool (this file). +- `examples.md` - Demonstrates practical usage scenarios. + +## Key Differences from API Documentation + +Context files are NOT the same as API documentation: + +- **Context files**: Help agents accomplish tasks ("How do I configure authentication?"). +- **API documentation**: Document methods and classes ("Method `authenticate` returns Boolean"). + +Context files should answer questions like: +- "How do I get started?". +- "How do I configure this for production?". +- "What do I do when X goes wrong?". +- "How do I migrate from version Y to Z?". + +## Testing Your Context -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. +Before publishing, test your context files: -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. +1. Have an AI agent try to follow your getting-started guide. +2. Check that all code examples actually work. +3. Ensure the files are focused and don't try to cover too much. +4. Verify that they complement rather than duplicate your main documentation. -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. +## Summary -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. +- **`context/`** = source (in gems). +- **`.agents/context/`** = destination (in your project). diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 307e951..00eaf1e 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -2,36 +2,173 @@ This guide explains how to install package guidance and skills using `agent-context`. +## Overview + +`agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` directory. + ## Installation -```sh -bundle add agent-context -bundle exec bake agent:context:install +Add the gem to your project: + +```bash +$ bundle add agent-context ``` -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. +Then install agent context files: + +```bash +$ bundle exec bake agent:context:install +``` ## Commands -```sh +```bash +# See what context is available bake agent:context:list -bake agent:context:show --gem async --file getting-started + +# Install all available context +bake agent:context:install + +# Install context from a specific gem bake agent:context:install --gem async + +# See what context files a gem provides +bake agent:context:list --gem async + +# View a specific context file +bake agent:context:show --gem async --file thread-safety + +# Refresh the generated index bake agent:context:index + +# List, show, or install dependency skills 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 +bake agent:context:skill:install --gem provider --skill provider-workflow ``` -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. +## Understanding context/ vs .agents/context/ -## Migrating from Agent Skills +**Important distinction:** +- **`context/`** (no dot) = Directory in gems that contains context files to share. +- **`.agents/context/`** (with dot) = Directory in your project where context gets installed. -Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. +### What happens when you install context? + +When you run `bake agent:context:install`, the tool: -## Provider Layout +1. Scans resolved project gems for `context/` directories (in the gem's root). +2. Creates a `.agents/context/` directory in your current project. +3. Copies ordinary context files organized by gem name. +4. Installs metadata-declared skills and their resources into `.agents/skills/`. +5. Generates `.agents/context/index.md`, preserving repository-owned `agents.md`. Add a stable link to the generated index in your repository instructions. -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. +For example: +``` +your-project/ +├── .agents/context/ # ← Installed context (with dot) +│ ├── async/ # ← From the 'async' gem's context/ directory +│ │ ├── thread-safety.md +│ │ └── performance.md +│ └── rack/ # ← From the 'rack' gem's context/ directory +│ └── middleware.md +├── lib/ +└── Gemfile +``` + +Meanwhile, in the gems themselves: +``` +async-gem/ +├── context/ # ← Source context (no dot) +│ ├── thread-safety.md +│ └── performance.md +├── lib/ +└── async.gemspec +``` + +## Using Context (For Gem Users) + +### Why use this? + +- **Discover hidden documentation** that gems provide. +- **Get practical examples** and guidance. +- **Understand best practices** from gem authors. +- **Access migration guides** and troubleshooting tips. + +### Key Points for Users + +- Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). +- The `.agents/context/` directory is where installed context lives in your project. +- Installation maintains local Git exclusions for generated context and dependency-owned skills. Project-owned instructions and skills under `.agents/` remain trackable; remove blanket `/.agents/` ignore rules. +- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. + +## Providing Context (For Gem Authors) + +### How to provide context in your gem + +#### 1. Create a `context/` directory + +In your gem's root directory, create a `context/` folder (no dot): + +``` +your-gem/ +├── context/ # ← Source context (no dot) - this is what you create +│ ├── getting-started.md +│ ├── configuration.md +│ └── troubleshooting.md +├── lib/ +└── your-gem.gemspec +``` + +**Important:** This is different from `.agents/context/` (with dot) which is where context gets installed in user projects. + +#### 2. Add context files + +Create files with helpful information for users of your gem. Common types include: + +- **getting-started.md** - Quick start guide for using your gem. +- **configuration.md** - Configuration options and examples. +- **troubleshooting.md** - Common issues and solutions. +- **migration.md** - Migration guides between versions. +- **performance.md** - Performance tips and best practices. +- **security.md** - Security considerations. + +**Focus on the agent experience:** These files should help AI agents understand how to use your gem effectively, not document your gem's internal APIs. + +#### 3. Document your context + +Add a section to your gem's README: + +```markdown +## Context + +This gem provides additional context files that can be installed using `bake agent:context:install`. + +Available context files: +- `getting-started.md` - Quick start guide. +- `configuration.md` - Configuration options. +- `troubleshooting.md` - Common issues and solutions. +``` + +#### 4. File format and content guidelines + +Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. A provider-authored `index.yaml` can control ordering, titles, and descriptions; installation copies it unchanged and does not generate one. + +Context files can be in any format, but `.md` is commonly used for documentation. The content should be: + +- **Practical** - Include real examples and working code. +- **Focused** - One topic per file. +- **Clear** - Easy to understand and follow. +- **Actionable** - Provide specific guidance and next steps. +- **Agent-focused** - Help AI agents understand how to use your gem effectively. + +### Key Points for Gem Authors + +- Create a `context/` directory (no dot) in your gem's root. +- Put helpful guides for users of your gem there. +- Focus on practical usage, not API documentation. + +## Providing Skills To provide a skill, put a Markdown document directly inside `context/`: @@ -50,12 +187,41 @@ Follow the setup instructions. 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 +## Migrating from Agent Skills + +Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. + +Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. 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. + +## Example Context Files + +For examples of well-structured context files, see the existing files in this directory: +- `usage.md` - Shows how to use the tool (this file). +- `examples.md` - Demonstrates practical usage scenarios. + +## Key Differences from API Documentation + +Context files are NOT the same as API documentation: + +- **Context files**: Help agents accomplish tasks ("How do I configure authentication?"). +- **API documentation**: Document methods and classes ("Method `authenticate` returns Boolean"). + +Context files should answer questions like: +- "How do I get started?". +- "How do I configure this for production?". +- "What do I do when X goes wrong?". +- "How do I migrate from version Y to Z?". + +## Testing Your Context -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. +Before publishing, test your context files: -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. +1. Have an AI agent try to follow your getting-started guide. +2. Check that all code examples actually work. +3. Ensure the files are focused and don't try to cover too much. +4. Verify that they complement rather than duplicate your main documentation. -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. +## Summary -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. +- **`context/`** = source (in gems). +- **`.agents/context/`** = destination (in your project). diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index 30b46ce..b503995 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -9,6 +9,7 @@ require "yaml" require "uri" require "tempfile" +require "rubygems" require "agent/context/skills/registry" require "agent/context/skills/exclusion" require_relative "paths" @@ -20,8 +21,10 @@ module Context class Index # Initialize an index for installed context. # @parameter context_path [String] The installed context directory. - def initialize(context_path = CONTEXT_PATH) + # @parameter specifications [Enumerable] The resolved gems supplying package descriptions. + def initialize(context_path = CONTEXT_PATH, specifications: ::Gem::Specification) @context_path = File.expand_path(context_path) + @package_descriptions = specifications.to_a.to_h{|specification| [specification.name, specification.summary]} end # @attribute [String] The installed context directory. @@ -68,8 +71,8 @@ def generate_index end next if documents.empty? found = true - metadata = load_gem_index(name, directory) - sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || "Context files for #{name}"), ""]) + metadata = load_gem_index(directory) + sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || @package_descriptions[name] || "Context files for #{name}"), ""]) listed = [] Array(metadata["files"]).each do |entry| next unless entry.is_a?(Hash) && documents.key?(entry["path"]) && !listed.include?(entry["path"]) @@ -87,7 +90,7 @@ def generate_index private - def load_gem_index(name, directory) + def load_gem_index(directory) path = File.join(directory, "index.yaml") metadata = File.file?(path) ? YAML.safe_load_file(path, aliases: false) : {} metadata.is_a?(Hash) ? metadata : {} diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 3088e09..1deca41 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -7,7 +7,6 @@ require "rubygems" require "fileutils" require "pathname" -require "yaml" require "tmpdir" require_relative "paths" @@ -22,16 +21,6 @@ module Context # This class provides methods to find, list, show, and install context files # from gems that provide them in a `context/` directory. class Installer - CANONICAL_ORDER = [ - "getting-started", - "overview", - "usage", - "configuration", - "migration", - "troubleshooting", - "debugging" - ] - # Initialize a new Installer instance. # # @parameter root [String] The root directory to work from (default: current directory). @@ -134,7 +123,6 @@ def install_gem_context(gem_name) FileUtils.mkdir_p(File.dirname(destination)) FileUtils.copy_file(source, destination, true) end - ensure_gem_index(gem, fresh) previous = File.exist?(target_path) || File.symlink?(target_path) File.rename(target_path, backup) if previous begin @@ -175,70 +163,10 @@ def install(gem: nil) end installed_skills = @skills.install(gem: gem) installed_context = gem ? (install_gem_context(gem) ? [gem] : []) : install_all_context - Index.new(@context_path).update_index + Index.new(@context_path, specifications: @specifications).update_index {context: installed_context, skills: installed_skills} end - private - - # Generate a dynamic index from gemspec when no index.yaml is present - def generate_dynamic_index(gem, gem_directory) - # Collect all markdown files - markdown_files = Dir.glob(File.join(gem_directory, "**", "*.md")).sort - - # Sort files: canonical first, then alpha - files_sorted = markdown_files.sort_by do |file_path| - base_filename = File.basename(file_path, ".md").downcase - canonical_index = CANONICAL_ORDER.index(base_filename) - [canonical_index ? CANONICAL_ORDER.index(base_filename) : CANONICAL_ORDER.length, base_filename] - end - - files = [] - files_sorted.each do |file_path| - next if File.basename(file_path) == "index.yaml" # Skip the index file itself - title, description = extract_content(file_path) - relative_path = file_path.sub("#{gem_directory}/", "") - files << { - "path" => relative_path, - "title" => title, - "description" => description - } - end - - { - "description" => gem[:summary] || "Context files for #{gem[:name]}", - "metadata" => gem[:metadata], - "files" => files - } - end - - # Check if a gem has an index.yaml file, generate one if not - def ensure_gem_index(gem, gem_directory) - index_path = File.join(gem_directory, "index.yaml") - - unless File.exist?(index_path) - # Generate dynamic index from gemspec - index = generate_dynamic_index(gem, gem_directory) - - # Write the generated index - File.write(index_path, index.to_yaml) - end - - # Load and return the index - YAML.safe_load_file(index_path, aliases: false) - rescue => error - # Return a fallback index - { - "description" => gem[:summary] || "Context files for #{gem[:name]}", - "metadata" => gem[:metadata], - "files" => [] - } - end - - def extract_content(file_path) - document = Document.new(file_path) - [document.title, document.description] - end end end end diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index 3a9ad65..706ed91 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -83,21 +83,6 @@ def show_skill(gem_name, skill_name) list_skills(gem_name)&.find{|skill| skill.name == skill_name}&.content 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. - def install_gem_skills(gem_name) - return unless find_specification(gem_name) - install(gem: gem_name) - 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. - 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. diff --git a/readme.md b/readme.md index fb4c7e0..4088aee 100644 --- a/readme.md +++ b/readme.md @@ -1,42 +1,151 @@ # Agent::Context -Discover and install practical guidance and skills shipped by Ruby gems. +Provides tools for installing and managing context files and skills from Ruby gems for AI agents, and generating `.agents/context/index.md` while preserving repository-owned `agents.md`. -## Installation +[![Development Status](https://github.com/socketry/agent-context/workflows/Test/badge.svg)](https://github.com/socketry/agent-context/actions?workflow=Test) -```sh -bundle add agent-context -bundle exec bake agent:context:install +## Overview + +This gem allows you to install and manage context files from other gems. Gems can provide context files in a `context/` directory in their root, which can contain documentation, configuration examples, migration guides, and other contextual information for AI agents. + +When you install context from gems, ordinary guides are placed in `.agents/context/` and dependency skills in `.agents/skills/`. The generated `.agents/context/index.md` links to the guides; your repository-owned `agents.md` is preserved. + +## Quick Start + +Add the gem to your project and install context from all available gems: + +``` bash +$ bundle add agent-context +$ bake agent:context:install +``` + +This workflow: + + - Adds the `agent-context` gem to your project. + - Installs context files from all gems into `.agents/context/`. + - Installs metadata-declared skills into `.agents/skills/`. + - Generates `.agents/context/index.md` with a comprehensive overview, preserving `agents.md`. + - Follows the specification for agentic coding tools. + +## Context + +This gem provides its own context files in the `context/` directory, including: + + - `getting-started.md` - Comprehensive guide for using and providing context files and skills. + +When you install context from other gems, they will be placed in `.agents/context/` and referenced in `.agents/context/index.md`. + +## Usage + +Please see the [project documentation](https://ioquatix.github.io/agent-context/) for more details. + + - [Getting Started](https://ioquatix.github.io/agent-context/guides/getting-started/index) - This guide explains how to use `agent-context`, a tool for discovering and installing contextual information from Ruby gems to help AI agents. + +### Installation + +Add the `agent-context` gem to your project: + +``` bash +$ bundle add agent-context +``` + +### Commands + +#### Install Context (Primary Command) + +Install ordinary context and skills from all available gems and update `.agents/context/index.md`: + +``` bash +$ bake agent:context:install +``` + +Install context from a specific gem: + +``` bash +$ bake agent:context:install --gem async +``` + +#### List available context + +List all gems that have context available: + +``` bash +$ bake agent:context:list ``` -Installation copies ordinary context into `.agents/context//`, installs dependency skills into `.agents/skills/`, and generates `.agents/context/index.md`. Repository-owned `agents.md` is preserved. - -## Commands - -```sh -bake agent:context:list -bake agent:context:list --gem async -bake agent:context:show --gem async --file getting-started -bake agent:context:install -bake agent:context:install --gem async -bake agent:context:index -bake agent:context:skill:list -bake agent:context:skill:show --gem provider --skill provider-workflow -bake agent:context:skill:install -bake agent:context:skill:install --gem provider --skill provider-workflow +List context files for a specific gem: + +``` bash +$ bake agent:context:list --gem async +``` + +#### Show context content + +Show the content of a specific context file: + +``` bash +$ bake agent:context:show --gem async --file thread-safety +``` + +#### Refresh the Context Index + +Refresh the index from installed context: + +``` bash +$ bake agent:context:index +``` + +#### Skills + +List, show, or install metadata-declared dependency skills: + +``` bash +$ bake agent:context:skill:list +$ bake agent:context:skill:show --gem provider --skill provider-workflow +$ bake agent:context:skill:install +$ bake agent:context:skill:install --gem provider --skill provider-workflow ``` `agent-context` includes skill discovery and installation directly; the separate `agent-skills` gem is deprecated and is no longer needed. Remove it from your Gemfile. Replace the former `agent:skills:*` commands with `agent:context:skill:*`. -## Agent Instructions and Version Control +## Version Control Add a stable link to `.agents/context/index.md` in your repository-owned `agents.md`, together with instructions to read relevant installed guides. Run installation after changing dependencies. When migrating from an older generated `agents.md`, replace its generated dependency listing with this link while retaining project instructions. Installation maintains a marked block in the local Git exclude file, discovered through `git rev-parse --git-path info/exclude`. It excludes generated context, ownership files, and exact dependency-installed skill directories. Project-owned instructions and skills under `.agents/` remain trackable. Remove any blanket `/.agents/` rule from your project's `.gitignore` when adopting this layout. Re-run installation in each checkout. -## Providing Context and Skills +## Providing Context in Your Gem + +To provide context files in your gem, create a `context/` directory in your gem's root: + + your-gem/ + ├── context/ + │ ├── getting-started.md + │ ├── usage.md + │ ├── configuration.md + │ └── index.yaml (optional) + ├── lib/ + └── your-gem.gemspec + +### Optional: Custom Index File + +You can provide a custom `index.yaml` file to control ordering and metadata: + +``` yaml +description: "Your gem description from gemspec" +version: "1.0.0" +files: + - path: getting-started.md + title: "Getting Started" + description: "Quick start guide" + - path: usage.md + title: "Usage Guide" + description: "Detailed usage instructions" +``` + +If no `index.yaml` is provided, guide titles, summaries, and ordering are derived directly from Markdown, with the gem summary supplying the package description. Installation generates only `.agents/context/index.md`; it does not create per-provider `index.yaml` files. -Include a top-level `context/` directory in your packaged gem. Write focused guides with a clear heading and first sentence. Optional YAML `description` overrides the prose summary; other document metadata is tolerated. +### Providing Skills A root-level context document can declare a skill: @@ -56,8 +165,6 @@ Follow these instructions. Skills are discovered only through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. To migrate a `skills//SKILL.md` bundle, move its instructions to `context/.md`, add `type: skill` to the front matter, and move its resources to `context//`. Installed skill names become package-prefixed. -Providers can retain `context/index.yaml` to control guide ordering and metadata. Explicit entries take precedence, missing and skill-only entries are skipped, and unlisted guides are appended. - ## Ownership and Updates Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes preserve Cargo-owned entries and project-owned directories. Skill replacements are staged; failed copying or committing preserves the previous installation. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. @@ -66,14 +173,54 @@ Legacy Ruby YAML ownership and version-one Cargo JSON ownership migrate to the s See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). -## Development +## Releases + +Please see the [project releases](https://ioquatix.github.io/agent-context/releases/index) for all releases. + +### v0.3.0 -```sh -bundle exec sus -bundle exec rubocop + - Rename `agent.md` -\> `agents.md`. + +### v0.2.0 + + - Don't limit description length. + +## See Also + + - [Bake](https://github.com/ioquatix/bake) — The bake task execution tool. + +### Gems With Context Files + + - [Async](https://github.com/socketry/async) + - [Decode](https://github.com/ioquatix/decode) + - [Falcon](https:///github.com/socketry/falcon) + - [Sus](https://github.com/socketry/sus) + +## 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 ``` -See [releases.md](releases.md) and [license.md](license.md). +### Making Releases + +To make a new release: + +``` bash +$ bundle exec bake gem:release:patch # or minor or major +``` ### Developer Certificate of Origin diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index b9ac76f..7c9005d 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -89,18 +89,6 @@ def around expect(content).to be_nil end - it "handles a malformed context index" do - File.write(File.join(context_path, "index.yaml"), "invalid: yaml: content: [") - - helper = subject.new(specifications: @specifications) - gem = helper.find_gem_with_context("fake-gem") - index = helper.send(:ensure_gem_index, gem, context_path) - - expect(index["description"]).to be == "A fake gem for testing" - expect(index["metadata"]).to be == {} - expect(index["files"]).to be == [] - end - with "installation" do let(:target_path) {Dir.mktmpdir} @@ -149,18 +137,34 @@ def around expect(File).to be(:exist?, File.join(target_context_path, "configuration.md")) end - it "generates metadata for minimally structured context" do + it "generates the Markdown index directly without per-provider YAML" do File.write(File.join(context_path, "notes.md"), "First paragraph.\n\nSecond paragraph.") helper = subject.new(root: @target_path, specifications: @specifications) + target_context_path = File.join(helper.context_path, "fake-gem") + FileUtils.mkdir_p(target_context_path) + File.write(File.join(target_context_path, "index.yaml"), "description: Old generated metadata.\n") - expect(helper.install_gem_context("fake-gem")).to be_truthy + expect(helper.install[:context]).to be == ["fake-gem"] + expect(File).not.to be(:exist?, File.join(target_context_path, "index.yaml")) + expect(File).not.to be(:exist?, File.join(context_path, "index.yaml")) - index_path = File.join(@target_path, ".agents", "context", "fake-gem", "index.yaml") - index = YAML.load_file(index_path) - notes = index["files"].find{|file| file["path"] == "notes.md"} - - expect(notes["title"]).to be == "notes" - expect(notes["description"]).to be == "First paragraph." + index = File.read(File.join(helper.context_path, "index.md")) + expect(index).to be(:include?, "A fake gem for testing") + expect(index).to be(:include?, "### [notes](fake-gem/notes.md)") + expect(index).to be(:include?, "First paragraph.") + expect(index.index("Getting Started")).to be < index.index("Configuration") + end + + it "copies provider-authored YAML unchanged and honors its overrides" do + authored = "# Provider-owned metadata\ndescription: Custom provider description.\nfiles:\n- path: configuration.md\n title: Custom Configuration\n" + File.write(File.join(context_path, "index.yaml"), authored) + helper = subject.new(root: @target_path, specifications: @specifications) + helper.install + expect(File.read(File.join(helper.context_path, "fake-gem", "index.yaml"))).to be == authored + expect(File.read(File.join(context_path, "index.yaml"))).to be == authored + index = File.read(File.join(helper.context_path, "index.md")) + expect(index).to be(:include?, "Custom provider description.") + expect(index.index("Custom Configuration")).to be < index.index("Getting Started") end end end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index 55792d3..4b8bed0 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -64,7 +64,9 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these it "returns nil for a gem without skills" do expect(installer.find_gem_with_skills("missing-gem")).to be_nil - expect(installer.install_gem_skills("missing-gem")).to be_nil + expect do + installer.install(gem: "missing-gem") + end.to raise_exception(Agent::Context::Skills::Installer::Error) end it "ignores legacy skill bundles and discovers only context metadata" do @@ -87,7 +89,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these with "installation" do it "installs skills relative to the consuming project root" do - installed = installer.install_gem_skills("fake-gem") + installed = installer.install(gem: "fake-gem") destination = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") expect(installed).to be == ["fake-gem-ruby-testing"] @@ -95,7 +97,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end it "records ownership in the registry" do - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") registry_path = File.join(consumer_root, ".agents", "skills", Agent::Context::Skills::Registry::FILE_NAME) registry = Agent::Context::Skills::Registry.new(registry_path) @@ -107,9 +109,9 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end it "updates a skill owned by the same gem" do - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") write_skill(provider_root, "ruby-testing", description: "Updated description.", body: "# Updated\n") - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") content = File.read(File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing", "SKILL.md")) expect(content).to be(:include?, "# Updated") @@ -117,9 +119,9 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these it "removes skills no longer provided by the same gem" do write_skill(provider_root, "documentation", description: "Write documentation.") - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") File.delete(File.join(provider_root, "context", "documentation.md")) - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-documentation")) end @@ -130,7 +132,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these File.write(File.join(destination, "SKILL.md"), "project-owned") expect do - installer.install_gem_skills("fake-gem") + installer.install(gem: "fake-gem") end.to raise_exception(Agent::Context::Skills::Installer::Conflict) expect(File.read(File.join(destination, "SKILL.md"))).to be == "project-owned" @@ -143,7 +145,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these duplicate_installer = subject.new(root: consumer_root, specifications: duplicate_specifications) expect do - duplicate_installer.install_all_skills + duplicate_installer.install end.to raise_exception(Agent::Context::Skills::Installer::Conflict) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing")) @@ -157,7 +159,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these all_specifications = specifications + [build_specification("other-gem", "2.0.0", second_root)] all_installer = subject.new(root: consumer_root, specifications: all_specifications) - expect(all_installer.install_all_skills.sort).to be == ["fake-gem-ruby-testing", "other-gem-documentation"] + expect(all_installer.install.sort).to be == ["fake-gem-ruby-testing", "other-gem-documentation"] expect(File).to be(:exist?, File.join(consumer_root, ".agents", "skills", "other-gem-documentation", "SKILL.md")) ensure FileUtils.rm_rf(second_root) if second_root @@ -182,7 +184,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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-ruby-testing", "fake-gem-workflow"] + expect(installer.install(gem: "fake-gem").sort).to be == ["fake-gem-ruby-testing", "fake-gem-workflow"] 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") @@ -191,13 +193,13 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end it "removes a provider's last skill and cleans disappeared gem providers" do - installer.install_all_skills + installer.install FileUtils.rm_rf(File.join(provider_root, "context")) - expect(installer.install_gem_skills("fake-gem")).to be == [] + expect(installer.install(gem: "fake-gem")).to be == [] expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) write_skill(provider_root, "ruby-testing", description: "Test projects.") - installer.install_all_skills - subject.new(root: consumer_root, specifications: []).install_all_skills + installer.install + subject.new(root: consumer_root, specifications: []).install expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -209,7 +211,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 + installer.install expect(File.read(cargo)).to be == "Cargo instructions" expect(Agent::Context::Skills::Registry.new(registry_path).owner("cargo-workflow")["ecosystem"]).to be == "cargo" end @@ -219,12 +221,12 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these registry.claim("fake-gem-ruby-testing", "fake-gem", "1.0.0", ecosystem: "cargo") registry.save expect do - installer.install_all_skills + installer.install end.to raise_exception(Agent::Context::Skills::Installer::Conflict) end it "preserves the previous skills and registry when resource copying fails" do - installer.install_all_skills + installer.install registry_path = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) previous = File.read(registry_path) destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") @@ -233,7 +235,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these FileUtils.mkdir_p(File.join(provider_root, "context/ruby-testing")) File.symlink("missing", File.join(provider_root, "context/ruby-testing/broken")) expect do - installer.install_all_skills + installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) expect(File.read(registry_path)).to be == previous expect(File.read(destination)).to be == instructions @@ -241,7 +243,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these it "preserves unselected skills when installing one named skill" do write_skill(provider_root, "documentation", description: "Write docs.") - installer.install_all_skills + installer.install File.delete(File.join(provider_root, "context/documentation.md")) installer.install(skill: "fake-gem-ruby-testing") expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-documentation/SKILL.md")) @@ -260,7 +262,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these it "includes the package prefix when validating installed name length" do write_skill(provider_root, "a" * 56, description: "Run workflow.") expect do - installer.install_all_skills + installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -271,21 +273,21 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these File.write(File.join(legacy_root, "SKILL.md"), "Legacy instructions") 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) - expect(installer.install_gem_skills("fake-gem")).to be == ["fake-gem-ruby-testing"] + expect(installer.install(gem: "fake-gem")).to be == ["fake-gem-ruby-testing"] expect(File).not.to be(:exist?, legacy_root) expect(File).not.to be(:exist?, legacy) expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) end it "migrates legacy Ruby ownership without resurrecting removed skills" do - installer.install_all_skills + installer.install shared = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) File.delete(shared) legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") File.write(legacy, {"version" => 1, "skills" => {"fake-gem-ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml) FileUtils.rm_rf(File.join(provider_root, "context")) - installer.install_all_skills - installer.install_all_skills + installer.install + installer.install expect(File).not.to be(:exist?, legacy) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) expect(Agent::Context::Skills::Registry.new(shared).owners).to be == {} @@ -296,7 +298,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 + installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -306,7 +308,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 + installer.install content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) expect(installer.list_skills("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == "é" * 1024 expect(content).not.to be(:include?, " é") @@ -315,7 +317,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 + installer.install registry = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) exclude = File.join(consumer_root, ".git/info/exclude") previous_registry = File.read(registry) @@ -331,7 +333,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these raise Errno::EACCES, "Injected registry failure" if target == registry end expect do - installer.install_all_skills + installer.install end.to raise_exception(Errno::EACCES) expect(File.read(registry)).to be == previous_registry expect(File.read(exclude)).to be == previous_exclude @@ -348,23 +350,23 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(installer.list_skills("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] File.write(guide, "---\ntype: skill\ndescription: [invalid\n---\n") expect do - installer.install_all_skills + installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) write_skill(provider_root, "Bad_Name", description: "Invalid name.") File.delete(guide) expect do - installer.install_all_skills + installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) end it "refuses a reserved SKILL.md resource before changing installed skills" do - installer.install_all_skills + installer.install 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 + installer.install end.to raise_exception(Agent::Context::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/fake-gem-ruby-testing/SKILL.md")) @@ -372,12 +374,12 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 == ["fake-gem-ruby-testing"] + expect(installer.install).to be == ["fake-gem-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 + installer.install end.to raise_exception(Agent::Context::Skills::Registry::Invalid) end end From 022ecde445a879a74f0b7f85342082c398aaccf3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 17:16:10 +0530 Subject: [PATCH 08/37] Provide an agent-context usage skill for gem consumers Signed-off-by: Samuel Williams --- context/usage.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 context/usage.md diff --git a/context/usage.md b/context/usage.md new file mode 100644 index 0000000..1638603 --- /dev/null +++ b/context/usage.md @@ -0,0 +1,69 @@ +--- +type: skill +description: Install and navigate guidance and skills from Ruby gem dependencies, or publish context and skills from a gem using agent-context. +--- + +# Using and Providing Context + +Use `agent-context` to find practical guidance shipped by Ruby gem dependencies and make package guidance available to downstream agents. + +## Use Dependency Guidance + +Follow the repository's agent instructions and read `.agents/context/index.md` to find installed dependency guidance. Use its package descriptions, document titles, and summaries to select the guides relevant to the task, then read those guides before changing code that uses the package. + +If the index or relevant installed files are missing, run `bundle exec bake agent:context:install` from the consuming project. Run it again after changing dependencies. It installs ordinary context and skills from resolved gems and refreshes `.agents/context/index.md`. The consuming gem's own source context is excluded from a full dependency install. + +To inspect a provider or install only its guidance: + +```bash +bundle exec bake agent:context:list +bundle exec bake agent:context:list --gem async +bundle exec bake agent:context:show --gem async --file thread-safety +bundle exec bake agent:context:install --gem async +``` + +`show` reads the provider's source file without installing it; the `.md` extension is optional. `install --gem NAME` installs both ordinary context and skills from that provider. Use `bundle exec bake agent:context:index` to regenerate the index from already installed context. + +Ordinary context is copied to `.agents/context/GEM/`. The index links to Markdown guides using their headings and prose summaries. Installation preserves repository-owned `agents.md`; add a stable link to `.agents/context/index.md` there when configuring repository instructions. + +## Use Dependency Skills + +Skills contain procedures for particular tasks and are installed under `.agents/skills/`. Use the matching skill's instructions and resources when its description applies to the work. Skill sources are omitted from ordinary context and its index. + +```bash +bundle exec bake agent:context:skill:list +bundle exec bake agent:context:skill:show --gem agent-context --skill agent-context-usage +bundle exec bake agent:context:skill:install +bundle exec bake agent:context:skill:install --gem agent-context --skill agent-context-usage +``` + +Skill names are prefixed by the provider gem. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. Selecting a single skill leaves other installed skills intact. This document installs as `agent-context-usage/SKILL.md`. + +## Keep Sources and Installed Files Separate + +The provider's tracked `context/` directory is source content. Installed dependency copies under `.agents/context/` and `.agents/skills/` are generated; edit provider sources and reinstall to update them. Keep repository-only instructions and project-owned skills in the consuming repository. + +Installation maintains local Git exclusions for generated context, the skill ownership index, and exact dependency-installed skill directories. Project-owned skills remain trackable. Remove blanket `/.agents/` ignore rules when adopting this layout. + +The `.agents/skills/.agent-context-skills.json` file records installed ownership. Skill discovery comes from source document metadata. Full Ruby skill refreshes remove stale gem-owned skills while preserving Cargo-owned and project-owned skills; copying or replacing skills preserves the previous installation if it fails. + +## Publish Context and Skills + +Put focused Markdown guides directly in the gem's `context/` directory, using descriptive lowercase filenames. Give each guide a clear heading and first prose sentence. YAML front matter can override its summary with `description`. A provider-authored `index.yaml` can override ordering, titles, and descriptions; installation preserves it and generates the Markdown index directly without creating provider YAML. + +To publish a skill, add `context/workflow.md` with metadata describing when to use it: + +```markdown +--- +type: skill +description: Set up a project using this gem's conventions. +--- + +# Workflow + +Follow the setup instructions. +``` + +The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and preserves additional metadata. Keep the resources directory free of a top-level `SKILL.md`, which the installer generates. + +Include `context/**/*` in the gemspec's packaged files. Installed skill names must use lowercase ASCII letters, digits, and single hyphens, with at most 64 characters including the package prefix. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. diff --git a/readme.md b/readme.md index 4088aee..6b17fd6 100644 --- a/readme.md +++ b/readme.md @@ -32,6 +32,7 @@ This workflow: This gem provides its own context files in the `context/` directory, including: - `getting-started.md` - Comprehensive guide for using and providing context files and skills. + - [`usage.md`](context/usage.md) - Agent instructions for finding dependency guidance and managing context and skills, installed as the `agent-context-usage` skill. When you install context from other gems, they will be placed in `.agents/context/` and referenced in `.agents/context/index.md`. From db5b711d968dc7e487d44f9a5150317f336cf294 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 17:32:38 +0530 Subject: [PATCH 09/37] Describe context and skills behavior directly Signed-off-by: Samuel Williams --- context/getting-started.md | 14 +++++++------- context/usage.md | 20 ++++++++++---------- guides/getting-started/readme.md | 14 +++++++------- readme.md | 18 +++++++++--------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 00eaf1e..0d3f599 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -61,7 +61,7 @@ When you run `bake agent:context:install`, the tool: 2. Creates a `.agents/context/` directory in your current project. 3. Copies ordinary context files organized by gem name. 4. Installs metadata-declared skills and their resources into `.agents/skills/`. -5. Generates `.agents/context/index.md`, preserving repository-owned `agents.md`. Add a stable link to the generated index in your repository instructions. +5. Generates `.agents/context/index.md`. Add a stable link to the generated index in your repository instructions. For example: ``` @@ -99,8 +99,8 @@ async-gem/ - Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). - The `.agents/context/` directory is where installed context lives in your project. -- Installation maintains local Git exclusions for generated context and dependency-owned skills. Project-owned instructions and skills under `.agents/` remain trackable; remove blanket `/.agents/` ignore rules. -- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. +- Installation maintains local Git exclusions for generated context and dependency-owned skills. Track project-owned instructions and skills under `.agents/` and remove blanket `/.agents/` ignore rules. +- Edit provider sources and reinstall to update generated files in `.agents/context/`. ## Providing Context (For Gem Authors) @@ -152,7 +152,7 @@ Available context files: #### 4. File format and content guidelines -Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. A provider-authored `index.yaml` can control ordering, titles, and descriptions; installation copies it unchanged and does not generate one. +Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. Context files can be in any format, but `.md` is commonly used for documentation. The content should be: @@ -183,15 +183,15 @@ 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. +`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. -Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. ## Migrating from Agent Skills Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. -Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. 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. +Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. ## Example Context Files diff --git a/context/usage.md b/context/usage.md index 1638603..79b2d1d 100644 --- a/context/usage.md +++ b/context/usage.md @@ -11,9 +11,9 @@ Use `agent-context` to find practical guidance shipped by Ruby gem dependencies Follow the repository's agent instructions and read `.agents/context/index.md` to find installed dependency guidance. Use its package descriptions, document titles, and summaries to select the guides relevant to the task, then read those guides before changing code that uses the package. -If the index or relevant installed files are missing, run `bundle exec bake agent:context:install` from the consuming project. Run it again after changing dependencies. It installs ordinary context and skills from resolved gems and refreshes `.agents/context/index.md`. The consuming gem's own source context is excluded from a full dependency install. +If the index or relevant installed files are missing, run `bundle exec bake agent:context:install` from the consuming project. Run it again after changing dependencies. It installs ordinary context and skills from resolved dependency gems and refreshes `.agents/context/index.md`. -To inspect a provider or install only its guidance: +To inspect or install guidance from a specific provider: ```bash bundle exec bake agent:context:list @@ -22,13 +22,13 @@ bundle exec bake agent:context:show --gem async --file thread-safety bundle exec bake agent:context:install --gem async ``` -`show` reads the provider's source file without installing it; the `.md` extension is optional. `install --gem NAME` installs both ordinary context and skills from that provider. Use `bundle exec bake agent:context:index` to regenerate the index from already installed context. +`show` reads the provider's source file; the `.md` extension is optional. `install --gem NAME` installs both ordinary context and skills from that provider. Use `bundle exec bake agent:context:index` to regenerate the index from already installed context. -Ordinary context is copied to `.agents/context/GEM/`. The index links to Markdown guides using their headings and prose summaries. Installation preserves repository-owned `agents.md`; add a stable link to `.agents/context/index.md` there when configuring repository instructions. +Ordinary context is copied to `.agents/context/GEM/`. The index links to Markdown guides using their headings and prose summaries. Add a stable link to `.agents/context/index.md` in the repository's `agents.md` when configuring repository instructions. ## Use Dependency Skills -Skills contain procedures for particular tasks and are installed under `.agents/skills/`. Use the matching skill's instructions and resources when its description applies to the work. Skill sources are omitted from ordinary context and its index. +Skills contain procedures for particular tasks and are installed under `.agents/skills/`. Use the matching skill's instructions and resources when its description applies to the work. ```bash bundle exec bake agent:context:skill:list @@ -37,19 +37,19 @@ bundle exec bake agent:context:skill:install bundle exec bake agent:context:skill:install --gem agent-context --skill agent-context-usage ``` -Skill names are prefixed by the provider gem. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. Selecting a single skill leaves other installed skills intact. This document installs as `agent-context-usage/SKILL.md`. +Skill names are prefixed by the provider gem. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. This document installs as `agent-context-usage/SKILL.md`. ## Keep Sources and Installed Files Separate The provider's tracked `context/` directory is source content. Installed dependency copies under `.agents/context/` and `.agents/skills/` are generated; edit provider sources and reinstall to update them. Keep repository-only instructions and project-owned skills in the consuming repository. -Installation maintains local Git exclusions for generated context, the skill ownership index, and exact dependency-installed skill directories. Project-owned skills remain trackable. Remove blanket `/.agents/` ignore rules when adopting this layout. +Installation maintains local Git exclusions for generated context, the skill ownership index, and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. -The `.agents/skills/.agent-context-skills.json` file records installed ownership. Skill discovery comes from source document metadata. Full Ruby skill refreshes remove stale gem-owned skills while preserving Cargo-owned and project-owned skills; copying or replacing skills preserves the previous installation if it fails. +The `.agents/skills/.agent-context-skills.json` file records installed ownership. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale entries. Skill updates use staged replacements with rollback on failure. ## Publish Context and Skills -Put focused Markdown guides directly in the gem's `context/` directory, using descriptive lowercase filenames. Give each guide a clear heading and first prose sentence. YAML front matter can override its summary with `description`. A provider-authored `index.yaml` can override ordering, titles, and descriptions; installation preserves it and generates the Markdown index directly without creating provider YAML. +Put focused Markdown guides directly in the gem's `context/` directory, using descriptive lowercase filenames. Give each guide a clear heading and first prose sentence. YAML front matter can override its summary with `description`. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. To publish a skill, add `context/workflow.md` with metadata describing when to use it: @@ -64,6 +64,6 @@ description: Set up a project using this gem's conventions. Follow the setup instructions. ``` -The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and preserves additional metadata. Keep the resources directory free of a top-level `SKILL.md`, which the installer generates. +The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and includes additional metadata. The top-level `SKILL.md` is reserved for the generated instructions. Include `context/**/*` in the gemspec's packaged files. Installed skill names must use lowercase ASCII letters, digits, and single hyphens, with at most 64 characters including the package prefix. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 00eaf1e..0d3f599 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -61,7 +61,7 @@ When you run `bake agent:context:install`, the tool: 2. Creates a `.agents/context/` directory in your current project. 3. Copies ordinary context files organized by gem name. 4. Installs metadata-declared skills and their resources into `.agents/skills/`. -5. Generates `.agents/context/index.md`, preserving repository-owned `agents.md`. Add a stable link to the generated index in your repository instructions. +5. Generates `.agents/context/index.md`. Add a stable link to the generated index in your repository instructions. For example: ``` @@ -99,8 +99,8 @@ async-gem/ - Run `bake agent:context:install` to copy context to `.agents/context/` (with dot). - The `.agents/context/` directory is where installed context lives in your project. -- Installation maintains local Git exclusions for generated context and dependency-owned skills. Project-owned instructions and skills under `.agents/` remain trackable; remove blanket `/.agents/` ignore rules. -- Don't edit files in `.agents/context/` - they get completely replaced when you reinstall. +- Installation maintains local Git exclusions for generated context and dependency-owned skills. Track project-owned instructions and skills under `.agents/` and remove blanket `/.agents/` ignore rules. +- Edit provider sources and reinstall to update generated files in `.agents/context/`. ## Providing Context (For Gem Authors) @@ -152,7 +152,7 @@ Available context files: #### 4. File format and content guidelines -Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. A provider-authored `index.yaml` can control ordering, titles, and descriptions; installation copies it unchanged and does not generate one. +Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. Context files can be in any format, but `.md` is commonly used for documentation. The content should be: @@ -183,15 +183,15 @@ 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. +`context/workflow.md` in gem `provider` installs as `provider-workflow/SKILL.md`. The matching `context/workflow/` directory supplies resources. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. -Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed only as skills. +Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. ## Migrating from Agent Skills Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. -Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. 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. +Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. ## Example Context Files diff --git a/readme.md b/readme.md index 6b17fd6..1197e9e 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Agent::Context -Provides tools for installing and managing context files and skills from Ruby gems for AI agents, and generating `.agents/context/index.md` while preserving repository-owned `agents.md`. +Provides tools for installing and managing context files and skills from Ruby gems for AI agents, and generating `.agents/context/index.md`. [![Development Status](https://github.com/socketry/agent-context/workflows/Test/badge.svg)](https://github.com/socketry/agent-context/actions?workflow=Test) @@ -8,7 +8,7 @@ Provides tools for installing and managing context files and skills from Ruby ge This gem allows you to install and manage context files from other gems. Gems can provide context files in a `context/` directory in their root, which can contain documentation, configuration examples, migration guides, and other contextual information for AI agents. -When you install context from gems, ordinary guides are placed in `.agents/context/` and dependency skills in `.agents/skills/`. The generated `.agents/context/index.md` links to the guides; your repository-owned `agents.md` is preserved. +When you install context from gems, ordinary guides are placed in `.agents/context/` and dependency skills in `.agents/skills/`. The generated `.agents/context/index.md` links to the guides. ## Quick Start @@ -24,7 +24,7 @@ This workflow: - Adds the `agent-context` gem to your project. - Installs context files from all gems into `.agents/context/`. - Installs metadata-declared skills into `.agents/skills/`. - - Generates `.agents/context/index.md` with a comprehensive overview, preserving `agents.md`. + - Generates `.agents/context/index.md` with a comprehensive overview. - Follows the specification for agentic coding tools. ## Context @@ -107,13 +107,13 @@ $ bake agent:context:skill:install $ bake agent:context:skill:install --gem provider --skill provider-workflow ``` -`agent-context` includes skill discovery and installation directly; the separate `agent-skills` gem is deprecated and is no longer needed. Remove it from your Gemfile. Replace the former `agent:skills:*` commands with `agent:context:skill:*`. +`agent-context` includes skill discovery and installation directly. To migrate from the deprecated `agent-skills` gem, replace it with `agent-context` in your Gemfile and use `agent:context:skill:*` commands. ## Version Control Add a stable link to `.agents/context/index.md` in your repository-owned `agents.md`, together with instructions to read relevant installed guides. Run installation after changing dependencies. When migrating from an older generated `agents.md`, replace its generated dependency listing with this link while retaining project instructions. -Installation maintains a marked block in the local Git exclude file, discovered through `git rev-parse --git-path info/exclude`. It excludes generated context, ownership files, and exact dependency-installed skill directories. Project-owned instructions and skills under `.agents/` remain trackable. Remove any blanket `/.agents/` rule from your project's `.gitignore` when adopting this layout. Re-run installation in each checkout. +Installation maintains a marked block in the local Git exclude file, discovered through `git rev-parse --git-path info/exclude`. It excludes generated context, ownership files, and exact dependency-installed skill directories. Track project-owned instructions and skills under `.agents/`. Remove any blanket `/.agents/` rule from your project's `.gitignore` when adopting this layout. Re-run installation in each checkout. ## Providing Context in Your Gem @@ -144,7 +144,7 @@ files: description: "Detailed usage instructions" ``` -If no `index.yaml` is provided, guide titles, summaries, and ordering are derived directly from Markdown, with the gem summary supplying the package description. Installation generates only `.agents/context/index.md`; it does not create per-provider `index.yaml` files. +Installation generates `.agents/context/index.md` directly from Markdown headings, prose summaries, and document ordering, with the gem summary supplying the package description. A provider-authored `index.yaml` customizes these values. ### Providing Skills @@ -162,13 +162,13 @@ license: MIT Follow these instructions. ``` -`context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Skill sources and resources are excluded from ordinary context and its index. Installed names have a 64-character limit; descriptions have a 1,024-character limit. Additional skill metadata is preserved. +`context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Installed names have a 64-character limit; descriptions have a 1,024-character limit. The generated instructions include additional skill metadata. -Skills are discovered only through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. To migrate a `skills//SKILL.md` bundle, move its instructions to `context/.md`, add `type: skill` to the front matter, and move its resources to `context//`. Installed skill names become package-prefixed. +Skills are discovered through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. To migrate a `skills//SKILL.md` bundle, move its instructions to `context/.md`, add `type: skill` to the front matter, and move its resources to `context//`. Installed skill names become package-prefixed. ## Ownership and Updates -Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes preserve Cargo-owned entries and project-owned directories. Skill replacements are staged; failed copying or committing preserves the previous installation. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. +Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. Legacy Ruby YAML ownership and version-one Cargo JSON ownership migrate to the shared format. Ruby's legacy ownership file is retired after successful installation. From 6797ea4504bda646971d13259c6c873bbe93cf77 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 17:43:26 +0530 Subject: [PATCH 10/37] Simplify skill ownership to the shared JSON index Signed-off-by: Samuel Williams --- context/getting-started.md | 4 +--- guides/getting-started/readme.md | 4 +--- lib/agent/context/skills/exclusion.rb | 1 - lib/agent/context/skills/installer.rb | 3 --- lib/agent/context/skills/registry.rb | 28 +--------------------- readme.md | 6 ++--- specification.md | 2 +- test/agent/context/skills/installer.rb | 32 +------------------------- test/agent/context/skills/registry.rb | 19 --------------- 9 files changed, 8 insertions(+), 91 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 0d3f599..4c7ed0d 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -187,9 +187,7 @@ Follow the setup instructions. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. -## Migrating from Agent Skills - -Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. +## Ownership and Updates Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 0d3f599..4c7ed0d 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -187,9 +187,7 @@ Follow the setup instructions. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. -## Migrating from Agent Skills - -Skill discovery and installation are built into `agent-context`. Replace the `agent-skills` dependency in your Gemfile with `agent-context`, then run `bundle install`. Use `agent:context:skill:*` in place of `agent:skills:*`. Move existing `skills//SKILL.md` instructions to `context/.md`, add `type: skill` to the front matter, and move their resources to `context//`. Installed names become package-prefixed. Ownership from `.agent-skills.yaml` migrates automatically during installation. +## Ownership and Updates Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb index cb9d43e..cf4b93a 100644 --- a/lib/agent/context/skills/exclusion.rb +++ b/lib/agent/context/skills/exclusion.rb @@ -39,7 +39,6 @@ def initialize(root, names) "# 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, ]) diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index 706ed91..f1198b1 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -200,7 +200,6 @@ def install_definitions(definitions, package:, reconcile:) 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| @@ -211,7 +210,6 @@ def install_definitions(definitions, package:, reconcile:) 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| @@ -219,7 +217,6 @@ def install_definitions(definitions, package:, reconcile:) 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 diff --git a/lib/agent/context/skills/registry.rb b/lib/agent/context/skills/registry.rb index b5b497e..c625cbc 100644 --- a/lib/agent/context/skills/registry.rb +++ b/lib/agent/context/skills/registry.rb @@ -6,7 +6,6 @@ require "fileutils" require "json" -require "yaml" require "tempfile" module Agent @@ -22,12 +21,11 @@ class Registry class Invalid < StandardError end - # Load shared ownership and import legacy Ruby ownership. + # Load shared skill 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. @@ -99,30 +97,6 @@ def load_data raise Invalid, "Invalid skill ownership index #{@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}" - 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}" - 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 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?} diff --git a/readme.md b/readme.md index 1197e9e..fc55784 100644 --- a/readme.md +++ b/readme.md @@ -107,7 +107,7 @@ $ bake agent:context:skill:install $ bake agent:context:skill:install --gem provider --skill provider-workflow ``` -`agent-context` includes skill discovery and installation directly. To migrate from the deprecated `agent-skills` gem, replace it with `agent-context` in your Gemfile and use `agent:context:skill:*` commands. +`agent-context` includes skill discovery and installation directly. Use it for Ruby dependency skills; the standalone `agent-skills` gem is deprecated. ## Version Control @@ -164,13 +164,13 @@ Follow these instructions. `context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Installed names have a 64-character limit; descriptions have a 1,024-character limit. The generated instructions include additional skill metadata. -Skills are discovered through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. To migrate a `skills//SKILL.md` bundle, move its instructions to `context/.md`, add `type: skill` to the front matter, and move its resources to `context//`. Installed skill names become package-prefixed. +Skills are discovered through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. Installed skill names become package-prefixed. ## Ownership and Updates Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. -Legacy Ruby YAML ownership and version-one Cargo JSON ownership migrate to the shared format. Ruby's legacy ownership file is retired after successful installation. +Version-one Cargo JSON ownership migrates to the shared format. See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). diff --git a/specification.md b/specification.md index cc94551..385a2a4 100644 --- a/specification.md +++ b/specification.md @@ -224,6 +224,6 @@ Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. Each instal 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. +Version-one JSON ownership has implicit Cargo ownership and MUST migrate to version two when rewritten. 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/context/skills/installer.rb b/test/agent/context/skills/installer.rb index 4b8bed0..e9f96d8 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -267,32 +267,6 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end - it "replaces legacy owned bundle names with context-declared skill names" do - legacy_root = File.join(consumer_root, ".agents/skills/ruby-testing") - FileUtils.mkdir_p(legacy_root) - File.write(File.join(legacy_root, "SKILL.md"), "Legacy instructions") - 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) - expect(installer.install(gem: "fake-gem")).to be == ["fake-gem-ruby-testing"] - expect(File).not.to be(:exist?, legacy_root) - expect(File).not.to be(:exist?, legacy) - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) - end - - it "migrates legacy Ruby ownership without resurrecting removed skills" do - installer.install - shared = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) - File.delete(shared) - legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") - File.write(legacy, {"version" => 1, "skills" => {"fake-gem-ruby-testing" => {"gem" => "fake-gem", "version" => "1.0.0"}}}.to_yaml) - FileUtils.rm_rf(File.join(provider_root, "context")) - installer.install - installer.install - expect(File).not.to be(:exist?, legacy) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) - expect(Agent::Context::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) @@ -314,7 +288,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(content).not.to be(:include?, " é") end - it "restores skills, legacy ownership, and exclusions when the registry commit fails" do + it "restores skills, 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 @@ -324,9 +298,6 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these previous_exclude = File.read(exclude) destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") previous_instructions = File.read(destination) - legacy = File.join(consumer_root, ".agents/skills/.agent-skills.yaml") - legacy_content = {"version" => 1, "skills" => {"fake-gem-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") File.delete(File.join(provider_root, "context/documentation.md")) mock(File).before(:rename) do |_source, target| @@ -338,7 +309,6 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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/fake-gem-documentation/SKILL.md")) end diff --git a/test/agent/context/skills/registry.rb b/test/agent/context/skills/registry.rb index cfdeba3..a1294ed 100644 --- a/test/agent/context/skills/registry.rb +++ b/test/agent/context/skills/registry.rb @@ -77,23 +77,4 @@ def around 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 From 3d816fd7fa850b719b8d7cb7f5658ca807afc00a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 9 Oct 2026 23:23:22 +0530 Subject: [PATCH 11/37] Record installation ownership inside each skill directory Signed-off-by: Samuel Williams --- context/getting-started.md | 2 +- context/skill.schema.json | 24 +++++ context/usage.md | 6 +- guides/getting-started/readme.md | 2 +- lib/agent/context/index.rb | 6 +- lib/agent/context/skills.rb | 2 +- lib/agent/context/skills/definition.rb | 6 +- lib/agent/context/skills/exclusion.rb | 4 +- lib/agent/context/skills/installer.rb | 16 ++-- lib/agent/context/skills/ownership.rb | 66 ++++++++++++++ lib/agent/context/skills/registry.rb | 110 ----------------------- readme.md | 2 +- specification.md | 31 +++---- test/agent/context/index.rb | 6 +- test/agent/context/skills/installer.rb | 84 ++++++++++++----- test/agent/context/skills/ownership.rb | 74 +++++++++++++++ test/agent/context/skills/registry.rb | 80 ----------------- test/fixtures/skill-ownership-index.json | 15 ---- test/fixtures/skill.json | 5 ++ 19 files changed, 272 insertions(+), 269 deletions(-) create mode 100644 context/skill.schema.json create mode 100644 lib/agent/context/skills/ownership.rb delete mode 100644 lib/agent/context/skills/registry.rb create mode 100644 test/agent/context/skills/ownership.rb delete mode 100644 test/agent/context/skills/registry.rb delete mode 100644 test/fixtures/skill-ownership-index.json create mode 100644 test/fixtures/skill.json diff --git a/context/getting-started.md b/context/getting-started.md index 4c7ed0d..2a23191 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -189,7 +189,7 @@ Include the complete source directories in your gem's packaged files. Skill docu ## Ownership and Updates -Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. ## Example Context Files diff --git a/context/skill.schema.json b/context/skill.schema.json new file mode 100644 index 0000000..8519287 --- /dev/null +++ b/context/skill.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Installed skill ownership", + "type": "object", + "required": [ + "ecosystem", + "package", + "version" + ], + "properties": { + "ecosystem": { + "type": "string", + "pattern": "\\S" + }, + "package": { + "type": "string", + "pattern": "\\S" + }, + "version": { + "type": "string", + "pattern": "\\S" + } + } +} diff --git a/context/usage.md b/context/usage.md index 79b2d1d..96db7e4 100644 --- a/context/usage.md +++ b/context/usage.md @@ -43,9 +43,9 @@ Skill names are prefixed by the provider gem. `skill:install` refreshes all depe The provider's tracked `context/` directory is source content. Installed dependency copies under `.agents/context/` and `.agents/skills/` are generated; edit provider sources and reinstall to update them. Keep repository-only instructions and project-owned skills in the consuming repository. -Installation maintains local Git exclusions for generated context, the skill ownership index, and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. +Installation maintains local Git exclusions for generated context and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. -The `.agents/skills/.agent-context-skills.json` file records installed ownership. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale entries. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` recording its provider ecosystem, package, and version. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale skills. Skill updates use staged replacements with rollback on failure. ## Publish Context and Skills @@ -64,6 +64,6 @@ description: Set up a project using this gem's conventions. Follow the setup instructions. ``` -The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and includes additional metadata. The top-level `SKILL.md` is reserved for the generated instructions. +The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and includes additional metadata. Top-level `SKILL.md` and `skill.json` are reserved for generated instructions and ownership. Include `context/**/*` in the gemspec's packaged files. Installed skill names must use lowercase ASCII letters, digits, and single hyphens, with at most 64 characters including the package prefix. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 4c7ed0d..2a23191 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -189,7 +189,7 @@ Include the complete source directories in your gem's packaged files. Skill docu ## Ownership and Updates -Skills use `.agents/skills/.agent-context-skills.json` to record dependency ownership. Full Ruby refreshes reconcile gem-owned skills and remove stale entries. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. ## Example Context Files diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index b503995..a8fdd88 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -10,7 +10,7 @@ require "uri" require "tempfile" require "rubygems" -require "agent/context/skills/registry" +require "agent/context/skills/ownership" require "agent/context/skills/exclusion" require_relative "paths" require_relative "document" @@ -40,8 +40,8 @@ def update_index File.rename(file.path, File.join(@context_path, "index.md")) end root = File.dirname(File.dirname(@context_path)) - registry = Skills::Registry.new(File.join(root, ".agents", "skills", Skills::Registry::FILE_NAME)) - Skills::Exclusion.new(root, registry.owners.keys).apply + owners = Skills::Ownership.scan(File.join(root, ".agents", "skills")) + Skills::Exclusion.new(root, owners.keys).apply end # Render links relative to the generated index, preserving custom provider metadata. diff --git a/lib/agent/context/skills.rb b/lib/agent/context/skills.rb index 9a027a4..be6df65 100644 --- a/lib/agent/context/skills.rb +++ b/lib/agent/context/skills.rb @@ -4,7 +4,7 @@ # Copyright, 2026, by Samuel Williams. require_relative "skills/definition" -require_relative "skills/registry" +require_relative "skills/ownership" require_relative "skills/installer" module Agent diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index 521e316..7f4ca46 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -5,6 +5,7 @@ # Copyright, 2026, by Samuel Williams. require "fileutils" +require_relative "ownership" module Agent module Context @@ -53,6 +54,7 @@ def write_to(destination) FileUtils.mkdir_p(destination) copy_resources(@path, destination, true) if @path File.write(File.join(destination, "SKILL.md"), @document) + Ownership.write(destination, @provider_name, @provider_version) end private @@ -65,8 +67,8 @@ def copy_resources(source, destination, top_level) if metadata.symlink? || (!metadata.file? && !metadata.directory?) raise Installer::InvalidSkill, "Skill resources must be regular files or directories: #{from}" end - if top_level && name.downcase == "skill.md" - raise Installer::InvalidSkill, "SKILL.md is reserved for generated instructions: #{from}" + if top_level && ["skill.md", Ownership::FILE_NAME].include?(name.downcase) + raise Installer::InvalidSkill, "#{name} is reserved for generated skill files: #{from}" end if metadata.directory? FileUtils.mkdir_p(to) diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb index cf4b93a..ff8f075 100644 --- a/lib/agent/context/skills/exclusion.rb +++ b/lib/agent/context/skills/exclusion.rb @@ -6,6 +6,7 @@ require "open3" require "fileutils" require "tempfile" +require_relative "ownership" module Agent module Context @@ -28,7 +29,7 @@ def initialize(root, names) 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}" + raise Ownership::Invalid, "Malformed Agent Context exclusion block: #{@path}" end lines.slice!(begins.first..ends.first) end @@ -38,7 +39,6 @@ def initialize(root, names) BEGIN_MARKER, "# Generated by Bake Agent Context; do not edit this block manually.", "/.agents/context/", - "/.agents/skills/#{Registry::FILE_NAME}", *names.sort.map{|name| "/.agents/skills/#{name}/"}, END_MARKER, ]) diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index f1198b1..8b2a364 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -10,7 +10,7 @@ require "tmpdir" require_relative "definition" -require_relative "registry" +require_relative "ownership" require_relative "exclusion" require_relative "../document" @@ -19,7 +19,7 @@ module Context module Skills # Represents discovery and installation of skills from resolved gems. class Installer - NAME_PATTERN = Registry::NAME_PATTERN + NAME_PATTERN = Ownership::NAME_PATTERN MAXIMUM_NAME_LENGTH = 64 MAXIMUM_DESCRIPTION_LENGTH = 1024 @@ -177,9 +177,9 @@ def install_definitions(definitions, package:, reconcile:) 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 - registry = Registry.new(File.join(@skills_path, Registry::FILE_NAME)) + owners = Ownership.scan(@skills_path) definitions.each do |definition| - owner = registry.owner(definition.name) + owner = owners[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"]}" @@ -189,10 +189,9 @@ def install_definitions(definitions, package:, reconcile:) end 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) + owned = owners.select{|_name, owner| owner["ecosystem"] == "gem" && (!package || owner["package"] == package)} + stale = reconcile ? owned.keys - names : [] + exclusion = Exclusion.new(@root, (owners.keys - stale + names).uniq) FileUtils.mkdir_p(@skills_path) Dir.mktmpdir(".agent-context-staging-", @skills_path) do |stage| staged = File.join(stage, "new") @@ -210,7 +209,6 @@ def install_definitions(definitions, package:, reconcile:) changes << [name, previous] File.rename(File.join(staged, name), destination) if names.include?(name) end - registry.save rescue changes.reverse_each do |name, previous| destination = File.join(@skills_path, name) diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb new file mode 100644 index 0000000..dc19016 --- /dev/null +++ b/lib/agent/context/skills/ownership.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. + +require "json" + +module Agent + module Context + module Skills + # Represents installation ownership recorded in each skill directory. + class Ownership + FILE_NAME = "skill.json" + NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ + + # Raised when installed ownership cannot be safely interpreted. + class Invalid < StandardError + end + + # Write ownership into a staged skill directory. + # @parameter directory [String] The staged skill directory. + # @parameter package [String] The provider package. + # @parameter version [String] The provider version. + # @parameter ecosystem [String] The provider ecosystem. + def self.write(directory, package, version, ecosystem: "gem") + owner = {"ecosystem" => ecosystem, "package" => package, "version" => version} + File.write(File.join(directory, FILE_NAME), JSON.pretty_generate(owner) + "\n") + end + + # Read a skill's installation ownership. + # @parameter directory [String] The installed skill directory. + # @returns [Hash | Nil] Ownership when the skill has an installation record. + def self.load(directory) + path = File.join(directory, FILE_NAME) + return unless File.exist?(path) || File.symlink?(path) + raise Invalid, "Skill ownership must be a regular file: #{path}" unless File.lstat(path).file? + owner = JSON.parse(File.read(path)) + unless owner.is_a?(Hash) && owner.values_at("ecosystem", "package", "version").all?{|value| value.is_a?(String) && !value.strip.empty?} + raise Invalid, "Invalid skill ownership: #{path}" + end + owner + rescue JSON::ParserError => error + raise Invalid, "Invalid skill ownership #{path}: #{error.message}" + end + + # Collect ownership from installed skill directories. + # @parameter directory [String] The installed skills root. + # @returns [Hash(String, Hash)] Skill names and their installation records. + def self.scan(directory) + return {} unless File.exist?(directory) || File.symlink?(directory) + raise Invalid, "Skill installation path must be a regular directory: #{directory}" unless File.lstat(directory).directory? + Dir.children(directory).sort.each_with_object({}) do |name, owners| + path = File.join(directory, name) + next unless File.lstat(path).directory? + next unless owner = load(path) + unless name.match?(NAME_PATTERN) && name.length <= 64 + raise Invalid, "Invalid installed skill name: #{name.inspect}" + end + owners[name] = owner + end + end + end + end + end +end diff --git a/lib/agent/context/skills/registry.rb b/lib/agent/context/skills/registry.rb deleted file mode 100644 index c625cbc..0000000 --- a/lib/agent/context/skills/registry.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Shopify Inc. -# Copyright, 2026, by Samuel Williams. - -require "fileutils" -require "json" -require "tempfile" - -module Agent - module Context - module Skills - # Represents the shared ownership index for dependency-installed skills. - class Registry - FILE_NAME = ".agent-context-skills.json" - FORMAT_VERSION = 2 - NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ - - # Raised when ownership cannot be safely interpreted. - class Invalid < StandardError - end - - # Load shared skill ownership. - # @parameter path [String] The shared ownership index. - def initialize(path) - @path = path - @data = load_data - end - - # @attribute [String] The path of the shared ownership index. - attr_reader :path - - # @attribute [Hash] The recorded skill owners. - def owners - @data["skills"] - 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) - 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} - end - - # Release skill ownership. - # @parameter name [String] The installed skill name. - def release(name) - owners.delete(name) - end - - # Persist shared ownership using an atomic rename. - 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 - 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"]) - data - rescue JSON::ParserError => error - raise Invalid, "Invalid skill ownership index #{@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 - end - end - end - end -end diff --git a/readme.md b/readme.md index fc55784..a0a06ff 100644 --- a/readme.md +++ b/readme.md @@ -168,7 +168,7 @@ Skills are discovered through `type: skill` metadata in `context/*.md`, matching ## Ownership and Updates -Skills use the shared version-two JSON ownership index at `.agents/skills/.agent-context-skills.json`. Owners record ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. +Each installed skill directory contains `skill.json` with its provider ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. Version-one Cargo JSON ownership migrates to the shared format. diff --git a/specification.md b/specification.md index 385a2a4..8c15828 100644 --- a/specification.md +++ b/specification.md @@ -152,7 +152,7 @@ A context provider may distribute an Agent Skill as a Markdown document directly 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. +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. ## 5. Discovery and Installation @@ -203,27 +203,28 @@ Ordinary documents need no front matter. Consumers SHOULD use a document's first 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 +## 7. Skill Installation Ownership -Consumers MUST record dependency-installed skill ownership in `.agents/skills/.agent-context-skills.json`: +Consumers MUST write `skill.json` inside each dependency-installed skill directory, alongside `SKILL.md`: ```json { - "version": 2, - "skills": { - "provider-workflow": { - "ecosystem": "cargo", - "package": "provider", - "version": "1.0.0" - } - } + "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. +`SKILL.md` provides agent-facing metadata and instructions. `skill.json` records installation ownership. The shared JSON Schema is distributed as `context/skill.schema.json`. -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. +The ownership record MUST be a JSON object with non-empty string fields `ecosystem`, `package`, and `version`; whitespace-only values are invalid. Consumers MUST tolerate additional fields. Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. `package` is the provider's package name and `version` is the installed provider version. -Version-one JSON ownership has implicit Cargo ownership and MUST migrate to version two when rewritten. +Consumers MUST discover installed ownership by scanning direct child directories of `.agents/skills/` for `skill.json`. The skills root and managed skill directories MUST be regular directories. Symbolic links and non-directory children are skipped. Ownership files MUST be regular files. Managed directory names MUST satisfy the installed skill name rules in section 4.4. Malformed or unreadable ownership MUST fail the operation before installed skills are changed. A directory without `skill.json` is project-owned. -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. +A full refresh reconciles only the installer's ecosystem; a package refresh reconciles only that ecosystem and package; installing one named skill retains other owned skills. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Installers MUST retain skills owned by other ecosystems. + +Existing project-owned destinations and destinations owned by another ecosystem or package MUST NOT be replaced. Installers MUST validate selected sources and collisions, stage instructions, resources, and ownership together, and replace each skill directory by renaming the staged directory. They MUST restore previous directories, ownership, and Git exclusions if a replacement fails. + +Top-level resource names `SKILL.md` and `skill.json` are reserved, case-insensitively, for generated files. Consumers MUST reject provider resources with either name, including directories. Nested resource files with these names MAY be copied as opaque assets. + +Git integrations SHOULD maintain one marked exclusion block covering generated context and exact dependency-owned skill directories from all ecosystems. They MUST retain user-authored rules outside that block. The ownership file is excluded with its containing skill directory. diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index 3befbe3..d290e62 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -111,9 +111,9 @@ def write(path, content) system("git", "init", "--quiet", directory, exception: true) exclude = File.join(directory, ".git", "info", "exclude") File.write(exclude, "user-rule\n") - registry = Agent::Context::Skills::Registry.new(File.join(directory, ".agents", "skills", Agent::Context::Skills::Registry::FILE_NAME)) - registry.claim("cargo-workflow", "provider", "1.0", ecosystem: "cargo") - registry.save + skill = File.join(directory, ".agents", "skills", "cargo-workflow") + FileUtils.mkdir_p(skill) + Agent::Context::Skills::Ownership.write(skill, "provider", "1.0", ecosystem: "cargo") index.update_index first = File.read(exclude) index.update_index diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index e9f96d8..e30981a 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -96,12 +96,11 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File).to be(:exist?, File.join(destination, "SKILL.md")) end - it "records ownership in the registry" do + it "records ownership beside the installed instructions" do installer.install(gem: "fake-gem") - registry_path = File.join(consumer_root, ".agents", "skills", Agent::Context::Skills::Registry::FILE_NAME) - registry = Agent::Context::Skills::Registry.new(registry_path) + path = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") - expect(registry.owner("fake-gem-ruby-testing")).to be == { + expect(Agent::Context::Skills::Ownership.load(path)).to be == { "ecosystem" => "gem", "package" => "fake-gem", "version" => "1.0.0", @@ -204,31 +203,29 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end it "preserves Cargo skills during a full Ruby refresh" do - registry_path = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) - registry = Agent::Context::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") + Agent::Context::Skills::Ownership.write(File.dirname(cargo), "fake-gem", "2.0.0", ecosystem: "cargo") + metadata = File.read(File.join(File.dirname(cargo), "skill.json")) installer.install expect(File.read(cargo)).to be == "Cargo instructions" - expect(Agent::Context::Skills::Registry.new(registry_path).owner("cargo-workflow")["ecosystem"]).to be == "cargo" + expect(File.read(File.join(File.dirname(cargo), "skill.json"))).to be == metadata end it "refuses a matching name owned by Cargo even when the package names match" do - registry = Agent::Context::Skills::Registry.new(File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME)) - registry.claim("fake-gem-ruby-testing", "fake-gem", "1.0.0", ecosystem: "cargo") - registry.save + path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") + FileUtils.mkdir_p(path) + Agent::Context::Skills::Ownership.write(path, "fake-gem", "1.0.0", ecosystem: "cargo") expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::Conflict) end - it "preserves the previous skills and registry when resource copying fails" do + it "preserves the installed skill and ownership when resource copying fails" do installer.install - registry_path = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) - previous = File.read(registry_path) + ownership_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/skill.json") + previous = File.read(ownership_path) destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") instructions = File.read(destination) write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") @@ -237,7 +234,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) - expect(File.read(registry_path)).to be == previous + expect(File.read(ownership_path)).to be == previous expect(File.read(destination)).to be == instructions end @@ -288,28 +285,31 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(content).not.to be(:include?, " é") end - it "restores skills, ownership, and exclusions when the registry commit fails" do + it "restores skills, ownership, and exclusions when directory replacement fails" do system("git", "init", "--quiet", consumer_root, exception: true) write_skill(provider_root, "documentation", description: "Write docs.") installer.install - registry = File.join(consumer_root, ".agents/skills", Agent::Context::Skills::Registry::FILE_NAME) + skill_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") + ownership_path = File.join(skill_path, "skill.json") exclude = File.join(consumer_root, ".git/info/exclude") - previous_registry = File.read(registry) + previous_ownership = File.read(ownership_path) previous_exclude = File.read(exclude) destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") previous_instructions = File.read(destination) write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") File.delete(File.join(provider_root, "context/documentation.md")) - mock(File).before(:rename) do |_source, target| - raise Errno::EACCES, "Injected registry failure" if target == registry + write_skill(provider_root, "aardvark", description: "New workflow.") + mock(File).before(:rename) do |source, target| + raise Errno::EACCES, "Injected replacement failure" if target == skill_path && source.include?("/new/") end expect do installer.install end.to raise_exception(Errno::EACCES) - expect(File.read(registry)).to be == previous_registry + expect(File.read(ownership_path)).to be == previous_ownership expect(File.read(exclude)).to be == previous_exclude expect(File.read(destination)).to be == previous_instructions expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-documentation/SKILL.md")) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-aardvark")) end it "keeps plain guides out of skills and rejects invalid context skill metadata" do @@ -342,6 +342,44 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) end + it "reserves ownership resource names for files and directories" do + installer.install + resources = File.join(provider_root, "context/workflow") + FileUtils.mkdir_p(resources) + write_skill(provider_root, "workflow", description: "Run workflow.") + ["skill.json", "SKILL.JSON", "SKILL.md"].each do |name| + path = File.join(resources, name) + [false, true].each do |directory| + directory ? FileUtils.mkdir_p(path) : File.write(path, "Conflict") + expect{installer.install}.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow")) + FileUtils.rm_rf(path) + end + end + FileUtils.mkdir_p(File.join(resources, "references")) + File.write(File.join(resources, "references/skill.json"), "Opaque resource") + installer.install + expect(File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/references/skill.json"))).to be == "Opaque resource" + end + + it "retains installed files and exclusions if staged ownership cannot be written" do + system("git", "init", "--quiet", consumer_root, exception: true) + installer.install + skill_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") + previous = Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]} + exclude = File.join(consumer_root, ".git/info/exclude") + previous_exclude = File.read(exclude) + write_skill(provider_root, "ruby-testing", description: "Updated workflow.") + mock(File).before(:write) do |path, *| + raise Errno::EACCES, "Injected ownership write failure" if path.end_with?("/skill.json") && path.include?("/new/") + end + expect{installer.install}.to raise_exception(Errno::EACCES) + expect(Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]}).to be == previous + expect(File.read(exclude)).to be == previous_exclude + ensure + mock(File).clear + end + it "works without Git and refuses malformed generated exclusion blocks" do mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} expect(installer.install).to be == ["fake-gem-ruby-testing"] @@ -350,6 +388,6 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these File.write(File.join(consumer_root, ".git/info/exclude"), "# BEGIN bake-agent-context\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Registry::Invalid) + end.to raise_exception(Agent::Context::Skills::Ownership::Invalid) end end diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb new file mode 100644 index 0000000..0ad8dab --- /dev/null +++ b/test/agent/context/skills/ownership.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require "agent/context/skills/ownership" +require "fileutils" +require "tmpdir" + +describe Agent::Context::Skills::Ownership do + let(:directory) {Dir.mktmpdir} + let(:skill_path) {File.join(directory, "provider-workflow")} + + def around + FileUtils.mkdir_p(skill_path) + yield + ensure + FileUtils.rm_rf(directory) + end + + it "reads and writes the portable ownership fixture" do + fixture = File.expand_path("../../../fixtures/skill.json", __dir__) + FileUtils.copy_file(fixture, File.join(skill_path, "skill.json")) + expected = JSON.parse(File.read(fixture)) + expect(subject.load(skill_path)).to be == expected + subject.write(skill_path, expected["package"], expected["version"], ecosystem: expected["ecosystem"]) + expect(JSON.parse(File.read(File.join(skill_path, "skill.json")))).to be == expected + end + + it "scans owned directories alongside project skills and other files" do + subject.write(skill_path, "provider", "1.0.0") + FileUtils.mkdir_p(File.join(directory, "project-workflow")) + File.write(File.join(directory, "readme.md"), "Project skills.") + File.symlink(skill_path, File.join(directory, "linked-workflow")) + expect(subject.scan(directory)).to be == {"provider-workflow" => subject.load(skill_path)} + expect(subject.scan(File.join(directory, "missing"))).to be == {} + end + + it "validates ownership before treating an installation as managed" do + [ + "{invalid json}", + [].to_json, + {"ecosystem" => "gem", "package" => " ", "version" => "1.0.0"}.to_json, + ].each do |content| + File.write(File.join(skill_path, "skill.json"), content) + expect do + subject.scan(directory) + end.to raise_exception(subject::Invalid) + end + end + + it "requires regular ownership files and skill directories" do + File.symlink("missing.json", File.join(skill_path, "skill.json")) + expect do + subject.load(skill_path) + end.to raise_exception(subject::Invalid) + File.write(File.join(directory, "blocker"), "Not a directory.") + expect do + subject.scan(File.join(directory, "blocker")) + end.to raise_exception(subject::Invalid) + end + + it "validates the installed directory name" do + ["Bad_Name", "a" * 65].each do |name| + path = File.join(directory, name) + FileUtils.mkdir_p(path) + subject.write(path, "provider", "1.0.0") + expect do + subject.scan(directory) + end.to raise_exception(subject::Invalid) + FileUtils.rm_rf(path) + end + end +end diff --git a/test/agent/context/skills/registry.rb b/test/agent/context/skills/registry.rb deleted file mode 100644 index a1294ed..0000000 --- a/test/agent/context/skills/registry.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Shopify Inc. - -require "agent/context/skills/registry" -require "tmpdir" - -describe Agent::Context::Skills::Registry do - let(:temporary_directory) {Dir.mktmpdir} - let(:registry_path) {File.join(temporary_directory, Agent::Context::Skills::Registry::FILE_NAME)} - - def around - yield - ensure - FileUtils.rm_rf(temporary_directory) - end - - it "records and reloads skill ownership" do - registry = subject.new(registry_path) - registry.claim("ruby-testing", "sus", "1.0.0") - registry.save - - reloaded = subject.new(registry_path) - expect(reloaded.owner("ruby-testing")).to be == { - "ecosystem" => "gem", - "package" => "sus", - "version" => "1.0.0", - } - end - - it "lists skills owned by a gem" do - registry = subject.new(registry_path) - registry.claim("ruby-testing", "sus", "1.0.0") - registry.claim("http-server", "falcon", "2.0.0") - - expect(registry.skills_for("sus").keys).to be == ["ruby-testing"] - end - - it "releases ownership" do - registry = subject.new(registry_path) - registry.claim("ruby-testing", "sus", "1.0.0") - registry.release("ruby-testing") - - expect(registry.owner("ruby-testing")).to be_nil - end - - it "rejects an invalid registry" do - File.write(registry_path, {"version" => 99, "skills" => {}}.to_json) - - expect do - subject.new(registry_path) - end.to raise_exception(Agent::Context::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 -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" - } - } -} diff --git a/test/fixtures/skill.json b/test/fixtures/skill.json new file mode 100644 index 0000000..13c198c --- /dev/null +++ b/test/fixtures/skill.json @@ -0,0 +1,5 @@ +{ + "ecosystem": "gem", + "package": "provider", + "version": "1.0.0" +} From 07f4efe768db908aa8864af90e0235fcbe3fa3a1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 10 Oct 2026 06:41:46 +0530 Subject: [PATCH 12/37] Fix context resources, ownership checks, and nested Git exclusions --- lib/agent/context/installer.rb | 2 +- lib/agent/context/skills/exclusion.rb | 20 ++++++++++++-------- lib/agent/context/skills/ownership.rb | 16 ++++++++++++---- readme.md | 2 -- specification.md | 2 +- test/agent/context/index.rb | 25 +++++++++++++++++++++++++ test/agent/context/installer.rb | 12 ++++++++++++ test/agent/context/skills/installer.rb | 18 ++++++++++++++++++ test/agent/context/skills/ownership.rb | 10 ++++++++++ test/agent/context/tasks.rb | 1 + 10 files changed, 92 insertions(+), 16 deletions(-) diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 1deca41..1f51a42 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -84,7 +84,7 @@ def list_context_files(gem_name) return nil unless gem skill_paths = Array(@skills.list_skills(gem_name)).flat_map{|skill| [skill.source_file, skill.path].compact} - Dir.glob(File.join(gem[:path], "**/*")).select do |file| + Dir.glob(File.join(gem[:path], "**/*"), File::FNM_DOTMATCH).select do |file| File.file?(file) && !File.symlink?(file) && !skill_paths.any?{|path| file == path || file.start_with?("#{path}/")} end end diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb index ff8f075..3fe5664 100644 --- a/lib/agent/context/skills/exclusion.rb +++ b/lib/agent/context/skills/exclusion.rb @@ -20,13 +20,17 @@ class Exclusion # @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) + output, status = Open3.capture2e("git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", chdir: root) return unless status.success? - @path = File.expand_path(output.strip, root) + prefix, path = output.lines(chomp: true) + @path = File.expand_path(path, root) + begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip + end_marker = "#{END_MARKER} #{prefix}".rstrip + pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} @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} + 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 Ownership::Invalid, "Malformed Agent Context exclusion block: #{@path}" @@ -36,11 +40,11 @@ def initialize(root, names) lines.pop while lines.last == "" lines << "" unless lines.empty? lines.concat([ - BEGIN_MARKER, + begin_marker, "# Generated by Bake Agent Context; do not edit this block manually.", - "/.agents/context/", - *names.sort.map{|name| "/.agents/skills/#{name}/"}, - END_MARKER, + "/#{pattern}.agents/context/", + *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"}, + end_marker, ]) @updated = lines.join("\n") + "\n" rescue Errno::ENOENT diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb index dc19016..c6bcdad 100644 --- a/lib/agent/context/skills/ownership.rb +++ b/lib/agent/context/skills/ownership.rb @@ -33,8 +33,12 @@ def self.write(directory, package, version, ecosystem: "gem") # @returns [Hash | Nil] Ownership when the skill has an installation record. def self.load(directory) path = File.join(directory, FILE_NAME) - return unless File.exist?(path) || File.symlink?(path) - raise Invalid, "Skill ownership must be a regular file: #{path}" unless File.lstat(path).file? + begin + stat = File.lstat(path) + rescue Errno::ENOENT + return + end + raise Invalid, "Skill ownership must be a regular file: #{path}" unless stat.file? owner = JSON.parse(File.read(path)) unless owner.is_a?(Hash) && owner.values_at("ecosystem", "package", "version").all?{|value| value.is_a?(String) && !value.strip.empty?} raise Invalid, "Invalid skill ownership: #{path}" @@ -48,8 +52,12 @@ def self.load(directory) # @parameter directory [String] The installed skills root. # @returns [Hash(String, Hash)] Skill names and their installation records. def self.scan(directory) - return {} unless File.exist?(directory) || File.symlink?(directory) - raise Invalid, "Skill installation path must be a regular directory: #{directory}" unless File.lstat(directory).directory? + begin + stat = File.lstat(directory) + rescue Errno::ENOENT + return {} + end + raise Invalid, "Skill installation path must be a regular directory: #{directory}" unless stat.directory? Dir.children(directory).sort.each_with_object({}) do |name, owners| path = File.join(directory, name) next unless File.lstat(path).directory? diff --git a/readme.md b/readme.md index a0a06ff..9a136a3 100644 --- a/readme.md +++ b/readme.md @@ -170,8 +170,6 @@ Skills are discovered through `type: skill` metadata in `context/*.md`, matching Each installed skill directory contains `skill.json` with its provider ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. -Version-one Cargo JSON ownership migrates to the shared format. - See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). ## Releases diff --git a/specification.md b/specification.md index 8c15828..3759a61 100644 --- a/specification.md +++ b/specification.md @@ -227,4 +227,4 @@ Existing project-owned destinations and destinations owned by another ecosystem Top-level resource names `SKILL.md` and `skill.json` are reserved, case-insensitively, for generated files. Consumers MUST reject provider resources with either name, including directories. Nested resource files with these names MAY be copied as opaque assets. -Git integrations SHOULD maintain one marked exclusion block covering generated context and exact dependency-owned skill directories from all ecosystems. They MUST retain user-authored rules outside that block. The ownership file is excluded with its containing skill directory. +Git integrations SHOULD maintain one marked exclusion block per consuming project, covering generated context and exact dependency-owned skill directories from all ecosystems. Exclusion paths MUST be relative to the Git repository root. Nested projects SHOULD identify their block by appending the repository-relative project path, with a trailing slash, to the `# BEGIN bake-agent-context` and `# END bake-agent-context` markers. They MUST retain user-authored rules and blocks belonging to other projects. The ownership file is excluded with its containing skill directory. diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index d290e62..df77b3f 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -107,6 +107,31 @@ def write(path, content) expect(index.generate_index).to be(:include?, "Guide") end + it "scopes Git exclusions to each project in a repository" do + system("git", "init", "--quiet", directory, exception: true) + exclusion = Agent::Context::Skills::Exclusion + ["", "apps/web [dev]", "apps/worker"].each do |relative| + root = File.join(directory, relative) + FileUtils.mkdir_p(root) + exclusion.new(root, ["provider-old"]).apply + end + exclusion.new(File.join(directory, "apps/web [dev]"), ["provider-new"]).apply + exclusion.new(directory, ["provider-old"]).apply + + { + ".agents/skills/provider-old/SKILL.md" => true, + "apps/worker/.agents/skills/provider-old/SKILL.md" => true, + "apps/web [dev]/.agents/context/index.md" => true, + "apps/web [dev]/.agents/skills/provider-new/SKILL.md" => true, + "apps/web [dev]/.agents/skills/provider-old/SKILL.md" => false, + "apps/web [dev]/.agents/skills/project-local/SKILL.md" => false, + "apps/web d/.agents/skills/provider-new/SKILL.md" => false, + }.each do |path, ignored| + _, status = Open3.capture2e("git", "check-ignore", "--quiet", path, chdir: directory) + expect(status.success?).to be == ignored + end + end + it "maintains local exclusions without hiding project-owned instructions or skills" do system("git", "init", "--quiet", directory, exception: true) exclude = File.join(directory, ".git", "info", "exclude") diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 7c9005d..0a2765d 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -125,6 +125,18 @@ def around expect(result).to be_falsey end + it "installs dotfiles and files inside hidden context directories" do + File.write(File.join(context_path, ".env.example"), "SETTING=value\n") + FileUtils.mkdir_p(File.join(context_path, ".config")) + File.write(File.join(context_path, ".config/settings.yaml"), "setting: value\n") + helper = subject.new(root: @target_path, specifications: @specifications) + helper.install + + target = File.join(helper.context_path, "fake-gem") + expect(File.read(File.join(target, ".env.example"))).to be == "SETTING=value\n" + expect(File.read(File.join(target, ".config/settings.yaml"))).to be == "setting: value\n" + end + it "can install context from all gems" do helper = subject.new(root: @target_path, specifications: @specifications) diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index e30981a..13f8427 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -380,6 +380,24 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these mock(File).clear end + it "reports inaccessible ownership before changing installed files or exclusions" do + system("git", "init", "--quiet", consumer_root, exception: true) + installer.install + skill_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") + previous = Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]} + exclude = File.join(consumer_root, ".git/info/exclude") + previous_exclude = File.read(exclude) + write_skill(provider_root, "ruby-testing", description: "Updated workflow.") + begin + File.chmod(0o400, skill_path) + expect{installer.install}.to raise_exception(Errno::EACCES) + ensure + File.chmod(0o700, skill_path) + end + expect(Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]}).to be == previous + expect(File.read(exclude)).to be == previous_exclude + end + it "works without Git and refuses malformed generated exclusion blocks" do mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} expect(installer.install).to be == ["fake-gem-ruby-testing"] diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb index 0ad8dab..8f5204b 100644 --- a/test/agent/context/skills/ownership.rb +++ b/test/agent/context/skills/ownership.rb @@ -40,6 +40,7 @@ def around [ "{invalid json}", [].to_json, + ["gem", "provider", "1.0.0"].to_json, {"ecosystem" => "gem", "package" => " ", "version" => "1.0.0"}.to_json, ].each do |content| File.write(File.join(skill_path, "skill.json"), content) @@ -49,6 +50,15 @@ def around end end + it "reports inaccessible ownership files and installation roots" do + subject.write(skill_path, "provider", "1.0.0") + File.chmod(0o400, skill_path) + expect{subject.load(skill_path)}.to raise_exception(Errno::EACCES) + expect{subject.scan(File.join(skill_path, "skills"))}.to raise_exception(Errno::EACCES) + ensure + File.chmod(0o700, skill_path) + end + it "requires regular ownership files and skill directories" do File.symlink("missing.json", File.join(skill_path, "skill.json")) expect do diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index 146ba76..cc03d1e 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -50,6 +50,7 @@ end.to raise_exception(ArgumentError) context.call("agent:context:skill:install", "--gem", "provider", "--skill", "provider-workflow") context.call("agent:context:install", "--gem", "provider") + context.call("agent:context:index") expect(output.join("\n")).to be(:include?, "Provider Guide") expect(output.join("\n")).to be(:include?, "provider-workflow (provider@1.0.0)") expect(output.join("\n")).to be(:include?, "Installed skills: provider-workflow") From 3d9fd3917af7905641d48e29497ff7f7e0bd7e68 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 10 Oct 2026 17:57:52 +0530 Subject: [PATCH 13/37] Prepare Git exclusions explicitly before construction --- lib/agent/context/index.rb | 2 +- lib/agent/context/skills/exclusion.rb | 29 ++++++++++++++++++--------- lib/agent/context/skills/installer.rb | 6 +++--- test/agent/context/index.rb | 6 +++--- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index a8fdd88..fb12728 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -41,7 +41,7 @@ def update_index end root = File.dirname(File.dirname(@context_path)) owners = Skills::Ownership.scan(File.join(root, ".agents", "skills")) - Skills::Exclusion.new(root, owners.keys).apply + Skills::Exclusion.prepare(root, owners.keys)&.apply end # Render links relative to the generated index, preserving custom provider metadata. diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb index 3fe5664..3454b94 100644 --- a/lib/agent/context/skills/exclusion.rb +++ b/lib/agent/context/skills/exclusion.rb @@ -19,21 +19,22 @@ class Exclusion # 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) + # @returns [Exclusion | Nil] The prepared update when Git is available for this project. + def self.prepare(root, names) output, status = Open3.capture2e("git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", chdir: root) return unless status.success? prefix, path = output.lines(chomp: true) - @path = File.expand_path(path, root) + path = File.expand_path(path, root) begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip end_marker = "#{END_MARKER} #{prefix}".rstrip pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} - @original = File.exist?(@path) ? File.read(@path) : "" - lines = @original.lines(chomp: true) + 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 Ownership::Invalid, "Malformed Agent Context exclusion block: #{@path}" + raise Ownership::Invalid, "Malformed Agent Context exclusion block: #{path}" end lines.slice!(begins.first..ends.first) end @@ -46,20 +47,30 @@ def initialize(root, names) *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"}, end_marker, ]) - @updated = lines.join("\n") + "\n" + new(path, original, lines.join("\n") + "\n") rescue Errno::ENOENT # Installation also works when Git is unavailable: - @path = nil + nil + end + + # Initialize a prepared exclusion update. + # @parameter path [String] The Git exclusion file path. + # @parameter original [String] The original file contents. + # @parameter updated [String] The prepared file contents. + def initialize(path, original, updated) + @path = path + @original = original + @updated = updated end # Apply the prepared exclusion block. def apply - write(@updated) if @path && @updated != @original + write(@updated) if @updated != @original end # Restore exclusions if installation fails. def restore - write(@original) if @path && @updated != @original + write(@original) if @updated != @original end private diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index 8b2a364..f5b48b8 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -191,7 +191,7 @@ def install_definitions(definitions, package:, reconcile:) names = definitions.map(&:name) owned = owners.select{|_name, owner| owner["ecosystem"] == "gem" && (!package || owner["package"] == package)} stale = reconcile ? owned.keys - names : [] - exclusion = Exclusion.new(@root, (owners.keys - stale + names).uniq) + exclusion = Exclusion.prepare(@root, (owners.keys - stale + names).uniq) FileUtils.mkdir_p(@skills_path) Dir.mktmpdir(".agent-context-staging-", @skills_path) do |stage| staged = File.join(stage, "new") @@ -200,7 +200,7 @@ def install_definitions(definitions, package:, reconcile:) definitions.each{|definition| definition.write_to(File.join(staged, definition.name))} changes = [] begin - exclusion.apply + exclusion&.apply (names + stale).each do |name| destination = File.join(@skills_path, name) backup = File.join(backups, name) @@ -215,7 +215,7 @@ def install_definitions(definitions, package:, reconcile:) FileUtils.rm_rf(destination) File.rename(File.join(backups, name), destination) if previous end - exclusion.restore + exclusion&.restore raise end end diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb index df77b3f..be7a4e5 100644 --- a/test/agent/context/index.rb +++ b/test/agent/context/index.rb @@ -113,10 +113,10 @@ def write(path, content) ["", "apps/web [dev]", "apps/worker"].each do |relative| root = File.join(directory, relative) FileUtils.mkdir_p(root) - exclusion.new(root, ["provider-old"]).apply + exclusion.prepare(root, ["provider-old"]).apply end - exclusion.new(File.join(directory, "apps/web [dev]"), ["provider-new"]).apply - exclusion.new(directory, ["provider-old"]).apply + exclusion.prepare(File.join(directory, "apps/web [dev]"), ["provider-new"]).apply + exclusion.prepare(directory, ["provider-old"]).apply { ".agents/skills/provider-old/SKILL.md" => true, From 49c10b621ed1d3d5bf1174a0340f59debe73ff56 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 10 Oct 2026 18:02:11 +0530 Subject: [PATCH 14/37] Move document loading and gem enumeration out of constructors --- lib/agent/context/document.rb | 38 +++++++++++++++++++++------ lib/agent/context/index.rb | 7 ++--- lib/agent/context/installer.rb | 4 +-- lib/agent/context/skills/installer.rb | 4 +-- test/agent/context/document.rb | 12 ++++----- test/agent/context/skills.rb | 18 ++++++++++++- test/agent/context/tasks.rb | 2 +- 7 files changed, 62 insertions(+), 23 deletions(-) diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 5428183..48eefb1 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -17,19 +17,41 @@ class Document class Invalid < ArgumentError end - # Initialize a document from its source path. + # Read and parse a document from its source path. # @parameter path [String] The Markdown source file. - def initialize(path) - @path = path - @content = File.read(path) - @parts = Markly.parse(@content, flags: Markly::FRONT_MATTER).to_a - @front_matter = @parts.first if @parts.first&.type == :front_matter - @metadata = @front_matter ? YAML.safe_load(@front_matter.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) : {} - raise Invalid, "Context frontmatter must be a mapping: #{path}" unless @metadata.is_a?(Hash) + # @returns [Document] The parsed document. + def self.load(path) + parse(File.read(path), path: path) + end + + # Parse Markdown and validate its front matter. + # @parameter content [String] The Markdown source content. + # @parameter path [String] The source path used for titles and error messages. + # @returns [Document] The parsed document. + def self.parse(content, path:) + parts = Markly.parse(content, flags: Markly::FRONT_MATTER).to_a + front_matter = parts.first if parts.first&.type == :front_matter + metadata = front_matter ? YAML.safe_load(front_matter.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) : {} + raise Invalid, "Context frontmatter must be a mapping: #{path}" unless metadata.is_a?(Hash) + new(path, content, parts, front_matter, metadata) rescue Psych::Exception => error raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" end + # Initialize a document from parsed Markdown and metadata. + # @parameter path [String] The Markdown source path. + # @parameter content [String] The original Markdown source content. + # @parameter parts [Array(Markly::Node)] The top-level Markdown nodes. + # @parameter front_matter [Markly::Node | Nil] The leading front matter node. + # @parameter metadata [Hash] The decoded front matter. + def initialize(path, content, parts, front_matter, metadata) + @path = path + @content = content + @parts = parts + @front_matter = front_matter + @metadata = metadata + end + # @attribute [Hash] Decoded context metadata. attr_reader :metadata diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index fb12728..c8575de 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -24,7 +24,7 @@ class Index # @parameter specifications [Enumerable] The resolved gems supplying package descriptions. def initialize(context_path = CONTEXT_PATH, specifications: ::Gem::Specification) @context_path = File.expand_path(context_path) - @package_descriptions = specifications.to_a.to_h{|specification| [specification.name, specification.summary]} + @specifications = specifications end # @attribute [String] The installed context directory. @@ -47,6 +47,7 @@ def update_index # Render links relative to the generated index, preserving custom provider metadata. # @returns [String] The generated Markdown index. def generate_index + package_descriptions = @specifications.to_h{|specification| [specification.name, specification.summary]} sections = [ "# Context Index", "", "This index links to guidance installed from resolved gem dependencies. It is generated by `bake agent:context:install` and can be refreshed with `bake agent:context:index`.", "", @@ -62,7 +63,7 @@ def generate_index files.sort_by{|path| [File.dirname(path) == directory ? 0 : 1, path]}.each do |path| relative = Pathname.new(path).relative_path_from(Pathname.new(directory)).to_s next if skill_roots.any?{|skill| relative.start_with?("#{skill}/")} - document = Document.new(path) + document = Document.load(path) if document.skill? skill_roots << relative.delete_suffix(File.extname(relative)) else @@ -72,7 +73,7 @@ def generate_index next if documents.empty? found = true metadata = load_gem_index(directory) - sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || @package_descriptions[name] || "Context files for #{name}"), ""]) + sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || package_descriptions[name] || "Context files for #{name}"), ""]) listed = [] Array(metadata["files"]).each do |entry| next unless entry.is_a?(Hash) && documents.key?(entry["path"]) && !listed.include?(entry["path"]) diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 1f51a42..243dcc0 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -28,7 +28,7 @@ class Installer def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @context_path = File.join(@root, CONTEXT_PATH) - @specifications = specifications.to_a + @specifications = specifications @skills = Agent::Context::Skills::Installer.new(root: @root, specifications: @specifications) end @@ -158,7 +158,7 @@ def install(gem: nil) # Read ordinary document metadata before modifying installed skills: providers.each do |provider| list_context_files(provider[:name]).each do |path| - Document.new(path).description if File.extname(path).downcase == ".md" + Document.load(path).description if File.extname(path).downcase == ".md" end end installed_skills = @skills.install(gem: gem) diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index f5b48b8..3eaa079 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -39,7 +39,7 @@ class Conflict < Error 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. @@ -151,7 +151,7 @@ def discover_context_skills(root, specification) end def parse_document(file) - Document.new(file) + Document.load(file) rescue Document::Invalid => error raise InvalidSkill, error.message end diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index 171f7f4..77f012a 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -10,7 +10,7 @@ describe Agent::Context::Document do let(:directory) {Dir.mktmpdir} let(:path) {File.join(directory, "guide.md")} - let(:document) {subject.new(path)} + let(:document) {subject.load(path)} def around yield @@ -35,12 +35,12 @@ def around expect(File.binread(path)).to be == content end - it "keeps later thematic breaks and code examples in the body" do + it "parses in-memory Markdown with thematic breaks and code examples" do content = "# Guide\n\n---\n\n~~~yaml\n---\ntype: skill\n---\n~~~\n" - File.write(path, content) - expect(document.metadata).to be == {} - expect(document.body).to be == content - expect(document).not.to be(:skill?) + parsed = subject.parse(content, path: path) + expect(parsed.metadata).to be == {} + expect(parsed.body).to be == content + expect(parsed).not.to be(:skill?) end it "accepts empty front matter" do diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index d1c830c..f8af31c 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -31,6 +31,22 @@ def around FileUtils.rm_rf(directory) end + it "enumerates gem specifications during discovery and index generation" do + enumerations = 0 + specifications = Enumerator.new do |yielder| + enumerations += 1 + yielder << specification + end + installer = subject.new(root: consumer, specifications: specifications) + skills = Agent::Context::Skills::Installer.new(root: consumer, specifications: specifications) + index = Agent::Context::Index.new(installer.context_path, specifications: specifications) + expect(enumerations).to be == 0 + expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["provider"] + expect(skills.find_gems_with_skills.map{|gem| gem[:name]}).to be == ["provider"] + installer.install + expect(index.generate_index).to be(:include?, "Provider guidance.") + end + it "installs context and skills without duplicating skill documents or assets" do agents = File.join(consumer, "agents.md") File.write(agents, "Repository-owned instructions") @@ -71,7 +87,7 @@ def around expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == guide instructions = File.binread(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) expect(instructions).to be(:end_with?, body) - metadata = Agent::Context::Document.new(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")).metadata + metadata = Agent::Context::Document.load(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")).metadata expect(metadata["name"]).to be == "provider-workflow" expect(metadata).not.to be(:key?, "type") expect(metadata["date"]).to be == Date.new(2026, 10, 9) diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index cc03d1e..6b810f9 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -33,7 +33,7 @@ spec.summary = "Provider guidance." end specification.instance_variable_set(:@full_gem_path, provider) - mock(Gem::Specification).replace(:to_a){[specification]} + mock(Gem::Specification).replace(:each){|&block| [specification].each(&block)} output = [] mock($stdout).replace(:puts){|*messages| output.concat(messages)} context = Bake::Context.load(root) From d30ec167cf2ecefedbaf883009e9e07db66680e9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 10 Oct 2026 18:24:16 +0530 Subject: [PATCH 15/37] Remove the optional skill ownership JSON Schema --- context/skill.schema.json | 24 ------------------------ specification.md | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 context/skill.schema.json diff --git a/context/skill.schema.json b/context/skill.schema.json deleted file mode 100644 index 8519287..0000000 --- a/context/skill.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Installed skill ownership", - "type": "object", - "required": [ - "ecosystem", - "package", - "version" - ], - "properties": { - "ecosystem": { - "type": "string", - "pattern": "\\S" - }, - "package": { - "type": "string", - "pattern": "\\S" - }, - "version": { - "type": "string", - "pattern": "\\S" - } - } -} diff --git a/specification.md b/specification.md index 3759a61..92ef728 100644 --- a/specification.md +++ b/specification.md @@ -215,7 +215,7 @@ Consumers MUST write `skill.json` inside each dependency-installed skill directo } ``` -`SKILL.md` provides agent-facing metadata and instructions. `skill.json` records installation ownership. The shared JSON Schema is distributed as `context/skill.schema.json`. +`SKILL.md` provides agent-facing metadata and instructions. `skill.json` records installation ownership. The ownership record MUST be a JSON object with non-empty string fields `ecosystem`, `package`, and `version`; whitespace-only values are invalid. Consumers MUST tolerate additional fields. Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. `package` is the provider's package name and `version` is the installed provider version. From 65679f4876e865171a70568e38a2ab462d724af6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 10 Oct 2026 18:29:35 +0530 Subject: [PATCH 16/37] Clarify context discovery and installation flow --- bake/agent/context/skill.rb | 20 ++- lib/agent/context/document.rb | 20 ++- lib/agent/context/index.rb | 107 ++++++++++---- lib/agent/context/installer.rb | 26 +++- lib/agent/context/skills/definition.rb | 15 +- lib/agent/context/skills/exclusion.rb | 19 ++- lib/agent/context/skills/installer.rb | 189 ++++++++++++++++++------- lib/agent/context/skills/ownership.rb | 13 +- test/agent/context/skills/installer.rb | 45 ++++++ 9 files changed, 356 insertions(+), 98 deletions(-) diff --git a/bake/agent/context/skill.rb b/bake/agent/context/skill.rb index 265438f..2a54668 100644 --- a/bake/agent/context/skill.rb +++ b/bake/agent/context/skill.rb @@ -9,8 +9,16 @@ # @parameter gem [String | Nil] An optional provider gem. def list(gem: nil) installer = Agent::Context::Skills::Installer.new(root: context.root) - skills = gem ? Array(installer.list_skills(gem)) : installer.find_gems_with_skills.flat_map{|provider| provider[:skills]} - skills.each{|skill| puts "#{skill.name} (#{skill.provider_name}@#{skill.provider_version}) — #{skill.description}"} + skills = if gem + Array(installer.list_skills(gem)) + else + installer.find_gems_with_skills.flat_map{|provider| provider[:skills]} + end + + skills.each do |skill| + puts "#{skill.name} (#{skill.provider_name}@#{skill.provider_version}) — #{skill.description}" + end + puts "No dependency skills found" if skills.empty? end @@ -18,8 +26,10 @@ def list(gem: nil) # @parameter gem [String] The provider gem. # @parameter skill [String] The installed skill name. def show(gem:, skill:) - content = Agent::Context::Skills::Installer.new(root: context.root).show_skill(gem, skill) + installer = Agent::Context::Skills::Installer.new(root: context.root) + content = installer.show_skill(gem, skill) raise ArgumentError, "No skill #{skill.inspect} in gem #{gem.inspect}" unless content + puts content end @@ -27,6 +37,8 @@ def show(gem:, skill:) # @parameter gem [String | Nil] An optional provider gem. # @parameter skill [String | Nil] An optional installed skill name. def install(gem: nil, skill: nil) - names = Agent::Context::Skills::Installer.new(root: context.root).install(gem: gem, skill: skill) + installer = Agent::Context::Skills::Installer.new(root: context.root) + names = installer.install(gem: gem, skill: skill) + puts names.empty? ? "No dependency skills were installed" : "Installed skills: #{names.join(", ")}" end diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 48eefb1..122dddc 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -31,8 +31,20 @@ def self.load(path) def self.parse(content, path:) parts = Markly.parse(content, flags: Markly::FRONT_MATTER).to_a front_matter = parts.first if parts.first&.type == :front_matter - metadata = front_matter ? YAML.safe_load(front_matter.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) : {} + + metadata = if front_matter + YAML.safe_load( + front_matter.string_content, + permitted_classes: [Date, Time], + aliases: false, + fallback: {}, + ) + else + {} + end + raise Invalid, "Context frontmatter must be a mapping: #{path}" unless metadata.is_a?(Hash) + new(path, content, parts, front_matter, metadata) rescue Psych::Exception => error raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" @@ -57,7 +69,9 @@ def initialize(path, content, parts, front_matter, metadata) # @returns [String] The original Markdown body after the front matter node. def body - @front_matter ? @content.lines.drop(@front_matter.source_position[:end_line]).join : @content + return @content unless @front_matter + + @content.lines.drop(@front_matter.source_position[:end_line]).join end # @returns [String] The first heading or a filename-derived title. @@ -71,8 +85,10 @@ def description explicit = @metadata["description"] raise ArgumentError, "Context description must be a string: #{@path}" if explicit && !explicit.is_a?(String) return explicit.strip if explicit && !explicit.strip.empty? + paragraph = @parts.find{|part| part.type == :paragraph} return unless paragraph + plain_text(paragraph).split(/(?<=[.!?])\s+/, 2).first end diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb index c8575de..6a49785 100644 --- a/lib/agent/context/index.rb +++ b/lib/agent/context/index.rb @@ -33,12 +33,14 @@ def initialize(context_path = CONTEXT_PATH, specifications: ::Gem::Specification # Write the generated index and update local Git exclusions. def update_index content = generate_index + FileUtils.mkdir_p(@context_path) Tempfile.create(".agent-context-index-", @context_path) do |file| file.write(content) file.flush File.rename(file.path, File.join(@context_path, "index.md")) end + root = File.dirname(File.dirname(@context_path)) owners = Skills::Ownership.scan(File.join(root, ".agents", "skills")) Skills::Exclusion.prepare(root, owners.keys)&.apply @@ -47,43 +49,34 @@ def update_index # Render links relative to the generated index, preserving custom provider metadata. # @returns [String] The generated Markdown index. def generate_index - package_descriptions = @specifications.to_h{|specification| [specification.name, specification.summary]} + package_descriptions = @specifications.to_h do |specification| + [specification.name, specification.summary] + end + sections = [ - "# Context Index", "", - "This index links to guidance installed from resolved gem dependencies. It is generated by `bake agent:context:install` and can be refreshed with `bake agent:context:index`.", "", - "Before working on a package, read the relevant context files below. If these files are missing, run `bake agent:context:install`.", "", + "# Context Index", + "", + "This index links to guidance installed from resolved gem dependencies. " \ + "It is generated by `bake agent:context:install` and can be refreshed with `bake agent:context:index`.", + "", + "Before working on a package, read the relevant context files below. " \ + "If these files are missing, run `bake agent:context:install`.", + "", ] - providers = Dir.exist?(@context_path) ? Dir.children(@context_path).sort.select{|name| File.directory?(File.join(@context_path, name)) && !File.symlink?(File.join(@context_path, name))} : [] + found = false - providers.each do |name| + provider_names.each do |name| directory = File.join(@context_path, name) - files = Dir.glob(File.join(directory, "**", "*")).select{|path| File.file?(path) && !File.symlink?(path) && File.extname(path).downcase == ".md"} - documents = {} - skill_roots = [] - files.sort_by{|path| [File.dirname(path) == directory ? 0 : 1, path]}.each do |path| - relative = Pathname.new(path).relative_path_from(Pathname.new(directory)).to_s - next if skill_roots.any?{|skill| relative.start_with?("#{skill}/")} - document = Document.load(path) - if document.skill? - skill_roots << relative.delete_suffix(File.extname(relative)) - else - documents[relative] = document - end - end + documents = discover_documents(directory) next if documents.empty? - found = true + metadata = load_gem_index(directory) - sections.concat(["## #{escape(name)}", "", escape(metadata["description"] || package_descriptions[name] || "Context files for #{name}"), ""]) - listed = [] - Array(metadata["files"]).each do |entry| - next unless entry.is_a?(Hash) && documents.key?(entry["path"]) && !listed.include?(entry["path"]) - append_document(sections, name, entry["path"], documents.fetch(entry["path"]), entry) - listed << entry["path"] - end - (documents.keys - listed).sort_by{|path| Document.order(path)}.each do |path| - append_document(sections, name, path, documents.fetch(path)) - end + description = metadata["description"] || package_descriptions[name] || "Context files for #{name}" + sections.concat(["## #{escape(name)}", "", escape(description), ""]) + append_documents(sections, name, documents, metadata) + found = true end + sections << "No context files are installed." unless found sections.pop while sections.last == "" sections.join("\n") + "\n" @@ -91,6 +84,40 @@ def generate_index private + def provider_names + return [] unless Dir.exist?(@context_path) + + Dir.children(@context_path).sort.select do |name| + path = File.join(@context_path, name) + File.directory?(path) && !File.symlink?(path) + end + end + + def discover_documents(directory) + files = Dir.glob(File.join(directory, "**", "*")).select do |path| + File.file?(path) && !File.symlink?(path) && File.extname(path).downcase == ".md" + end + + # Read root-level skill declarations before excluding their resource directories: + files.sort_by!{|path| [File.dirname(path) == directory ? 0 : 1, path]} + + documents = {} + skill_roots = [] + files.each do |path| + relative = Pathname.new(path).relative_path_from(Pathname.new(directory)).to_s + next if skill_roots.any?{|skill| relative.start_with?("#{skill}/")} + + document = Document.load(path) + if document.skill? + skill_roots << relative.delete_suffix(File.extname(relative)) + else + documents[relative] = document + end + end + + documents + end + def load_gem_index(directory) path = File.join(directory, "index.yaml") metadata = File.file?(path) ? YAML.safe_load_file(path, aliases: false) : {} @@ -99,10 +126,30 @@ def load_gem_index(directory) {} end + def append_documents(sections, package, documents, metadata) + listed = [] + Array(metadata["files"]).each do |entry| + next unless entry.is_a?(Hash) + + path = entry["path"] + next unless documents.key?(path) + next if listed.include?(path) + + append_document(sections, package, path, documents.fetch(path), entry) + listed << path + end + + remaining = (documents.keys - listed).sort_by{|path| Document.order(path)} + remaining.each do |path| + append_document(sections, package, path, documents.fetch(path)) + end + end + def append_document(sections, package, path, document, metadata = {}) title = metadata["title"] || document.title description = metadata["description"] || document.description link = URI::DEFAULT_PARSER.escape("#{package}/#{path}", /[^a-zA-Z0-9\-._~\/]/) + sections.concat(["### [#{escape(title)}](#{link})", ""]) sections.concat([escape(description), ""]) if description && !description.empty? end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 243dcc0..2b10358 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -83,9 +83,14 @@ def list_context_files(gem_name) gem = find_gem_with_context(gem_name) return nil unless gem - skill_paths = Array(@skills.list_skills(gem_name)).flat_map{|skill| [skill.source_file, skill.path].compact} + skill_paths = Array(@skills.list_skills(gem_name)).flat_map do |skill| + [skill.source_file, skill.path].compact + end + Dir.glob(File.join(gem[:path], "**/*"), File::FNM_DOTMATCH).select do |file| - File.file?(file) && !File.symlink?(file) && !skill_paths.any?{|path| file == path || file.start_with?("#{path}/")} + next unless File.file?(file) && !File.symlink?(file) + + skill_paths.none?{|path| file == path || file.start_with?("#{path}/")} end end @@ -98,10 +103,13 @@ def show_context_file(gem_name, file_name) if requested.absolute? || requested.each_filename.any?{|part| part == ".."} raise ArgumentError, "Context file must be a relative path inside context/" end + candidates = [File.join(gem[:path], file_name)] candidates << "#{candidates.first}.md" if File.extname(file_name).empty? + available = list_context_files(gem_name) path = candidates.find{|candidate| available.include?(candidate)} + path ? File.read(path) : nil end @@ -112,19 +120,23 @@ def install_gem_context(gem_name) files = list_context_files(gem_name) target_path = File.join(@context_path, gem_name) + FileUtils.mkdir_p(@context_path) Dir.mktmpdir(".agent-context-staging-", @context_path) do |stage| fresh = File.join(stage, "new") backup = File.join(stage, "old") FileUtils.mkdir_p(fresh) + files.each do |source| relative = Pathname.new(source).relative_path_from(Pathname.new(gem[:path])).to_s destination = File.join(fresh, relative) FileUtils.mkdir_p(File.dirname(destination)) FileUtils.copy_file(source, destination, true) end + previous = File.exist?(target_path) || File.symlink?(target_path) File.rename(target_path, backup) if previous + begin File.rename(fresh, target_path) rescue @@ -155,15 +167,23 @@ def install_all_context(skip_local: true) # @returns [Hash(Symbol, Array(String))] The installed provider and skill names. def install(gem: nil) providers = gem ? [find_gem_with_context(gem)].compact : find_gems_with_context + # Read ordinary document metadata before modifying installed skills: providers.each do |provider| list_context_files(provider[:name]).each do |path| Document.load(path).description if File.extname(path).downcase == ".md" end end + installed_skills = @skills.install(gem: gem) - installed_context = gem ? (install_gem_context(gem) ? [gem] : []) : install_all_context + installed_context = if gem + install_gem_context(gem) ? [gem] : [] + else + install_all_context + end + Index.new(@context_path, specifications: @specifications).update_index + {context: installed_context, skills: installed_skills} end diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index 7f4ca46..a33e51d 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -20,7 +20,11 @@ class Definition # @parameter provider_version [String] The provider version. # @parameter source_file [String] The source context document. # @parameter document [String] Generated skill instructions. - def initialize(name:, description:, path:, provider_name:, provider_version:, source_file:, document:) + def initialize( + name:, description:, path:, + provider_name:, provider_version:, + source_file:, document: + ) @name = name @description = description @path = path @@ -32,14 +36,19 @@ def initialize(name:, description:, path:, provider_name:, provider_version:, so # @attribute [String] The installed skill name. attr_reader :name + # @attribute [String] When to use the skill. attr_reader :description + # @attribute [String | Nil] The companion 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] The source context document. attr_reader :source_file @@ -53,6 +62,7 @@ def content def write_to(destination) FileUtils.mkdir_p(destination) copy_resources(@path, destination, true) if @path + File.write(File.join(destination, "SKILL.md"), @document) Ownership.write(destination, @provider_name, @provider_version) end @@ -64,12 +74,15 @@ def copy_resources(source, destination, top_level) 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 top_level && ["skill.md", Ownership::FILE_NAME].include?(name.downcase) raise Installer::InvalidSkill, "#{name} is reserved for generated skill files: #{from}" end + if metadata.directory? FileUtils.mkdir_p(to) copy_resources(from, to, false) diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb index 3454b94..2b0f507 100644 --- a/lib/agent/context/skills/exclusion.rb +++ b/lib/agent/context/skills/exclusion.rb @@ -21,25 +21,36 @@ class Exclusion # @parameter names [Array(String)] All dependency-owned skills. # @returns [Exclusion | Nil] The prepared update when Git is available for this project. def self.prepare(root, names) - output, status = Open3.capture2e("git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", chdir: root) + output, status = Open3.capture2e( + "git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", + chdir: root, + ) return unless status.success? + prefix, path = output.lines(chomp: true) path = File.expand_path(path, root) + original = File.exist?(path) ? File.read(path) : "" + begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip end_marker = "#{END_MARKER} #{prefix}".rstrip - pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} - original = File.exist?(path) ? File.read(path) : "" + + # Replace this project's block, retaining the surrounding Git exclusions: 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 Ownership::Invalid, "Malformed Agent Context exclusion block: #{path}" end + lines.slice!(begins.first..ends.first) end + lines.pop while lines.last == "" lines << "" unless lines.empty? + + pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} lines.concat([ begin_marker, "# Generated by Bake Agent Context; do not edit this block manually.", @@ -47,6 +58,7 @@ def self.prepare(root, names) *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"}, end_marker, ]) + new(path, original, lines.join("\n") + "\n") rescue Errno::ENOENT # Installation also works when Git is unavailable: @@ -77,6 +89,7 @@ def restore def write(content) FileUtils.mkdir_p(File.dirname(@path)) + Tempfile.create(".agent-context-exclude-", File.dirname(@path)) do |file| file.write(content) file.flush diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index 3eaa079..f2bab82 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -26,9 +26,11 @@ class Installer # Raised when a skill cannot be installed. class Error < StandardError end + # Raised when skill metadata or resources are invalid. class InvalidSkill < Error end + # Raised when a destination has ambiguous or foreign ownership. class Conflict < Error end @@ -44,6 +46,7 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) # @attribute [String] The consuming project root. attr_reader :root + # @attribute [String] The installed skill directory. attr_reader :skills_path @@ -53,6 +56,7 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) 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? end @@ -64,6 +68,7 @@ def find_gems_with_skills(skip_local: true) def find_gem_with_skills(gem_name) specification = find_specification(gem_name) return unless specification + information = build_gem_information(specification) information unless information[:skills].empty? end @@ -96,11 +101,13 @@ def install(gem: nil, skill: nil, skip_local: true) 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 @@ -109,47 +116,88 @@ def install(gem: nil, skill: nil, skip_local: true) 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) context_root = File.join(specification.full_gem_path, "context") skills = discover_context_skills(context_root, specification) - {name: specification.name, version: specification.version.to_s, skills: skills.sort_by(&:name)} + + { + name: specification.name, + version: specification.version.to_s, + skills: skills.sort_by(&:name), + } 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) + + # Discover root-level skills before inspecting their resource directories: + files = Dir.glob(File.join(root, "**", "*")).sort_by do |file| + [File.dirname(file) == root ? 0 : 1, file] + end + 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) + next unless File.file?(file) && !File.symlink?(file) + next unless File.extname(file).downcase == ".md" + source = parse_document(file) - metadata = source.metadata - 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, 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 + type = source.metadata["type"] + next unless type + + unless type == "skill" + raise InvalidSkill, "Unsupported context type in #{file}: #{type.inspect}" + end + + unless File.dirname(file) == root + raise InvalidSkill, "Skill documents must be directly inside context/: #{file}" end - document = "---\n#{YAML.dump(metadata).delete_prefix("---\n")}---\n\n#{source.body.sub(/\A\r?\n/, "")}" - 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) + + skills << build_definition(file, source, specification) end + skills end + def build_definition(file, source, specification) + local_name = File.basename(file, File.extname(file)) + installed_name = "#{specification.name.downcase.tr("_", "-")}-#{local_name}" + validate_name(local_name, file) + + metadata = source.metadata.merge("name" => installed_name) + metadata.delete("type") + validate_metadata(metadata, file) + + assets = File.join(File.dirname(file), local_name) + if File.exist?(assets) || File.symlink?(assets) + unless File.lstat(assets).directory? + raise InvalidSkill, "Skill resources must be a regular directory: #{assets}" + end + else + assets = nil + end + + front_matter = YAML.dump(metadata).delete_prefix("---\n") + body = source.body.sub(/\A\r?\n/, "") + document = "---\n#{front_matter}---\n\n#{body}" + document += "\n" unless document.end_with?("\n") + + 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 + def parse_document(file) Document.load(file) rescue Document::Invalid => error @@ -164,62 +212,95 @@ def validate_name(name, file) def validate_metadata(metadata, file) validate_name(metadata["name"], file) + description = metadata["description"] - unless description.is_a?(String) && !description.strip.empty? && description.strip.length <= MAXIMUM_DESCRIPTION_LENGTH + unless description.is_a?(String) raise InvalidSkill, "Invalid skill description in #{file}" end - metadata["description"] = description.strip + + description = description.strip + if description.empty? || description.length > MAXIMUM_DESCRIPTION_LENGTH + raise InvalidSkill, "Invalid skill description in #{file}" + end + + metadata["description"] = description end def install_definitions(definitions, package:, reconcile:) 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? + unless File.lstat(@skills_path).directory? + raise Conflict, "Skill installation path must be a regular directory: #{@skills_path}" + end end + owners = Ownership.scan(@skills_path) + validate_destinations(definitions, owners) + + names = definitions.map(&:name) + owned = owners.select do |_name, owner| + owner["ecosystem"] == "gem" && (!package || owner["package"] == package) + end + stale = reconcile ? owned.keys - names : [] + exclusion = Exclusion.prepare(@root, (owners.keys - stale + names).uniq) + + 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 do |definition| + definition.write_to(File.join(staged, definition.name)) + end + + replace_skills(staged, backups, names, stale, exclusion) + end + + names + end + + def validate_destinations(definitions, owners) definitions.each do |definition| owner = owners[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 - names = definitions.map(&:name) - owned = owners.select{|_name, owner| owner["ecosystem"] == "gem" && (!package || owner["package"] == package)} - stale = reconcile ? owned.keys - names : [] - exclusion = Exclusion.prepare(@root, (owners.keys - stale + names).uniq) - 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 = [] - 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 - 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 - exclusion&.restore - raise + end + + def replace_skills(staged, backups, names, stale, exclusion) + changes = [] + 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 + 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 + + exclusion&.restore + raise end - names end end end diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb index c6bcdad..6c4ae16 100644 --- a/lib/agent/context/skills/ownership.rb +++ b/lib/agent/context/skills/ownership.rb @@ -33,16 +33,23 @@ def self.write(directory, package, version, ecosystem: "gem") # @returns [Hash | Nil] Ownership when the skill has an installation record. def self.load(directory) path = File.join(directory, FILE_NAME) + begin stat = File.lstat(path) rescue Errno::ENOENT return end + raise Invalid, "Skill ownership must be a regular file: #{path}" unless stat.file? + owner = JSON.parse(File.read(path)) - unless owner.is_a?(Hash) && owner.values_at("ecosystem", "package", "version").all?{|value| value.is_a?(String) && !value.strip.empty?} + raise Invalid, "Invalid skill ownership: #{path}" unless owner.is_a?(Hash) + + values = owner.values_at("ecosystem", "package", "version") + unless values.all?{|value| value.is_a?(String) && !value.strip.empty?} raise Invalid, "Invalid skill ownership: #{path}" end + owner rescue JSON::ParserError => error raise Invalid, "Invalid skill ownership #{path}: #{error.message}" @@ -57,14 +64,18 @@ def self.scan(directory) rescue Errno::ENOENT return {} end + raise Invalid, "Skill installation path must be a regular directory: #{directory}" unless stat.directory? + Dir.children(directory).sort.each_with_object({}) do |name, owners| path = File.join(directory, name) next unless File.lstat(path).directory? next unless owner = load(path) + unless name.match?(NAME_PATTERN) && name.length <= 64 raise Invalid, "Invalid installed skill name: #{name.inspect}" end + owners[name] = owner end end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index 13f8427..e096c11 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -137,6 +137,18 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File.read(File.join(destination, "SKILL.md"))).to be == "project-owned" end + it "reports a file occupying the skills directory" do + destination = File.join(consumer_root, ".agents", "skills") + FileUtils.mkdir_p(File.dirname(destination)) + File.write(destination, "project-owned") + + expect do + installer.install + end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + + expect(File.read(destination)).to be == "project-owned" + end + it "refuses duplicate skill names from different gems before installation" do second_root = Dir.mktmpdir write_skill(second_root, "ruby-testing", description: "Another implementation.") @@ -176,6 +188,39 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these File.write(File.join(provider_root, "context/ruby-testing.md"), "# Plain guidance\n") expect(installer.find_gems_with_skills).to be == [] end + + it "reports unsupported context types before installation" do + file = File.join(provider_root, "context/ruby-testing.md") + File.write(file, "---\ntype: unknown\n---\n\n# Instructions\n") + + expect do + installer.install + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + + expect(File).not.to be(:exist?, installer.skills_path) + end + + it "requires a nonempty string description" do + [nil, 123, " "].each do |description| + write_skill(provider_root, "ruby-testing", description: description) + + expect do + installer.install + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end + + expect(File).not.to be(:exist?, installer.skills_path) + end + + it "requires a directory for companion resources" do + File.write(File.join(provider_root, "context/ruby-testing"), "Not a directory") + + expect do + installer.install + end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + + expect(File).not.to be(:exist?, installer.skills_path) + end end it "installs context-declared skills with metadata and opaque resources" do From a4742051fa4aa8d9e3c891b6b46a80772969b714 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 11 Oct 2026 16:56:43 +0530 Subject: [PATCH 17/37] Document the shared Agent Context architecture. --- design.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 2 + 2 files changed, 123 insertions(+) create mode 100644 design.md diff --git a/design.md b/design.md new file mode 100644 index 0000000..ef30a97 --- /dev/null +++ b/design.md @@ -0,0 +1,121 @@ +# Agent Context Design + +This document proposes a common architecture for the Ruby `agent-context` gem and Rust `bake-agent-context` crate. It explains the boundaries that should make both implementations easy to understand and change together. + +The [specification](specification.md) defines the portable file formats and installation behavior. This design describes implementation responsibilities. The current-state comparison refers to Ruby PR #4 and Rust PR #30; the target architecture and implementation sequence below are proposed work. + +## Current Architecture + +The implementations share the provider layout, metadata-declared skills, per-directory `skill.json` ownership, and generated Markdown index. Their internal boundaries differ: + +| Concern | Ruby today | Rust today | +| --- | --- | --- | +| Dependency discovery | Both installers enumerate gem specifications and build provider hashes. | `Installer::discover` resolves Cargo metadata and stores `ContextPackage` values. | +| Document interpretation | `Document` wraps Markly, YAML metadata, body extraction, title, and description. It also contains index ordering. | `index::extract_content` and `skill::parse_skill_document` parse independently. Generic front matter helpers live in `skill.rs`. | +| Index entries | The index retains parsed `Document` objects during rendering. | The private `ContextDocument` contains only a path, title, and description. | +| Skill definitions | `Skills::Definition` stores generated instructions and provider details, and writes staged files. | `Skill` stores metadata, body, and its package. Functions in `skill.rs` discover, validate, stage, reconcile, and replace skills. | +| Ownership and Git | `Skills::Ownership` and `Skills::Exclusion` are separate classes. Both index and skill installation use exclusions. | Ownership lives in `skill.rs`; Git exclusions live in `exclude.rs`. | +| Installation sequence | The library coordinates metadata validation, skills, context, and index updates. Ordinary context uses staged directory replacement. | The Bake task coordinates context, skills, and index updates. Ordinary context removes its destination before copying. | + +Ruby retains the original Markdown body when generating skill instructions. Rust currently renders the body from its Markdown AST. Ruby supports provider-authored `index.yaml` overrides; Rust currently derives its index from documents and package descriptions. + +The main design issue is overlapping ownership of work. Indexing depends on skill code for generic parsing in Rust; ordinary context discovery depends on full skill discovery in both implementations. Installation and discovery also share large implementation units. Formatting helps readers, but these boundaries determine how much code they must understand to make one change. + +## Design Principles + +- Give each domain rule one owner, shared by all commands that need it. +- Align responsibilities, data flow, and observable behavior across languages. Use idiomatic Ruby objects and Rust types, modules, and functions. +- Keep construction limited to storing supplied state and cheap normalization. Name operations that read, parse, discover, prepare, or install explicitly. +- Keep coordination methods readable as a sequence of domain operations. Extract helpers around cohesive responsibilities and use whitespace to separate phases. +- Keep recovery proportional to the operation: validate first, stage replacements, and restore the affected directories when replacement fails. + +## Proposed Boundaries + +These boundaries can be expressed with existing objects and small cohesive modules. Keep related helpers together when they have the same owner. + +| Boundary | Responsibility | Ruby shape | Rust shape | +| --- | --- | --- | --- | +| Package discovery | Resolve dependencies and produce package identity, description, selector, and context root. | A package value shared by both installers; gem discovery at the boundary. | Existing `ContextPackage`; Cargo discovery functions. | +| Document | Interpret one Markdown source and its front matter. | `Document`. | A parsed `ContextDocument` in its own module. | +| Skill definition | Apply skill rules to a document and package; describe instructions and companion resources. | `Skills::Definition`. | `Skill`. | +| Installation | Select providers, classify files, validate destinations, stage content, and replace installed directories. | `Installer` and `Skills::Installer`. | `Installer` and focused skill installation functions. | +| Skill ownership | Read, validate, write, and scan per-directory ownership records. | `Skills::Ownership`. | An ownership module containing `SkillOwner` and its operations. | +| Git exclusion | Prepare, apply, and restore the consuming project's exclusion block. | `Exclusion` under `Agent::Context`. | `exclude::Update` and preparation functions. | +| Index | Order ordinary documents, apply provider overrides, and render links and summaries. | `Index`. | `ContextIndex`. | +| Bake tasks | Translate arguments, call library operations, and format results. | `bake/agent/context*.rb`. | Task adapters exported from the crate. | + +Package values carry ecosystem, package name, version, description, context root, and the selector needed by their ecosystem. Cargo can distinguish resolved versions; gem discovery uses the active specifications. A selector identifies a provider to the user; ownership retains the package name and version defined by the specification. + +```mermaid +flowchart TD + Tasks[Bake tasks] --> Installer[Installation coordinator] + Installer --> Packages[Package discovery] + Installer --> Documents[Document loading and classification] + Documents --> Skills[Skill definitions] + Installer --> Ownership[Skill ownership] + Installer --> Replacement[Staging and replacement] + Installer --> Exclusions[Git exclusion update] + Installer --> Index[Index rendering] + Index --> Documents +``` + +Index rendering and skill definition both consume the document abstraction. Package resolution, directory traversal, destination mutation, and Git commands belong to the operations that explicitly perform those effects. + +## Document Contract + +`Document.load(path)` / `ContextDocument::load(path)` read a source file and delegate to `parse`. `parse(content, path)` interprets already supplied text, using the path for diagnostics and filename-derived values. Constructors retain the resulting state. + +The document exposes: + +- The source path and original content. +- Decoded front matter, including additional provider metadata. +- The declared document kind: ordinary guidance or a skill. +- A title from the first nonempty heading, with a filename fallback. +- A description from explicit metadata or the first prose sentence. +- The original body after the front matter recognized by the Markdown parser. + +Markly and `socketry-markdown` own Markdown syntax and front matter recognition. YAML decoding and context metadata interpretation live in the document layer. That layer reports malformed metadata and unsupported declared kinds with source locations. Skill-specific name, required description, and length checks belong to the skill definition. + +Both implementations should preserve the source body when generating `SKILL.md`, using the parser's source positions to identify the front matter boundary. Only the generated front matter and its separator need serialization. Shared fixtures should cover formatting, links, fenced blocks, Unicode, and line endings. + +Move Ruby's canonical document ordering to the index. Replace Rust's index-only `ContextDocument` with the parsed document abstraction; an index entry can retain a relative path and extracted display values when useful. + +## Discovery and Installation + +Resolve package metadata once for an operation and pass the resulting values through context and skill processing. For each selected provider, inspect root-level Markdown declarations first. Classify companion skill directories before traversing other context paths; their contents are opaque resources. Reuse that classification for listing, showing, copying, and skill installation. + +The main library operation follows these phases: + +1. **Discover:** select packages and classify their context files into ordinary files and skill definitions. +2. **Validate:** check selected document metadata, skill rules, resource paths, ownership, and destination collisions before replacement begins. +3. **Stage:** copy ordinary files with their relative paths; write each skill's `SKILL.md`, resources, and `skill.json` together. +4. **Replace:** publish staged provider directories and the selected skill batch; reconcile stale skills within the requested ownership scope. +5. **Index:** load the installed ordinary documents and render `.agents/context/index.md`. + +Git exclusion preparation happens before replacement. Skill installation applies its prepared update within the skill replacement boundary so its existing restoration behavior remains explicit. Index refresh can prepare the context exclusion update through the coordinating operation. Rendering the index itself consumes documents and package descriptions. + +Each ordinary provider replacement and the selected skill batch has its own rollback boundary. A complete refresh is incremental: successful earlier phases remain installed if a later phase fails, and rerunning the command refreshes the remaining outputs. This keeps the failure model understandable with temporary directories, renames, and local restoration. + +`skill.json` remains the ownership authority. Scanning installed directories supplies reconciliation and exclusion inputs. The ownership module handles the record format; the installer decides which ecosystem, package, or named skill the operation selects. + +## Alignment Decisions + +The shared specification is authoritative for portable behavior. The Rust implementation remains a useful reference for the package model and metadata-driven workflow; the Ruby document abstraction provides the common parsing boundary. Changes to either implementation should be evaluated against the shared design. + +The first alignment pass should preserve existing supported behavior while moving responsibilities. Subsequent behavior changes should be explicit and covered by common fixtures: + +- Preserve skill body source formatting in Rust, matching the proposed document contract. +- Use staged replacement for ordinary context in Rust, matching Ruby's provider replacement boundary. +- Bring the full installation sequence into the Rust library so Bake tasks delegate orchestration consistently. +- Treat provider-authored `index.yaml` as an optional index input. Keep its interpretation in the index layer; Rust support is a separate parity task. The generated artifact remains `index.md`. + +Dependency resolution and command option names can remain ecosystem-specific. Shared fixtures should check document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, index content and ordering, and replacement outcomes. Match semantics explicitly; introduce byte-for-byte assertions where exact source preservation is part of the contract. + +## Implementation Sequence + +1. Introduce the Rust parsed document module and route both indexing and skill discovery through it. Move ordering into Ruby's index. +2. Share provider values and file classification within each implementation so ordinary context and skills use the same discovery result. +3. Separate Rust ownership and installation operations from skill definition code. Move Ruby Git exclusions to the context namespace. +4. Align orchestration and replacement boundaries, then address the listed output differences with shared fixtures. + +For each step, update the current-state comparison and validate the affected public commands. Keep new helpers private until callers need a public contract. Promote the general lessons about construction, responsibility, readability, and cross-language alignment into the shared Socketry conventions. diff --git a/readme.md b/readme.md index 9a136a3..7516ec6 100644 --- a/readme.md +++ b/readme.md @@ -199,6 +199,8 @@ Please see the [project releases](https://ioquatix.github.io/agent-context/relea We welcome contributions to this project. +Read the [architecture design](design.md) for the current Ruby and Rust boundaries and the proposed alignment work. + 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.'`). From ef45137568d643462a93cdcded516093ac2141d4 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 11 Oct 2026 19:06:05 +0530 Subject: [PATCH 18/37] Align installation design with simple rerunnable operations. --- context/getting-started.md | 2 +- context/usage.md | 2 +- design.md | 42 ++++++++++++++++++-------------- guides/getting-started/readme.md | 2 +- readme.md | 2 +- specification.md | 6 +++-- 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 2a23191..d5b43bd 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -189,7 +189,7 @@ Include the complete source directories in your gem's packaged files. Skill docu ## Ownership and Updates -Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. After resolving an installation error, rerun the command to refresh generated files. ## Example Context Files diff --git a/context/usage.md b/context/usage.md index 96db7e4..a1fcb36 100644 --- a/context/usage.md +++ b/context/usage.md @@ -45,7 +45,7 @@ The provider's tracked `context/` directory is source content. Installed depende Installation maintains local Git exclusions for generated context and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. -Each installed skill directory contains `skill.json` recording its provider ecosystem, package, and version. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale skills. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` recording its provider ecosystem, package, and version. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale skills. After resolving an installation error, rerun the command to refresh generated files. ## Publish Context and Skills diff --git a/design.md b/design.md index ef30a97..5056ffd 100644 --- a/design.md +++ b/design.md @@ -21,13 +21,17 @@ Ruby retains the original Markdown body when generating skill instructions. Rust The main design issue is overlapping ownership of work. Indexing depends on skill code for generic parsing in Rust; ordinary context discovery depends on full skill discovery in both implementations. Installation and discovery also share large implementation units. Formatting helps readers, but these boundaries determine how much code they must understand to make one change. +The current installers also carry staging and restoration code for generated output. The target flow below uses direct writes and incremental progress; adopting it is part of the proposed implementation work. + ## Design Principles +- Choose the simplest structure that makes the behavior, state, and responsibilities easy to follow. - Give each domain rule one owner, shared by all commands that need it. - Align responsibilities, data flow, and observable behavior across languages. Use idiomatic Ruby objects and Rust types, modules, and functions. - Keep construction limited to storing supplied state and cheap normalization. Name operations that read, parse, discover, prepare, or install explicitly. -- Keep coordination methods readable as a sequence of domain operations. Extract helpers around cohesive responsibilities and use whitespace to separate phases. -- Keep recovery proportional to the operation: validate first, stage replacements, and restore the affected directories when replacement fails. +- Keep coordination methods readable as a sequence of domain operations. Introduce logical phases with behavioral comments, keep related statements together, and separate phases with blank lines. +- Validate data as part of loading or parsing it, and use that validated representation when applying changes. +- Prefer straightforward operations with explicit effects and simple error handling. Write generated files directly, propagate failures, and allow completed steps to remain in place for a later refresh. ## Proposed Boundaries @@ -38,9 +42,9 @@ These boundaries can be expressed with existing objects and small cohesive modul | Package discovery | Resolve dependencies and produce package identity, description, selector, and context root. | A package value shared by both installers; gem discovery at the boundary. | Existing `ContextPackage`; Cargo discovery functions. | | Document | Interpret one Markdown source and its front matter. | `Document`. | A parsed `ContextDocument` in its own module. | | Skill definition | Apply skill rules to a document and package; describe instructions and companion resources. | `Skills::Definition`. | `Skill`. | -| Installation | Select providers, classify files, validate destinations, stage content, and replace installed directories. | `Installer` and `Skills::Installer`. | `Installer` and focused skill installation functions. | +| Installation | Select providers, classify files, check destination ownership, write generated files, and reconcile stale skills. | `Installer` and `Skills::Installer`. | `Installer` and focused skill installation functions. | | Skill ownership | Read, validate, write, and scan per-directory ownership records. | `Skills::Ownership`. | An ownership module containing `SkillOwner` and its operations. | -| Git exclusion | Prepare, apply, and restore the consuming project's exclusion block. | `Exclusion` under `Agent::Context`. | `exclude::Update` and preparation functions. | +| Git exclusion | Load Git exclusions, update the consuming project's block, and write the result. | `Exclusion` under `Agent::Context`. | Functions in `exclude`. | | Index | Order ordinary documents, apply provider overrides, and render links and summaries. | `Index`. | `ContextIndex`. | | Bake tasks | Translate arguments, call library operations, and format results. | `bake/agent/context*.rb`. | Task adapters exported from the crate. | @@ -53,7 +57,7 @@ flowchart TD Installer --> Documents[Document loading and classification] Documents --> Skills[Skill definitions] Installer --> Ownership[Skill ownership] - Installer --> Replacement[Staging and replacement] + Installer --> Files[File copying and writing] Installer --> Exclusions[Git exclusion update] Installer --> Index[Index rendering] Index --> Documents @@ -76,7 +80,7 @@ The document exposes: Markly and `socketry-markdown` own Markdown syntax and front matter recognition. YAML decoding and context metadata interpretation live in the document layer. That layer reports malformed metadata and unsupported declared kinds with source locations. Skill-specific name, required description, and length checks belong to the skill definition. -Both implementations should preserve the source body when generating `SKILL.md`, using the parser's source positions to identify the front matter boundary. Only the generated front matter and its separator need serialization. Shared fixtures should cover formatting, links, fenced blocks, Unicode, and line endings. +Both implementations should preserve the source body when generating `SKILL.md`, using the parser's source positions to identify the front matter boundary. Only the generated front matter and its separator need serialization. Tests should cover formatting, links, fenced blocks, Unicode, and line endings; shared fixtures are useful where both implementations exercise the same rule. Move Ruby's canonical document ordering to the index. Replace Rust's index-only `ContextDocument` with the parsed document abstraction; an index entry can retain a relative path and extracted display values when useful. @@ -84,17 +88,19 @@ Move Ruby's canonical document ordering to the index. Replace Rust's index-only Resolve package metadata once for an operation and pass the resulting values through context and skill processing. For each selected provider, inspect root-level Markdown declarations first. Classify companion skill directories before traversing other context paths; their contents are opaque resources. Reuse that classification for listing, showing, copying, and skill installation. -The main library operation follows these phases: +Retain loaded documents and their validated metadata for installation. Write ordinary Markdown from the loaded source and generate skill instructions from the same parsed representation. Copy opaque resources directly. A sequence such as `Document.load(path).write(destination)` / `ContextDocument::load(path)?.write(destination)?` expresses the intended separation between reading and writing. + +The main library operation follows these steps: -1. **Discover:** select packages and classify their context files into ordinary files and skill definitions. -2. **Validate:** check selected document metadata, skill rules, resource paths, ownership, and destination collisions before replacement begins. -3. **Stage:** copy ordinary files with their relative paths; write each skill's `SKILL.md`, resources, and `skill.json` together. -4. **Replace:** publish staged provider directories and the selected skill batch; reconcile stale skills within the requested ownership scope. -5. **Index:** load the installed ordinary documents and render `.agents/context/index.md`. +1. **Discover:** resolve and select providers for the requested scope. +2. **Load:** parse and validate documents, classify ordinary files and skill resources, and load installation ownership. +3. **Install:** check destination ownership, refresh generated provider and skill directories, and write their files directly. Establish each skill's `skill.json` before writing instructions and copying resources so a failure in those later steps leaves its ownership identifiable. +4. **Reconcile:** remove stale skills within the requested ownership scope and remove generated resources that the provider no longer supplies. +5. **Refresh:** render `.agents/context/index.md` and update the consuming project's Git exclusion block from installed ownership. -Git exclusion preparation happens before replacement. Skill installation applies its prepared update within the skill replacement boundary so its existing restoration behavior remains explicit. Index refresh can prepare the context exclusion update through the coordinating operation. Rendering the index itself consumes documents and package descriptions. +Git exclusion updates follow the same load, transform, and write pattern. The coordinator loads the exclusion file, updates this project's block using installed skill names, and writes the result. Index rendering consumes installed documents and package descriptions. -Each ordinary provider replacement and the selected skill batch has its own rollback boundary. A complete refresh is incremental: successful earlier phases remain installed if a later phase fails, and rerunning the command refreshes the remaining outputs. This keeps the failure model understandable with temporary directories, renames, and local restoration. +Installation is a straightforward, rerunnable operation. Use `File.write` / `std::fs::write` for generated files and propagate ordinary I/O errors with the relevant path. Completed changes and partially written output can remain when a later step fails. After resolving the reported failure, rerunning installation refreshes generated output, ownership, the index, and exclusions. Loading and writing are separate operations that work with the loaded snapshot; this is the intended contract for local generated state. `skill.json` remains the ownership authority. Scanning installed directories supplies reconciliation and exclusion inputs. The ownership module handles the record format; the installer decides which ecosystem, package, or named skill the operation selects. @@ -102,20 +108,20 @@ Each ordinary provider replacement and the selected skill batch has its own roll The shared specification is authoritative for portable behavior. The Rust implementation remains a useful reference for the package model and metadata-driven workflow; the Ruby document abstraction provides the common parsing boundary. Changes to either implementation should be evaluated against the shared design. -The first alignment pass should preserve existing supported behavior while moving responsibilities. Subsequent behavior changes should be explicit and covered by common fixtures: +Align responsibilities incrementally and test each implementation against the agreed behavior. The proposed changes are: - Preserve skill body source formatting in Rust, matching the proposed document contract. -- Use staged replacement for ordinary context in Rust, matching Ruby's provider replacement boundary. +- Simplify context, skill, index, and exclusion updates around direct writes and rerunnable progress in both implementations. - Bring the full installation sequence into the Rust library so Bake tasks delegate orchestration consistently. - Treat provider-authored `index.yaml` as an optional index input. Keep its interpretation in the index layer; Rust support is a separate parity task. The generated artifact remains `index.md`. -Dependency resolution and command option names can remain ecosystem-specific. Shared fixtures should check document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, index content and ordering, and replacement outcomes. Match semantics explicitly; introduce byte-for-byte assertions where exact source preservation is part of the contract. +Dependency resolution and command option names can remain ecosystem-specific. Test document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, index content and ordering, and rerunning installation after a failure. Use shared fixtures where they make a common format or rule easier to verify. Compare exact bytes when formatting or preservation is part of the contract; otherwise compare meaning. ## Implementation Sequence 1. Introduce the Rust parsed document module and route both indexing and skill discovery through it. Move ordering into Ruby's index. 2. Share provider values and file classification within each implementation so ordinary context and skills use the same discovery result. 3. Separate Rust ownership and installation operations from skill definition code. Move Ruby Git exclusions to the context namespace. -4. Align orchestration and replacement boundaries, then address the listed output differences with shared fixtures. +4. Simplify installation and exclusion updates to direct operations, then align library orchestration and address the listed output differences. Use focused tests for the agreed behavior and shared fixtures where useful. For each step, update the current-state comparison and validate the affected public commands. Keep new helpers private until callers need a public contract. Promote the general lessons about construction, responsibility, readability, and cross-language alignment into the shared Socketry conventions. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 2a23191..d5b43bd 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -189,7 +189,7 @@ Include the complete source directories in your gem's packaged files. Skill docu ## Ownership and Updates -Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. Skill updates use staged replacements with rollback on failure. +Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. After resolving an installation error, rerun the command to refresh generated files. ## Example Context Files diff --git a/readme.md b/readme.md index 7516ec6..c3c3901 100644 --- a/readme.md +++ b/readme.md @@ -168,7 +168,7 @@ Skills are discovered through `type: skill` metadata in `context/*.md`, matching ## Ownership and Updates -Each installed skill directory contains `skill.json` with its provider ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. Skill updates use staged replacements with rollback on failure. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. +Each installed skill directory contains `skill.json` with its provider ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. After resolving an installation error, rerun the command to refresh generated files. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). diff --git a/specification.md b/specification.md index 92ef728..d527391 100644 --- a/specification.md +++ b/specification.md @@ -179,6 +179,8 @@ Context installation MUST follow these principles: 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. +Consumers SHOULD validate document data as part of loading or parsing it and use that validated representation when applying changes. Installation MAY use direct file writes and copies. Consumers MUST report failures; completed changes and partially written output MAY remain after an error. After resolving the reported failure, consumers SHOULD support refreshing generated output by rerunning installation. + ### 5.3 Installation Algorithm ``` @@ -219,11 +221,11 @@ Consumers MUST write `skill.json` inside each dependency-installed skill directo The ownership record MUST be a JSON object with non-empty string fields `ecosystem`, `package`, and `version`; whitespace-only values are invalid. Consumers MUST tolerate additional fields. Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. `package` is the provider's package name and `version` is the installed provider version. -Consumers MUST discover installed ownership by scanning direct child directories of `.agents/skills/` for `skill.json`. The skills root and managed skill directories MUST be regular directories. Symbolic links and non-directory children are skipped. Ownership files MUST be regular files. Managed directory names MUST satisfy the installed skill name rules in section 4.4. Malformed or unreadable ownership MUST fail the operation before installed skills are changed. A directory without `skill.json` is project-owned. +Consumers MUST discover installed ownership by scanning direct child directories of `.agents/skills/` for `skill.json`. The skills root and managed skill directories MUST be regular directories. Symbolic links and non-directory children are skipped. Ownership files MUST be regular files. Managed directory names MUST satisfy the installed skill name rules in section 4.4. Consumers MUST reject malformed or unreadable ownership when loading it. A directory without `skill.json` is project-owned. A full refresh reconciles only the installer's ecosystem; a package refresh reconciles only that ecosystem and package; installing one named skill retains other owned skills. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Installers MUST retain skills owned by other ecosystems. -Existing project-owned destinations and destinations owned by another ecosystem or package MUST NOT be replaced. Installers MUST validate selected sources and collisions, stage instructions, resources, and ownership together, and replace each skill directory by renaming the staged directory. They MUST restore previous directories, ownership, and Git exclusions if a replacement fails. +Installers MUST restrict updates and removals of existing skill directories to the selected ecosystem and package scope, and MUST reject collisions with project-owned or differently owned destinations. Once a destination is accepted, installers MAY write instructions, resources, and ownership directly into the skill directory. When writing directly, installers SHOULD establish a valid `skill.json` before writing instructions and copying resources so a failure in those later steps leaves the destination identifiable as managed. The incremental failure behavior in section 5.2 applies to skill installation and Git exclusion updates. Top-level resource names `SKILL.md` and `skill.json` are reserved, case-insensitively, for generated files. Consumers MUST reject provider resources with either name, including directories. Nested resource files with these names MAY be copied as opaque assets. From 6c1e4ffb0796817dd03dcb43d1e7b77b7c029d01 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 11 Oct 2026 19:21:11 +0530 Subject: [PATCH 19/37] Remove generated context indexes. --- bake/agent/context.rb | 10 +- context/getting-started.md | 9 +- context/index.yaml | 15 --- context/usage.md | 10 +- design.md | 41 ++++---- guides/getting-started/readme.md | 9 +- lib/agent/context.rb | 1 - lib/agent/context/document.rb | 49 +--------- lib/agent/context/index.rb | 162 ------------------------------- lib/agent/context/installer.rb | 15 +-- post.md | 2 + readme.md | 41 ++------ releases.md | 4 + specification.md | 12 ++- test/agent/context/document.rb | 6 +- test/agent/context/exclusion.rb | 59 +++++++++++ test/agent/context/index.rb | 151 ---------------------------- test/agent/context/installer.rb | 30 ------ test/agent/context/skills.rb | 25 +++-- test/agent/context/tasks.rb | 11 +-- 20 files changed, 138 insertions(+), 524 deletions(-) delete mode 100644 context/index.yaml delete mode 100644 lib/agent/context/index.rb create mode 100644 test/agent/context/exclusion.rb delete mode 100644 test/agent/context/index.rb diff --git a/bake/agent/context.rb b/bake/agent/context.rb index bfa6c15..a83341e 100644 --- a/bake/agent/context.rb +++ b/bake/agent/context.rb @@ -5,7 +5,6 @@ # Copyright, 2025, by Samuel Williams. require_relative "../../lib/agent/context/installer" -require_relative "../../lib/agent/context/index" include Agent::Context @@ -56,17 +55,10 @@ def show(gem:, file:) end end -# Install ordinary context and skills, then refresh the generated index. +# Install ordinary context and skills. # @parameter gem [String | Nil] An optional provider gem. def install(gem: nil) installed = @installer.install(gem: gem) puts "Installed context from: #{installed[:context].join(", ")}" unless installed[:context].empty? puts "Installed skills: #{installed[:skills].join(", ")}" unless installed[:skills].empty? - puts "Updated .agents/context/index.md" -end - -# Refresh the generated index of installed context. -def index - Agent::Context::Index.new(@installer.context_path).update_index - puts "Updated .agents/context/index.md" end diff --git a/context/getting-started.md b/context/getting-started.md index d5b43bd..749cf8b 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -38,9 +38,6 @@ bake agent:context:list --gem async # View a specific context file bake agent:context:show --gem async --file thread-safety -# Refresh the generated index -bake agent:context:index - # List, show, or install dependency skills bake agent:context:skill:list bake agent:context:skill:show --gem provider --skill provider-workflow @@ -61,7 +58,9 @@ When you run `bake agent:context:install`, the tool: 2. Creates a `.agents/context/` directory in your current project. 3. Copies ordinary context files organized by gem name. 4. Installs metadata-declared skills and their resources into `.agents/skills/`. -5. Generates `.agents/context/index.md`. Add a stable link to the generated index in your repository instructions. +5. Updates local Git exclusions for installed context and dependency skills. + +Discover installed Markdown guides by listing files under `.agents/context/GEM/`. Repository instructions can direct agents to read the guides relevant to their task. For example: ``` @@ -152,7 +151,7 @@ Available context files: #### 4. File format and content guidelines -Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. +Use descriptive filenames, a clear heading, and an introduction for each Markdown guide. Link related guides so agents can follow them when they need more detail. Context files can be in any format, but `.md` is commonly used for documentation. The content should be: diff --git a/context/index.yaml b/context/index.yaml deleted file mode 100644 index c856e9d..0000000 --- a/context/index.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# 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 context files and skills from Ruby gems. -metadata: - bug_tracker_uri: https://github.com/ioquatix/agent-context/issues - changelog_uri: https://github.com/ioquatix/agent-context/blob/main/releases.md - documentation_uri: https://ioquatix.github.io/agent-context/ - funding_uri: https://github.com/sponsors/ioquatix/ - source_code_uri: https://github.com/ioquatix/agent-context.git -files: -- path: getting-started.md - title: Getting Started - description: This guide explains how to install package guidance and skills using - `agent-context`. diff --git a/context/usage.md b/context/usage.md index a1fcb36..8ef9b40 100644 --- a/context/usage.md +++ b/context/usage.md @@ -9,9 +9,9 @@ Use `agent-context` to find practical guidance shipped by Ruby gem dependencies ## Use Dependency Guidance -Follow the repository's agent instructions and read `.agents/context/index.md` to find installed dependency guidance. Use its package descriptions, document titles, and summaries to select the guides relevant to the task, then read those guides before changing code that uses the package. +Follow the repository's agent instructions and discover installed dependency guidance by listing Markdown files under `.agents/context/GEM/`. Use descriptive filenames to select relevant guides, then read them before changing code that uses the package. For example, `rg --files --hidden --no-ignore .agents/context -g "*.md"` lists the installed Markdown files. -If the index or relevant installed files are missing, run `bundle exec bake agent:context:install` from the consuming project. Run it again after changing dependencies. It installs ordinary context and skills from resolved dependency gems and refreshes `.agents/context/index.md`. +If relevant installed files are missing, run `bundle exec bake agent:context:install` from the consuming project. Run it again after changing dependencies. It installs ordinary context and skills from resolved dependency gems. To inspect or install guidance from a specific provider: @@ -22,9 +22,9 @@ bundle exec bake agent:context:show --gem async --file thread-safety bundle exec bake agent:context:install --gem async ``` -`show` reads the provider's source file; the `.md` extension is optional. `install --gem NAME` installs both ordinary context and skills from that provider. Use `bundle exec bake agent:context:index` to regenerate the index from already installed context. +`show` reads the provider's source file; the `.md` extension is optional. `install --gem NAME` installs both ordinary context and skills from that provider. -Ordinary context is copied to `.agents/context/GEM/`. The index links to Markdown guides using their headings and prose summaries. Add a stable link to `.agents/context/index.md` in the repository's `agents.md` when configuring repository instructions. +Ordinary context is copied to `.agents/context/GEM/`. Repository instructions can direct agents to discover and read relevant guides there. ## Use Dependency Skills @@ -49,7 +49,7 @@ Each installed skill directory contains `skill.json` recording its provider ecos ## Publish Context and Skills -Put focused Markdown guides directly in the gem's `context/` directory, using descriptive lowercase filenames. Give each guide a clear heading and first prose sentence. YAML front matter can override its summary with `description`. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. +Put focused Markdown guides in the gem's `context/` directory, using descriptive lowercase filenames. Give each guide a clear heading and introduction, and link related guides together. To publish a skill, add `context/workflow.md` with metadata describing when to use it: diff --git a/design.md b/design.md index 5056ffd..42e4dad 100644 --- a/design.md +++ b/design.md @@ -6,20 +6,21 @@ The [specification](specification.md) defines the portable file formats and inst ## Current Architecture -The implementations share the provider layout, metadata-declared skills, per-directory `skill.json` ownership, and generated Markdown index. Their internal boundaries differ: +The implementations share the provider layout, metadata-declared skills, per-directory `skill.json` ownership, and installed directory layout. Their internal boundaries differ: | Concern | Ruby today | Rust today | | --- | --- | --- | | Dependency discovery | Both installers enumerate gem specifications and build provider hashes. | `Installer::discover` resolves Cargo metadata and stores `ContextPackage` values. | -| Document interpretation | `Document` wraps Markly, YAML metadata, body extraction, title, and description. It also contains index ordering. | `index::extract_content` and `skill::parse_skill_document` parse independently. Generic front matter helpers live in `skill.rs`. | -| Index entries | The index retains parsed `Document` objects during rendering. | The private `ContextDocument` contains only a path, title, and description. | +| Document interpretation | `Document` wraps Markly, YAML metadata, and original body extraction. | `skill::parse_skill_document` interprets Markdown and metadata. Generic front matter helpers live in `skill.rs`. | | Skill definitions | `Skills::Definition` stores generated instructions and provider details, and writes staged files. | `Skill` stores metadata, body, and its package. Functions in `skill.rs` discover, validate, stage, reconcile, and replace skills. | -| Ownership and Git | `Skills::Ownership` and `Skills::Exclusion` are separate classes. Both index and skill installation use exclusions. | Ownership lives in `skill.rs`; Git exclusions live in `exclude.rs`. | -| Installation sequence | The library coordinates metadata validation, skills, context, and index updates. Ordinary context uses staged directory replacement. | The Bake task coordinates context, skills, and index updates. Ordinary context removes its destination before copying. | +| Ownership and Git | `Skills::Ownership` and `Skills::Exclusion` are separate classes. Skill installation updates exclusions for installed context and skills. | Ownership lives in `skill.rs`; Git exclusions live in `exclude.rs`. | +| Installation sequence | The library coordinates skills and context installation. Ordinary context uses staged directory replacement. | The Bake task coordinates context and skills installation. Ordinary context removes its destination before copying. | -Ruby retains the original Markdown body when generating skill instructions. Rust currently renders the body from its Markdown AST. Ruby supports provider-authored `index.yaml` overrides; Rust currently derives its index from documents and package descriptions. +Ruby retains the original Markdown body when generating skill instructions. Rust currently renders the body from its Markdown AST. -The main design issue is overlapping ownership of work. Indexing depends on skill code for generic parsing in Rust; ordinary context discovery depends on full skill discovery in both implementations. Installation and discovery also share large implementation units. Formatting helps readers, but these boundaries determine how much code they must understand to make one change. +The main design issue is overlapping ownership of work. Ordinary context discovery depends on full skill discovery in both implementations. Rust keeps generic document interpretation in its skill module. Installation and discovery also share large implementation units. Formatting helps readers, but these boundaries determine how much code they must understand to make one change. + +Agents discover guidance through the installed directory layout and list/show commands. The packaged usage skills explain this workflow. The current installers also carry staging and restoration code for generated output. The target flow below uses direct writes and incremental progress; adopting it is part of the proposed implementation work. @@ -39,16 +40,15 @@ These boundaries can be expressed with existing objects and small cohesive modul | Boundary | Responsibility | Ruby shape | Rust shape | | --- | --- | --- | --- | -| Package discovery | Resolve dependencies and produce package identity, description, selector, and context root. | A package value shared by both installers; gem discovery at the boundary. | Existing `ContextPackage`; Cargo discovery functions. | +| Package discovery | Resolve dependencies and produce package identity, selector, and context root. | A package value shared by both installers; gem discovery at the boundary. | Existing `ContextPackage`; Cargo discovery functions. | | Document | Interpret one Markdown source and its front matter. | `Document`. | A parsed `ContextDocument` in its own module. | | Skill definition | Apply skill rules to a document and package; describe instructions and companion resources. | `Skills::Definition`. | `Skill`. | | Installation | Select providers, classify files, check destination ownership, write generated files, and reconcile stale skills. | `Installer` and `Skills::Installer`. | `Installer` and focused skill installation functions. | | Skill ownership | Read, validate, write, and scan per-directory ownership records. | `Skills::Ownership`. | An ownership module containing `SkillOwner` and its operations. | | Git exclusion | Load Git exclusions, update the consuming project's block, and write the result. | `Exclusion` under `Agent::Context`. | Functions in `exclude`. | -| Index | Order ordinary documents, apply provider overrides, and render links and summaries. | `Index`. | `ContextIndex`. | | Bake tasks | Translate arguments, call library operations, and format results. | `bake/agent/context*.rb`. | Task adapters exported from the crate. | -Package values carry ecosystem, package name, version, description, context root, and the selector needed by their ecosystem. Cargo can distinguish resolved versions; gem discovery uses the active specifications. A selector identifies a provider to the user; ownership retains the package name and version defined by the specification. +Package values carry ecosystem, package name, version, context root, and the selector needed by their ecosystem. Cargo can distinguish resolved versions; gem discovery uses the active specifications. A selector identifies a provider to the user; ownership retains the package name and version defined by the specification. ```mermaid flowchart TD @@ -59,11 +59,9 @@ flowchart TD Installer --> Ownership[Skill ownership] Installer --> Files[File copying and writing] Installer --> Exclusions[Git exclusion update] - Installer --> Index[Index rendering] - Index --> Documents ``` -Index rendering and skill definition both consume the document abstraction. Package resolution, directory traversal, destination mutation, and Git commands belong to the operations that explicitly perform those effects. +File classification and skill definition consume the document abstraction. Package resolution, directory traversal, destination mutation, and Git commands belong to the operations that explicitly perform those effects. ## Document Contract @@ -74,16 +72,12 @@ The document exposes: - The source path and original content. - Decoded front matter, including additional provider metadata. - The declared document kind: ordinary guidance or a skill. -- A title from the first nonempty heading, with a filename fallback. -- A description from explicit metadata or the first prose sentence. - The original body after the front matter recognized by the Markdown parser. Markly and `socketry-markdown` own Markdown syntax and front matter recognition. YAML decoding and context metadata interpretation live in the document layer. That layer reports malformed metadata and unsupported declared kinds with source locations. Skill-specific name, required description, and length checks belong to the skill definition. Both implementations should preserve the source body when generating `SKILL.md`, using the parser's source positions to identify the front matter boundary. Only the generated front matter and its separator need serialization. Tests should cover formatting, links, fenced blocks, Unicode, and line endings; shared fixtures are useful where both implementations exercise the same rule. -Move Ruby's canonical document ordering to the index. Replace Rust's index-only `ContextDocument` with the parsed document abstraction; an index entry can retain a relative path and extracted display values when useful. - ## Discovery and Installation Resolve package metadata once for an operation and pass the resulting values through context and skill processing. For each selected provider, inspect root-level Markdown declarations first. Classify companion skill directories before traversing other context paths; their contents are opaque resources. Reuse that classification for listing, showing, copying, and skill installation. @@ -96,11 +90,11 @@ The main library operation follows these steps: 2. **Load:** parse and validate documents, classify ordinary files and skill resources, and load installation ownership. 3. **Install:** check destination ownership, refresh generated provider and skill directories, and write their files directly. Establish each skill's `skill.json` before writing instructions and copying resources so a failure in those later steps leaves its ownership identifiable. 4. **Reconcile:** remove stale skills within the requested ownership scope and remove generated resources that the provider no longer supplies. -5. **Refresh:** render `.agents/context/index.md` and update the consuming project's Git exclusion block from installed ownership. +5. **Refresh exclusions:** update the consuming project's Git exclusion block from installed ownership. -Git exclusion updates follow the same load, transform, and write pattern. The coordinator loads the exclusion file, updates this project's block using installed skill names, and writes the result. Index rendering consumes installed documents and package descriptions. +Git exclusion updates follow the same load, transform, and write pattern. The coordinator loads the exclusion file, updates this project's block using installed skill names, and writes the result. -Installation is a straightforward, rerunnable operation. Use `File.write` / `std::fs::write` for generated files and propagate ordinary I/O errors with the relevant path. Completed changes and partially written output can remain when a later step fails. After resolving the reported failure, rerunning installation refreshes generated output, ownership, the index, and exclusions. Loading and writing are separate operations that work with the loaded snapshot; this is the intended contract for local generated state. +Installation is a straightforward, rerunnable operation. Use `File.write` / `std::fs::write` for generated files and propagate ordinary I/O errors with the relevant path. Completed changes and partially written output can remain when a later step fails. After resolving the reported failure, rerunning installation refreshes generated output, ownership, and exclusions. Loading and writing are separate operations that work with the loaded snapshot; this is the intended contract for local generated state. `skill.json` remains the ownership authority. Scanning installed directories supplies reconciliation and exclusion inputs. The ownership module handles the record format; the installer decides which ecosystem, package, or named skill the operation selects. @@ -111,15 +105,14 @@ The shared specification is authoritative for portable behavior. The Rust implem Align responsibilities incrementally and test each implementation against the agreed behavior. The proposed changes are: - Preserve skill body source formatting in Rust, matching the proposed document contract. -- Simplify context, skill, index, and exclusion updates around direct writes and rerunnable progress in both implementations. +- Simplify context, skill, and exclusion updates around direct writes and rerunnable progress in both implementations. - Bring the full installation sequence into the Rust library so Bake tasks delegate orchestration consistently. -- Treat provider-authored `index.yaml` as an optional index input. Keep its interpretation in the index layer; Rust support is a separate parity task. The generated artifact remains `index.md`. -Dependency resolution and command option names can remain ecosystem-specific. Test document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, index content and ordering, and rerunning installation after a failure. Use shared fixtures where they make a common format or rule easier to verify. Compare exact bytes when formatting or preservation is part of the contract; otherwise compare meaning. +Dependency resolution and command option names can remain ecosystem-specific. Test document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, context file discovery, and rerunning installation after a failure. Use shared fixtures where they make a common format or rule easier to verify. Compare exact bytes when formatting or preservation is part of the contract; otherwise compare meaning. ## Implementation Sequence -1. Introduce the Rust parsed document module and route both indexing and skill discovery through it. Move ordering into Ruby's index. +1. Introduce the Rust parsed document module and route file classification and skill definition through it. 2. Share provider values and file classification within each implementation so ordinary context and skills use the same discovery result. 3. Separate Rust ownership and installation operations from skill definition code. Move Ruby Git exclusions to the context namespace. 4. Simplify installation and exclusion updates to direct operations, then align library orchestration and address the listed output differences. Use focused tests for the agreed behavior and shared fixtures where useful. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index d5b43bd..749cf8b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -38,9 +38,6 @@ bake agent:context:list --gem async # View a specific context file bake agent:context:show --gem async --file thread-safety -# Refresh the generated index -bake agent:context:index - # List, show, or install dependency skills bake agent:context:skill:list bake agent:context:skill:show --gem provider --skill provider-workflow @@ -61,7 +58,9 @@ When you run `bake agent:context:install`, the tool: 2. Creates a `.agents/context/` directory in your current project. 3. Copies ordinary context files organized by gem name. 4. Installs metadata-declared skills and their resources into `.agents/skills/`. -5. Generates `.agents/context/index.md`. Add a stable link to the generated index in your repository instructions. +5. Updates local Git exclusions for installed context and dependency skills. + +Discover installed Markdown guides by listing files under `.agents/context/GEM/`. Repository instructions can direct agents to read the guides relevant to their task. For example: ``` @@ -152,7 +151,7 @@ Available context files: #### 4. File format and content guidelines -Use a clear heading and first prose sentence for each Markdown guide. Optional YAML `description` overrides the prose summary. Installation generates the Markdown index directly from these documents and uses provider-authored `index.yaml` to customize ordering, titles, and descriptions. +Use descriptive filenames, a clear heading, and an introduction for each Markdown guide. Link related guides so agents can follow them when they need more detail. Context files can be in any format, but `.md` is commonly used for documentation. The content should be: diff --git a/lib/agent/context.rb b/lib/agent/context.rb index 125808f..36e4c61 100644 --- a/lib/agent/context.rb +++ b/lib/agent/context.rb @@ -8,7 +8,6 @@ require_relative "context/paths" require_relative "context/skills" require_relative "context/installer" -require_relative "context/index" # @namespace module Agent diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 122dddc..3aaf7aa 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -11,8 +11,6 @@ module Agent module Context # Represents a context document parsed into Markdown and metadata. class Document - CANONICAL_ORDER = %w[getting-started overview usage configuration migration troubleshooting debugging].freeze - # Raised when context front matter cannot be safely interpreted. class Invalid < ArgumentError end @@ -26,7 +24,7 @@ def self.load(path) # Parse Markdown and validate its front matter. # @parameter content [String] The Markdown source content. - # @parameter path [String] The source path used for titles and error messages. + # @parameter path [String] The source path used for error messages. # @returns [Document] The parsed document. def self.parse(content, path:) parts = Markly.parse(content, flags: Markly::FRONT_MATTER).to_a @@ -45,21 +43,17 @@ def self.parse(content, path:) raise Invalid, "Context frontmatter must be a mapping: #{path}" unless metadata.is_a?(Hash) - new(path, content, parts, front_matter, metadata) + new(content, front_matter, metadata) rescue Psych::Exception => error raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" end # Initialize a document from parsed Markdown and metadata. - # @parameter path [String] The Markdown source path. # @parameter content [String] The original Markdown source content. - # @parameter parts [Array(Markly::Node)] The top-level Markdown nodes. # @parameter front_matter [Markly::Node | Nil] The leading front matter node. # @parameter metadata [Hash] The decoded front matter. - def initialize(path, content, parts, front_matter, metadata) - @path = path + def initialize(content, front_matter, metadata) @content = content - @parts = parts @front_matter = front_matter @metadata = metadata end @@ -73,43 +67,6 @@ def body @content.lines.drop(@front_matter.source_position[:end_line]).join end - - # @returns [String] The first heading or a filename-derived title. - def title - heading = @parts.find{|part| part.type == :header && !plain_text(part).empty?} - heading ? plain_text(heading) : File.basename(@path, File.extname(@path)).tr("-", " ") - end - - # @returns [String | Nil] Explicit metadata or the first prose sentence. - def description - explicit = @metadata["description"] - raise ArgumentError, "Context description must be a string: #{@path}" if explicit && !explicit.is_a?(String) - return explicit.strip if explicit && !explicit.strip.empty? - - paragraph = @parts.find{|part| part.type == :paragraph} - return unless paragraph - - plain_text(paragraph).split(/(?<=[.!?])\s+/, 2).first - end - - # @returns [bool] Whether this document declares a skill. - def skill? - @metadata["type"] == "skill" - end - - # Compute stable document ordering. - # @parameter path [String] The relative document path. - # @returns [Array] Canonical position, filename, and relative path. - def self.order(path) - name = File.basename(path, File.extname(path)).downcase - [CANONICAL_ORDER.index(name) || CANONICAL_ORDER.length, name, path] - end - - private - - def plain_text(node) - node.to_plaintext.gsub(/\s+/, " ").strip - end end end end diff --git a/lib/agent/context/index.rb b/lib/agent/context/index.rb deleted file mode 100644 index 6a49785..0000000 --- a/lib/agent/context/index.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2025-2026, by Samuel Williams. -# Copyright, 2025, by Shopify Inc. - -require "fileutils" -require "pathname" -require "yaml" -require "uri" -require "tempfile" -require "rubygems" -require "agent/context/skills/ownership" -require "agent/context/skills/exclusion" -require_relative "paths" -require_relative "document" - -module Agent - module Context - # Represents the generated index of installed dependency guidance. - class Index - # Initialize an index for installed context. - # @parameter context_path [String] The installed context directory. - # @parameter specifications [Enumerable] The resolved gems supplying package descriptions. - def initialize(context_path = CONTEXT_PATH, specifications: ::Gem::Specification) - @context_path = File.expand_path(context_path) - @specifications = specifications - end - - # @attribute [String] The installed context directory. - attr_reader :context_path - - # Write the generated index and update local Git exclusions. - def update_index - content = generate_index - - FileUtils.mkdir_p(@context_path) - Tempfile.create(".agent-context-index-", @context_path) do |file| - file.write(content) - file.flush - File.rename(file.path, File.join(@context_path, "index.md")) - end - - root = File.dirname(File.dirname(@context_path)) - owners = Skills::Ownership.scan(File.join(root, ".agents", "skills")) - Skills::Exclusion.prepare(root, owners.keys)&.apply - end - - # Render links relative to the generated index, preserving custom provider metadata. - # @returns [String] The generated Markdown index. - def generate_index - package_descriptions = @specifications.to_h do |specification| - [specification.name, specification.summary] - end - - sections = [ - "# Context Index", - "", - "This index links to guidance installed from resolved gem dependencies. " \ - "It is generated by `bake agent:context:install` and can be refreshed with `bake agent:context:index`.", - "", - "Before working on a package, read the relevant context files below. " \ - "If these files are missing, run `bake agent:context:install`.", - "", - ] - - found = false - provider_names.each do |name| - directory = File.join(@context_path, name) - documents = discover_documents(directory) - next if documents.empty? - - metadata = load_gem_index(directory) - description = metadata["description"] || package_descriptions[name] || "Context files for #{name}" - sections.concat(["## #{escape(name)}", "", escape(description), ""]) - append_documents(sections, name, documents, metadata) - found = true - end - - sections << "No context files are installed." unless found - sections.pop while sections.last == "" - sections.join("\n") + "\n" - end - - private - - def provider_names - return [] unless Dir.exist?(@context_path) - - Dir.children(@context_path).sort.select do |name| - path = File.join(@context_path, name) - File.directory?(path) && !File.symlink?(path) - end - end - - def discover_documents(directory) - files = Dir.glob(File.join(directory, "**", "*")).select do |path| - File.file?(path) && !File.symlink?(path) && File.extname(path).downcase == ".md" - end - - # Read root-level skill declarations before excluding their resource directories: - files.sort_by!{|path| [File.dirname(path) == directory ? 0 : 1, path]} - - documents = {} - skill_roots = [] - files.each do |path| - relative = Pathname.new(path).relative_path_from(Pathname.new(directory)).to_s - next if skill_roots.any?{|skill| relative.start_with?("#{skill}/")} - - document = Document.load(path) - if document.skill? - skill_roots << relative.delete_suffix(File.extname(relative)) - else - documents[relative] = document - end - end - - documents - end - - def load_gem_index(directory) - path = File.join(directory, "index.yaml") - metadata = File.file?(path) ? YAML.safe_load_file(path, aliases: false) : {} - metadata.is_a?(Hash) ? metadata : {} - rescue Psych::Exception - {} - end - - def append_documents(sections, package, documents, metadata) - listed = [] - Array(metadata["files"]).each do |entry| - next unless entry.is_a?(Hash) - - path = entry["path"] - next unless documents.key?(path) - next if listed.include?(path) - - append_document(sections, package, path, documents.fetch(path), entry) - listed << path - end - - remaining = (documents.keys - listed).sort_by{|path| Document.order(path)} - remaining.each do |path| - append_document(sections, package, path, documents.fetch(path)) - end - end - - def append_document(sections, package, path, document, metadata = {}) - title = metadata["title"] || document.title - description = metadata["description"] || document.description - link = URI::DEFAULT_PARSER.escape("#{package}/#{path}", /[^a-zA-Z0-9\-._~\/]/) - - sections.concat(["### [#{escape(title)}](#{link})", ""]) - sections.concat([escape(description), ""]) if description && !description.empty? - end - - def escape(text) - text.to_s.gsub(/\s+/, " ").gsub(/([\\`*_\[\]<>])/){"\\#{$1}"} - end - end - end -end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 2b10358..00dfcee 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -10,8 +10,6 @@ require "tmpdir" require_relative "paths" -require_relative "document" -require_relative "index" require "agent/context/skills/installer" module Agent @@ -162,19 +160,10 @@ def install_all_context(skip_local: true) installed end - # Install context and skills and refresh the generated index. + # Install context and skills. # @parameter gem [String | Nil] An optional provider gem. # @returns [Hash(Symbol, Array(String))] The installed provider and skill names. def install(gem: nil) - providers = gem ? [find_gem_with_context(gem)].compact : find_gems_with_context - - # Read ordinary document metadata before modifying installed skills: - providers.each do |provider| - list_context_files(provider[:name]).each do |path| - Document.load(path).description if File.extname(path).downcase == ".md" - end - end - installed_skills = @skills.install(gem: gem) installed_context = if gem install_gem_context(gem) ? [gem] : [] @@ -182,8 +171,6 @@ def install(gem: nil) install_all_context end - Index.new(@context_path, specifications: @specifications).update_index - {context: installed_context, skills: installed_skills} end diff --git a/post.md b/post.md index a8c63b9..f99c651 100644 --- a/post.md +++ b/post.md @@ -1,5 +1,7 @@ # Ruby and Claude's Great Adventure: How We Made Gems AI-Friendly +This article describes the original release. See the [current usage guide](guides/getting-started/readme.md) for dependency context and skill installation. + *Once upon a time, in the bustling world of software development, Ruby and Claude were having a conversation that would change everything...* ## The Problem: A Tale of Miscommunication diff --git a/readme.md b/readme.md index c3c3901..5f7851a 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Agent::Context -Provides tools for installing and managing context files and skills from Ruby gems for AI agents, and generating `.agents/context/index.md`. +Provides tools for installing and managing context files and skills from Ruby gems for AI agents. [![Development Status](https://github.com/socketry/agent-context/workflows/Test/badge.svg)](https://github.com/socketry/agent-context/actions?workflow=Test) @@ -8,7 +8,7 @@ Provides tools for installing and managing context files and skills from Ruby ge This gem allows you to install and manage context files from other gems. Gems can provide context files in a `context/` directory in their root, which can contain documentation, configuration examples, migration guides, and other contextual information for AI agents. -When you install context from gems, ordinary guides are placed in `.agents/context/` and dependency skills in `.agents/skills/`. The generated `.agents/context/index.md` links to the guides. +When you install context from gems, ordinary guides are placed in `.agents/context/` and dependency skills in `.agents/skills/`. Browse `.agents/context/GEM/` or use the list/show commands to find relevant guides. ## Quick Start @@ -24,8 +24,6 @@ This workflow: - Adds the `agent-context` gem to your project. - Installs context files from all gems into `.agents/context/`. - Installs metadata-declared skills into `.agents/skills/`. - - Generates `.agents/context/index.md` with a comprehensive overview. - - Follows the specification for agentic coding tools. ## Context @@ -34,7 +32,7 @@ This gem provides its own context files in the `context/` directory, including: - `getting-started.md` - Comprehensive guide for using and providing context files and skills. - [`usage.md`](context/usage.md) - Agent instructions for finding dependency guidance and managing context and skills, installed as the `agent-context-usage` skill. -When you install context from other gems, they will be placed in `.agents/context/` and referenced in `.agents/context/index.md`. +When you install context from other gems, they will be placed in `.agents/context/`. ## Usage @@ -54,7 +52,7 @@ $ bundle add agent-context #### Install Context (Primary Command) -Install ordinary context and skills from all available gems and update `.agents/context/index.md`: +Install ordinary context and skills from all available gems: ``` bash $ bake agent:context:install @@ -88,14 +86,6 @@ Show the content of a specific context file: $ bake agent:context:show --gem async --file thread-safety ``` -#### Refresh the Context Index - -Refresh the index from installed context: - -``` bash -$ bake agent:context:index -``` - #### Skills List, show, or install metadata-declared dependency skills: @@ -111,7 +101,7 @@ $ bake agent:context:skill:install --gem provider --skill provider-workflow ## Version Control -Add a stable link to `.agents/context/index.md` in your repository-owned `agents.md`, together with instructions to read relevant installed guides. Run installation after changing dependencies. When migrating from an older generated `agents.md`, replace its generated dependency listing with this link while retaining project instructions. +In your repository instructions, direct agents to read relevant Markdown guides under `.agents/context/GEM/` and apply matching skills from `.agents/skills/`. Run installation after changing dependencies. Installation maintains a marked block in the local Git exclude file, discovered through `git rev-parse --git-path info/exclude`. It excludes generated context, ownership files, and exact dependency-installed skill directories. Track project-owned instructions and skills under `.agents/`. Remove any blanket `/.agents/` rule from your project's `.gitignore` when adopting this layout. Re-run installation in each checkout. @@ -123,28 +113,11 @@ To provide context files in your gem, create a `context/` directory in your gem' ├── context/ │ ├── getting-started.md │ ├── usage.md - │ ├── configuration.md - │ └── index.yaml (optional) + │ └── configuration.md ├── lib/ └── your-gem.gemspec -### Optional: Custom Index File - -You can provide a custom `index.yaml` file to control ordering and metadata: - -``` yaml -description: "Your gem description from gemspec" -version: "1.0.0" -files: - - path: getting-started.md - title: "Getting Started" - description: "Quick start guide" - - path: usage.md - title: "Usage Guide" - description: "Detailed usage instructions" -``` - -Installation generates `.agents/context/index.md` directly from Markdown headings, prose summaries, and document ordering, with the gem summary supplying the package description. A provider-authored `index.yaml` customizes these values. +Use descriptive filenames and focused Markdown guides. Agents find the installed files through directory listing and the list/show commands. ### Providing Skills diff --git a/releases.md b/releases.md index 1a9a219..99a9229 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + +- Discover dependency guidance through installed Markdown files and the list/show commands. Remove generated indexes and the `agent:context:index` task. + ## v0.3.0 - Rename `agent.md` -\> `agents.md`. diff --git a/specification.md b/specification.md index d527391..ea46bd8 100644 --- a/specification.md +++ b/specification.md @@ -150,7 +150,7 @@ Common file names: 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. -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. +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/`. 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. @@ -197,13 +197,15 @@ FOR each package with context: END ``` -## 6. Generated Index and Repository Ownership +## 6. Discovering Installed Context -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. +Consumers SHOULD provide commands to list providers, list their context files, and show a selected file. Agents discover installed guidance by enumerating Markdown files under `.agents/context//` and reading the files relevant to their task. A packaged usage skill SHOULD explain this workflow and how to install missing 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. +Providers SHOULD use descriptive filenames, keep guides focused on one topic, and link related documents. Ordinary documents need no front matter. 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. +The installed directory layout is the discovery interface. Consumers MUST NOT generate a context index or interpret `context/index.yaml` as indexing metadata. Provider-authored files are copied as ordinary resources according to section 5. + +Context installation MUST NOT create or modify the repository owner's `agents.md`. Repository owners can instruct agents to read relevant installed guidance under `.agents/context/`. ## 7. Skill Installation Ownership diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index 77f012a..60bb6c1 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -20,10 +20,9 @@ def around it "reads native front matter with a YAML format hint and standard dates" do File.write(path, "--- yaml\ntitle: Metadata title\ndate: 2026-10-09\nupdated: 2026-10-09T12:30:00Z\n---\n\n# Actual Title\n\nFirst sentence. Second sentence.\n") + expect(document.metadata["title"]).to be == "Metadata title" expect(document.metadata["date"]).to be == Date.new(2026, 10, 9) expect(document.metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) - expect(document.title).to be == "Actual Title" - expect(document.description).to be == "First sentence." end it "preserves the body bytes and source file when front matter uses CRLF" do @@ -31,7 +30,6 @@ def around content = "---\ndescription: Explicit summary.\n---\n".gsub("\n", "\r\n") + body File.binwrite(path, content) expect(document.body).to be == body - expect(document.description).to be == "Explicit summary." expect(File.binread(path)).to be == content end @@ -40,13 +38,11 @@ def around parsed = subject.parse(content, path: path) expect(parsed.metadata).to be == {} expect(parsed.body).to be == content - expect(parsed).not.to be(:skill?) end it "accepts empty front matter" do File.write(path, "---\n---\n# Guide\n") expect(document.metadata).to be == {} - expect(document.title).to be == "Guide" expect(document.body).to be == "# Guide\n" end diff --git a/test/agent/context/exclusion.rb b/test/agent/context/exclusion.rb new file mode 100644 index 0000000..787277c --- /dev/null +++ b/test/agent/context/exclusion.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "agent/context/installer" +require "tmpdir" + +describe Agent::Context::Skills::Exclusion do + let(:directory) {Dir.mktmpdir} + + def around + yield + ensure + FileUtils.rm_rf(directory) + end + + it "scopes Git exclusions to each project in a repository" do + system("git", "init", "--quiet", directory, exception: true) + exclusion = Agent::Context::Skills::Exclusion + ["", "apps/web [dev]", "apps/worker"].each do |relative| + root = File.join(directory, relative) + FileUtils.mkdir_p(root) + exclusion.prepare(root, ["provider-old"]).apply + end + exclusion.prepare(File.join(directory, "apps/web [dev]"), ["provider-new"]).apply + exclusion.prepare(directory, ["provider-old"]).apply + + { + ".agents/skills/provider-old/SKILL.md" => true, + "apps/worker/.agents/skills/provider-old/SKILL.md" => true, + "apps/web [dev]/.agents/context/provider/guide.md" => true, + "apps/web [dev]/.agents/skills/provider-new/SKILL.md" => true, + "apps/web [dev]/.agents/skills/provider-old/SKILL.md" => false, + "apps/web [dev]/.agents/skills/project-local/SKILL.md" => false, + "apps/web d/.agents/skills/provider-new/SKILL.md" => false, + }.each do |path, ignored| + _, status = Open3.capture2e("git", "check-ignore", "--quiet", path, chdir: directory) + expect(status.success?).to be == ignored + end + end + + it "maintains local exclusions without hiding project-owned instructions or skills" do + system("git", "init", "--quiet", directory, exception: true) + exclude = File.join(directory, ".git", "info", "exclude") + File.write(exclude, "user-rule\n") + skill = File.join(directory, ".agents", "skills", "cargo-workflow") + FileUtils.mkdir_p(skill) + Agent::Context::Skills::Ownership.write(skill, "provider", "1.0", ecosystem: "cargo") + Agent::Context::Installer.new(root: directory, specifications: []).install + first = File.read(exclude) + Agent::Context::Installer.new(root: directory, specifications: []).install + expect(File.read(exclude)).to be == first + expect(first).to be(:include?, "user-rule") + expect(first).to be(:include?, "/.agents/skills/cargo-workflow/") + expect(first.lines).not.to be(:include?, "/.agents/\n") + expect(first.lines).not.to be(:include?, "/.agents/skills/\n") + end +end diff --git a/test/agent/context/index.rb b/test/agent/context/index.rb deleted file mode 100644 index be7a4e5..0000000 --- a/test/agent/context/index.rb +++ /dev/null @@ -1,151 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2025-2026, by Samuel Williams. -# Copyright, 2025, by Shopify Inc. - -require "agent/context/index" -require "tmpdir" - -describe Agent::Context::Index do - let(:directory) {Dir.mktmpdir} - let(:context_path) {File.join(directory, ".agents", "context")} - let(:index) {subject.new(context_path)} - - def around - FileUtils.mkdir_p(context_path) - yield - ensure - FileUtils.rm_rf(directory) - end - - def write(path, content) - target = File.join(context_path, path) - FileUtils.mkdir_p(File.dirname(target)) - File.write(target, content) - end - - it "generates an empty index without creating agents.md" do - index.update_index - expect(File.read(File.join(context_path, "index.md"))).to be(:include?, "No context files are installed.") - expect(File).not.to be(:exist?, File.join(directory, "agents.md")) - end - - it "preserves repository-owned agent instructions exactly" do - agents = File.join(directory, "agents.md") - File.write(agents, "# Agent\n\n## Context\n\nHandwritten guidance.\n") - index.update_index - expect(File.read(agents)).to be == "# Agent\n\n## Context\n\nHandwritten guidance.\n" - end - - it "extracts a real heading and first prose sentence while skipping code" do - write("example/guide.md", "```ruby\n# False heading\n```\n\n# Real Title\n\nFirst sentence. Second sentence.\n") - content = index.generate_index - expect(content).to be(:include?, "### [Real Title](example/guide.md)") - expect(content).to be(:include?, "First sentence.") - expect(content).not.to be(:include?, "Second sentence.") - expect(content).not.to be(:include?, "False heading") - end - - it "prefers custom index ordering and metadata and appends unlisted guides" do - write("example/getting-started.md", "# Getting Started\n\nDefault description.") - write("example/configuration.md", "---\ndescription: Front matter description.\ntitle: Unrelated metadata\n---\n\n# Configuration\n\nIgnored prose.") - write("example/unlisted.md", "# Unlisted\n") - write("example/index.yaml", {"description" => "Provider description", "files" => [{"path" => "configuration.md", "title" => "Custom Configuration", "description" => "Custom description."}, {"path" => "missing.md"}]}.to_yaml) - content = index.generate_index - expect(content).to be(:include?, "Provider description") - expect(content).to be(:include?, "Custom description.") - expect(content.index("Custom Configuration")).to be < content.index("Getting Started") - expect(content).to be(:include?, "Unlisted") - expect(content).not.to be(:include?, "missing.md") - end - - it "extracts readable titles and summaries from inline Markdown" do - write("example/guide.md", "# [Guide](https://example.test) for **Ruby**\n\nUse **bold**, `code`, and HTML & text.\nSecond sentence.\n") - content = index.generate_index - expect(content).to be(:include?, "### [Guide for Ruby](example/guide.md)") - expect(content).to be(:include?, "Use bold, code, and HTML & text.") - expect(content).not.to be(:include?, "Second sentence.") - end - - it "skips nested headings and paragraphs when extracting document metadata" do - write("example/guide.md", "> # Quoted title\n>\n> Quoted summary.\n\n- Listed summary.\n\n#\n\n# Guide\n\nActual summary.\n") - content = index.generate_index - expect(content).to be(:include?, "### [Guide](example/guide.md)") - expect(content).to be(:include?, "Actual summary.") - expect(content).not.to be(:include?, "Quoted") - expect(content).not.to be(:include?, "Listed") - end - - it "uses front matter descriptions and filename-derived fallback titles" do - write("example/notes-file.md", "---\ndescription: Explicit summary.\nlayout: guide\n---\n\nOrdinary prose.") - expect(index.generate_index).to be(:include?, "### [notes file](example/notes-file.md)") - expect(index.generate_index).to be(:include?, "Explicit summary.") - end - - it "escapes titles and percent-encodes relative links" do - write("example/a [file] #.md", "# [Title](https://example.test)\n\nUse safely.") - content = index.generate_index - expect(content).to be(:include?, "(example/a%20%5Bfile%5D%20%23.md)") - expect(content).not.to be(:include?, "](https://example.test)") - end - - it "excludes skill documents and resources even when a custom index lists them" do - write("example/workflow.md", "---\ntype: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") - write("example/workflow/references/checklist.md", "---\ninvalid: [yaml\n---\n\n# Checklist\n") - write("example/guide.md", "# Guide\n") - write("example/index.yaml", {"files" => [{"path" => "workflow.md"}, {"path" => "workflow/references/checklist.md"}]}.to_yaml) - content = index.generate_index - expect(content).to be(:include?, "Guide") - expect(content).not.to be(:include?, "Workflow") - expect(content).not.to be(:include?, "Checklist") - end - - it "falls back when provider index YAML is malformed" do - write("example/guide.md", "# Guide\n") - write("example/index.yaml", "invalid: yaml: [") - expect(index.generate_index).to be(:include?, "Guide") - end - - it "scopes Git exclusions to each project in a repository" do - system("git", "init", "--quiet", directory, exception: true) - exclusion = Agent::Context::Skills::Exclusion - ["", "apps/web [dev]", "apps/worker"].each do |relative| - root = File.join(directory, relative) - FileUtils.mkdir_p(root) - exclusion.prepare(root, ["provider-old"]).apply - end - exclusion.prepare(File.join(directory, "apps/web [dev]"), ["provider-new"]).apply - exclusion.prepare(directory, ["provider-old"]).apply - - { - ".agents/skills/provider-old/SKILL.md" => true, - "apps/worker/.agents/skills/provider-old/SKILL.md" => true, - "apps/web [dev]/.agents/context/index.md" => true, - "apps/web [dev]/.agents/skills/provider-new/SKILL.md" => true, - "apps/web [dev]/.agents/skills/provider-old/SKILL.md" => false, - "apps/web [dev]/.agents/skills/project-local/SKILL.md" => false, - "apps/web d/.agents/skills/provider-new/SKILL.md" => false, - }.each do |path, ignored| - _, status = Open3.capture2e("git", "check-ignore", "--quiet", path, chdir: directory) - expect(status.success?).to be == ignored - end - end - - it "maintains local exclusions without hiding project-owned instructions or skills" do - system("git", "init", "--quiet", directory, exception: true) - exclude = File.join(directory, ".git", "info", "exclude") - File.write(exclude, "user-rule\n") - skill = File.join(directory, ".agents", "skills", "cargo-workflow") - FileUtils.mkdir_p(skill) - Agent::Context::Skills::Ownership.write(skill, "provider", "1.0", ecosystem: "cargo") - index.update_index - first = File.read(exclude) - index.update_index - expect(File.read(exclude)).to be == first - expect(first).to be(:include?, "user-rule") - expect(first).to be(:include?, "/.agents/skills/cargo-workflow/") - expect(first.lines).not.to be(:include?, "/.agents/\n") - expect(first.lines).not.to be(:include?, "/.agents/skills/\n") - end -end diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 0a2765d..9f965c2 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -148,36 +148,6 @@ def around expect(File).to be(:exist?, File.join(target_context_path, "getting-started.md")) expect(File).to be(:exist?, File.join(target_context_path, "configuration.md")) end - - it "generates the Markdown index directly without per-provider YAML" do - File.write(File.join(context_path, "notes.md"), "First paragraph.\n\nSecond paragraph.") - helper = subject.new(root: @target_path, specifications: @specifications) - target_context_path = File.join(helper.context_path, "fake-gem") - FileUtils.mkdir_p(target_context_path) - File.write(File.join(target_context_path, "index.yaml"), "description: Old generated metadata.\n") - - expect(helper.install[:context]).to be == ["fake-gem"] - expect(File).not.to be(:exist?, File.join(target_context_path, "index.yaml")) - expect(File).not.to be(:exist?, File.join(context_path, "index.yaml")) - - index = File.read(File.join(helper.context_path, "index.md")) - expect(index).to be(:include?, "A fake gem for testing") - expect(index).to be(:include?, "### [notes](fake-gem/notes.md)") - expect(index).to be(:include?, "First paragraph.") - expect(index.index("Getting Started")).to be < index.index("Configuration") - end - - it "copies provider-authored YAML unchanged and honors its overrides" do - authored = "# Provider-owned metadata\ndescription: Custom provider description.\nfiles:\n- path: configuration.md\n title: Custom Configuration\n" - File.write(File.join(context_path, "index.yaml"), authored) - helper = subject.new(root: @target_path, specifications: @specifications) - helper.install - expect(File.read(File.join(helper.context_path, "fake-gem", "index.yaml"))).to be == authored - expect(File.read(File.join(context_path, "index.yaml"))).to be == authored - index = File.read(File.join(helper.context_path, "index.md")) - expect(index).to be(:include?, "Custom provider description.") - expect(index.index("Custom Configuration")).to be < index.index("Getting Started") - end end end diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index f8af31c..e670f83 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -31,7 +31,7 @@ def around FileUtils.rm_rf(directory) end - it "enumerates gem specifications during discovery and index generation" do + it "enumerates gem specifications during discovery" do enumerations = 0 specifications = Enumerator.new do |yielder| enumerations += 1 @@ -39,12 +39,11 @@ def around end installer = subject.new(root: consumer, specifications: specifications) skills = Agent::Context::Skills::Installer.new(root: consumer, specifications: specifications) - index = Agent::Context::Index.new(installer.context_path, specifications: specifications) expect(enumerations).to be == 0 expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["provider"] expect(skills.find_gems_with_skills.map{|gem| gem[:name]}).to be == ["provider"] installer.install - expect(index.generate_index).to be(:include?, "Provider guidance.") + expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == File.read(File.join(provider, "context/guide.md")) end it "installs context and skills without duplicating skill documents or assets" do @@ -58,7 +57,20 @@ def around skill_root = File.join(consumer, ".agents/skills/provider-workflow") expect(File.read(File.join(skill_root, "SKILL.md"))).to be(:include?, "license: MIT") expect(File).to be(:exist?, File.join(skill_root, "references/checklist.md")) - expect(File.read(File.join(consumer, ".agents/context/index.md"))).not.to be(:include?, "Workflow") + expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/index.md")) + end + + it "updates Git exclusions when a provider only has ordinary context" do + system("git", "init", "--quiet", consumer, exception: true) + File.delete(File.join(provider, "context/workflow.md")) + FileUtils.rm_rf(File.join(provider, "context/workflow")) + + expect(installer.install(gem: "provider")).to be == {context: ["provider"], skills: []} + path = ".agents/context/provider/guide.md" + expect(File.read(File.join(consumer, path))).to be == File.read(File.join(provider, "context/guide.md")) + _, status = Open3.capture2e("git", "check-ignore", "--quiet", path, chdir: consumer) + expect(status.success?).to be == true + expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/index.md")) end it "excludes skill instructions and resources from listing and showing context" do @@ -70,7 +82,7 @@ def around end.to raise_exception(ArgumentError) end - it "validates ordinary metadata before modifying installed skills" do + it "validates front matter when classifying context documents" do File.write(File.join(provider, "context/guide.md"), "---\ndescription: [broken\n---\n\n# Guide") expect do installer.install @@ -92,13 +104,12 @@ def around expect(metadata).not.to be(:key?, "type") expect(metadata["date"]).to be == Date.new(2026, 10, 9) expect(metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) - expect(File.read(File.join(consumer, ".agents/context/index.md"))).to be(:include?, "Use the provider.") end it "handles a provider containing only skills" do File.delete(File.join(provider, "context/guide.md")) installer.install(gem: "provider") - expect(File.read(File.join(consumer, ".agents/context/index.md"))).to be(:include?, "No context files are installed.") + expect(Dir.children(File.join(consumer, ".agents/context/provider"))).to be == [] expect(File).to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) end it "restores ordinary context when replacing a provider fails" do diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index 6b810f9..1133823 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -10,14 +10,12 @@ it "exposes context and skill tasks without the old command namespace" do Dir.mktmpdir do |root| context = Bake::Context.load(root) - %w[agent:context:list agent:context:show agent:context:install agent:context:index agent:context:skill:list agent:context:skill:show agent:context:skill:install].each do |name| + %w[agent:context:list agent:context:show agent:context:install agent:context:skill:list agent:context:skill:show agent:context:skill:install].each do |name| expect(context.lookup(name)).to be_truthy end + expect(context.lookup("agent:context:index")).to be_nil expect(context.lookup("agent:context:agents_md")).to be_nil expect(context.lookup("agent:skills:install")).to be_nil - context.call("agent:context:index") - expect(File).to be(:exist?, File.join(root, ".agents/context/index.md")) - expect(File).not.to be(:exist?, File.join(root, "agents.md")) end end @@ -50,11 +48,12 @@ end.to raise_exception(ArgumentError) context.call("agent:context:skill:install", "--gem", "provider", "--skill", "provider-workflow") context.call("agent:context:install", "--gem", "provider") - context.call("agent:context:index") expect(output.join("\n")).to be(:include?, "Provider Guide") expect(output.join("\n")).to be(:include?, "provider-workflow (provider@1.0.0)") expect(output.join("\n")).to be(:include?, "Installed skills: provider-workflow") - expect(File).to be(:exist?, File.join(root, ".agents/context/index.md")) + expect(File).not.to be(:exist?, File.join(root, ".agents/context/index.md")) + expect(File).to be(:exist?, File.join(root, ".agents/context/provider/guide.md")) + expect(File).not.to be(:exist?, File.join(root, "agents.md")) expect(File).to be(:exist?, File.join(root, ".agents/skills/provider-workflow/SKILL.md")) FileUtils.rm_rf(File.join(provider, "context")) context.call("agent:context:list") From 5eb768ac0e1148c06863ded0240c7bd462ad2010 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 11 Oct 2026 19:23:02 +0530 Subject: [PATCH 20/37] Verify ordinary context metadata is copied unchanged. --- test/agent/context/skills.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index e670f83..f4e58a4 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -60,6 +60,15 @@ def around expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/index.md")) end + it "copies ordinary document metadata without interpreting descriptions" do + guide = "---\ndescription: {text: Provider metadata.}\n---\n\n# Guide\n" + File.write(File.join(provider, "context/guide.md"), guide) + + expect(installer.show_context_file("provider", "guide")).to be == guide + installer.install + expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == guide + end + it "updates Git exclusions when a provider only has ordinary context" do system("git", "init", "--quiet", consumer, exception: true) File.delete(File.join(provider, "context/workflow.md")) From 63e72ceaf7da967192a613b18eaba30038f93136 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 10:01:31 +0530 Subject: [PATCH 21/37] Align skill front matter and installation responsibilities. --- context/getting-started.md | 7 +- context/usage.md | 14 +- design.md | 130 ++++++------------ guides/getting-started/readme.md | 7 +- lib/agent/context/document.rb | 92 ++++++++----- lib/agent/context/exclusion.rb | 93 +++++++++++++ lib/agent/context/installer.rb | 178 ++++++++---------------- lib/agent/context/package.rb | 114 ++++++++++++++++ lib/agent/context/skills/definition.rb | 76 ++++++++++- lib/agent/context/skills/exclusion.rb | 102 -------------- lib/agent/context/skills/installer.rb | 180 ++++--------------------- lib/agent/context/skills/ownership.rb | 28 ++-- readme.md | 9 +- releases.md | 3 + specification.md | 51 ++++--- test/agent/context/document.rb | 40 +++++- test/agent/context/exclusion.rb | 6 +- test/agent/context/skills.rb | 28 ++-- test/agent/context/skills/installer.rb | 141 +++++++++---------- test/agent/context/skills/ownership.rb | 40 +++--- test/agent/context/tasks.rb | 2 +- test/fixtures/SKILL.md | 22 +++ test/fixtures/skill.json | 5 - 23 files changed, 681 insertions(+), 687 deletions(-) create mode 100644 lib/agent/context/exclusion.rb create mode 100644 lib/agent/context/package.rb delete mode 100644 lib/agent/context/skills/exclusion.rb create mode 100644 test/fixtures/SKILL.md delete mode 100644 test/fixtures/skill.json diff --git a/context/getting-started.md b/context/getting-started.md index 749cf8b..1661dca 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -173,7 +173,8 @@ To provide a skill, put a Markdown document directly inside `context/`: ```markdown --- -type: skill +metadata: + type: skill description: Follow the workflow when setting up this package. --- @@ -182,13 +183,13 @@ 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. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. +`context/workflow.md` in gem `provider` defaults to `provider-workflow/SKILL.md`. An authored `name` is preserved and used as the installed directory name. The matching `context/workflow/` directory supplies resources. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. ## Ownership and Updates -Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. After resolving an installation error, rerun the command to refresh generated files. +Installed `SKILL.md` front matter records `metadata.type: skill` and the resolved `metadata.ecosystem`, `metadata.package`, and `metadata.version`. Ruby refreshes reconcile skills within their selected ecosystem and package scope. A full refresh also removes skills from removed or empty providers. After resolving an installation error, rerun the command to refresh generated files. ## Example Context Files diff --git a/context/usage.md b/context/usage.md index 8ef9b40..3268e87 100644 --- a/context/usage.md +++ b/context/usage.md @@ -1,5 +1,6 @@ --- -type: skill +metadata: + type: skill description: Install and navigate guidance and skills from Ruby gem dependencies, or publish context and skills from a gem using agent-context. --- @@ -37,7 +38,7 @@ bundle exec bake agent:context:skill:install bundle exec bake agent:context:skill:install --gem agent-context --skill agent-context-usage ``` -Skill names are prefixed by the provider gem. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. This document installs as `agent-context-usage/SKILL.md`. +An authored skill name determines its installed directory; a missing name defaults to `-`. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. This document installs as `agent-context-usage/SKILL.md`. ## Keep Sources and Installed Files Separate @@ -45,7 +46,7 @@ The provider's tracked `context/` directory is source content. Installed depende Installation maintains local Git exclusions for generated context and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. -Each installed skill directory contains `skill.json` recording its provider ecosystem, package, and version. Skill discovery comes from source document metadata. Full Ruby skill refreshes reconcile gem-owned skills and remove stale skills. After resolving an installation error, rerun the command to refresh generated files. +Installed `SKILL.md` front matter records `metadata.type: skill` and the resolved `metadata.ecosystem`, `metadata.package`, and `metadata.version`. Ruby refreshes reconcile skills within their selected ecosystem and package scope. A full refresh also removes skills from removed or empty providers. After resolving an installation error, rerun the command to refresh generated files. ## Publish Context and Skills @@ -55,7 +56,8 @@ To publish a skill, add `context/workflow.md` with metadata describing when to u ```markdown --- -type: skill +metadata: + type: skill description: Set up a project using this gem's conventions. --- @@ -64,6 +66,6 @@ description: Set up a project using this gem's conventions. Follow the setup instructions. ``` -The filename supplies the local name: gem `provider` installs this document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer supplies the required skill `name`, removes the source `type`, and includes additional metadata. Top-level `SKILL.md` and `skill.json` are reserved for generated instructions and ownership. +For this example, gem `provider` derives the missing name and installs the document as `.agents/skills/provider-workflow/SKILL.md`. Put companion resources in `context/workflow/`; they are copied alongside the generated instructions. The installer preserves an authored `name` and derives a missing name from the provider package and filename. It retains the description, additional metadata, and original Markdown body, and adds resolved provenance under `metadata`. The top-level resource name `SKILL.md` is reserved for generated instructions. -Include `context/**/*` in the gemspec's packaged files. Installed skill names must use lowercase ASCII letters, digits, and single hyphens, with at most 64 characters including the package prefix. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. +Include `context/**/*` in the gemspec's packaged files. Installed skill names must use lowercase ASCII letters, digits, and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. diff --git a/design.md b/design.md index 42e4dad..448534a 100644 --- a/design.md +++ b/design.md @@ -1,120 +1,68 @@ # Agent Context Design -This document proposes a common architecture for the Ruby `agent-context` gem and Rust `bake-agent-context` crate. It explains the boundaries that should make both implementations easy to understand and change together. +The Ruby `agent-context` gem and Rust `bake-agent-context` crate share a portable installation contract. The [specification](specification.md) defines file formats and observable behavior; this guide maps the implementation responsibilities. -The [specification](specification.md) defines the portable file formats and installation behavior. This design describes implementation responsibilities. The current-state comparison refers to Ruby PR #4 and Rust PR #30; the target architecture and implementation sequence below are proposed work. +Choose the simplest structure that makes the behavior, state, and responsibilities easy to follow. -## Current Architecture +## Responsibilities -The implementations share the provider layout, metadata-declared skills, per-directory `skill.json` ownership, and installed directory layout. Their internal boundaries differ: - -| Concern | Ruby today | Rust today | +| Concern | Ruby | Rust | | --- | --- | --- | -| Dependency discovery | Both installers enumerate gem specifications and build provider hashes. | `Installer::discover` resolves Cargo metadata and stores `ContextPackage` values. | -| Document interpretation | `Document` wraps Markly, YAML metadata, and original body extraction. | `skill::parse_skill_document` interprets Markdown and metadata. Generic front matter helpers live in `skill.rs`. | -| Skill definitions | `Skills::Definition` stores generated instructions and provider details, and writes staged files. | `Skill` stores metadata, body, and its package. Functions in `skill.rs` discover, validate, stage, reconcile, and replace skills. | -| Ownership and Git | `Skills::Ownership` and `Skills::Exclusion` are separate classes. Skill installation updates exclusions for installed context and skills. | Ownership lives in `skill.rs`; Git exclusions live in `exclude.rs`. | -| Installation sequence | The library coordinates skills and context installation. Ordinary context uses staged directory replacement. | The Bake task coordinates context and skills installation. Ordinary context removes its destination before copying. | - -Ruby retains the original Markdown body when generating skill instructions. Rust currently renders the body from its Markdown AST. - -The main design issue is overlapping ownership of work. Ordinary context discovery depends on full skill discovery in both implementations. Rust keeps generic document interpretation in its skill module. Installation and discovery also share large implementation units. Formatting helps readers, but these boundaries determine how much code they must understand to make one change. - -Agents discover guidance through the installed directory layout and list/show commands. The packaged usage skills explain this workflow. - -The current installers also carry staging and restoration code for generated output. The target flow below uses direct writes and incremental progress; adopting it is part of the proposed implementation work. - -## Design Principles - -- Choose the simplest structure that makes the behavior, state, and responsibilities easy to follow. -- Give each domain rule one owner, shared by all commands that need it. -- Align responsibilities, data flow, and observable behavior across languages. Use idiomatic Ruby objects and Rust types, modules, and functions. -- Keep construction limited to storing supplied state and cheap normalization. Name operations that read, parse, discover, prepare, or install explicitly. -- Keep coordination methods readable as a sequence of domain operations. Introduce logical phases with behavioral comments, keep related statements together, and separate phases with blank lines. -- Validate data as part of loading or parsing it, and use that validated representation when applying changes. -- Prefer straightforward operations with explicit effects and simple error handling. Write generated files directly, propagate failures, and allow completed steps to remain in place for a later refresh. - -## Proposed Boundaries - -These boundaries can be expressed with existing objects and small cohesive modules. Keep related helpers together when they have the same owner. - -| Boundary | Responsibility | Ruby shape | Rust shape | -| --- | --- | --- | --- | -| Package discovery | Resolve dependencies and produce package identity, selector, and context root. | A package value shared by both installers; gem discovery at the boundary. | Existing `ContextPackage`; Cargo discovery functions. | -| Document | Interpret one Markdown source and its front matter. | `Document`. | A parsed `ContextDocument` in its own module. | -| Skill definition | Apply skill rules to a document and package; describe instructions and companion resources. | `Skills::Definition`. | `Skill`. | -| Installation | Select providers, classify files, check destination ownership, write generated files, and reconcile stale skills. | `Installer` and `Skills::Installer`. | `Installer` and focused skill installation functions. | -| Skill ownership | Read, validate, write, and scan per-directory ownership records. | `Skills::Ownership`. | An ownership module containing `SkillOwner` and its operations. | -| Git exclusion | Load Git exclusions, update the consuming project's block, and write the result. | `Exclusion` under `Agent::Context`. | Functions in `exclude`. | -| Bake tasks | Translate arguments, call library operations, and format results. | `bake/agent/context*.rb`. | Task adapters exported from the crate. | - -Package values carry ecosystem, package name, version, context root, and the selector needed by their ecosystem. Cargo can distinguish resolved versions; gem discovery uses the active specifications. A selector identifies a provider to the user; ownership retains the package name and version defined by the specification. - -```mermaid -flowchart TD - Tasks[Bake tasks] --> Installer[Installation coordinator] - Installer --> Packages[Package discovery] - Installer --> Documents[Document loading and classification] - Documents --> Skills[Skill definitions] - Installer --> Ownership[Skill ownership] - Installer --> Files[File copying and writing] - Installer --> Exclusions[Git exclusion update] -``` +| Resolved provider | `Gem::Specification`, retained by `Package` | `ContextPackage`, resolved by `Installer::discover` | +| Parsed Markdown | `Document` | `ContextDocument` in `document.rs` | +| Loaded provider context | `Package` | `PackageContext` | +| Skill definition | `Skills::Definition` | `Skill` | +| Installation workflow | `Installer#install`, `Skills::Installer` | `Installer::install`, skill installation functions | +| Installed provenance | `Skills::Ownership` | `ownership` module | +| Local Git exclusions | `Exclusion` | `exclude` module | +| Command presentation | Bake task adapters | Bake task adapters | -File classification and skill definition consume the document abstraction. Package resolution, directory traversal, destination mutation, and Git commands belong to the operations that explicitly perform those effects. +Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. -## Document Contract +Ruby keeps a gem specification with its loaded files. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`. Cargo selectors can distinguish multiple resolved versions; Ruby operates on the supplied gem specifications. Those package-manager differences share the same document and installation rules. -`Document.load(path)` / `ContextDocument::load(path)` read a source file and delegate to `parse`. `parse(content, path)` interprets already supplied text, using the path for diagnostics and filename-derived values. Constructors retain the resulting state. +## Documents and Front Matter -The document exposes: +`Document.load(path)` and `ContextDocument::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. -- The source path and original content. -- Decoded front matter, including additional provider metadata. -- The declared document kind: ordinary guidance or a skill. -- The original body after the front matter recognized by the Markdown parser. +Markly and `socketry-markdown` identify front matter. The document layer decodes YAML from the node's raw value and classifies context using the nested `metadata.type` field. Ordinary guides can carry their own top-level fields. Other front matter languages remain opaque. -Markly and `socketry-markdown` own Markdown syntax and front matter recognition. YAML decoding and context metadata interpretation live in the document layer. That layer reports malformed metadata and unsupported declared kinds with source locations. Skill-specific name, required description, and length checks belong to the skill definition. +A skill definition validates its top-level name and description. It preserves an authored name and uses it for the installed directory. When the name is omitted, it derives `-`. Resource directories follow the source filename. Description validation trims whitespace to measure the value; the authored value is retained. -Both implementations should preserve the source body when generating `SKILL.md`, using the parser's source positions to identify the front matter boundary. Only the generated front matter and its separator need serialization. Tests should cover formatting, links, fenced blocks, Unicode, and line endings; shared fixtures are useful where both implementations exercise the same rule. +To generate installed instructions, the definition merges resolved `ecosystem`, `package`, and `version` into the nested metadata mapping and supplies a missing name. The document writes the YAML back to its front matter AST node and asks the Markdown library to serialize that node. It appends the original body using the parser's source boundary. Links, code blocks, spacing, Unicode, and body line endings retain their original bytes. -## Discovery and Installation +The portable YAML form uses `---` delimiters. Rust also recognizes the additional front matter forms supported by `socketry-markdown`, such as a leading YAML code fence, and retains their delimiter style. Markly's supported syntax defines the Ruby parsing boundary. -Resolve package metadata once for an operation and pass the resulting values through context and skill processing. For each selected provider, inspect root-level Markdown declarations first. Classify companion skill directories before traversing other context paths; their contents are opaque resources. Reuse that classification for listing, showing, copying, and skill installation. +## Discovery and Loading -Retain loaded documents and their validated metadata for installation. Write ordinary Markdown from the loaded source and generate skill instructions from the same parsed representation. Copy opaque resources directly. A sequence such as `Document.load(path).write(destination)` / `ContextDocument::load(path)?.write(destination)?` expresses the intended separation between reading and writing. +For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The main library operation follows these steps: +The loaded package stores ordinary Markdown documents, opaque resource paths, and prepared skill definitions. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then passes the resulting values to context and skill installation. Ordinary Markdown is written from the loaded source; opaque files are copied directly. -1. **Discover:** resolve and select providers for the requested scope. -2. **Load:** parse and validate documents, classify ordinary files and skill resources, and load installation ownership. -3. **Install:** check destination ownership, refresh generated provider and skill directories, and write their files directly. Establish each skill's `skill.json` before writing instructions and copying resources so a failure in those later steps leaves its ownership identifiable. -4. **Reconcile:** remove stale skills within the requested ownership scope and remove generated resources that the provider no longer supplies. -5. **Refresh exclusions:** update the consuming project's Git exclusion block from installed ownership. +## Installation Flow -Git exclusion updates follow the same load, transform, and write pattern. The coordinator loads the exclusion file, updates this project's block using installed skill names, and writes the result. +The library owns orchestration. Bake tasks translate arguments, call the library, and present installed provider and skill names. -Installation is a straightforward, rerunnable operation. Use `File.write` / `std::fs::write` for generated files and propagate ordinary I/O errors with the relevant path. Completed changes and partially written output can remain when a later step fails. After resolving the reported failure, rerunning installation refreshes generated output, ownership, and exclusions. Loading and writing are separate operations that work with the loaded snapshot; this is the intended contract for local generated state. +1. Resolve the selected providers and load their documents. +2. Validate selected skill names, read installed provenance, and check destination ownership. +3. Refresh selected skill directories, writing `SKILL.md` before their resources. +4. Remove stale skills within the selected ownership scope and refresh the project's Git exclusions. +5. Refresh ordinary context from the loaded documents and opaque resources. -`skill.json` remains the ownership authority. Scanning installed directories supplies reconciliation and exclusion inputs. The ownership module handles the record format; the installer decides which ecosystem, package, or named skill the operation selects. +Generated files use direct `File.write` or `std::fs::write`. Completed steps remain in place if a later operation fails. Report the relevant path and let the caller resolve the failure and rerun. Cleanup around a failed instruction write removes the incomplete destination; a later resource-copy failure retains the completed instructions and their provenance. -## Alignment Decisions +Loading and writing are separate operations over a loaded snapshot. `Document.load(path).write(destination)` expresses that contract. Installation is a local, rerunnable workflow with incremental progress. -The shared specification is authoritative for portable behavior. The Rust implementation remains a useful reference for the package model and metadata-driven workflow; the Ruby document abstraction provides the common parsing boundary. Changes to either implementation should be evaluated against the shared design. +## Ownership and Git Exclusions -Align responsibilities incrementally and test each implementation against the agreed behavior. The proposed changes are: +Installed `SKILL.md` metadata carries the resolved ecosystem, package, and version. Scanning direct skill directories supplies the names used for reconciliation and local Git exclusions. Ownership recognition requires `metadata.type: skill` and an explicit ecosystem before validating the complete tuple. Project skills can use unrelated metadata such as a version without claiming installation ownership. -- Preserve skill body source formatting in Rust, matching the proposed document contract. -- Simplify context, skill, and exclusion updates around direct writes and rerunnable progress in both implementations. -- Bring the full installation sequence into the Rust library so Bake tasks delegate orchestration consistently. +A full refresh selects one ecosystem; a package refresh selects one ecosystem and package; an individual skill install selects one name. Destination collisions are reported. Other owned scopes and project skills remain available. -Dependency resolution and command option names can remain ecosystem-specific. Test document interpretation, generated skill metadata and body, ownership scopes, reserved resource names, context file discovery, and rerunning installation after a failure. Use shared fixtures where they make a common format or rule easier to verify. Compare exact bytes when formatting or preservation is part of the contract; otherwise compare meaning. +Git exclusion handling locates and reads the local exclusion file, replaces the consuming project's marked block, and writes the result directly. Prefixes distinguish nested projects sharing a repository. The block lists generated context and exact installed skill directories from all ecosystems. -## Implementation Sequence +## Readability and Verification -1. Introduce the Rust parsed document module and route file classification and skill definition through it. -2. Share provider values and file classification within each implementation so ordinary context and skills use the same discovery result. -3. Separate Rust ownership and installation operations from skill definition code. Move Ruby Git exclusions to the context namespace. -4. Simplify installation and exclusion updates to direct operations, then align library orchestration and address the listed output differences. Use focused tests for the agreed behavior and shared fixtures where useful. +Multi-step operations group related calculations and effects under short behavioral comments, separated by blank lines. Method documentation explains single-step operations. Helpers represent distinct responsibilities or independently meaningful operations. -For each step, update the current-state comparison and validate the affected public commands. Keep new helpers private until callers need a public contract. Promote the general lessons about construction, responsibility, readability, and cross-language alignment into the shared Socketry conventions. +Both implementations exercise the shared `SKILL.md` fixture. Compare decoded metadata for format rules and exact body bytes for preservation. Tests cover classification, authored and derived names, metadata preservation, ownership scopes, opaque resources, Git exclusions, failures, and a successful retry. Keep the specification and this component mapping aligned when behavior or responsibilities change. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 749cf8b..1661dca 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -173,7 +173,8 @@ To provide a skill, put a Markdown document directly inside `context/`: ```markdown --- -type: skill +metadata: + type: skill description: Follow the workflow when setting up this package. --- @@ -182,13 +183,13 @@ 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. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. +`context/workflow.md` in gem `provider` defaults to `provider-workflow/SKILL.md`. An authored `name` is preserved and used as the installed directory name. The matching `context/workflow/` directory supplies resources. The generated instructions include additional skill metadata. Installed names must use lowercase ASCII letters, digits and single hyphens, with at most 64 characters. Descriptions must contain 1–1,024 characters. Include the complete source directories in your gem's packaged files. Skill documents and resource trees are installed under `.agents/skills/`. ## Ownership and Updates -Each installed skill directory contains `skill.json` to record its provider ecosystem, package, and version. Full Ruby refreshes reconcile gem-owned skills and remove stale skills. A selected-skill install updates the selected skill. After resolving an installation error, rerun the command to refresh generated files. +Installed `SKILL.md` front matter records `metadata.type: skill` and the resolved `metadata.ecosystem`, `metadata.package`, and `metadata.version`. Ruby refreshes reconcile skills within their selected ecosystem and package scope. A full refresh also removes skills from removed or empty providers. After resolving an installation error, rerun the command to refresh generated files. ## Example Context Files diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 3aaf7aa..f11bd89 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -9,63 +9,85 @@ module Agent module Context - # Represents a context document parsed into Markdown and metadata. + # Represents a Markdown source and its parsed front matter. class Document - # Raised when context front matter cannot be safely interpreted. + # Raised when context front matter cannot be interpreted. class Invalid < ArgumentError end - # Read and parse a document from its source path. - # @parameter path [String] The Markdown source file. - # @returns [Document] The parsed document. + # Read and parse a Markdown document. def self.load(path) parse(File.read(path), path: path) end - # Parse Markdown and validate its front matter. - # @parameter content [String] The Markdown source content. - # @parameter path [String] The source path used for error messages. - # @returns [Document] The parsed document. + # Parse YAML front matter using Markdown's native syntax tree. def self.parse(content, path:) - parts = Markly.parse(content, flags: Markly::FRONT_MATTER).to_a - front_matter = parts.first if parts.first&.type == :front_matter + # Let Markly identify the front matter and its source boundary: + first = Markly.parse(content, flags: Markly::FRONT_MATTER).first_child + node = first if first&.type == :front_matter + language = node&.fence_info&.split&.first - metadata = if front_matter - YAML.safe_load( - front_matter.string_content, - permitted_classes: [Date, Time], - aliases: false, - fallback: {}, - ) - else - {} + # Interpret YAML metadata, keeping other formats opaque: + frontmatter = {} + if node && [nil, "yaml", "yml"].include?(language) + frontmatter = YAML.safe_load(node.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) + raise Invalid, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) end - raise Invalid, "Context frontmatter must be a mapping: #{path}" unless metadata.is_a?(Hash) - - new(content, front_matter, metadata) + new(path, content, node, frontmatter) rescue Psych::Exception => error raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" end - # Initialize a document from parsed Markdown and metadata. - # @parameter content [String] The original Markdown source content. - # @parameter front_matter [Markly::Node | Nil] The leading front matter node. - # @parameter metadata [Hash] The decoded front matter. - def initialize(content, front_matter, metadata) + # Retain the source and its parsed representation. + def initialize(path, content, node, frontmatter) + @path = path @content = content - @front_matter = front_matter - @metadata = metadata + @node = node + @frontmatter = frontmatter end - # @attribute [Hash] Decoded context metadata. - attr_reader :metadata + # @attribute [String] The document source path. + attr_reader :path + + # @attribute [String] The original Markdown source. + attr_reader :content - # @returns [String] The original Markdown body after the front matter node. + # @attribute [Hash] The decoded YAML front matter. + attr_reader :frontmatter + + # The extensible metadata mapping within front matter. + def metadata + value = @frontmatter["metadata"] + value.is_a?(Hash) ? value : {} + end + + # Classify documents from their context metadata. + def skill? + type = metadata["type"] + unless type.nil? || type == "skill" + raise Invalid, "Unsupported context type in #{@path}: #{type.inspect}" + end + + type == "skill" + end + + # The original body following the parser's front matter boundary. def body - return @content unless @front_matter + return @content unless @node - @content.lines.drop(@front_matter.source_position[:end_line]).join + @content.lines.drop(@node.source_position[:end_line]).join + end + + # Serialize edited front matter through its AST node, preserving the body. + def render(frontmatter) + @node.string_content = YAML.dump(frontmatter).delete_prefix("---\n") + @node.to_markdown + body + end + + # Write the loaded source to its destination. + def write(path) + File.write(path, @content) end end end diff --git a/lib/agent/context/exclusion.rb b/lib/agent/context/exclusion.rb new file mode 100644 index 0000000..d1aa835 --- /dev/null +++ b/lib/agent/context/exclusion.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "open3" +require "fileutils" + +module Agent + module Context + # Represents local Git exclusions shared by context and skill installers. + class Exclusion + # Raised when the generated exclusion block cannot be interpreted. + class Invalid < StandardError + end + + 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. + # @returns [Exclusion | Nil] The prepared update when Git is available for this project. + def self.prepare(root, names) + output, status = Open3.capture2e( + "git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", + chdir: root, + ) + return unless status.success? + + prefix, path = output.lines(chomp: true) + path = File.expand_path(path, root) + original = File.exist?(path) ? File.read(path) : "" + + begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip + end_marker = "#{END_MARKER} #{prefix}".rstrip + + # Replace this project's block, retaining the surrounding Git exclusions: + 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 Invalid, "Malformed Agent Context exclusion block: #{path}" + end + + lines.slice!(begins.first..ends.first) + end + + lines.pop while lines.last == "" + lines << "" unless lines.empty? + + pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} + lines.concat([ + begin_marker, + "# Generated by Bake Agent Context; do not edit this block manually.", + "/#{pattern}.agents/context/", + *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"}, + end_marker, + ]) + + new(path, original, lines.join("\n") + "\n") + rescue Errno::ENOENT + # Installation also works when Git is unavailable: + nil + end + + # Initialize a prepared exclusion update. + # @parameter path [String] The Git exclusion file path. + # @parameter original [String] The original file contents. + # @parameter updated [String] The prepared file contents. + def initialize(path, original, updated) + @path = path + @original = original + @updated = updated + end + + # Apply the prepared exclusion block. + def apply + write(@updated) if @updated != @original + end + + private + + def write(content) + FileUtils.mkdir_p(File.dirname(@path)) + + File.write(@path, content) + end + end + end +end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 00dfcee..9132339 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -2,178 +2,108 @@ # Released under the MIT License. # Copyright, 2025, by Shopify Inc. -# Copyright, 2025, by Samuel Williams. +# Copyright, 2025-2026, by Samuel Williams. require "rubygems" -require "fileutils" require "pathname" -require "tmpdir" - require_relative "paths" -require "agent/context/skills/installer" +require_relative "skills/installer" +require_relative "package" module Agent module Context - # Installer class for managing context files from Ruby gems. - # - # This class provides methods to find, list, show, and install context files - # from gems that provide them in a `context/` directory. + # Represents discovery and installation of context from resolved Ruby gems. class Installer - # Initialize a new Installer instance. - # - # @parameter root [String] The root directory to work from (default: current directory). - # @parameter specifications [Gem::Specification] The gem specifications to search (default: all installed gems). + # Retain the consuming project and gem specifications. def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @root = File.expand_path(root) @context_path = File.join(@root, CONTEXT_PATH) @specifications = specifications - @skills = Agent::Context::Skills::Installer.new(root: @root, specifications: @specifications) end + # @attribute [String] The consuming project root. attr_reader :root + + # @attribute [String] The installed context directory. attr_reader :context_path - # Find all gems that have a context directory + # Find providers with ordinary context. def find_gems_with_context(skip_local: true) - gems_with_context = [] - - @specifications.each do |spec| - # Skip gems loaded from current working directory if requested: - next if skip_local && File.expand_path(spec.full_gem_path) == @root - - context_path = File.join(spec.full_gem_path, "context") - if Dir.exist?(context_path) - gems_with_context << { - name: spec.name, - version: spec.version.to_s, - summary: spec.summary, - metadata: spec.metadata, - path: context_path - } - end - end - - gems_with_context + packages(skip_local: skip_local).reject{|package| package.files.empty?}.map(&:information) end - # Find a specific gem with context. + # Find one provider with ordinary context. def find_gem_with_context(gem_name) - spec = @specifications.find{|spec| spec.name == gem_name} - return nil unless spec - - context_path = File.join(spec.full_gem_path, "context") - - if Dir.exist?(context_path) - { - name: spec.name, - version: spec.version.to_s, - summary: spec.summary, - metadata: spec.metadata, - path: context_path - } - else - nil - end + package = packages(gem: gem_name).first + package.information if package && !package.files.empty? end - # List context files for a gem. + # List a provider's ordinary context paths. def list_context_files(gem_name) - gem = find_gem_with_context(gem_name) - return nil unless gem - - skill_paths = Array(@skills.list_skills(gem_name)).flat_map do |skill| - [skill.source_file, skill.path].compact - end - - Dir.glob(File.join(gem[:path], "**/*"), File::FNM_DOTMATCH).select do |file| - next unless File.file?(file) && !File.symlink?(file) - - skill_paths.none?{|path| file == path || file.start_with?("#{path}/")} - end + package = packages(gem: gem_name).first + package.files.keys if package && !package.files.empty? end - # Show content of a specific context file. + # Read a selected ordinary context document or resource. def show_context_file(gem_name, file_name) - gem = find_gem_with_context(gem_name) - return nil unless gem - + # Resolve the requested path within the loaded provider files: requested = Pathname.new(file_name) if requested.absolute? || requested.each_filename.any?{|part| part == ".."} raise ArgumentError, "Context file must be a relative path inside context/" end - candidates = [File.join(gem[:path], file_name)] + package = packages(gem: gem_name).first + return unless package + candidates = [File.join(package.path, file_name)] candidates << "#{candidates.first}.md" if File.extname(file_name).empty? + path = candidates.find{|candidate| package.files.key?(candidate)} + return unless path - available = list_context_files(gem_name) - path = candidates.find{|candidate| available.include?(candidate)} - - path ? File.read(path) : nil + # Return loaded Markdown or read the selected opaque resource: + package.files[path]&.content || File.read(path) end - # Install context from a specific gem. + # Refresh ordinary context for one provider. def install_gem_context(gem_name) - gem = find_gem_with_context(gem_name) - return false unless gem - - files = list_context_files(gem_name) - target_path = File.join(@context_path, gem_name) - - FileUtils.mkdir_p(@context_path) - Dir.mktmpdir(".agent-context-staging-", @context_path) do |stage| - fresh = File.join(stage, "new") - backup = File.join(stage, "old") - FileUtils.mkdir_p(fresh) - - files.each do |source| - relative = Pathname.new(source).relative_path_from(Pathname.new(gem[:path])).to_s - destination = File.join(fresh, relative) - FileUtils.mkdir_p(File.dirname(destination)) - FileUtils.copy_file(source, destination, true) - end - - previous = File.exist?(target_path) || File.symlink?(target_path) - File.rename(target_path, backup) if previous - - begin - File.rename(fresh, target_path) - rescue - File.rename(backup, target_path) if previous - raise - end - end - - true + package = packages(gem: gem_name).first + package ? package.install(File.join(@context_path, package.name)) : false end - # Install context from all gems. + # Refresh ordinary context for all selected providers. def install_all_context(skip_local: true) - gems = find_gems_with_context(skip_local: skip_local) - installed = [] - - gems.each do |gem| - if install_gem_context(gem[:name]) - installed << gem[:name] - end + packages(skip_local: skip_local).filter_map do |package| + package.name if package.install(File.join(@context_path, package.name)) end - - installed end - # Install context and skills. - # @parameter gem [String | Nil] An optional provider gem. - # @returns [Hash(Symbol, Array(String))] The installed provider and skill names. + # Install context and skills from the same loaded provider documents. def install(gem: nil) - installed_skills = @skills.install(gem: gem) - installed_context = if gem - install_gem_context(gem) ? [gem] : [] - else - install_all_context + # Resolve providers and parse their documents once: + selected = packages(gem: gem) + raise Skills::Installer::Error, "No gem found: #{gem}" if gem && selected.empty? + definitions = selected.flat_map(&:skills) + + # Refresh skills and ordinary context using that loaded representation: + skills = Skills::Installer.new(root: @root, specifications: @specifications) + installed_skills = skills.install_definitions(definitions, package: gem, reconcile: true) + installed_context = selected.filter_map do |package| + package.name if package.install(File.join(@context_path, package.name)) end - {context: installed_context, skills: installed_skills} end + private + + def packages(gem: nil, skip_local: true) + specifications = @specifications.select do |specification| + gem ? specification.name == gem : !skip_local || File.expand_path(specification.full_gem_path) != @root + end + if gem && specifications.length > 1 + raise Skills::Installer::Conflict, "Multiple resolved versions provide gem #{gem.inspect}" + end + + specifications.map{|specification| Package.load(specification)} + end end end end diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb new file mode 100644 index 0000000..97aa34f --- /dev/null +++ b/lib/agent/context/package.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" +require_relative "document" +require_relative "skills/definition" + +module Agent + module Context + # Represents a resolved gem and its loaded context documents and skills. + class Package + # Load and classify a gem's context, keeping skill resources opaque. + def self.load(specification) + package = new(specification, {}, []) + root = package.path + return package unless File.exist?(root) || File.symlink?(root) + + unless File.lstat(root).directory? + raise Skills::Installer::InvalidSkill, "Context provider must be a regular directory: #{root}" + end + + package.load_directory(root) + package + rescue Document::Invalid => error + raise Skills::Installer::InvalidSkill, error.message + end + + # Retain the provider and its classified files. + def initialize(specification, files, skills) + @specification = specification + @files = files + @skills = skills + end + + # @attribute [Gem::Specification] The resolved provider. + attr_reader :specification + + # @attribute [Hash(String, Document | Nil)] Ordinary files and loaded Markdown. + attr_reader :files + + # @attribute [Array(Skills::Definition)] Prepared skill definitions. + attr_reader :skills + + # @returns [String] The provider gem name. + def name + @specification.name + end + + # @returns [String] The provider context directory. + def path + File.join(@specification.full_gem_path, "context") + end + + # Describe a provider for command presentation. + def information + { + name: name, + version: @specification.version.to_s, + summary: @specification.summary, + metadata: @specification.metadata, + path: path, + skills: @skills, + } + end + + # Load Markdown before descending into companion directories. + def load_directory(directory) + # Classify declarations before traversing their resource directories: + directories = [] + Dir.children(directory).sort.each do |name| + file = File.join(directory, name) + stat = File.lstat(file) + if stat.directory? + directories << file + elsif stat.file? + document = File.extname(file).downcase == ".md" ? Document.load(file) : nil + if document&.skill? + raise Skills::Installer::InvalidSkill, "Skill documents must be directly inside context/: #{file}" unless directory == path + @skills << Skills::Definition.from_document(document, @specification) + else + @files[file] = document + end + end + end + + # Companion resources belong to the skill and are copied as opaque files: + resources = @skills.map(&:path) + directories.each do |directory| + load_directory(directory) unless resources.include?(directory) + end + @skills.sort_by!(&:name) + end + + # Refresh ordinary context from the loaded documents and opaque resources. + def install(destination) + FileUtils.rm_rf(destination) + return false if @files.empty? + + @files.each do |source, document| + target = File.join(destination, source.delete_prefix("#{path}/")) + FileUtils.mkdir_p(File.dirname(target)) + if document + document.write(target) + else + FileUtils.copy_file(source, target, true) + end + end + true + end + end + end +end diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index a33e51d..c9ea4d4 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -12,6 +12,67 @@ module Context module Skills # Represents a dependency skill declared by a context document and its resources. class Definition + # Build instructions from a parsed skill document and its provider. + def self.from_document(source, specification) + # Preserve authored names and derive missing names from the provider and file: + file = source.path + local_name = File.basename(file, File.extname(file)) + installed_name = source.frontmatter.fetch("name"){"#{specification.name.downcase.tr("_", "-")}-#{local_name}"} + + # Add the resolved provider identity to the authored front matter: + frontmatter = source.frontmatter.merge("name" => installed_name) + frontmatter["metadata"] = source.metadata.merge( + "ecosystem" => "gem", + "package" => specification.name, + "version" => specification.version.to_s, + ) + validate_metadata(frontmatter, file) + + # Resolve resources using the source filename: + assets = File.join(File.dirname(file), local_name) + if File.exist?(assets) || File.symlink?(assets) + unless File.lstat(assets).directory? + raise Installer::InvalidSkill, "Skill resources must be a regular directory: #{assets}" + end + else + assets = nil + end + + document = source.render(frontmatter) + + new( + name: installed_name, + description: frontmatter["description"], + path: assets, + source_file: file, + document: document, + provider_name: specification.name, + provider_version: specification.version.to_s, + ) + end + + # Validate an installed directory name. + def self.validate_name(name, file) + unless name.is_a?(String) && name.match?(Ownership::NAME_PATTERN) && name.length <= 64 + raise Installer::InvalidSkill, "Invalid skill name in #{file}: #{name.inspect}" + end + end + + # Validate the required authored skill fields. + def self.validate_metadata(frontmatter, file) + validate_name(frontmatter["name"], file) + + description = frontmatter["description"] + unless description.is_a?(String) + raise Installer::InvalidSkill, "Invalid skill description in #{file}" + end + + description = description.strip + if description.empty? || description.length > 1024 + raise Installer::InvalidSkill, "Invalid skill description in #{file}" + end + end + # Initialize a skill from a context document. # @parameter name [String] The installed skill name. # @parameter description [String] When to use the skill. @@ -57,14 +118,17 @@ def content @document end - # Write instructions and resources into a staging directory. - # @parameter destination [String] The staging directory. + # Write instructions before resources so ownership is available during refresh. + # @parameter destination [String] The installed skill directory. def write_to(destination) FileUtils.mkdir_p(destination) + begin + File.write(File.join(destination, "SKILL.md"), @document) + rescue + FileUtils.rm_rf(destination) + raise + end copy_resources(@path, destination, true) if @path - - File.write(File.join(destination, "SKILL.md"), @document) - Ownership.write(destination, @provider_name, @provider_version) end private @@ -79,7 +143,7 @@ def copy_resources(source, destination, top_level) raise Installer::InvalidSkill, "Skill resources must be regular files or directories: #{from}" end - if top_level && ["skill.md", Ownership::FILE_NAME].include?(name.downcase) + if top_level && name.downcase == "skill.md" raise Installer::InvalidSkill, "#{name} is reserved for generated skill files: #{from}" end diff --git a/lib/agent/context/skills/exclusion.rb b/lib/agent/context/skills/exclusion.rb deleted file mode 100644 index 2b0f507..0000000 --- a/lib/agent/context/skills/exclusion.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "open3" -require "fileutils" -require "tempfile" -require_relative "ownership" - -module Agent - module Context - 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. - # @returns [Exclusion | Nil] The prepared update when Git is available for this project. - def self.prepare(root, names) - output, status = Open3.capture2e( - "git", "rev-parse", "--show-prefix", "--git-path", "info/exclude", - chdir: root, - ) - return unless status.success? - - prefix, path = output.lines(chomp: true) - path = File.expand_path(path, root) - original = File.exist?(path) ? File.read(path) : "" - - begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip - end_marker = "#{END_MARKER} #{prefix}".rstrip - - # Replace this project's block, retaining the surrounding Git exclusions: - 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 Ownership::Invalid, "Malformed Agent Context exclusion block: #{path}" - end - - lines.slice!(begins.first..ends.first) - end - - lines.pop while lines.last == "" - lines << "" unless lines.empty? - - pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"} - lines.concat([ - begin_marker, - "# Generated by Bake Agent Context; do not edit this block manually.", - "/#{pattern}.agents/context/", - *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"}, - end_marker, - ]) - - new(path, original, lines.join("\n") + "\n") - rescue Errno::ENOENT - # Installation also works when Git is unavailable: - nil - end - - # Initialize a prepared exclusion update. - # @parameter path [String] The Git exclusion file path. - # @parameter original [String] The original file contents. - # @parameter updated [String] The prepared file contents. - def initialize(path, original, updated) - @path = path - @original = original - @updated = updated - end - - # Apply the prepared exclusion block. - def apply - write(@updated) if @updated != @original - end - - # Restore exclusions if installation fails. - def restore - write(@original) if @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 -end diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb index f2bab82..5123afe 100644 --- a/lib/agent/context/skills/installer.rb +++ b/lib/agent/context/skills/installer.rb @@ -7,12 +7,11 @@ require "fileutils" require "rubygems" require "yaml" -require "tmpdir" require_relative "definition" require_relative "ownership" -require_relative "exclusion" -require_relative "../document" +require_relative "../exclusion" +require_relative "../package" module Agent module Context @@ -20,8 +19,6 @@ module Skills # Represents discovery and installation of skills from resolved gems. class Installer NAME_PATTERN = Ownership::NAME_PATTERN - MAXIMUM_NAME_LENGTH = 64 - MAXIMUM_DESCRIPTION_LENGTH = 1024 # Raised when a skill cannot be installed. class Error < StandardError @@ -111,121 +108,7 @@ def install(gem: nil, skill: nil, skip_local: true) 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) - context_root = File.join(specification.full_gem_path, "context") - skills = discover_context_skills(context_root, specification) - - { - name: specification.name, - version: specification.version.to_s, - skills: skills.sort_by(&:name), - } - 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) - - # Discover root-level skills before inspecting their resource directories: - files = Dir.glob(File.join(root, "**", "*")).sort_by do |file| - [File.dirname(file) == root ? 0 : 1, file] - end - - skills = [] - files.each do |file| - next if skills.any?{|skill| skill.path && file.start_with?("#{skill.path}/")} - next unless File.file?(file) && !File.symlink?(file) - next unless File.extname(file).downcase == ".md" - - source = parse_document(file) - type = source.metadata["type"] - next unless type - - unless type == "skill" - raise InvalidSkill, "Unsupported context type in #{file}: #{type.inspect}" - end - - unless File.dirname(file) == root - raise InvalidSkill, "Skill documents must be directly inside context/: #{file}" - end - - skills << build_definition(file, source, specification) - end - - skills - end - - def build_definition(file, source, specification) - local_name = File.basename(file, File.extname(file)) - installed_name = "#{specification.name.downcase.tr("_", "-")}-#{local_name}" - validate_name(local_name, file) - - metadata = source.metadata.merge("name" => installed_name) - metadata.delete("type") - validate_metadata(metadata, file) - - assets = File.join(File.dirname(file), local_name) - if File.exist?(assets) || File.symlink?(assets) - unless File.lstat(assets).directory? - raise InvalidSkill, "Skill resources must be a regular directory: #{assets}" - end - else - assets = nil - end - - front_matter = YAML.dump(metadata).delete_prefix("---\n") - body = source.body.sub(/\A\r?\n/, "") - document = "---\n#{front_matter}---\n\n#{body}" - document += "\n" unless document.end_with?("\n") - - 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 - - def parse_document(file) - Document.load(file) - rescue Document::Invalid => error - raise InvalidSkill, error.message - end - - 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 #{file}: #{name.inspect}" - end - end - - def validate_metadata(metadata, file) - validate_name(metadata["name"], file) - - description = metadata["description"] - unless description.is_a?(String) - raise InvalidSkill, "Invalid skill description in #{file}" - end - - description = description.strip - if description.empty? || description.length > MAXIMUM_DESCRIPTION_LENGTH - raise InvalidSkill, "Invalid skill description in #{file}" - end - - metadata["description"] = description - end - + # Install prepared definitions and reconcile their ownership scope. def install_definitions(definitions, package:, reconcile:) duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1} raise Conflict, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? @@ -244,24 +127,34 @@ def install_definitions(definitions, package:, reconcile:) owner["ecosystem"] == "gem" && (!package || owner["package"] == package) end stale = reconcile ? owned.keys - names : [] - exclusion = Exclusion.prepare(@root, (owners.keys - stale + names).uniq) + # Refresh each selected skill from its loaded instructions: 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 do |definition| - definition.write_to(File.join(staged, definition.name)) - end - - replace_skills(staged, backups, names, stale, exclusion) + definitions.each do |definition| + destination = File.join(@skills_path, definition.name) + FileUtils.rm_rf(destination) + definition.write_to(destination) end + # Reconcile the requested ownership scope and refresh Git exclusions: + stale.each{|name| FileUtils.rm_rf(File.join(@skills_path, name))} + Exclusion.prepare(@root, (owners.keys - stale + names).uniq)&.apply names 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) + Package.load(specification).information + end + def validate_destinations(definitions, owners) definitions.each do |definition| owner = owners[definition.name] @@ -277,31 +170,6 @@ def validate_destinations(definitions, owners) end end - def replace_skills(staged, backups, names, stale, exclusion) - changes = [] - 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 - 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 - - exclusion&.restore - raise - end - end end end end diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb index 6c4ae16..156ba10 100644 --- a/lib/agent/context/skills/ownership.rb +++ b/lib/agent/context/skills/ownership.rb @@ -4,30 +4,20 @@ # Copyright, 2026, by Shopify Inc. # Copyright, 2026, by Samuel Williams. -require "json" +require_relative "../document" module Agent module Context module Skills # Represents installation ownership recorded in each skill directory. class Ownership - FILE_NAME = "skill.json" + FILE_NAME = "SKILL.md" NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ # Raised when installed ownership cannot be safely interpreted. class Invalid < StandardError end - # Write ownership into a staged skill directory. - # @parameter directory [String] The staged skill directory. - # @parameter package [String] The provider package. - # @parameter version [String] The provider version. - # @parameter ecosystem [String] The provider ecosystem. - def self.write(directory, package, version, ecosystem: "gem") - owner = {"ecosystem" => ecosystem, "package" => package, "version" => version} - File.write(File.join(directory, FILE_NAME), JSON.pretty_generate(owner) + "\n") - end - # Read a skill's installation ownership. # @parameter directory [String] The installed skill directory. # @returns [Hash | Nil] Ownership when the skill has an installation record. @@ -42,17 +32,17 @@ def self.load(directory) raise Invalid, "Skill ownership must be a regular file: #{path}" unless stat.file? - owner = JSON.parse(File.read(path)) - raise Invalid, "Invalid skill ownership: #{path}" unless owner.is_a?(Hash) + metadata = Document.load(path).metadata + fields = ["ecosystem", "package", "version"] + return unless metadata["type"] == "skill" && metadata.key?("ecosystem") - values = owner.values_at("ecosystem", "package", "version") - unless values.all?{|value| value.is_a?(String) && !value.strip.empty?} + unless fields.all?{|field| metadata[field].is_a?(String) && !metadata[field].strip.empty?} raise Invalid, "Invalid skill ownership: #{path}" end - owner - rescue JSON::ParserError => error - raise Invalid, "Invalid skill ownership #{path}: #{error.message}" + metadata.slice(*fields) + rescue Document::Invalid => error + raise Invalid, error.message end # Collect ownership from installed skill directories. diff --git a/readme.md b/readme.md index 5f7851a..dfc2ab9 100644 --- a/readme.md +++ b/readme.md @@ -125,7 +125,8 @@ A root-level context document can declare a skill: ```markdown --- -type: skill +metadata: + type: skill description: Run the provider's workflow when preparing a new project. license: MIT --- @@ -135,13 +136,13 @@ license: MIT Follow these instructions. ``` -`context/workflow.md` from gem `provider` becomes `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Installed names have a 64-character limit; descriptions have a 1,024-character limit. The generated instructions include additional skill metadata. +`context/workflow.md` from gem `provider` defaults to `.agents/skills/provider-workflow/SKILL.md`. Resources in `context/workflow/` are copied alongside the instructions. Installed names have a 64-character limit; descriptions have a 1,024-character limit. The generated instructions include additional skill metadata. -Skills are discovered through `type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. Installed skill names become package-prefixed. +Skills are discovered through `metadata.type: skill` metadata in `context/*.md`, matching `bake-agent-context-rust`. Include `context/**/*` in your gem's file list. Authored skill names are preserved; missing names default to `-`. ## Ownership and Updates -Each installed skill directory contains `skill.json` with its provider ecosystem, package, and version. Ruby refreshes reconcile gem-owned skills. After resolving an installation error, rerun the command to refresh generated files. A full refresh removes stale gem-owned skills, including skills from removed or empty providers. +Installed `SKILL.md` front matter records `metadata.type: skill` and the resolved `metadata.ecosystem`, `metadata.package`, and `metadata.version`. Ruby refreshes reconcile skills within their selected ecosystem and package scope. A full refresh also removes skills from removed or empty providers. After resolving an installation error, rerun the command to refresh generated files. See [the portable specification](specification.md) and [Getting Started](guides/getting-started/readme.md). diff --git a/releases.md b/releases.md index 99a9229..e56a044 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,9 @@ ## Unreleased +- Store resolved skill provenance in `SKILL.md` front matter under `metadata`, with `metadata.type: skill` declaring skills. +- Preserve authored names, descriptions, additional metadata, and the original Markdown body using native front matter AST nodes. +- Share loaded documents between context and skill installation, and use direct writes with rerunnable progress. - Discover dependency guidance through installed Markdown files and the list/show commands. Remove generated indexes and the `agent:context:index` task. ## v0.3.0 diff --git a/specification.md b/specification.md index ea46bd8..36e7a98 100644 --- a/specification.md +++ b/specification.md @@ -148,9 +148,22 @@ Common file names: ### 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 context provider may distribute an Agent Skill as a Markdown document directly inside `context/`. The document MUST use YAML front matter with `metadata.type: skill` and a non-empty top-level `description`: + +```yaml +--- +name: provider-workflow +description: Run the provider workflow when preparing a project. +metadata: + type: skill +--- +``` + +An authored top-level `name` MUST be preserved and used as the installed directory name. When `name` is absent, consumers MUST derive it from the lowercase provider package name (normalizing underscores to hyphens), a hyphen, and the source filename without its `.md` extension. 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 for validation; consumers MUST retain the authored description value. -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/`. +Consumers MUST install the document as `.agents/skills//SKILL.md`. A companion directory named after the source filename without its extension MAY provide resources, independently of the installed name. Its files MUST be copied into the installed skill directory. Consumers MUST retain additional top-level fields and nested metadata, except for the resolved installation provenance defined in section 7. Skill documents and their resources belong exclusively to the installed skill. + +Consumers MUST use their Markdown parser's front matter node to read and serialize front matter. YAML decoding and encoding operate on that node's raw value. Consumers MUST preserve the original Markdown body after the parser's front matter boundary. Front matter formatting may be normalized during serialization. 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. @@ -209,26 +222,30 @@ Context installation MUST NOT create or modify the repository owner's `agents.md ## 7. Skill Installation Ownership -Consumers MUST write `skill.json` inside each dependency-installed skill directory, alongside `SKILL.md`: - -```json -{ - "ecosystem": "cargo", - "package": "provider", - "version": "1.0.0" -} +Consumers MUST record resolved installation provenance inside `SKILL.md` front matter: + +```yaml +--- +name: provider-workflow +description: Run the provider workflow when preparing a project. +metadata: + type: skill + ecosystem: cargo + package: provider + version: "1.0.0" +--- ``` -`SKILL.md` provides agent-facing metadata and instructions. `skill.json` records installation ownership. +The installer supplies `metadata.ecosystem`, `metadata.package`, and `metadata.version` from its resolved provider, overriding source values for these fields. Ruby gems use `gem`; Cargo crates use `cargo`. These three fields MUST be non-empty strings, including a quoted version when YAML would otherwise decode it as a number. Additional metadata MUST be retained. -The ownership record MUST be a JSON object with non-empty string fields `ecosystem`, `package`, and `version`; whitespace-only values are invalid. Consumers MUST tolerate additional fields. Ruby gems use `ecosystem: gem`; Cargo crates use `ecosystem: cargo`. `package` is the provider's package name and `version` is the installed provider version. +Consumers MUST discover ownership by scanning direct child directories of `.agents/skills/` and parsing their `SKILL.md` files. The skills root and managed skill directories MUST be regular directories. Symbolic links and non-directory children are skipped. Instruction files MUST be regular files. Managed directory names MUST satisfy section 4.4. -Consumers MUST discover installed ownership by scanning direct child directories of `.agents/skills/` for `skill.json`. The skills root and managed skill directories MUST be regular directories. Symbolic links and non-directory children are skipped. Ownership files MUST be regular files. Managed directory names MUST satisfy the installed skill name rules in section 4.4. Consumers MUST reject malformed or unreadable ownership when loading it. A directory without `skill.json` is project-owned. +`metadata.type: skill` together with an explicit `metadata.ecosystem` identifies installation provenance. Consumers MUST validate the complete provenance tuple after recognizing that discriminator, and report malformed or unreadable metadata. Other directories are project-owned; an authored `metadata.version` alone is ordinary skill metadata. -A full refresh reconciles only the installer's ecosystem; a package refresh reconciles only that ecosystem and package; installing one named skill retains other owned skills. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Installers MUST retain skills owned by other ecosystems. +A full refresh reconciles the installer's ecosystem; a package refresh reconciles that ecosystem and package; installing one named skill retains other owned skills. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Consumers MUST retain skills belonging to other ecosystems and packages outside the requested scope, and MUST reject conflicting destinations. -Installers MUST restrict updates and removals of existing skill directories to the selected ecosystem and package scope, and MUST reject collisions with project-owned or differently owned destinations. Once a destination is accepted, installers MAY write instructions, resources, and ownership directly into the skill directory. When writing directly, installers SHOULD establish a valid `skill.json` before writing instructions and copying resources so a failure in those later steps leaves the destination identifiable as managed. The incremental failure behavior in section 5.2 applies to skill installation and Git exclusion updates. +Installers SHOULD write `SKILL.md` before copying companion resources, establishing ownership for subsequent refreshes. If writing the instructions fails, installers SHOULD remove that incomplete destination. Resource-copy failures leave completed instructions in place. The incremental failure behavior in section 5.2 applies to skills and Git exclusion updates. -Top-level resource names `SKILL.md` and `skill.json` are reserved, case-insensitively, for generated files. Consumers MUST reject provider resources with either name, including directories. Nested resource files with these names MAY be copied as opaque assets. +The top-level resource name `SKILL.md` is reserved case-insensitively for generated instructions. Consumers MUST reject a resource file or directory with that name. Nested files with that name and other resource names are copied as opaque assets. -Git integrations SHOULD maintain one marked exclusion block per consuming project, covering generated context and exact dependency-owned skill directories from all ecosystems. Exclusion paths MUST be relative to the Git repository root. Nested projects SHOULD identify their block by appending the repository-relative project path, with a trailing slash, to the `# BEGIN bake-agent-context` and `# END bake-agent-context` markers. They MUST retain user-authored rules and blocks belonging to other projects. The ownership file is excluded with its containing skill directory. +Git integrations SHOULD maintain one marked exclusion block per consuming project, covering generated context and exact dependency-owned skill directories from all ecosystems. Exclusion paths MUST be relative to the Git repository root. Nested projects SHOULD identify their block by appending the repository-relative project path, with a trailing slash, to the `# BEGIN bake-agent-context` and `# END bake-agent-context` markers. They MUST retain user-authored rules and blocks belonging to other projects. Installed instructions and resources are excluded with their containing skill directory. diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index 60bb6c1..6a89df4 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -20,9 +20,9 @@ def around it "reads native front matter with a YAML format hint and standard dates" do File.write(path, "--- yaml\ntitle: Metadata title\ndate: 2026-10-09\nupdated: 2026-10-09T12:30:00Z\n---\n\n# Actual Title\n\nFirst sentence. Second sentence.\n") - expect(document.metadata["title"]).to be == "Metadata title" - expect(document.metadata["date"]).to be == Date.new(2026, 10, 9) - expect(document.metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) + expect(document.frontmatter["title"]).to be == "Metadata title" + expect(document.frontmatter["date"]).to be == Date.new(2026, 10, 9) + expect(document.frontmatter["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) end it "preserves the body bytes and source file when front matter uses CRLF" do @@ -36,13 +36,13 @@ def around it "parses in-memory Markdown with thematic breaks and code examples" do content = "# Guide\n\n---\n\n~~~yaml\n---\ntype: skill\n---\n~~~\n" parsed = subject.parse(content, path: path) - expect(parsed.metadata).to be == {} + expect(parsed.frontmatter).to be == {} expect(parsed.body).to be == content end it "accepts empty front matter" do File.write(path, "---\n---\n# Guide\n") - expect(document.metadata).to be == {} + expect(document.frontmatter).to be == {} expect(document.body).to be == "# Guide\n" end @@ -59,4 +59,34 @@ def around document end.to raise_exception(subject::Invalid, message: be(:include?, path)) end + it "edits native front matter while retaining the shared fixture body" do + fixture = File.read(File.expand_path("../../fixtures/SKILL.md", __dir__)) + ["\n", "\r\n"].each do |ending| + source = subject.parse(fixture.gsub("\n", ending), path: path) + output = source.render(source.frontmatter.merge("license" => "Apache-2.0")) + installed = subject.parse(output, path: path) + expect(installed.body).to be == source.body + expect(installed.frontmatter["license"]).to be == "Apache-2.0" + expect(installed.metadata["author"]).to be == "Provider" + end + end + + it "writes the source loaded before the provider file changes" do + File.write(path, "# Original\n") + loaded = document + File.write(path, "# Changed\n") + destination = File.join(directory, "installed.md") + loaded.write(destination) + expect(File.read(destination)).to be == "# Original\n" + end + + it "uses nested skill metadata and keeps other front matter languages opaque" do + ["--- json\ninvalid: [\n---\n", "---\ntype: skill\nmetadata: unrelated\n---\n"].each do |content| + expect(subject.parse(content, path: path)).not.to be(:skill?) + end + expect do + subject.parse("---\nmetadata: {type: false}\n---\n", path: path).skill? + end.to raise_exception(subject::Invalid) + end + end diff --git a/test/agent/context/exclusion.rb b/test/agent/context/exclusion.rb index 787277c..efdd65e 100644 --- a/test/agent/context/exclusion.rb +++ b/test/agent/context/exclusion.rb @@ -6,7 +6,7 @@ require "agent/context/installer" require "tmpdir" -describe Agent::Context::Skills::Exclusion do +describe Agent::Context::Exclusion do let(:directory) {Dir.mktmpdir} def around @@ -17,7 +17,7 @@ def around it "scopes Git exclusions to each project in a repository" do system("git", "init", "--quiet", directory, exception: true) - exclusion = Agent::Context::Skills::Exclusion + exclusion = Agent::Context::Exclusion ["", "apps/web [dev]", "apps/worker"].each do |relative| root = File.join(directory, relative) FileUtils.mkdir_p(root) @@ -46,7 +46,7 @@ def around File.write(exclude, "user-rule\n") skill = File.join(directory, ".agents", "skills", "cargo-workflow") FileUtils.mkdir_p(skill) - Agent::Context::Skills::Ownership.write(skill, "provider", "1.0", ecosystem: "cargo") + File.write(File.join(skill, "SKILL.md"), "---\nmetadata:\n type: skill\n ecosystem: cargo\n package: provider\n version: 1.0.0\n---\n") Agent::Context::Installer.new(root: directory, specifications: []).install first = File.read(exclude) Agent::Context::Installer.new(root: directory, specifications: []).install diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index f4e58a4..9247046 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -24,7 +24,7 @@ def around FileUtils.mkdir_p([File.join(provider, "context/workflow/references"), consumer]) File.write(File.join(provider, "context/guide.md"), "# Guide\n\nUse the provider.") - File.write(File.join(provider, "context/workflow.md"), "---\ntype: skill\ndescription: Run the workflow.\nlicense: MIT\n---\n\n# Workflow\n") + File.write(File.join(provider, "context/workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run the workflow.\nlicense: MIT\n---\n\n# Workflow\n") File.write(File.join(provider, "context/workflow/references/checklist.md"), "# Checklist\n") yield ensure @@ -103,14 +103,14 @@ def around guide = "--- yaml\ndate: 2026-10-09\nlayout: guide\n---\n\n# Guide\n\nUse the provider.\n" File.write(File.join(provider, "context/guide.md"), guide) body = "# Workflow\r\n\r\nUse [reference][guide].\r\n\r\n~~~yaml\r\n---\r\nexample: unchanged\r\n---\r\n~~~\r\n\r\n[guide]: https://example.test\r\n" - File.binwrite(File.join(provider, "context/workflow.md"), "--- yaml\r\ntype: skill\r\nname: ignored\r\ndescription: Run the workflow.\r\ndate: 2026-10-09\r\nupdated: 2026-10-09T12:30:00Z\r\n---\r\n\r\n#{body}") + File.binwrite(File.join(provider, "context/workflow.md"), "--- yaml\r\nmetadata:\r\n type: skill\r\nname: provider-workflow\r\ndescription: Run the workflow.\r\ndate: 2026-10-09\r\nupdated: 2026-10-09T12:30:00Z\r\n---\r\n\r\n#{body}") expect(installer.install).to be == {context: ["provider"], skills: ["provider-workflow"]} expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == guide instructions = File.binread(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) expect(instructions).to be(:end_with?, body) - metadata = Agent::Context::Document.load(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")).metadata + metadata = Agent::Context::Document.load(File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")).frontmatter expect(metadata["name"]).to be == "provider-workflow" - expect(metadata).not.to be(:key?, "type") + expect(metadata["metadata"]["type"]).to be == "skill" expect(metadata["date"]).to be == Date.new(2026, 10, 9) expect(metadata["updated"]).to be == Time.utc(2026, 10, 9, 12, 30) end @@ -118,20 +118,20 @@ def around it "handles a provider containing only skills" do File.delete(File.join(provider, "context/guide.md")) installer.install(gem: "provider") - expect(Dir.children(File.join(consumer, ".agents/context/provider"))).to be == [] + expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/provider")) expect(File).to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow/SKILL.md")) end - it "restores ordinary context when replacing a provider fails" do + + it "propagates context write failures and refreshes successfully on retry" do installer.install - destination = File.join(consumer, ".agents/context/provider") - previous = File.read(File.join(destination, "guide.md")) + destination = File.join(consumer, ".agents/context/provider/guide.md") File.write(File.join(provider, "context/guide.md"), "# Updated\n") - mock(File).before(:rename) do |source, target| - raise Errno::EACCES, "Injected context failure" if File.basename(source) == "new" && target == destination + mock(File).before(:write) do |path, *| + raise Errno::EACCES, "Injected context failure" if path == destination end - expect do - installer.install_gem_context("provider") - end.to raise_exception(Errno::EACCES) - expect(File.read(File.join(destination, "guide.md"))).to be == previous + expect{installer.install_gem_context("provider")}.to raise_exception(Errno::EACCES) + mock(File).clear + installer.install_gem_context("provider") + expect(File.read(destination)).to be == "# Updated\n" end end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index e096c11..196dd9e 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -33,10 +33,15 @@ def build_specification(name, version, root) def write_skill(root, name, description:, body: "# Instructions\n\nFollow these instructions.\n") directory = File.join(root, "context") FileUtils.mkdir_p(directory) - metadata = {"type" => "skill", "description" => description} + metadata = {"metadata" => {"type" => "skill"}, "description" => description} File.write(File.join(directory, "#{name}.md"), "#{metadata.to_yaml}---\n\n#{body}") end + def write_owner(directory, package, version, ecosystem: "gem", body: "Instructions") + metadata = {"metadata" => {"type" => "skill", "ecosystem" => ecosystem, "package" => package, "version" => version}} + File.write(File.join(directory, "SKILL.md"), "#{metadata.to_yaml}---\n\n#{body}") + end + it "finds gems which provide skills" do gems = installer.find_gems_with_skills @@ -96,7 +101,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File).to be(:exist?, File.join(destination, "SKILL.md")) end - it "records ownership beside the installed instructions" do + it "records ownership in the installed front matter" do installer.install(gem: "fake-gem") path = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") @@ -178,10 +183,10 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end with "validation" do - it "uses the package-prefixed filename instead of a source name" do + it "preserves an authored name and uses it as the installed directory" do file = File.join(provider_root, "context/ruby-testing.md") - File.write(file, "---\ntype: skill\nname: Different_Name\ndescription: Test projects.\n---\n\n# Instructions\n") - expect(installer.show_skill("fake-gem", "fake-gem-ruby-testing")).to be(:include?, "name: fake-gem-ruby-testing") + File.write(file, "---\nmetadata:\n type: skill\nname: provider-authored\ndescription: Test projects.\n---\n\n# Instructions\n") + expect(installer.show_skill("fake-gem", "provider-authored")).to be(:include?, "name: provider-authored") end it "does not discover documents without skill metadata" do @@ -191,7 +196,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these it "reports unsupported context types before installation" do file = File.join(provider_root, "context/ruby-testing.md") - File.write(file, "---\ntype: unknown\n---\n\n# Instructions\n") + File.write(file, "---\nmetadata:\n type: unknown\n---\n\n# Instructions\n") expect do installer.install @@ -226,7 +231,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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.md"), "---\ndescription: Run workflow.\nlicense: MIT\nmetadata:\n type: skill\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: "fake-gem").sort).to be == ["fake-gem-ruby-testing", "fake-gem-workflow"] content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) @@ -251,36 +256,37 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these cargo = File.join(consumer_root, ".agents/skills/cargo-workflow/SKILL.md") FileUtils.mkdir_p(File.dirname(cargo)) File.write(cargo, "Cargo instructions") - Agent::Context::Skills::Ownership.write(File.dirname(cargo), "fake-gem", "2.0.0", ecosystem: "cargo") - metadata = File.read(File.join(File.dirname(cargo), "skill.json")) + write_owner(File.dirname(cargo), "fake-gem", "2.0.0", ecosystem: "cargo") + metadata = File.read(File.join(File.dirname(cargo), "SKILL.md")) installer.install - expect(File.read(cargo)).to be == "Cargo instructions" - expect(File.read(File.join(File.dirname(cargo), "skill.json"))).to be == metadata + expect(File.read(cargo)).to be == metadata + expect(File.read(File.join(File.dirname(cargo), "SKILL.md"))).to be == metadata end it "refuses a matching name owned by Cargo even when the package names match" do path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") FileUtils.mkdir_p(path) - Agent::Context::Skills::Ownership.write(path, "fake-gem", "1.0.0", ecosystem: "cargo") + write_owner(path, "fake-gem", "1.0.0", ecosystem: "cargo") expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::Conflict) end - it "preserves the installed skill and ownership when resource copying fails" do + it "keeps refreshed instructions identifiable when resource copying fails" do installer.install - ownership_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/skill.json") - previous = File.read(ownership_path) + ownership_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") - instructions = File.read(destination) write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") FileUtils.mkdir_p(File.join(provider_root, "context/ruby-testing")) File.symlink("missing", File.join(provider_root, "context/ruby-testing/broken")) expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) - expect(File.read(ownership_path)).to be == previous - expect(File.read(destination)).to be == instructions + expect(Agent::Context::Skills::Ownership.load(File.dirname(ownership_path))["package"]).to be == "fake-gem" + expect(File.read(destination)).to be(:include?, "# Updated") + File.unlink(File.join(provider_root, "context/ruby-testing/broken")) + installer.install + expect(File.read(destination)).to be(:include?, "# Updated") end it "preserves unselected skills when installing one named skill" do @@ -312,7 +318,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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") + File.write(File.join(context, "workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) @@ -322,39 +328,12 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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} "} + metadata = {"metadata" => {"type" => "skill"}, "description" => " #{"é" * 1024} "} File.write(File.join(context, "workflow.md"), "#{metadata.to_yaml}---\n\n# Workflow\n") installer.install content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) - expect(installer.list_skills("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == "é" * 1024 - expect(content).not.to be(:include?, " é") - end - - it "restores skills, ownership, and exclusions when directory replacement fails" do - system("git", "init", "--quiet", consumer_root, exception: true) - write_skill(provider_root, "documentation", description: "Write docs.") - installer.install - skill_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") - ownership_path = File.join(skill_path, "skill.json") - exclude = File.join(consumer_root, ".git/info/exclude") - previous_ownership = File.read(ownership_path) - previous_exclude = File.read(exclude) - destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") - previous_instructions = File.read(destination) - write_skill(provider_root, "ruby-testing", description: "Updated workflow.", body: "# Updated\n") - File.delete(File.join(provider_root, "context/documentation.md")) - write_skill(provider_root, "aardvark", description: "New workflow.") - mock(File).before(:rename) do |source, target| - raise Errno::EACCES, "Injected replacement failure" if target == skill_path && source.include?("/new/") - end - expect do - installer.install - end.to raise_exception(Errno::EACCES) - expect(File.read(ownership_path)).to be == previous_ownership - expect(File.read(exclude)).to be == previous_exclude - expect(File.read(destination)).to be == previous_instructions - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-documentation/SKILL.md")) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-aardvark")) + expect(installer.list_skills("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == " #{"é" * 1024} " + expect(Agent::Context::Document.parse(content, path: "installed").frontmatter["description"]).to be == " #{"é" * 1024} " end it "keeps plain guides out of skills and rejects invalid context skill metadata" do @@ -363,7 +342,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these 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 == ["fake-gem-ruby-testing"] - File.write(guide, "---\ntype: skill\ndescription: [invalid\n---\n") + File.write(guide, "---\nmetadata:\n type: skill\ndescription: [invalid\n---\n") expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) @@ -374,55 +353,37 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) end - it "refuses a reserved SKILL.md resource before changing installed skills" do + it "refuses a reserved SKILL.md resource while retaining generated ownership" do installer.install 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.md"), "---\nmetadata:\n type: skill\ndescription: Run workflow.\n---\n") File.write(File.join(context, "workflow/SKILL.md"), "Conflicting instructions") expect do installer.install end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow")) + expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) end - it "reserves ownership resource names for files and directories" do + it "reserves generated instructions and copies other resource names" do installer.install resources = File.join(provider_root, "context/workflow") FileUtils.mkdir_p(resources) write_skill(provider_root, "workflow", description: "Run workflow.") - ["skill.json", "SKILL.JSON", "SKILL.md"].each do |name| + ["SKILL.md", "skill.MD"].each do |name| path = File.join(resources, name) [false, true].each do |directory| directory ? FileUtils.mkdir_p(path) : File.write(path, "Conflict") expect{installer.install}.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) - expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-workflow")) + expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" FileUtils.rm_rf(path) end end FileUtils.mkdir_p(File.join(resources, "references")) - File.write(File.join(resources, "references/skill.json"), "Opaque resource") + File.write(File.join(resources, "skill.json"), "Opaque resource") installer.install - expect(File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/references/skill.json"))).to be == "Opaque resource" - end - - it "retains installed files and exclusions if staged ownership cannot be written" do - system("git", "init", "--quiet", consumer_root, exception: true) - installer.install - skill_path = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") - previous = Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]} - exclude = File.join(consumer_root, ".git/info/exclude") - previous_exclude = File.read(exclude) - write_skill(provider_root, "ruby-testing", description: "Updated workflow.") - mock(File).before(:write) do |path, *| - raise Errno::EACCES, "Injected ownership write failure" if path.end_with?("/skill.json") && path.include?("/new/") - end - expect{installer.install}.to raise_exception(Errno::EACCES) - expect(Dir.children(skill_path).to_h{|name| [name, File.read(File.join(skill_path, name))]}).to be == previous - expect(File.read(exclude)).to be == previous_exclude - ensure - mock(File).clear + expect(File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/skill.json"))).to be == "Opaque resource" end it "reports inaccessible ownership before changing installed files or exclusions" do @@ -443,7 +404,7 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these expect(File.read(exclude)).to be == previous_exclude end - it "works without Git and refuses malformed generated exclusion blocks" do + it "works without Git and reports malformed generated exclusion blocks" do mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} expect(installer.install).to be == ["fake-gem-ruby-testing"] mock(Open3).clear @@ -451,6 +412,30 @@ def write_skill(root, name, description:, body: "# Instructions\n\nFollow these File.write(File.join(consumer_root, ".git/info/exclude"), "# BEGIN bake-agent-context\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Ownership::Invalid) + end.to raise_exception(Agent::Context::Exclusion::Invalid) + end + it "removes an incomplete instruction write and permits a retry" do + destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") + mock(File).before(:write) do |path, *| + raise Errno::ENOSPC if path == File.join(destination, "SKILL.md") + end + expect{installer.install}.to raise_exception(Errno::ENOSPC) + expect(File).not.to be(:exist?, destination) + mock(File).clear + installer.install + expect(Agent::Context::Skills::Ownership.load(destination)["package"]).to be == "fake-gem" end + + it "installs loaded documents alongside opaque ordinary files" do + context = File.join(provider_root, "context") + File.write(File.join(context, "a-guide.md"), "# Original\n") + File.binwrite(File.join(context, "z-image.bin"), "\x00\xff".b) + package = Agent::Context::Package.load(specifications.first) + File.write(File.join(context, "a-guide.md"), "# Changed\n") + destination = File.join(consumer_root, ".agents/context/fake-gem") + package.install(destination) + expect(File.read(File.join(destination, "a-guide.md"))).to be == "# Original\n" + expect(File.binread(File.join(destination, "z-image.bin"))).to be == "\x00\xff".b + end + end diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb index 8f5204b..b0aebf3 100644 --- a/test/agent/context/skills/ownership.rb +++ b/test/agent/context/skills/ownership.rb @@ -18,17 +18,21 @@ def around FileUtils.rm_rf(directory) end - it "reads and writes the portable ownership fixture" do - fixture = File.expand_path("../../../fixtures/skill.json", __dir__) - FileUtils.copy_file(fixture, File.join(skill_path, "skill.json")) - expected = JSON.parse(File.read(fixture)) + def write_owner(directory, package, version) + metadata = {"metadata" => {"type" => "skill", "ecosystem" => "gem", "package" => package, "version" => version}} + File.write(File.join(directory, "SKILL.md"), "#{metadata.to_yaml}---\n") + end + + it "reads ownership from the shared installed skill fixture" do + fixture = File.expand_path("../../../fixtures/SKILL.md", __dir__) + FileUtils.copy_file(fixture, File.join(skill_path, "SKILL.md")) + expected = {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"} expect(subject.load(skill_path)).to be == expected - subject.write(skill_path, expected["package"], expected["version"], ecosystem: expected["ecosystem"]) - expect(JSON.parse(File.read(File.join(skill_path, "skill.json")))).to be == expected + end it "scans owned directories alongside project skills and other files" do - subject.write(skill_path, "provider", "1.0.0") + write_owner(skill_path, "provider", "1.0.0") FileUtils.mkdir_p(File.join(directory, "project-workflow")) File.write(File.join(directory, "readme.md"), "Project skills.") File.symlink(skill_path, File.join(directory, "linked-workflow")) @@ -38,12 +42,11 @@ def around it "validates ownership before treating an installation as managed" do [ - "{invalid json}", - [].to_json, - ["gem", "provider", "1.0.0"].to_json, - {"ecosystem" => "gem", "package" => " ", "version" => "1.0.0"}.to_json, + "---\nmetadata: [invalid\n---\n", + "---\nmetadata:\n type: skill\n ecosystem: gem\n---\n", + "---\nmetadata:\n type: skill\n ecosystem: gem\n package: ' '\n version: '1.0'\n---\n", ].each do |content| - File.write(File.join(skill_path, "skill.json"), content) + File.write(File.join(skill_path, "SKILL.md"), content) expect do subject.scan(directory) end.to raise_exception(subject::Invalid) @@ -51,7 +54,7 @@ def around end it "reports inaccessible ownership files and installation roots" do - subject.write(skill_path, "provider", "1.0.0") + write_owner(skill_path, "provider", "1.0.0") File.chmod(0o400, skill_path) expect{subject.load(skill_path)}.to raise_exception(Errno::EACCES) expect{subject.scan(File.join(skill_path, "skills"))}.to raise_exception(Errno::EACCES) @@ -60,7 +63,7 @@ def around end it "requires regular ownership files and skill directories" do - File.symlink("missing.json", File.join(skill_path, "skill.json")) + File.symlink("missing.json", File.join(skill_path, "SKILL.md")) expect do subject.load(skill_path) end.to raise_exception(subject::Invalid) @@ -74,11 +77,18 @@ def around ["Bad_Name", "a" * 65].each do |name| path = File.join(directory, name) FileUtils.mkdir_p(path) - subject.write(path, "provider", "1.0.0") + write_owner(path, "provider", "1.0.0") expect do subject.scan(directory) end.to raise_exception(subject::Invalid) FileUtils.rm_rf(path) end end + it "keeps project skill metadata separate from installation provenance" do + ["version: '1.0'", "type: skill, version: '1.0'", "ecosystem: gem, package: provider, version: '1.0'"].each do |metadata| + File.write(File.join(skill_path, "SKILL.md"), "---\nmetadata: {#{metadata}}\n---\nProject instructions.\n") + expect(subject.scan(directory)).to be == {} + end + end + end diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index 1133823..c0c43da 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -24,7 +24,7 @@ provider = File.join(root, "provider") FileUtils.mkdir_p(File.join(provider, "context")) File.write(File.join(provider, "context/guide.md"), "# Provider Guide\n\nUse the package.\n") - File.write(File.join(provider, "context/workflow.md"), "---\ntype: skill\ndescription: Run the workflow.\n---\n\n# Workflow\n") + File.write(File.join(provider, "context/workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run the workflow.\n---\n\n# Workflow\n") specification = Gem::Specification.new do |spec| spec.name = "provider" spec.version = "1.0.0" diff --git a/test/fixtures/SKILL.md b/test/fixtures/SKILL.md new file mode 100644 index 0000000..65c8122 --- /dev/null +++ b/test/fixtures/SKILL.md @@ -0,0 +1,22 @@ +--- +name: provider-workflow +description: Run the provider workflow. +license: MIT +metadata: + type: skill + ecosystem: cargo + package: provider + version: "1.0.0" + author: Provider +--- + +# Workflow + +Use [the guide][guide] and `inline code`. + +~~~yaml +metadata: + type: ordinary-example +~~~ + +[guide]: https://example.test/guide diff --git a/test/fixtures/skill.json b/test/fixtures/skill.json deleted file mode 100644 index 13c198c..0000000 --- a/test/fixtures/skill.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ecosystem": "gem", - "package": "provider", - "version": "1.0.0" -} From cf78e2954ee368b27a4376ccd8bb59fe861451d7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 10:05:18 +0530 Subject: [PATCH 22/37] Cover provider validation failures. --- test/agent/context/skills.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index 9247046..25a8dc0 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -134,4 +134,21 @@ def around installer.install_gem_context("provider") expect(File.read(destination)).to be == "# Updated\n" end + + it "reports ambiguous resolved versions before installing a named provider" do + other = specification.dup + other.version = "2.0.0" + installer = subject.new(root: consumer, specifications: [specification, other]) + expect{installer.install(gem: "provider")}.to raise_exception(Agent::Context::Skills::Installer::Conflict) + expect(File).not.to be(:exist?, File.join(consumer, ".agents")) + end + + it "requires a regular context directory before loading a provider" do + context = File.join(provider, "context") + FileUtils.rm_r(context) + File.write(context, "Provider configuration") + expect{installer.install}.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + expect(File).not.to be(:exist?, File.join(consumer, ".agents")) + end + end From bffeab2268dbc0e8b5a1cac9a565bcba75a65b88 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 10:17:53 +0530 Subject: [PATCH 23/37] Share the skill document through a Sus fixture module. --- fixtures/agent/context/test_document.rb | 16 ++++++++++++++++ .../agent/context/test_document}/SKILL.md | 0 test/agent/context/document.rb | 4 +++- test/agent/context/skills/ownership.rb | 5 ++--- 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 fixtures/agent/context/test_document.rb rename {test/fixtures => fixtures/agent/context/test_document}/SKILL.md (100%) diff --git a/fixtures/agent/context/test_document.rb b/fixtures/agent/context/test_document.rb new file mode 100644 index 0000000..cc07e4f --- /dev/null +++ b/fixtures/agent/context/test_document.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Agent + module Context + # Provides the skill document shared by parsing and ownership tests. + module TestDocument + # @returns [String] The path to the shared skill document. + def self.path + File.expand_path("test_document/SKILL.md", __dir__) + end + end + end +end diff --git a/test/fixtures/SKILL.md b/fixtures/agent/context/test_document/SKILL.md similarity index 100% rename from test/fixtures/SKILL.md rename to fixtures/agent/context/test_document/SKILL.md diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index 6a89df4..f8d6107 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require "agent/context/document" +require "agent/context/test_document" require "tmpdir" require "fileutils" @@ -59,8 +60,9 @@ def around document end.to raise_exception(subject::Invalid, message: be(:include?, path)) end + it "edits native front matter while retaining the shared fixture body" do - fixture = File.read(File.expand_path("../../fixtures/SKILL.md", __dir__)) + fixture = File.read(Agent::Context::TestDocument.path) ["\n", "\r\n"].each do |ending| source = subject.parse(fixture.gsub("\n", ending), path: path) output = source.render(source.frontmatter.merge("license" => "Apache-2.0")) diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb index b0aebf3..3cab1eb 100644 --- a/test/agent/context/skills/ownership.rb +++ b/test/agent/context/skills/ownership.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Shopify Inc. require "agent/context/skills/ownership" +require "agent/context/test_document" require "fileutils" require "tmpdir" @@ -24,11 +25,9 @@ def write_owner(directory, package, version) end it "reads ownership from the shared installed skill fixture" do - fixture = File.expand_path("../../../fixtures/SKILL.md", __dir__) - FileUtils.copy_file(fixture, File.join(skill_path, "SKILL.md")) + FileUtils.copy_file(Agent::Context::TestDocument.path, File.join(skill_path, "SKILL.md")) expected = {"ecosystem" => "cargo", "package" => "provider", "version" => "1.0.0"} expect(subject.load(skill_path)).to be == expected - end it "scans owned directories alongside project skills and other files" do From e22a02fad5431e38ab33270307ad9255750191cf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 10:28:47 +0530 Subject: [PATCH 24/37] Clarify the shared context installation contract. --- design.md | 6 ++++-- specification.md | 38 ++++++++++++++++++-------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/design.md b/design.md index 448534a..e2a5f10 100644 --- a/design.md +++ b/design.md @@ -19,13 +19,15 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. -Ruby keeps a gem specification with its loaded files. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`. Cargo selectors can distinguish multiple resolved versions; Ruby operates on the supplied gem specifications. Those package-manager differences share the same document and installation rules. +Ruby keeps a gem specification with its loaded files. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`. Both apply the same document and installation rules. + +Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. ## Documents and Front Matter `Document.load(path)` and `ContextDocument::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. -Markly and `socketry-markdown` identify front matter. The document layer decodes YAML from the node's raw value and classifies context using the nested `metadata.type` field. Ordinary guides can carry their own top-level fields. Other front matter languages remain opaque. +Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. An absent or null type denotes ordinary context; `skill` declares a skill; other values produce an unsupported-type error. Ordinary guides can carry their own top-level fields. Other front matter languages remain opaque. A skill definition validates its top-level name and description. It preserves an authored name and uses it for the installed directory. When the name is omitted, it derives `-`. Resource directories follow the source filename. Description validation trims whitespace to measure the value; the authored value is retained. diff --git a/specification.md b/specification.md index 36e7a98..e180a3f 100644 --- a/specification.md +++ b/specification.md @@ -15,7 +15,7 @@ AI agents and automated tools working with software projects often lack access t - **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. +- **Extensibility**: Preserve additional provider-authored metadata alongside the fields defined by this specification. ## 2. Core Concepts @@ -35,7 +35,7 @@ A **Context Consumer** is any project or tool that utilizes contextual informati - 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. +- Read and apply guidance relevant to the task. ### 2.3 Context Files @@ -83,10 +83,12 @@ Context consumers SHOULD create a `.agents/context/` directory in their project - **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. +- **Organization**: Must organize context by resolved provider in subdirectories named by a package selector. - **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. +A package selector identifies one resolved provider. It is normally the package name. Consumers that support multiple resolved versions MUST distinguish them, for example with `package-a@1.0.0` and `package-a@2.0.0`. Tools SHOULD display accepted selectors when listing providers and use those selectors consistently in commands and installed directory names. + Example structure: ``` @@ -159,11 +161,13 @@ metadata: --- ``` +Within a context document's YAML `metadata` mapping, an absent or null `type` denotes ordinary context; the string `skill` declares a skill. Consumers MUST reject any other non-null `metadata.type` value and report it as an unsupported context type. + An authored top-level `name` MUST be preserved and used as the installed directory name. When `name` is absent, consumers MUST derive it from the lowercase provider package name (normalizing underscores to hyphens), a hyphen, and the source filename without its `.md` extension. 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 for validation; consumers MUST retain the authored description value. Consumers MUST install the document as `.agents/skills//SKILL.md`. A companion directory named after the source filename without its extension MAY provide resources, independently of the installed name. Its files MUST be copied into the installed skill directory. Consumers MUST retain additional top-level fields and nested metadata, except for the resolved installation provenance defined in section 7. Skill documents and their resources belong exclusively to the installed skill. -Consumers MUST use their Markdown parser's front matter node to read and serialize front matter. YAML decoding and encoding operate on that node's raw value. Consumers MUST preserve the original Markdown body after the parser's front matter boundary. Front matter formatting may be normalized during serialization. +Consumers MUST preserve the original Markdown body bytes following the front matter. Front matter formatting MAY be normalized during serialization. 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. @@ -188,31 +192,25 @@ The discovery process SHOULD integrate with the target language's package manage 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. +2. **Namespace Isolation**: Each resolved provider's ordinary context MUST be installed in its own subdirectory, named by its package selector. 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. Consumers SHOULD validate document data as part of loading or parsing it and use that validated representation when applying changes. Installation MAY use direct file writes and copies. Consumers MUST report failures; completed changes and partially written output MAY remain after an error. After resolving the reported failure, consumers SHOULD support refreshing generated output by rerunning installation. -### 5.3 Installation Algorithm +### 5.3 Installation Steps -``` -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 -``` +For each selected provider: + +1. Classify its context documents and identify companion skill resource directories, keeping their contents opaque. +2. If skills are supported, install each declared skill once as `.agents/skills//SKILL.md` and copy its companion resources into that skill directory. +3. Copy the remaining ordinary files into `.agents/context//`, preserving their relative paths. + +Skill documents and their resources are excluded from ordinary context copying even when the consumer does not support skill installation. ## 6. Discovering Installed Context -Consumers SHOULD provide commands to list providers, list their context files, and show a selected file. Agents discover installed guidance by enumerating Markdown files under `.agents/context//` and reading the files relevant to their task. A packaged usage skill SHOULD explain this workflow and how to install missing guidance. +Consumers SHOULD provide commands to list providers, list their context files, and show a selected file. Agents discover installed guidance by enumerating Markdown files under `.agents/context//` and reading the files relevant to their task. A packaged usage skill SHOULD explain this workflow and how to install missing guidance. Providers SHOULD use descriptive filenames, keep guides focused on one topic, and link related documents. Ordinary documents need no front matter. Unrelated ordinary-document metadata MUST be tolerated. Skill resource files are opaque assets and MUST NOT be parsed as context documents. From a9e0854248280547690383e3d717dd1fa55727b3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 10:57:42 +0530 Subject: [PATCH 25/37] Simplify document parsing and allow unfamiliar context types. --- design.md | 2 +- lib/agent/context/document.rb | 18 ++++++------------ releases.md | 1 + specification.md | 2 +- test/agent/context/document.rb | 26 ++++++++++++++++++-------- test/agent/context/installer.rb | 11 +++++++++++ test/agent/context/skills/installer.rb | 12 ++++-------- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/design.md b/design.md index e2a5f10..ec3b09e 100644 --- a/design.md +++ b/design.md @@ -27,7 +27,7 @@ Ruby uses the gem name as its package selector. Rust uses the crate name when un `Document.load(path)` and `ContextDocument::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. -Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. An absent or null type denotes ordinary context; `skill` declares a skill; other values produce an unsupported-type error. Ordinary guides can carry their own top-level fields. Other front matter languages remain opaque. +Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. The skill predicate matches `skill`; all other values follow the ordinary context path. Ordinary guides can carry their own top-level fields. Front matter uses YAML by default or with the explicit `yaml` language hint. Other front matter languages remain opaque. A skill definition validates its top-level name and description. It preserves an authored name and uses it for the installed directory. When the name is omitted, it derives `-`. Resource directories follow the source filename. Description validation trims whitespace to measure the value; the authored value is retained. diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index f11bd89..a866859 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -17,11 +17,11 @@ class Invalid < ArgumentError # Read and parse a Markdown document. def self.load(path) - parse(File.read(path), path: path) + parse(File.read(path), path) end # Parse YAML front matter using Markdown's native syntax tree. - def self.parse(content, path:) + def self.parse(content, path) # Let Markly identify the front matter and its source boundary: first = Markly.parse(content, flags: Markly::FRONT_MATTER).first_child node = first if first&.type == :front_matter @@ -29,7 +29,7 @@ def self.parse(content, path:) # Interpret YAML metadata, keeping other formats opaque: frontmatter = {} - if node && [nil, "yaml", "yml"].include?(language) + if node && [nil, "yaml"].include?(language) frontmatter = YAML.safe_load(node.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) raise Invalid, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) end @@ -58,18 +58,12 @@ def initialize(path, content, node, frontmatter) # The extensible metadata mapping within front matter. def metadata - value = @frontmatter["metadata"] - value.is_a?(Hash) ? value : {} + @frontmatter["metadata"] || {} end - # Classify documents from their context metadata. + # Whether the document declares a skill. def skill? - type = metadata["type"] - unless type.nil? || type == "skill" - raise Invalid, "Unsupported context type in #{@path}: #{type.inspect}" - end - - type == "skill" + metadata["type"] == "skill" end # The original body following the parser's front matter boundary. diff --git a/releases.md b/releases.md index e56a044..3be9915 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,7 @@ ## Unreleased +- Install unfamiliar context types as ordinary guidance so providers can introduce new types. - Store resolved skill provenance in `SKILL.md` front matter under `metadata`, with `metadata.type: skill` declaring skills. - Preserve authored names, descriptions, additional metadata, and the original Markdown body using native front matter AST nodes. - Share loaded documents between context and skill installation, and use direct writes with rerunnable progress. diff --git a/specification.md b/specification.md index e180a3f..d19ed32 100644 --- a/specification.md +++ b/specification.md @@ -161,7 +161,7 @@ metadata: --- ``` -Within a context document's YAML `metadata` mapping, an absent or null `type` denotes ordinary context; the string `skill` declares a skill. Consumers MUST reject any other non-null `metadata.type` value and report it as an unsupported context type. +Within a context document's YAML `metadata` mapping, the string `skill` declares a skill. Consumers MUST treat all other `metadata.type` values, including absent, null, and unfamiliar values, as ordinary context. This allows providers to introduce new types while existing consumers continue installing their documents. An authored top-level `name` MUST be preserved and used as the installed directory name. When `name` is absent, consumers MUST derive it from the lowercase provider package name (normalizing underscores to hyphens), a hyphen, and the source filename without its `.md` extension. 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 for validation; consumers MUST retain the authored description value. diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index f8d6107..84758c2 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -36,7 +36,7 @@ def around it "parses in-memory Markdown with thematic breaks and code examples" do content = "# Guide\n\n---\n\n~~~yaml\n---\ntype: skill\n---\n~~~\n" - parsed = subject.parse(content, path: path) + parsed = subject.parse(content, path) expect(parsed.frontmatter).to be == {} expect(parsed.body).to be == content end @@ -64,9 +64,9 @@ def around it "edits native front matter while retaining the shared fixture body" do fixture = File.read(Agent::Context::TestDocument.path) ["\n", "\r\n"].each do |ending| - source = subject.parse(fixture.gsub("\n", ending), path: path) + source = subject.parse(fixture.gsub("\n", ending), path) output = source.render(source.frontmatter.merge("license" => "Apache-2.0")) - installed = subject.parse(output, path: path) + installed = subject.parse(output, path) expect(installed.body).to be == source.body expect(installed.frontmatter["license"]).to be == "Apache-2.0" expect(installed.metadata["author"]).to be == "Provider" @@ -83,12 +83,22 @@ def around end it "uses nested skill metadata and keeps other front matter languages opaque" do - ["--- json\ninvalid: [\n---\n", "---\ntype: skill\nmetadata: unrelated\n---\n"].each do |content| - expect(subject.parse(content, path: path)).not.to be(:skill?) + ["--- json\ninvalid: [\n---\n", "--- yml\ninvalid: [\n---\n", "---\ntype: skill\n---\n"].each do |content| + expect(subject.parse(content, path)).not.to be(:skill?) + end + end + + it "defaults absent or null metadata to an empty mapping" do + ["", "---\nmetadata: null\n---\n"].each do |content| + expect(subject.parse(content, path).metadata).to be == {} + end + end + + it "recognizes skills and treats other metadata types as ordinary context" do + [["skill", true], ["guide", false], [nil, false], [false, false]].each do |type, skill| + content = {"metadata" => {"type" => type}}.to_yaml + "---\n" + expect(subject.parse(content, path).skill?).to be == skill end - expect do - subject.parse("---\nmetadata: {type: false}\n---\n", path: path).skill? - end.to raise_exception(subject::Invalid) end end diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 9f965c2..d44b9e5 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -148,6 +148,17 @@ def around expect(File).to be(:exist?, File.join(target_context_path, "getting-started.md")) expect(File).to be(:exist?, File.join(target_context_path, "configuration.md")) end + + it "copies unfamiliar context types alongside skills" do + guide = "---\nmetadata:\n type: guide\n---\n\n# Guide\n" + File.write(File.join(context_path, "guide.md"), guide) + File.write(File.join(context_path, "workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run the workflow.\n---\n\n# Workflow\n") + helper = subject.new(root: @target_path, specifications: @specifications) + + expect(helper.install).to be == {context: ["fake-gem"], skills: ["fake-gem-workflow"]} + expect(File.read(File.join(helper.context_path, "fake-gem/guide.md"))).to be == guide + expect(File.read(File.join(@target_path, ".agents/skills/fake-gem-workflow/SKILL.md"))).to be(:include?, "# Workflow") + end end end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index 196dd9e..b8892b4 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -194,15 +194,11 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect(installer.find_gems_with_skills).to be == [] end - it "reports unsupported context types before installation" do - file = File.join(provider_root, "context/ruby-testing.md") + it "installs skills alongside unfamiliar context types" do + file = File.join(provider_root, "context/guide.md") File.write(file, "---\nmetadata:\n type: unknown\n---\n\n# Instructions\n") - expect do - installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) - - expect(File).not.to be(:exist?, installer.skills_path) + expect(installer.install).to be == ["fake-gem-ruby-testing"] end it "requires a nonempty string description" do @@ -333,7 +329,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio installer.install content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) expect(installer.list_skills("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == " #{"é" * 1024} " - expect(Agent::Context::Document.parse(content, path: "installed").frontmatter["description"]).to be == " #{"é" * 1024} " + expect(Agent::Context::Document.parse(content, "installed").frontmatter["description"]).to be == " #{"é" * 1024} " end it "keeps plain guides out of skills and rejects invalid context skill metadata" do From c92151096296c281a3601f0853a288d0c0c31cac Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 18:46:07 +0530 Subject: [PATCH 26/37] Unify context and skill installation --- bake/agent/context.rb | 17 ++- bake/agent/context/skill.rb | 44 ------- context/getting-started.md | 8 +- context/usage.md | 10 +- design.md | 19 ++- lib/agent/context/definition.rb | 41 ++++++ lib/agent/context/installer.rb | 114 ++++++++++------ lib/agent/context/package.rb | 52 ++++---- lib/agent/context/skills.rb | 4 +- lib/agent/context/skills/definition.rb | 43 +++--- lib/agent/context/skills/errors.rb | 19 +++ lib/agent/context/skills/installer.rb | 176 ------------------------- lib/agent/context/skills/ownership.rb | 3 +- readme.md | 13 +- specification.md | 8 +- test/agent/context/installer.rb | 21 ++- test/agent/context/skills.rb | 26 ++-- test/agent/context/skills/installer.rb | 101 +++++++------- test/agent/context/tasks.rb | 26 +--- 19 files changed, 293 insertions(+), 452 deletions(-) delete mode 100644 bake/agent/context/skill.rb create mode 100644 lib/agent/context/definition.rb create mode 100644 lib/agent/context/skills/errors.rb delete mode 100644 lib/agent/context/skills/installer.rb diff --git a/bake/agent/context.rb b/bake/agent/context.rb index a83341e..a3642f8 100644 --- a/bake/agent/context.rb +++ b/bake/agent/context.rb @@ -16,16 +16,15 @@ def initialize(context) attr :installer -# List all gems that have context available. -# @parameter gem [String] Optional specific gem name to list context files for. +# List providers and their context definitions, including skills. +# @parameter gem [String | Nil] An optional provider gem. def list(gem: nil) if gem - files = @installer.list_context_files(gem) - if files + provider = @installer.find_gem_with_context(gem) + if provider puts "Context files for gem '#{gem}':" - files.each do |file| - relative_path = Pathname.new(file).relative_path_from(Pathname.new(@installer.find_gem_with_context(gem)[:path])) - puts " #{relative_path}" + provider[:definitions].each do |definition| + puts " #{definition.display_name(provider[:path])}" end else puts "No context found for gem '#{gem}'" @@ -43,9 +42,9 @@ def list(gem: nil) end end -# Show the content of a specific context file. +# Show one context definition from a provider. # @parameter gem [String] The gem name. -# @parameter file [String] The context file name. +# @parameter file [String] The source filename or skill name. def show(gem:, file:) content = @installer.show_context_file(gem, file) if content diff --git a/bake/agent/context/skill.rb b/bake/agent/context/skill.rb deleted file mode 100644 index 2a54668..0000000 --- a/bake/agent/context/skill.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "agent/context/skills/installer" - -# List dependency skills from all gems or one provider. -# @parameter gem [String | Nil] An optional provider gem. -def list(gem: nil) - installer = Agent::Context::Skills::Installer.new(root: context.root) - skills = if gem - Array(installer.list_skills(gem)) - else - installer.find_gems_with_skills.flat_map{|provider| provider[:skills]} - end - - skills.each do |skill| - puts "#{skill.name} (#{skill.provider_name}@#{skill.provider_version}) — #{skill.description}" - end - - puts "No dependency skills found" if skills.empty? -end - -# Show a dependency skill's installed instructions. -# @parameter gem [String] The provider gem. -# @parameter skill [String] The installed skill name. -def show(gem:, skill:) - installer = Agent::Context::Skills::Installer.new(root: context.root) - content = installer.show_skill(gem, skill) - raise ArgumentError, "No skill #{skill.inspect} in gem #{gem.inspect}" unless content - - puts content -end - -# Install dependency skills and reconcile only the selected scope. -# @parameter gem [String | Nil] An optional provider gem. -# @parameter skill [String | Nil] An optional installed skill name. -def install(gem: nil, skill: nil) - installer = Agent::Context::Skills::Installer.new(root: context.root) - names = installer.install(gem: gem, skill: skill) - - puts names.empty? ? "No dependency skills were installed" : "Installed skills: #{names.join(", ")}" -end diff --git a/context/getting-started.md b/context/getting-started.md index 1661dca..36eb0ca 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -38,10 +38,10 @@ bake agent:context:list --gem async # View a specific context file bake agent:context:show --gem async --file thread-safety -# List, show, or install dependency skills -bake agent:context:skill:list -bake agent:context:skill:show --gem provider --skill provider-workflow -bake agent:context:skill:install --gem provider --skill provider-workflow +# List, show, or install a skill through the context tasks +bake agent:context:list --gem provider +bake agent:context:show --gem provider --file workflow +bake agent:context:install --gem provider ``` ## Understanding context/ vs .agents/context/ diff --git a/context/usage.md b/context/usage.md index 3268e87..a029a7e 100644 --- a/context/usage.md +++ b/context/usage.md @@ -32,13 +32,13 @@ Ordinary context is copied to `.agents/context/GEM/`. Repository instructions ca Skills contain procedures for particular tasks and are installed under `.agents/skills/`. Use the matching skill's instructions and resources when its description applies to the work. ```bash -bundle exec bake agent:context:skill:list -bundle exec bake agent:context:skill:show --gem agent-context --skill agent-context-usage -bundle exec bake agent:context:skill:install -bundle exec bake agent:context:skill:install --gem agent-context --skill agent-context-usage +bundle exec bake agent:context:list --gem agent-context +bundle exec bake agent:context:show --gem agent-context --file usage +bundle exec bake agent:context:install +bundle exec bake agent:context:install --gem agent-context ``` -An authored skill name determines its installed directory; a missing name defaults to `-`. `skill:install` refreshes all dependency skills by default; `--gem NAME` selects one provider and `--skill NAME` selects one installed skill name. This document installs as `agent-context-usage/SKILL.md`. +The context list marks skill documents with `(skill)`. Show them by their source filename, and install them together with ordinary context. An authored skill name determines its installed directory; a missing name defaults to `-`. This document installs as `agent-context-usage/SKILL.md`. ## Keep Sources and Installed Files Separate diff --git a/design.md b/design.md index ec3b09e..9370c88 100644 --- a/design.md +++ b/design.md @@ -10,9 +10,10 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | --- | --- | --- | | Resolved provider | `Gem::Specification`, retained by `Package` | `ContextPackage`, resolved by `Installer::discover` | | Parsed Markdown | `Document` | `ContextDocument` in `document.rs` | -| Loaded provider context | `Package` | `PackageContext` | -| Skill definition | `Skills::Definition` | `Skill` | -| Installation workflow | `Installer#install`, `Skills::Installer` | `Installer::install`, skill installation functions | +| Loaded provider definitions | `Package#definitions` | `PackageContext::definitions` | +| Ordinary definition | `Definition` | `Definition::File` | +| Skill definition | `Skills::Definition < Definition` | `Definition::Skill` | +| Installation workflow | `Installer#install` | `Installer::install` | | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | | Command presentation | Bake task adapters | Bake task adapters | @@ -39,17 +40,15 @@ The portable YAML form uses `---` delimiters. Rust also recognizes the additiona For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The loaded package stores ordinary Markdown documents, opaque resource paths, and prepared skill definitions. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then passes the resulting values to context and skill installation. Ordinary Markdown is written from the loaded source; opaque files are copied directly. +The loaded package stores ordinary definitions and skill definitions in one collection. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. Ordinary Markdown is written from the loaded source; opaque files are copied directly. ## Installation Flow The library owns orchestration. Bake tasks translate arguments, call the library, and present installed provider and skill names. -1. Resolve the selected providers and load their documents. -2. Validate selected skill names, read installed provenance, and check destination ownership. -3. Refresh selected skill directories, writing `SKILL.md` before their resources. -4. Remove stale skills within the selected ownership scope and refresh the project's Git exclusions. -5. Refresh ordinary context from the loaded documents and opaque resources. +1. Resolve the selected providers and load their definitions. +2. Validate and install skills under `.agents/skills/`, reconcile stale entries within the selected scope, and refresh Git exclusions. +3. Install ordinary definitions under `.agents/context/`. Generated files use direct `File.write` or `std::fs::write`. Completed steps remain in place if a later operation fails. Report the relevant path and let the caller resolve the failure and rerun. Cleanup around a failed instruction write removes the incomplete destination; a later resource-copy failure retains the completed instructions and their provenance. @@ -59,7 +58,7 @@ Loading and writing are separate operations over a loaded snapshot. `Document.lo Installed `SKILL.md` metadata carries the resolved ecosystem, package, and version. Scanning direct skill directories supplies the names used for reconciliation and local Git exclusions. Ownership recognition requires `metadata.type: skill` and an explicit ecosystem before validating the complete tuple. Project skills can use unrelated metadata such as a version without claiming installation ownership. -A full refresh selects one ecosystem; a package refresh selects one ecosystem and package; an individual skill install selects one name. Destination collisions are reported. Other owned scopes and project skills remain available. +A full refresh selects one ecosystem; a package refresh selects one ecosystem and package. Destination collisions are reported. Other owned scopes and project skills remain available. Git exclusion handling locates and reads the local exclusion file, replaces the consuming project's marked block, and writes the result directly. Prefixes distinguish nested projects sharing a repository. The block lists generated context and exact installed skill directories from all ecosystems. diff --git a/lib/agent/context/definition.rb b/lib/agent/context/definition.rb new file mode 100644 index 0000000..51d3ec5 --- /dev/null +++ b/lib/agent/context/definition.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" + +module Agent + module Context + # Represents one ordinary file provided in a package's context directory. + class Definition + def initialize(path:, document: nil) + @path = path + @document = document + end + + attr_reader :path + + def skill? + false + end + + def content + @document ? @document.content : File.read(@path) + end + + def display_name(context_path) + @path.delete_prefix("#{context_path}/") + end + + def write_to(destination) + FileUtils.mkdir_p(File.dirname(destination)) + if @document + @document.write(destination) + else + FileUtils.copy_file(@path, destination, true) + end + end + end + end +end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 9132339..a83eb0b 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -6,13 +6,18 @@ require "rubygems" require "pathname" +require "fileutils" + require_relative "paths" -require_relative "skills/installer" +require_relative "skills/errors" +require_relative "skills/ownership" +require_relative "skills/definition" +require_relative "exclusion" require_relative "package" module Agent module Context - # Represents discovery and installation of context from resolved Ruby gems. + # Discovers, lists, reads, and installs context from resolved Ruby gems. class Installer # Retain the consuming project and gem specifications. def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @@ -21,32 +26,28 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) @specifications = specifications end - # @attribute [String] The consuming project root. attr_reader :root - - # @attribute [String] The installed context directory. attr_reader :context_path - # Find providers with ordinary context. + # Find providers with ordinary context or skills. def find_gems_with_context(skip_local: true) - packages(skip_local: skip_local).reject{|package| package.files.empty?}.map(&:information) + packages(skip_local: skip_local).reject{|package| package.definitions.empty?}.map(&:information) end - # Find one provider with ordinary context. + # Find one provider with context or skills. def find_gem_with_context(gem_name) package = packages(gem: gem_name).first - package.information if package && !package.files.empty? + package.information if package && !package.definitions.empty? end - # List a provider's ordinary context paths. - def list_context_files(gem_name) + # List every definition from a provider, including skill documents. + def list_context_definitions(gem_name) package = packages(gem: gem_name).first - package.files.keys if package && !package.files.empty? + package.definitions if package && !package.definitions.empty? end - # Read a selected ordinary context document or resource. + # Read a selected document, opaque resource, or generated skill document. def show_context_file(gem_name, file_name) - # Resolve the requested path within the loaded provider files: requested = Pathname.new(file_name) if requested.absolute? || requested.each_filename.any?{|part| part == ".."} raise ArgumentError, "Context file must be a relative path inside context/" @@ -54,41 +55,30 @@ def show_context_file(gem_name, file_name) package = packages(gem: gem_name).first return unless package + candidates = [File.join(package.path, file_name)] candidates << "#{candidates.first}.md" if File.extname(file_name).empty? - path = candidates.find{|candidate| package.files.key?(candidate)} - return unless path - - # Return loaded Markdown or read the selected opaque resource: - package.files[path]&.content || File.read(path) - end - - # Refresh ordinary context for one provider. - def install_gem_context(gem_name) - package = packages(gem: gem_name).first - package ? package.install(File.join(@context_path, package.name)) : false - end - - # Refresh ordinary context for all selected providers. - def install_all_context(skip_local: true) - packages(skip_local: skip_local).filter_map do |package| - package.name if package.install(File.join(@context_path, package.name)) - end + definition = package.definitions.find{|item| candidates.include?(item.path)} + definition ||= package.definitions.find{|item| item.skill? && item.name == file_name} + definition&.content end - # Install context and skills from the same loaded provider documents. + # Install all selected definitions and reconcile dependency-owned skills. def install(gem: nil) - # Resolve providers and parse their documents once: + # Resolve providers and load every definition once: selected = packages(gem: gem) - raise Skills::Installer::Error, "No gem found: #{gem}" if gem && selected.empty? - definitions = selected.flat_map(&:skills) + raise Skills::Error, "No gem found: #{gem}" if gem && selected.empty? + definitions = selected.flat_map(&:definitions) + skills = definitions.grep(Skills::Definition) + + # Apply the skill-specific destination and ownership rules: + installed_skills = install_skills(skills, package: gem) - # Refresh skills and ordinary context using that loaded representation: - skills = Skills::Installer.new(root: @root, specifications: @specifications) - installed_skills = skills.install_definitions(definitions, package: gem, reconcile: true) + # Refresh ordinary files from the same loaded definitions: installed_context = selected.filter_map do |package| package.name if package.install(File.join(@context_path, package.name)) end + {context: installed_context, skills: installed_skills} end @@ -99,11 +89,55 @@ def packages(gem: nil, skip_local: true) gem ? specification.name == gem : !skip_local || File.expand_path(specification.full_gem_path) != @root end if gem && specifications.length > 1 - raise Skills::Installer::Conflict, "Multiple resolved versions provide gem #{gem.inspect}" + raise Skills::Conflict, "Multiple resolved versions provide gem #{gem.inspect}" end specifications.map{|specification| Package.load(specification)} end + + def install_skills(definitions, package:) + # Validate selected names and ownership before writing any skill: + duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1} + raise Skills::Conflict, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? + + skills_path = File.join(@root, ".agents", "skills") + owners = Skills::Ownership.scan(skills_path) + validate_skill_destinations(definitions, skills_path, owners) + + names = definitions.map(&:name) + owned = owners.select do |_name, owner| + owner["ecosystem"] == "gem" && (!package || owner["package"] == package) + end + stale = owned.keys - names + + # Refresh selected skills, then remove stale entries in this scope: + FileUtils.mkdir_p(skills_path) unless definitions.empty? && stale.empty? + definitions.each do |definition| + destination = File.join(skills_path, definition.name) + FileUtils.rm_rf(destination) + definition.write_to(destination) + end + stale.each{|name| FileUtils.rm_rf(File.join(skills_path, name))} + + # Keep local Git exclusions aligned with the remaining installed skills: + Exclusion.prepare(@root, (owners.keys - stale + names).uniq)&.apply + names + end + + def validate_skill_destinations(definitions, skills_path, owners) + definitions.each do |definition| + owner = owners[definition.name] + destination = File.join(skills_path, definition.name) + + if owner && owner.values_at("ecosystem", "package") != ["gem", definition.provider_name] + raise Skills::Conflict, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" + end + + if (File.exist?(destination) || File.symlink?(destination)) && !owner + raise Skills::Conflict, "Skill #{definition.name.inspect} is not managed by Agent Context" + end + end + end end end end diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb index 97aa34f..b110802 100644 --- a/lib/agent/context/package.rb +++ b/lib/agent/context/package.rb @@ -4,44 +4,42 @@ # Copyright, 2026, by Samuel Williams. require "fileutils" +require_relative "definition" require_relative "document" require_relative "skills/definition" +require_relative "skills/errors" module Agent module Context - # Represents a resolved gem and its loaded context documents and skills. + # Represents a resolved gem and its loaded context definitions. class Package # Load and classify a gem's context, keeping skill resources opaque. def self.load(specification) - package = new(specification, {}, []) + package = new(specification, []) root = package.path return package unless File.exist?(root) || File.symlink?(root) unless File.lstat(root).directory? - raise Skills::Installer::InvalidSkill, "Context provider must be a regular directory: #{root}" + raise Skills::Invalid, "Context provider must be a regular directory: #{root}" end package.load_directory(root) package rescue Document::Invalid => error - raise Skills::Installer::InvalidSkill, error.message + raise Skills::Invalid, error.message end - # Retain the provider and its classified files. - def initialize(specification, files, skills) + # Retain the provider and its loaded definitions. + def initialize(specification, definitions) @specification = specification - @files = files - @skills = skills + @definitions = definitions end # @attribute [Gem::Specification] The resolved provider. attr_reader :specification - # @attribute [Hash(String, Document | Nil)] Ordinary files and loaded Markdown. - attr_reader :files - - # @attribute [Array(Skills::Definition)] Prepared skill definitions. - attr_reader :skills + # @attribute [Array(Definition)] All loaded context definitions. + attr_reader :definitions # @returns [String] The provider gem name. def name @@ -61,7 +59,7 @@ def information summary: @specification.summary, metadata: @specification.metadata, path: path, - skills: @skills, + definitions: @definitions, } end @@ -77,35 +75,33 @@ def load_directory(directory) elsif stat.file? document = File.extname(file).downcase == ".md" ? Document.load(file) : nil if document&.skill? - raise Skills::Installer::InvalidSkill, "Skill documents must be directly inside context/: #{file}" unless directory == path - @skills << Skills::Definition.from_document(document, @specification) + raise Skills::Invalid, "Skill documents must be directly inside context/: #{file}" unless directory == path + @definitions << Skills::Definition.from_document(document, @specification) else - @files[file] = document + @definitions << Definition.new(path: file, document: document) end end end # Companion resources belong to the skill and are copied as opaque files: - resources = @skills.map(&:path) + resources = @definitions.grep(Skills::Definition).map(&:resources) directories.each do |directory| load_directory(directory) unless resources.include?(directory) end - @skills.sort_by!(&:name) + @definitions.sort_by! do |definition| + [definition.skill? ? 1 : 0, definition.path] + end end # Refresh ordinary context from the loaded documents and opaque resources. def install(destination) + files = @definitions.reject(&:skill?) FileUtils.rm_rf(destination) - return false if @files.empty? + return false if files.empty? - @files.each do |source, document| - target = File.join(destination, source.delete_prefix("#{path}/")) - FileUtils.mkdir_p(File.dirname(target)) - if document - document.write(target) - else - FileUtils.copy_file(source, target, true) - end + files.each do |definition| + target = File.join(destination, definition.path.delete_prefix("#{path}/")) + definition.write_to(target) end true end diff --git a/lib/agent/context/skills.rb b/lib/agent/context/skills.rb index be6df65..596da07 100644 --- a/lib/agent/context/skills.rb +++ b/lib/agent/context/skills.rb @@ -5,11 +5,11 @@ require_relative "skills/definition" require_relative "skills/ownership" -require_relative "skills/installer" +require_relative "skills/errors" module Agent module Context - # Tools for discovering and installing dependency skills from Ruby gems. + # Skill definitions and installation ownership for dependency context. module Skills end end diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index c9ea4d4..239d5dc 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -5,13 +5,15 @@ # Copyright, 2026, by Samuel Williams. require "fileutils" +require_relative "../definition" +require_relative "errors" require_relative "ownership" module Agent module Context module Skills # Represents a dependency skill declared by a context document and its resources. - class Definition + class Definition < Agent::Context::Definition # Build instructions from a parsed skill document and its provider. def self.from_document(source, specification) # Preserve authored names and derive missing names from the provider and file: @@ -32,7 +34,7 @@ def self.from_document(source, specification) assets = File.join(File.dirname(file), local_name) if File.exist?(assets) || File.symlink?(assets) unless File.lstat(assets).directory? - raise Installer::InvalidSkill, "Skill resources must be a regular directory: #{assets}" + raise Invalid, "Skill resources must be a regular directory: #{assets}" end else assets = nil @@ -43,7 +45,7 @@ def self.from_document(source, specification) new( name: installed_name, description: frontmatter["description"], - path: assets, + resources: assets, source_file: file, document: document, provider_name: specification.name, @@ -54,7 +56,7 @@ def self.from_document(source, specification) # Validate an installed directory name. def self.validate_name(name, file) unless name.is_a?(String) && name.match?(Ownership::NAME_PATTERN) && name.length <= 64 - raise Installer::InvalidSkill, "Invalid skill name in #{file}: #{name.inspect}" + raise Invalid, "Invalid skill name in #{file}: #{name.inspect}" end end @@ -64,35 +66,36 @@ def self.validate_metadata(frontmatter, file) description = frontmatter["description"] unless description.is_a?(String) - raise Installer::InvalidSkill, "Invalid skill description in #{file}" + raise Invalid, "Invalid skill description in #{file}" end description = description.strip if description.empty? || description.length > 1024 - raise Installer::InvalidSkill, "Invalid skill description in #{file}" + raise Invalid, "Invalid skill description in #{file}" end end # Initialize a skill from a context document. # @parameter name [String] The installed skill name. # @parameter description [String] When to use the skill. - # @parameter path [String | Nil] The companion resource directory. + # @parameter resources [String | Nil] The companion resource directory. # @parameter provider_name [String] The provider gem. # @parameter provider_version [String] The provider version. # @parameter source_file [String] The source context document. # @parameter document [String] Generated skill instructions. def initialize( - name:, description:, path:, + name:, description:, resources:, provider_name:, provider_version:, source_file:, document: ) + super(path: source_file) @name = name @description = description - @path = path + @resources = resources @provider_name = provider_name @provider_version = provider_version @source_file = source_file - @document = document + @content = document end # @attribute [String] The installed skill name. @@ -102,7 +105,7 @@ def initialize( attr_reader :description # @attribute [String | Nil] The companion resource directory. - attr_reader :path + attr_reader :resources # @attribute [String] The provider gem. attr_reader :provider_name @@ -115,7 +118,15 @@ def initialize( # @returns [String] The installed skill instructions. def content - @document + @content + end + + def skill? + true + end + + def display_name(_context_path) + "#{File.basename(@source_file, File.extname(@source_file))} (skill)" end # Write instructions before resources so ownership is available during refresh. @@ -123,12 +134,12 @@ def content def write_to(destination) FileUtils.mkdir_p(destination) begin - File.write(File.join(destination, "SKILL.md"), @document) + File.write(File.join(destination, "SKILL.md"), @content) rescue FileUtils.rm_rf(destination) raise end - copy_resources(@path, destination, true) if @path + copy_resources(@resources, destination, true) if @resources end private @@ -140,11 +151,11 @@ def copy_resources(source, destination, top_level) metadata = File.lstat(from) if metadata.symlink? || (!metadata.file? && !metadata.directory?) - raise Installer::InvalidSkill, "Skill resources must be regular files or directories: #{from}" + raise Invalid, "Skill resources must be regular files or directories: #{from}" end if top_level && name.downcase == "skill.md" - raise Installer::InvalidSkill, "#{name} is reserved for generated skill files: #{from}" + raise Invalid, "#{name} is reserved for generated skill files: #{from}" end if metadata.directory? diff --git a/lib/agent/context/skills/errors.rb b/lib/agent/context/skills/errors.rb new file mode 100644 index 0000000..07e6b4a --- /dev/null +++ b/lib/agent/context/skills/errors.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Agent + module Context + module Skills + class Error < StandardError + end + + class Invalid < Error + end + + class Conflict < Error + end + end + end +end diff --git a/lib/agent/context/skills/installer.rb b/lib/agent/context/skills/installer.rb deleted file mode 100644 index 5123afe..0000000 --- a/lib/agent/context/skills/installer.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Shopify Inc. -# Copyright, 2026, by Samuel Williams. - -require "fileutils" -require "rubygems" -require "yaml" - -require_relative "definition" -require_relative "ownership" -require_relative "../exclusion" -require_relative "../package" - -module Agent - module Context - module Skills - # Represents discovery and installation of skills from resolved gems. - class Installer - NAME_PATTERN = Ownership::NAME_PATTERN - - # Raised when a skill cannot be installed. - class Error < StandardError - end - - # Raised when skill metadata or resources are invalid. - class InvalidSkill < Error - end - - # Raised when a destination has ambiguous or foreign ownership. - class Conflict < Error - end - - # Initialize discovery from the project's gem specifications. - # @parameter root [String] The consuming project root. - # @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 - 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. - 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? - end - end - - # 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 = find_specification(gem_name) - return unless specification - - information = build_gem_information(specification) - information unless information[:skills].empty? - end - - # List a gem's skills. - # @parameter gem_name [String] The provider gem. - # @returns [Array(Definition) | Nil] The provided skills. - def list_skills(gem_name) - find_gem_with_skills(gem_name)&.fetch(: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. - def show_skill(gem_name, skill_name) - list_skills(gem_name)&.find{|skill| skill.name == skill_name}&.content - 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 - - # Install prepared definitions and reconcile their ownership scope. - def install_definitions(definitions, package:, reconcile:) - 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) - unless File.lstat(@skills_path).directory? - raise Conflict, "Skill installation path must be a regular directory: #{@skills_path}" - end - end - - owners = Ownership.scan(@skills_path) - validate_destinations(definitions, owners) - - names = definitions.map(&:name) - owned = owners.select do |_name, owner| - owner["ecosystem"] == "gem" && (!package || owner["package"] == package) - end - stale = reconcile ? owned.keys - names : [] - - # Refresh each selected skill from its loaded instructions: - FileUtils.mkdir_p(@skills_path) - definitions.each do |definition| - destination = File.join(@skills_path, definition.name) - FileUtils.rm_rf(destination) - definition.write_to(destination) - end - - # Reconcile the requested ownership scope and refresh Git exclusions: - stale.each{|name| FileUtils.rm_rf(File.join(@skills_path, name))} - Exclusion.prepare(@root, (owners.keys - stale + names).uniq)&.apply - names - 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) - Package.load(specification).information - end - - def validate_destinations(definitions, owners) - definitions.each do |definition| - owner = owners[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 - - end - end - end -end diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb index 156ba10..eaabe25 100644 --- a/lib/agent/context/skills/ownership.rb +++ b/lib/agent/context/skills/ownership.rb @@ -5,6 +5,7 @@ # Copyright, 2026, by Samuel Williams. require_relative "../document" +require_relative "errors" module Agent module Context @@ -15,7 +16,7 @@ class Ownership NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ # Raised when installed ownership cannot be safely interpreted. - class Invalid < StandardError + class Invalid < Agent::Context::Skills::Invalid end # Read a skill's installation ownership. diff --git a/readme.md b/readme.md index dfc2ab9..ddd9beb 100644 --- a/readme.md +++ b/readme.md @@ -86,18 +86,7 @@ Show the content of a specific context file: $ bake agent:context:show --gem async --file thread-safety ``` -#### Skills - -List, show, or install metadata-declared dependency skills: - -``` bash -$ bake agent:context:skill:list -$ bake agent:context:skill:show --gem provider --skill provider-workflow -$ bake agent:context:skill:install -$ bake agent:context:skill:install --gem provider --skill provider-workflow -``` - -`agent-context` includes skill discovery and installation directly. Use it for Ruby dependency skills; the standalone `agent-skills` gem is deprecated. +The context list marks skill documents with `(skill)`, and the show and install tasks handle them alongside ordinary context. `agent-context` includes skill discovery and installation directly. Use it for Ruby dependency skills; the standalone `agent-skills` gem is deprecated. ## Version Control diff --git a/specification.md b/specification.md index d19ed32..947c039 100644 --- a/specification.md +++ b/specification.md @@ -210,13 +210,11 @@ Skill documents and their resources are excluded from ordinary context copying e ## 6. Discovering Installed Context -Consumers SHOULD provide commands to list providers, list their context files, and show a selected file. Agents discover installed guidance by enumerating Markdown files under `.agents/context//` and reading the files relevant to their task. A packaged usage skill SHOULD explain this workflow and how to install missing guidance. +Consumers SHOULD provide commands to list providers, list their definitions, and show a selected definition. Agents discover installed guidance by enumerating Markdown files under `.agents/context//` and reading the files relevant to their task. A packaged usage skill SHOULD explain this workflow and how to install missing guidance. Providers SHOULD use descriptive filenames, keep guides focused on one topic, and link related documents. Ordinary documents need no front matter. Unrelated ordinary-document metadata MUST be tolerated. Skill resource files are opaque assets and MUST NOT be parsed as context documents. -The installed directory layout is the discovery interface. Consumers MUST NOT generate a context index or interpret `context/index.yaml` as indexing metadata. Provider-authored files are copied as ordinary resources according to section 5. - -Context installation MUST NOT create or modify the repository owner's `agents.md`. Repository owners can instruct agents to read relevant installed guidance under `.agents/context/`. +The installed directory layout is the discovery interface. ## 7. Skill Installation Ownership @@ -240,7 +238,7 @@ Consumers MUST discover ownership by scanning direct child directories of `.agen `metadata.type: skill` together with an explicit `metadata.ecosystem` identifies installation provenance. Consumers MUST validate the complete provenance tuple after recognizing that discriminator, and report malformed or unreadable metadata. Other directories are project-owned; an authored `metadata.version` alone is ordinary skill metadata. -A full refresh reconciles the installer's ecosystem; a package refresh reconciles that ecosystem and package; installing one named skill retains other owned skills. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Consumers MUST retain skills belonging to other ecosystems and packages outside the requested scope, and MUST reject conflicting destinations. +A full refresh reconciles the installer's ecosystem; a package refresh reconciles that ecosystem and package. Full reconciliation includes providers removed from the dependency graph and providers that become empty. Consumers MUST retain skills belonging to other ecosystems and packages outside the requested scope, and MUST reject conflicting destinations. Installers SHOULD write `SKILL.md` before copying companion resources, establishing ownership for subsequent refreshes. If writing the instructions fails, installers SHOULD remove that incomplete destination. Resource-copy failures leave completed instructions in place. The incremental failure behavior in section 5.2 applies to skills and Git exclusion updates. diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index d44b9e5..cdaedd2 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -62,13 +62,11 @@ def around expect(gem_info).to be_nil end - it "can list context files for a gem" do + it "can list context definitions for a gem" do helper = subject.new(specifications: @specifications) - files = helper.list_context_files("fake-gem") + definitions = helper.list_context_definitions("fake-gem") - expect(files.length).to be == 2 - expect(files).to be(:include?, File.join(context_path, "getting-started.md")) - expect(files).to be(:include?, File.join(context_path, "configuration.md")) + expect(definitions.map{|definition| File.basename(definition.path)}).to be == ["configuration.md", "getting-started.md"] end it "can show content of a specific context file" do @@ -104,8 +102,8 @@ def around it "can install context from a specific gem" do helper = subject.new(root: @target_path, specifications: @specifications) - result = helper.install_gem_context("fake-gem") - expect(result).to be_truthy + result = helper.install(gem: "fake-gem") + expect(result[:context]).to be(:include?, "fake-gem") target_context_path = File.join(@target_path, ".agents", "context", "fake-gem") expect(helper.context_path).to be == File.join(@target_path, ".agents", "context") @@ -119,10 +117,9 @@ def around expect(content).to be == "# Getting Started\n\nThis is a test." end - it "returns false for gems without context" do + it "reports a missing gem" do helper = subject.new(specifications: @specifications) - result = helper.install_gem_context("non-existent-gem") - expect(result).to be_falsey + expect{helper.install(gem: "non-existent-gem")}.to raise_exception(Agent::Context::Skills::Error) end it "installs dotfiles and files inside hidden context directories" do @@ -140,8 +137,8 @@ def around it "can install context from all gems" do helper = subject.new(root: @target_path, specifications: @specifications) - installed = helper.install_all_context - expect(installed).to be(:include?, "fake-gem") + installed = helper.install + expect(installed[:context]).to be(:include?, "fake-gem") # Check that files were copied target_context_path = File.join(@target_path, ".agents", "context", "fake-gem") diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index 25a8dc0..19fd8cd 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -38,26 +38,20 @@ def around yielder << specification end installer = subject.new(root: consumer, specifications: specifications) - skills = Agent::Context::Skills::Installer.new(root: consumer, specifications: specifications) expect(enumerations).to be == 0 expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["provider"] - expect(skills.find_gems_with_skills.map{|gem| gem[:name]}).to be == ["provider"] installer.install expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == File.read(File.join(provider, "context/guide.md")) end - it "installs context and skills without duplicating skill documents or assets" do - agents = File.join(consumer, "agents.md") - File.write(agents, "Repository-owned instructions") + it "installs context and skills to their distinct destinations" do result = installer.install expect(result).to be == {context: ["provider"], skills: ["provider-workflow"]} - expect(File.read(agents)).to be == "Repository-owned instructions" expect(File).to be(:exist?, File.join(consumer, ".agents/context/provider/guide.md")) expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/provider/workflow.md")) skill_root = File.join(consumer, ".agents/skills/provider-workflow") expect(File.read(File.join(skill_root, "SKILL.md"))).to be(:include?, "license: MIT") expect(File).to be(:exist?, File.join(skill_root, "references/checklist.md")) - expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/index.md")) end it "copies ordinary document metadata without interpreting descriptions" do @@ -79,12 +73,12 @@ def around expect(File.read(File.join(consumer, path))).to be == File.read(File.join(provider, "context/guide.md")) _, status = Open3.capture2e("git", "check-ignore", "--quiet", path, chdir: consumer) expect(status.success?).to be == true - expect(File).not.to be(:exist?, File.join(consumer, ".agents/context/index.md")) end - it "excludes skill instructions and resources from listing and showing context" do - expect(installer.list_context_files("provider").map{|path| File.basename(path)}).to be == ["guide.md"] - expect(installer.show_context_file("provider", "workflow")).to be_nil + it "lists and shows skills alongside ordinary context" do + definitions = installer.list_context_definitions("provider") + expect(definitions.map{|definition| definition.display_name(File.join(provider, "context"))}).to be == ["guide.md", "workflow (skill)"] + expect(installer.show_context_file("provider", "workflow")).to be(:include?, "name: provider-workflow") expect(installer.show_context_file("provider", "workflow/references/checklist.md")).to be_nil expect do installer.show_context_file("provider", "../secret") @@ -95,7 +89,7 @@ def around File.write(File.join(provider, "context/guide.md"), "---\ndescription: [broken\n---\n\n# Guide") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(File).not.to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow")) end @@ -129,9 +123,9 @@ def around mock(File).before(:write) do |path, *| raise Errno::EACCES, "Injected context failure" if path == destination end - expect{installer.install_gem_context("provider")}.to raise_exception(Errno::EACCES) + expect{installer.install(gem: "provider")}.to raise_exception(Errno::EACCES) mock(File).clear - installer.install_gem_context("provider") + installer.install(gem: "provider") expect(File.read(destination)).to be == "# Updated\n" end @@ -139,7 +133,7 @@ def around other = specification.dup other.version = "2.0.0" installer = subject.new(root: consumer, specifications: [specification, other]) - expect{installer.install(gem: "provider")}.to raise_exception(Agent::Context::Skills::Installer::Conflict) + expect{installer.install(gem: "provider")}.to raise_exception(Agent::Context::Skills::Conflict) expect(File).not.to be(:exist?, File.join(consumer, ".agents")) end @@ -147,7 +141,7 @@ def around context = File.join(provider, "context") FileUtils.rm_r(context) File.write(context, "Provider configuration") - expect{installer.install}.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + expect{installer.install}.to raise_exception(Agent::Context::Skills::Invalid) expect(File).not.to be(:exist?, File.join(consumer, ".agents")) end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index b8892b4..c9f6fcc 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -3,10 +3,10 @@ # Released under the MIT License. # Copyright, 2026, by Shopify Inc. -require "agent/context/skills/installer" +require "agent/context" require "tmpdir" -describe Agent::Context::Skills::Installer do +describe Agent::Context::Installer do let(:consumer_root) {Dir.mktmpdir} let(:provider_root) {Dir.mktmpdir} let(:specifications) {[build_specification("fake-gem", "1.0.0", provider_root)]} @@ -42,16 +42,20 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio File.write(File.join(directory, "SKILL.md"), "#{metadata.to_yaml}---\n\n#{body}") end + def skill_definitions(gem_name) + Array(installer.list_context_definitions(gem_name)).grep(Agent::Context::Skills::Definition) + end + it "finds gems which provide skills" do - gems = installer.find_gems_with_skills + gems = installer.find_gems_with_context expect(gems.length).to be == 1 expect(gems.first[:name]).to be == "fake-gem" - expect(gems.first[:skills].map(&:name)).to be == ["fake-gem-ruby-testing"] + expect(gems.first[:definitions].grep(Agent::Context::Skills::Definition).map(&:name)).to be == ["fake-gem-ruby-testing"] end it "lists skills provided by a gem" do - skills = installer.list_skills("fake-gem") + skills = skill_definitions("fake-gem") expect(skills.length).to be == 1 expect(skills.first).to have_attributes( @@ -61,26 +65,26 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio end it "shows a skill instruction file" do - content = installer.show_skill("fake-gem", "fake-gem-ruby-testing") + content = installer.show_context_file("fake-gem", "fake-gem-ruby-testing") expect(content).to be(:include?, "name: fake-gem-ruby-testing") expect(content).to be(:include?, "# Instructions") end it "returns nil for a gem without skills" do - expect(installer.find_gem_with_skills("missing-gem")).to be_nil + expect(installer.find_gem_with_context("missing-gem")).to be_nil expect do installer.install(gem: "missing-gem") - end.to raise_exception(Agent::Context::Skills::Installer::Error) + end.to raise_exception(Agent::Context::Skills::Error) end it "ignores legacy skill bundles and discovers only context metadata" do FileUtils.mkdir_p(File.join(provider_root, "skills", "legacy")) File.write(File.join(provider_root, "skills/legacy/SKILL.md"), "---\nname: legacy\ndescription: Legacy instructions.\n---\n") - expect(installer.list_skills("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] + expect(skill_definitions("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] File.delete(File.join(provider_root, "context/ruby-testing.md")) - expect(installer.find_gems_with_skills).to be == [] + expect(installer.find_gems_with_context).to be == [] end it "skips a gem loaded from the consuming project by default" do @@ -88,8 +92,8 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio local_specification = build_specification("local-gem", "1.0.0", consumer_root) local_installer = subject.new(root: consumer_root, specifications: [local_specification]) - expect(local_installer.find_gems_with_skills).to be == [] - expect(local_installer.find_gems_with_skills(skip_local: false).map{|gem| gem[:name]}).to be == ["local-gem"] + expect(local_installer.find_gems_with_context).to be == [] + expect(local_installer.find_gems_with_context(skip_local: false).map{|gem| gem[:name]}).to be == ["local-gem"] end with "installation" do @@ -97,7 +101,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio installed = installer.install(gem: "fake-gem") destination = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") - expect(installed).to be == ["fake-gem-ruby-testing"] + expect(installed[:skills]).to be == ["fake-gem-ruby-testing"] expect(File).to be(:exist?, File.join(destination, "SKILL.md")) end @@ -137,7 +141,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect do installer.install(gem: "fake-gem") - end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + end.to raise_exception(Agent::Context::Skills::Conflict) expect(File.read(File.join(destination, "SKILL.md"))).to be == "project-owned" end @@ -149,7 +153,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(File.read(destination)).to be == "project-owned" end @@ -162,7 +166,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect do duplicate_installer.install - end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + end.to raise_exception(Agent::Context::Skills::Conflict) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing")) ensure @@ -175,7 +179,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio all_specifications = specifications + [build_specification("other-gem", "2.0.0", second_root)] all_installer = subject.new(root: consumer_root, specifications: all_specifications) - expect(all_installer.install.sort).to be == ["fake-gem-ruby-testing", "other-gem-documentation"] + expect(all_installer.install[:skills].sort).to be == ["fake-gem-ruby-testing", "other-gem-documentation"] expect(File).to be(:exist?, File.join(consumer_root, ".agents", "skills", "other-gem-documentation", "SKILL.md")) ensure FileUtils.rm_rf(second_root) if second_root @@ -186,19 +190,20 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio it "preserves an authored name and uses it as the installed directory" do file = File.join(provider_root, "context/ruby-testing.md") File.write(file, "---\nmetadata:\n type: skill\nname: provider-authored\ndescription: Test projects.\n---\n\n# Instructions\n") - expect(installer.show_skill("fake-gem", "provider-authored")).to be(:include?, "name: provider-authored") + expect(installer.show_context_file("fake-gem", "provider-authored")).to be(:include?, "name: provider-authored") end - it "does not discover documents without skill metadata" do + it "lists unmarked documents as ordinary context" do File.write(File.join(provider_root, "context/ruby-testing.md"), "# Plain guidance\n") - expect(installer.find_gems_with_skills).to be == [] + expect(skill_definitions("fake-gem")).to be == [] + expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["fake-gem"] end it "installs skills alongside unfamiliar context types" do file = File.join(provider_root, "context/guide.md") File.write(file, "---\nmetadata:\n type: unknown\n---\n\n# Instructions\n") - expect(installer.install).to be == ["fake-gem-ruby-testing"] + expect(installer.install[:skills]).to be == ["fake-gem-ruby-testing"] end it "requires a nonempty string description" do @@ -207,10 +212,10 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) end - expect(File).not.to be(:exist?, installer.skills_path) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) end it "requires a directory for companion resources" do @@ -218,9 +223,9 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) - expect(File).not.to be(:exist?, installer.skills_path) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) end end @@ -229,7 +234,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio FileUtils.mkdir_p(File.join(context, "workflow/references")) File.write(File.join(context, "workflow.md"), "---\ndescription: Run workflow.\nlicense: MIT\nmetadata:\n type: skill\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: "fake-gem").sort).to be == ["fake-gem-ruby-testing", "fake-gem-workflow"] + expect(installer.install(gem: "fake-gem")[:skills].sort).to be == ["fake-gem-ruby-testing", "fake-gem-workflow"] 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") @@ -240,7 +245,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio it "removes a provider's last skill and cleans disappeared gem providers" do installer.install FileUtils.rm_rf(File.join(provider_root, "context")) - expect(installer.install(gem: "fake-gem")).to be == [] + expect(installer.install(gem: "fake-gem")[:skills]).to be == [] expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) write_skill(provider_root, "ruby-testing", description: "Test projects.") installer.install @@ -265,7 +270,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio write_owner(path, "fake-gem", "1.0.0", ecosystem: "cargo") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::Conflict) + end.to raise_exception(Agent::Context::Skills::Conflict) end it "keeps refreshed instructions identifiable when resource copying fails" do @@ -277,7 +282,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio File.symlink("missing", File.join(provider_root, "context/ruby-testing/broken")) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(Agent::Context::Skills::Ownership.load(File.dirname(ownership_path))["package"]).to be == "fake-gem" expect(File.read(destination)).to be(:include?, "# Updated") File.unlink(File.join(provider_root, "context/ruby-testing/broken")) @@ -285,29 +290,21 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio expect(File.read(destination)).to be(:include?, "# Updated") end - it "preserves unselected skills when installing one named skill" do - write_skill(provider_root, "documentation", description: "Write docs.") - installer.install - File.delete(File.join(provider_root, "context/documentation.md")) - installer.install(skill: "fake-gem-ruby-testing") - expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-documentation/SKILL.md")) - end - it "accepts the common metadata limits and rejects overlong descriptions" do write_skill(provider_root, "a" * 55, description: "d" * 1024) - expect(installer.list_skills("fake-gem").length).to be == 2 - expect(installer.list_skills("fake-gem").first.name.length).to be == 64 + expect(skill_definitions("fake-gem").length).to be == 2 + expect(skill_definitions("fake-gem").first.name.length).to be == 64 write_skill(provider_root, "a" * 55, description: "d" * 1025) expect do - installer.list_skills("fake-gem") - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + skill_definitions("fake-gem") + end.to raise_exception(Agent::Context::Skills::Invalid) end it "includes the package prefix when validating installed name length" do write_skill(provider_root, "a" * 56, description: "Run workflow.") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -317,7 +314,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio File.write(File.join(context, "workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -328,7 +325,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio File.write(File.join(context, "workflow.md"), "#{metadata.to_yaml}---\n\n# Workflow\n") installer.install content = File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/SKILL.md")) - expect(installer.list_skills("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == " #{"é" * 1024} " + expect(skill_definitions("fake-gem").find{|skill| skill.name == "fake-gem-workflow"}.description).to be == " #{"é" * 1024} " expect(Agent::Context::Document.parse(content, "installed").frontmatter["description"]).to be == " #{"é" * 1024} " end @@ -337,16 +334,16 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio 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 == ["fake-gem-ruby-testing"] + expect(skill_definitions("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] File.write(guide, "---\nmetadata:\n type: skill\ndescription: [invalid\n---\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) write_skill(provider_root, "Bad_Name", description: "Invalid name.") File.delete(guide) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) end it "refuses a reserved SKILL.md resource while retaining generated ownership" do @@ -357,7 +354,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio File.write(File.join(context, "workflow/SKILL.md"), "Conflicting instructions") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + end.to raise_exception(Agent::Context::Skills::Invalid) expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) end @@ -371,15 +368,15 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio path = File.join(resources, name) [false, true].each do |directory| directory ? FileUtils.mkdir_p(path) : File.write(path, "Conflict") - expect{installer.install}.to raise_exception(Agent::Context::Skills::Installer::InvalidSkill) + expect{installer.install}.to raise_exception(Agent::Context::Skills::Invalid) expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" FileUtils.rm_rf(path) end end FileUtils.mkdir_p(File.join(resources, "references")) - File.write(File.join(resources, "skill.json"), "Opaque resource") + File.write(File.join(resources, "notes.txt"), "Opaque resource") installer.install - expect(File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/skill.json"))).to be == "Opaque resource" + expect(File.read(File.join(consumer_root, ".agents/skills/fake-gem-workflow/notes.txt"))).to be == "Opaque resource" end it "reports inaccessible ownership before changing installed files or exclusions" do @@ -402,7 +399,7 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio it "works without Git and reports malformed generated exclusion blocks" do mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} - expect(installer.install).to be == ["fake-gem-ruby-testing"] + expect(installer.install[:skills]).to be == ["fake-gem-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") diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index c0c43da..3f65b5a 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -7,19 +7,16 @@ require "tmpdir" describe "Agent Context tasks" do - it "exposes context and skill tasks without the old command namespace" do + it "exposes the context task family" do Dir.mktmpdir do |root| context = Bake::Context.load(root) - %w[agent:context:list agent:context:show agent:context:install agent:context:skill:list agent:context:skill:show agent:context:skill:install].each do |name| + %w[agent:context:list agent:context:show agent:context:install].each do |name| expect(context.lookup(name)).to be_truthy end - expect(context.lookup("agent:context:index")).to be_nil - expect(context.lookup("agent:context:agents_md")).to be_nil - expect(context.lookup("agent:skills:install")).to be_nil end end - it "runs context and skill tasks against a resolved provider" do + it "lists, shows, and installs context and skills together" do Dir.mktmpdir do |root| provider = File.join(root, "provider") FileUtils.mkdir_p(File.join(provider, "context")) @@ -39,30 +36,19 @@ context.call("agent:context:list", "--gem", "provider") context.call("agent:context:list", "--gem", "missing") context.call("agent:context:show", "--gem", "provider", "--file", "guide") + context.call("agent:context:show", "--gem", "provider", "--file", "workflow") context.call("agent:context:show", "--gem", "provider", "--file", "missing") - context.call("agent:context:skill:list") - context.call("agent:context:skill:list", "--gem", "provider") - context.call("agent:context:skill:show", "--gem", "provider", "--skill", "provider-workflow") - expect do - context.call("agent:context:skill:show", "--gem", "provider", "--skill", "missing") - end.to raise_exception(ArgumentError) - context.call("agent:context:skill:install", "--gem", "provider", "--skill", "provider-workflow") context.call("agent:context:install", "--gem", "provider") expect(output.join("\n")).to be(:include?, "Provider Guide") - expect(output.join("\n")).to be(:include?, "provider-workflow (provider@1.0.0)") + expect(output.join("\n")).to be(:include?, "workflow (skill)") + expect(output.join("\n")).to be(:include?, "name: provider-workflow") expect(output.join("\n")).to be(:include?, "Installed skills: provider-workflow") - expect(File).not.to be(:exist?, File.join(root, ".agents/context/index.md")) expect(File).to be(:exist?, File.join(root, ".agents/context/provider/guide.md")) - expect(File).not.to be(:exist?, File.join(root, "agents.md")) expect(File).to be(:exist?, File.join(root, ".agents/skills/provider-workflow/SKILL.md")) FileUtils.rm_rf(File.join(provider, "context")) context.call("agent:context:list") - context.call("agent:context:skill:list") - context.call("agent:context:skill:install") context.call("agent:context:install") expect(output).to be(:include?, "No gems with context found") - expect(output).to be(:include?, "No dependency skills found") - expect(output).to be(:include?, "No dependency skills were installed") expect(File).not.to be(:exist?, File.join(root, ".agents/skills/provider-workflow")) end end From a0342dd441bdd6ddef41abaee90873adce06c9c1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 12 Oct 2026 18:51:08 +0530 Subject: [PATCH 27/37] Document context definition interfaces --- lib/agent/context/definition.rb | 12 ++++++++++++ lib/agent/context/skills/definition.rb | 5 +++++ lib/agent/context/skills/errors.rb | 3 +++ 3 files changed, 20 insertions(+) diff --git a/lib/agent/context/definition.rb b/lib/agent/context/definition.rb index 51d3ec5..b828eba 100644 --- a/lib/agent/context/definition.rb +++ b/lib/agent/context/definition.rb @@ -9,6 +9,9 @@ module Agent module Context # Represents one ordinary file provided in a package's context directory. class Definition + # Initialize a context definition from its source path and parsed document. + # @parameter path [String] The source file path. + # @parameter document [Document | Nil] The parsed Markdown document, when applicable. def initialize(path:, document: nil) @path = path @document = document @@ -16,18 +19,27 @@ def initialize(path:, document: nil) attr_reader :path + # Whether this definition installs as an Agent Skill. + # @returns [bool] `false` for ordinary context definitions. def skill? false end + # Read the definition's Markdown content or opaque file contents. + # @returns [String] The source content. def content @document ? @document.content : File.read(@path) end + # Return the relative name displayed by the context list. + # @parameter context_path [String] The provider's context directory. + # @returns [String] The path relative to the provider context directory. def display_name(context_path) @path.delete_prefix("#{context_path}/") end + # Write the definition to its installation destination. + # @parameter destination [String] The target file path. def write_to(destination) FileUtils.mkdir_p(File.dirname(destination)) if @document diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index 239d5dc..cc4b060 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -121,10 +121,15 @@ def content @content end + # Whether this definition installs as an Agent Skill. + # @returns [bool] `true` for skill definitions. def skill? true end + # Return the filename shown by the context list with its skill marker. + # @parameter _context_path [String] The provider's context directory. + # @returns [String] The source filename and skill marker. def display_name(_context_path) "#{File.basename(@source_file, File.extname(@source_file))} (skill)" end diff --git a/lib/agent/context/skills/errors.rb b/lib/agent/context/skills/errors.rb index 07e6b4a..27d0113 100644 --- a/lib/agent/context/skills/errors.rb +++ b/lib/agent/context/skills/errors.rb @@ -6,12 +6,15 @@ module Agent module Context module Skills + # Represents an error during dependency skill processing. class Error < StandardError end + # Represents invalid skill definitions or ownership metadata. class Invalid < Error end + # Represents a conflict with an existing skill destination. class Conflict < Error end end From bdb08579e7c6b1f08a138b8ff0f8ecbab595002a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 09:33:48 +0800 Subject: [PATCH 28/37] Use shared context error types --- design.md | 1 + lib/agent/context/document.rb | 9 +++----- lib/agent/context/errors.rb | 20 ++++++++++++++++ lib/agent/context/exclusion.rb | 7 ++---- lib/agent/context/installer.rb | 12 +++++----- lib/agent/context/package.rb | 8 +++---- lib/agent/context/skills.rb | 1 - lib/agent/context/skills/definition.rb | 14 +++++------ lib/agent/context/skills/errors.rb | 22 ------------------ lib/agent/context/skills/ownership.rb | 16 ++++--------- test/agent/context/document.rb | 4 ++-- test/agent/context/installer.rb | 2 +- test/agent/context/skills.rb | 6 ++--- test/agent/context/skills/installer.rb | 32 +++++++++++++------------- test/agent/context/skills/ownership.rb | 8 +++---- 15 files changed, 73 insertions(+), 89 deletions(-) create mode 100644 lib/agent/context/errors.rb delete mode 100644 lib/agent/context/skills/errors.rb diff --git a/design.md b/design.md index 9370c88..46dcfcf 100644 --- a/design.md +++ b/design.md @@ -17,6 +17,7 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | | Command presentation | Bake task adapters | Bake task adapters | +| Errors | `Agent::Context::Error`, `InvalidError`, `ConflictError` | `bake::Error` returned through `Result` | Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index a866859..3bb0b98 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -6,15 +6,12 @@ require "markly" require "yaml" require "date" +require_relative "errors" module Agent module Context # Represents a Markdown source and its parsed front matter. class Document - # Raised when context front matter cannot be interpreted. - class Invalid < ArgumentError - end - # Read and parse a Markdown document. def self.load(path) parse(File.read(path), path) @@ -31,12 +28,12 @@ def self.parse(content, path) frontmatter = {} if node && [nil, "yaml"].include?(language) frontmatter = YAML.safe_load(node.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) - raise Invalid, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) + raise InvalidError, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) end new(path, content, node, frontmatter) rescue Psych::Exception => error - raise Invalid, "Invalid YAML frontmatter in #{path}: #{error.message}" + raise InvalidError, "Invalid YAML frontmatter in #{path}: #{error.message}" end # Retain the source and its parsed representation. diff --git a/lib/agent/context/errors.rb b/lib/agent/context/errors.rb new file mode 100644 index 0000000..f547fe4 --- /dev/null +++ b/lib/agent/context/errors.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Agent + module Context + # Base class for errors raised while loading or installing context. + class Error < StandardError + end + + # Raised when provider content or installation metadata is invalid. + class InvalidError < Error + end + + # Raised when providers or installation destinations conflict. + class ConflictError < Error + end + end +end diff --git a/lib/agent/context/exclusion.rb b/lib/agent/context/exclusion.rb index d1aa835..6d2e6c0 100644 --- a/lib/agent/context/exclusion.rb +++ b/lib/agent/context/exclusion.rb @@ -5,15 +5,12 @@ require "open3" require "fileutils" +require_relative "errors" module Agent module Context # Represents local Git exclusions shared by context and skill installers. class Exclusion - # Raised when the generated exclusion block cannot be interpreted. - class Invalid < StandardError - end - BEGIN_MARKER = "# BEGIN bake-agent-context" END_MARKER = "# END bake-agent-context" @@ -42,7 +39,7 @@ def self.prepare(root, names) unless begins.empty? && ends.empty? unless begins.length == 1 && ends.length == 1 && begins.first < ends.first - raise Invalid, "Malformed Agent Context exclusion block: #{path}" + raise InvalidError, "Malformed Agent Context exclusion block: #{path}" end lines.slice!(begins.first..ends.first) diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index a83eb0b..1e483d4 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -9,7 +9,7 @@ require "fileutils" require_relative "paths" -require_relative "skills/errors" +require_relative "errors" require_relative "skills/ownership" require_relative "skills/definition" require_relative "exclusion" @@ -67,7 +67,7 @@ def show_context_file(gem_name, file_name) def install(gem: nil) # Resolve providers and load every definition once: selected = packages(gem: gem) - raise Skills::Error, "No gem found: #{gem}" if gem && selected.empty? + raise Error, "No gem found: #{gem}" if gem && selected.empty? definitions = selected.flat_map(&:definitions) skills = definitions.grep(Skills::Definition) @@ -89,7 +89,7 @@ def packages(gem: nil, skip_local: true) gem ? specification.name == gem : !skip_local || File.expand_path(specification.full_gem_path) != @root end if gem && specifications.length > 1 - raise Skills::Conflict, "Multiple resolved versions provide gem #{gem.inspect}" + raise ConflictError, "Multiple resolved versions provide gem #{gem.inspect}" end specifications.map{|specification| Package.load(specification)} @@ -98,7 +98,7 @@ def packages(gem: nil, skip_local: true) def install_skills(definitions, package:) # Validate selected names and ownership before writing any skill: duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1} - raise Skills::Conflict, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? + raise ConflictError, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty? skills_path = File.join(@root, ".agents", "skills") owners = Skills::Ownership.scan(skills_path) @@ -130,11 +130,11 @@ def validate_skill_destinations(definitions, skills_path, owners) destination = File.join(skills_path, definition.name) if owner && owner.values_at("ecosystem", "package") != ["gem", definition.provider_name] - raise Skills::Conflict, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" + raise ConflictError, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" end if (File.exist?(destination) || File.symlink?(destination)) && !owner - raise Skills::Conflict, "Skill #{definition.name.inspect} is not managed by Agent Context" + raise ConflictError, "Skill #{definition.name.inspect} is not managed by Agent Context" end end end diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb index b110802..d31731e 100644 --- a/lib/agent/context/package.rb +++ b/lib/agent/context/package.rb @@ -7,7 +7,7 @@ require_relative "definition" require_relative "document" require_relative "skills/definition" -require_relative "skills/errors" +require_relative "errors" module Agent module Context @@ -20,13 +20,11 @@ def self.load(specification) return package unless File.exist?(root) || File.symlink?(root) unless File.lstat(root).directory? - raise Skills::Invalid, "Context provider must be a regular directory: #{root}" + raise InvalidError, "Context provider must be a regular directory: #{root}" end package.load_directory(root) package - rescue Document::Invalid => error - raise Skills::Invalid, error.message end # Retain the provider and its loaded definitions. @@ -75,7 +73,7 @@ def load_directory(directory) elsif stat.file? document = File.extname(file).downcase == ".md" ? Document.load(file) : nil if document&.skill? - raise Skills::Invalid, "Skill documents must be directly inside context/: #{file}" unless directory == path + raise InvalidError, "Skill documents must be directly inside context/: #{file}" unless directory == path @definitions << Skills::Definition.from_document(document, @specification) else @definitions << Definition.new(path: file, document: document) diff --git a/lib/agent/context/skills.rb b/lib/agent/context/skills.rb index 596da07..fc56a24 100644 --- a/lib/agent/context/skills.rb +++ b/lib/agent/context/skills.rb @@ -5,7 +5,6 @@ require_relative "skills/definition" require_relative "skills/ownership" -require_relative "skills/errors" module Agent module Context diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index cc4b060..bd2434d 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -6,7 +6,7 @@ require "fileutils" require_relative "../definition" -require_relative "errors" +require_relative "../errors" require_relative "ownership" module Agent @@ -34,7 +34,7 @@ def self.from_document(source, specification) assets = File.join(File.dirname(file), local_name) if File.exist?(assets) || File.symlink?(assets) unless File.lstat(assets).directory? - raise Invalid, "Skill resources must be a regular directory: #{assets}" + raise InvalidError, "Skill resources must be a regular directory: #{assets}" end else assets = nil @@ -56,7 +56,7 @@ def self.from_document(source, specification) # Validate an installed directory name. def self.validate_name(name, file) unless name.is_a?(String) && name.match?(Ownership::NAME_PATTERN) && name.length <= 64 - raise Invalid, "Invalid skill name in #{file}: #{name.inspect}" + raise InvalidError, "Invalid skill name in #{file}: #{name.inspect}" end end @@ -66,12 +66,12 @@ def self.validate_metadata(frontmatter, file) description = frontmatter["description"] unless description.is_a?(String) - raise Invalid, "Invalid skill description in #{file}" + raise InvalidError, "Invalid skill description in #{file}" end description = description.strip if description.empty? || description.length > 1024 - raise Invalid, "Invalid skill description in #{file}" + raise InvalidError, "Invalid skill description in #{file}" end end @@ -156,11 +156,11 @@ def copy_resources(source, destination, top_level) metadata = File.lstat(from) if metadata.symlink? || (!metadata.file? && !metadata.directory?) - raise Invalid, "Skill resources must be regular files or directories: #{from}" + raise InvalidError, "Skill resources must be regular files or directories: #{from}" end if top_level && name.downcase == "skill.md" - raise Invalid, "#{name} is reserved for generated skill files: #{from}" + raise InvalidError, "#{name} is reserved for generated skill files: #{from}" end if metadata.directory? diff --git a/lib/agent/context/skills/errors.rb b/lib/agent/context/skills/errors.rb deleted file mode 100644 index 27d0113..0000000 --- a/lib/agent/context/skills/errors.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -module Agent - module Context - module Skills - # Represents an error during dependency skill processing. - class Error < StandardError - end - - # Represents invalid skill definitions or ownership metadata. - class Invalid < Error - end - - # Represents a conflict with an existing skill destination. - class Conflict < Error - end - end - end -end diff --git a/lib/agent/context/skills/ownership.rb b/lib/agent/context/skills/ownership.rb index eaabe25..500506a 100644 --- a/lib/agent/context/skills/ownership.rb +++ b/lib/agent/context/skills/ownership.rb @@ -5,7 +5,7 @@ # Copyright, 2026, by Samuel Williams. require_relative "../document" -require_relative "errors" +require_relative "../errors" module Agent module Context @@ -15,10 +15,6 @@ class Ownership FILE_NAME = "SKILL.md" NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ - # Raised when installed ownership cannot be safely interpreted. - class Invalid < Agent::Context::Skills::Invalid - end - # Read a skill's installation ownership. # @parameter directory [String] The installed skill directory. # @returns [Hash | Nil] Ownership when the skill has an installation record. @@ -31,19 +27,17 @@ def self.load(directory) return end - raise Invalid, "Skill ownership must be a regular file: #{path}" unless stat.file? + raise InvalidError, "Skill ownership must be a regular file: #{path}" unless stat.file? metadata = Document.load(path).metadata fields = ["ecosystem", "package", "version"] return unless metadata["type"] == "skill" && metadata.key?("ecosystem") unless fields.all?{|field| metadata[field].is_a?(String) && !metadata[field].strip.empty?} - raise Invalid, "Invalid skill ownership: #{path}" + raise InvalidError, "Invalid skill ownership: #{path}" end metadata.slice(*fields) - rescue Document::Invalid => error - raise Invalid, error.message end # Collect ownership from installed skill directories. @@ -56,7 +50,7 @@ def self.scan(directory) return {} end - raise Invalid, "Skill installation path must be a regular directory: #{directory}" unless stat.directory? + raise InvalidError, "Skill installation path must be a regular directory: #{directory}" unless stat.directory? Dir.children(directory).sort.each_with_object({}) do |name, owners| path = File.join(directory, name) @@ -64,7 +58,7 @@ def self.scan(directory) next unless owner = load(path) unless name.match?(NAME_PATTERN) && name.length <= 64 - raise Invalid, "Invalid installed skill name: #{name.inspect}" + raise InvalidError, "Invalid installed skill name: #{name.inspect}" end owners[name] = owner diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index 84758c2..b667f95 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -51,14 +51,14 @@ def around File.write(path, "---\n- invalid\n---\n# Guide\n") expect do document - end.to raise_exception(subject::Invalid, message: be(:include?, path)) + end.to raise_exception(Agent::Context::InvalidError, message: be(:include?, path)) end it "rejects arbitrary Ruby objects in YAML metadata" do File.write(path, "---\nobject: !ruby/object:Object {}\n---\n# Guide\n") expect do document - end.to raise_exception(subject::Invalid, message: be(:include?, path)) + end.to raise_exception(Agent::Context::InvalidError, message: be(:include?, path)) end it "edits native front matter while retaining the shared fixture body" do diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index cdaedd2..8d0d560 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -119,7 +119,7 @@ def around it "reports a missing gem" do helper = subject.new(specifications: @specifications) - expect{helper.install(gem: "non-existent-gem")}.to raise_exception(Agent::Context::Skills::Error) + expect{helper.install(gem: "non-existent-gem")}.to raise_exception(Agent::Context::Error) end it "installs dotfiles and files inside hidden context directories" do diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index 19fd8cd..763d191 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -89,7 +89,7 @@ def around File.write(File.join(provider, "context/guide.md"), "---\ndescription: [broken\n---\n\n# Guide") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(File).not.to be(:exist?, File.join(consumer, ".agents/skills/provider-workflow")) end @@ -133,7 +133,7 @@ def around other = specification.dup other.version = "2.0.0" installer = subject.new(root: consumer, specifications: [specification, other]) - expect{installer.install(gem: "provider")}.to raise_exception(Agent::Context::Skills::Conflict) + expect{installer.install(gem: "provider")}.to raise_exception(Agent::Context::ConflictError) expect(File).not.to be(:exist?, File.join(consumer, ".agents")) end @@ -141,7 +141,7 @@ def around context = File.join(provider, "context") FileUtils.rm_r(context) File.write(context, "Provider configuration") - expect{installer.install}.to raise_exception(Agent::Context::Skills::Invalid) + expect{installer.install}.to raise_exception(Agent::Context::InvalidError) expect(File).not.to be(:exist?, File.join(consumer, ".agents")) end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index c9f6fcc..d6dfe76 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -75,7 +75,7 @@ def skill_definitions(gem_name) expect(installer.find_gem_with_context("missing-gem")).to be_nil expect do installer.install(gem: "missing-gem") - end.to raise_exception(Agent::Context::Skills::Error) + end.to raise_exception(Agent::Context::Error) end it "ignores legacy skill bundles and discovers only context metadata" do @@ -141,7 +141,7 @@ def skill_definitions(gem_name) expect do installer.install(gem: "fake-gem") - end.to raise_exception(Agent::Context::Skills::Conflict) + end.to raise_exception(Agent::Context::ConflictError) expect(File.read(File.join(destination, "SKILL.md"))).to be == "project-owned" end @@ -153,7 +153,7 @@ def skill_definitions(gem_name) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(File.read(destination)).to be == "project-owned" end @@ -166,7 +166,7 @@ def skill_definitions(gem_name) expect do duplicate_installer.install - end.to raise_exception(Agent::Context::Skills::Conflict) + end.to raise_exception(Agent::Context::ConflictError) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing")) ensure @@ -212,7 +212,7 @@ def skill_definitions(gem_name) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) @@ -223,7 +223,7 @@ def skill_definitions(gem_name) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) end @@ -270,7 +270,7 @@ def skill_definitions(gem_name) write_owner(path, "fake-gem", "1.0.0", ecosystem: "cargo") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Conflict) + end.to raise_exception(Agent::Context::ConflictError) end it "keeps refreshed instructions identifiable when resource copying fails" do @@ -282,7 +282,7 @@ def skill_definitions(gem_name) File.symlink("missing", File.join(provider_root, "context/ruby-testing/broken")) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(Agent::Context::Skills::Ownership.load(File.dirname(ownership_path))["package"]).to be == "fake-gem" expect(File.read(destination)).to be(:include?, "# Updated") File.unlink(File.join(provider_root, "context/ruby-testing/broken")) @@ -297,14 +297,14 @@ def skill_definitions(gem_name) write_skill(provider_root, "a" * 55, description: "d" * 1025) expect do skill_definitions("fake-gem") - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end it "includes the package prefix when validating installed name length" do write_skill(provider_root, "a" * 56, description: "Run workflow.") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -314,7 +314,7 @@ def skill_definitions(gem_name) File.write(File.join(context, "workflow.md"), "---\nmetadata:\n type: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) end @@ -338,12 +338,12 @@ def skill_definitions(gem_name) File.write(guide, "---\nmetadata:\n type: skill\ndescription: [invalid\n---\n") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) write_skill(provider_root, "Bad_Name", description: "Invalid name.") File.delete(guide) expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end it "refuses a reserved SKILL.md resource while retaining generated ownership" do @@ -354,7 +354,7 @@ def skill_definitions(gem_name) File.write(File.join(context, "workflow/SKILL.md"), "Conflicting instructions") expect do installer.install - end.to raise_exception(Agent::Context::Skills::Invalid) + end.to raise_exception(Agent::Context::InvalidError) expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" expect(File).to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md")) end @@ -368,7 +368,7 @@ def skill_definitions(gem_name) path = File.join(resources, name) [false, true].each do |directory| directory ? FileUtils.mkdir_p(path) : File.write(path, "Conflict") - expect{installer.install}.to raise_exception(Agent::Context::Skills::Invalid) + expect{installer.install}.to raise_exception(Agent::Context::InvalidError) expect(Agent::Context::Skills::Ownership.load(File.join(consumer_root, ".agents/skills/fake-gem-workflow"))["package"]).to be == "fake-gem" FileUtils.rm_rf(path) end @@ -405,7 +405,7 @@ def skill_definitions(gem_name) File.write(File.join(consumer_root, ".git/info/exclude"), "# BEGIN bake-agent-context\n") expect do installer.install - end.to raise_exception(Agent::Context::Exclusion::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end it "removes an incomplete instruction write and permits a retry" do destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing") diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb index 3cab1eb..9163866 100644 --- a/test/agent/context/skills/ownership.rb +++ b/test/agent/context/skills/ownership.rb @@ -48,7 +48,7 @@ def write_owner(directory, package, version) File.write(File.join(skill_path, "SKILL.md"), content) expect do subject.scan(directory) - end.to raise_exception(subject::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end end @@ -65,11 +65,11 @@ def write_owner(directory, package, version) File.symlink("missing.json", File.join(skill_path, "SKILL.md")) expect do subject.load(skill_path) - end.to raise_exception(subject::Invalid) + end.to raise_exception(Agent::Context::InvalidError) File.write(File.join(directory, "blocker"), "Not a directory.") expect do subject.scan(File.join(directory, "blocker")) - end.to raise_exception(subject::Invalid) + end.to raise_exception(Agent::Context::InvalidError) end it "validates the installed directory name" do @@ -79,7 +79,7 @@ def write_owner(directory, package, version) write_owner(path, "provider", "1.0.0") expect do subject.scan(directory) - end.to raise_exception(subject::Invalid) + end.to raise_exception(Agent::Context::InvalidError) FileUtils.rm_rf(path) end end From 48ae57c981a9bbbad26f09ffc21347285460bca5 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 09:35:39 +0800 Subject: [PATCH 29/37] Fix error class formatting --- lib/agent/context/errors.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/agent/context/errors.rb b/lib/agent/context/errors.rb index f547fe4..b8b056e 100644 --- a/lib/agent/context/errors.rb +++ b/lib/agent/context/errors.rb @@ -8,11 +8,11 @@ module Context # Base class for errors raised while loading or installing context. class Error < StandardError end - + # Raised when provider content or installation metadata is invalid. class InvalidError < Error end - + # Raised when providers or installation destinations conflict. class ConflictError < Error end From eb018ea94becdc959dcd966a78f716c75520bead Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 10:43:48 +0800 Subject: [PATCH 30/37] Return provider packages from context lookup --- bake/agent/context.rb | 8 ++++---- design.md | 2 +- lib/agent/context/installer.rb | 4 ++-- lib/agent/context/package.rb | 17 +++++------------ test/agent/context/installer.rb | 20 +++++++++++--------- test/agent/context/skills.rb | 2 +- test/agent/context/skills/installer.rb | 8 ++++---- 7 files changed, 28 insertions(+), 33 deletions(-) diff --git a/bake/agent/context.rb b/bake/agent/context.rb index a3642f8..5b1463c 100644 --- a/bake/agent/context.rb +++ b/bake/agent/context.rb @@ -23,8 +23,8 @@ def list(gem: nil) provider = @installer.find_gem_with_context(gem) if provider puts "Context files for gem '#{gem}':" - provider[:definitions].each do |definition| - puts " #{definition.display_name(provider[:path])}" + provider.definitions.each do |definition| + puts " #{definition.display_name(provider.path)}" end else puts "No context found for gem '#{gem}'" @@ -33,8 +33,8 @@ def list(gem: nil) gems = @installer.find_gems_with_context if gems.any? puts "Gems with context available:" - gems.each do |gem_info| - puts " #{gem_info[:name]} (#{gem_info[:version]})" + gems.each do |provider| + puts " #{provider.name} (#{provider.version})" end else puts "No gems with context found" diff --git a/design.md b/design.md index 46dcfcf..325bf8d 100644 --- a/design.md +++ b/design.md @@ -21,7 +21,7 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. -Ruby keeps a gem specification with its loaded files. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`. Both apply the same document and installation rules. +Ruby keeps a gem specification with its loaded files, and provider lookups return that `Package` directly so callers use its name, version, path, and definitions. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`, which owns definition loading and ordinary-file installation. Both apply the same document and installation rules. Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 1e483d4..3696bab 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -31,13 +31,13 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) # Find providers with ordinary context or skills. def find_gems_with_context(skip_local: true) - packages(skip_local: skip_local).reject{|package| package.definitions.empty?}.map(&:information) + packages(skip_local: skip_local).reject{|package| package.definitions.empty?} end # Find one provider with context or skills. def find_gem_with_context(gem_name) package = packages(gem: gem_name).first - package.information if package && !package.definitions.empty? + package if package && !package.definitions.empty? end # List every definition from a provider, including skill documents. diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb index d31731e..e62a76e 100644 --- a/lib/agent/context/package.rb +++ b/lib/agent/context/package.rb @@ -43,24 +43,17 @@ def initialize(specification, definitions) def name @specification.name end + + # @returns [String] The provider gem version. + def version + @specification.version.to_s + end # @returns [String] The provider context directory. def path File.join(@specification.full_gem_path, "context") end - # Describe a provider for command presentation. - def information - { - name: name, - version: @specification.version.to_s, - summary: @specification.summary, - metadata: @specification.metadata, - path: path, - definitions: @definitions, - } - end - # Load Markdown before descending into companion directories. def load_directory(directory) # Classify declarations before traversing their resource directories: diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 8d0d560..a10bbb3 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -42,24 +42,26 @@ def around gems = helper.find_gems_with_context expect(gems.length).to be == 1 - expect(gems.first[:name]).to be == "fake-gem" - expect(gems.first[:version]).to be == "1.0.0" - expect(gems.first[:path]).to be == context_path + expect(gems.first.name).to be == "fake-gem" + expect(gems.first.version).to be == "1.0.0" + expect(gems.first.path).to be == context_path + expect(gems.first.definitions.length).to be == 2 end it "can find a specific gem with context" do helper = subject.new(specifications: @specifications) - gem_info = helper.find_gem_with_context("fake-gem") + package = helper.find_gem_with_context("fake-gem") - expect(gem_info).to be_truthy - expect(gem_info[:name]).to be == "fake-gem" - expect(gem_info[:path]).to be == context_path + expect(package).to be_truthy + expect(package.name).to be == "fake-gem" + expect(package.path).to be == context_path + expect(package.definitions.length).to be == 2 end it "returns nil for gems without context" do helper = subject.new(specifications: @specifications) - gem_info = helper.find_gem_with_context("non-existent-gem") - expect(gem_info).to be_nil + package = helper.find_gem_with_context("non-existent-gem") + expect(package).to be_nil end it "can list context definitions for a gem" do diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index 763d191..4d106f4 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -39,7 +39,7 @@ def around end installer = subject.new(root: consumer, specifications: specifications) expect(enumerations).to be == 0 - expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["provider"] + expect(installer.find_gems_with_context.map(&:name)).to be == ["provider"] installer.install expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == File.read(File.join(provider, "context/guide.md")) end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index d6dfe76..ff8bda2 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -50,8 +50,8 @@ def skill_definitions(gem_name) gems = installer.find_gems_with_context expect(gems.length).to be == 1 - expect(gems.first[:name]).to be == "fake-gem" - expect(gems.first[:definitions].grep(Agent::Context::Skills::Definition).map(&:name)).to be == ["fake-gem-ruby-testing"] + expect(gems.first.name).to be == "fake-gem" + expect(gems.first.definitions.grep(Agent::Context::Skills::Definition).map(&:name)).to be == ["fake-gem-ruby-testing"] end it "lists skills provided by a gem" do @@ -93,7 +93,7 @@ def skill_definitions(gem_name) local_installer = subject.new(root: consumer_root, specifications: [local_specification]) expect(local_installer.find_gems_with_context).to be == [] - expect(local_installer.find_gems_with_context(skip_local: false).map{|gem| gem[:name]}).to be == ["local-gem"] + expect(local_installer.find_gems_with_context(skip_local: false).map(&:name)).to be == ["local-gem"] end with "installation" do @@ -196,7 +196,7 @@ def skill_definitions(gem_name) it "lists unmarked documents as ordinary context" do File.write(File.join(provider_root, "context/ruby-testing.md"), "# Plain guidance\n") expect(skill_definitions("fake-gem")).to be == [] - expect(installer.find_gems_with_context.map{|gem| gem[:name]}).to be == ["fake-gem"] + expect(installer.find_gems_with_context.map(&:name)).to be == ["fake-gem"] end it "installs skills alongside unfamiliar context types" do From 50c063ba8f48eef667dc22b703ac92650f823859 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 10:45:04 +0800 Subject: [PATCH 31/37] Fix package formatting --- lib/agent/context/package.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb index e62a76e..0af9b77 100644 --- a/lib/agent/context/package.rb +++ b/lib/agent/context/package.rb @@ -43,7 +43,7 @@ def initialize(specification, definitions) def name @specification.name end - + # @returns [String] The provider gem version. def version @specification.version.to_s From ed92751222f595ae571cc842b1dfe1d636181f7d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 11:05:59 +0800 Subject: [PATCH 32/37] Document provider and package model --- design.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/design.md b/design.md index 325bf8d..e4d33db 100644 --- a/design.md +++ b/design.md @@ -8,11 +8,11 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | Concern | Ruby | Rust | | --- | --- | --- | -| Resolved provider | `Gem::Specification`, retained by `Package` | `ContextPackage`, resolved by `Installer::discover` | +| Resolved provider | `Gem::Specification`, retained by `Package` | `Provider`, resolved by `Installer::discover` | | Parsed Markdown | `Document` | `ContextDocument` in `document.rs` | -| Loaded provider definitions | `Package#definitions` | `PackageContext::definitions` | -| Ordinary definition | `Definition` | `Definition::File` | -| Skill definition | `Skills::Definition < Definition` | `Definition::Skill` | +| Loaded provider definitions | `Package#definitions` | `Package::definitions` | +| Ordinary definition | `Definition` | `Definition::File(ContextFile)` | +| Skill definition | `Skills::Definition < Definition` | `Definition::Skill(Skill)` | | Installation workflow | `Installer#install` | `Installer::install` | | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | @@ -21,7 +21,7 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. -Ruby keeps a gem specification with its loaded files, and provider lookups return that `Package` directly so callers use its name, version, path, and definitions. Rust separates the resolved `ContextPackage` from its loaded `PackageContext`, which owns definition loading and ordinary-file installation. Both apply the same document and installation rules. +Ruby's loaded `Package` retains its gem specification and definitions. Rust's resolved `Provider` carries Cargo metadata and the context path; loaded `Package` wraps that provider with its definitions. Both apply the same document and installation rules. Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. @@ -41,7 +41,9 @@ The portable YAML form uses `---` delimiters. Rust also recognizes the additiona For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The loaded package stores ordinary definitions and skill definitions in one collection. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. Ordinary Markdown is written from the loaded source; opaque files are copied directly. +The loaded package stores ordinary definitions and skill definitions in one collection. A Rust `ContextFile` is the ordinary-file payload: its provider-relative destination path, source path, and optional parsed `ContextDocument` let the package install Markdown from the loaded document and copy opaque files directly. This corresponds to Ruby's ordinary `Definition`; `ContextDocument` corresponds to Ruby's `Document`. + +Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. ## Installation Flow From 30e50a8431b195ffeb0ade2a8fc65f443095f36d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 11:14:15 +0800 Subject: [PATCH 33/37] Clarify definition naming across implementations --- design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index e4d33db..db448d5 100644 --- a/design.md +++ b/design.md @@ -11,8 +11,8 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | Resolved provider | `Gem::Specification`, retained by `Package` | `Provider`, resolved by `Installer::discover` | | Parsed Markdown | `Document` | `ContextDocument` in `document.rs` | | Loaded provider definitions | `Package#definitions` | `Package::definitions` | -| Ordinary definition | `Definition` | `Definition::File(ContextFile)` | -| Skill definition | `Skills::Definition < Definition` | `Definition::Skill(Skill)` | +| Ordinary definition | `Definition` | `Definition` | +| All definition variants | `Definition` and `Skills::Definition < Definition` | `ContextDefinition::File(Definition)` or `ContextDefinition::Skill(Skill)` | | Installation workflow | `Installer#install` | `Installer::install` | | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | @@ -41,7 +41,7 @@ The portable YAML form uses `---` delimiters. Rust also recognizes the additiona For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The loaded package stores ordinary definitions and skill definitions in one collection. A Rust `ContextFile` is the ordinary-file payload: its provider-relative destination path, source path, and optional parsed `ContextDocument` let the package install Markdown from the loaded document and copy opaque files directly. This corresponds to Ruby's ordinary `Definition`; `ContextDocument` corresponds to Ruby's `Document`. +The loaded package stores ordinary definitions and skills in one collection. Rust's `Definition` is the ordinary-file payload, matching Ruby's ordinary `Definition`; it carries the provider-relative destination path, source path, and optional parsed `ContextDocument`. Rust's `ContextDefinition` enum holds either that file definition or a skill. `ContextDocument` corresponds to Ruby's `Document`. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. From eaf657b00b44cd28474d9b9beb1bca3297bb3a0e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 12:22:31 +0800 Subject: [PATCH 34/37] Document shared definition names --- design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/design.md b/design.md index db448d5..81d0bf0 100644 --- a/design.md +++ b/design.md @@ -9,10 +9,10 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | Concern | Ruby | Rust | | --- | --- | --- | | Resolved provider | `Gem::Specification`, retained by `Package` | `Provider`, resolved by `Installer::discover` | -| Parsed Markdown | `Document` | `ContextDocument` in `document.rs` | +| Parsed Markdown | `Document` | `Document` in `document.rs` | | Loaded provider definitions | `Package#definitions` | `Package::definitions` | -| Ordinary definition | `Definition` | `Definition` | -| All definition variants | `Definition` and `Skills::Definition < Definition` | `ContextDefinition::File(Definition)` or `ContextDefinition::Skill(Skill)` | +| Ordinary file | `Definition` | `FileDefinition` | +| All definition variants | `Definition` and `Skills::Definition < Definition` | `Definition::File(FileDefinition)` or `Definition::Skill(Skill)` | | Installation workflow | `Installer#install` | `Installer::install` | | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | @@ -27,7 +27,7 @@ Ruby uses the gem name as its package selector. Rust uses the crate name when un ## Documents and Front Matter -`Document.load(path)` and `ContextDocument::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. +`Document.load(path)` and `Document::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. The skill predicate matches `skill`; all other values follow the ordinary context path. Ordinary guides can carry their own top-level fields. Front matter uses YAML by default or with the explicit `yaml` language hint. Other front matter languages remain opaque. @@ -41,7 +41,7 @@ The portable YAML form uses `---` delimiters. Rust also recognizes the additiona For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The loaded package stores ordinary definitions and skills in one collection. Rust's `Definition` is the ordinary-file payload, matching Ruby's ordinary `Definition`; it carries the provider-relative destination path, source path, and optional parsed `ContextDocument`. Rust's `ContextDefinition` enum holds either that file definition or a skill. `ContextDocument` corresponds to Ruby's `Document`. +The loaded package stores ordinary definitions and skills in one collection. Ruby uses `Definition` for ordinary files and `Skills::Definition < Definition` for skills. Rust represents the same alternatives with a `Definition` enum: `File(FileDefinition)` carries the provider-relative destination path, source path, and optional parsed `Document`; `Skill(Skill)` carries skill-specific metadata and resources. Both implementations use `Document` for parsed Markdown. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. From 2505cc6cea5a09a3ca338299f6adccfd3d0ff538 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 12:32:37 +0800 Subject: [PATCH 35/37] Align design taxonomy with Rust names --- design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/design.md b/design.md index 81d0bf0..1739681 100644 --- a/design.md +++ b/design.md @@ -8,11 +8,11 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | Concern | Ruby | Rust | | --- | --- | --- | -| Resolved provider | `Gem::Specification`, retained by `Package` | `Provider`, resolved by `Installer::discover` | +| Resolved package specification | `Gem::Specification`, retained by `Package` | `Specification`, resolved by `Installer::discover` | | Parsed Markdown | `Document` | `Document` in `document.rs` | | Loaded provider definitions | `Package#definitions` | `Package::definitions` | | Ordinary file | `Definition` | `FileDefinition` | -| All definition variants | `Definition` and `Skills::Definition < Definition` | `Definition::File(FileDefinition)` or `Definition::Skill(Skill)` | +| All definition variants | `Definition` and `Skills::Definition < Definition` | `Definition::File(FileDefinition)` or `Definition::Skill(SkillDefinition)` | | Installation workflow | `Installer#install` | `Installer::install` | | Installed provenance | `Skills::Ownership` | `ownership` module | | Local Git exclusions | `Exclusion` | `exclude` module | @@ -21,7 +21,7 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti Each parser, metadata rule, and file format has one owner. Document parsing interprets Markdown and YAML; skill definitions apply name and description rules; ownership interprets installed provenance; installers select destinations and change files. Constructors retain supplied state. Reading, parsing, discovery, and installation have explicit entry points. -Ruby's loaded `Package` retains its gem specification and definitions. Rust's resolved `Provider` carries Cargo metadata and the context path; loaded `Package` wraps that provider with its definitions. Both apply the same document and installation rules. +Ruby's loaded `Package` retains its `Gem::Specification` and definitions. Rust's resolved `Specification` carries Cargo metadata and the context path; loaded `Package` wraps that specification with its definitions. Both apply the same document and installation rules. Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. @@ -41,7 +41,7 @@ The portable YAML form uses `---` delimiters. Rust also recognizes the additiona For each provider, load Markdown documents before descending into subdirectories. Root-level skill declarations identify companion resource directories. Keep those resources opaque, including Markdown-looking assets. -The loaded package stores ordinary definitions and skills in one collection. Ruby uses `Definition` for ordinary files and `Skills::Definition < Definition` for skills. Rust represents the same alternatives with a `Definition` enum: `File(FileDefinition)` carries the provider-relative destination path, source path, and optional parsed `Document`; `Skill(Skill)` carries skill-specific metadata and resources. Both implementations use `Document` for parsed Markdown. +The loaded package stores ordinary definitions and skills in one collection. Ruby uses `Definition` for ordinary files and `Skills::Definition < Definition` for skills. Rust represents the same alternatives with a `Definition` enum: `File(FileDefinition)` carries the provider-relative destination path, source path, and optional parsed `Document`; `Skill(SkillDefinition)` carries skill-specific metadata and resources. Both implementations use `Document` for parsed Markdown. Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. From ca0153ade08ba73104d45e51b469a18c064bc62e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 13 Oct 2026 12:38:23 +0800 Subject: [PATCH 36/37] Document specification-oriented Rust API names --- design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design.md b/design.md index 1739681..2c8ccfd 100644 --- a/design.md +++ b/design.md @@ -23,7 +23,7 @@ Each parser, metadata rule, and file format has one owner. Document parsing inte Ruby's loaded `Package` retains its `Gem::Specification` and definitions. Rust's resolved `Specification` carries Cargo metadata and the context path; loaded `Package` wraps that specification with its definitions. Both apply the same document and installation rules. -Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. +Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. Rust exposes the resolved values as `Installer::specifications()` and `Installer::find_specification()`; “provider” remains the domain role of a package that supplies context. ## Documents and Front Matter From 5add54bc0e553bc7251395fafc3b8084c0e7ff5d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 15 Oct 2026 23:13:40 +1300 Subject: [PATCH 37/37] Simplify provider lookup and definition contracts. --- bake/agent/context.rb | 8 ++-- context/usage.md | 2 + design.md | 18 +++++--- lib/agent/context/definition.rb | 21 +++++---- lib/agent/context/document.rb | 17 ++++--- lib/agent/context/installer.rb | 46 ++++++------------- lib/agent/context/package.rb | 33 ++++++++++---- lib/agent/context/skills/definition.rb | 62 +++++++------------------- releases.md | 2 + specification.md | 10 ++++- test/agent/context/document.rb | 1 + test/agent/context/installer.rb | 16 +++---- test/agent/context/skills.rb | 58 +++++++++++++++++++++--- test/agent/context/skills/installer.rb | 20 ++++----- test/agent/context/tasks.rb | 2 +- 15 files changed, 178 insertions(+), 138 deletions(-) diff --git a/bake/agent/context.rb b/bake/agent/context.rb index 5b1463c..53a51f0 100644 --- a/bake/agent/context.rb +++ b/bake/agent/context.rb @@ -20,17 +20,17 @@ def initialize(context) # @parameter gem [String | Nil] An optional provider gem. def list(gem: nil) if gem - provider = @installer.find_gem_with_context(gem) - if provider + provider = @installer.find_provider(gem) + if provider && !provider.definitions.empty? puts "Context files for gem '#{gem}':" provider.definitions.each do |definition| - puts " #{definition.display_name(provider.path)}" + puts " #{definition.display_name}" end else puts "No context found for gem '#{gem}'" end else - gems = @installer.find_gems_with_context + gems = @installer.providers.reject{|provider| provider.definitions.empty?} if gems.any? puts "Gems with context available:" gems.each do |provider| diff --git a/context/usage.md b/context/usage.md index a029a7e..5b970d4 100644 --- a/context/usage.md +++ b/context/usage.md @@ -27,6 +27,8 @@ bundle exec bake agent:context:install --gem async Ordinary context is copied to `.agents/context/GEM/`. Repository instructions can direct agents to discover and read relevant guides there. +Source filenames take precedence over installed skill names. The `show` command returns authored content, including for skills; generated installation metadata is available in the installed `SKILL.md`. + ## Use Dependency Skills Skills contain procedures for particular tasks and are installed under `.agents/skills/`. Use the matching skill's instructions and resources when its description applies to the work. diff --git a/design.md b/design.md index 2c8ccfd..bf8b13f 100644 --- a/design.md +++ b/design.md @@ -10,7 +10,9 @@ Choose the simplest structure that makes the behavior, state, and responsibiliti | --- | --- | --- | | Resolved package specification | `Gem::Specification`, retained by `Package` | `Specification`, resolved by `Installer::discover` | | Parsed Markdown | `Document` | `Document` in `document.rs` | -| Loaded provider definitions | `Package#definitions` | `Package::definitions` | +| Loaded provider | `Package` wrapping `Gem::Specification` | `Package` wrapping `Specification` | +| Provider lookup | `Installer#providers`, `#find_provider` | `Installer::providers`, `find_provider` | +| Definition lookup | `Package#find_definition` | `Package::find_definition` | | Ordinary file | `Definition` | `FileDefinition` | | All definition variants | `Definition` and `Skills::Definition < Definition` | `Definition::File(FileDefinition)` or `Definition::Skill(SkillDefinition)` | | Installation workflow | `Installer#install` | `Installer::install` | @@ -23,17 +25,17 @@ Each parser, metadata rule, and file format has one owner. Document parsing inte Ruby's loaded `Package` retains its `Gem::Specification` and definitions. Rust's resolved `Specification` carries Cargo metadata and the context path; loaded `Package` wraps that specification with its definitions. Both apply the same document and installation rules. -Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. Rust exposes the resolved values as `Installer::specifications()` and `Installer::find_specification()`; “provider” remains the domain role of a package that supplies context. +Ruby uses the gem name as its package selector. Rust uses the crate name when unique and `crate@version` when multiple versions provide context. These selectors identify providers in commands and installed context directories. “Provider” is the domain role of a package that supplies context. Both installers expose `providers` and `find_provider`, returning loaded packages. A provider with an existing empty `context/` directory has an empty definition collection; a dependency without that directory is absent from provider lookup. Listing commands show providers with available definitions. Rust additionally exposes resolved Cargo metadata through `specifications` and `find_specification`. ## Documents and Front Matter `Document.load(path)` and `Document::load(path)` read the source and delegate to `parse`. The parsed value retains the source path, original Markdown, decoded YAML mapping, and the Markdown parser's front matter node or source boundary. -Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. The skill predicate matches `skill`; all other values follow the ordinary context path. Ordinary guides can carry their own top-level fields. Front matter uses YAML by default or with the explicit `yaml` language hint. Other front matter languages remain opaque. +Both implementations use their Markdown parser's front matter node to read and serialize front matter: Markly in Ruby and `socketry-markdown` in Rust. The document layer decodes and encodes YAML through the node's raw value and classifies context using the nested `metadata.type` field. The skill predicate matches `skill`; all other values follow the ordinary context path. Only a `metadata` mapping is interpreted as context metadata; other authored values remain intact in the source. Ordinary guides can carry their own top-level fields. Front matter uses YAML by default or with the explicit `yaml` language hint. Other front matter languages remain opaque. A skill definition validates its top-level name and description. It preserves an authored name and uses it for the installed directory. When the name is omitted, it derives `-`. Resource directories follow the source filename. Description validation trims whitespace to measure the value; the authored value is retained. -To generate installed instructions, the definition merges resolved `ecosystem`, `package`, and `version` into the nested metadata mapping and supplies a missing name. The document writes the YAML back to its front matter AST node and asks the Markdown library to serialize that node. It appends the original body using the parser's source boundary. Links, code blocks, spacing, Unicode, and body line endings retain their original bytes. +To generate installed instructions, the definition merges resolved `ecosystem`, `package`, and `version` into the nested metadata mapping and supplies a missing name. The document writes the YAML into a copy of its front matter AST node and asks the Markdown library to serialize that node. It appends the original body using the parser's source boundary. Links, code blocks, spacing, Unicode, and body line endings retain their original bytes. The portable YAML form uses `---` delimiters. Rust also recognizes the additional front matter forms supported by `socketry-markdown`, such as a leading YAML code fence, and retains their delimiter style. Markly's supported syntax defines the Ruby parsing boundary. @@ -43,11 +45,15 @@ For each provider, load Markdown documents before descending into subdirectories The loaded package stores ordinary definitions and skills in one collection. Ruby uses `Definition` for ordinary files and `Skills::Definition < Definition` for skills. Rust represents the same alternatives with a `Definition` enum: `File(FileDefinition)` carries the provider-relative destination path, source path, and optional parsed `Document`; `Skill(SkillDefinition)` carries skill-specific metadata and resources. Both implementations use `Document` for parsed Markdown. -Listing, showing, and installation use this classification. The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. +Listing, showing, and installation use this classification. Each definition's `path` is relative to the provider's `context/` directory; `source_path` locates the authored file. A skill's `name` identifies its installed directory. Definition lists are ordered by source path. + +`Package.find_definition(file)` checks the exact source path, then appends `.md` when no extension was given, and finally checks installed skill names. The lookup uses the loaded definitions. `content` and the `show` command return authored content for every definition. Skill definitions retain their parsed `Document` and resolved specification, and expose generated instructions as `installation_content`. + +The main installation operation resolves providers and loads each provider once, then applies the ordinary or skill destination rules to each definition. `Package.install_files(destination)` refreshes ordinary files in a package directory. A skill definition's `install(destination)` writes generated instructions and resources into its skill directory. Ruby's ordinary `Definition#write_to(path)` writes authored content to a file path, including when called on a skill definition. ## Installation Flow -The library owns orchestration. Bake tasks translate arguments, call the library, and present installed provider and skill names. +The library owns orchestration. Bake tasks translate arguments, call the library, and present installed provider and skill names. Installation results contain package selectors under `context` and plain installed skill names under `skills`; presentation formatting belongs in task adapters. 1. Resolve the selected providers and load their definitions. 2. Validate and install skills under `.agents/skills/`, reconcile stale entries within the selected scope, and refresh Git exclusions. diff --git a/lib/agent/context/definition.rb b/lib/agent/context/definition.rb index b828eba..fd2bd95 100644 --- a/lib/agent/context/definition.rb +++ b/lib/agent/context/definition.rb @@ -9,16 +9,22 @@ module Agent module Context # Represents one ordinary file provided in a package's context directory. class Definition - # Initialize a context definition from its source path and parsed document. - # @parameter path [String] The source file path. + # Initialize a context definition from its paths and parsed document. + # @parameter path [String] The path relative to the provider's context directory. + # @parameter source_path [String] The source file path. # @parameter document [Document | Nil] The parsed Markdown document, when applicable. - def initialize(path:, document: nil) + def initialize(path, source_path, document = nil) @path = path + @source_path = source_path @document = document end + # @attribute [String] The path relative to the provider's context directory. attr_reader :path + # @attribute [String] The source file path. + attr_reader :source_path + # Whether this definition installs as an Agent Skill. # @returns [bool] `false` for ordinary context definitions. def skill? @@ -28,14 +34,13 @@ def skill? # Read the definition's Markdown content or opaque file contents. # @returns [String] The source content. def content - @document ? @document.content : File.read(@path) + @document ? @document.content : File.read(@source_path) end # Return the relative name displayed by the context list. - # @parameter context_path [String] The provider's context directory. # @returns [String] The path relative to the provider context directory. - def display_name(context_path) - @path.delete_prefix("#{context_path}/") + def display_name + @path end # Write the definition to its installation destination. @@ -45,7 +50,7 @@ def write_to(destination) if @document @document.write(destination) else - FileUtils.copy_file(@path, destination, true) + FileUtils.copy_file(@source_path, destination, true) end end end diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb index 3bb0b98..5ef68bb 100644 --- a/lib/agent/context/document.rb +++ b/lib/agent/context/document.rb @@ -31,17 +31,21 @@ def self.parse(content, path) raise InvalidError, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) end - new(path, content, node, frontmatter) + # Interpret only mappings as context metadata, retaining other authored values: + metadata = frontmatter["metadata"] + metadata = {} unless metadata.is_a?(Hash) + new(path, content, node, frontmatter, metadata) rescue Psych::Exception => error raise InvalidError, "Invalid YAML frontmatter in #{path}: #{error.message}" end # Retain the source and its parsed representation. - def initialize(path, content, node, frontmatter) + def initialize(path, content, node, frontmatter, metadata) @path = path @content = content @node = node @frontmatter = frontmatter + @metadata = metadata end # @attribute [String] The document source path. @@ -54,9 +58,7 @@ def initialize(path, content, node, frontmatter) attr_reader :frontmatter # The extensible metadata mapping within front matter. - def metadata - @frontmatter["metadata"] || {} - end + attr_reader :metadata # Whether the document declares a skill. def skill? @@ -72,8 +74,9 @@ def body # Serialize edited front matter through its AST node, preserving the body. def render(frontmatter) - @node.string_content = YAML.dump(frontmatter).delete_prefix("---\n") - @node.to_markdown + body + node = @node.dup + node.string_content = YAML.dump(frontmatter).delete_prefix("---\n") + node.to_markdown + body end # Write the loaded source to its destination. diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index 3696bab..044d3ee 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -5,7 +5,6 @@ # Copyright, 2025-2026, by Samuel Williams. require "rubygems" -require "pathname" require "fileutils" require_relative "paths" @@ -29,45 +28,26 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) attr_reader :root attr_reader :context_path - # Find providers with ordinary context or skills. - def find_gems_with_context(skip_local: true) - packages(skip_local: skip_local).reject{|package| package.definitions.empty?} + # Load providers with a context directory, including empty providers. + def providers(skip_local: true) + packages(skip_local: skip_local) end - # Find one provider with context or skills. - def find_gem_with_context(gem_name) - package = packages(gem: gem_name).first - package if package && !package.definitions.empty? + # Load one provider by its gem name. + def find_provider(name) + packages(gem: name).first end - # List every definition from a provider, including skill documents. - def list_context_definitions(gem_name) - package = packages(gem: gem_name).first - package.definitions if package && !package.definitions.empty? - end - - # Read a selected document, opaque resource, or generated skill document. + # Read the authored content of a selected definition. def show_context_file(gem_name, file_name) - requested = Pathname.new(file_name) - if requested.absolute? || requested.each_filename.any?{|part| part == ".."} - raise ArgumentError, "Context file must be a relative path inside context/" - end - - package = packages(gem: gem_name).first - return unless package - - candidates = [File.join(package.path, file_name)] - candidates << "#{candidates.first}.md" if File.extname(file_name).empty? - definition = package.definitions.find{|item| candidates.include?(item.path)} - definition ||= package.definitions.find{|item| item.skill? && item.name == file_name} - definition&.content + find_provider(gem_name)&.find_definition(file_name)&.content end # Install all selected definitions and reconcile dependency-owned skills. def install(gem: nil) # Resolve providers and load every definition once: selected = packages(gem: gem) - raise Error, "No gem found: #{gem}" if gem && selected.empty? + raise Error, "No context found for gem: #{gem}" if gem && selected.empty? definitions = selected.flat_map(&:definitions) skills = definitions.grep(Skills::Definition) @@ -76,7 +56,7 @@ def install(gem: nil) # Refresh ordinary files from the same loaded definitions: installed_context = selected.filter_map do |package| - package.name if package.install(File.join(@context_path, package.name)) + package.name if package.install_files(File.join(@context_path, package.name)) end {context: installed_context, skills: installed_skills} @@ -92,7 +72,7 @@ def packages(gem: nil, skip_local: true) raise ConflictError, "Multiple resolved versions provide gem #{gem.inspect}" end - specifications.map{|specification| Package.load(specification)} + specifications.filter_map{|specification| Package.load(specification)} end def install_skills(definitions, package:) @@ -115,7 +95,7 @@ def install_skills(definitions, package:) definitions.each do |definition| destination = File.join(skills_path, definition.name) FileUtils.rm_rf(destination) - definition.write_to(destination) + definition.install(destination) end stale.each{|name| FileUtils.rm_rf(File.join(skills_path, name))} @@ -129,7 +109,7 @@ def validate_skill_destinations(definitions, skills_path, owners) owner = owners[definition.name] destination = File.join(skills_path, definition.name) - if owner && owner.values_at("ecosystem", "package") != ["gem", definition.provider_name] + if owner && owner.values_at("ecosystem", "package") != ["gem", definition.specification.name] raise ConflictError, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" end diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb index 0af9b77..58aa8e3 100644 --- a/lib/agent/context/package.rb +++ b/lib/agent/context/package.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require "fileutils" +require "pathname" require_relative "definition" require_relative "document" require_relative "skills/definition" @@ -16,8 +17,8 @@ class Package # Load and classify a gem's context, keeping skill resources opaque. def self.load(specification) package = new(specification, []) - root = package.path - return package unless File.exist?(root) || File.symlink?(root) + root = package.context_path + return unless File.exist?(root) || File.symlink?(root) unless File.lstat(root).directory? raise InvalidError, "Context provider must be a regular directory: #{root}" @@ -50,7 +51,7 @@ def version end # @returns [String] The provider context directory. - def path + def context_path File.join(@specification.full_gem_path, "context") end @@ -66,10 +67,10 @@ def load_directory(directory) elsif stat.file? document = File.extname(file).downcase == ".md" ? Document.load(file) : nil if document&.skill? - raise InvalidError, "Skill documents must be directly inside context/: #{file}" unless directory == path + raise InvalidError, "Skill documents must be directly inside context/: #{file}" unless directory == context_path @definitions << Skills::Definition.from_document(document, @specification) else - @definitions << Definition.new(path: file, document: document) + @definitions << Definition.new(file.delete_prefix("#{context_path}/"), file, document) end end end @@ -79,19 +80,33 @@ def load_directory(directory) directories.each do |directory| load_directory(directory) unless resources.include?(directory) end - @definitions.sort_by! do |definition| - [definition.skill? ? 1 : 0, definition.path] + @definitions.sort_by!(&:path) + end + + # Find an authored source path, optionally omitting .md, then an installed skill name. + def find_definition(file) + requested = Pathname.new(file) + if file.empty? || requested.absolute? || requested.each_filename.any?{|part| part == ".." || part == "."} + raise InvalidError, "Context file must be a relative path inside context/" + end + + candidates = [file] + candidates << "#{file}.md" if File.extname(file).empty? + candidates.each do |path| + definition = @definitions.find{|item| item.path == path} + return definition if definition end + @definitions.find{|item| item.skill? && item.name == file} end # Refresh ordinary context from the loaded documents and opaque resources. - def install(destination) + def install_files(destination) files = @definitions.reject(&:skill?) FileUtils.rm_rf(destination) return false if files.empty? files.each do |definition| - target = File.join(destination, definition.path.delete_prefix("#{path}/")) + target = File.join(destination, definition.path) definition.write_to(target) end true diff --git a/lib/agent/context/skills/definition.rb b/lib/agent/context/skills/definition.rb index bd2434d..152a8d3 100644 --- a/lib/agent/context/skills/definition.rb +++ b/lib/agent/context/skills/definition.rb @@ -40,17 +40,9 @@ def self.from_document(source, specification) assets = nil end - document = source.render(frontmatter) + installation_content = source.render(frontmatter) - new( - name: installed_name, - description: frontmatter["description"], - resources: assets, - source_file: file, - document: document, - provider_name: specification.name, - provider_version: specification.version.to_s, - ) + new(source, specification, installed_name, frontmatter["description"], assets, installation_content) end # Validate an installed directory name. @@ -75,29 +67,19 @@ def self.validate_metadata(frontmatter, file) end end - # Initialize a skill from a context document. - # @parameter name [String] The installed skill name. - # @parameter description [String] When to use the skill. - # @parameter resources [String | Nil] The companion resource directory. - # @parameter provider_name [String] The provider gem. - # @parameter provider_version [String] The provider version. - # @parameter source_file [String] The source context document. - # @parameter document [String] Generated skill instructions. - def initialize( - name:, description:, resources:, - provider_name:, provider_version:, - source_file:, document: - ) - super(path: source_file) + # Retain the source document, specification, and prepared skill instructions. + def initialize(document, specification, name, description, resources, installation_content) + super(File.basename(document.path), document.path, document) + @specification = specification @name = name @description = description @resources = resources - @provider_name = provider_name - @provider_version = provider_version - @source_file = source_file - @content = document + @installation_content = installation_content end + # @attribute [Gem::Specification] The resolved package specification. + attr_reader :specification + # @attribute [String] The installed skill name. attr_reader :name @@ -107,19 +89,8 @@ def initialize( # @attribute [String | Nil] The companion resource directory. attr_reader :resources - # @attribute [String] The provider gem. - attr_reader :provider_name - - # @attribute [String] The provider version. - attr_reader :provider_version - - # @attribute [String] The source context document. - attr_reader :source_file - - # @returns [String] The installed skill instructions. - def content - @content - end + # @attribute [String] The generated instructions with resolved provenance. + attr_reader :installation_content # Whether this definition installs as an Agent Skill. # @returns [bool] `true` for skill definitions. @@ -128,18 +99,17 @@ def skill? end # Return the filename shown by the context list with its skill marker. - # @parameter _context_path [String] The provider's context directory. # @returns [String] The source filename and skill marker. - def display_name(_context_path) - "#{File.basename(@source_file, File.extname(@source_file))} (skill)" + def display_name + "#{File.basename(@path, File.extname(@path))} (skill)" end # Write instructions before resources so ownership is available during refresh. # @parameter destination [String] The installed skill directory. - def write_to(destination) + def install(destination) FileUtils.mkdir_p(destination) begin - File.write(File.join(destination, "SKILL.md"), @content) + File.write(File.join(destination, "SKILL.md"), @installation_content) rescue FileUtils.rm_rf(destination) raise diff --git a/releases.md b/releases.md index 3be9915..74c4741 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,8 @@ ## Unreleased +- Resolve context source filenames before installed skill names and show authored content consistently. + - Install unfamiliar context types as ordinary guidance so providers can introduce new types. - Store resolved skill provenance in `SKILL.md` front matter under `metadata`, with `metadata.type: skill` declaring skills. - Preserve authored names, descriptions, additional metadata, and the original Markdown body using native front matter AST nodes. diff --git a/specification.md b/specification.md index 947c039..a34a0e0 100644 --- a/specification.md +++ b/specification.md @@ -48,6 +48,10 @@ A **Context Consumer** is any project or tool that utilizes contextual informati - Security considerations. - Troubleshooting guides. +A **Definition** is one installable unit loaded from a provider: an ordinary file, or a skill document together with its companion resources. A parsed **Document** represents the Markdown source and front matter of a definition. Files such as images and examples remain opaque. + +A definition's **path** is its filename relative to the provider's `context/` directory, including any subdirectories and extension. Its **source path** locates the authored file in the installed package. A skill's **installed name** is its authored or derived `name`, used for its destination directory. These identifiers serve different purposes. + ### 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. @@ -161,7 +165,7 @@ metadata: --- ``` -Within a context document's YAML `metadata` mapping, the string `skill` declares a skill. Consumers MUST treat all other `metadata.type` values, including absent, null, and unfamiliar values, as ordinary context. This allows providers to introduce new types while existing consumers continue installing their documents. +Within a context document's YAML `metadata` mapping, the string `skill` declares a skill. Consumers MUST treat all other `metadata.type` values, including absent, null, and unfamiliar values, as ordinary context. This allows providers to introduce new types while existing consumers continue installing their documents. Consumers MUST interpret only mappings as context metadata; other values of `metadata` are unrelated authored data and MUST be retained as ordinary context. An authored top-level `name` MUST be preserved and used as the installed directory name. When `name` is absent, consumers MUST derive it from the lowercase provider package name (normalizing underscores to hyphens), a hyphen, and the source filename without its `.md` extension. 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 for validation; consumers MUST retain the authored description value. @@ -214,6 +218,10 @@ Consumers SHOULD provide commands to list providers, list their definitions, and Providers SHOULD use descriptive filenames, keep guides focused on one topic, and link related documents. Ordinary documents need no front matter. Unrelated ordinary-document metadata MUST be tolerated. Skill resource files are opaque assets and MUST NOT be parsed as context documents. +Provider lookup identifies dependencies with a `context/` directory, including empty directories. An empty provider has no definitions; listing commands MAY omit it from the available guidance list. Refreshing an empty provider still reconciles its installed output. + +Showing a definition MUST return its authored content. Lookup MUST prefer an exact provider-relative path, then that path with `.md` appended when no extension was supplied. Consumers MAY also accept installed skill names as a fallback after source path lookup. Definition paths retain their filename case and extension. Generated skill instructions add installation provenance when installed. + The installed directory layout is the discovery interface. ## 7. Skill Installation Ownership diff --git a/test/agent/context/document.rb b/test/agent/context/document.rb index b667f95..98071a1 100644 --- a/test/agent/context/document.rb +++ b/test/agent/context/document.rb @@ -67,6 +67,7 @@ def around source = subject.parse(fixture.gsub("\n", ending), path) output = source.render(source.frontmatter.merge("license" => "Apache-2.0")) installed = subject.parse(output, path) + expect(source.content).to be == fixture.gsub("\n", ending) expect(installed.body).to be == source.body expect(installed.frontmatter["license"]).to be == "Apache-2.0" expect(installed.metadata["author"]).to be == "Provider" diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index a10bbb3..6a332b8 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -39,34 +39,34 @@ def around it "can find gems with context" do helper = subject.new(specifications: @specifications) - gems = helper.find_gems_with_context + gems = helper.providers expect(gems.length).to be == 1 expect(gems.first.name).to be == "fake-gem" expect(gems.first.version).to be == "1.0.0" - expect(gems.first.path).to be == context_path + expect(gems.first.context_path).to be == context_path expect(gems.first.definitions.length).to be == 2 end it "can find a specific gem with context" do helper = subject.new(specifications: @specifications) - package = helper.find_gem_with_context("fake-gem") + package = helper.find_provider("fake-gem") expect(package).to be_truthy expect(package.name).to be == "fake-gem" - expect(package.path).to be == context_path + expect(package.context_path).to be == context_path expect(package.definitions.length).to be == 2 end it "returns nil for gems without context" do helper = subject.new(specifications: @specifications) - package = helper.find_gem_with_context("non-existent-gem") + package = helper.find_provider("non-existent-gem") expect(package).to be_nil end it "can list context definitions for a gem" do helper = subject.new(specifications: @specifications) - definitions = helper.list_context_definitions("fake-gem") + definitions = helper.find_provider("fake-gem").definitions expect(definitions.map{|definition| File.basename(definition.path)}).to be == ["configuration.md", "getting-started.md"] end @@ -182,13 +182,13 @@ def around it "does not find gems without context" do helper = subject.new(specifications: @specifications) - gems = helper.find_gems_with_context + gems = helper.providers expect(gems.length).to be == 0 end it "returns nil for gems without context" do helper = subject.new(specifications: @specifications) - gem_info = helper.find_gem_with_context("no-context-gem") + gem_info = helper.find_provider("no-context-gem") expect(gem_info).to be_nil end end diff --git a/test/agent/context/skills.rb b/test/agent/context/skills.rb index 4d106f4..c08e7e7 100644 --- a/test/agent/context/skills.rb +++ b/test/agent/context/skills.rb @@ -39,7 +39,7 @@ def around end installer = subject.new(root: consumer, specifications: specifications) expect(enumerations).to be == 0 - expect(installer.find_gems_with_context.map(&:name)).to be == ["provider"] + expect(installer.providers.map(&:name)).to be == ["provider"] installer.install expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == File.read(File.join(provider, "context/guide.md")) end @@ -76,13 +76,13 @@ def around end it "lists and shows skills alongside ordinary context" do - definitions = installer.list_context_definitions("provider") - expect(definitions.map{|definition| definition.display_name(File.join(provider, "context"))}).to be == ["guide.md", "workflow (skill)"] - expect(installer.show_context_file("provider", "workflow")).to be(:include?, "name: provider-workflow") + definitions = installer.find_provider("provider").definitions + expect(definitions.map{|definition| definition.display_name}).to be == ["guide.md", "workflow (skill)"] + expect(installer.show_context_file("provider", "workflow")).to be == File.read(File.join(provider, "context/workflow.md")) expect(installer.show_context_file("provider", "workflow/references/checklist.md")).to be_nil expect do installer.show_context_file("provider", "../secret") - end.to raise_exception(ArgumentError) + end.to raise_exception(Agent::Context::InvalidError) end it "validates front matter when classifying context documents" do @@ -145,4 +145,52 @@ def around expect(File).not.to be(:exist?, File.join(consumer, ".agents")) end + it "resolves source paths before installed skill names and retains authored content" do + context = File.join(provider, "context") + source = "---\nname: guide\ndescription: Run workflow.\nmetadata: {type: skill}\n---\n# Workflow\n" + File.write(File.join(context, "workflow.md"), source) + package = installer.find_provider("provider") + definition = package.find_definition("workflow") + expect(definition.path).to be == "workflow.md" + expect(definition.source_path).to be == File.join(context, "workflow.md") + expect(definition.specification).to be_equal(specification) + expect(definition.content).to be == source + expect(definition.installation_content).to be(:include?, "ecosystem: gem") + expect(installer.show_context_file("provider", "guide")).to be == File.read(File.join(context, "guide.md")) + expect(installer.show_context_file("provider", "guide.md")).to be == File.read(File.join(context, "guide.md")) + + File.delete(File.join(context, "guide.md")) + expect(package.find_definition("guide").content).to be == "# Guide\n\nUse the provider." + expect(installer.show_context_file("provider", "guide")).to be == source + File.write(File.join(context, "guide"), "exact source") + File.write(File.join(context, "guide.md"), "markdown source") + expect(installer.show_context_file("provider", "guide")).to be == "exact source" + + File.rename(File.join(context, "workflow.md"), File.join(context, "workflow.MD")) + expect(installer.show_context_file("provider", "workflow.MD")).to be == source + ["", "/private.md", "./guide.md", "nested/../guide.md"].each do |path| + expect{package.find_definition(path)}.to raise_exception(Agent::Context::InvalidError) + end + end + + it "retains empty providers and refreshes their installed skills" do + installer.install + context = File.join(provider, "context") + FileUtils.rm_r(Dir.children(context).map{|name| File.join(context, name)}) + expect(installer.find_provider("provider").definitions).to be == [] + expect(installer.providers.map(&:name)).to be == ["provider"] + expect(installer.install(gem: "provider")).to be == {context: [], skills: []} + expect(Agent::Context::Skills::Ownership.scan(File.join(consumer, ".agents/skills"))).to be == {} + end + + it "preserves unrelated metadata when installing ordinary definitions" do + [[], 42, "unrelated"].each do |metadata| + source = {"metadata" => metadata}.to_yaml + "---\n# Guide\n" + File.write(File.join(provider, "context/guide.md"), source) + expect(installer.show_context_file("provider", "guide")).to be == source + installer.install + expect(File.read(File.join(consumer, ".agents/context/provider/guide.md"))).to be == source + end + end + end diff --git a/test/agent/context/skills/installer.rb b/test/agent/context/skills/installer.rb index ff8bda2..f6956b7 100644 --- a/test/agent/context/skills/installer.rb +++ b/test/agent/context/skills/installer.rb @@ -43,11 +43,11 @@ def write_owner(directory, package, version, ecosystem: "gem", body: "Instructio end def skill_definitions(gem_name) - Array(installer.list_context_definitions(gem_name)).grep(Agent::Context::Skills::Definition) + Array(installer.find_provider(gem_name)&.definitions).grep(Agent::Context::Skills::Definition) end it "finds gems which provide skills" do - gems = installer.find_gems_with_context + gems = installer.providers expect(gems.length).to be == 1 expect(gems.first.name).to be == "fake-gem" @@ -67,12 +67,12 @@ def skill_definitions(gem_name) it "shows a skill instruction file" do content = installer.show_context_file("fake-gem", "fake-gem-ruby-testing") - expect(content).to be(:include?, "name: fake-gem-ruby-testing") + expect(content).to be == File.read(File.join(provider_root, "context/ruby-testing.md")) expect(content).to be(:include?, "# Instructions") end it "returns nil for a gem without skills" do - expect(installer.find_gem_with_context("missing-gem")).to be_nil + expect(installer.find_provider("missing-gem")).to be_nil expect do installer.install(gem: "missing-gem") end.to raise_exception(Agent::Context::Error) @@ -84,7 +84,7 @@ def skill_definitions(gem_name) expect(skill_definitions("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] File.delete(File.join(provider_root, "context/ruby-testing.md")) - expect(installer.find_gems_with_context).to be == [] + expect(installer.providers.first.definitions).to be == [] end it "skips a gem loaded from the consuming project by default" do @@ -92,8 +92,8 @@ def skill_definitions(gem_name) local_specification = build_specification("local-gem", "1.0.0", consumer_root) local_installer = subject.new(root: consumer_root, specifications: [local_specification]) - expect(local_installer.find_gems_with_context).to be == [] - expect(local_installer.find_gems_with_context(skip_local: false).map(&:name)).to be == ["local-gem"] + expect(local_installer.providers).to be == [] + expect(local_installer.providers(skip_local: false).map(&:name)).to be == ["local-gem"] end with "installation" do @@ -196,7 +196,7 @@ def skill_definitions(gem_name) it "lists unmarked documents as ordinary context" do File.write(File.join(provider_root, "context/ruby-testing.md"), "# Plain guidance\n") expect(skill_definitions("fake-gem")).to be == [] - expect(installer.find_gems_with_context.map(&:name)).to be == ["fake-gem"] + expect(installer.providers.map(&:name)).to be == ["fake-gem"] end it "installs skills alongside unfamiliar context types" do @@ -245,7 +245,7 @@ def skill_definitions(gem_name) it "removes a provider's last skill and cleans disappeared gem providers" do installer.install FileUtils.rm_rf(File.join(provider_root, "context")) - expect(installer.install(gem: "fake-gem")[:skills]).to be == [] + expect(installer.install[:skills]).to be == [] expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) write_skill(provider_root, "ruby-testing", description: "Test projects.") installer.install @@ -426,7 +426,7 @@ def skill_definitions(gem_name) package = Agent::Context::Package.load(specifications.first) File.write(File.join(context, "a-guide.md"), "# Changed\n") destination = File.join(consumer_root, ".agents/context/fake-gem") - package.install(destination) + package.install_files(destination) expect(File.read(File.join(destination, "a-guide.md"))).to be == "# Original\n" expect(File.binread(File.join(destination, "z-image.bin"))).to be == "\x00\xff".b end diff --git a/test/agent/context/tasks.rb b/test/agent/context/tasks.rb index 3f65b5a..f37c692 100644 --- a/test/agent/context/tasks.rb +++ b/test/agent/context/tasks.rb @@ -41,7 +41,7 @@ context.call("agent:context:install", "--gem", "provider") expect(output.join("\n")).to be(:include?, "Provider Guide") expect(output.join("\n")).to be(:include?, "workflow (skill)") - expect(output.join("\n")).to be(:include?, "name: provider-workflow") + expect(output.join("\n")).to be(:include?, "# Workflow") expect(output.join("\n")).to be(:include?, "Installed skills: provider-workflow") expect(File).to be(:exist?, File.join(root, ".agents/context/provider/guide.md")) expect(File).to be(:exist?, File.join(root, ".agents/skills/provider-workflow/SKILL.md"))