cli: add run command for script replay

`lightpanda agent <script>` replays a saved script token-free (no LLM),
but running it under `agent` reads wrong. Add `run` as a dedicated,
script-only command: `lightpanda run <script>`.

`run` exposes only a required [SCRIPT] positional plus the common
options; its LLM-session flags (--task/--save/--list-models/REPL) are
absent, so they're rejected at parse time. `parseArgs` normalizes a
`run` invocation to an `.agent` command, reusing the existing token-free
replay path unchanged, so nothing downstream needs a `.run` case.

`agent <script>` keeps working. Telemetry records `run` as its own mode
code (R) via a new Config.command field, so its adoption is measurable
separately from agent script replay (AR).
This commit is contained in:
Adrià Arrufat
2026-07-12 23:37:23 +02:00
parent 2649b4c6cf
commit 08eecfbbac
4 changed files with 58 additions and 8 deletions

View File

@@ -78,7 +78,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.mode, config.interactive());
app.telemetry = try Telemetry.init(app, config.command, config.interactive());
errdefer app.telemetry.deinit(allocator);
app.arena_pool = ArenaPool.init(allocator, .{});

View File

@@ -263,6 +263,13 @@ const Commands = cli.Builder(.{
},
.shared_options = CommonOptions,
},
.{
// Normalized to `.agent` in `parseArgs`; intentionally no LLM options.
.name = "run",
.positional = .{ .name = "script_file", .type = ?[:0]const u8 },
.options = .{},
.shared_options = CommonOptions,
},
.{ .name = "version", .options = .{
.{ .name = "check", .type = bool },
} },
@@ -273,6 +280,9 @@ pub const Mode = Commands.Union;
pub const Agent = @FieldType(Mode, "agent");
mode: Mode,
// The command as typed. Mirrors `mode`, except `run` normalizes to `.agent`
// for execution while this keeps `.run` for telemetry.
command: RunMode,
exec_name: []const u8,
http_headers: HttpHeaders,
@@ -287,6 +297,7 @@ fn modeNeedsHttp(mode: Mode) bool {
pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
var config = Config{
.mode = mode,
.command = std.meta.activeTag(mode),
.exec_name = exec_name,
.http_headers = undefined,
};
@@ -713,7 +724,7 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool)
, .{Help.general});
std.debug.print(template, .{exec_name});
},
inline .fetch, .serve, .mcp, .agent => |tag| {
inline .fetch, .serve, .mcp, .agent, .run => |tag| {
const template = comptimePrint(
\\{s}
\\
@@ -735,11 +746,28 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool)
}
pub fn parseArgs(allocator: Allocator) !Config {
const exec_name, const command = try Commands.parse(allocator);
const exec_name, var command = try Commands.parse(allocator);
if (command == .serve and command.serve.timeout != null) {
log.warn(.app, "--timeout is deprecated", .{});
}
return .init(allocator, exec_name, command);
const invoked = std.meta.activeTag(command);
// Rewrite `run` to `.agent` so nothing downstream needs a `.run` case.
if (command == .run) {
const run = command.run;
if (run.script_file == null) {
log.fatal(.app, "missing script file", .{ .hint = "usage: lightpanda run <script.js>" });
return error.MissingArgument;
}
// run's fields are a strict subset of Agent's (compile error otherwise).
var agent_opts: Agent = .{};
inline for (@typeInfo(@TypeOf(run)).@"struct".fields) |f| {
@field(agent_opts, f.name) = @field(run, f.name);
}
command = .{ .agent = agent_opts };
}
var config = try Config.init(allocator, exec_name, command);
config.command = invoked;
return config;
}
pub fn validateUserAgent(ua: []const u8) !void {

View File

@@ -8,6 +8,7 @@
\\ fetch fetches the specified URL
\\ help displays this message
\\ mcp starts an MCP (Model Context Protocol) server over stdio
\\ run runs a saved script (no LLM), then exits
\\ serve starts a WebSocket CDP server
\\ version displays the version of {0s}
\\
@@ -143,14 +144,15 @@
\\ {0s} agent --provider huggingface (HF serverless router, HF_TOKEN)
\\ {0s} agent --provider ollama --model qwen3.5:latest
\\ {0s} agent --no-llm (basic slash-command-only REPL)
\\ {0s} agent script.js (run a saved script, then exit)
\\ {0s} run script.js (replay a saved script; see `run`)
\\ {0s} agent --task "..." --save out.js (synthesize a replayable script)
\\
\\Arguments:
\\[SCRIPT]
\\ Optional path to a .js script. Runs the script (no LLM calls) and
\\ exits. With no script and no --task, the REPL starts; from there
\\ /load runs a script and /save exports the session to a file.
\\ exits; `{0s} run SCRIPT` is the preferred spelling. With no script
\\ and no --task, the REPL starts; from there /load runs a script and
\\ /save exports the session to a file.
\\ Caution: .js files can contain evaluate(...) calls that run
\\ arbitrary JavaScript in the page. Only run scripts you trust, the
\\ same way you would a shell script.
@@ -209,7 +211,7 @@
\\ --save <PATH>
\\ Synthesize a replayable .js script from the --task run and write
\\ it to PATH, instead of printing the answer. Replay it later with
\\ `agent PATH` (no LLM calls). Overwrites PATH if it exists.
\\ `run PATH` (no LLM calls). Overwrites PATH if it exists.
\\ Requires --task.
\\ --system-prompt <STRING>
\\ Override the default system prompt.
@@ -237,6 +239,25 @@
\\MISTRAL_API_KEY. The local servers (Ollama, llama.cpp) do not require an
\\API key.
,
.run =
\\run command
\\Runs a saved script, then exits. No LLM calls, no API key needed.
\\
\\Usage:
\\ {0s} run <SCRIPT> [COMMON_OPTIONS]
\\
\\Examples:
\\ {0s} run script.js (replay a saved script)
\\
\\Arguments:
\\<SCRIPT>
\\ Path to a .js script to run, then exit. Produce one with
\\ `{0s} agent --task "..." --save script.js`, or `/save` from the
\\ agent REPL.
\\ Caution: .js files can contain evaluate(...) calls that run
\\ arbitrary JavaScript in the page. Only run scripts you trust, the
\\ same way you would a shell script.
,
.version =
\\usage: {0s} version [OPTIONS]
\\

View File

@@ -82,6 +82,7 @@ pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode
.fetch => "F",
.serve => "S",
.agent => if (interactive == false) "AR" else "A",
.run => "R",
.mcp => "M",
.version => "V",
.help => "H",