summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 19:41:56 -0600
committert <t@tjp.lol>2026-06-02 22:09:31 -0600
commit442888c6c306cfff0708c3d4bbbeda685cec2e75 (patch)
tree858ad09d4da908cc7e61ca5e570e2f46e74bdb68 /src
parent8b88b886346460b1ab29edb03ad85bc3bb565a45 (diff)
lua /commands
Diffstat (limited to 'src')
-rw-r--r--src/command.zig86
-rw-r--r--src/lua_bridge.zig162
-rw-r--r--src/lua_runtime.zig204
-rw-r--r--src/main.zig23
4 files changed, 468 insertions, 7 deletions
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, &registrations_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, &registrations_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(
@@ -295,12 +386,25 @@ fn expectField(
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) {