diff options
| author | T <t@tjp.lol> | 2026-05-27 07:59:03 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 07:59:24 -0600 |
| commit | b72a405534d6be019573ee0a806014e2713fe55e (patch) | |
| tree | 7c4b8685f2d98382b5c0798e7ce1034cc1617bb2 /src | |
| parent | 2b937beb0b5635d89df84cfd112dabca34018afe (diff) | |
.panto/tools/ resolution
Diffstat (limited to 'src')
| -rw-r--r-- | src/extension_loader.zig | 370 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 68 |
2 files changed, 382 insertions, 56 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 2a35d15..8a871c9 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -1,24 +1,33 @@ -//! Extension discovery: walk well-known directories, locate Lua extensions, -//! and load each one into a long-lived `LuaRuntime`. +//! Extension and tool discovery: walk well-known directories, locate Lua +//! files, and load each into a long-lived `LuaRuntime`. //! -//! Search order (later entries shadow earlier ones by extension *name*): -//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") -//! 2. `./.panto/extensions/` ("project") +//! Two parallel namespaces are scanned, each at user and project scope: //! -//! Layout per directory: -//! - `<name>.lua` -- single-file extension; the extension name is -//! the basename without the `.lua` suffix. -//! - `<name>/init.lua` -- directory extension; the extension name is -//! the directory name. The directory is added -//! to the extension's `package.path` so it can +//! Extensions (full-featured; call `panto.register_tool` from a script +//! that may register many tools): +//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") +//! 2. `./.panto/extensions/` ("project") +//! +//! Tools (ergonomic single-tool form; the script returns one table +//! shaped like the argument to `panto.register_tool`): +//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user") +//! 2. `./.panto/tools/` ("project") +//! +//! Layout per directory (identical for extensions and tools): +//! - `<name>.lua` -- single-file entry; the logical name is the +//! basename without the `.lua` suffix. +//! - `<name>/init.lua` -- directory entry; the logical name is the +//! directory name. The directory is added to +//! the script's `package.path` so it can //! `require` sibling Lua files. //! //! Conflict rules: -//! - Within one directory, two entries with the same extension name +//! - Within one directory, two entries with the same logical name //! are an error. -//! - Project shadows user by extension name (debug-logged, not an error). -//! - Tool-name collisions *between* loaded extensions are an error: a -//! tool name is a contract the LLM relies on. +//! - Project shadows user *within the same kind* (extension or tool). +//! Extensions and tools live in distinct namespaces for shadowing; +//! the registered *tool names* still share one global namespace +//! across both, and collisions there are still an error. //! //! Symlinks: followed normally. Dotfiles: skipped. @@ -30,16 +39,18 @@ const Allocator = std.mem.Allocator; const Io = std.Io; const LuaRuntime = lua_runtime.LuaRuntime; -/// A discovered extension before loading. Owns its strings. +/// A discovered extension or tool before loading. Owns its strings. const Found = struct { /// Logical name (basename without `.lua`, or directory name). name: []u8, /// Absolute path to the Lua script to execute. script_path: []u8, - /// For directory-style extensions, the directory containing the script. + /// For directory-style entries, the directory containing the script. package_root: ?[]u8, /// Which search-path source this came from. source: Source, + /// Whether this is a full extension or a single-tool script. + kind: Kind, pub fn deinit(self: *Found, allocator: Allocator) void { allocator.free(self.name); @@ -60,34 +71,69 @@ pub const Source = enum { } }; -/// Discover and load every extension found in the standard paths into -/// `runtime`. Returns the number of *tools* (not extensions) declared. +pub const Kind = enum { + extension, + tool, + + pub fn label(self: Kind) []const u8 { + return switch (self) { + .extension => "extension", + .tool => "tool", + }; + } +}; + +/// Discover and load every extension and tool found in the standard +/// paths into `runtime`. Returns the number of registered tools added +/// to the runtime by this call. /// /// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The -/// project directory is always `cwd()/.panto/extensions`. +/// project directories are always `cwd()/.panto/{extensions,tools}`. pub fn discoverAndLoad( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, runtime: *LuaRuntime, ) !usize { - const user_dir = try userExtensionsDir(allocator, environ_map); - defer if (user_dir) |d| allocator.free(d); + const user_ext = try userKindDir(allocator, environ_map, .extension); + defer if (user_ext) |d| allocator.free(d); + const user_tool = try userKindDir(allocator, environ_map, .tool); + defer if (user_tool) |d| allocator.free(d); - const project_dir = try projectExtensionsDir(allocator, io); - defer allocator.free(project_dir); + const project_ext = try projectKindDir(allocator, io, .extension); + defer allocator.free(project_ext); + const project_tool = try projectKindDir(allocator, io, .tool); + defer allocator.free(project_tool); - return loadFromDirs(allocator, io, runtime, user_dir, project_dir); + return loadFromDirs( + allocator, + io, + runtime, + .{ + .user_extensions = user_ext, + .project_extensions = project_ext, + .user_tools = user_tool, + .project_tools = project_tool, + }, + ); } +/// Set of search paths consumed by `loadFromDirs`. Any field may be +/// null; missing directories on disk are silently skipped. +pub const DirSet = struct { + user_extensions: ?[]const u8 = null, + project_extensions: ?[]const u8 = null, + user_tools: ?[]const u8 = null, + project_tools: ?[]const u8 = null, +}; + /// Lower-level entry point: load from explicit user/project paths. /// Either path may be null; missing directories are silently skipped. pub fn loadFromDirs( allocator: Allocator, io: Io, runtime: *LuaRuntime, - user_dir: ?[]const u8, - project_dir: ?[]const u8, + dirs: DirSet, ) !usize { var found: std.array_list.Managed(Found) = .init(allocator); defer { @@ -95,30 +141,36 @@ pub fn loadFromDirs( found.deinit(); } - if (user_dir) |d| try scanDir(allocator, io, d, .user, &found); - if (project_dir) |d| try scanDir(allocator, io, d, .project, &found); + if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found); + if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found); + if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found); + if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found); try applyShadowing(allocator, &found); const before = runtime.toolCount(); for (found.items) |f| { - runtime.loadExtension(f.script_path, f.package_root) catch |err| { + const load_result = switch (f.kind) { + .extension => runtime.loadExtension(f.script_path, f.package_root), + .tool => runtime.loadTool(f.script_path, f.package_root), + }; + load_result catch |err| { if (@import("builtin").is_test) { std.log.warn( - "extension '{s}' ({s}: {s}) failed to load: {t}", - .{ f.name, f.source.label(), f.script_path, err }, + "{s} '{s}' ({s}: {s}) failed to load: {t}", + .{ f.kind.label(), f.name, f.source.label(), f.script_path, err }, ); } else { std.log.err( - "extension '{s}' ({s}: {s}) failed to load: {t}", - .{ f.name, f.source.label(), f.script_path, err }, + "{s} '{s}' ({s}: {s}) failed to load: {t}", + .{ f.kind.label(), f.name, f.source.label(), f.script_path, err }, ); } return err; }; std.log.debug( - "extension: loaded '{s}' ({s})", - .{ f.name, f.source.label() }, + "{s}: loaded '{s}' ({s})", + .{ f.kind.label(), f.name, f.source.label() }, ); } return runtime.toolCount() - before; @@ -128,23 +180,32 @@ pub fn loadFromDirs( // Path resolution // --------------------------------------------------------------------------- -fn userExtensionsDir( +fn kindSubdir(kind: Kind) []const u8 { + return switch (kind) { + .extension => "extensions", + .tool => "tools", + }; +} + +fn userKindDir( allocator: Allocator, environ_map: *const std.process.Environ.Map, + kind: Kind, ) !?[]u8 { + const sub = kindSubdir(kind); if (environ_map.get("XDG_CONFIG_HOME")) |xdg| { - return try std.fs.path.join(allocator, &.{ xdg, "panto", "extensions" }); + return try std.fs.path.join(allocator, &.{ xdg, "panto", sub }); } if (environ_map.get("HOME")) |home| { - return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "extensions" }); + return try std.fs.path.join(allocator, &.{ home, ".config", "panto", sub }); } return null; } -fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 { +fn projectKindDir(allocator: Allocator, io: Io, kind: Kind) ![]u8 { const cwd = try std.process.currentPathAlloc(io, allocator); defer allocator.free(cwd); - return try std.fs.path.join(allocator, &.{ cwd, ".panto", "extensions" }); + return try std.fs.path.join(allocator, &.{ cwd, ".panto", kindSubdir(kind) }); } // --------------------------------------------------------------------------- @@ -156,6 +217,7 @@ fn scanDir( io: Io, dir_path: []const u8, source: Source, + kind: Kind, out: *std.array_list.Managed(Found), ) !void { var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |err| switch (err) { @@ -176,8 +238,8 @@ fn scanDir( if (entry.name.len == 0 or entry.name[0] == '.') continue; const maybe_found: ?Found = switch (entry.kind) { - .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source), - .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source), + .file, .sym_link => try classifyFile(allocator, dir_path, entry.name, source, kind), + .directory => try classifyDirectory(allocator, io, dir, dir_path, entry.name, source, kind), else => null, }; const f = maybe_found orelse continue; @@ -186,13 +248,13 @@ fn scanDir( if (gop.found_existing) { if (@import("builtin").is_test) { std.log.warn( - "extension name '{s}' is provided by multiple entries in {s}", - .{ f.name, dir_path }, + "{s} name '{s}' is provided by multiple entries in {s}", + .{ kind.label(), f.name, dir_path }, ); } else { std.log.err( - "extension name '{s}' is provided by multiple entries in {s}", - .{ f.name, dir_path }, + "{s} name '{s}' is provided by multiple entries in {s}", + .{ kind.label(), f.name, dir_path }, ); } var dup = f; @@ -210,6 +272,7 @@ fn classifyFile( dir_path: []const u8, entry_name: []const u8, source: Source, + kind: Kind, ) !?Found { if (!std.mem.endsWith(u8, entry_name, ".lua")) return null; const base = entry_name[0 .. entry_name.len - ".lua".len]; @@ -225,6 +288,7 @@ fn classifyFile( .script_path = script_path, .package_root = null, .source = source, + .kind = kind, }; } @@ -235,6 +299,7 @@ fn classifyDirectory( dir_path: []const u8, entry_name: []const u8, source: Source, + kind: Kind, ) !?Found { var sub = parent.openDir(io, entry_name, .{}) catch return null; defer sub.close(io); @@ -256,6 +321,7 @@ fn classifyDirectory( .script_path = script_path, .package_root = package_root, .source = source, + .kind = kind, }; } @@ -263,11 +329,31 @@ fn classifyDirectory( // Shadowing // --------------------------------------------------------------------------- +/// Shadowing key combines (kind, name) so a tool named `foo` does not +/// shadow an extension named `foo` (or vice versa). Tool-name collisions +/// across these are still caught later by the runtime/registry. +const ShadowKey = struct { + kind: Kind, + name: []const u8, +}; + +const ShadowKeyCtx = struct { + pub fn hash(_: ShadowKeyCtx, k: ShadowKey) u64 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(&[_]u8{@intFromEnum(k.kind)}); + hasher.update(k.name); + return hasher.final(); + } + pub fn eql(_: ShadowKeyCtx, a: ShadowKey, b: ShadowKey) bool { + return a.kind == b.kind and std.mem.eql(u8, a.name, b.name); + } +}; + fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void { - var latest: std.StringHashMap(usize) = .init(allocator); + var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator); for (list.items, 0..) |f, i| { - try latest.put(f.name, i); + try latest.put(.{ .kind = f.kind, .name = f.name }, i); } var keep: std.array_list.Managed(Found) = .init(allocator); @@ -283,13 +369,13 @@ fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !v try drop.ensureTotalCapacity(list.items.len); for (list.items, 0..) |f, i| { - const winner = latest.get(f.name).?; + const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?; if (winner == i) { keep.appendAssumeCapacity(f); } else { std.log.debug( - "extension: '{s}' from {s} shadowed by {s}", - .{ f.name, f.source.label(), list.items[winner].source.label() }, + "{s}: '{s}' from {s} shadowed by {s}", + .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() }, ); drop.appendAssumeCapacity(f); } @@ -339,7 +425,7 @@ test "scanDir picks up single-file and directory-style extensions" { for (list.items) |*f| f.deinit(testing.allocator); list.deinit(); } - try scanDir(testing.allocator, testing.io, ext_root, .user, &list); + try scanDir(testing.allocator, testing.io, ext_root, .user, .extension, &list); try testing.expectEqual(@as(usize, 2), list.items.len); @@ -376,7 +462,7 @@ test "duplicate name in same directory is an error" { list.deinit(); } - const result = scanDir(testing.allocator, testing.io, ext_root, .project, &list); + const result = scanDir(testing.allocator, testing.io, ext_root, .project, .extension, &list); try testing.expectError(error.DuplicateExtensionInDirectory, result); } @@ -398,6 +484,7 @@ test "applyShadowing keeps the latest occurrence" { .script_path = try testing.allocator.dupe(u8, row[1]), .package_root = null, .source = row[2], + .kind = .extension, }); } @@ -450,7 +537,10 @@ test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" { var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, user_path, proj_path); + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .user_extensions = user_path, + .project_extensions = proj_path, + }); try testing.expectEqual(@as(usize, 1), n_tools); // Invoke the tool through the source and verify the project handler ran. @@ -490,6 +580,174 @@ test "loadFromDirs: tool-name collision between extensions errors" { var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); - const result = loadFromDirs(testing.allocator, testing.io, rt, null, ext_path); + const result = loadFromDirs(testing.allocator, testing.io, rt, .{ + .project_extensions = ext_path, + }); try testing.expectError(error.DuplicateTool, result); } + +test "loadFromDirs: tools/ directory — single-file tool form" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "tools"); + try writeFile(tmp.dir, "tools/echo.lua", + \\return { + \\ name = "echo", description = "Echo back input.", + \\ schema = { type = "object", properties = { msg = { type = "string" } } }, + \\ handler = function(input) return "echo: " .. input.msg end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); + const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); + defer testing.allocator.free(tools_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .user_tools = tools_path, + }); + try testing.expectEqual(@as(usize, 1), n_tools); + + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{.{ .tool_name = "echo", .input = "{\"msg\":\"hi\"}" }}; + var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer testing.allocator.free(results[0].ok); + try testing.expectEqualStrings("echo: hi", results[0].ok); +} + +test "loadFromDirs: tools/ directory — directory-style tool with sibling require" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "tools/shout"); + try writeFile(tmp.dir, "tools/shout/util.lua", + \\local M = {} + \\function M.shout(s) return s:upper() .. "!" end + \\return M + ); + try writeFile(tmp.dir, "tools/shout/init.lua", + \\local util = require("util") + \\return { + \\ name = "shout", description = "uppercase + bang", + \\ schema = { type = "object", properties = { text = { type = "string" } } }, + \\ handler = function(input) return util.shout(input.text) end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); + const tools_path = try testing.allocator.dupe(u8, path_buf[0..n]); + defer testing.allocator.free(tools_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .project_tools = tools_path, + }); + try testing.expectEqual(@as(usize, 1), n_tools); + + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }}; + var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer testing.allocator.free(results[0].ok); + try testing.expectEqualStrings("HI!", results[0].ok); +} + +test "loadFromDirs: project tool shadows user tool of the same name" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "user_tools"); + try makeDir(tmp.dir, "project_tools"); + + try writeFile(tmp.dir, "user_tools/greet.lua", + \\return { + \\ name = "greet", description = "user version", + \\ schema = { type = "object" }, + \\ handler = function(input) return "USER" end, + \\} + ); + try writeFile(tmp.dir, "project_tools/greet.lua", + \\return { + \\ name = "greet", description = "project version", + \\ schema = { type = "object" }, + \\ handler = function(input) return "PROJECT" end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); + const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); + defer testing.allocator.free(user_path); + + const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf); + const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); + defer testing.allocator.free(proj_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .user_tools = user_path, + .project_tools = proj_path, + }); + try testing.expectEqual(@as(usize, 1), n_tools); + + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; + var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer testing.allocator.free(results[0].ok); + try testing.expectEqualStrings("PROJECT", results[0].ok); +} + +test "loadFromDirs: extension and tool share a *file* name independently" { + // The shadow-key is (kind, name) so an extension named `foo` and a + // tool named `foo` coexist — as long as their *registered* tool names + // don't collide. + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "ext"); + try makeDir(tmp.dir, "tools"); + + try writeFile(tmp.dir, "ext/foo.lua", + \\panto.register_tool { + \\ name = "ext_foo", description = "e", + \\ schema = { type = "object" }, + \\ handler = function(input) return "ext" end, + \\} + ); + try writeFile(tmp.dir, "tools/foo.lua", + \\return { + \\ name = "tool_foo", description = "t", + \\ schema = { type = "object" }, + \\ handler = function(input) return "tool" end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const e_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf); + const ext_path = try testing.allocator.dupe(u8, path_buf[0..e_len]); + defer testing.allocator.free(ext_path); + + const t_len = try tmp.dir.realPathFile(testing.io, "tools", &path_buf); + const tools_path = try testing.allocator.dupe(u8, path_buf[0..t_len]); + defer testing.allocator.free(tools_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .user_extensions = ext_path, + .user_tools = tools_path, + }); + try testing.expectEqual(@as(usize, 2), n_tools); +} diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 44b64a4..fb00951 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -139,6 +139,74 @@ pub const LuaRuntime = struct { try self.harvestAndStoreHandlers(); } + /// Load a single-tool Lua script and register the table it returns + /// as if `panto.register_tool` had been called on that table. + /// + /// The script's top-level chunk must return a table with the same + /// shape that `panto.register_tool` accepts: + /// `{ name, description, schema, handler }`. This is the ergonomic + /// form supported under a `tools/` directory. + pub fn loadTool( + self: *LuaRuntime, + script_path: []const u8, + package_root: ?[]const u8, + ) !void { + const path_z = try self.allocator.dupeZ(u8, script_path); + defer self.allocator.free(path_z); + + lua_bridge.resetRegistrations(self.L); + + if (package_root) |root| { + const root_z = try self.allocator.dupeZ(u8, root); + defer self.allocator.free(root_z); + try prependPackagePath(self.L, root_z); + } + + // Run the file expecting exactly one returned value (the tool + // table). Use luaL_loadfilex + lua_pcallk directly so we can + // ask for a return value (the bridge's loadFile discards them). + // + // Push `panto.register_tool` *first*, then load+run the chunk so + // its return value naturally lands above it; calling pcall then + // consumes both in the right order. + const L = self.L; + _ = c.lua_getglobal(L, "panto"); + _ = c.lua_getfield(L, -1, "register_tool"); + c.lua_copy(L, -1, -2); // overwrite `panto` with `register_tool` + c.lua_settop(L, c.lua_gettop(L) - 1); // pop the duplicate + // Stack: ..., register_tool + + if (c.luaL_loadfilex(L, path_z.ptr, null) != 0) { + logTopAsError(L, "lua: failed to load tool"); + c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool + return error.LuaLoadFailed; + } + if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + logTopAsError(L, "lua: failed to run tool"); + c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool + return error.LuaRunFailed; + } + // Stack: ..., register_tool, returned_value + if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { + c.lua_settop(L, c.lua_gettop(L) - 2); // pop both + std.log.err( + "lua: tool script '{s}' must return a table", + .{script_path}, + ); + return error.BadRegistration; + } + + // Invoke register_tool(returned_table). Same validation, schema + // serialization, and registrations-table append logic as an + // extension's `panto.register_tool` call. + if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) { + logTopAsError(L, "lua: register_tool failed for tool script"); + return error.LuaRunFailed; + } + + try self.harvestAndStoreHandlers(); + } + /// Walk the registrations table that the script just populated. /// For each entry: /// - Copy `name`, `description`, `schema_json` into owned bytes. |
