Files
Ignacy Łątka c5bab2a83d fix(cli): let a tool's own --args field win over the whole-payload flag (#458)
## Bug

`argent run flow-add-step` declares its own `args` field — a JSON string
holding the recorded step's tool arguments. But the CLI flag parser
hardcoded `--args` as a whole-payload escape hatch, so `--args` shadowed
the tool's own `args` field. The documented per-flag form

```
argent run flow-add-step --command gesture-tap --args '{"udid":"...","x":0.5,"y":0.5}'
```

silently dropped the step arguments: the `--args` JSON was consumed as
the whole payload and the tool's `args` field stayed `undefined`,
producing a misleading downstream validation error. Only the nested
full-payload form worked.

## Fix

Make `--args` schema-aware in `parseFlags()`:

- When the tool's schema declares a property named `args`
(`schema.properties.args`), `--args <value>` is routed through the
normal per-field handling and coerced by its declared type (a `string`
for flow-add-step, so the raw JSON string passes straight into the
`args` field). Both `--args value` and `--args=value` forms are
supported.
- When the schema does NOT declare `args`, the existing whole-payload
behavior is preserved exactly, including the `--args -` stdin sentinel.

`printToolHelp()` no longer prints the global whole-payload `--args
<json>` / `--args -` lines for tools that declare their own `args`
field, so the help stops contradicting the per-field `--args` shown in
the schema flags.

The change is general: any tool with an `args` field benefits, with no
special-casing by tool name.

## Verification

- `npm run build` (root tsc) — clean.
- `npm test -w @argent/cli` — 172 passed, including 6 new `parseFlags`
cases covering schema-with-`args` (space-separated + inline forms, plus
sibling fields) and schema-without-`args` (whole-payload preserved,
`--args -` sentinel, no-schema fallback).
- Prettier + ESLint (`--max-warnings 0`) clean on the touched files.

Full CLI end-to-end (running `flow-add-step` against a live tool-server
with an active flow recording) was not exercised here; the added unit
tests prove the parser behavior.

Fixes #452
---

### CLI end-to-end verification

Ran the documented per-flag form against a live tool-server with an
active flow recording:

- **Before (global argent 0.13.0):** `argent run flow-add-step --command
gesture-tap --args '{"udid":"...","x":0.5,"y":0.35}'` → `Invalid params
for tool "gesture-tap": udid expected string, received undefined ...`
(args dropped).
- **After (this branch):** same command → `{ "message": "Step added to
\"colltest\" flow", "toolResult": { "tapped": true } }`, and the
recorded YAML contains the step **with** its args (`udid`/`x`/`y`). The
per-flag form now works as documented.
2026-07-07 13:37:09 +02:00

273 lines
11 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { parseFlags, FlagParseException, type JsonSchema } from "../src/flag-parser.js";
const numSchema: JsonSchema = {
type: "object",
properties: { x: { type: "number" }, n: { type: "integer" } },
};
const arrSchema: JsonSchema = {
type: "object",
properties: { tags: { type: "array", items: { type: "string" } } },
};
const numArrSchema: JsonSchema = {
type: "object",
properties: { nums: { type: "array", items: { type: "number" } } },
};
describe("flag-parser number coercion rejects empty/whitespace", () => {
it("rejects --x= (empty)", () => {
expect(() => parseFlags(["--x="], numSchema)).toThrow(FlagParseException);
});
it('rejects --x " " (whitespace)', () => {
expect(() => parseFlags(["--x", " "], numSchema)).toThrow(FlagParseException);
});
it("rejects --n= (empty integer)", () => {
expect(() => parseFlags(["--n="], numSchema)).toThrow(FlagParseException);
});
it("still accepts a valid number", () => {
expect(parseFlags(["--x", "12"], numSchema).args.x).toBe(12);
});
it("still accepts a number with surrounding whitespace", () => {
// The empty/whitespace guard keys off raw.trim() === "", so a padded but
// otherwise-valid number (" 12 ") must still be accepted — Number() ignores
// the surrounding whitespace. Pins that the guard doesn't over-reject.
expect(parseFlags(["--x", " 12 "], numSchema).args.x).toBe(12);
});
it("still rejects a non-numeric string", () => {
expect(() => parseFlags(["--x", "abc"], numSchema)).toThrow(FlagParseException);
});
it("still ACCEPTS zero and negatives (guards against over-rejecting falsy numbers)", () => {
// The whole fix exists because Number("") === 0 slipped through, so the
// tempting-but-wrong guard is `if (!Number(raw))` / `Number(raw) === 0`,
// which would ALSO reject the legitimate value 0. Pin that 0 and negatives
// (and exponent form) still parse — the guard must key off emptiness, not
// falsiness.
expect(parseFlags(["--x=0"], numSchema).args.x).toBe(0);
expect(parseFlags(["--x=-5"], numSchema).args.x).toBe(-5);
expect(parseFlags(["--x=1e3"], numSchema).args.x).toBe(1000);
expect(parseFlags(["--n=0"], numSchema).args.n).toBe(0);
expect(parseFlags(["--n=-3"], numSchema).args.n).toBe(-3);
});
it("rejects a non-integer / whitespace-only integer, still accepts a valid one", () => {
expect(() => parseFlags(["--n=1.5"], numSchema)).toThrow(FlagParseException);
expect(() => parseFlags(["--n", " "], numSchema)).toThrow(FlagParseException);
expect(parseFlags(["--n", " 7 "], numSchema).args.n).toBe(7);
});
});
describe("flag-parser array + -json interleave never throws a raw error", () => {
it("throws FlagParseException (not TypeError) on interleave", () => {
let err: unknown;
try {
parseFlags(["--tags", "a", "--tags-json", '"b"', "--tags", "c"], arrSchema);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(FlagParseException);
// Assert the message too — it names the field/flags and the guidance the
// user acts on, so a wording or flag-interpolation regression is caught.
expect((err as Error).message).toMatch(/--tags and --tags-json cannot be mixed/);
});
it("rejects the reverse order too, instead of silently discarding --tags-json", () => {
// --tags-json first, then a plain --tags: the plain flag used to hit the
// "first occurrence" branch and silently overwrite the JSON-parsed value
// with no error at all.
expect(() => parseFlags(["--tags-json", '["a","b"]', "--tags", "c"], arrSchema)).toThrow(
/--tags and --tags-json cannot be mixed/
);
});
it("rejects --tags-json arriving after a plain --tags, instead of silently overwriting it", () => {
expect(() => parseFlags(["--tags", "a", "--tags-json", '["b","c"]'], arrSchema)).toThrow(
/--tags and --tags-json cannot be mixed/
);
});
it("reports the mixing error (not a coercion error) when the plain numeric value is also invalid", () => {
// For a numeric array, the mixing check runs BEFORE scalar coercion, so a
// mixed --nums-json/--nums whose plain value ALSO fails to coerce surfaces
// the actionable "cannot be mixed" error rather than "expected a number".
expect(() => parseFlags(["--nums-json", "[1]", "--nums", "abc"], numArrSchema)).toThrow(
/--nums and --nums-json cannot be mixed/
);
// A valid plain value in the same mix still reports the mixing error.
expect(() => parseFlags(["--nums-json", "[1]", "--nums", "5"], numArrSchema)).toThrow(
/--nums and --nums-json cannot be mixed/
);
});
it("still allows repeated plain --tags with no -json involved", () => {
expect(parseFlags(["--tags", "a", "--tags", "b"], arrSchema).args.tags).toEqual(["a", "b"]);
});
it("still allows a bare --tags-json with no plain --tags involved", () => {
expect(parseFlags(["--tags-json", '["a","b"]'], arrSchema).args.tags).toEqual(["a", "b"]);
});
});
// A tool (like flow-add-step) whose schema declares its own `args` field — a
// JSON string holding the recorded step's tool arguments.
const flowAddStepSchema: JsonSchema = {
type: "object",
properties: {
command: { type: "string" },
args: { type: "string" },
delayMs: { type: "integer" },
},
required: ["command"],
};
// A tool (like gesture-tap) with NO `args` field — here `--args` must stay the
// whole-payload escape hatch.
const gestureTapSchema: JsonSchema = {
type: "object",
properties: {
udid: { type: "string" },
x: { type: "number" },
y: { type: "number" },
},
required: ["udid", "x", "y"],
};
describe("parseFlags — schema-aware --args", () => {
it("treats --args as the tool's own string field (space-separated form)", () => {
const result = parseFlags(
["--command", "gesture-tap", "--args", '{"udid":"X","x":0.5}'],
flowAddStepSchema
);
expect(result.args.command).toBe("gesture-tap");
// The raw JSON string is passed through untouched into the `args` field...
expect(result.args.args).toBe('{"udid":"X","x":0.5}');
// ...and NOT consumed as the whole-payload escape hatch.
expect(result.rawArgs).toBeNull();
});
it("treats --args=<value> inline form as the tool's own field", () => {
const result = parseFlags(
["--command", "gesture-tap", '--args={"udid":"X","x":0.5}'],
flowAddStepSchema
);
expect(result.args.command).toBe("gesture-tap");
expect(result.args.args).toBe('{"udid":"X","x":0.5}');
expect(result.rawArgs).toBeNull();
});
it("still parses sibling fields alongside the --args field", () => {
const result = parseFlags(
["--command", "screenshot", "--args", "{}", "--delayMs", "250"],
flowAddStepSchema
);
expect(result.args.command).toBe("screenshot");
expect(result.args.args).toBe("{}");
expect(result.args.delayMs).toBe(250);
expect(result.rawArgs).toBeNull();
});
it("treats --args - as the literal field value, NOT the stdin sentinel", () => {
// For a tool that owns `args`, `-` is just this field's value. The
// whole-payload stdin sentinel must not fire, so `rawArgs` stays null and
// nothing is read from stdin. This is the inverse of the no-`args` case
// below; asserting it here guards against a refactor that moved the `-`
// sentinel ahead of the `properties.args === undefined` gate and started
// routing flow-add-step's `--args -` to stdin.
const result = parseFlags(["--command", "gesture-tap", "--args", "-"], flowAddStepSchema);
expect(result.args.command).toBe("gesture-tap");
expect(result.args.args).toBe("-");
expect(result.rawArgs).toBeNull();
});
it("treats --args=- inline form as the literal field value too", () => {
const result = parseFlags(["--command", "gesture-tap", "--args=-"], flowAddStepSchema);
expect(result.args.args).toBe("-");
expect(result.rawArgs).toBeNull();
});
});
// A hypothetical future tool that OWNS its top-level `args` field but as an
// object / array of objects (flow-add-step's is a string). The whole-payload
// `--args '<json>'` hatch is disabled for any tool that owns `args`, so the
// error text must NOT suggest that dead-end form — only `--<field>-json` works.
const objectArgsSchema: JsonSchema = {
type: "object",
properties: { args: { type: "object" } },
};
const arrayArgsSchema: JsonSchema = {
type: "object",
properties: { args: { type: "array", items: { type: "object" } } },
};
// A control tool that does NOT own `args`, with an object field. Here the
// whole-payload hatch exists, so the "or --args '<json>'" suggestion must stay.
const objectFieldSchema: JsonSchema = {
type: "object",
properties: { filter: { type: "object" } },
};
describe("parseFlags — error hints omit the whole-payload --args form when the tool owns `args`", () => {
it("object `args`: suggests only --args-json, not the dead-end --args '<json>'", () => {
let err: unknown;
try {
parseFlags(["--args", '{"a":1}'], objectArgsSchema);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(FlagParseException);
const msg = (err as Error).message;
expect(msg).toBe("--args is an object; pass it as --args-json '<json>'");
// The whole-payload form no longer routes to the hatch for this tool, so it
// must not be advertised as a fallback (it would just re-enter this branch).
expect(msg).not.toContain("or --args '<json>'");
});
it("array-of-objects `args`: suggests only --args-json, not the dead-end --args '<json>'", () => {
let err: unknown;
try {
parseFlags(["--args", '[{"a":1}]'], arrayArgsSchema);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(FlagParseException);
const msg = (err as Error).message;
expect(msg).toBe("--args is an array of objects; pass it as --args-json '<json>'");
expect(msg).not.toContain("or --args '<json>'");
});
it("still suggests --args '<json>' for an object field on a tool WITHOUT its own `args`", () => {
// Control: dropping the whole-payload suggestion is scoped to tools that own
// `args`; for everyone else the hatch exists and stays advertised.
let err: unknown;
try {
parseFlags(["--filter", '{"a":1}'], objectFieldSchema);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(FlagParseException);
expect((err as Error).message).toBe(
"--filter is an object; pass it as --filter-json '<json>' or --args '<json>'"
);
});
});
describe("parseFlags — whole-payload --args (no own `args` field)", () => {
it("keeps --args as the whole-payload escape hatch", () => {
const result = parseFlags(["--args", '{"udid":"X","x":0.5,"y":0.5}'], gestureTapSchema);
expect(result.rawArgs).toBe('{"udid":"X","x":0.5,"y":0.5}');
// `args` must not appear as a parsed field.
expect("args" in result.args).toBe(false);
});
it("keeps the --args - stdin sentinel as whole-payload", () => {
const result = parseFlags(["--args", "-"], gestureTapSchema);
expect(result.rawArgs).toBe("-");
expect("args" in result.args).toBe(false);
});
it("keeps whole-payload behavior when no schema is provided", () => {
const result = parseFlags(["--args", '{"udid":"X"}'], undefined);
expect(result.rawArgs).toBe('{"udid":"X"}');
expect("args" in result.args).toBe(false);
});
});