1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
|
-- 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: <XDG_DATA_HOME>/panto/agent/skills
-- user: <XDG_CONFIG_HOME>/panto/skills
-- ~/.agent/skills
-- project: <cwd>/.panto/skills
-- <cwd>/.agent/skills
--
-- The `.agent/skills` variants outrank their own layer's normal directory
-- but nothing above it (`~/.agent/skills` does not outrank
-- `<cwd>/.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 = <resolved skill directory>, 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
|