From 518c01a9f55d0eed2752706044913739d5b928ea Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 21:17:00 +0000 Subject: [PATCH] repl: user-defined skills loader (closes #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 = "", -- must match [%w_-]+ description = "", -- 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) --- repl.lua | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/repl.lua b/repl.lua index 732d2a7..521c144 100644 --- a/repl.lua +++ b/repl.lua @@ -136,6 +136,7 @@ Meta commands: :route classes show current class → model mapping :route check report which class 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 = "", description = "...", run = function(args, h) end } + -- and gets registered as a meta command :. 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())