-- agent.skill: on-demand Agent Skills (agentskills.io) via an -- `agent.skill` tool. -- -- Discovery happens once, at activation. Skill directories are searched -- lowest priority first: -- -- base: /panto/agent/skills -- user: /panto/skills -- ~/.agent/skills -- project: /.panto/skills -- /.agent/skills -- -- The `.agent/skills` variants outrank their own layer's normal directory -- but nothing above it (`~/.agent/skills` does not outrank -- `/.panto/skills`). A skill is any subdirectory containing a -- SKILL.md with YAML frontmatter; the frontmatter `name` (not the -- directory name) is the skill's identity, and a later directory's skill -- shadows an earlier one with the same name. -- -- The surviving names and descriptions are baked into one registered -- tool: the description lists every skill, and the input schema -- constrains `skill` to the discovered names as an enum. Calling the tool -- re-reads the skill's SKILL.md (so edits mid-session are picked up), -- strips the frontmatter, and returns the instructions behind a short -- preamble telling the model to resolve the skill's relative paths -- (scripts/, references/, assets/) against the skill's real directory. -- -- Frontmatter parsing is deliberately minimal: top-level `key: value` -- lines only (quoted values unquoted). Nested mappings like `metadata:` -- are ignored, which is all this extension needs (`name`, `description`). local uv = require("luv") local panto = require("panto") local M = { name = "agent.skill", skills = {} } -- Coroutine-aware libuv await (same shape as agent.rules): arm the -- callback form of a uv request, yield, resume with its results. Panto -- runs every user-Lua entrypoint (activate, event handlers, tools) -- inside a coroutine with the uv loop driven, so this just works. local function await(label, arm) local co = assert(coroutine.running(), label .. ": await must run inside a coroutine") local res arm(function(...) res = table.pack(...) local ok, err = coroutine.resume(co) if not ok then error(err, 0) end end) coroutine.yield() return table.unpack(res, 1, res.n) end local function fs_realpath(p) return await("fs_realpath", function(r) uv.fs_realpath(p, r) end) end local function fs_open(p, flags, mode) return await("fs_open", function(r) uv.fs_open(p, flags, mode, r) end) end local function fs_fstat(fd) return await("fs_fstat", function(r) uv.fs_fstat(fd, r) end) end local function fs_read(fd, size, off) return await("fs_read", function(r) uv.fs_read(fd, size, off, r) end) end local function fs_close(fd) return await("fs_close", function(r) uv.fs_close(fd, r) end) end local function fs_scandir(p) return await("fs_scandir", function(r) uv.fs_scandir(p, r) end) end --- Read a whole file (nil on any failure). local function read_all(path) local oerr, fd = fs_open(path, "r", 0) if not fd then return nil, oerr end local serr, stat = fs_fstat(fd) local rerr, text if stat then rerr, text = fs_read(fd, stat.size, 0) else rerr = serr end fs_close(fd) if not text then return nil, rerr end return text end --- Entry names of a directory ({} when it doesn't exist / isn't readable). local function list_dir(dir) local _, req = fs_scandir(dir) if not req then return {} end local names = {} while true do local name = uv.fs_scandir_next(req) if not name then break end names[#names + 1] = name end return names end --- Split SKILL.md into (meta, body). Returns nil unless the file opens --- with a `---` frontmatter block containing a non-empty `name`. function M.parse_skill_md(text) local fm, body = text:match("^%-%-%-\r?\n(.-)\r?\n%-%-%-%s*\r?\n?(.*)$") if not fm then return nil end local meta = {} for line in fm:gmatch("[^\r\n]+") do local k, v = line:match("^([%w%-]+):%s*(.-)%s*$") if k then meta[k] = v:match('^"(.*)"$') or v:match("^'(.*)'$") or v end end if not meta.name or meta.name == "" then return nil end return meta, body end --- The skill directories to search, lowest priority first. `cwd` is the --- project root (falls back to the process cwd). function M.skill_dirs(cwd) local dirs = {} local home = os.getenv("HOME") local data = os.getenv("XDG_DATA_HOME") or (home and home .. "/.local/share") if data then dirs[#dirs + 1] = data .. "/panto/agent/skills" end local cfg = os.getenv("XDG_CONFIG_HOME") or (home and home .. "/.config") if cfg then dirs[#dirs + 1] = cfg .. "/panto/skills" end if home then dirs[#dirs + 1] = home .. "/.agent/skills" end local root = cwd or uv.cwd() dirs[#dirs + 1] = root .. "/.panto/skills" dirs[#dirs + 1] = root .. "/.agent/skills" return dirs end --- Scan `dirs` (lowest priority first) and return a map of skill name -> --- { dir = , description }. Same-named skills --- from later directories shadow earlier ones. Awaits, so it must run --- inside a panto entrypoint (or an equivalent harness). function M.discover(dirs) local skills = {} for _, dir in ipairs(dirs) do for _, entry in ipairs(list_dir(dir)) do local _, real = fs_realpath(dir .. "/" .. entry) if real then local text = read_all(real .. "/SKILL.md") local meta = text and M.parse_skill_md(text) if meta then skills[meta.name] = { dir = real, description = meta.description or "" } end end end end return skills end --- Tool handler: return the named skill's SKILL.md instructions --- (frontmatter stripped) behind a path-resolution preamble. Unknown --- names come back as a plain-text error the model can react to. function M.on_call(input) local s = type(input) == "table" and type(input.skill) == "string" and M.skills[input.skill] if not s then local names = {} for name in pairs(M.skills) do names[#names + 1] = name end table.sort(names) return "unknown skill: " .. tostring(type(input) == "table" and input.skill or input) .. ". Available skills: " .. table.concat(names, ", ") end local text = read_all(s.dir .. "/SKILL.md") if not text then return "failed to read " .. s.dir .. "/SKILL.md" end local meta, body = M.parse_skill_md(text) if not meta then body = text end return "Skill '" .. input.skill .. "', loaded from " .. s.dir .. "/SKILL.md.\n" .. "Any relative path these instructions reference (scripts/, references/, assets/, ...)" .. " lives under that directory: resolve it against " .. s.dir .. "/.\n\n" .. body end function M.activate() M.skills = M.discover(M.skill_dirs(uv.cwd())) local names = {} for name in pairs(M.skills) do names[#names + 1] = name end if #names == 0 then return end -- nothing to offer: don't register the tool table.sort(names) local lines = {} for _, n in ipairs(names) do lines[#lines + 1] = "- " .. n .. ": " .. M.skills[n].description end panto.ext.register_tool { name = "agent.skill", description = "Load a skill: expert instructions for a specific kind of task." .. " Call this before starting work a skill covers.\nAvailable skills:\n" .. table.concat(lines, "\n"), schema = { type = "object", properties = { skill = { type = "string", enum = names, description = "Name of the skill to load.", }, }, required = { "skill" }, }, handler = M.on_call, } end return M