mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
d4a712c401
`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.
400 lines
13 KiB
TypeScript
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}`);
|
|
}
|
|
}
|