script: enforce positional arguments come first

Returns a specific `PositionalMustComeFirst` error when a positional
argument is placed after key-value pairs.
This commit is contained in:
Adrià Arrufat
2026-06-11 18:24:44 +02:00
parent d292f2b342
commit aeba63427e
4 changed files with 20 additions and 3 deletions

View File

@@ -1419,6 +1419,7 @@ pub fn printSlashParseError(self: *Terminal, err: Schema.ParseError, name: []con
error.UnknownField => "unknown field (typo?)",
error.DuplicateField => "the same field was supplied twice (check for case-variants like Selector vs selector)",
error.PositionalNotAllowed => "positional only works for commands with one required field. Use key=value",
error.PositionalMustComeFirst => "the positional value must come before key=value pairs",
error.UnterminatedQuote => "unterminated quote",
error.UnsupportedEscape => "backslash escapes aren't supported in quoted values; use the other quote style or `'''…'''`",
error.InvalidValue => "invalid value (check argument type)",

View File

@@ -162,8 +162,9 @@ pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembe
/// Precedence: explicit `--effort` flag > remembered `.lp-agent.zon` value >
/// mode default. The interactive REPL defaults to `.low` so turns stay snappy;
/// one-shot `--task` and script runs default to `.medium`, where answer
/// quality matters more than per-turn latency.
/// one-shot `--task` defaults to `.medium`, where answer quality matters more
/// than per-turn latency. (Script runs never call the LLM, so the resolved
/// effort is unused there.)
pub fn resolveEffort(opts: Config.Agent, remembered: ?Remembered, will_repl: bool) Config.Effort {
if (opts.effort) |e| return e;
if (remembered) |r| if (r.effort) |e| return e;

View File

@@ -78,6 +78,7 @@ pub const ParseError = error{
DuplicateField,
MalformedKv,
PositionalNotAllowed,
PositionalMustComeFirst,
UnterminatedQuote,
UnsupportedEscape,
InvalidValue,
@@ -224,7 +225,13 @@ pub fn parseValueDiag(self: Schema, arena: std.mem.Allocator, rest_raw: []const
list.appendAssumeCapacity(.{ .key = self.required[0], .value = stripQuotes(tokens[0]) });
}
for (tokens[kv_start..]) |tok| {
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse return error.MalformedKv;
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse {
// `/extract save=x '{…}'` — the value would have bound fine as a
// leading positional, so point at the ordering instead of the
// generic kv complaint.
if (self.required.len == 1 and !leading_positional) return error.PositionalMustComeFirst;
return error.MalformedKv;
};
if (eq == 0 or eq == tok.len - 1) return error.MalformedKv;
const key = tok[0..eq];
// Reject typos like `checke=false` that would otherwise be silently

View File

@@ -384,6 +384,14 @@ test "parse: /click rejects positional (zero required fields)" {
try testing.expectString("Login", cmd.tool_call.args.?.object.get("selector").?.string);
}
test "parse: /extract rejects positional after key=value" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.PositionalMustComeFirst, Command.parse(arena.allocator(), "/extract save=front '{\"karma\":\"#karma\"}'"));
const cmd = try Command.parse(arena.allocator(), "/extract '{\"karma\":\"#karma\"}' save=front");
try testing.expectString("front", cmd.tool_call.args.?.object.get("save").?.string);
}
test "parse: /scroll y=200" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();