Commit d251648
Changed files (15)
lib
elelem
skills
spec
elelem
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
+...
+```