diff --git a/.cursor/rules/agent.mdc b/.cursor/rules/agent.mdc deleted file mode 100644 index 9e19237..0000000 --- a/.cursor/rules/agent.mdc +++ /dev/null @@ -1,4 +0,0 @@ ---- -alwaysApply: true ---- -Read the `agent.md` file in the project root directory for detailed context relating to this project and external dependencies. 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..3950bed 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,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "bake", ">= 0.23" + spec.add_dependency "markly", "~> 0.16" end diff --git a/bake/agent/context.rb b/bake/agent/context.rb index 6586085..53a51f0 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 @@ -17,26 +16,25 @@ 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_provider(gem) + if provider && !provider.definitions.empty? 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}" 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 |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" @@ -44,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 @@ -56,33 +54,10 @@ 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. +# @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 -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) + 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? end diff --git a/context/getting-started.md b/context/getting-started.md index 29e3bed..36eb0ca 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -1,6 +1,6 @@ # 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. +This guide explains how to install package guidance and skills using `agent-context`. ## Overview @@ -37,6 +37,11 @@ bake agent:context:list --gem async # View a specific context file bake agent:context:show --gem async --file thread-safety + +# 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/ @@ -49,9 +54,13 @@ bake agent:context:show --gem async --file thread-safety When you run `bake agent:context:install`, the tool: -1. Scans all installed gems for `context/` directories (in the gem's root). +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 context files organized by gem name. +3. Copies ordinary context files organized by gem name. +4. Installs metadata-declared skills and their resources into `.agents/skills/`. +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: ``` @@ -89,8 +98,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. -- Ignore the generated `.agents/` directory in version control. -- 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) @@ -142,6 +151,8 @@ Available context files: #### 4. File format and content guidelines +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: - **Practical** - Include real examples and working code. @@ -156,6 +167,30 @@ Context files can be in any format, but `.md` is commonly used for documentation - 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/`: + +```markdown +--- +metadata: + type: skill +description: Follow the workflow when setting up this package. +--- + +# Workflow + +Follow the setup instructions. +``` + +`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 + +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 For examples of well-structured context files, see the existing files in this directory: diff --git a/context/index.yaml b/context/index.yaml deleted file mode 100644 index 4d2aa8e..0000000 --- a/context/index.yaml +++ /dev/null @@ -1,13 +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 from Ruby gems. -metadata: - 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. diff --git a/context/usage.md b/context/usage.md new file mode 100644 index 0000000..5b970d4 --- /dev/null +++ b/context/usage.md @@ -0,0 +1,73 @@ +--- +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. +--- + +# 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 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 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: + +```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; 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/`. 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. + +```bash +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 +``` + +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 + +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 and exact dependency-installed skill directories. Track project-owned skills under `.agents/skills/` and remove blanket `/.agents/` ignore rules when adopting this layout. + +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 + +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: + +```markdown +--- +metadata: + type: skill +description: Set up a project using this gem's conventions. +--- + +# Workflow + +Follow the setup instructions. +``` + +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. Descriptions must contain 1–1,024 characters and explain the task that should activate the skill. diff --git a/design.md b/design.md new file mode 100644 index 0000000..bf8b13f --- /dev/null +++ b/design.md @@ -0,0 +1,78 @@ +# Agent Context Design + +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. + +Choose the simplest structure that makes the behavior, state, and responsibilities easy to follow. + +## Responsibilities + +| Concern | Ruby | Rust | +| --- | --- | --- | +| Resolved package specification | `Gem::Specification`, retained by `Package` | `Specification`, resolved by `Installer::discover` | +| Parsed Markdown | `Document` | `Document` in `document.rs` | +| 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` | +| 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. + +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. “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. 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 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. + +## Discovery and Loading + +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(SkillDefinition)` carries skill-specific metadata and resources. Both implementations use `Document` for parsed Markdown. + +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. 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. +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. + +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. + +## Ownership and Git Exclusions + +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. 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. + +## Readability and Verification + +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. + +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/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/fixtures/agent/context/test_document/SKILL.md b/fixtures/agent/context/test_document/SKILL.md new file mode 100644 index 0000000..65c8122 --- /dev/null +++ b/fixtures/agent/context/test_document/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/guides/getting-started/readme.md b/guides/getting-started/readme.md index 29e3bed..1661dca 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -1,6 +1,6 @@ # 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. +This guide explains how to install package guidance and skills using `agent-context`. ## Overview @@ -37,6 +37,11 @@ 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 ``` ## Understanding context/ vs .agents/context/ @@ -49,9 +54,13 @@ bake agent:context:show --gem async --file thread-safety When you run `bake agent:context:install`, the tool: -1. Scans all installed gems for `context/` directories (in the gem's root). +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 context files organized by gem name. +3. Copies ordinary context files organized by gem name. +4. Installs metadata-declared skills and their resources into `.agents/skills/`. +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: ``` @@ -89,8 +98,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. -- Ignore the generated `.agents/` directory in version control. -- 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) @@ -142,6 +151,8 @@ Available context files: #### 4. File format and content guidelines +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: - **Practical** - Include real examples and working code. @@ -156,6 +167,30 @@ Context files can be in any format, but `.md` is commonly used for documentation - 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/`: + +```markdown +--- +metadata: + type: skill +description: Follow the workflow when setting up this package. +--- + +# Workflow + +Follow the setup instructions. +``` + +`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 + +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 For examples of well-structured context files, see the existing files in this directory: diff --git a/lib/agent/context.rb b/lib/agent/context.rb index 873070e..36e4c61 100644 --- a/lib/agent/context.rb +++ b/lib/agent/context.rb @@ -6,8 +6,8 @@ require_relative "context/version" 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/definition.rb b/lib/agent/context/definition.rb new file mode 100644 index 0000000..fd2bd95 --- /dev/null +++ b/lib/agent/context/definition.rb @@ -0,0 +1,58 @@ +# 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 + # 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, 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? + false + end + + # Read the definition's Markdown content or opaque file contents. + # @returns [String] The source content. + def content + @document ? @document.content : File.read(@source_path) + end + + # Return the relative name displayed by the context list. + # @returns [String] The path relative to the provider context directory. + def display_name + @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 + @document.write(destination) + else + FileUtils.copy_file(@source_path, destination, true) + end + end + end + end +end diff --git a/lib/agent/context/document.rb b/lib/agent/context/document.rb new file mode 100644 index 0000000..5ef68bb --- /dev/null +++ b/lib/agent/context/document.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "markly" +require "yaml" +require "date" +require_relative "errors" + +module Agent + module Context + # Represents a Markdown source and its parsed front matter. + class Document + # Read and parse a Markdown document. + def self.load(path) + parse(File.read(path), path) + end + + # Parse YAML front matter using Markdown's native syntax tree. + 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 + language = node&.fence_info&.split&.first + + # Interpret YAML metadata, keeping other formats opaque: + frontmatter = {} + if node && [nil, "yaml"].include?(language) + frontmatter = YAML.safe_load(node.string_content, permitted_classes: [Date, Time], aliases: false, fallback: {}) + raise InvalidError, "Context frontmatter must be a mapping: #{path}" unless frontmatter.is_a?(Hash) + end + + # 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, metadata) + @path = path + @content = content + @node = node + @frontmatter = frontmatter + @metadata = metadata + end + + # @attribute [String] The document source path. + attr_reader :path + + # @attribute [String] The original Markdown source. + attr_reader :content + + # @attribute [Hash] The decoded YAML front matter. + attr_reader :frontmatter + + # The extensible metadata mapping within front matter. + attr_reader :metadata + + # Whether the document declares a skill. + def skill? + metadata["type"] == "skill" + end + + # The original body following the parser's front matter boundary. + def body + return @content unless @node + + @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 = @node.dup + 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 +end diff --git a/lib/agent/context/errors.rb b/lib/agent/context/errors.rb new file mode 100644 index 0000000..b8b056e --- /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 new file mode 100644 index 0000000..6d2e6c0 --- /dev/null +++ b/lib/agent/context/exclusion.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "open3" +require "fileutils" +require_relative "errors" + +module Agent + module Context + # 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 InvalidError, "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/index.rb b/lib/agent/context/index.rb deleted file mode 100644 index 16ae55f..0000000 --- a/lib/agent/context/index.rb +++ /dev/null @@ -1,332 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2025, by Samuel Williams. -# Copyright, 2025, by Shopify Inc. - -require_relative "version" -require_relative "paths" -require "fileutils" -require "pathname" -require "yaml" - -# @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. - 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) - 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 - 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 - 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 - 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 - end - - index += 1 - end - - index - 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 - - # 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" => [] - } - 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 - if description.length > 197 - description = description[0..196] + "..." - end - - description - end - end - end -end diff --git a/lib/agent/context/installer.rb b/lib/agent/context/installer.rb index f03654a..044d3ee 100644 --- a/lib/agent/context/installer.rb +++ b/lib/agent/context/installer.rb @@ -2,36 +2,23 @@ # 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 "yaml" require_relative "paths" +require_relative "errors" +require_relative "skills/ownership" +require_relative "skills/definition" +require_relative "exclusion" +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. + # Discovers, lists, reads, and installs context from resolved Ruby gems. 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). - # @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) @@ -41,219 +28,95 @@ def initialize(root: Dir.pwd, specifications: ::Gem::Specification) attr_reader :root attr_reader :context_path - # Find all gems that have a context directory - 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 + # Load providers with a context directory, including empty providers. + def providers(skip_local: true) + packages(skip_local: skip_local) end - # Find a specific gem with 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 + # Load one provider by its gem name. + def find_provider(name) + packages(gem: name).first end - # List context files for a gem. - 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)} - end - - # Show content of a specific context file. + # Read the authored content of a selected definition. 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) - end - - # Install context from a specific gem. - def install_gem_context(gem_name) - gem = find_gem_with_context(gem_name) - return false unless gem - - 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) - - true + find_provider(gem_name)&.find_definition(file_name)&.content end - # Install context from all gems. - 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 + # 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 context found for gem: #{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 ordinary files from the same loaded definitions: + installed_context = selected.filter_map do |package| + package.name if package.install_files(File.join(@context_path, package.name)) end - installed + {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] + 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 - - 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 - } + if gem && specifications.length > 1 + raise ConflictError, "Multiple resolved versions provide gem #{gem.inspect}" end - { - "description" => gem[:summary] || "Context files for #{gem[:name]}", - "metadata" => gem[:metadata], - "files" => files - } + specifications.filter_map{|specification| Package.load(specification)} 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) - Console.debug("Generated dynamic index for #{gem[:name]}: #{index_path}") + 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 ConflictError, "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 - - # Load and return the index - YAML.load_file(index_path) - rescue => error - Console.debug("Error generating index for #{gem[:name]}: #{error.message}") - # Return a fallback index - { - "description" => gem[:summary] || "Context files for #{gem[:name]}", - "metadata" => gem[:metadata], - "files" => [] - } - 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" + 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.install(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 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?("#") + def validate_skill_destinations(definitions, skills_path, owners) + definitions.each do |definition| + owner = owners[definition.name] + destination = File.join(skills_path, definition.name) - # Skip empty lines until we find content - if !content_start && line.empty? - next + if owner && owner.values_at("ecosystem", "package") != ["gem", definition.specification.name] + raise ConflictError, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}" 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 + if (File.exist?(destination) || File.symlink?(destination)) && !owner + raise ConflictError, "Skill #{definition.name.inspect} is not managed by Agent Context" end - - description_lines << line end - - # Join the lines and truncate if too long - description = description_lines.join(" ").strip - - description end end end diff --git a/lib/agent/context/package.rb b/lib/agent/context/package.rb new file mode 100644 index 0000000..58aa8e3 --- /dev/null +++ b/lib/agent/context/package.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" +require "pathname" +require_relative "definition" +require_relative "document" +require_relative "skills/definition" +require_relative "errors" + +module Agent + module Context + # 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, []) + 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}" + end + + package.load_directory(root) + package + end + + # Retain the provider and its loaded definitions. + def initialize(specification, definitions) + @specification = specification + @definitions = definitions + end + + # @attribute [Gem::Specification] The resolved provider. + attr_reader :specification + + # @attribute [Array(Definition)] All loaded context definitions. + attr_reader :definitions + + # @returns [String] The provider gem name. + 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 context_path + File.join(@specification.full_gem_path, "context") + 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 InvalidError, "Skill documents must be directly inside context/: #{file}" unless directory == context_path + @definitions << Skills::Definition.from_document(document, @specification) + else + @definitions << Definition.new(file.delete_prefix("#{context_path}/"), file, document) + end + end + end + + # Companion resources belong to the skill and are copied as opaque files: + resources = @definitions.grep(Skills::Definition).map(&:resources) + directories.each do |directory| + load_directory(directory) unless resources.include?(directory) + end + @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_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) + definition.write_to(target) + end + true + end + end + end +end diff --git a/lib/agent/context/skills.rb b/lib/agent/context/skills.rb new file mode 100644 index 0000000..fc56a24 --- /dev/null +++ b/lib/agent/context/skills.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "skills/definition" +require_relative "skills/ownership" + +module Agent + module Context + # Skill definitions and installation ownership for dependency context. + 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..152a8d3 --- /dev/null +++ b/lib/agent/context/skills/definition.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. +# 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 < 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: + 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 InvalidError, "Skill resources must be a regular directory: #{assets}" + end + else + assets = nil + end + + installation_content = source.render(frontmatter) + + new(source, specification, installed_name, frontmatter["description"], assets, installation_content) + 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 InvalidError, "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 InvalidError, "Invalid skill description in #{file}" + end + + description = description.strip + if description.empty? || description.length > 1024 + raise InvalidError, "Invalid skill description in #{file}" + end + end + + # 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 + @installation_content = installation_content + end + + # @attribute [Gem::Specification] The resolved package specification. + attr_reader :specification + + # @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 :resources + + # @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. + def skill? + true + end + + # Return the filename shown by the context list with its skill marker. + # @returns [String] The source filename and skill marker. + 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 install(destination) + FileUtils.mkdir_p(destination) + begin + File.write(File.join(destination, "SKILL.md"), @installation_content) + rescue + FileUtils.rm_rf(destination) + raise + end + copy_resources(@resources, destination, true) if @resources + 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 InvalidError, "Skill resources must be regular files or directories: #{from}" + end + + if top_level && name.downcase == "skill.md" + raise InvalidError, "#{name} is reserved for generated skill files: #{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/ownership.rb b/lib/agent/context/skills/ownership.rb new file mode 100644 index 0000000..500506a --- /dev/null +++ b/lib/agent/context/skills/ownership.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. + +require_relative "../document" +require_relative "../errors" + +module Agent + module Context + module Skills + # Represents installation ownership recorded in each skill directory. + class Ownership + FILE_NAME = "SKILL.md" + NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ + + # 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) + + begin + stat = File.lstat(path) + rescue Errno::ENOENT + return + end + + 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 InvalidError, "Invalid skill ownership: #{path}" + end + + metadata.slice(*fields) + 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) + begin + stat = File.lstat(directory) + rescue Errno::ENOENT + return {} + end + + 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) + next unless File.lstat(path).directory? + next unless owner = load(path) + + unless name.match?(NAME_PATTERN) && name.length <= 64 + raise InvalidError, "Invalid installed skill name: #{name.inspect}" + end + + owners[name] = owner + end + end + end + end + end +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 abf3c65..ddd9beb 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Agent::Context -Provides tools for installing and managing context files from Ruby gems for AI agents, and generating `agents.md` files following the specification. +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 from Ruby gems for AI a 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. +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 @@ -23,16 +23,16 @@ 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. + - Installs metadata-declared skills into `.agents/skills/`. ## Context This gem provides its own context files in the `context/` directory, including: - - `usage.md` - Comprehensive guide for using and providing context files. + - `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 the `.agents/context/` directory and referenced in `agents.md`. +When you install context from other gems, they will be placed in `.agents/context/`. ## Usage @@ -52,7 +52,7 @@ $ bundle add agent-context #### Install Context (Primary Command) -Install context from all available gems and update `agents.md`: +Install ordinary context and skills from all available gems: ``` bash $ bake agent:context:install @@ -86,13 +86,13 @@ Show the content of a specific context file: $ bake agent:context:show --gem async --file thread-safety ``` +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 -The `.agents/` directory contains generated files and should be excluded from version control. The generated `agents.md` index should be committed: +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. - - `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. +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 @@ -102,28 +102,38 @@ 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 +Use descriptive filenames and focused Markdown guides. Agents find the installed files through directory listing and the list/show commands. + +### Providing Skills + +A root-level context document can declare a skill: -You can provide a custom `index.yaml` file to control ordering and metadata: +```markdown +--- +metadata: + type: skill +description: Run the provider's workflow when preparing a new project. +license: MIT +--- -``` 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" +# Workflow + +Follow these instructions. ``` -If no `index.yaml` is provided, one will be generated automatically from your gemspec and markdown files. +`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 `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 + +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). ## Releases @@ -152,6 +162,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.'`). diff --git a/releases.md b/releases.md index 1a9a219..74c4741 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,15 @@ # Releases +## 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. +- 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 - Rename `agent.md` -\> `agents.md`. diff --git a/specification.md b/specification.md index afac15c..a34a0e0 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 @@ -33,8 +33,9 @@ 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. +- Read and apply guidance relevant to the task. ### 2.3 Context Files @@ -47,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. @@ -63,6 +68,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/ @@ -81,11 +87,14 @@ 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`). +- **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: + ``` project-root/ ├── .agents/context/ @@ -135,6 +144,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 +152,29 @@ 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 `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 +--- +``` + +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. + +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 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. + ## 5. Discovery and Installation ### 5.1 Discovery Process @@ -163,14 +196,60 @@ 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 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. -### 5.3 Installation Algorithm +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 Steps + +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 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. + +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 + +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" +--- ``` -FOR each package with context: - CREATE directory .agents/context/package-name/ - COPY all files recursively from package/context/ to .agents/context/package-name/ -END -``` + +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. + +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. + +`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. 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. + +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. 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 new file mode 100644 index 0000000..98071a1 --- /dev/null +++ b/test/agent/context/document.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "agent/context/document" +require "agent/context/test_document" +require "tmpdir" +require "fileutils" + +describe Agent::Context::Document do + let(:directory) {Dir.mktmpdir} + let(:path) {File.join(directory, "guide.md")} + let(:document) {subject.load(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.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 + 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(File.binread(path)).to be == content + end + + 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) + 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.frontmatter).to be == {} + 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(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(Agent::Context::InvalidError, message: be(:include?, path)) + end + + 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) + 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" + 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", "--- 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 + end + +end diff --git a/test/agent/context/exclusion.rb b/test/agent/context/exclusion.rb new file mode 100644 index 0000000..efdd65e --- /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::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::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) + 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 + 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 cb4c5c3..0000000 --- a/test/agent/context/index.rb +++ /dev/null @@ -1,375 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2025, by Samuel Williams. -# Copyright, 2025, by Shopify Inc. - -require "agent/context/index" -require "tmpdir" -require "fileutils" - -describe Agent::Context::Index do - - - 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 - 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 - 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 - end -end diff --git a/test/agent/context/installer.rb b/test/agent/context/installer.rb index 763032f..6a332b8 100644 --- a/test/agent/context/installer.rb +++ b/test/agent/context/installer.rb @@ -39,36 +39,36 @@ 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.name).to be == "fake-gem" + expect(gems.first.version).to be == "1.0.0" + 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) - gem_info = helper.find_gem_with_context("fake-gem") + package = helper.find_provider("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.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) - gem_info = helper.find_gem_with_context("non-existent-gem") - expect(gem_info).to be_nil + package = helper.find_provider("non-existent-gem") + expect(package).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.find_provider("fake-gem").definitions - 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 @@ -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} @@ -116,8 +104,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") @@ -131,17 +119,28 @@ 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::Error) + 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) - 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") @@ -149,18 +148,15 @@ def around expect(File).to be(:exist?, File.join(target_context_path, "configuration.md")) end - it "generates metadata for minimally structured context" do - File.write(File.join(context_path, "notes.md"), "First paragraph.\n\nSecond paragraph.") + 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_gem_context("fake-gem")).to be_truthy - - 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 == "Documentation" - expect(notes["description"]).to be == "First paragraph." + 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 @@ -186,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 new file mode 100644 index 0000000..c08e7e7 --- /dev/null +++ b/test/agent/context/skills.rb @@ -0,0 +1,196 @@ +# 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"), "---\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 + FileUtils.rm_rf(directory) + end + + it "enumerates gem specifications during discovery" do + enumerations = 0 + specifications = Enumerator.new do |yielder| + enumerations += 1 + yielder << specification + end + installer = subject.new(root: consumer, specifications: specifications) + expect(enumerations).to be == 0 + 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 + + it "installs context and skills to their distinct destinations" do + result = installer.install + expect(result).to be == {context: ["provider"], skills: ["provider-workflow"]} + 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")) + 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")) + 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 + end + + it "lists and shows skills alongside ordinary context" do + 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(Agent::Context::InvalidError) + end + + 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 + end.to raise_exception(Agent::Context::InvalidError) + 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\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")).frontmatter + expect(metadata["name"]).to be == "provider-workflow" + 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 + + it "handles a provider containing only skills" do + File.delete(File.join(provider, "context/guide.md")) + installer.install(gem: "provider") + 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 "propagates context write failures and refreshes successfully on retry" do + installer.install + destination = File.join(consumer, ".agents/context/provider/guide.md") + File.write(File.join(provider, "context/guide.md"), "# Updated\n") + mock(File).before(:write) do |path, *| + raise Errno::EACCES, "Injected context failure" if path == destination + end + expect{installer.install(gem: "provider")}.to raise_exception(Errno::EACCES) + mock(File).clear + installer.install(gem: "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::ConflictError) + 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::InvalidError) + 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 new file mode 100644 index 0000000..f6956b7 --- /dev/null +++ b/test/agent/context/skills/installer.rb @@ -0,0 +1,434 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require "agent/context" +require "tmpdir" + +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)]} + 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 = {"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 + + def skill_definitions(gem_name) + Array(installer.find_provider(gem_name)&.definitions).grep(Agent::Context::Skills::Definition) + end + + it "finds gems which provide skills" do + gems = installer.providers + + 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"] + end + + it "lists skills provided by a gem" do + skills = skill_definitions("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_context_file("fake-gem", "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_provider("missing-gem")).to be_nil + expect do + installer.install(gem: "missing-gem") + end.to raise_exception(Agent::Context::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(skill_definitions("fake-gem").map(&:name)).to be == ["fake-gem-ruby-testing"] + File.delete(File.join(provider_root, "context/ruby-testing.md")) + expect(installer.providers.first.definitions).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.providers).to be == [] + expect(local_installer.providers(skip_local: false).map(&:name)).to be == ["local-gem"] + end + + with "installation" do + it "installs skills relative to the consuming project root" do + installed = installer.install(gem: "fake-gem") + destination = File.join(consumer_root, ".agents", "skills", "fake-gem-ruby-testing") + + expect(installed[:skills]).to be == ["fake-gem-ruby-testing"] + expect(File).to be(:exist?, File.join(destination, "SKILL.md")) + end + + 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") + + expect(Agent::Context::Skills::Ownership.load(path)).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: "fake-gem") + write_skill(provider_root, "ruby-testing", description: "Updated description.", body: "# Updated\n") + 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") + end + + it "removes skills no longer provided by the same gem" do + write_skill(provider_root, "documentation", description: "Write documentation.") + installer.install(gem: "fake-gem") + File.delete(File.join(provider_root, "context", "documentation.md")) + installer.install(gem: "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: "fake-gem") + end.to raise_exception(Agent::Context::ConflictError) + + 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::InvalidError) + + 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.") + 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 + end.to raise_exception(Agent::Context::ConflictError) + + 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[: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 "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_context_file("fake-gem", "provider-authored")).to be(:include?, "name: provider-authored") + end + + 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.providers.map(&: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[:skills]).to be == ["fake-gem-ruby-testing"] + 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::InvalidError) + end + + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) + 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::InvalidError) + + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills")) + 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"), "---\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")[: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") + 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 + FileUtils.rm_rf(File.join(provider_root, "context")) + 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 + subject.new(root: consumer_root, specifications: []).install + 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 + cargo = File.join(consumer_root, ".agents/skills/cargo-workflow/SKILL.md") + FileUtils.mkdir_p(File.dirname(cargo)) + File.write(cargo, "Cargo instructions") + 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 == 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) + write_owner(path, "fake-gem", "1.0.0", ecosystem: "cargo") + expect do + installer.install + end.to raise_exception(Agent::Context::ConflictError) + end + + 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.md") + destination = File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing/SKILL.md") + 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::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")) + installer.install + expect(File.read(destination)).to be(:include?, "# Updated") + end + + it "accepts the common metadata limits and rejects overlong descriptions" do + write_skill(provider_root, "a" * 55, description: "d" * 1024) + 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 + skill_definitions("fake-gem") + 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::InvalidError) + expect(File).not.to be(:exist?, File.join(consumer_root, ".agents/skills/fake-gem-ruby-testing")) + 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"), "---\nmetadata:\n type: skill\ndescription: Run workflow.\n---\n\n# Workflow\n") + expect do + installer.install + end.to raise_exception(Agent::Context::InvalidError) + 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 = {"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(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 + + 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(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::InvalidError) + write_skill(provider_root, "Bad_Name", description: "Invalid name.") + File.delete(guide) + expect do + installer.install + end.to raise_exception(Agent::Context::InvalidError) + end + + 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"), "---\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::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 + + 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.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::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 + end + FileUtils.mkdir_p(File.join(resources, "references")) + File.write(File.join(resources, "notes.txt"), "Opaque resource") + installer.install + 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 + 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 reports malformed generated exclusion blocks" do + mock(Open3).before(:capture2e){raise Errno::ENOENT, "Git unavailable"} + 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") + expect do + installer.install + 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") + 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_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 + +end diff --git a/test/agent/context/skills/ownership.rb b/test/agent/context/skills/ownership.rb new file mode 100644 index 0000000..9163866 --- /dev/null +++ b/test/agent/context/skills/ownership.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. + +require "agent/context/skills/ownership" +require "agent/context/test_document" +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 + + 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 + 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 + 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")) + 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 + [ + "---\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.md"), content) + expect do + subject.scan(directory) + end.to raise_exception(Agent::Context::InvalidError) + end + end + + it "reports inaccessible ownership files and installation roots" do + 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) + 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.md")) + expect do + subject.load(skill_path) + 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(Agent::Context::InvalidError) + end + + it "validates the installed directory name" do + ["Bad_Name", "a" * 65].each do |name| + path = File.join(directory, name) + FileUtils.mkdir_p(path) + write_owner(path, "provider", "1.0.0") + expect do + subject.scan(directory) + end.to raise_exception(Agent::Context::InvalidError) + 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 new file mode 100644 index 0000000..f37c692 --- /dev/null +++ b/test/agent/context/tasks.rb @@ -0,0 +1,55 @@ +# 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 the context task family" do + Dir.mktmpdir do |root| + context = Bake::Context.load(root) + %w[agent:context:list agent:context:show agent:context:install].each do |name| + expect(context.lookup(name)).to be_truthy + end + end + end + + 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")) + File.write(File.join(provider, "context/guide.md"), "# Provider Guide\n\nUse the package.\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" + spec.summary = "Provider guidance." + end + specification.instance_variable_set(:@full_gem_path, provider) + mock(Gem::Specification).replace(:each){|&block| [specification].each(&block)} + 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", "workflow") + context.call("agent:context:show", "--gem", "provider", "--file", "missing") + 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?, "# 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")) + FileUtils.rm_rf(File.join(provider, "context")) + context.call("agent:context:list") + context.call("agent:context:install") + expect(output).to be(:include?, "No gems with context found") + expect(File).not.to be(:exist?, File.join(root, ".agents/skills/provider-workflow")) + end + end +end