repl: user-defined skills loader (closes #2)

PHASE0.md §5.2 froze the meta-command set at compile time. Skills let
the user package repeatable workflows (project queries, prompt
templates, audit routines) without forking aish.

Discovery: scan ~/.config/aish/skills/*.lua at startup (or whatever
$AISH_SKILLS_DIR points at — used both by users with non-XDG layouts
and by CI). Each module exports:

    return {
        name        = "<meta-cmd-name>",     -- must match [%w_-]+
        description = "<one-line>",          -- shown by :skills
        run         = function(args, h) ... end,
    }

Helpers passed to run():
    h.ask(text)   — same path as :ask (with @path expansion)
    h.status(s)   — emit "[aish] s"
    h.exec(cmd)   — run a shell command (subject to plan_mode, hooks)
    h.model()     — current active model name
    h.ctx         — raw Context object (advanced)
    h.config      — the loaded config table

Validation rejects modules that miss name/run, use whitespace in the
name, or collide with an existing meta command (built-in or earlier
skill). Each rejection emits a status line so the user sees why a
skill didn't appear.

New meta command :skills lists what's loaded (sorted, with description).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:17:00 +00:00
parent fb15f7a690
commit 518c01a9f5
+68
View File
@@ -136,6 +136,7 @@ Meta commands:
:route classes show current class → model mapping
:route check <text> report which class <text> would route to (debug)
:fallback on/off toggle cloud retry when local transport fails
:skills list user-defined skills loaded from ~/.config/aish/skills/
:help this message
]]
@@ -1340,6 +1341,73 @@ function M.run(config)
help = function() io.write(HELP) end,
}
-- Issue #2: user-defined skills loader. Scan ~/.config/aish/skills/
-- (or $AISH_SKILLS_DIR) for *.lua modules. Each module returns
-- { name = "<meta-cmd-name>", description = "...", run = function(args, h) end }
-- and gets registered as a meta command :<name>. Helpers passed to run():
-- h.ask(text) -- send text as an ai-kind prompt (same path as :ask)
-- h.status(s) -- emit a [aish] status line
-- h.exec(cmd) -- run a shell command (subject to plan/hooks)
-- h.model() -- current active model name
-- h.ctx -- raw context object (advanced)
-- h.config -- raw config table
local skills = {} -- { [name] = {description=, run=} }
local function load_skills()
local dir = os.getenv("AISH_SKILLS_DIR")
or ((os.getenv("HOME") or ".") .. "/.config/aish/skills")
local pipe = io.popen(("ls -1 %q/*.lua 2>/dev/null"):format(dir))
if not pipe then return end
for path in pipe:lines() do
local ok, mod = pcall(dofile, path)
if not ok then
renderer.status(("skill load failed: %s: %s")
:format(path, tostring(mod)))
elseif type(mod) ~= "table"
or type(mod.name) ~= "string"
or type(mod.run) ~= "function"
or not mod.name:match("^[%w_-]+$")
then
renderer.status(("skill %s: invalid module (need {name, run})")
:format(path))
elseif meta[mod.name] or skills[mod.name] then
renderer.status(("skill %s: name '%s' already in use")
:format(path, mod.name))
else
skills[mod.name] = {
description = mod.description or "",
run = mod.run,
}
local helpers = {
ask = function(t) ask_ai(expand_mentions(t or "", renderer.status)) end,
status = renderer.status,
exec = run_shell,
model = function() return active_name end,
ctx = ctx,
config = config,
}
meta[mod.name] = function(args)
local sk_ok, sk_err = pcall(mod.run, args or "", helpers)
if not sk_ok then
renderer.status(("skill %s failed: %s")
:format(mod.name, tostring(sk_err)))
end
end
end
end
pipe:close()
end
meta.skills = function()
local names = {}
for n, _ in pairs(skills) do names[#names + 1] = n end
table.sort(names)
if #names == 0 then renderer.status("(no skills loaded)"); return end
renderer.status(("skills (%d):"):format(#names))
for _, n in ipairs(names) do
io.write((" :%-16s %s\n"):format(n, skills[n].description))
end
end
load_skills()
-- Main loop.
while true do
local line = rl.readline(prompt())