Comparing changes

v0.2.0 v0.3.0
22 commits 32 files changed

Commits

c09ebbd chore: bump version mo khan 2026-09-08 14:21:29
364beb1 chore: tidy up before release mo khan 2026-09-08 14:21:02
1aac0dc chore: add bin/test script mo khan 2026-09-08 14:15:04
2dbb102 chore: update gems mo khan 2026-09-08 14:14:13
5bd95e6 docs: update README.md mo khan 2026-09-08 14:12:48
873f73f bump to 0.10.0 mo khan 2026-09-08 06:19:15
3006ff4 bump to 0.9.0 mo khan 2026-09-08 06:05:43
76d3e5f bump to 0.8.0 mo khan 2026-09-08 05:59:46
1e1dd4c revert: remove the rfc skill mo khan 2026-09-08 05:59:36
674fd80 bump to 0.7.0 mo khan 2026-09-08 05:42:47
4da80e5 bump to 0.6.0 mo khan 2026-09-08 05:34:07
5d932c5 bump to 0.5.0 mo khan 2026-09-08 05:09:59
2c6aaff bump to 0.4.0 mo khan 2026-09-08 05:01:07
059df79 bump to 0.3.0 mo khan 2026-09-08 04:28:21
bin/test
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+set -e
+[ -n "$DEBUG" ] && set -x
+
+cd "$(dirname "$0")/.."
+
+bundle exec rspec "$@"
lib/elelem/skills/builtin/scrum/build.md
@@ -0,0 +1,29 @@
+Implement a story's tasks, one at a time, test-first.
+
+# Role
+- Work through the Tasks in the story the user specifies (see plan.md for the naming convention)
+- Follow TDD: write a failing test, implement the minimal code to pass, then move on
+- Check off each task in the story file as it's completed
+
+# Process
+1. **Focus** - If the user didn't name a story, list .agents/backlog/ and stop -- do not guess which one
+2. **Read** - Load the story and its Tasks
+3. **Red** - Write a failing test for the next unchecked task
+4. **Green** - Write the minimal code to make it pass
+5. **Verify** - Run the test suite
+6. **Check** - Mark the task complete in the story file, then repeat from step 3 for the next task
+
+# Task Completion
+When a task is done, edit the story file:
+```markdown
+# Tasks
+
+* [x] Create FooService in lib/foo_service.rb  ← mark done
+* [ ] Add #bar method to handle X              ← next task
+```
+
+# Guidelines
+- One task at a time, in order
+- Minimal diffs -- only what the current task needs
+- No defensive code or speculative abstraction
+- Run the test suite after every change, not just at the end
lib/elelem/skills/builtin/scrum/design.md
@@ -0,0 +1,32 @@
+Research the codebase and turn a story's intent into a concrete task list.
+
+# Role
+- Read the story from .agents/backlog/ (see plan.md for the naming convention)
+- Explore the codebase to find where the change belongs and what it touches
+- Fill in the story's Tasks section
+- Note risks or dependencies in the story's DESCRIPTION or SEE ALSO
+
+Do not write or edit application/test code in this skill -- only the story
+file. That's what makes design safe to run before committing to an approach.
+
+# Process
+1. **Review** - Read the story in .agents/backlog/
+2. **Explore** - Trace code paths, find the extension points and existing patterns to follow
+3. **Plan** - Break the story into small, ordered, testable tasks
+4. **Update** - Edit the story file's Tasks section
+
+# Task Format
+In the story's # Tasks section:
+```markdown
+# Tasks
+
+* [ ] Write a failing spec for FooService#bar in spec/foo_service_spec.rb
+* [ ] Create FooService#bar in lib/foo_service.rb to pass it
+* [ ] Update config/routes.rb to add the endpoint
+```
+
+# Guidelines
+- Tasks should be small, atomic, and independently testable
+- Order by dependency (write the test before the implementation task it drives)
+- Reference specific files to modify or create
+- One task should map to roughly one red-green cycle during the build phase
lib/elelem/skills/builtin/scrum/plan.md
@@ -0,0 +1,67 @@
+Turn the user's request into a backlog of small, testable user stories.
+
+# Role
+- Interview the user before writing anything. Ask clarifying questions, one
+  turn at a time, until you are at least 95% sure you understand the
+  request -- personas, goals, edge cases, what's explicitly out of scope.
+- Never make assumptions to fill a gap. If something is unclear or
+  unstated, ask about it instead of guessing.
+- Break large requests into small, independently deliverable stories.
+- Capture acceptance criteria in testable terms.
+- Write each story to .agents/backlog/ as a separate file.
+
+# Asking Questions
+This is a chat REPL: each response you give ends your turn and the user's
+next message continues the same conversation, so a real back-and-forth
+interview works here. To ask, just respond with the question as plain text
+and stop -- do not call a tool, do not write a story file yet. Ask one
+focused question (or a short related group) at a time rather than a long
+questionnaire in one message; that keeps the interview conversational and
+lets the user's answer to one question inform the next.
+
+(If you're running as `elelem ask` rather than `elelem chat`, there's no
+next turn to read an answer from. Say so, then proceed with your best
+understanding and mark the actual gaps as SEE ALSO items -- this is the
+one situation where documenting an assumption is the only option.)
+
+# Process
+1. **Read** - Take in the user's request; identify the distinct capabilities inside it.
+2. **Interview** - Ask clarifying questions, one exchange at a time, until you're at least 95% sure you understand each capability -- what it is, who it's for, and what's out of scope.
+3. **Scope** - Split into stories small enough to finish in one build session.
+4. **Document** - Write each as a story file (template below).
+5. **List** - After writing, list the story filenames and one-line summaries back to the user so they can redirect before build starts.
+
+# Story Template
+```markdown
+As a `[persona]`, I `[want to]`, so that `[goal]`.
+
+# SYNOPSIS
+
+<one-line summary>
+
+# DESCRIPTION
+
+<detailed explanation, informed by the interview -- not by assumptions>
+
+# SEE ALSO
+
+* [ ] <related files or concepts; not a place to park unanswered questions>
+
+# Tasks
+
+* [ ] TBD (filled in during the design phase, see design.md)
+
+# Acceptance Criteria
+
+* [ ] <testable criterion>
+```
+
+# Naming Convention
+Files: .agents/backlog/NNN-short-name.md (e.g., 001-user-login.md). NNN is
+zero-padded and continues from the highest existing number in the directory.
+
+# Guidelines
+- One story per file
+- Stories should be small enough to complete in one session
+- Acceptance criteria must be objectively testable -- ask "how will we know this is done?"
+- Don't write a story until the interview has resolved its open questions
lib/elelem/skills/prompts/review.erb → lib/elelem/skills/builtin/scrum/review.md
@@ -1,25 +1,24 @@
-You are in review mode. Verify changes meet acceptance criteria.
+Review code changes against a story's acceptance criteria.
 
 # Role
-- Review code changes against story acceptance criteria
+- Review code changes against the story's acceptance criteria
 - Check test coverage
 - Identify bugs, security issues, and quality concerns
 
 # Process
-1. **Context** - Read the story from .elelem/backlog/
+1. **Context** - Read the story from .agents/backlog/
 2. **Diff** - Run `git diff` to see changes
-3. **Trace** - Read surrounding context
-4. **Verify** - Check each acceptance criterion
-5. **Report** - Summarize findings
+3. **Trace** - Read surrounding context for anything the diff touches
+4. **Verify** - Check each acceptance criterion against the actual code
+5. **Report** - Summarize findings in the format below
 
 # Review Checklist
-- [ ] All tasks in story are checked off
+- [ ] All tasks in the story are checked off
 - [ ] Acceptance criteria are satisfied
 - [ ] Tests exist and pass
 - [ ] No logic errors or edge case bugs
 - [ ] No security vulnerabilities
 - [ ] No performance issues
-- [ ] SOLID principles followed
 - [ ] Code is readable and minimal
 
 # Output Format
@@ -40,5 +39,5 @@ Severity: critical | warning | nit
 
 # Guidelines
 - Be specific: cite file:line
-- Suggest fixes
-- Distinguish blocking from non-blocking issues
+- Suggest fixes, not just problems
+- Distinguish blocking issues from non-blocking ones
lib/elelem/skills/builtin/scrum/SKILL.md
@@ -0,0 +1,32 @@
+---
+name: scrum
+description: Story-driven workflow for building a feature -- plan it into stories, design tasks, build test-first, review, then verify. Stories live in .agents/backlog/. Use when the user wants to work through a feature as a tracked, multi-step process rather than a single one-off change.
+---
+
+# Scrum
+
+A five-phase workflow, one story at a time, tracked as a markdown file in
+`.agents/backlog/`. Each phase is a separate file in this skill's directory
+-- read the one for the phase you're in.
+
+# Phases
+
+| Phase | File | When |
+|---|---|---|
+| Plan | [plan.md](plan.md) | Nothing is broken into stories yet |
+| Design | [design.md](design.md) | A story exists but has no Tasks, or its Tasks are stale |
+| Build | [build.md](build.md) | A story has Tasks and needs implementing |
+| Review | [review.md](review.md) | A story's Tasks are all checked off |
+| Verify | [verify.md](verify.md) | A story passed review |
+
+Read only the phase file you need for the current request -- each one is
+self-contained and says what to read the story for and what to write back.
+
+# Story Lifecycle
+
+A story file moves through the phases in order: plan creates it, design adds
+Tasks, build implements and checks them off, review approves the diff, verify
+confirms it works end-to-end and closes it out. Each phase only reads/writes
+the story file and the code it describes -- nothing here tracks phases across
+turns, so name the story explicitly once it exists (`.agents/backlog/NNN-*.md`)
+rather than relying on this skill to remember where you left off.
lib/elelem/skills/builtin/scrum/verify.md
@@ -0,0 +1,38 @@
+Smoke-test a finished feature end-to-end and record what you found.
+
+# Role
+- Run the feature as a user would, not by re-reading the code
+- Walk through the happy path and at least one realistic error case
+- Verify the actual behavior matches the story's intent
+- Record demo notes in the story file
+
+# Process
+1. **Setup** - Read the story in .agents/backlog/ to know what to demo
+2. **Execute** - Run the feature end-to-end (real commands, not a read-through)
+3. **Observe** - Note actual behavior, output, and any issues
+4. **Document** - Append demo notes to the story file (format below)
+5. **Report** - Summarize the result
+
+# Demo Checklist
+- [ ] Feature works as described in the story
+- [ ] Happy path completes successfully
+- [ ] At least one error case was tried and handled gracefully
+- [ ] Actual output matches the story's acceptance criteria
+
+# Story Update
+Append to the story file:
+```markdown
+# Demo Notes
+
+Status: ACCEPTED | NEEDS WORK
+
+Observations:
+- <what was tested>
+- <what worked>
+- <what needs attention, if anything>
+```
+
+# Guidelines
+- Test from the user's perspective, not the developer's
+- Try realistic scenarios, not just the exact example from the story
+- Be honest about gaps -- NEEDS WORK is a valid, useful outcome
lib/elelem/skills/prompts/build.erb
@@ -1,39 +0,0 @@
-Terminal coding agent. Execute tasks from a story.
-
-# Role
-- Work through Tasks in the story the user specifies
-- Check off completed tasks
-- Follow TDD: write failing test, implement, refactor
-
-# Process
-1. **Focus** - Ask which story to work on if not specified
-2. **Read** - Load the story from .elelem/backlog/
-3. **Test** - Write failing test first
-4. **Implement** - Minimal code to pass
-5. **Verify** - Run tests
-6. **Check** - Mark task complete in story file
-
-# Editing
-Multi-line: `echo "DIFF" | patch -p1`
-Single-line: `sed -i'' 's/old/new/' file`
-New files: write tool
-
-# Search
-`rg -n "pattern" .` - text search
-`fd -e rb .` - file discovery
-
-# Task Completion
-When a task is done, edit the story file:
-```markdown
-# Tasks
-
-* [x] Create FooService in lib/foo_service.rb  ← mark done
-* [ ] Add #bar method to handle X              ← next task
-```
-
-# Guidelines
-- Work on what the user asks for
-- One task at a time
-- Minimal diffs
-- No defensive code
-- Verify after every change
lib/elelem/skills/prompts/design.erb
@@ -1,41 +0,0 @@
-You are in design mode. Research and plan implementation for backlog stories.
-
-# Role
-- Read stories from .elelem/backlog/
-- Explore codebase to understand existing patterns
-- Fill in the Tasks section of each story
-- Identify risks and dependencies
-
-# Constraints
-Allowed: read, glob, grep, task, execute (read-only), edit (.elelem/backlog/ only)
-Blocked: code changes, test changes
-
-# Process
-1. **Review** - Read stories in .elelem/backlog/
-2. **Explore** - Trace code paths, find extension points
-3. **Research** - Consider design options and trade-offs
-4. **Plan** - Break each story into implementation tasks
-5. **Update** - Edit story files to add Tasks
-
-# Task Format
-In the story's # Tasks section:
-```markdown
-# Tasks
-
-* [ ] Create FooService in lib/foo_service.rb
-* [ ] Add #bar method to handle X
-* [ ] Write spec in spec/foo_service_spec.rb
-* [ ] Update config/routes.rb to add endpoint
-```
-
-# Guidelines
-- Tasks should be small, atomic, and independently testable
-- Order tasks by dependency (do X before Y)
-- Reference specific files to modify
-- Note if new files are needed
-- Consider test-first ordering
-
-# Trade-off Dimensions
-- Simplicity vs Flexibility
-- Performance vs Readability
-- Coupling vs Cohesion
lib/elelem/skills/prompts/plan.erb
@@ -1,59 +0,0 @@
-You are a Scrum Master capturing requirements. Interview the user as if they are the Product Owner.
-
-# Role
-- Ask clarifying questions to understand the feature
-- Break large requests into focused user stories
-- Capture acceptance criteria in testable terms
-- Write each story to .elelem/backlog/ as a separate file
-
-# Tools
-- interview(question, context?): Ask the user a question and wait for response
-- read(path): Read existing stories or code
-- glob(pattern): Find files
-- grep(pattern): Search contents
-- write(path, content): Write story files to .elelem/backlog/
-
-# Constraints
-Allowed: interview, read, glob, grep, task, execute (read-only), write (.elelem/backlog/ only)
-Blocked: code changes, test changes
-
-# Process
-1. **Listen** - Understand what the user wants to achieve
-2. **Clarify** - Ask questions about personas, goals, edge cases
-3. **Scope** - Break large features into small, deliverable stories
-4. **Document** - Create story files in .elelem/backlog/
-5. **Confirm** - Read back the stories for user approval
-
-# Story Template
-```markdown
-As a `[persona]`, I `[want to]`, so that `[goal]`.
-
-# SYNOPSIS
-
-<one-line summary>
-
-# DESCRIPTION
-
-<detailed explanation>
-
-# SEE ALSO
-
-* [ ] <related files or concepts>
-
-# Tasks
-
-* [ ] TBD (filled in design mode)
-
-# Acceptance Criteria
-
-* [ ] <testable criterion>
-```
-
-# Naming Convention
-Files: .elelem/backlog/NNN-short-name.md (e.g., 001-user-login.md)
-
-# Guidelines
-- One story per file
-- Stories should be small enough to complete in one session
-- Acceptance criteria must be objectively testable
-- Ask "how will we know this is done?"
lib/elelem/skills/prompts/verify.erb
@@ -1,39 +0,0 @@
-You are in verify mode. Demo the feature to the Product Owner.
-
-# Role
-- Perform a smoke test of implemented features
-- Walk through the feature as if demoing to the Product Owner
-- Verify the user experience matches the story intent
-
-# Process
-1. **Setup** - Identify what to demo from .elelem/backlog/
-2. **Execute** - Run the feature end-to-end
-3. **Observe** - Note behavior, output, any issues
-4. **Document** - Add demo notes to story file
-5. **Report** - Summarize for Product Owner
-
-# Demo Checklist
-- [ ] Feature works as described in story
-- [ ] Happy path completes successfully
-- [ ] Error cases are handled gracefully
-- [ ] Output/behavior matches user expectations
-
-# Story Update
-After demo, add to story file:
-```markdown
-# Demo Notes
-
-Verified: <date>
-Status: ACCEPTED | NEEDS WORK
-
-Observations:
-- <what was tested>
-- <what worked>
-- <what needs attention>
-```
-
-# Guidelines
-- Test from user perspective, not developer
-- Try realistic scenarios
-- Note any UX issues
-- Be honest about gaps
lib/elelem/skills/catalog.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Skills
+    class Catalog
+      GEM_SKILLS = File.expand_path("builtin", __dir__).freeze
+      REMOTE_SKILLS = File.expand_path("~/.agents/remote-skills").freeze
+
+      LOAD_PATHS = [
+        GEM_SKILLS,
+        "~/.agents/skills",
+        ".agents/skills"
+      ].freeze
+
+      def initialize(load_paths: LOAD_PATHS, remote_skills: REMOTE_SKILLS)
+        @load_paths = load_paths
+        @remote_skills = remote_skills
+      end
+
+      def all
+        @all ||= discover
+      end
+
+      def find(name)
+        all.find { |skill| skill.name == name }
+      end
+
+      def index(limit: nil)
+        summaries = all.map(&:summary)
+        summaries = summaries.map { |text| truncate(text, limit) } if limit
+        summaries.join("\n")
+      end
+
+      private
+
+      def discover
+        seen = {}
+
+        @load_paths.each do |path|
+          scan_one_level(path, seen)
+          scan_remote(seen) if path == GEM_SKILLS && @remote_skills
+        end
+
+        seen.values
+      end
+
+      def scan_one_level(path, seen)
+        dir = File.expand_path(path)
+        return unless File.directory?(dir)
+
+        Dir["#{dir}/*/SKILL.md"].sort.each { |skill_md| load_into(skill_md, seen) }
+      end
+
+      def scan_remote(seen)
+        dir = File.expand_path(@remote_skills)
+        return unless File.directory?(dir)
+
+        Dir["#{dir}/*/*/SKILL.md"].sort.each { |skill_md| load_into(skill_md, seen) }
+      end
+
+      def load_into(skill_md, seen)
+        skill = SkillFile.load(skill_md)
+        return unless skill
+
+        seen[skill.name] = skill
+      rescue => e
+        Elelem.logger.warn("elelem-skills: failed to load #{skill_md}: #{e.message}")
+      end
+
+      def truncate(text, limit)
+        return text if text.length <= limit
+
+        "#{text[0, limit]}..."
+      end
+    end
+  end
+end
lib/elelem/skills/fetcher.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require "digest"
+require "fileutils"
+require "json"
+require "net/http"
+require "uri"
+
+module Elelem
+  module Skills
+    class Fetcher
+      SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
+
+      FetchError = Class.new(StandardError)
+
+      def initialize(install_dir: Catalog::REMOTE_SKILLS, http: Net::HTTP)
+        @install_dir = install_dir
+        @http = http
+      end
+
+      # Fetches https://<origin>/.well-known/agent-skills/index.json
+      def fetch(origin)
+        base = normalize(origin)
+        index_url = URI.join(base, "/.well-known/agent-skills/index.json")
+        index = get_json(index_url)
+
+        unless index["$schema"] == SCHEMA
+          raise FetchError, "unrecognized index schema #{index["$schema"].inspect} at #{index_url} (expected #{SCHEMA})"
+        end
+
+        host_dir = File.join(@install_dir, sanitize(base.host))
+        (index["skills"] || []).filter_map { |entry| install(entry, index_url, host_dir) }
+      end
+
+      private
+
+      def normalize(origin)
+        origin = "https://#{origin}" unless origin.start_with?("http://", "https://")
+        URI.parse(origin)
+      end
+
+      def sanitize(host)
+        host.to_s.downcase.gsub(/[^a-z0-9.-]/, "_")
+      end
+
+      def install(entry, index_url, host_dir)
+        name, type, url, digest = entry.values_at("name", "type", "url", "digest")
+
+        unless type == "skill-md"
+          Elelem.logger.warn("elelem-skills: fetch: skipping #{name.inspect}, unsupported type #{type.inspect} (archives are not implemented)")
+          return nil
+        end
+
+        body = get_raw(URI.join(index_url, url))
+        verify!(name, body, digest)
+
+        dir = File.join(host_dir, sanitize(name))
+        FileUtils.mkdir_p(dir)
+        File.write(File.join(dir, "SKILL.md"), body)
+
+        name
+      end
+
+      def verify!(name, body, digest)
+        expected = digest.to_s.delete_prefix("sha256:")
+        actual = Digest::SHA256.hexdigest(body)
+        return if expected == actual
+
+        raise FetchError, "digest mismatch for #{name.inspect}: expected sha256:#{expected}, got sha256:#{actual}"
+      end
+
+      def get_json(uri)
+        JSON.parse(get_raw(uri))
+      rescue JSON::ParserError => e
+        raise FetchError, "invalid JSON at #{uri}: #{e.message}"
+      end
+
+      def get_raw(uri)
+        response = @http.get_response(uri)
+        raise FetchError, "GET #{uri} -> #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
+
+        response.body
+      rescue FetchError
+        raise
+      rescue => e
+        raise FetchError, "GET #{uri} failed: #{e.class}: #{e.message}"
+      end
+    end
+  end
+end
lib/elelem/skills/setup.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Skills
+    class Setup
+      SUMMARY_LIMIT = 400
+
+      def self.call(agent)
+        new(agent, Catalog.new).call
+      end
+
+      def initialize(agent, catalog)
+        @agent = agent
+        @catalog = catalog
+      end
+
+      def call
+        return if @catalog.all.empty?
+
+        register_tool
+        register_command
+      end
+
+      private
+
+      def register_tool
+        catalog = @catalog
+
+        @agent.toolbox.add("skill",
+          description: "Load a skill's instructions by name. Available skills:\n#{catalog.index(limit: SUMMARY_LIMIT)}",
+          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
+      end
+
+      def register_command
+        catalog = @catalog
+        agent = @agent
+        completions = -> { catalog.all.map(&:name) }
+
+        agent.commands.register("skills", description: "List available skills, or `/skills fetch <origin>` to install from a remote index", completions: completions) do |arg|
+          if arg&.start_with?("fetch ")
+            fetch(agent, arg.delete_prefix("fetch ").strip)
+          elsif arg && !arg.empty?
+            skill = catalog.find(arg)
+            skill ? agent.output.say(skill.body) : agent.output.say("Unknown skill: #{arg}")
+          else
+            agent.output.say(catalog.index)
+          end
+        end
+      end
+
+      def fetch(agent, origin)
+        if origin.empty?
+          agent.output.say("Usage: /skills fetch <origin>")
+          return
+        end
+
+        names = Fetcher.new.fetch(origin)
+        agent.output.say(names.empty? ? "No skills installed from #{origin}." : "Installed: #{names.join(", ")}\nRestart elelem to pick them up (/reload does not currently re-register this gem's tool -- see README).")
+      rescue Fetcher::FetchError => e
+        agent.output.say(e.message, as: :error)
+      end
+    end
+  end
+end
lib/elelem/skills/skill.rb
@@ -1,9 +1,11 @@
 # 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
+    Skill = Struct.new(:name, :description, :dir, :body) do
+      def summary
+        "#{name}: #{description}"
+      end
     end
   end
 end
lib/elelem/skills/skill_file.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Skills
+    module SkillFile
+      def self.load(path)
+        parse(File.read(path), path: path)
+      end
+
+      def self.parse(text, path:)
+        frontmatter, body = split(text)
+        unless frontmatter
+          Elelem.logger.warn("elelem-skills: #{path} 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: #{path} is missing name or description, skipping")
+          return nil
+        end
+
+        Skill.new(name, description, File.dirname(path), 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
+    end
+  end
+end
lib/elelem/skills/version.rb
@@ -2,6 +2,6 @@
 
 module Elelem
   module Skills
-    VERSION = "0.2.0"
+    VERSION = "0.3.0"
   end
 end
lib/elelem/skills.rb
@@ -1,6 +1,17 @@
 # frozen_string_literal: true
 
 require "elelem"
+require "yaml"
 
 require_relative "skills/version"
 require_relative "skills/skill"
+require_relative "skills/skill_file"
+require_relative "skills/catalog"
+require_relative "skills/fetcher"
+require_relative "skills/setup"
+
+Elelem.configure do |config|
+  config.setup(:skills) do |agent|
+    Elelem::Skills::Setup.call(agent)
+  end
+end
spec/elelem/skills/catalog_spec.rb
@@ -0,0 +1,135 @@
+# 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 "GEM_SKILLS" do
+    it "bundles the scrum skill with the gem" do
+      catalog = described_class.new
+
+      expect(catalog.all.map(&:name)).to include("scrum")
+    end
+
+    it "does not treat the scrum skill's phase files as separate skills" do
+      catalog = described_class.new
+
+      expect(catalog.all.map(&:name)).not_to include("plan", "design", "build", "review", "verify")
+    end
+  end
+
+  describe "REMOTE_SKILLS" do
+    it "discovers skills fetched into <remote_skills>/<host>/<name>/SKILL.md" do
+      Dir.mktmpdir do |remote_root|
+        skill_dir = File.join(remote_root, "example.com", "wrangler")
+        FileUtils.mkdir_p(skill_dir)
+        File.write(File.join(skill_dir, "SKILL.md"), <<~SKILL)
+          ---
+          name: wrangler
+          description: fetched from example.com
+          ---
+          body
+        SKILL
+
+        catalog = described_class.new(load_paths: [described_class::GEM_SKILLS], remote_skills: remote_root)
+
+        expect(catalog.find("wrangler")&.description).to eq("fetched from example.com")
+      end
+    end
+
+    it "does not use a recursive glob that would pick up a nested SKILL.md" do
+      Dir.mktmpdir do |remote_root|
+        nested = File.join(remote_root, "example.com", "wrangler", "resources", "nested")
+        FileUtils.mkdir_p(nested)
+        File.write(File.join(nested, "SKILL.md"), <<~SKILL)
+          ---
+          name: nested-imposter
+          description: should not be discovered
+          ---
+          body
+        SKILL
+
+        catalog = described_class.new(load_paths: [described_class::GEM_SKILLS], remote_skills: remote_root)
+
+        expect(catalog.find("nested-imposter")).to be_nil
+      end
+    end
+
+    it "lets a user-level skill of the same name shadow a remote-fetched one" do
+      Dir.mktmpdir do |remote_root|
+        skill_dir = File.join(remote_root, "example.com", "tdd")
+        FileUtils.mkdir_p(skill_dir)
+        File.write(File.join(skill_dir, "SKILL.md"), <<~SKILL)
+          ---
+          name: tdd
+          description: remote version
+          ---
+          body
+        SKILL
+
+        catalog = described_class.new(load_paths: [described_class::GEM_SKILLS, fixtures], remote_skills: remote_root)
+
+        expect(catalog.find("tdd").description).to eq("Test-driven development. Use when the user wants tests written first.")
+      end
+    end
+
+    it "is not scanned when remote_skills is nil" do
+      catalog = described_class.new(load_paths: [described_class::GEM_SKILLS], remote_skills: nil)
+
+      expect { catalog.all }.not_to raise_error
+    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
+
+  describe "#index" do
+    it "joins summaries without truncating by default" do
+      catalog = described_class.new(load_paths: [fixtures])
+
+      expect(catalog.index).to eq("tdd: Test-driven development. Use when the user wants tests written first.")
+    end
+
+    it "truncates summaries when a limit is given" do
+      catalog = described_class.new(load_paths: [fixtures])
+
+      expect(catalog.index(limit: 10)).to eq("tdd: Test-...")
+    end
+  end
+end
spec/elelem/skills/fetcher_spec.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::Fetcher do
+  let(:install_dir) { Dir.mktmpdir }
+  let(:http) { class_double(Net::HTTP) }
+  let(:fetcher) { described_class.new(install_dir: install_dir, http: http) }
+
+  after { FileUtils.rm_rf(install_dir) }
+
+  def ok(body)
+    res = Net::HTTPOK.new("1.1", "200", "OK")
+    res.instance_variable_set(:@read, true)
+    res.body = body
+    res
+  end
+
+  def not_found
+    res = Net::HTTPNotFound.new("1.1", "404", "Not Found")
+    res.instance_variable_set(:@read, true)
+    res.body = ""
+    res
+  end
+
+  let(:skill_body) { "---\nname: example\ndescription: An example skill.\n---\nBody.\n" }
+  let(:digest) { "sha256:#{Digest::SHA256.hexdigest(skill_body)}" }
+
+  let(:index) do
+    {
+      "$schema" => Elelem::Skills::Fetcher::SCHEMA,
+      "skills" => [
+        { "name" => "example", "type" => "skill-md", "description" => "An example skill.",
+          "url" => "/.well-known/agent-skills/example/SKILL.md", "digest" => digest }
+      ]
+    }.to_json
+  end
+
+  describe "#fetch" do
+    it "installs a skill-md entry whose digest matches" do
+      expect(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(index))
+      expect(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/example/SKILL.md"))
+        .and_return(ok(skill_body))
+
+      names = fetcher.fetch("example.com")
+
+      expect(names).to eq(["example"])
+      expect(File.read(File.join(install_dir, "example.com", "example", "SKILL.md"))).to eq(skill_body)
+    end
+
+    it "adds https:// when the origin has no scheme" do
+      expect(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(index))
+      allow(http).to receive(:get_response).with(URI("https://example.com/.well-known/agent-skills/example/SKILL.md")).and_return(ok(skill_body))
+
+      fetcher.fetch("example.com")
+    end
+
+    it "raises when the digest does not match" do
+      tampered = index.sub(digest, "sha256:#{"0" * 64}")
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(tampered))
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/example/SKILL.md"))
+        .and_return(ok(skill_body))
+
+      expect { fetcher.fetch("example.com") }.to raise_error(Elelem::Skills::Fetcher::FetchError, /digest mismatch/)
+    end
+
+    it "raises on an unrecognized schema" do
+      bad = index.sub(Elelem::Skills::Fetcher::SCHEMA, "https://schemas.agentskills.io/discovery/9.9.9/schema.json")
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(bad))
+
+      expect { fetcher.fetch("example.com") }.to raise_error(Elelem::Skills::Fetcher::FetchError, /unrecognized index schema/)
+    end
+
+    it "raises when the schema field is absent (treated as incompatible v0.1.0)" do
+      no_schema = JSON.parse(index).tap { |h| h.delete("$schema") }.to_json
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(no_schema))
+
+      expect { fetcher.fetch("example.com") }.to raise_error(Elelem::Skills::Fetcher::FetchError, /unrecognized index schema/)
+    end
+
+    it "skips archive entries and warns, without raising" do
+      archive_index = {
+        "$schema" => Elelem::Skills::Fetcher::SCHEMA,
+        "skills" => [
+          { "name" => "bundled", "type" => "archive", "description" => "Has resources.",
+            "url" => "/.well-known/agent-skills/bundled.tar.gz", "digest" => "sha256:#{"0" * 64}" }
+        ]
+      }.to_json
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(archive_index))
+      expect(Elelem.logger).to receive(:warn).with(/unsupported type "archive"/)
+
+      expect(fetcher.fetch("example.com")).to eq([])
+    end
+
+    it "raises when the index request fails" do
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(not_found)
+
+      expect { fetcher.fetch("example.com") }.to raise_error(Elelem::Skills::Fetcher::FetchError, /404/)
+    end
+
+    it "partitions by host so two origins publishing the same skill name don't clobber each other" do
+      other_body = "---\nname: example\ndescription: A different example skill, from another host.\n---\nOther body.\n"
+      other_digest = "sha256:#{Digest::SHA256.hexdigest(other_body)}"
+      other_index = {
+        "$schema" => Elelem::Skills::Fetcher::SCHEMA,
+        "skills" => [
+          { "name" => "example", "type" => "skill-md", "description" => "A different example skill.",
+            "url" => "/.well-known/agent-skills/example/SKILL.md", "digest" => other_digest }
+        ]
+      }.to_json
+
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/index.json"))
+        .and_return(ok(index))
+      allow(http).to receive(:get_response)
+        .with(URI("https://example.com/.well-known/agent-skills/example/SKILL.md"))
+        .and_return(ok(skill_body))
+      allow(http).to receive(:get_response)
+        .with(URI("https://other.example/.well-known/agent-skills/index.json"))
+        .and_return(ok(other_index))
+      allow(http).to receive(:get_response)
+        .with(URI("https://other.example/.well-known/agent-skills/example/SKILL.md"))
+        .and_return(ok(other_body))
+
+      fetcher.fetch("example.com")
+      fetcher.fetch("other.example")
+
+      expect(File.read(File.join(install_dir, "example.com", "example", "SKILL.md"))).to eq(skill_body)
+      expect(File.read(File.join(install_dir, "other.example", "example", "SKILL.md"))).to eq(other_body)
+    end
+
+    it "lowercases the host when using it as a directory name" do
+      index_url = URI("https://Example.COM/.well-known/agent-skills/index.json")
+      allow(http).to receive(:get_response).with(index_url).and_return(ok(index))
+      allow(http).to receive(:get_response)
+        .with(URI("https://Example.COM/.well-known/agent-skills/example/SKILL.md"))
+        .and_return(ok(skill_body))
+
+      fetcher.fetch("Example.COM")
+
+      expect(File.directory?(File.join(install_dir, "example.com"))).to be(true)
+    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_file_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::SkillFile do
+  describe ".parse" do
+    it "parses frontmatter and body into a Skill" do
+      text = <<~SKILL
+        ---
+        name: tdd
+        description: Test-driven development.
+        ---
+
+        # TDD
+
+        Red, green, refactor.
+      SKILL
+
+      skill = described_class.parse(text, path: "/skills/tdd/SKILL.md")
+
+      expect(skill.name).to eq("tdd")
+      expect(skill.description).to eq("Test-driven development.")
+      expect(skill.dir).to eq("/skills/tdd")
+      expect(skill.body).to eq("# TDD\n\nRed, green, refactor.")
+    end
+
+    it "returns nil when description is missing" do
+      text = <<~SKILL
+        ---
+        name: tdd
+        ---
+        body
+      SKILL
+
+      expect(described_class.parse(text, path: "SKILL.md")).to be_nil
+    end
+
+    it "returns nil when there is no frontmatter" do
+      expect(described_class.parse("just a body, no frontmatter\n", path: "SKILL.md")).to be_nil
+    end
+  end
+
+  describe ".load" do
+    it "reads the file and parses it" do
+      fixtures = File.expand_path("../../fixtures/skills", __dir__)
+
+      skill = described_class.load(File.join(fixtures, "tdd", "SKILL.md"))
+
+      expect(skill.name).to eq("tdd")
+      expect(skill.dir).to eq(File.join(fixtures, "tdd"))
+    end
+
+    it "returns nil for a skill with no description" do
+      fixtures = File.expand_path("../../fixtures/skills", __dir__)
+
+      skill = described_class.load(File.join(fixtures, "no-description", "SKILL.md"))
+
+      expect(skill).to be_nil
+    end
+  end
+end
spec/elelem/skills/skill_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::Skill do
+  describe "#summary" do
+    it "combines name and description" do
+      skill = described_class.new("tdd", "desc", ".", "")
+
+      expect(skill.summary).to eq("tdd: desc")
+    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"
@@ -26,4 +26,7 @@ Gem::Specification.new do |spec|
   spec.require_paths = ["lib"]
 
   spec.add_dependency "elelem", "~> 0.11"
+  spec.add_dependency "json", "~> 3.0"
+  spec.add_dependency "net-http", "~> 0.9"
+  spec.add_dependency "yaml", "~> 0.4"
 end
Gemfile
@@ -6,3 +6,4 @@ gemspec name: "elelem-skills"
 
 gem "irb"
 gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Gemfile.lock
@@ -1,13 +1,17 @@
 PATH
   remote: .
   specs:
-    elelem-skills (0.2.0)
+    elelem-skills (0.3.0)
       elelem (~> 0.11)
+      json (~> 3.0)
+      net-http (~> 0.9)
+      yaml (~> 0.4)
 
 GEM
   remote: https://rubygems.org/
   specs:
     bigdecimal (4.1.2)
+    diff-lcs (1.6.2)
     elelem (0.11.0)
       erb (~> 6.0)
       forwardable (~> 1.4)
@@ -30,13 +34,15 @@ GEM
       prism (>= 1.3.0)
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
-    json (3.0.0)
+    json (3.0.1)
     json_schemer (2.5.0)
       bigdecimal
       hana (~> 1.3)
       regexp_parser (~> 2.0)
       simpleidn (~> 0.2)
     logger (1.7.0)
+    net-http (0.9.1)
+      uri (>= 0.11.1)
     open3 (0.2.1)
     optparse (0.8.1)
     pathname (0.5.0)
@@ -57,10 +63,24 @@ GEM
     regexp_parser (2.12.0)
     reline (0.7.0)
       io-console (~> 0.5)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.6)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
     shellwords (0.2.2)
     simpleidn (0.3.0)
     tsort (0.2.0)
     uri (1.1.1)
+    yaml (0.4.0)
 
 PLATFORMS
   ruby
@@ -70,20 +90,23 @@ DEPENDENCIES
   elelem-skills!
   irb
   rake (~> 13.0)
+  rspec (~> 3.0)
 
 CHECKSUMS
   bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
   bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
   elelem (0.11.0) sha256=552cafb092320e3896b8e07572d322fe2b94f0f10d965bf7b993b74517a6f744
-  elelem-skills (0.2.0)
+  elelem-skills (0.3.0)
   erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
   forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
   hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
   io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
   irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
-  json (3.0.0) sha256=1ff82a28c05c5cc7b646f3e3a3ac710e4ae2aa933690cba12f1f650b0ecdeb99
+  json (3.0.1) sha256=c80f74a3570c3a310cd41c526904e7ef8363abdece4432f31fe97d812bc4a39e
   json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
   logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
   open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
   optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
   pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
@@ -95,10 +118,16 @@ CHECKSUMS
   rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
   regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
   reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
   shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
   simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
   tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
   uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+  yaml (0.4.0) sha256=240e69d1e6ce3584d6085978719a0faa6218ae426e034d8f9b02fb54d3471942
 
 BUNDLED WITH
   4.0.20
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,3 @@
+# elelem-skills
+
+Loads [agent skills](https://github.com/anthropics/skills) files under an `.agents/skills/` directory and exposes them to an `elelem` agent.