Comparing changes
v0.2.0
→
v0.3.0
22 commits
32 files changed
Commits
e229268
feat: add rfc skill (draft/style/finalize/feedback), adapted from lemieux/rfc-skills
2026-09-08 05:42:39
18ba6a0
feat: convert stale ERB prompts into bundled skills, load them by default
2026-09-08 05:09:51
5a998a1
refactor: split Skill from parsing, unify the summary index, tighten setup
2026-09-08 05:01:00
Changed files (32)
bin
lib
elelem
skills
prompts
spec
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.