1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
|
//! Subcommand dispatch for the `panto` CLI.
//!
//! Routes argv[1] to one of:
//! - `lua` — drop into the embedded standalone Lua interpreter
//! (panto's `lua.c` build), with luarocks's runtime
//! bootstrap completed first so `require("luarocks.*")`
//! and the configured rocks tree work the same as in
//! the agent process.
//! - `bootstrap` — run the luarocks runtime bootstrap pipeline only;
//! exit before entering any agent loop. Lets users
//! do first-run setup on a fresh machine without
//! starting a chat session.
//! - anything else (or absent) — fall through to the agent REPL.
//!
//! Both `lua` and `bootstrap` end up calling `luarocks_runtime.bootstrap`
//! before doing their thing. The agent path does the same; the only
//! difference is whether the agent loop runs afterward.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const lua_bridge = @import("lua_bridge.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
const self_exe = @import("self_exe.zig");
const session_paths = @import("session_paths.zig");
const config_file = @import("config_file.zig");
const panto_home = @import("panto_home.zig");
const panto = @import("panto");
const c = lua_bridge.c;
pub const Action = enum {
/// Continue with the default agent REPL.
agent,
/// Bootstrap is already done; the dispatcher consumed the subcommand.
/// `main` should exit immediately.
done,
};
/// Inspect `argv[1]`, run the appropriate subcommand, and return what
/// the caller should do next. On `.agent`, the dispatcher leaves argv
/// untouched and `main` continues as before. On `.done`, the caller
/// must return promptly (the subcommand has already produced output).
///
/// `panto_executable_path` is the absolute path of the running panto
/// binary, used both to wire up the embedded luarocks `LUA` variable
/// and to `exec` ourselves where needed.
pub fn dispatch(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
args: std.process.Args,
panto_executable_path: []const u8,
) !Action {
var it = args.iterate();
defer it.deinit();
_ = it.next(); // argv[0]
const sub = it.next() orelse return .agent;
if (std.mem.eql(u8, sub, "lua")) {
try runLuaSubcommand(allocator, io, environ_map, args, panto_executable_path);
return .done;
}
if (std.mem.eql(u8, sub, "bootstrap")) {
var force = false;
while (it.next()) |flag| {
if (std.mem.eql(u8, flag, "--force")) {
force = true;
} else {
std.log.err("panto bootstrap: unknown flag '{s}'", .{flag});
return error.UnknownFlag;
}
}
try runBootstrapSubcommand(allocator, io, environ_map, panto_executable_path, .{ .force = force });
return .done;
}
if (std.mem.eql(u8, sub, "sessions")) {
try runSessionsSubcommand(allocator, io, environ_map);
return .done;
}
if (std.mem.eql(u8, sub, "auth")) {
try runAuthSubcommand(allocator, io, environ_map, &it);
return .done;
}
if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) {
try printHelp(io);
return .done;
}
return .agent;
}
fn printHelp(io: Io) !void {
var buffer: [4096]u8 = undefined;
var stdout_file = std.Io.File.stdout().writer(io, &buffer);
const w = &stdout_file.interface;
try w.writeAll(
\\panto — a conversational coding agent
\\
\\Usage:
\\ panto Start a new conversation.
\\ panto --resume Resume the most recent conversation in this directory.
\\ panto --resume <id> Resume the conversation whose id begins with <id>.
\\ panto sessions List saved sessions for this directory.
\\ panto auth status Show configured auth sessions and login state.
\\ panto auth login <name>
\\ Log in to an OAuth auth session (device flow).
\\ panto auth logout <name>
\\ Forget a stored OAuth token.
\\ panto bootstrap [--force]
\\ Run the luarocks bootstrap and exit.
\\ panto lua [args...] Drop into the embedded Lua interpreter.
\\ panto help Show this message.
\\
\\Configuration (TOML, merged base → user → project):
\\ $XDG_DATA_HOME/panto/config.toml (base; auto-generated)
\\ $XDG_CONFIG_HOME/panto/config.toml (user)
\\ ./.panto/config.toml (project)
\\ Define providers under [providers.<name>], pick a default with
\\ [defaults] model = "<provider>:<alias>", and gate tools/extensions
\\ with [tools]/[extensions] allow/deny globs. Model aliases (wire name,
\\ reasoning, max_tokens, pricing) live in models.toml.
\\
\\Environment:
\\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers.
\\ PANTO_SESSION_DIR Override the base sessions directory. Defaults to
\\ $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions.
\\ PANTO_HOME Override the runtime/rocks tree location.
\\
);
try stdout_file.flush();
}
pub const BootstrapOptions = struct {
/// Wipe the per-Lua-version tree before reinstalling everything.
/// Surfaced as `panto bootstrap --force`. Equivalent to deleting
/// `$PANTO_HOME/rocks/lua-X.Y.Z/` by hand and then running
/// `panto bootstrap`.
force: bool = false,
};
// ---------------------------------------------------------------------------
// `panto lua`
// ---------------------------------------------------------------------------
extern "c" fn panto_lua_pmain(L: *c.lua_State, argc: c_int, argv: [*]?[*:0]u8) c_int;
/// Drop into the embedded Lua standalone interpreter, with the
/// luarocks runtime bootstrap completed so `require("luarocks.*")`
/// and rocks installed under `$PANTO_HOME` are visible.
///
/// argv is rewritten so the interpreter sees `lua [...args]` rather
/// than `panto lua [...args]` — matching upstream behavior. The first
/// argument visible to `pmain` is the program name; this matters for
/// `arg[0]` and error reporting.
fn runLuaSubcommand(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
args: std.process.Args,
panto_executable_path: []const u8,
) !void {
// Build a fresh lua_State that we own, configure it like luarocks
// expects, then hand it to `pmain`.
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
// Run bootstrap against this state. This installs the embedded
// searcher, configures package.path/cpath, and stages on-disk
// resources. We deliberately do NOT call `luaL_openlibs` here —
// `pmain` does that itself, and we want exactly the upstream
// ordering for everything that runs inside the REPL.
//
// The searcher install only requires `package.searchers` to be
// present; the stock libs ship it. We open libs once here just
// to satisfy that, then pmain's own `luaL_openlibs` is idempotent.
c.luaL_openlibs(L);
const rt = try luarocks_runtime.bootstrap(
allocator,
io,
environ_map,
L,
panto_executable_path,
);
defer rt.deinit();
// Re-create the argv the standalone interpreter expects. argv[0]
// is the program name; argv[1..] are the user's args.
var raw_args = args.iterate();
defer raw_args.deinit();
_ = raw_args.next(); // panto
_ = raw_args.next(); // lua
var argv_list: std.array_list.Managed([:0]u8) = .init(allocator);
defer {
for (argv_list.items) |s| allocator.free(s);
argv_list.deinit();
}
// Program name first.
try argv_list.append(try allocator.dupeZ(u8, "lua"));
while (raw_args.next()) |a| {
try argv_list.append(try allocator.dupeZ(u8, a));
}
// Build a `[*]?[*:0]u8` argv pointer array. lua.c expects a
// NULL-terminated array (it uses `argv[i]` indexed access through
// argc; the trailing NULL is conventional for C `main`).
var argv_c: std.array_list.Managed(?[*:0]u8) = .init(allocator);
defer argv_c.deinit();
for (argv_list.items) |s| {
try argv_c.append(s.ptr);
}
try argv_c.append(null);
const exit_code = panto_lua_pmain(L, @intCast(argv_list.items.len), argv_c.items.ptr);
if (exit_code != 0) std.process.exit(@intCast(exit_code));
}
// ---------------------------------------------------------------------------
// `panto bootstrap`
// ---------------------------------------------------------------------------
/// Run the luarocks bootstrap and exit. Useful for first-run setup on
/// a clean machine (downloads + compiles batteries, stages headers,
/// materializes config) and for CI/scripted installs.
///
/// Idempotent: subsequent invocations no-op fast, unless `force` was
/// passed — then the entire per-Lua-version tree is wiped before the
/// regular bootstrap pipeline runs.
fn runBootstrapSubcommand(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
panto_executable_path: []const u8,
opts: BootstrapOptions,
) !void {
if (opts.force) {
try luarocks_runtime.wipeTree(allocator, io, environ_map);
}
const L = c.luaL_newstate() orelse return error.LuaInitFailed;
defer c.lua_close(L);
c.luaL_openlibs(L);
const rt = try luarocks_runtime.bootstrap(
allocator,
io,
environ_map,
L,
panto_executable_path,
);
defer rt.deinit();
// Pleasant single-line confirmation. The interesting bits (rock
// installs etc.) print their own progress.
std.log.info(
"panto bootstrap: tree ready at {s}",
.{rt.layout.tree},
);
}
// ---------------------------------------------------------------------------
// `panto sessions`
// ---------------------------------------------------------------------------
/// List sessions for the current working directory.
///
/// Output format (one session per line):
/// <short-id> <created> <message-count> messages
///
/// where `<short-id>` is the first 8 hex chars of the session UUIDv7.
fn runSessionsSubcommand(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
) !void {
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd_n = try std.process.currentPath(io, &cwd_buf);
const cwd = cwd_buf[0..cwd_n];
const session_dir = try session_paths.sessionDirForCwd(allocator, environ_map, cwd);
defer allocator.free(session_dir);
var store_impl = try panto.FileSystemJSONLStore.init(allocator, io, session_dir);
defer store_impl.deinit();
const store = store_impl.store();
const infos = try store.list();
defer store.freeSessionInfos(infos);
var stdout_buffer: [4096]u8 = undefined;
var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_file.interface;
if (infos.len == 0) {
try stdout.print("no sessions for {s}\n", .{cwd});
try stdout_file.flush();
return;
}
for (infos) |info| {
const short = info.id[0..@min(8, info.id.len)];
// `created` is ISO 8601 (e.g. `2026-04-25T17:40:15.990Z`). Trim
// to `YYYY-MM-DD HH:MM` for terseness.
const created_short = trimCreated(info.created);
try stdout.print(
"{s} {s} {d} messages\n",
.{ short, created_short, info.message_count },
);
}
try stdout_file.flush();
}
fn trimCreated(iso: []const u8) []const u8 {
if (iso.len < 16) return iso;
// `YYYY-MM-DDTHH:MM:...` → `YYYY-MM-DD HH:MM` (T → space).
// We can't mutate a borrowed slice, so just return a 16-byte slice
// of the original; the caller prints character-by-character via
// format, so the 'T' will still appear. Use a small buffer trick:
// return the slice unmodified — the 'T' is fine and unambiguous.
return iso[0..16];
}
// ---------------------------------------------------------------------------
// `panto auth`
// ---------------------------------------------------------------------------
/// Line-based device-code presenter for the `panto auth login` flow (the TUI
/// is not running here). Prints the verification URL + user code to stdout.
const CliPresenter = struct {
io: Io,
fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void {
const self: *CliPresenter = @ptrCast(@alignCast(ptr));
var buf: [1024]u8 = undefined;
var fw = std.Io.File.stdout().writer(self.io, &buf);
const w = &fw.interface;
w.print(
"\nTo authorize, open this URL in a browser:\n {s}\n\nand enter the code:\n {s}\n\n",
.{ prompt.verification_uri, prompt.user_code },
) catch {};
fw.flush() catch {};
}
fn status(ptr: *anyopaque, msg: []const u8) void {
const self: *CliPresenter = @ptrCast(@alignCast(ptr));
var buf: [256]u8 = undefined;
var fw = std.Io.File.stdout().writer(self.io, &buf);
const w = &fw.interface;
w.print("{s}\n", .{msg}) catch {};
fw.flush() catch {};
}
const vtable: panto.Presenter.VTable = .{
.on_device_code = deviceCode,
.on_status = status,
};
fn presenter(self: *CliPresenter) panto.Presenter {
return .{ .ptr = self, .vtable = &vtable };
}
};
fn nowUnix(io: Io) i64 {
const ns = std.Io.Clock.now(.real, io).nanoseconds;
return @intCast(@divFloor(ns, std.time.ns_per_s));
}
/// `panto auth [status|login <name>|logout <name>]`.
fn runAuthSubcommand(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
it: *std.process.Args.Iterator,
) !void {
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd_n = try std.process.currentPath(io, &cwd_buf);
const cwd = cwd_buf[0..cwd_n];
var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| {
std.log.err("panto auth: failed to load config ({t})", .{err});
return err;
};
defer cfg.deinit();
var layout = try panto_home.resolve(allocator, environ_map);
defer layout.deinit();
const auth_dir = layout.auth_dir;
var out_buf: [4096]u8 = undefined;
var out_file = std.Io.File.stdout().writer(io, &out_buf);
const out = &out_file.interface;
const action = it.next() orelse "status";
if (std.mem.eql(u8, action, "status")) {
try authStatus(allocator, io, &cfg, auth_dir, out);
try out_file.flush();
return;
}
if (std.mem.eql(u8, action, "logout")) {
const name = it.next() orelse {
try out.writeAll("usage: panto auth logout <name>\n");
try out_file.flush();
return;
};
const removed = try panto.deleteTokenSet(allocator, io, auth_dir, name);
if (removed) {
try out.print("logged out of '{s}'\n", .{name});
} else {
try out.print("no stored token for '{s}'\n", .{name});
}
try out_file.flush();
return;
}
if (std.mem.eql(u8, action, "login")) {
const name = it.next() orelse {
try out.writeAll("usage: panto auth login <name>\n");
try out_file.flush();
return;
};
try authLogin(allocator, io, &cfg, auth_dir, name, out);
try out_file.flush();
return;
}
try out.print("unknown auth action '{s}' (try: status, login, logout)\n", .{action});
try out_file.flush();
}
fn authStatus(
allocator: Allocator,
io: Io,
cfg: *const config_file.Config,
auth_dir: []const u8,
out: *std.Io.Writer,
) !void {
if (cfg.auths.len == 0) {
try out.writeAll("no auth sessions configured\n");
return;
}
const now = nowUnix(io);
for (cfg.auths) |a| {
switch (a.config) {
.api_key => {
const state = if (a.resolved_api_key != null) "resolved" else "unresolved (key/env missing)";
try out.print("{s} api_key {s}\n", .{ a.name, state });
},
.oauth_device => {
var loaded = panto.loadTokenSet(allocator, io, auth_dir, a.name) catch null;
defer if (loaded) |*l| l.deinit();
if (loaded) |l| {
const ts = l.value;
if (ts.expires_at) |exp| {
const mins = @divFloor(exp - now, 60);
try out.print("{s} oauth_device logged in (access expires in ~{d}m)\n", .{ a.name, mins });
} else {
try out.print("{s} oauth_device logged in\n", .{a.name});
}
} else {
try out.print("{s} oauth_device not logged in (run: panto auth login {s})\n", .{ a.name, a.name });
}
},
}
}
}
fn authLogin(
allocator: Allocator,
io: Io,
cfg: *const config_file.Config,
auth_dir: []const u8,
name: []const u8,
out: *std.Io.Writer,
) !void {
const a = cfg.auth(name) orelse {
try out.print("no auth session named '{s}' in config\n", .{name});
return;
};
const oauth = switch (a.config) {
.oauth_device => |o| o,
.api_key => {
try out.print("'{s}' is an api_key session; nothing to log in to\n", .{name});
return;
},
};
panto.init(allocator, io);
defer panto.deinit();
const client = panto.httpClient();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();
var presenter = CliPresenter{ .io = io };
const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter()) catch |err| {
try out.print("login failed: {t}\n", .{err});
return;
};
const now = nowUnix(io);
var ts = try panto.tokensToTokenSet(aa, oauth, toks, now);
// Run the secondary exchange now (if configured) so the first turn is
// immediately usable and we surface any exchange error during login.
if (oauth.exchange) |exchange| {
if (ts.access_token) |access| {
ts.exchange = panto.runExchange(aa, client, exchange, access) catch |err| blk: {
try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err});
break :blk null;
};
}
}
try panto.saveTokenSet(allocator, io, auth_dir, name, ts);
try out.print("\nauthorized — '{s}' is ready to use.\n", .{name});
}
// ---------------------------------------------------------------------------
// `panto lua` argv plumbing — sketched against the older Args API for
// reference (kept here so the design notes survive the implementation).
// ---------------------------------------------------------------------------
//
// Because we own the `lua_State` end-to-end, the subcommand can also
// expose extra panto-specific globals to user code (e.g. surface the
// resolved $PANTO_HOME) without disturbing upstream `lua.c` behavior.
// Step out of scope for the current makeover; add when needed.
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
// Note: `dispatch` reads from the process's real argv, which isn't
// controllable from a unit test. The behavior is exercised by
// integration runs of the panto binary. We test the smaller pieces.
//
// Suppress dead-code warnings for `self_exe` (it's used by main, not
// by tests in this module).
test {
_ = self_exe;
}
|