xapi-to 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.js +115 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -122,6 +122,15 @@ xapi-to get twitter.tweet_detail
122
122
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
123
123
  ```
124
124
 
125
+ For APIs that return binary data, use `--output` to request raw bytes and save
126
+ them directly. The CLI refuses to overwrite an existing file.
127
+
128
+ ```bash
129
+ xapi-to call openrouter.audio_speech \
130
+ --input '{"body":{"input":"Hello","model":"hexgrad/kokoro-82m","voice":"af_bella"}}' \
131
+ --output speech.mp3
132
+ ```
133
+
125
134
  ## Output Formats
126
135
 
127
136
  All output is JSON by default — designed for agent consumption.
package/dist/index.js CHANGED
@@ -180,6 +180,10 @@ function showConfig() {
180
180
  }
181
181
 
182
182
  // src/client.ts
183
+ import { open, rm } from "fs/promises";
184
+ import { resolve } from "path";
185
+ import { Readable, Transform } from "stream";
186
+ import { pipeline } from "stream/promises";
183
187
  var DEFAULT_TIMEOUT_MS = 3e4;
184
188
  var EXECUTE_TIMEOUT_MS = 6e4;
185
189
  var IDEMPOTENT_RETRIES = 2;
@@ -237,7 +241,7 @@ function parseRetryAfterMs(res) {
237
241
  return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
238
242
  }
239
243
  function sleep(ms) {
240
- return new Promise((resolve) => setTimeout(resolve, ms));
244
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
241
245
  }
242
246
  async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0) {
243
247
  assertAllowedHost(url);
@@ -372,6 +376,84 @@ async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeou
372
376
  retries
373
377
  );
374
378
  }
379
+ async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
380
+ const controller = new AbortController();
381
+ let timedOut = false;
382
+ const timer = setTimeout(() => {
383
+ timedOut = true;
384
+ controller.abort();
385
+ }, EXECUTE_TIMEOUT_MS);
386
+ const target = resolve(outputPath);
387
+ let file;
388
+ let complete = false;
389
+ try {
390
+ try {
391
+ file = await open(target, "wx");
392
+ } catch (error) {
393
+ if (error?.code === "EEXIST") {
394
+ throw new Error(`Output file already exists: ${target}`);
395
+ }
396
+ throw error;
397
+ }
398
+ const url = `${baseUrl(opts)}/v1/actions/execute`;
399
+ assertAllowedHost(url);
400
+ const res = await fetch(url, {
401
+ method: "POST",
402
+ headers: headers(opts.apiKey),
403
+ body: JSON.stringify({
404
+ action_id: actionId,
405
+ ...httpMethod ? { method: httpMethod } : {},
406
+ input,
407
+ response_mode: "raw"
408
+ }),
409
+ redirect: "manual",
410
+ signal: controller.signal
411
+ });
412
+ if (res.status >= 300 && res.status < 400) {
413
+ throw new Error(
414
+ `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
415
+ );
416
+ }
417
+ if (!res.ok) {
418
+ const text = await res.text();
419
+ throw new HttpError(
420
+ res.status,
421
+ text.slice(0, 300),
422
+ isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0
423
+ );
424
+ }
425
+ let bytes = 0;
426
+ if (res.body) {
427
+ const source = Readable.fromWeb(res.body);
428
+ const counter = new Transform({
429
+ transform(chunk, _encoding, callback) {
430
+ bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
431
+ callback(null, chunk);
432
+ }
433
+ });
434
+ await pipeline(source, counter, file.createWriteStream());
435
+ } else {
436
+ await file.close();
437
+ }
438
+ complete = true;
439
+ return {
440
+ output: target,
441
+ bytes,
442
+ contentType: res.headers.get("content-type") || void 0,
443
+ contentDisposition: res.headers.get("content-disposition") || void 0,
444
+ status: res.status
445
+ };
446
+ } catch (error) {
447
+ if (timedOut) throw new RequestTimeoutError(EXECUTE_TIMEOUT_MS);
448
+ throw error;
449
+ } finally {
450
+ clearTimeout(timer);
451
+ if (!complete && file) {
452
+ await file.close().catch(() => void 0);
453
+ await rm(target, { force: true }).catch(() => void 0);
454
+ }
455
+ }
456
+ }
375
457
  async function actionServices(opts, params = {}) {
376
458
  const url = new URL(`${baseUrl(opts)}/v1/actions/services`);
377
459
  if (params.page) url.searchParams.set("page", String(params.page));
@@ -795,6 +877,7 @@ USAGE
795
877
  FLAGS
796
878
  --input <json> Input payload as JSON (required for execution)
797
879
  --method GET|POST|... Override HTTP method
880
+ --output <path> Save a raw binary response to a new file
798
881
  --code <target> Generate code snippet instead of executing
799
882
  --format json|pretty|table Output format
800
883
 
@@ -817,6 +900,7 @@ CODE TARGETS
817
900
 
818
901
  EXAMPLES
819
902
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
903
+ xapi-to call openrouter.audio_speech --input '{"body":{"input":"Hello"}}' --output speech.mp3
820
904
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code py
821
905
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code curl --format pretty
822
906
  `;
@@ -985,6 +1069,10 @@ async function actionCall2(args, flags) {
985
1069
  const id = args[0];
986
1070
  if (!id) err(`usage: xapi-to call <id> --input '{"key":"val"}'`);
987
1071
  if (flags.code) validateCodeFlag(flags);
1072
+ if (flags.output === "true") err("--output requires a file path");
1073
+ if (flags.code && flags.output) {
1074
+ err("--output cannot be combined with --code");
1075
+ }
988
1076
  const cfg = getConfig();
989
1077
  let input = {};
990
1078
  if (flags.input) {
@@ -1006,6 +1094,27 @@ async function actionCall2(args, flags) {
1006
1094
  }
1007
1095
  requireApiKey(cfg);
1008
1096
  try {
1097
+ if (flags.output) {
1098
+ const result = await actionDownload(
1099
+ id,
1100
+ cleanInput,
1101
+ cfg,
1102
+ flags.output,
1103
+ method
1104
+ );
1105
+ output(
1106
+ {
1107
+ success: true,
1108
+ output: result.output,
1109
+ bytes: result.bytes,
1110
+ status: result.status,
1111
+ content_type: result.contentType,
1112
+ content_disposition: result.contentDisposition
1113
+ },
1114
+ flags.format
1115
+ );
1116
+ return;
1117
+ }
1009
1118
  const res = await actionCall(id, cleanInput, cfg, method);
1010
1119
  output(res, flags.format);
1011
1120
  } catch (e) {
@@ -1283,7 +1392,7 @@ async function selectScopesInteractive(provider) {
1283
1392
  out.write("\x1B[J");
1284
1393
  out.write(buildFrame());
1285
1394
  };
1286
- return new Promise((resolve) => {
1395
+ return new Promise((resolve2) => {
1287
1396
  const { stdin } = process;
1288
1397
  const wasRaw = stdin.isRaw;
1289
1398
  stdin.setRawMode(true);
@@ -1294,7 +1403,7 @@ async function selectScopesInteractive(provider) {
1294
1403
  stdin.pause();
1295
1404
  out.write("\x1B[?25h");
1296
1405
  out.write("\n");
1297
- resolve(result);
1406
+ resolve2(result);
1298
1407
  };
1299
1408
  const onData = (buf) => {
1300
1409
  const key = buf.toString();
@@ -1597,7 +1706,7 @@ function parsePositiveInt(raw, flagName) {
1597
1706
  }
1598
1707
  function sleep2(ms) {
1599
1708
  if (ms <= 0) return Promise.resolve();
1600
- return new Promise((resolve) => setTimeout(resolve, ms));
1709
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1601
1710
  }
1602
1711
  function extractTaskPayload(res) {
1603
1712
  if (res && typeof res === "object") {
@@ -1763,6 +1872,7 @@ COMMANDS
1763
1872
  --code <target> Generate code snippet (curl, py, js, ts, go)
1764
1873
  call <id> --input '{"key":"val"}' Execute an action
1765
1874
  --method GET|POST|... Override HTTP method
1875
+ --output <path> Save a raw binary response to a new file
1766
1876
  --code <target> Generate code snippet instead of executing
1767
1877
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1768
1878
 
@@ -1809,6 +1919,7 @@ EXAMPLES
1809
1919
  xapi-to get twitter.tweet_detail --code curl
1810
1920
  xapi-to get twitter.tweet_detail --code py --format pretty
1811
1921
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
1922
+ xapi-to call openrouter.audio_speech --input '{"body":{"input":"Hello"}}' --output speech.mp3
1812
1923
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' --code python
1813
1924
  xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1814
1925
  xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xapi-to",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs",
5
5
  "type": "module",
6
6
  "bin": {