summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-10 09:46:00 -0600
committert <t@tjp.lol>2026-06-10 09:55:05 -0600
commit9bf1d800f2a24ed71221c64370c98ee8d907314b (patch)
treed1b77ac9140e57ba523e97a4b815475ddba2c04e
parent938db5f476184b2d4f613e04cce60a5191581d7f (diff)
expose `panto` to extensions as a require()able module, not a global
-rw-r--r--examples/extensions/echo.lua2
-rw-r--r--examples/extensions/greet.lua2
-rw-r--r--src/extension_loader.zig5
-rw-r--r--src/lua_bridge.zig72
-rw-r--r--src/lua_event_bridge.zig7
-rw-r--r--src/lua_runtime.zig19
6 files changed, 96 insertions, 11 deletions
diff --git a/examples/extensions/echo.lua b/examples/extensions/echo.lua
index d384e5b..2f2d4de 100644
--- a/examples/extensions/echo.lua
+++ b/examples/extensions/echo.lua
@@ -1,6 +1,8 @@
-- A trivial Lua tool that echoes back whatever the LLM asks it to.
-- Useful for exercising the panto CLI's Lua extension path end-to-end.
+local panto = require("panto")
+
panto.ext.register_tool {
name = "echo",
description = "Echo back the given message. Useful for testing whether tools work.",
diff --git a/examples/extensions/greet.lua b/examples/extensions/greet.lua
index 30a6f12..d74095b 100644
--- a/examples/extensions/greet.lua
+++ b/examples/extensions/greet.lua
@@ -6,6 +6,8 @@
-- 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.
+local panto = require("panto")
+
panto.ext.register_command {
name = "greet",
description = "Print a greeting. Optional args name who to greet.",
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 8a735bf..0ab8091 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -627,6 +627,7 @@ test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
try makeDir(tmp.dir, "project_ext");
try writeFile(tmp.dir, "user_ext/greet.lua",
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "greet", description = "user version",
\\ schema = { type = "object" },
@@ -634,6 +635,7 @@ test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" {
\\}
);
try writeFile(tmp.dir, "project_ext/greet.lua",
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "greet", description = "project version",
\\ schema = { type = "object" },
@@ -674,6 +676,7 @@ test "loadFromDirs: tool-name collision between extensions errors" {
try makeDir(tmp.dir, "ext");
try writeFile(tmp.dir, "ext/alpha.lua",
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "clash", description = "a",
\\ schema = { type = "object" },
@@ -681,6 +684,7 @@ test "loadFromDirs: tool-name collision between extensions errors" {
\\}
);
try writeFile(tmp.dir, "ext/beta.lua",
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "clash", description = "b",
\\ schema = { type = "object" },
@@ -941,6 +945,7 @@ test "loadFromDirs: extension and tool share a *file* name independently" {
try makeDir(tmp.dir, "tools");
try writeFile(tmp.dir, "ext/foo.lua",
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "ext_foo", description = "e",
\\ schema = { type = "object" },
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index 4446ca7..9ee5089 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -1,6 +1,9 @@
//! Lua C-API bridge for the panto CLI.
//!
-//! Exposes a `panto` global table inside any `lua_State` we construct.
+//! Exposes a `panto` table inside any `lua_State` we construct, reachable
+//! from Lua via `require('panto')` (registered into `package.preload` —
+//! there is no `panto` global). Internal Zig code reaches the same table
+//! through the registry via `pushPantoTable`.
//! All extension-authoring APIs live under the `panto.ext` subtable; the
//! bare `panto` namespace is reserved for the future full libpanto API
//! surface, so extensions never collide with it. The current `panto.ext`
@@ -98,6 +101,13 @@ pub var command_registrations_key: u8 = 0;
/// handler that bridges back into Lua.
pub var on_registrations_key: u8 = 0;
+/// The key under which we stash the canonical `panto` module table in
+/// `LUA_REGISTRYINDEX`. The `package.preload['panto']` loader returns this
+/// table, and internal Zig setup (`installEmit`, the runtime's
+/// `_record_result` / wrapper / register_tool paths) fetches it via
+/// `pushPantoTable` instead of a global lookup — `panto` is not a global.
+pub var panto_table_key: u8 = 0;
+
/// A single declared tool, as harvested from a script's top-level call to
/// `panto.ext.register_tool`. All slices reference Lua-owned strings on the
/// state's stack/registry; copy them before closing the state.
@@ -127,8 +137,31 @@ pub const OnRegistration = struct {
// Public bridge API
// ---------------------------------------------------------------------------
-/// Install the `panto` global (with its `panto.ext` extension subtable)
-/// into the given state.
+/// Push the canonical `panto` table (built by `install`) onto the stack
+/// from its registry slot. Internal Zig setup uses this instead of a
+/// global lookup, since `panto` is not a global — it is delivered to Lua
+/// only through `require('panto')`.
+pub fn pushPantoTable(L: *c.lua_State) void {
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &panto_table_key);
+}
+
+/// The `package.preload['panto']` loader. Returns the canonical `panto`
+/// table so `require('panto')` yields it (and `panto.ext`) without a
+/// global. Standard preload-loader contract: one return value.
+fn pantoPreloadThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ pushPantoTable(L);
+ return 1;
+}
+
+/// Install the `panto` table (with its `panto.ext` extension subtable)
+/// into the given state, reachable from Lua via `require('panto')`.
+///
+/// There is no `panto` global. The table is stashed in the registry
+/// (`panto_table_key`) and a loader is registered into
+/// `package.preload['panto']`, so any `require('panto')` — from an
+/// extension or otherwise — returns it. Internal Zig code reaches the
+/// same table via `pushPantoTable`.
///
/// All extension-authoring APIs (`register_tool`, `register_command`,
/// `on`, `emit`) live under `panto.ext`; the bare `panto` table is
@@ -168,7 +201,25 @@ pub fn install(L: *c.lua_State) void {
c.lua_setfield(L, -2, "emit");
// panto.ext = <ext table>
c.lua_setfield(L, -2, "ext");
- c.lua_setglobal(L, "panto");
+
+ // Stash the `panto` table in the registry (keeps internal Zig access
+ // working without a global), then register the preload loader so
+ // `require('panto')` returns it. The table is still on the stack top.
+ c.lua_pushvalue(L, -1); // dup `panto` for the registry slot
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &panto_table_key);
+ // Stack: ..., panto. Register the preload loader, consuming `panto`.
+ installPreloadLoader(L);
+}
+
+/// Register `pantoPreloadThunk` into `package.preload['panto']`. Consumes
+/// the `panto` table currently on the stack top (pops it).
+fn installPreloadLoader(L: *c.lua_State) void {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop the leftover `panto`
+ _ = c.lua_getglobal(L, "package");
+ _ = c.lua_getfield(L, -1, "preload");
+ c.lua_pushcclosure(L, pantoPreloadThunk, 0);
+ c.lua_setfield(L, -2, "panto");
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop preload + package
}
/// Override `panto.ext.emit` with a closure that carries `ctx` as a
@@ -184,7 +235,7 @@ pub fn installEmit(
ctx: *anyopaque,
emit_fn: *const fn (L_opt: ?*c.lua_State) callconv(.c) c_int,
) void {
- _ = c.lua_getglobal(L, "panto");
+ pushPantoTable(L);
_ = c.lua_getfield(L, -1, "ext");
c.lua_pushlightuserdata(L, ctx);
c.lua_pushcclosure(L, emit_fn, 1);
@@ -840,7 +891,10 @@ test "install creates panto.ext table with register_tool/register_command/on/emi
c.luaL_openlibs(L);
install(L);
- _ = c.lua_getglobal(L, "panto");
+ // No `panto` global: it is delivered via `require('panto')`.
+ try std.testing.expectEqual(@as(c_int, T_NIL), c.lua_getglobal(L, "panto"));
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ pushPantoTable(L);
try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1));
_ = c.lua_getfield(L, -1, "ext");
try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1));
@@ -858,6 +912,7 @@ test "panto.ext.on records event registrations in order" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.on("tool", function(e) end)
\\panto.ext.on("assistant_text", function(e) end)
\\panto.ext.on("tool", function(e) end)
@@ -906,6 +961,7 @@ test "register_command records name and description" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.register_command {
\\ name = "hello",
\\ description = "Greets the user.",
@@ -935,6 +991,7 @@ test "register_command rejects a non-function handler" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.register_command {
\\ name = "bad", description = "d", handler = 42,
\\}
@@ -955,6 +1012,7 @@ test "register_tool records name, description, schema_json" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "echo",
\\ description = "Echoes its input back.",
@@ -988,6 +1046,7 @@ test "handler invocation: input parsed, result captured" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "echo", description = "echoes",
\\ schema = { type = "object" },
@@ -1052,6 +1111,7 @@ test "handler crash: error message surfaces via xpcall traceback hook" {
install(L);
const script =
+ \\local panto = require("panto")
\\panto.ext.register_tool {
\\ name = "boom", description = "crashes",
\\ schema = { type = "object" },
diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig
index b445072..1fbc82a 100644
--- a/src/lua_event_bridge.zig
+++ b/src/lua_event_bridge.zig
@@ -720,7 +720,12 @@ fn harvestOnInto(bridge: *EventBridge) !void {
}
fn runScript(L: *c.lua_State, src: [:0]const u8) !void {
- if (c.luaL_loadstring(L, src.ptr) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
+ // `panto` is not a global; extensions reach it via `require('panto')`.
+ // Prepend the require so these bridge tests can keep using `panto.ext`
+ // without each script repeating the boilerplate.
+ var buf: [8192]u8 = undefined;
+ const wrapped = std.fmt.bufPrintZ(&buf, "local panto = require(\"panto\")\n{s}", .{src}) catch return error.LuaScriptFailed;
+ if (c.luaL_loadstring(L, wrapped.ptr) != 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]});
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 797b97b..e26d017 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -263,7 +263,7 @@ pub const LuaRuntime = struct {
// 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");
+ lua_bridge.pushPantoTable(L);
_ = c.lua_getfield(L, -1, "ext");
_ = c.lua_getfield(L, -1, "register_tool");
// Stack: ..., panto, ext, register_tool. Collapse to just
@@ -974,7 +974,7 @@ fn invokeCoroutineSync(
/// stores into the matching slot.
fn installRecordResult(self: *LuaRuntime) !void {
const L = self.L;
- _ = c.lua_getglobal(L, "panto");
+ lua_bridge.pushPantoTable(L);
if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
c.lua_settop(L, c.lua_gettop(L) - 1);
return RuntimeError.LuaInitFailed;
@@ -1038,7 +1038,10 @@ fn recordResultC(L: ?*c.lua_State) callconv(.c) c_int {
/// Stored in the Lua registry under `self.wrapper_ref`.
fn installWrapperClosure(self: *LuaRuntime) !void {
const L = self.L;
+ // `panto` is not a global, so the wrapper closes over the table
+ // (passed as an argument) rather than looking it up globally.
const snippet =
+ \\local panto = ...
\\return function(idx, handler, input)
\\ local ok, val = pcall(handler, input)
\\ panto._record_result(idx, ok, val)
@@ -1049,7 +1052,9 @@ fn installWrapperClosure(self: *LuaRuntime) !void {
c.lua_settop(L, c.lua_gettop(L) - 1);
return RuntimeError.LuaInitFailed;
}
- if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
+ // Pass the `panto` table as the chunk's vararg argument.
+ lua_bridge.pushPantoTable(L);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
logTopAsError(L, "panto-lua: wrapper closure failed to evaluate");
c.lua_settop(L, c.lua_gettop(L) - 1);
return RuntimeError.LuaInitFailed;
@@ -1153,7 +1158,12 @@ fn freeResults(results: []panto.ToolCallResult) void {
}
fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 {
- try dir.writeFile(testing.io, .{ .sub_path = name, .data = source });
+ // `panto` is not a global; extensions reach it via `require('panto')`.
+ // Prepend the require so these tests exercise the real preload path
+ // without each script repeating the boilerplate.
+ var src_buf: [16384]u8 = undefined;
+ const data = try std.fmt.bufPrint(&src_buf, "local panto = require(\"panto\")\n{s}", .{source});
+ try dir.writeFile(testing.io, .{ .sub_path = name, .data = data });
var buf: [std.fs.max_path_bytes]u8 = undefined;
const n = try dir.realPathFile(testing.io, name, &buf);
return testing.allocator.dupe(u8, buf[0..n]);
@@ -1428,6 +1438,7 @@ test "directory-style extension can require sibling modules" {
try tmp.dir.writeFile(testing.io, .{
.sub_path = "ext/init.lua",
.data =
+ \\local panto = require("panto")
\\local util = require("util")
\\panto.ext.register_tool {
\\ name = "shout", description = "uppercase + bang",