Commit 6a66dd9
Changed files (19)
exe
lib
elelem
skills
spec
elelem
exe/elelem-skills
@@ -0,0 +1,6 @@
+#!/usr/bin/env -S ruby -W0
+# frozen_string_literal: true
+
+require "elelem/skills"
+
+Elelem::Skills::CLI.new(ARGV).run
lib/elelem/skills/builtin/scrum/build.md
@@ -1,29 +0,0 @@
-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
@@ -1,32 +0,0 @@
-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
@@ -1,67 +0,0 @@
-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/builtin/scrum/review.md
@@ -1,43 +0,0 @@
-Review code changes against a story's acceptance criteria.
-
-# Role
-- 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 .agents/backlog/
-2. **Diff** - Run `git diff` to see changes
-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 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
-- [ ] Code is readable and minimal
-
-# Output Format
-## Story: <story file name>
-
-### Acceptance Criteria
-- [x] <criterion> - PASS
-- [ ] <criterion> - FAIL: <reason>
-
-### Issues
-#### [severity] filename:line - title
-<description and suggestion>
-
-Severity: critical | warning | nit
-
-### Verdict
-<approve | request changes | needs discussion>
-
-# Guidelines
-- Be specific: cite file:line
-- Suggest fixes, not just problems
-- Distinguish blocking issues from non-blocking ones
lib/elelem/skills/builtin/scrum/SKILL.md
@@ -1,32 +0,0 @@
----
-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
@@ -1,38 +0,0 @@
-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/catalog.rb
@@ -3,11 +3,9 @@
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
@@ -17,7 +15,8 @@ module Elelem
@remote_skills = remote_skills
end
- def all
+ def all(reload: false)
+ @all = nil if reload
@all ||= discover
end
@@ -36,10 +35,8 @@ module Elelem
def discover
seen = {}
- @load_paths.each do |path|
- scan_one_level(path, seen)
- scan_remote(seen) if path == GEM_SKILLS && @remote_skills
- end
+ scan_remote(seen) if @remote_skills
+ @load_paths.each { |path| scan_one_level(path, seen) }
seen.values
end
lib/elelem/skills/cli.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Skills
+ class CLI
+ COMMANDS = %w[fetch list help].freeze
+
+ def initialize(args)
+ @args = args
+ end
+
+ def run
+ command = @args.shift || "help"
+ abort "Unknown command: #{command}\n\n#{usage}" unless COMMANDS.include?(command)
+ send(command)
+ end
+
+ private
+
+ def fetch
+ origin = @args.shift
+ abort "Usage: elelem-skills fetch <origin>" unless origin
+
+ names = Fetcher.new.fetch(origin)
+ return puts "No skills installed from #{origin}." if names.empty?
+
+ puts "Installed: #{names.join(", ")}"
+ rescue Fetcher::FetchError => e
+ abort "elelem-skills: #{e.message}"
+ end
+
+ def list
+ catalog = Catalog.new
+ return puts "No skills found." if catalog.all.empty?
+
+ catalog.all.each { |skill| puts skill.summary }
+ end
+
+ def help
+ puts usage
+ end
+
+ def usage
+ <<~USAGE
+ Usage: elelem-skills <command> [args]
+
+ Commands:
+ fetch <origin> Install skills from <origin>/.well-known/agent-skills/index.json
+ list List all discovered skills
+ help Show this help
+ USAGE
+ end
+ end
+ end
+end
lib/elelem/skills/fetcher.rb
@@ -13,9 +13,10 @@ module Elelem
FetchError = Class.new(StandardError)
- def initialize(install_dir: Catalog::REMOTE_SKILLS, http: Net::HTTP)
+ def initialize(install_dir: Catalog::REMOTE_SKILLS, http: Net::HTTP, lockfile: nil)
@install_dir = install_dir
@http = http
+ @lockfile = lockfile || Lockfile.new(File.join(install_dir, ".skill-lock.json"))
end
# Fetches https://<origin>/.well-known/agent-skills/index.json
@@ -28,8 +29,9 @@ module Elelem
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) }
+ host = sanitize(base.host)
+ host_dir = File.join(@install_dir, host)
+ (index["skills"] || []).filter_map { |entry| install(entry, index_url, host, host_dir) }
end
private
@@ -43,7 +45,7 @@ module Elelem
host.to_s.downcase.gsub(/[^a-z0-9.-]/, "_")
end
- def install(entry, index_url, host_dir)
+ def install(entry, index_url, host, host_dir)
name, type, url, digest = entry.values_at("name", "type", "url", "digest")
unless type == "skill-md"
@@ -51,12 +53,14 @@ module Elelem
return nil
end
- body = get_raw(URI.join(index_url, url))
+ skill_url = URI.join(index_url, url)
+ body = get_raw(skill_url)
verify!(name, body, digest)
dir = File.join(host_dir, sanitize(name))
FileUtils.mkdir_p(dir)
File.write(File.join(dir, "SKILL.md"), body)
+ @lockfile.record(host: host, name: name, url: skill_url.to_s, digest: digest)
name
end
lib/elelem/skills/lockfile.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "fileutils"
+require "json"
+require "time"
+
+module Elelem
+ module Skills
+ class Lockfile
+ VERSION = 1
+
+ def initialize(path)
+ @path = path
+ end
+
+ def record(host:, name:, url:, digest:)
+ data = read
+ key = "#{host}/#{name}"
+ now = Time.now.utc.iso8601
+ installed_at = data.dig("skills", key, "installedAt") || now
+
+ data["skills"][key] = {
+ "host" => host,
+ "name" => name,
+ "url" => url,
+ "digest" => digest,
+ "installedAt" => installed_at,
+ "updatedAt" => now
+ }
+
+ write(data)
+ end
+
+ def read
+ return { "version" => VERSION, "skills" => {} } unless File.exist?(@path)
+
+ data = JSON.parse(File.read(@path))
+ data["skills"] ||= {}
+ data
+ rescue JSON::ParserError
+ { "version" => VERSION, "skills" => {} }
+ end
+
+ private
+
+ def write(data)
+ FileUtils.mkdir_p(File.dirname(@path))
+ File.write(@path, JSON.pretty_generate(data))
+ end
+ end
+ end
+end
lib/elelem/skills/setup.rb
@@ -15,8 +15,6 @@ module Elelem
end
def call
- return if @catalog.all.empty?
-
register_tool
register_command
end
@@ -50,7 +48,8 @@ module Elelem
skill = catalog.find(arg)
skill ? agent.output.say(skill.body) : agent.output.say("Unknown skill: #{arg}")
else
- agent.output.say(catalog.index)
+ list = catalog.all.map { |skill| "- **#{skill.name}**: #{truncate(skill.description, SUMMARY_LIMIT)}" }.join("\n")
+ agent.output.say(list.empty? ? "No skills found." : list, as: :markdown)
end
end
end
@@ -62,10 +61,19 @@ module Elelem
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).")
+ return agent.output.say("No skills installed from #{origin}.") if names.empty?
+
+ @catalog.all(reload: true)
+ agent.output.say("Installed: #{names.join(", ")}\nAvailable via /skills and the skill tool now. Restart elelem to refresh the skill tool's description with the new catalog.")
rescue Fetcher::FetchError => e
agent.output.say(e.message, as: :error)
end
+
+ def truncate(text, limit)
+ return text if text.length <= limit
+
+ "#{text[0, limit]}..."
+ end
end
end
end
lib/elelem/skills.rb
@@ -7,8 +7,10 @@ require_relative "skills/version"
require_relative "skills/skill"
require_relative "skills/skill_file"
require_relative "skills/catalog"
+require_relative "skills/lockfile"
require_relative "skills/fetcher"
require_relative "skills/setup"
+require_relative "skills/cli"
Elelem.configure do |config|
config.setup(:skills) do |agent|
spec/elelem/skills/catalog_spec.rb
@@ -35,20 +35,6 @@ RSpec.describe Elelem::Skills::Catalog do
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|
@@ -62,7 +48,7 @@ RSpec.describe Elelem::Skills::Catalog do
body
SKILL
- catalog = described_class.new(load_paths: [described_class::GEM_SKILLS], remote_skills: remote_root)
+ catalog = described_class.new(load_paths: [], remote_skills: remote_root)
expect(catalog.find("wrangler")&.description).to eq("fetched from example.com")
end
@@ -80,7 +66,7 @@ RSpec.describe Elelem::Skills::Catalog do
body
SKILL
- catalog = described_class.new(load_paths: [described_class::GEM_SKILLS], remote_skills: remote_root)
+ catalog = described_class.new(load_paths: [], remote_skills: remote_root)
expect(catalog.find("nested-imposter")).to be_nil
end
@@ -98,14 +84,14 @@ RSpec.describe Elelem::Skills::Catalog do
body
SKILL
- catalog = described_class.new(load_paths: [described_class::GEM_SKILLS, fixtures], remote_skills: remote_root)
+ catalog = described_class.new(load_paths: [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)
+ catalog = described_class.new(load_paths: [], remote_skills: nil)
expect { catalog.all }.not_to raise_error
end
spec/elelem/skills/cli_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::CLI do
+ def capture_stdout
+ original = $stdout
+ $stdout = StringIO.new
+ yield
+ $stdout.string
+ ensure
+ $stdout = original
+ end
+
+ describe "#run" do
+ it "fetches skills from an origin and prints the installed names" do
+ fetcher = instance_double(Elelem::Skills::Fetcher, fetch: ["example"])
+ allow(Elelem::Skills::Fetcher).to receive(:new).and_return(fetcher)
+
+ output = capture_stdout { described_class.new(["fetch", "example.com"]).run }
+
+ expect(fetcher).to have_received(:fetch).with("example.com")
+ expect(output).to include("Installed: example")
+ end
+
+ it "reports when nothing was installed" do
+ fetcher = instance_double(Elelem::Skills::Fetcher, fetch: [])
+ allow(Elelem::Skills::Fetcher).to receive(:new).and_return(fetcher)
+
+ output = capture_stdout { described_class.new(["fetch", "example.com"]).run }
+
+ expect(output).to include("No skills installed from example.com")
+ end
+
+ it "aborts with the fetch error message on failure" do
+ fetcher = instance_double(Elelem::Skills::Fetcher)
+ allow(fetcher).to receive(:fetch).and_raise(Elelem::Skills::Fetcher::FetchError, "boom")
+ allow(Elelem::Skills::Fetcher).to receive(:new).and_return(fetcher)
+
+ expect { described_class.new(["fetch", "example.com"]).run }.to raise_error(SystemExit)
+ end
+
+ it "requires an origin for fetch" do
+ expect { described_class.new(["fetch"]).run }.to raise_error(SystemExit)
+ end
+
+ it "lists discovered skills" do
+ fixtures = File.expand_path("../../fixtures/skills", __dir__)
+ catalog = instance_double(Elelem::Skills::Catalog, all: [Elelem::Skills::Skill.new("tdd", "Test-driven development.", fixtures, "body")])
+ allow(Elelem::Skills::Catalog).to receive(:new).and_return(catalog)
+
+ output = capture_stdout { described_class.new(["list"]).run }
+
+ expect(output).to include("tdd: Test-driven development.")
+ end
+
+ it "prints usage for an unknown command" do
+ expect { described_class.new(["bogus"]).run }.to raise_error(SystemExit)
+ end
+
+ it "prints usage with no args" do
+ output = capture_stdout { described_class.new([]).run }
+
+ expect(output).to include("Usage: elelem-skills")
+ end
+ end
+end
spec/elelem/skills/fetcher_spec.rb
@@ -3,6 +3,7 @@
RSpec.describe Elelem::Skills::Fetcher do
let(:install_dir) { Dir.mktmpdir }
let(:http) { class_double(Net::HTTP) }
+ let(:lockfile) { Elelem::Skills::Lockfile.new(File.join(install_dir, ".skill-lock.json")) }
let(:fetcher) { described_class.new(install_dir: install_dir, http: http) }
after { FileUtils.rm_rf(install_dir) }
@@ -49,6 +50,24 @@ RSpec.describe Elelem::Skills::Fetcher do
expect(File.read(File.join(install_dir, "example.com", "example", "SKILL.md"))).to eq(skill_body)
end
+ it "records the install in the lockfile, keyed by host/name" do
+ 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))
+
+ fetcher.fetch("example.com")
+ entry = lockfile.read.dig("skills", "example.com/example")
+
+ expect(entry["host"]).to eq("example.com")
+ expect(entry["name"]).to eq("example")
+ expect(entry["url"]).to eq("https://example.com/.well-known/agent-skills/example/SKILL.md")
+ expect(entry["digest"]).to eq(digest)
+ expect(entry["installedAt"]).not_to be_nil
+ 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"))
spec/elelem/skills/lockfile_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Skills::Lockfile do
+ let(:dir) { Dir.mktmpdir }
+ let(:path) { File.join(dir, ".skill-lock.json") }
+ let(:lockfile) { described_class.new(path) }
+
+ after { FileUtils.rm_rf(dir) }
+
+ describe "#read" do
+ it "returns an empty skills map when the file does not exist" do
+ expect(lockfile.read).to eq({ "version" => 1, "skills" => {} })
+ end
+ end
+
+ describe "#record" do
+ it "writes a new entry keyed by host/name" do
+ lockfile.record(host: "example.com", name: "example", url: "https://example.com/example/SKILL.md", digest: "sha256:abc")
+
+ entry = lockfile.read.dig("skills", "example.com/example")
+ expect(entry["host"]).to eq("example.com")
+ expect(entry["name"]).to eq("example")
+ expect(entry["url"]).to eq("https://example.com/example/SKILL.md")
+ expect(entry["digest"]).to eq("sha256:abc")
+ expect(entry["installedAt"]).to eq(entry["updatedAt"])
+ end
+
+ it "keeps the original installedAt but bumps updatedAt on re-install" do
+ lockfile.record(host: "example.com", name: "example", url: "https://example.com/example/SKILL.md", digest: "sha256:abc")
+ first_installed_at = lockfile.read.dig("skills", "example.com/example", "installedAt")
+
+ lockfile.record(host: "example.com", name: "example", url: "https://example.com/example/SKILL.md", digest: "sha256:def")
+ entry = lockfile.read.dig("skills", "example.com/example")
+
+ expect(entry["installedAt"]).to eq(first_installed_at)
+ expect(entry["digest"]).to eq("sha256:def")
+ end
+
+ it "does not clobber an entry for the same skill name from a different host" do
+ lockfile.record(host: "example.com", name: "example", url: "https://example.com/example/SKILL.md", digest: "sha256:abc")
+ lockfile.record(host: "other.example", name: "example", url: "https://other.example/example/SKILL.md", digest: "sha256:def")
+
+ expect(lockfile.read["skills"].keys).to contain_exactly("example.com/example", "other.example/example")
+ end
+ end
+end
spec/elelem/skills/setup_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe "elelem-skills setup" do
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.")
+ expect(output).to receive(:say).with("- **tdd**: Test-driven development. Use when the user wants tests written first.", as: :markdown)
agent.commands.run("skills", nil)
end
@@ -63,4 +63,60 @@ RSpec.describe "elelem-skills setup" do
agent.commands.run("skills", "tdd")
end
end
+
+ describe "with an empty catalog" do
+ around do |example|
+ original = Elelem::Skills::Catalog::LOAD_PATHS
+ Elelem::Skills::Catalog.send(:remove_const, :LOAD_PATHS)
+ Elelem::Skills::Catalog.const_set(:LOAD_PATHS, [].freeze)
+ example.run
+ ensure
+ Elelem::Skills::Catalog.send(:remove_const, :LOAD_PATHS)
+ Elelem::Skills::Catalog.const_set(:LOAD_PATHS, original)
+ end
+
+ it "still registers the skill tool and /skills command so remote skills can be fetched" do
+ agent = Elelem::Agent.new(Elelem::StubProvider.new)
+ Elelem::Config.default.instance_variable_get(:@registration).setups.fetch("skills").call(agent)
+
+ expect(agent.toolbox.tool_for("skill")).not_to be_nil
+ expect(agent.commands.names).to include("/skills")
+ end
+
+ it "sees a skill fetched via /skills fetch without re-running setup" do
+ remote_root = Dir.mktmpdir
+ begin
+ original_remote = Elelem::Skills::Catalog::REMOTE_SKILLS
+ Elelem::Skills::Catalog.send(:remove_const, :REMOTE_SKILLS)
+ Elelem::Skills::Catalog.const_set(:REMOTE_SKILLS, remote_root)
+
+ output = instance_double(Elelem::Output)
+ allow(output).to receive(:say)
+ agent = Elelem::Agent.new(Elelem::StubProvider.new, output: output)
+ Elelem::Config.default.instance_variable_get(:@registration).setups.fetch("skills").call(agent)
+
+ fetcher = instance_double(Elelem::Skills::Fetcher, fetch: ["wrangler"])
+ allow(Elelem::Skills::Fetcher).to receive(:new).and_return(fetcher)
+ 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
+
+ agent.commands.run("skills", "fetch example.com")
+
+ expect(output).to have_received(:say).with(/Installed: wrangler/)
+ result = agent.toolbox.run("skill", { "name" => "wrangler" })
+ expect(result).to be_ok
+ ensure
+ FileUtils.rm_rf(remote_root)
+ Elelem::Skills::Catalog.send(:remove_const, :REMOTE_SKILLS)
+ Elelem::Skills::Catalog.const_set(:REMOTE_SKILLS, original_remote)
+ end
+ end
+ end
end
elelem-skills.gemspec
@@ -24,6 +24,8 @@ Gem::Specification.new do |spec|
end
end
spec.require_paths = ["lib"]
+ spec.bindir = "exe"
+ spec.executables = ["elelem-skills"]
spec.add_dependency "elelem", "~> 0.11"
spec.add_dependency "json", "~> 3.0"