Commit d251648

mo khan <mo@mokhan.ca>
2026-09-08 04:28:17
feat: implement skill loading per the .agents/skills SKILL.md standard
Discovers SKILL.md files under ~/.agents/skills and ./.agents/skills (project shadows user by name), parses YAML frontmatter (name, description), and registers a `skill` toolbox tool whose description carries the skill index -- the model calls skill(name: ...) to pull in the full body on demand, matching the standard's progressive-disclosure design. A /skills [name] command covers human-driven listing/reading. Long descriptions are capped (Skill.truncate) since the index ships on every provider.fetch call via toolbox.to_a.
1 parent 8eb3a30
lib/elelem/skills/catalog.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Skills
+    class Catalog
+      LOAD_PATHS = [
+        "~/.agents/skills",
+        ".agents/skills"
+      ].freeze
+
+      def initialize(load_paths: LOAD_PATHS)
+        @load_paths = load_paths
+      end
+
+      def all
+        @all ||= discover
+      end
+
+      def find(name)
+        all.find { |skill| skill.name == name }
+      end
+
+      def each(&block)
+        all.each(&block)
+      end
+
+      private
+
+      def discover(seen = {})
+        @load_paths.each do |path|
+          dir = File.expand_path(path)
+          next unless File.directory?(dir)
+
+          Dir["#{dir}/*/SKILL.md"].sort.each do |skill_md|
+            skill = Skill.load(skill_md)
+            next unless skill
+
+            seen[skill.name] = skill
+          rescue => e
+            Elelem.logger.warn("elelem-skills: failed to load #{skill_md}: #{e.message}")
+          end
+        end
+
+        seen.values
+      end
+    end
+  end
+end
lib/elelem/skills/setup.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+Elelem.configure do |config|
+  config.setup(:skills) do |agent|
+    catalog = Elelem::Skills::Catalog.new
+    next if catalog.all.empty?
+
+    index = -> do
+      catalog.all.map { |skill| Elelem::Skills::Skill.truncate(skill.summary) }.join("\n")
+    end
+
+    agent.toolbox.add("skill",
+      description: "Load a skill's instructions by name. Available skills:\n#{index.call}",
+      params: { name: { type: "string", description: "the skill name" } },
+      required: ["name"]
+    ) do |a|
+      skill = catalog.find(a["name"])
+      next { error: "unknown skill: #{a["name"]}" } unless skill
+
+      { name: skill.name, dir: skill.dir, content: skill.body }
+    end
+
+    completions = -> { catalog.all.map(&:name) }
+
+    agent.commands.register("skills", description: "List available skills", completions: completions) do |arg|
+      if arg && !arg.empty?
+        skill = catalog.find(arg)
+        unless skill
+          agent.output.say "Unknown skill: #{arg}"
+          next
+        end
+
+        agent.output.say skill.body
+      else
+        list = catalog.all
+        if list.empty?
+          agent.output.say "No skills installed."
+        else
+          agent.output.say list.map(&:summary).join("\n")
+        end
+      end
+    end
+  end
+end
lib/elelem/skills/skill.rb
@@ -1,9 +1,56 @@
 # frozen_string_literal: true
 
-Elelem.configure do |config|
-  config.setup(:skill) do |agent|
-    agent.commands.register("skill", description: "Invoke a skill (not yet implemented)") do |_args|
-      agent.output.say NotImplementedError.new("skills are not implemented yet").message
+module Elelem
+  module Skills
+    class Skill
+      SUMMARY_LIMIT = 400
+
+      attr_reader :name, :description, :dir, :path, :body
+
+      def initialize(name:, description:, dir:, path:, body:)
+        @name = name
+        @description = description
+        @dir = dir
+        @path = path
+        @body = body
+      end
+
+      def self.truncate(text, limit: SUMMARY_LIMIT)
+        return text if text.length <= limit
+
+        "#{text[0, limit]}..."
+      end
+
+      def self.load(skill_md)
+        frontmatter, body = split(File.read(skill_md))
+        unless frontmatter
+          Elelem.logger.warn("elelem-skills: #{skill_md} has no frontmatter, skipping")
+          return nil
+        end
+
+        attrs = YAML.safe_load(frontmatter)
+        name, description = attrs["name"], attrs["description"]
+        unless name && description
+          Elelem.logger.warn("elelem-skills: #{skill_md} is missing name or description, skipping")
+          return nil
+        end
+
+        new(name: name, description: description, dir: File.dirname(skill_md), path: skill_md, body: body.strip)
+      end
+
+      def self.split(text)
+        return [nil, text] unless text.start_with?("---\n")
+
+        _, frontmatter, body = text.split(/^---\s*$/, 3)
+        return [nil, text] unless frontmatter && body
+
+        [frontmatter, body]
+      end
+      private_class_method :split
+
+      def summary
+        "#{name}: #{description}"
+      end
     end
   end
 end
lib/elelem/skills.rb
@@ -1,6 +1,9 @@
 # frozen_string_literal: true
 
 require "elelem"
+require "yaml"
 
 require_relative "skills/version"
 require_relative "skills/skill"
+require_relative "skills/catalog"
+require_relative "skills/setup"
spec/elelem/skills/catalog_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::Catalog do
+  let(:fixtures) { File.expand_path("../../fixtures/skills", __dir__) }
+
+  describe "#all" do
+    it "discovers skills with valid frontmatter and skips invalid ones" do
+      catalog = described_class.new(load_paths: [fixtures])
+
+      expect(catalog.all.map(&:name)).to eq(["tdd"])
+    end
+
+    it "lets later load paths shadow earlier ones by name" do
+      Dir.mktmpdir do |override_root|
+        override_dir = File.join(override_root, "tdd")
+        FileUtils.mkdir_p(override_dir)
+        File.write(File.join(override_dir, "SKILL.md"), <<~SKILL)
+          ---
+          name: tdd
+          description: overridden
+          ---
+          overridden body
+        SKILL
+
+        catalog = described_class.new(load_paths: [fixtures, override_root])
+
+        expect(catalog.find("tdd").description).to eq("overridden")
+      end
+    end
+
+    it "returns an empty list when no load paths exist" do
+      catalog = described_class.new(load_paths: ["/nonexistent/path"])
+
+      expect(catalog.all).to eq([])
+    end
+  end
+
+  describe "#find" do
+    it "returns nil for an unknown skill" do
+      catalog = described_class.new(load_paths: [fixtures])
+
+      expect(catalog.find("nope")).to be_nil
+    end
+  end
+end
spec/elelem/skills/setup_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+RSpec.describe "elelem-skills setup" do
+  let(:fixtures) { File.expand_path("../../fixtures/skills", __dir__) }
+  let(:agent) { Elelem::Agent.new(Elelem::StubProvider.new) }
+
+  around do |example|
+    original = Elelem::Skills::Catalog::LOAD_PATHS
+    Elelem::Skills::Catalog.send(:remove_const, :LOAD_PATHS)
+    Elelem::Skills::Catalog.const_set(:LOAD_PATHS, [fixtures].freeze)
+    example.run
+  ensure
+    Elelem::Skills::Catalog.send(:remove_const, :LOAD_PATHS)
+    Elelem::Skills::Catalog.const_set(:LOAD_PATHS, original)
+  end
+
+  before do
+    Elelem::Config.default.instance_variable_get(:@registration).setups.fetch("skills").call(agent)
+  end
+
+  describe "skill tool" do
+    it "advertises the catalog in its description" do
+      tool = agent.toolbox.tool_for("skill")
+
+      expect(tool.description).to include("tdd: Test-driven development")
+    end
+
+    it "returns the skill body and dir for a known skill" do
+      result = agent.toolbox.run("skill", { "name" => "tdd" })
+
+      expect(result).to be_ok
+      expect(result[:name]).to eq("tdd")
+      expect(result[:dir]).to eq(File.join(fixtures, "tdd"))
+      expect(result[:content]).to eq("# TDD\n\nRed, green, refactor.")
+    end
+
+    it "fails for an unknown skill" do
+      result = agent.toolbox.run("skill", { "name" => "nope" })
+
+      expect(result).not_to be_ok
+      expect(result.error).to eq("unknown skill: nope")
+    end
+  end
+
+  describe "/skills command" do
+    it "lists the catalog" do
+      output = instance_double(Elelem::Output)
+      agent = Elelem::Agent.new(Elelem::StubProvider.new, output: output)
+      Elelem::Config.default.instance_variable_get(:@registration).setups.fetch("skills").call(agent)
+
+      expect(output).to receive(:say).with("tdd: Test-driven development. Use when the user wants tests written first.")
+
+      agent.commands.run("skills", nil)
+    end
+
+    it "shows a single skill's body" do
+      output = instance_double(Elelem::Output)
+      agent = Elelem::Agent.new(Elelem::StubProvider.new, output: output)
+      Elelem::Config.default.instance_variable_get(:@registration).setups.fetch("skills").call(agent)
+
+      expect(output).to receive(:say).with("# TDD\n\nRed, green, refactor.")
+
+      agent.commands.run("skills", "tdd")
+    end
+  end
+end
spec/elelem/skills/skill_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::Skill do
+  let(:fixtures) { File.expand_path("../../fixtures/skills", __dir__) }
+
+  describe ".load" do
+    it "parses frontmatter and body" do
+      skill = described_class.load(File.join(fixtures, "tdd", "SKILL.md"))
+
+      expect(skill.name).to eq("tdd")
+      expect(skill.description).to eq("Test-driven development. Use when the user wants tests written first.")
+      expect(skill.dir).to eq(File.join(fixtures, "tdd"))
+      expect(skill.body).to eq("# TDD\n\nRed, green, refactor.")
+    end
+
+    it "returns nil when description is missing" do
+      skill = described_class.load(File.join(fixtures, "no-description", "SKILL.md"))
+
+      expect(skill).to be_nil
+    end
+
+    it "returns nil when there is no frontmatter" do
+      Dir.mktmpdir do |dir|
+        path = File.join(dir, "SKILL.md")
+        File.write(path, "just a body, no frontmatter\n")
+
+        expect(described_class.load(path)).to be_nil
+      end
+    end
+  end
+
+  describe "#summary" do
+    it "combines name and description" do
+      skill = described_class.new(name: "tdd", description: "desc", dir: ".", path: "SKILL.md", body: "")
+
+      expect(skill.summary).to eq("tdd: desc")
+    end
+  end
+
+  describe ".truncate" do
+    it "leaves short text untouched" do
+      expect(described_class.truncate("short")).to eq("short")
+    end
+
+    it "caps long text and marks it as truncated" do
+      text = "a" * 500
+      result = described_class.truncate(text, limit: 400)
+
+      expect(result.length).to eq(403)
+      expect(result).to end_with("...")
+    end
+  end
+end
spec/fixtures/skills/no-description/SKILL.md
@@ -0,0 +1,5 @@
+---
+name: no-description
+---
+
+Body with no description; should be skipped.
spec/fixtures/skills/tdd/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: tdd
+description: Test-driven development. Use when the user wants tests written first.
+---
+
+# TDD
+
+Red, green, refactor.
spec/spec_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "tmpdir"
+require_relative "../lib/elelem/skills"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
elelem-skills.gemspec
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
   spec.authors = ["mo khan"]
   spec.email = ["mo@mokhan.ca"]
 
-  spec.summary = "Skills loader for for elelem."
-  spec.description = "Skills loader for for elelem."
+  spec.summary = "Skill loader for elelem."
+  spec.description = "Discovers and loads .agents/skills/*/SKILL.md skills (per the agent skills standard) as an elelem tool and /skills command."
   spec.homepage = "https://src.mokhan.ca/elelem/skills"
   spec.license = "MIT"
   spec.required_ruby_version = ">= 4.0.0"
Gemfile
@@ -6,3 +6,4 @@ gemspec name: "elelem-skills"
 
 gem "irb"
 gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Rakefile
@@ -1,4 +1,8 @@
 # frozen_string_literal: true
 
 require "bundler/gem_tasks"
-task default: %i[]
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: %i[spec]
README.md
@@ -0,0 +1,65 @@
+# elelem-skills
+
+Loads [agent skills](https://github.com/anthropics/skills) — `SKILL.md` files
+under an `.agents/skills/` directory — and exposes them to an `elelem` agent.
+
+## What this actually is
+
+A skill is a directory containing a `SKILL.md` with YAML frontmatter:
+
+```
+.agents/skills/tdd/SKILL.md
+---
+name: tdd
+description: Test-driven development. Use when the user wants tests written first.
+---
+
+# Test-Driven Development
+...body...
+```
+
+`elelem-skills` scans:
+
+1. `~/.agents/skills/*/SKILL.md` (user-level)
+2. `.agents/skills/*/SKILL.md` (project-level, shadows user-level by name)
+
+for skills, and registers a `skill` toolbox tool whose *description* is the
+skill index (name + frontmatter description for every discovered skill) --
+this is the progressive-disclosure mechanism the standard relies on: the
+model sees the index up front and decides when to call `skill(name: "tdd")`
+to pull in the full body on demand, rather than every skill being crammed
+into the system prompt.
+
+The tool result includes the skill's directory (`dir`), so a skill body that
+links to bundled files (`[tests.md](tests.md)`) can be followed with the
+`read` tool from `elelem-builtins`.
+
+A `/skills [name]` command is also registered for human-driven listing/reading.
+
+## What's not implemented
+
+- `agents/*.yaml` sidecar files (per-runtime display metadata, invocation
+  policy) that some skills ship alongside `SKILL.md` -- ignored for now.
+- `allowed-tools` / permission scoping from the frontmatter.
+- Skill installation/updates (that's `npx skills`, a separate concern).
+
+## Setup
+
+```
+bundle install
+bundle exec rake spec
+```
+
+## Usage
+
+Add `elelem-skills` to an elelem project and skills under `~/.agents/skills/`
+or `.agents/skills/` are picked up automatically:
+
+```
+bundle exec elelem
+> /skills
+tdd: Test-driven development. Use when the user wants tests written first.
+> /skills tdd
+# Test-Driven Development
+...
+```