Files
Jakub Piasecki d4a712c401 refactor(gesture): rename the momentum-free flag to momentum (#732)
`settle: true` collided with the runner's other `settle` — waiting for
the tree to quiesce — which is the very thing a swipe step does after
every dispatch, whichever way the flag is set. One sentence of the flow
skill used both senses at once.

This names the physics instead: `momentum: false` is the momentum-free
gesture, and the natural fling is the default the prose already
described.

### Renamed

| surface | before | after |
| --- | --- | --- |
| `swipe` flow directive | `settle: true` | `momentum: false` |
| `gesture-swipe` param | `settle` | `momentum` |
| `gesture-drag` param | `settle` | `momentum` |

The directive passes the flag straight through to the tools, so all
three move together. `momentum: true` is the default and normalizes to
absent, mirroring what `settle: false` did — parse/serialize stay exact
inverses.

### Also

- Recording summary label `(settle)` → `(momentum-free)`
- Internal constants → `MOMENTUM_FREE_EASE_EXPONENT`,
`MOMENTUM_FREE_MIN_STEPS`
- `flow-run` tool description and `argent-create-flow` skill updated

### Testing

`tsc -b`, eslint (`--max-warnings 0`) and prettier clean; `test/flows` +
`test/tools` pass 671/671, including a new case covering the
legacy-spelling rejection.
2026-08-28 09:55:47 +02:00

400 lines
13 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import {
createToolsClient,
materializeArtifacts,
getDeviceIdFromArgs,
type ToolMeta,
type ToolsServerPaths,
type MaterializedImage,
} from "@argent/tools-client";
import { init as telemetryInit, shutdown as telemetryShutdown, track } from "@argent/telemetry";
import { FAILURE_CODES, type FailureCode, type FailureKind } from "@argent/registry";
import {
parseFlags,
formatSchemaUsage,
FlagParseException,
type JsonSchema,
} from "./flag-parser.js";
import {
findMissingRequired,
describeServerValidationFailure,
formatValidationError,
missingFlagNames,
type ValidationReport,
} from "./run-validation.js";
export interface RunCommandOptions {
paths: ToolsServerPaths;
}
interface RunOptions {
json: boolean;
outPath: string | null;
argvForFlags: string[];
}
function splitOptions(argv: string[]): RunOptions {
// Consumed here rather than by the schema-driven flag parser, so a tool with its
// own "json" or "out" property can't capture them.
let json = false;
let outPath: string | null = null;
const rest: string[] = [];
for (let i = 0; i < argv.length; i++) {
const tok = argv[i]!;
if (tok === "--json") {
json = true;
continue;
}
if (tok === "--out") {
const v = argv[i + 1];
if (!v) throw new FlagParseException("--out requires a path");
outPath = v;
i += 1;
continue;
}
if (tok.startsWith("--out=")) {
outPath = tok.slice("--out=".length);
continue;
}
rest.push(tok);
}
return { json, outPath, argvForFlags: rest };
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
}
function printToolHelp(meta: ToolMeta): void {
const description = meta.description?.trim() ?? "";
const schema = meta.inputSchema as JsonSchema | undefined;
// A tool with its own `args` field (e.g. flow-add-step) shows `--args` as a per-field
// flag in the schema block, so the whole-payload escape hatch no longer applies.
const hasOwnArgsField = schema?.properties?.args !== undefined;
console.log(`argent run ${meta.name} [flags]`);
if (description) console.log(`\n${description}\n`);
console.log("Flags:");
console.log(formatSchemaUsage(schema));
console.log("\nGlobal flags:");
if (!hasOwnArgsField) {
console.log(" --args <json> Pass the entire payload as JSON (overrides flags)");
console.log(" --args - Read the entire payload as JSON from stdin");
}
console.log(" --<field>-json <json> Pass a single field as JSON (objects/nested arrays)");
console.log(" --json Print the raw JSON result");
console.log(" --out <path> For image results, save to <path> instead of fetching URL");
console.log(" --help, -h Show this help");
}
async function fetchImageToFile(
result: { url?: string; path?: string },
outPath: string
): Promise<void> {
const url = result.url;
if (!url) {
throw new Error("Tool result did not include a `url`; cannot save image");
}
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download image: ${res.status} ${res.statusText}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
fs.writeFileSync(outPath, buf);
}
function renderResult(
result: unknown,
outputHint: string | undefined,
images: MaterializedImage[],
json: boolean
): string {
if (json) return JSON.stringify(result, null, 2);
if (outputHint === "image") {
// Artifact handle, already resolved to a local file by the materializer.
if (images.length > 0) return `Saved screenshot: ${images[0]!.localPath}`;
// Legacy `{ url, path }` shape from older tool-servers.
if (
result &&
typeof result === "object" &&
"path" in result &&
typeof (result as { path: unknown }).path === "string"
) {
return `Saved screenshot: ${(result as { path: string }).path}`;
}
}
if (typeof result === "string") return result;
return JSON.stringify(result, null, 2);
}
const SAFE_TOOL_RE = /^[a-z][a-z0-9_-]{0,63}$/;
function safeToolName(toolName: string | undefined): string {
return toolName && SAFE_TOOL_RE.test(toolName) ? toolName : "unknown";
}
async function trackRunFailure(
toolName: string | undefined,
startedAt: number,
signal: {
error_code: FailureCode;
failure_stage: string;
failure_area: "cli";
error_kind: FailureKind;
}
): Promise<void> {
track("cli:run_fail", {
tool: safeToolName(toolName),
duration_ms: performance.now() - startedAt,
...signal,
});
await telemetryShutdown();
}
export async function run(argv: string[], options: RunCommandOptions): Promise<void> {
telemetryInit("cli");
const startedAt = performance.now();
const { fetchTool, callTool, baseUrl } = createToolsClient({ paths: options.paths });
const [toolName, ...rest] = argv;
if (!toolName || toolName === "--help" || toolName === "-h") {
console.log(`Usage: argent run <tool> [flags]
Invoke a tool exposed by the argent tool-server. Run \`argent tools\` to list
available tools, or \`argent tools describe <name>\` to see one tool's flags.
Examples:
argent run list-devices
argent run gesture-tap --udid <UDID> --x 0.5 --y 0.5
argent run screenshot --udid <UDID> --out ./screen.png
argent run run-sequence --udid <UDID> --steps-json '[{"tool":"button","args":{"button":"home"}}]'
argent run gesture-tap --args '{"udid":"<UDID>","x":0.5,"y":0.5}'
`);
return;
}
// Parsed before the tool is known, so a bad option here can only point at the tool's own help
// rather than print it. Guarded so an unhandled throw doesn't surface as a raw stack.
let cliOptions: RunOptions;
try {
cliOptions = splitOptions(rest);
} catch (err) {
if (err instanceof FlagParseException) {
console.error(`Error: ${err.message}\n`);
console.error(`Run \`argent run ${toolName} --help\` to see this tool's flags.`);
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_FLAG_PARSE_FAILED,
failure_stage: "cli_run_split_options",
failure_area: "cli",
error_kind: "validation",
});
process.exit(2);
}
throw err;
}
const { json, outPath, argvForFlags } = cliOptions;
const meta = await fetchTool(toolName);
if (!meta) {
console.error(`Tool "${toolName}" not found. Run \`argent tools\` to list available tools.`);
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_TOOL_NOT_FOUND,
failure_stage: "cli_run_fetch_tool",
failure_area: "cli",
error_kind: "not_found",
});
process.exit(1);
}
const schema = meta.inputSchema as JsonSchema | undefined;
// The one place a rejected invocation is reported, so the three ways one can be refused
// (unparseable flags, locally detected, server-reported) cannot drift apart in wording, output
// channel or exit code.
const failInvocation = async (
summary: string,
report: ValidationReport | null,
failure: { error_code: FailureCode; failure_stage: string }
): Promise<never> => {
if (json) {
// One object on stderr and nothing on stdout, so `--json | jq` on a failed run reads an
// empty stream and a non-zero status.
// The keys stay present when flags never parsed and carry no report, so one reader
// handles every rejected invocation.
console.error(
JSON.stringify(
{
error: summary,
missing: report ? missingFlagNames(report, schema) : [],
issues: report?.rawIssues ?? [],
},
null,
2
)
);
} else {
console.error(`Error: ${summary}\n`);
printToolHelp(meta);
}
await trackRunFailure(toolName, startedAt, {
...failure,
failure_area: "cli",
error_kind: "validation",
});
process.exit(2);
};
const failValidation = (report: ValidationReport, stage: string): Promise<never> =>
failInvocation(formatValidationError(report, schema), report, {
error_code: FAILURE_CODES.CLI_RUN_INPUT_VALIDATION_FAILED,
failure_stage: stage,
});
let parsed;
try {
parsed = parseFlags(argvForFlags, schema);
} catch (err) {
if (err instanceof FlagParseException) {
await failInvocation(err.message, null, {
error_code: FAILURE_CODES.CLI_RUN_FLAG_PARSE_FAILED,
failure_stage: "cli_run_parse_flags",
});
}
throw err;
}
if (parsed.helpRequested) {
printToolHelp(meta);
return;
}
// `argent run` takes no positionals, so anything left here was typed and dropped —
// e.g. `--flag yes`, since a boolean consumes only `true`/`false`/`1`/`0`. Dropping it
// silently is the failure this warning exists to stop (#586). stderr keeps `--json`
// output parseable.
if (parsed.positional.length > 0) {
console.error(
`Note: ignoring unused argument(s): ${parsed.positional.join(", ")}. ` +
`Pass values as --flag <value> or --flag=<value>.`
);
}
// Precedence: --args JSON first, per-flag values merged on top, so
// `--args '{...}' --x 0.5` overrides a single field.
let payload: Record<string, unknown> = {};
if (parsed.rawArgs !== null) {
let rawJson = parsed.rawArgs;
if (rawJson === "-") {
rawJson = await readStdin();
}
try {
const parsedRaw = JSON.parse(rawJson);
if (parsedRaw === null || typeof parsedRaw !== "object" || Array.isArray(parsedRaw)) {
console.error("--args must be a JSON object");
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_ARGS_NOT_OBJECT,
failure_stage: "cli_run_parse_raw_args",
failure_area: "cli",
error_kind: "validation",
});
process.exit(2);
}
payload = parsedRaw as Record<string, unknown>;
} catch (err) {
console.error(`--args is not valid JSON: ${err instanceof Error ? err.message : err}`);
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_ARGS_JSON_INVALID,
failure_stage: "cli_run_parse_raw_args",
failure_area: "cli",
error_kind: "validation",
});
process.exit(2);
}
}
for (const [k, v] of Object.entries(parsed.args)) {
payload[k] = v;
}
// Checked against the merged payload, so a required field supplied through `--args` or
// `--<field>-json` counts. Answering here spares a round trip — and, for a tool that takes
// file inputs, spares uploading those files only to be told a flag was missing.
const missing = findMissingRequired(payload, schema);
if (missing.length > 0) {
await failValidation({ missing, invalid: [], rawIssues: null }, "cli_run_required_flags");
}
let result: unknown;
let note: string | undefined;
let images: MaterializedImage[] = [];
try {
const resp = await callTool(toolName, payload);
// Resolve artifact handles to local files (the file already on disk when the
// tool-server is co-located, downloaded otherwise), so rendering is
// location-agnostic.
const { url, token } = await baseUrl();
const materialized = await materializeArtifacts(resp.data, {
toolsUrl: url,
authToken: token,
deviceId: getDeviceIdFromArgs(payload),
});
result = materialized.result;
images = materialized.images;
note = resp.note;
} catch (err) {
// The tool rejected the input rather than failing to run it — report it like any other bad
// invocation.
const report = describeServerValidationFailure(err, payload, schema);
if (report) {
await failValidation(report, "cli_run_server_validation");
}
console.error(err instanceof Error ? err.message : String(err));
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_TOOL_CALL_FAILED,
failure_stage: "cli_run_call_tool",
failure_area: "cli",
error_kind: "unknown",
});
process.exit(1);
}
// Prefer the bytes the materializer already resolved; fall back to fetching the
// legacy `{ url }` for older tool-servers that don't emit artifact handles.
if (outPath && meta.outputHint === "image") {
try {
if (images.length > 0) {
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
fs.writeFileSync(outPath, images[0]!.data);
} else if (result && typeof result === "object") {
await fetchImageToFile(result as { url?: string; path?: string }, outPath);
}
} catch (err) {
console.error(`Failed to save image: ${err instanceof Error ? err.message : err}`);
await trackRunFailure(toolName, startedAt, {
error_code: FAILURE_CODES.CLI_RUN_SAVE_IMAGE_FAILED,
failure_stage: "cli_run_save_image",
failure_area: "cli",
error_kind: "unknown",
});
process.exit(1);
}
}
if (note) console.error(note);
console.log(renderResult(result, meta.outputHint, images, json));
if (outPath && meta.outputHint === "image" && !json) {
console.log(`Wrote: ${outPath}`);
}
}