From 442888c6c306cfff0708c3d4bbbeda685cec2e75 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 2 Jun 2026 19:41:56 -0600 Subject: lua /commands --- docs/todos.md | 7 +- examples/extensions/greet.lua | 16 ++++ src/command.zig | 86 +++++++++++++++++- src/lua_bridge.zig | 162 ++++++++++++++++++++++++++++++++- src/lua_runtime.zig | 204 ++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 23 ++++- 6 files changed, 487 insertions(+), 11 deletions(-) create mode 100644 examples/extensions/greet.lua diff --git a/docs/todos.md b/docs/todos.md index ba14677..55a97fc 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -3,8 +3,8 @@ - [x] user-provided system prompt (design: docs/archive/system-prompt.md) - [ ] polish zig API - [ ] C ABI -- [ ] Agent compaction with custom compaction prompts -- [ ] Agent auto-compaction +- [x] Agent compaction with custom compaction prompts +- [x] Agent auto-compaction - [ ] image upload support - [ ] google gemini native provider - [ ] openai responses API native provider @@ -20,8 +20,7 @@ - [ ] fuzzy typeahead model selector - [ ] tab completion: filenames in cwd, slash commands - [ ] rendering system for screen components -- [ ] lua slash commands -- [ ] markdown/prompt slash commands +- [x] lua slash commands - [ ] additional lua extension API - [ ] Agent objects - [ ] Conversation objects diff --git a/examples/extensions/greet.lua b/examples/extensions/greet.lua new file mode 100644 index 0000000..5ec406f --- /dev/null +++ b/examples/extensions/greet.lua @@ -0,0 +1,16 @@ +-- A trivial Lua slash command that greets the user. +-- Drop this under .panto/extensions/ (project) or your user config's +-- extensions/ dir, then type `/greet` or `/greet ` in the REPL. +-- +-- Slash-command handlers run synchronously and act by side effect (here, +-- writing to stdout). `args` is the trimmed text after the command name; +-- it is an empty string when none was given. The return value is ignored. + +panto.register_command { + name = "greet", + description = "Print a greeting. Optional args name who to greet.", + handler = function(args) + local who = args ~= "" and args or "world" + io.write("\n[greet] hello, " .. who .. "!\n") + end, +} diff --git a/src/command.zig b/src/command.zig index f10592f..26acc7a 100644 --- a/src/command.zig +++ b/src/command.zig @@ -17,8 +17,11 @@ //! model regardless. //! //! Builtin commands (e.g. `/compact`) register themselves from their own -//! modules; future Lua extensions will append commands here too via a -//! `panto.register_command` bridge. +//! modules. Lua extensions append commands here too: a script's call to +//! `panto.register_command { name, description, handler }` is harvested by +//! `lua_runtime.zig`, and `main.zig` registers each via `registerLua`. +//! Such commands carry a `lua_ref`; `dispatch` routes them back into the +//! Lua runtime instead of calling a native `run` function. const std = @import("std"); const panto = @import("panto"); @@ -46,6 +49,12 @@ pub const Context = struct { /// Banner identity strings used when stamping persisted entries. provider_name: []const u8, model_name: []const u8, + + /// The long-lived Lua runtime, if extensions are active. Used by + /// Lua-defined commands (see `lua_command.zig`) to invoke their + /// handler. `null` when no extensions loaded; Lua commands are only + /// registered when it is non-null, so handlers may assume it is set. + lua_rt: ?*anyopaque = null, }; /// A single slash command. @@ -57,6 +66,11 @@ pub const Command = struct { /// Handler. `args` is the remainder of the line after the command /// name, trimmed of surrounding whitespace (empty string if none). run: *const fn (args: []const u8, ctx: *Context) anyerror!void, + /// For commands backed by a Lua handler, the `luaL_ref` of that + /// handler in the runtime's registry. `null` for native Zig + /// commands. The shared Lua thunk reads this to know which handler + /// to invoke (via `ctx.lua_rt`). + lua_ref: ?c_int = null, }; pub const Error = error{ @@ -64,6 +78,13 @@ pub const Error = error{ CommandNotFound, }; +/// Placeholder `run` for Lua-backed commands. Dispatch intercepts them +/// via `Command.lua_ref` before `run` is ever called, so reaching here +/// indicates a wiring bug. +fn luaPlaceholderRun(_: []const u8, _: *Context) anyerror!void { + return error.LuaCommandMisrouted; +} + /// A registry of slash commands. Owns its backing list; call `deinit`. pub const Registry = struct { allocator: std.mem.Allocator, @@ -84,6 +105,23 @@ pub const Registry = struct { try self.commands.append(self.allocator, command); } + /// Register a Lua-backed command. `lua_ref` is the handler's registry + /// ref in the active `LuaRuntime`; dispatch routes to it via + /// `ctx.lua_rt`. The `run` field is a never-called placeholder. + pub fn registerLua( + self: *Registry, + name: []const u8, + description: []const u8, + lua_ref: c_int, + ) !void { + try self.register(.{ + .name = name, + .description = description, + .run = luaPlaceholderRun, + .lua_ref = lua_ref, + }); + } + /// Find a command by name (without leading `/`). Null if absent. pub fn find(self: *const Registry, name: []const u8) ?*const Command { for (self.commands.items) |*cmd| { @@ -114,10 +152,38 @@ pub const Registry = struct { const args = std.mem.trim(u8, body[name_end..], " \t"); const cmd = self.find(name) orelse return Error.CommandNotFound; + if (cmd.lua_ref) |ref| { + try runLuaCommand(ref, args, ctx); + return; + } try cmd.run(args, ctx); } }; +/// Invoke a Lua-backed command. Resolves `ctx.lua_rt` back to a +/// `LuaRuntime`, runs the handler synchronously, and prints any Lua +/// error to the REPL writer (the handler itself is responsible for +/// normal output). Kept out of `lua_runtime.zig` so that module stays +/// free of REPL/`Context` concerns; the runtime only exposes +/// `runCommand`. +fn runLuaCommand(ref: c_int, args: []const u8, ctx: *Context) !void { + const lua_runtime = @import("lua_runtime.zig"); + const rt: *lua_runtime.LuaRuntime = @ptrCast(@alignCast(ctx.lua_rt.?)); + + var err_buf: [4096]u8 = undefined; + var err_len: usize = 0; + rt.runCommand(ref, args, &err_buf, &err_len) catch |err| switch (err) { + error.LuaHandlerCrashed => { + try ctx.stdout.print( + "\n[command error: {s}]\n> ", + .{err_buf[0..err_len]}, + ); + try ctx.stdout_file.flush(); + }, + else => return err, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -151,6 +217,22 @@ test "register, find, list" { })); } +test "registerLua records name, description, and ref; collides with builtins" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try reg.register(.{ .name = "compact", .description = "d", .run = testHandler }); + try reg.registerLua("hello", "Lua greeting", 7); + + const cmd = reg.find("hello").?; + try testing.expectEqualStrings("Lua greeting", cmd.description); + try testing.expectEqual(@as(?c_int, 7), cmd.lua_ref); + // Native command has no lua_ref. + try testing.expectEqual(@as(?c_int, null), reg.find("compact").?.lua_ref); + // Name collision with a builtin is rejected. + try testing.expectError(error.DuplicateCommand, reg.registerLua("compact", "x", 1)); +} + test "dispatch splits name and args" { var reg = Registry.init(testing.allocator); defer reg.deinit(); diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index eca5661..cb57ade 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -77,6 +77,12 @@ pub const BridgeError = error{ /// needs to harvest entries. pub var registrations_key: u8 = 0; +/// The key under which we stash the *command* registrations table in +/// `LUA_REGISTRYINDEX`. Distinct from `registrations_key` so tool and +/// slash-command registrations never collide. Holds an array of records +/// shaped `{ name=, description=, handler= }`. +pub var command_registrations_key: u8 = 0; + /// A single declared tool, as harvested from a script's top-level call to /// `panto.register_tool`. All slices reference Lua-owned strings on the /// state's stack/registry; copy them before closing the state. @@ -87,6 +93,14 @@ pub const Registration = struct { schema_json: []const u8, }; +/// A single declared slash command, harvested from a script's top-level +/// call to `panto.register_command`. Slices reference Lua-owned strings; +/// copy them before closing the state. +pub const CommandRegistration = struct { + name: []const u8, + description: []const u8, +}; + // --------------------------------------------------------------------------- // Public bridge API // --------------------------------------------------------------------------- @@ -102,10 +116,17 @@ pub fn install(L: *c.lua_State) void { // Stash under our registry key. c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); - // Build the `panto` global table with `register_tool`. - c.lua_createtable(L, 0, 1); + // Create the command registrations table (parallel to the tool one). + c.lua_createtable(L, 0, 0); + c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); + + // Build the `panto` global table with `register_tool` and + // `register_command`. + c.lua_createtable(L, 0, 2); c.lua_pushcclosure(L, registerToolThunk, 0); c.lua_setfield(L, -2, "register_tool"); + c.lua_pushcclosure(L, registerCommandThunk, 0); + c.lua_setfield(L, -2, "register_command"); c.lua_setglobal(L, "panto"); } @@ -116,6 +137,8 @@ pub fn install(L: *c.lua_State) void { pub fn resetRegistrations(L: *c.lua_State) void { c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); + c.lua_createtable(L, 0, 0); + c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); } /// Load and execute a Lua source file in the given state. The file's @@ -164,6 +187,33 @@ pub fn harvestRegistrations( return out; } +/// Walk the *command* registrations table and copy each entry's name and +/// description into arena-owned bytes. The handler field is ignored; +/// callers that need to invoke handlers `luaL_ref` them separately. +pub fn harvestCommandRegistrations( + L: *c.lua_State, + arena: Allocator, +) BridgeError![]CommandRegistration { + _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); + defer c.lua_settop(L, c.lua_gettop(L) - 1); + + const n: usize = @intCast(c.lua_rawlen(L, -1)); + if (n == 0) return arena.alloc(CommandRegistration, 0) catch BridgeError.OutOfMemory; + + var out = arena.alloc(CommandRegistration, n) catch return BridgeError.OutOfMemory; + var i: usize = 1; + while (i <= n) : (i += 1) { + _ = c.lua_rawgeti(L, -1, @intCast(i)); + defer c.lua_settop(L, c.lua_gettop(L) - 1); + + out[i - 1] = .{ + .name = try readStringField(L, -1, "name", arena), + .description = try readStringField(L, -1, "description", arena), + }; + } + return out; +} + /// In an *invocation-mode* state (registrations table populated by re- /// running the script), push the handler function for `tool_name` onto the /// stack. Caller is responsible for popping it after use. @@ -287,6 +337,47 @@ fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { return 0; } +/// Implementation of `panto.register_command { name=, description=, handler= }`. +/// +/// Expects a single table argument with three named fields. The handler +/// is a function `function(args) ... end` where `args` is the trimmed +/// remainder of the slash-command line (a string, possibly empty). Its +/// return value is ignored — commands act by side effect, like the +/// builtin Zig commands. Appends a `{ name, description, handler }` +/// record to the command registrations table. +fn registerCommandThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { + const L = L_opt.?; + c.luaL_checktype(L, 1, T_TABLE); + + // Stack after these pulls: + // 1: args table (input) + // 2: name (string) + // 3: description (string) + // 4: handler (function) + expectFieldNamed(L, 1, "name", T_STRING, "register_command"); + expectFieldNamed(L, 1, "description", T_STRING, "register_command"); + expectFieldNamed(L, 1, "handler", T_FUNCTION, "register_command"); + + // Build the record table { name, description, handler }. + c.lua_createtable(L, 0, 3); + c.lua_pushvalue(L, 2); + c.lua_setfield(L, -2, "name"); + c.lua_pushvalue(L, 3); + c.lua_setfield(L, -2, "description"); + c.lua_pushvalue(L, 4); // handler + c.lua_setfield(L, -2, "handler"); + + // Append to the command registrations table. + _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); + // Stack: ..., record, regs_table + const n: c_int = @intCast(c.lua_rawlen(L, -1)); + c.lua_pushvalue(L, -2); // copy record above regs_table + c.lua_rawseti(L, -2, n + 1); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table + + return 0; +} + /// Push `args_table[field_name]` onto the stack and assert it has the /// expected type. Raises a Lua error if missing or wrong type. fn expectField( @@ -294,13 +385,26 @@ fn expectField( args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, +) void { + expectFieldNamed(L, args_idx, field_name, expected_type, "register_tool"); +} + +/// Like `expectField`, but lets the caller name the bridge function in +/// the error message (`register_tool` vs `register_command`). +fn expectFieldNamed( + L: *c.lua_State, + args_idx: c_int, + comptime field_name: [:0]const u8, + expected_type: c_int, + comptime fn_name: [:0]const u8, ) void { _ = c.lua_getfield(L, args_idx, field_name.ptr); const got = c.lua_type(L, -1); if (got != expected_type) { + const fmt: [:0]const u8 = fn_name ++ ": field '%s' must be %s (got %s)"; _ = c.luaL_error( L, - "register_tool: field '%s' must be %s (got %s)", + fmt.ptr, field_name.ptr, c.lua_typename(L, expected_type), c.lua_typename(L, got), @@ -476,6 +580,58 @@ test "install creates panto.register_tool global" { try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); _ = c.lua_getfield(L, -1, "register_tool"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); + _ = c.lua_getglobal(L, "panto"); + _ = c.lua_getfield(L, -1, "register_command"); + try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); +} + +test "register_command records name and description" { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + install(L); + + const script = + \\panto.register_command { + \\ name = "hello", + \\ description = "Greets the user.", + \\ handler = function(args) return "hi " .. args end, + \\} + ; + if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { + var len: usize = 0; + const msg = c.lua_tolstring(L, -1, &len); + std.debug.print("lua error: {s}\n", .{msg[0..len]}); + return error.LuaScriptFailed; + } + + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const cmds = try harvestCommandRegistrations(L, arena_state.allocator()); + + try std.testing.expectEqual(@as(usize, 1), cmds.len); + try std.testing.expectEqualStrings("hello", cmds[0].name); + try std.testing.expectEqualStrings("Greets the user.", cmds[0].description); +} + +test "register_command rejects a non-function handler" { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + install(L); + + const script = + \\panto.register_command { + \\ name = "bad", description = "d", handler = 42, + \\} + ; + const loaded = c.luaL_loadstring(L, script) == 0; + try std.testing.expect(loaded); + // Should raise a Lua error (non-zero pcall result). + try std.testing.expect(c.lua_pcallk(L, 0, 0, 0, 0, null) != 0); + var len: usize = 0; + const msg = c.lua_tolstring(L, -1, &len); + try std.testing.expect(std.mem.indexOf(u8, msg[0..len], "register_command") != null); } test "register_tool records name, description, schema_json" { diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 2cf3cf9..7d18fe9 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -46,6 +46,15 @@ pub const RuntimeError = error{ OutOfMemory, }; +/// A slash command declared by a Lua extension. Name and description +/// point into the runtime's `strings` pool; the handler lives in the Lua +/// registry, addressed by `handler_ref`. +pub const LuaCommand = struct { + name: []const u8, + description: []const u8, + handler_ref: c_int, +}; + /// Owned state for the runtime. pub const LuaRuntime = struct { allocator: Allocator, @@ -58,6 +67,14 @@ pub const LuaRuntime = struct { /// the Lua registry (`luaL_ref` index). handlers: std.StringHashMap(c_int), + /// Slash commands declared by extensions via `panto.register_command`. + /// Owned by this runtime; the CLI reads these to build its command + /// registry. Each entry's name/description point into `strings`. + commands: std.array_list.Managed(LuaCommand), + /// Map from command name (borrowed from `commands`) to its handler + /// ref in the Lua registry. + command_handlers: std.StringHashMap(c_int), + /// Registry ref to the wrapper closure that runs a user handler /// inside a `pcall` and reports the result back to Zig via /// `panto._record_result`. Allocated once at `create`; reused for @@ -92,6 +109,8 @@ pub const LuaRuntime = struct { .decls = std.array_list.Managed(panto.ToolDecl).init(allocator), .strings = std.array_list.Managed([]u8).init(allocator), .handlers = std.StringHashMap(c_int).init(allocator), + .commands = std.array_list.Managed(LuaCommand).init(allocator), + .command_handlers = std.StringHashMap(c_int).init(allocator), }; return self; } @@ -123,6 +142,11 @@ pub const LuaRuntime = struct { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); } self.handlers.deinit(); + var cit = self.command_handlers.iterator(); + while (cit.next()) |entry| { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); + } + self.command_handlers.deinit(); if (self.wrapper_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref); } @@ -133,6 +157,7 @@ pub const LuaRuntime = struct { c.lua_close(self.L); self.decls.deinit(); + self.commands.deinit(); for (self.strings.items) |s| self.allocator.free(s); self.strings.deinit(); self.allocator.destroy(self); @@ -309,6 +334,105 @@ pub const LuaRuntime = struct { c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } + + try self.harvestAndStoreCommands(); + } + + /// Walk the command registrations table populated by the script just + /// loaded. For each `{ name, description, handler }` record: copy the + /// strings, `luaL_ref` the handler, and append a `LuaCommand`. + /// Duplicate command names within the runtime are rejected. + fn harvestAndStoreCommands(self: *LuaRuntime) !void { + const L = self.L; + + _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.command_registrations_key); + defer c.lua_settop(L, c.lua_gettop(L) - 1); + + const n: usize = @intCast(c.lua_rawlen(L, -1)); + var i: usize = 1; + while (i <= n) : (i += 1) { + _ = c.lua_rawgeti(L, -1, @intCast(i)); // push record + + const name = try self.readStringFieldOwned("name"); + const desc = try self.readStringFieldOwned("description"); + + _ = c.lua_getfield(L, -1, "handler"); + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 2); // pop handler + record + return RuntimeError.LuaHandlerNotFound; + } + const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); + // Stack: ..., regs_table, record + + const gop = try self.command_handlers.getOrPut(name); + if (gop.found_existing) { + c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop record + return error.DuplicateCommand; + } + gop.value_ptr.* = ref; + + try self.commands.append(.{ + .name = name, + .description = desc, + .handler_ref = ref, + }); + + c.lua_settop(L, c.lua_gettop(L) - 1); // pop record + } + } + + /// Run a Lua slash-command handler synchronously. `handler_ref` is a + /// registry ref to a `function(args)` closure; `args` is the trimmed + /// remainder of the command line. The handler runs under `pcall` with + /// a `debug.traceback` error handler so failures surface a readable + /// message instead of crashing the REPL. + /// + /// Slash-command handlers act by side effect (printing, mutating + /// state) and their return value is ignored. On Lua error, returns + /// `RuntimeError.LuaHandlerCrashed` after copying the error message + /// into `err_buf` (truncated to fit); `*err_len` is set to the number + /// of bytes written. + pub fn runCommand( + self: *LuaRuntime, + handler_ref: c_int, + args: []const u8, + err_buf: []u8, + err_len: *usize, + ) RuntimeError!void { + const L = self.L; + err_len.* = 0; + + // Install a traceback error handler beneath the call frame. + _ = c.lua_getglobal(L, "debug"); + _ = c.lua_getfield(L, -1, "traceback"); + c.lua_copy(L, -1, -2); + c.lua_settop(L, c.lua_gettop(L) - 1); // pop the `debug` table + const errfunc_idx = c.lua_gettop(L); + + // Push handler + args string. + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); + _ = c.lua_pushlstring(L, args.ptr, args.len); + + const rc = c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null); + if (rc != 0) { + var len: usize = 0; + const ptr = c.lua_tolstring(L, -1, &len); + if (ptr != null) { + const n = @min(len, err_buf.len); + @memcpy(err_buf[0..n], ptr[0..n]); + err_len.* = n; + } + c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + errfunc + return RuntimeError.LuaHandlerCrashed; + } + c.lua_settop(L, c.lua_gettop(L) - 1); // pop errfunc + } + + /// Slash commands declared by loaded extensions, in registration + /// order. The CLI walks these to populate its command registry. + pub fn commandList(self: *const LuaRuntime) []const LuaCommand { + return self.commands.items; } fn readStringFieldOwned(self: *LuaRuntime, field_name: [:0]const u8) ![]const u8 { @@ -963,6 +1087,86 @@ test "loadExtension records tool decls" { try testing.expectEqualStrings("greet", rt.decls.items[0].name); } +test "loadExtension records command decls" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\_G.last_args = nil + \\panto.register_command { + \\ name = "shout", description = "Uppercases its args.", + \\ handler = function(args) _G.last_args = args:upper() end, + \\} + ; + const path = try writeTempScript(tmp.dir, "shout.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + try rt.loadExtension(path, null); + const cmds = rt.commandList(); + try testing.expectEqual(@as(usize, 1), cmds.len); + try testing.expectEqualStrings("shout", cmds[0].name); + try testing.expectEqualStrings("Uppercases its args.", cmds[0].description); + // No tool was registered. + try testing.expectEqual(@as(usize, 0), rt.toolCount()); + + // Invoke the handler and confirm the side effect ran with our args. + var err_buf: [256]u8 = undefined; + var err_len: usize = 0; + try rt.runCommand(cmds[0].handler_ref, "hi there", &err_buf, &err_len); + + _ = c.lua_getglobal(rt.L, "last_args"); + var len: usize = 0; + const got = c.lua_tolstring(rt.L, -1, &len); + try testing.expectEqualStrings("HI THERE", got[0..len]); + c.lua_settop(rt.L, c.lua_gettop(rt.L) - 1); +} + +test "runCommand surfaces a Lua error" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_command { + \\ name = "boom", description = "crashes", + \\ handler = function(args) error("kaboom") end, + \\} + ; + const path = try writeTempScript(tmp.dir, "boom.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + try rt.loadExtension(path, null); + + const cmds = rt.commandList(); + var err_buf: [4096]u8 = undefined; + var err_len: usize = 0; + try testing.expectError( + RuntimeError.LuaHandlerCrashed, + rt.runCommand(cmds[0].handler_ref, "", &err_buf, &err_len), + ); + try testing.expect(std.mem.indexOf(u8, err_buf[0..err_len], "kaboom") != null); +} + +test "duplicate command name within runtime is rejected" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_command { name = "dup", description = "a", handler = function() end } + \\panto.register_command { name = "dup", description = "b", handler = function() end } + ; + const path = try writeTempScript(tmp.dir, "dup.lua", source); + defer testing.allocator.free(path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + try testing.expectError(error.DuplicateCommand, rt.loadExtension(path, null)); +} + test "invokeBatch runs each call through a coroutine and returns the result" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); diff --git a/src/main.zig b/src/main.zig index 14a14b6..be0ac86 100644 --- a/src/main.zig +++ b/src/main.zig @@ -526,12 +526,30 @@ pub fn main(init: std.process.Init) !void { defer cli_recv.deinit(); var recv = cli_recv.receiver(); - // Build the slash-command registry and register builtins. Future Lua - // extensions will append commands here too. + // Build the slash-command registry and register builtins, then append + // any commands declared by Lua extensions (harvested at load time). var cmd_registry = command.Registry.init(alloc); defer cmd_registry.deinit(); try command_compaction.register(&cmd_registry); + // Append slash commands declared by Lua extensions via + // `panto.register_command`. A name collision with a builtin (or + // between two extensions) surfaces as `error.DuplicateCommand` and + // aborts startup, matching the tool-name collision policy. + for (rt.commandList()) |lua_cmd| { + cmd_registry.registerLua( + lua_cmd.name, + lua_cmd.description, + lua_cmd.handler_ref, + ) catch |err| { + std.log.err( + "lua: failed to register command '/{s}': {t}", + .{ lua_cmd.name, err }, + ); + return err; + }; + } + var cmd_ctx: command.Context = .{ .allocator = alloc, .conv = &conv, @@ -542,6 +560,7 @@ pub fn main(init: std.process.Init) !void { .compaction_prompt = compaction_prompt, .provider_name = banner_provider_initial, .model_name = banner_model_initial, + .lua_rt = rt, }; while (true) { -- cgit v1.3