summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/extension_loader.zig627
-rw-r--r--src/lua_tool.zig85
-rw-r--r--src/main.zig36
3 files changed, 724 insertions, 24 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
new file mode 100644
index 0000000..158e6b6
--- /dev/null
+++ b/src/extension_loader.zig
@@ -0,0 +1,627 @@
+//! Extension discovery: walk well-known directories, locate Lua extensions,
+//! and register their tools with a `ToolRegistry`.
+//!
+//! Search order (later entries shadow earlier ones by extension *name*):
+//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
+//! 2. `./.panto/extensions/` ("project")
+//!
+//! Layout per directory: each entry is either
+//! - `<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 `require`
+//! sibling Lua files.
+//!
+//! Conflict rules:
+//! - Within a single directory, two entries with the same extension 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, and surprising overrides at
+//! load time are worse than failing fast.
+//!
+//! Symlinks: followed normally (no special handling).
+//! Hidden files: dotfiles (`.foo.lua`) are skipped to leave room for editor
+//! swap files and the like.
+
+const std = @import("std");
+const panto = @import("panto");
+const lua_tool = @import("lua_tool.zig");
+const lua_bridge = @import("lua_bridge.zig");
+
+const c = lua_bridge.c;
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+/// A discovered extension 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
+ /// (used to extend `package.path`). Null for single-file extensions.
+ package_root: ?[]u8,
+ /// Which search-path source this came from. Informational, used for
+ /// shadowing log messages and tool-conflict error context.
+ source: Source,
+
+ pub fn deinit(self: *Found, allocator: Allocator) void {
+ allocator.free(self.name);
+ allocator.free(self.script_path);
+ if (self.package_root) |p| allocator.free(p);
+ }
+};
+
+pub const Source = enum {
+ user,
+ project,
+
+ pub fn label(self: Source) []const u8 {
+ return switch (self) {
+ .user => "user",
+ .project => "project",
+ };
+ }
+};
+
+/// Discover and register every extension found in the standard paths
+/// derived from the environment + current working directory. Returns the
+/// number of *tools* (not extensions) registered.
+///
+/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
+/// project directory is always taken as `cwd()/.panto/extensions`.
+pub fn discoverAndLoad(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ registry: *panto.ToolRegistry,
+) !usize {
+ const user_dir = try userExtensionsDir(allocator, environ_map);
+ defer if (user_dir) |d| allocator.free(d);
+
+ const project_dir = try projectExtensionsDir(allocator, io);
+ defer allocator.free(project_dir);
+
+ return loadFromDirs(allocator, io, registry, user_dir, project_dir);
+}
+
+/// Lower-level entry point: load from explicit user/project paths. Useful
+/// for tests that want deterministic behavior without touching
+/// process-global state (HOME, cwd). Either path may be null; missing
+/// directories on disk are silently skipped.
+pub fn loadFromDirs(
+ allocator: Allocator,
+ io: Io,
+ registry: *panto.ToolRegistry,
+ user_dir: ?[]const u8,
+ project_dir: ?[]const u8,
+) !usize {
+ // 1. Collect candidates from both sources, project last so it wins.
+ var found: std.array_list.Managed(Found) = .init(allocator);
+ defer {
+ for (found.items) |*f| f.deinit(allocator);
+ found.deinit();
+ }
+
+ if (user_dir) |d| try scanDir(allocator, io, d, .user, &found);
+ if (project_dir) |d| try scanDir(allocator, io, d, .project, &found);
+
+ // 2. Apply project-shadows-user by name. The latest occurrence wins.
+ try applyShadowing(allocator, &found);
+
+ // 3. Load each surviving extension. Tool-name conflicts surface as
+ // `ToolRegistry.register` errors and abort startup.
+ var total_tools: usize = 0;
+ for (found.items) |f| {
+ const n = loadOne(allocator, registry, f) catch |err| {
+ // In test builds, log at warn level so a deliberate failure
+ // test doesn't trip the test runner's err-count check.
+ 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 },
+ );
+ } else {
+ std.log.err(
+ "extension '{s}' ({s}: {s}) failed to load: {t}",
+ .{ f.name, f.source.label(), f.script_path, err },
+ );
+ }
+ return err;
+ };
+ std.log.debug(
+ "extension: loaded {d} tool(s) from '{s}' ({s})",
+ .{ n, f.name, f.source.label() },
+ );
+ total_tools += n;
+ }
+ return total_tools;
+}
+
+// ---------------------------------------------------------------------------
+// Path resolution
+// ---------------------------------------------------------------------------
+
+/// Returns the absolute path of the user extensions directory, or null if
+/// `HOME` is unset and `XDG_CONFIG_HOME` is not provided either. Caller
+/// owns the returned slice.
+fn userExtensionsDir(
+ allocator: Allocator,
+ environ_map: *const std.process.Environ.Map,
+) !?[]u8 {
+ if (environ_map.get("XDG_CONFIG_HOME")) |xdg| {
+ return try std.fs.path.join(allocator, &.{ xdg, "panto", "extensions" });
+ }
+ if (environ_map.get("HOME")) |home| {
+ return try std.fs.path.join(allocator, &.{ home, ".config", "panto", "extensions" });
+ }
+ return null;
+}
+
+/// Returns the absolute path of the project extensions directory.
+/// Caller owns the returned slice.
+fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 {
+ const cwd = try std.process.currentPathAlloc(io, allocator);
+ defer allocator.free(cwd);
+ return try std.fs.path.join(allocator, &.{ cwd, ".panto", "extensions" });
+}
+
+// ---------------------------------------------------------------------------
+// Directory scanning
+// ---------------------------------------------------------------------------
+
+/// Scan `dir_path` and append any candidate extensions to `out`. Missing
+/// directories are not an error: extensions are an optional feature.
+fn scanDir(
+ allocator: Allocator,
+ io: Io,
+ dir_path: []const u8,
+ source: Source,
+ out: *std.array_list.Managed(Found),
+) !void {
+ var dir = Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch |err| switch (err) {
+ error.FileNotFound, error.NotDir => return,
+ else => |e| return e,
+ };
+ defer dir.close(io);
+
+ var local_names: std.StringHashMap(void) = .init(allocator);
+ defer {
+ var it = local_names.keyIterator();
+ while (it.next()) |k| allocator.free(k.*);
+ local_names.deinit();
+ }
+
+ var iter = dir.iterate();
+ while (try iter.next(io)) |entry| {
+ // Skip dotfiles (editor swap files, .DS_Store, hidden dirs, ...).
+ 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),
+ else => null,
+ };
+ const f = maybe_found orelse continue;
+
+ // Within one directory, duplicate names are an error. (Can happen
+ // if both `foo.lua` and `foo/init.lua` exist.)
+ const gop = try local_names.getOrPut(f.name);
+ 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 },
+ );
+ } else {
+ std.log.err(
+ "extension name '{s}' is provided by multiple entries in {s}",
+ .{ f.name, dir_path },
+ );
+ }
+ // Free the duplicate's resources before bailing.
+ var dup = f;
+ dup.deinit(allocator);
+ return error.DuplicateExtensionInDirectory;
+ }
+ // Hash map key is borrowed from f.name; we need an independent copy
+ // since `out` owns f and we'd otherwise double-free.
+ gop.key_ptr.* = try allocator.dupe(u8, f.name);
+
+ try out.append(f);
+ }
+}
+
+fn classifyFile(
+ allocator: Allocator,
+ dir_path: []const u8,
+ entry_name: []const u8,
+ source: Source,
+) !?Found {
+ if (!std.mem.endsWith(u8, entry_name, ".lua")) return null;
+ const base = entry_name[0 .. entry_name.len - ".lua".len];
+ if (base.len == 0) return null;
+
+ const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
+ errdefer allocator.free(script_path);
+ const name = try allocator.dupe(u8, base);
+ errdefer allocator.free(name);
+
+ return Found{
+ .name = name,
+ .script_path = script_path,
+ .package_root = null,
+ .source = source,
+ };
+}
+
+/// Treat `entry_name/init.lua` as a directory-style extension if present.
+/// Returns null if no `init.lua` exists inside.
+fn classifyDirectory(
+ allocator: Allocator,
+ io: Io,
+ parent: Io.Dir,
+ dir_path: []const u8,
+ entry_name: []const u8,
+ source: Source,
+) !?Found {
+ // Probe for init.lua before doing any allocation.
+ var sub = parent.openDir(io, entry_name, .{}) catch return null;
+ defer sub.close(io);
+
+ sub.access(io, "init.lua", .{}) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ else => return null, // permission, etc. — quietly skip
+ };
+
+ const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
+ errdefer allocator.free(package_root);
+ const script_path = try std.fs.path.join(allocator, &.{ package_root, "init.lua" });
+ errdefer allocator.free(script_path);
+ const name = try allocator.dupe(u8, entry_name);
+ errdefer allocator.free(name);
+
+ return Found{
+ .name = name,
+ .script_path = script_path,
+ .package_root = package_root,
+ .source = source,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Shadowing
+// ---------------------------------------------------------------------------
+
+/// In-place: for every name that appears more than once, keep only the
+/// *last* occurrence and drop earlier ones with a debug log.
+///
+/// We do this in two passes. The first pass populates a `name → winning
+/// index` map (last-write-wins by construction). The second pass walks
+/// the list once, partitioning into kept and dropped sets *without*
+/// freeing strings yet — the map still references them via its keys, so
+/// freeing mid-walk would create dangling keys in the hash table.
+fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void {
+ var latest: std.StringHashMap(usize) = .init(allocator);
+
+ for (list.items, 0..) |f, i| {
+ try latest.put(f.name, i);
+ }
+
+ var keep: std.array_list.Managed(Found) = .init(allocator);
+ var drop: std.array_list.Managed(Found) = .init(allocator);
+ errdefer {
+ latest.deinit();
+ for (keep.items) |*f| f.deinit(allocator);
+ keep.deinit();
+ for (drop.items) |*f| f.deinit(allocator);
+ drop.deinit();
+ }
+ try keep.ensureTotalCapacity(list.items.len);
+ try drop.ensureTotalCapacity(list.items.len);
+
+ for (list.items, 0..) |f, i| {
+ const winner = latest.get(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() },
+ );
+ drop.appendAssumeCapacity(f);
+ }
+ }
+
+ // The map's keys still alias `drop`'s entries; deinit it first so we
+ // can then free those entries' strings without leaving dangling keys.
+ latest.deinit();
+ for (drop.items) |*f| f.deinit(allocator);
+ drop.deinit();
+
+ // Replace list contents without re-freeing the entries we kept.
+ list.clearRetainingCapacity();
+ list.appendSlice(keep.items) catch unreachable;
+ keep.deinit();
+}
+
+// ---------------------------------------------------------------------------
+// Loading
+// ---------------------------------------------------------------------------
+
+/// Load one discovered extension and register its tools. Returns the
+/// number of tools registered.
+fn loadOne(
+ allocator: Allocator,
+ registry: *panto.ToolRegistry,
+ found: Found,
+) !usize {
+ if (found.package_root) |root| {
+ return lua_tool.loadExtensionWithPackageRoot(
+ allocator,
+ registry,
+ found.script_path,
+ root,
+ );
+ }
+ return lua_tool.loadExtension(allocator, registry, found.script_path);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+/// Helper: write a single file inside `dir` at `sub_path`.
+fn writeFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void {
+ try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content });
+}
+
+/// Helper: create a directory (path may contain separators).
+fn makeDir(dir: Io.Dir, sub_path: []const u8) !void {
+ try dir.createDirPath(testing.io, sub_path);
+}
+
+test "scanDir picks up single-file and directory-style extensions" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ // Layout:
+ // ext_root/
+ // alpha.lua
+ // beta/
+ // init.lua
+ // helper.lua
+ // .ignored.lua (dotfile -> skipped)
+ // readme.txt (non-.lua -> skipped)
+ try makeDir(tmp.dir, "ext_root");
+ try makeDir(tmp.dir, "ext_root/beta");
+ try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n");
+ try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n");
+ try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n");
+ try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n");
+ try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n");
+
+ // Absolute path to ext_root.
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
+ const ext_root = path_buf[0..ext_root_len];
+
+ var list: std.array_list.Managed(Found) = .init(testing.allocator);
+ defer {
+ for (list.items) |*f| f.deinit(testing.allocator);
+ list.deinit();
+ }
+ try scanDir(testing.allocator, testing.io, ext_root, .user, &list);
+
+ try testing.expectEqual(@as(usize, 2), list.items.len);
+
+ // Order is filesystem-dependent; sort by name for stable assertions.
+ std.mem.sort(Found, list.items, {}, struct {
+ fn lt(_: void, a: Found, b: Found) bool {
+ return std.mem.lessThan(u8, a.name, b.name);
+ }
+ }.lt);
+
+ try testing.expectEqualStrings("alpha", list.items[0].name);
+ try testing.expect(list.items[0].package_root == null);
+ try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua"));
+
+ try testing.expectEqualStrings("beta", list.items[1].name);
+ try testing.expect(list.items[1].package_root != null);
+ try testing.expect(std.mem.endsWith(u8, list.items[1].script_path, "init.lua"));
+}
+
+test "duplicate name in same directory is an error" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ // Both `foo.lua` and `foo/init.lua` exist.
+ try makeDir(tmp.dir, "ext_root/foo");
+ try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n");
+ try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n");
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf);
+ const ext_root = path_buf[0..n];
+
+ var list: std.array_list.Managed(Found) = .init(testing.allocator);
+ defer {
+ for (list.items) |*f| f.deinit(testing.allocator);
+ list.deinit();
+ }
+
+ const result = scanDir(testing.allocator, testing.io, ext_root, .project, &list);
+ try testing.expectError(error.DuplicateExtensionInDirectory, result);
+}
+
+test "applyShadowing keeps the latest occurrence" {
+ var list: std.array_list.Managed(Found) = .init(testing.allocator);
+ defer {
+ for (list.items) |*f| f.deinit(testing.allocator);
+ list.deinit();
+ }
+
+ inline for (.{
+ .{ "shared", "/u/shared.lua", Source.user },
+ .{ "only_user", "/u/only_user.lua", Source.user },
+ .{ "shared", "/p/shared.lua", Source.project },
+ .{ "only_project", "/p/only_project.lua", Source.project },
+ }) |row| {
+ try list.append(.{
+ .name = try testing.allocator.dupe(u8, row[0]),
+ .script_path = try testing.allocator.dupe(u8, row[1]),
+ .package_root = null,
+ .source = row[2],
+ });
+ }
+
+ try applyShadowing(testing.allocator, &list);
+ try testing.expectEqual(@as(usize, 3), list.items.len);
+
+ // "shared" survives once, from the project source.
+ var shared_count: usize = 0;
+ var shared_source: ?Source = null;
+ for (list.items) |f| {
+ if (std.mem.eql(u8, f.name, "shared")) {
+ shared_count += 1;
+ shared_source = f.source;
+ }
+ }
+ try testing.expectEqual(@as(usize, 1), shared_count);
+ try testing.expectEqual(@as(?Source, .project), shared_source);
+}
+
+test "loadFromDirs: project shadows user end-to-end" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "user_ext");
+ try makeDir(tmp.dir, "project_ext");
+
+ try writeFile(tmp.dir, "user_ext/greet.lua",
+ \\panto.register_tool {
+ \\ name = "greet", description = "user version",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "USER" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "project_ext/greet.lua",
+ \\panto.register_tool {
+ \\ 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_ext", &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_ext", &path_buf);
+ const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
+ defer testing.allocator.free(proj_path);
+
+ var registry = panto.ToolRegistry.init(testing.allocator);
+ defer registry.deinit();
+
+ const n_tools = try loadFromDirs(
+ testing.allocator,
+ testing.io,
+ &registry,
+ user_path,
+ proj_path,
+ );
+ try testing.expectEqual(@as(usize, 1), n_tools);
+
+ const tool = registry.lookup("greet") orelse return error.NotRegistered;
+ try testing.expectEqualStrings("project version", tool.description);
+
+ const out = try tool.vtable.invoke(tool.ctx, "{}", testing.allocator);
+ defer testing.allocator.free(out);
+ try testing.expectEqualStrings("PROJECT", out);
+}
+
+test "loadFromDirs: directory-style extension can require siblings" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "ext/composer");
+ try writeFile(tmp.dir, "ext/composer/util.lua",
+ \\local M = {}
+ \\function M.shout(s) return s:upper() .. "!" end
+ \\return M
+ );
+ try writeFile(tmp.dir, "ext/composer/init.lua",
+ \\local util = require("util")
+ \\panto.register_tool {
+ \\ 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 ext_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
+ const ext_path = try testing.allocator.dupe(u8, path_buf[0..ext_len]);
+ defer testing.allocator.free(ext_path);
+
+ var registry = panto.ToolRegistry.init(testing.allocator);
+ defer registry.deinit();
+
+ const n = try loadFromDirs(
+ testing.allocator,
+ testing.io,
+ &registry,
+ null,
+ ext_path,
+ );
+ try testing.expectEqual(@as(usize, 1), n);
+
+ const tool = registry.lookup("shout") orelse return error.NotRegistered;
+ const out = try tool.vtable.invoke(tool.ctx, "{\"text\":\"hi\"}", testing.allocator);
+ defer testing.allocator.free(out);
+ try testing.expectEqualStrings("HI!", out);
+}
+
+test "loadFromDirs: tool-name collision between extensions errors" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "ext");
+ try writeFile(tmp.dir, "ext/alpha.lua",
+ \\panto.register_tool {
+ \\ name = "clash", description = "a",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "a" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "ext/beta.lua",
+ \\panto.register_tool {
+ \\ name = "clash", description = "b",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "b" end,
+ \\}
+ );
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try tmp.dir.realPathFile(testing.io, "ext", &path_buf);
+ const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]);
+ defer testing.allocator.free(ext_path);
+
+ var registry = panto.ToolRegistry.init(testing.allocator);
+ defer registry.deinit();
+
+ const result = loadFromDirs(
+ testing.allocator,
+ testing.io,
+ &registry,
+ null,
+ ext_path,
+ );
+ try testing.expectError(error.DuplicateTool, result);
+}
diff --git a/src/lua_tool.zig b/src/lua_tool.zig
index 3064d2b..21b3e51 100644
--- a/src/lua_tool.zig
+++ b/src/lua_tool.zig
@@ -25,6 +25,10 @@ pub const LuaTool = struct {
allocator: Allocator,
// Owned, NUL-terminated for `luaL_loadfilex`.
script_path_z: [:0]u8,
+ /// Optional directory to prepend to `package.path` so the script can
+ /// `require` sibling files. Owned, NUL-terminated. Null for single-file
+ /// extensions.
+ package_root_z: ?[:0]u8,
name_owned: []u8,
description_owned: []u8,
schema_owned: []u8,
@@ -39,16 +43,31 @@ pub const LuaTool = struct {
name: []const u8,
description: []const u8,
schema_json: []const u8,
+ package_root: ?[]const u8,
) !panto.Tool {
const self = try allocator.create(LuaTool);
errdefer allocator.destroy(self);
+ const script_path_z = try allocator.dupeZ(u8, script_path);
+ errdefer allocator.free(script_path_z);
+ const name_owned = try allocator.dupe(u8, name);
+ errdefer allocator.free(name_owned);
+ const description_owned = try allocator.dupe(u8, description);
+ errdefer allocator.free(description_owned);
+ const schema_owned = try allocator.dupe(u8, schema_json);
+ errdefer allocator.free(schema_owned);
+ const package_root_z: ?[:0]u8 = if (package_root) |p|
+ try allocator.dupeZ(u8, p)
+ else
+ null;
+
self.* = .{
.allocator = allocator,
- .script_path_z = try allocator.dupeZ(u8, script_path),
- .name_owned = try allocator.dupe(u8, name),
- .description_owned = try allocator.dupe(u8, description),
- .schema_owned = try allocator.dupe(u8, schema_json),
+ .script_path_z = script_path_z,
+ .package_root_z = package_root_z,
+ .name_owned = name_owned,
+ .description_owned = description_owned,
+ .schema_owned = schema_owned,
};
return panto.Tool{
@@ -63,6 +82,7 @@ pub const LuaTool = struct {
fn freeAll(self: *LuaTool) void {
const a = self.allocator;
a.free(self.script_path_z);
+ if (self.package_root_z) |p| a.free(p);
a.free(self.name_owned);
a.free(self.description_owned);
a.free(self.schema_owned);
@@ -102,6 +122,10 @@ fn runLuaHandler(
c.luaL_openlibs(L);
lua_bridge.install(L);
+ if (self.package_root_z) |root| {
+ try prependPackagePath(L, root);
+ }
+
// Run the script so register_tool fires.
lua_bridge.loadFile(L, self.script_path_z) catch |err| {
logTopAsError(L, "lua: failed to load extension");
@@ -176,6 +200,27 @@ pub fn loadExtension(
registry: *panto.ToolRegistry,
script_path: []const u8,
) !usize {
+ return loadExtensionImpl(allocator, registry, script_path, null);
+}
+
+/// Like `loadExtension`, but extends `package.path` with `<package_root>/?.lua`
+/// and `<package_root>/?/init.lua` so the script can `require` sibling files.
+/// `package_root` should be the directory containing the script.
+pub fn loadExtensionWithPackageRoot(
+ allocator: Allocator,
+ registry: *panto.ToolRegistry,
+ script_path: []const u8,
+ package_root: []const u8,
+) !usize {
+ return loadExtensionImpl(allocator, registry, script_path, package_root);
+}
+
+fn loadExtensionImpl(
+ allocator: Allocator,
+ registry: *panto.ToolRegistry,
+ script_path: []const u8,
+ package_root: ?[]const u8,
+) !usize {
const path_z = try allocator.dupeZ(u8, script_path);
defer allocator.free(path_z);
@@ -184,6 +229,13 @@ pub fn loadExtension(
c.luaL_openlibs(L);
lua_bridge.install(L);
+ // Configure require() for directory-style extensions before running.
+ if (package_root) |root| {
+ const root_z = try allocator.dupeZ(u8, root);
+ defer allocator.free(root_z);
+ try prependPackagePath(L, root_z);
+ }
+
lua_bridge.loadFile(L, path_z) catch |err| {
logTopAsError(L, "lua: failed to load extension");
return err;
@@ -200,6 +252,7 @@ pub fn loadExtension(
r.name,
r.description,
r.schema_json,
+ package_root,
);
// If registration fails (e.g. duplicate name), free the tool we just
// built and propagate the error.
@@ -211,6 +264,30 @@ pub fn loadExtension(
return regs.len;
}
+/// Prepend `<root>/?.lua` and `<root>/?/init.lua` to `package.path` so that
+/// `require("foo")` resolves against files next to the extension's
+/// `init.lua`. We prepend (not replace) so the standard library remains
+/// available.
+///
+/// Stack effect: net zero.
+fn prependPackagePath(L: *c.lua_State, root: [:0]const u8) !void {
+ // Run a tiny Lua snippet rather than fiddle with the stack manually:
+ // string concatenation is so much nicer in Lua.
+ const snippet =
+ \\local root = ...
+ \\package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
+ ;
+ if (c.luaL_loadstring(L, snippet) != 0) {
+ logTopAsError(L, "lua: package.path loader failed to compile");
+ return error.LuaPackagePathLoadFailed;
+ }
+ _ = c.lua_pushlstring(L, root.ptr, root.len);
+ if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) {
+ logTopAsError(L, "lua: package.path setup failed");
+ return error.LuaPackagePathSetupFailed;
+ }
+}
+
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
diff --git a/src/main.zig b/src/main.zig
index 6407611..56ea2ad 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -3,6 +3,7 @@ const panto = @import("panto");
const ping_tool = @import("ping_tool.zig");
const lua_bridge = @import("lua_bridge.zig");
const lua_tool = @import("lua_tool.zig");
+const extension_loader = @import("extension_loader.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -12,6 +13,7 @@ test {
std.testing.refAllDecls(@This());
_ = lua_bridge;
_ = lua_tool;
+ _ = extension_loader;
}
const Receiver = panto.provider.Receiver;
@@ -225,26 +227,20 @@ pub fn main(init: std.process.Init) !void {
// the tool-call loop against a real LLM.
try agent.registerTool(ping_tool.tool());
- // Load any Lua extensions specified via `--lua <path>` flags. This is a
- // slice-2 manual hook — slice 3 will replace it with directory discovery.
- const argv = try init.minimal.args.toSlice(init.arena.allocator());
- var i: usize = 1;
- while (i < argv.len) : (i += 1) {
- const a = argv[i];
- if (std.mem.eql(u8, a, "--lua")) {
- i += 1;
- if (i >= argv.len) {
- std.log.err("--lua requires a path argument", .{});
- return error.MissingLuaPath;
- }
- const path = argv[i];
- const n = lua_tool.loadExtension(alloc, &agent.registry, path) catch |err| {
- std.log.err("failed to load Lua extension {s}: {t}", .{ path, err });
- return err;
- };
- std.log.debug("lua: loaded {d} tool(s) from {s}", .{ n, path });
- }
- }
+ // Discover Lua extensions from $XDG_CONFIG_HOME/panto/extensions (or
+ // $HOME/.config/panto/extensions) and ./.panto/extensions. Project
+ // entries shadow user entries with the same name; tool-name collisions
+ // between extensions abort startup.
+ const n_ext_tools = extension_loader.discoverAndLoad(
+ alloc,
+ io,
+ init.environ_map,
+ &agent.registry,
+ ) catch |err| {
+ std.log.err("extension discovery failed: {t}", .{err});
+ return err;
+ };
+ std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
const banner_model: []const u8 = switch (config) {
inline else => |c| c.model,