xapi-to 0.1.17 → 0.1.19

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.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/config.ts
9
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
10
10
 
11
11
  // src/format.ts
12
12
  function getFormat() {
@@ -143,11 +143,18 @@ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
143
143
  function loadFileConfig() {
144
144
  if (!existsSync(CONFIG_FILE)) return {};
145
145
  try {
146
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
146
+ const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
147
+ if (!parsed || typeof parsed !== "object") return {};
148
+ return typeof parsed.apiKey === "string" && parsed.apiKey.trim() ? { apiKey: parsed.apiKey } : {};
147
149
  } catch {
148
150
  return {};
149
151
  }
150
152
  }
153
+ function getApiKeySource() {
154
+ if (process.env.XAPI_KEY) return "XAPI_KEY";
155
+ if (process.env.XAPI_API_KEY) return "XAPI_API_KEY";
156
+ return loadFileConfig().apiKey ? "file" : "none";
157
+ }
151
158
  function getConfig() {
152
159
  const file = loadFileConfig();
153
160
  return {
@@ -164,24 +171,31 @@ function saveConfig(updates) {
164
171
  const current = loadFileConfig();
165
172
  const merged = { ...current, ...updates };
166
173
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
174
+ if (process.platform !== "win32") chmodSync(CONFIG_DIR, 448);
167
175
  writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
176
+ if (process.platform !== "win32") chmodSync(CONFIG_FILE, 384);
168
177
  }
169
178
  function showConfig() {
170
179
  const cfg = getConfig();
171
- const file = loadFileConfig();
172
- console.log(JSON.stringify({
180
+ return {
173
181
  actionHost: cfg.actionHost,
174
182
  apiKey: cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}...` : void 0,
175
183
  source: {
176
- apiKey: process.env.XAPI_KEY || process.env.XAPI_API_KEY ? "env" : file.apiKey ? "file" : "none"
184
+ apiKey: getApiKeySource()
177
185
  },
178
186
  configFile: CONFIG_FILE
179
- }, null, 2));
187
+ };
180
188
  }
181
189
 
182
190
  // src/client.ts
191
+ import { open, rm } from "fs/promises";
192
+ import { once } from "events";
193
+ import { resolve } from "path";
194
+ import { Readable, Transform } from "stream";
195
+ import { pipeline } from "stream/promises";
183
196
  var DEFAULT_TIMEOUT_MS = 3e4;
184
197
  var EXECUTE_TIMEOUT_MS = 6e4;
198
+ var TRANSFER_IDLE_TIMEOUT_MS = 6e4;
185
199
  var IDEMPOTENT_RETRIES = 2;
186
200
  var RETRY_BASE_DELAY_MS = 500;
187
201
  var RETRY_MAX_DELAY_MS = 8e3;
@@ -221,6 +235,10 @@ function retryBaseDelayMs() {
221
235
  const override = Number(process.env.XAPI_RETRY_BASE_MS);
222
236
  return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
223
237
  }
238
+ function transferIdleTimeoutMs() {
239
+ const override = Number(process.env.XAPI_TRANSFER_IDLE_TIMEOUT_MS);
240
+ return Number.isFinite(override) && override > 0 ? override : TRANSFER_IDLE_TIMEOUT_MS;
241
+ }
224
242
  function backoffDelayMs(attempt, retryAfterMs) {
225
243
  if (retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
226
244
  return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
@@ -237,7 +255,7 @@ function parseRetryAfterMs(res) {
237
255
  return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
238
256
  }
239
257
  function sleep(ms) {
240
- return new Promise((resolve) => setTimeout(resolve, ms));
258
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
241
259
  }
242
260
  async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0) {
243
261
  assertAllowedHost(url);
@@ -292,7 +310,13 @@ async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0
292
310
  return body;
293
311
  } catch (e) {
294
312
  if (timedOut) {
295
- throw new RequestTimeoutError(timeoutMs);
313
+ const timeoutError = new RequestTimeoutError(timeoutMs);
314
+ if (attempt < retries) {
315
+ await sleep(backoffDelayMs(attempt));
316
+ attempt++;
317
+ continue;
318
+ }
319
+ throw timeoutError;
296
320
  }
297
321
  if (isRetryableNetworkError(e) && attempt < retries) {
298
322
  clearTimeout(timer);
@@ -335,6 +359,8 @@ async function actionSearch(query, opts, params = {}) {
335
359
  if (params.source) url.searchParams.set("source", params.source);
336
360
  if (params.page) url.searchParams.set("page", String(params.page));
337
361
  if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
362
+ if (params.include_all_versions) url.searchParams.set("include_all_versions", "true");
363
+ if (params.sort) url.searchParams.set("sort", params.sort);
338
364
  return request(
339
365
  url.toString(),
340
366
  { method: "GET", headers: headers(opts.apiKey) },
@@ -360,6 +386,19 @@ async function actionGet(id, opts) {
360
386
  IDEMPOTENT_RETRIES
361
387
  );
362
388
  }
389
+ async function actionBatch(ids, opts) {
390
+ return request(
391
+ `${baseUrl(opts)}/v1/actions/batch`,
392
+ {
393
+ method: "POST",
394
+ headers: headers(opts.apiKey),
395
+ body: JSON.stringify({ ids })
396
+ },
397
+ DEFAULT_TIMEOUT_MS,
398
+ IDEMPOTENT_RETRIES
399
+ // read-only metadata fetch — safe to retry
400
+ );
401
+ }
363
402
  async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
364
403
  return request(
365
404
  `${baseUrl(opts)}/v1/actions/execute`,
@@ -372,6 +411,161 @@ async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeou
372
411
  retries
373
412
  );
374
413
  }
414
+ async function actionStream(actionId, input, opts, httpMethod) {
415
+ const controller = new AbortController();
416
+ let timedOut = false;
417
+ let activeTimeoutMs = EXECUTE_TIMEOUT_MS;
418
+ let timer;
419
+ const resetTimeout = (timeoutMs) => {
420
+ if (timer) clearTimeout(timer);
421
+ activeTimeoutMs = timeoutMs;
422
+ timer = setTimeout(() => {
423
+ timedOut = true;
424
+ controller.abort();
425
+ }, timeoutMs);
426
+ };
427
+ resetTimeout(EXECUTE_TIMEOUT_MS);
428
+ const url = `${baseUrl(opts)}/v1/actions/execute`;
429
+ assertAllowedHost(url);
430
+ try {
431
+ const res = await fetch(url, {
432
+ method: "POST",
433
+ headers: {
434
+ ...headers(opts.apiKey),
435
+ Accept: "text/event-stream"
436
+ },
437
+ body: JSON.stringify({
438
+ action_id: actionId,
439
+ ...httpMethod ? { method: httpMethod } : {},
440
+ input,
441
+ stream: true
442
+ }),
443
+ redirect: "manual",
444
+ signal: controller.signal
445
+ });
446
+ if (res.status >= 300 && res.status < 400) {
447
+ throw new Error(
448
+ `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
449
+ );
450
+ }
451
+ if (!res.ok) {
452
+ const text = await res.text();
453
+ throw new HttpError(
454
+ res.status,
455
+ text.slice(0, 300),
456
+ isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0
457
+ );
458
+ }
459
+ const contentType = res.headers.get("content-type") || "";
460
+ if (!contentType.toLowerCase().includes("text/event-stream")) {
461
+ const text = await res.text();
462
+ throw new Error(
463
+ `expected an SSE response but received "${contentType || "unknown"}": ${text.slice(0, 300)}`
464
+ );
465
+ }
466
+ if (!res.body) return;
467
+ const idleTimeoutMs = transferIdleTimeoutMs();
468
+ resetTimeout(idleTimeoutMs);
469
+ const source = Readable.fromWeb(res.body);
470
+ for await (const chunk of source) {
471
+ resetTimeout(idleTimeoutMs);
472
+ if (!process.stdout.write(chunk)) await once(process.stdout, "drain");
473
+ }
474
+ } catch (error) {
475
+ if (timedOut) throw new RequestTimeoutError(activeTimeoutMs);
476
+ throw error;
477
+ } finally {
478
+ if (timer) clearTimeout(timer);
479
+ }
480
+ }
481
+ async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
482
+ const controller = new AbortController();
483
+ let timedOut = false;
484
+ let activeTimeoutMs = EXECUTE_TIMEOUT_MS;
485
+ let timer;
486
+ const resetTimeout = (timeoutMs) => {
487
+ if (timer) clearTimeout(timer);
488
+ activeTimeoutMs = timeoutMs;
489
+ timer = setTimeout(() => {
490
+ timedOut = true;
491
+ controller.abort();
492
+ }, timeoutMs);
493
+ };
494
+ resetTimeout(EXECUTE_TIMEOUT_MS);
495
+ const target = resolve(outputPath);
496
+ let file;
497
+ let complete = false;
498
+ try {
499
+ try {
500
+ file = await open(target, "wx");
501
+ } catch (error) {
502
+ if (error?.code === "EEXIST") {
503
+ throw new Error(`Output file already exists: ${target}`);
504
+ }
505
+ throw error;
506
+ }
507
+ const url = `${baseUrl(opts)}/v1/actions/execute`;
508
+ assertAllowedHost(url);
509
+ const res = await fetch(url, {
510
+ method: "POST",
511
+ headers: headers(opts.apiKey),
512
+ body: JSON.stringify({
513
+ action_id: actionId,
514
+ ...httpMethod ? { method: httpMethod } : {},
515
+ input,
516
+ response_mode: "raw"
517
+ }),
518
+ redirect: "manual",
519
+ signal: controller.signal
520
+ });
521
+ if (res.status >= 300 && res.status < 400) {
522
+ throw new Error(
523
+ `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
524
+ );
525
+ }
526
+ if (!res.ok) {
527
+ const text = await res.text();
528
+ throw new HttpError(
529
+ res.status,
530
+ text.slice(0, 300),
531
+ isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0
532
+ );
533
+ }
534
+ let bytes = 0;
535
+ if (res.body) {
536
+ const idleTimeoutMs = transferIdleTimeoutMs();
537
+ resetTimeout(idleTimeoutMs);
538
+ const source = Readable.fromWeb(res.body);
539
+ const counter = new Transform({
540
+ transform(chunk, _encoding, callback) {
541
+ resetTimeout(idleTimeoutMs);
542
+ bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
543
+ callback(null, chunk);
544
+ }
545
+ });
546
+ await pipeline(source, counter, file.createWriteStream());
547
+ } else {
548
+ await file.close();
549
+ }
550
+ complete = true;
551
+ return {
552
+ output: target,
553
+ bytes,
554
+ contentType: res.headers.get("content-type") || void 0,
555
+ contentDisposition: res.headers.get("content-disposition") || void 0,
556
+ status: res.status
557
+ };
558
+ } catch (error) {
559
+ if (timedOut) throw new RequestTimeoutError(activeTimeoutMs);
560
+ throw error;
561
+ } finally {
562
+ if (timer) clearTimeout(timer);
563
+ if (!complete && file) {
564
+ await file.close().catch(() => void 0);
565
+ await rm(target, { force: true }).catch(() => void 0);
566
+ }
567
+ }
568
+ }
375
569
  async function actionServices(opts, params = {}) {
376
570
  const url = new URL(`${baseUrl(opts)}/v1/actions/services`);
377
571
  if (params.page) url.searchParams.set("page", String(params.page));
@@ -719,6 +913,7 @@ function generateCode(target, params) {
719
913
 
720
914
  // src/commands/action.ts
721
915
  var VALID_SOURCES = ["capability", "api"];
916
+ var VALID_SEARCH_SORTS = ["default", "relevance", "price"];
722
917
  var LIST_HELP = `xapi-to list - List all actions
723
918
 
724
919
  USAGE
@@ -747,12 +942,50 @@ FLAGS
747
942
  --category <name> Filter by category
748
943
  --page N Page number (default: 1)
749
944
  --page-size N Results per page
945
+ --sort default|relevance|price
946
+ Recommended (default), strongest match, or lowest
947
+ comparable price after exact-id/local-match guards
948
+ --include-all-versions Include active non-default major versions
750
949
  --format json|pretty|table Output format
751
950
 
752
951
  EXAMPLES
753
952
  xapi-to search twitter
754
953
  xapi-to search "tweet detail" --source api
954
+ xapi-to search "tweet detail" --sort relevance
955
+ xapi-to search weather --sort price
755
956
  xapi-to search weather --category utility --format table
957
+ xapi-to search twitter --include-all-versions
958
+ `;
959
+ var CATEGORIES_HELP = `xapi-to categories - List action categories
960
+
961
+ USAGE
962
+ xapi-to categories [flags]
963
+
964
+ FLAGS
965
+ --source capability|api Filter by source type
966
+ --format json|pretty|table Output format
967
+ `;
968
+ var SERVICES_HELP = `xapi-to services - List services
969
+
970
+ USAGE
971
+ xapi-to services [flags]
972
+
973
+ FLAGS
974
+ --category <name> Filter by category
975
+ --page N Page number
976
+ --page-size N Results per page
977
+ --format json|pretty|table Output format
978
+ `;
979
+ var GET_BATCH_HELP = `xapi-to get-batch - Get multiple action schemas
980
+
981
+ USAGE
982
+ xapi-to get-batch <id> [id ...] [flags]
983
+
984
+ FLAGS
985
+ --format json|pretty|table Output format
986
+
987
+ EXAMPLES
988
+ xapi-to get-batch twitter.tweet_detail crypto.token.price
756
989
  `;
757
990
  var GET_HELP = `xapi-to get - Get action schema
758
991
 
@@ -795,6 +1028,8 @@ USAGE
795
1028
  FLAGS
796
1029
  --input <json> Input payload as JSON (required for execution)
797
1030
  --method GET|POST|... Override HTTP method
1031
+ --output <path> Save a raw binary response to a new file
1032
+ --stream Forward the action's HTTP SSE response unchanged
798
1033
  --code <target> Generate code snippet instead of executing
799
1034
  --format json|pretty|table Output format
800
1035
 
@@ -817,6 +1052,8 @@ CODE TARGETS
817
1052
 
818
1053
  EXAMPLES
819
1054
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
1055
+ xapi-to call openrouter.audio_speech --input '{"body":{"input":"Hello"}}' --output speech.mp3
1056
+ xapi-to call ai.text.chat.fast --input '{"messages":[{"role":"user","content":"Hello"}]}' --stream
820
1057
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code py
821
1058
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code curl --format pretty
822
1059
  `;
@@ -847,6 +1084,31 @@ function getSource(flags) {
847
1084
  }
848
1085
  return flags.source;
849
1086
  }
1087
+ function getSearchSort(flags) {
1088
+ const value = flags.sort;
1089
+ if (value === void 0) return void 0;
1090
+ if (value === "true") {
1091
+ err("--sort requires a value: default, relevance, or price");
1092
+ }
1093
+ if (!VALID_SEARCH_SORTS.includes(value)) {
1094
+ err(`invalid --sort value: "${value}". Must be default, relevance, or price.`);
1095
+ }
1096
+ return value;
1097
+ }
1098
+ function positiveIntegerFlag(value, name) {
1099
+ if (value === void 0) return void 0;
1100
+ if (!/^\d+$/.test(value) || Number(value) < 1) {
1101
+ err(`${name} must be a positive integer`);
1102
+ }
1103
+ return Number(value);
1104
+ }
1105
+ function httpMethodFlag(value) {
1106
+ if (value === void 0) return void 0;
1107
+ if (value === "true" || !/^[A-Za-z]+$/.test(value)) {
1108
+ err("--method requires an HTTP method, e.g. --method POST");
1109
+ }
1110
+ return value.toUpperCase();
1111
+ }
850
1112
  async function actionList2(args, flags) {
851
1113
  showHelpIfRequested(flags, LIST_HELP);
852
1114
  const cfg = getConfig();
@@ -854,8 +1116,8 @@ async function actionList2(args, flags) {
854
1116
  try {
855
1117
  const res = await actionList(cfg, {
856
1118
  source: getSource(flags),
857
- page: flags.page ? parseInt(flags.page) : void 0,
858
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0,
1119
+ page: positiveIntegerFlag(flags.page, "--page"),
1120
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
859
1121
  category: flags.category,
860
1122
  service_id: flags["service-id"]
861
1123
  });
@@ -881,15 +1143,23 @@ async function actionSearch2(args, flags) {
881
1143
  showHelpIfRequested(flags, SEARCH_HELP);
882
1144
  const query = args[0];
883
1145
  if (!query) err("usage: xapi-to search <query>");
1146
+ const requestedSort = getSearchSort(flags);
884
1147
  const cfg = getConfig();
885
1148
  const fmt = flags.format || getFormat();
886
1149
  try {
887
1150
  const res = await actionSearch(query, cfg, {
888
1151
  source: getSource(flags),
889
1152
  category: flags.category,
890
- page: flags.page ? parseInt(flags.page) : void 0,
891
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0
1153
+ page: positiveIntegerFlag(flags.page, "--page"),
1154
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
1155
+ include_all_versions: flags["include-all-versions"] === "true",
1156
+ sort: requestedSort
892
1157
  });
1158
+ if (requestedSort && res.sort !== requestedSort) {
1159
+ throw new Error(
1160
+ res.sort ? `backend applied sort "${res.sort}" instead of requested "${requestedSort}"` : "backend does not support search sorting yet; deploy the updated backend before using --sort"
1161
+ );
1162
+ }
893
1163
  const results = res.results || [];
894
1164
  if (fmt === "table") {
895
1165
  output(results.map((a) => ({
@@ -899,7 +1169,8 @@ async function actionSearch2(args, flags) {
899
1169
  source: a.source ?? "",
900
1170
  category: a.meta?.category ?? "",
901
1171
  status: a.status ?? "",
902
- cost: a.meta?.cost ?? ""
1172
+ price: a.meta?.pricing?.comparable ? a.meta.pricing.listed_price : "",
1173
+ pricing: a.meta?.pricing?.billing_type ?? ""
903
1174
  })), "table");
904
1175
  } else {
905
1176
  output(res, flags.format);
@@ -909,6 +1180,7 @@ async function actionSearch2(args, flags) {
909
1180
  }
910
1181
  }
911
1182
  async function actionCategories2(args, flags) {
1183
+ showHelpIfRequested(flags, CATEGORIES_HELP);
912
1184
  const cfg = getConfig();
913
1185
  const fmt = flags.format || getFormat();
914
1186
  try {
@@ -923,12 +1195,13 @@ async function actionCategories2(args, flags) {
923
1195
  }
924
1196
  }
925
1197
  async function actionServices2(args, flags) {
1198
+ showHelpIfRequested(flags, SERVICES_HELP);
926
1199
  const cfg = getConfig();
927
1200
  const fmt = flags.format || getFormat();
928
1201
  try {
929
1202
  const res = await actionServices(cfg, {
930
- page: flags.page ? parseInt(flags.page) : void 0,
931
- page_size: flags["page-size"] ? parseInt(flags["page-size"]) : void 0,
1203
+ page: positiveIntegerFlag(flags.page, "--page"),
1204
+ page_size: positiveIntegerFlag(flags["page-size"], "--page-size"),
932
1205
  category: flags.category
933
1206
  });
934
1207
  const services = res.services || [];
@@ -953,11 +1226,11 @@ async function actionGet2(args, flags) {
953
1226
  const id = args[0];
954
1227
  if (!id) err("usage: xapi-to get <id> [--method GET|POST|DELETE|...]");
955
1228
  if (flags.code) validateCodeFlag(flags);
1229
+ const methodFilter = httpMethodFlag(flags.method);
956
1230
  const cfg = getConfig();
957
1231
  try {
958
1232
  const res = await actionGet(id, cfg);
959
1233
  const actions = Array.isArray(res) ? res : [res];
960
- const methodFilter = flags.method?.toUpperCase();
961
1234
  const filtered = methodFilter ? actions.filter((a) => a.method?.toUpperCase() === methodFilter) : actions;
962
1235
  if (filtered.length === 0) {
963
1236
  err(`no endpoint found for method "${methodFilter}" in action "${id}"`);
@@ -980,11 +1253,30 @@ async function actionGet2(args, flags) {
980
1253
  err("get failed", e.message);
981
1254
  }
982
1255
  }
1256
+ async function actionBatchGet(args, flags) {
1257
+ showHelpIfRequested(flags, GET_BATCH_HELP);
1258
+ if (args.length === 0) err("usage: xapi-to get-batch <id> [id ...]");
1259
+ if (args.length > 100) err("get-batch accepts at most 100 action IDs");
1260
+ const cfg = getConfig();
1261
+ try {
1262
+ const res = await actionBatch(args, cfg);
1263
+ output(res, flags.format);
1264
+ } catch (e) {
1265
+ err("get-batch failed", e.message);
1266
+ }
1267
+ }
983
1268
  async function actionCall2(args, flags) {
984
1269
  showHelpIfRequested(flags, CALL_HELP);
985
1270
  const id = args[0];
986
1271
  if (!id) err(`usage: xapi-to call <id> --input '{"key":"val"}'`);
987
1272
  if (flags.code) validateCodeFlag(flags);
1273
+ if (flags.output === "true") err("--output requires a file path");
1274
+ const stream = flags.stream === "true" || flags.stream === "1" || flags.stream === "yes";
1275
+ if (flags.code && flags.output) {
1276
+ err("--output cannot be combined with --code");
1277
+ }
1278
+ if (stream && flags.output) err("--stream cannot be combined with --output");
1279
+ if (stream && flags.code) err("--stream cannot be combined with --code");
988
1280
  const cfg = getConfig();
989
1281
  let input = {};
990
1282
  if (flags.input) {
@@ -998,7 +1290,7 @@ async function actionCall2(args, flags) {
998
1290
  }
999
1291
  }
1000
1292
  const { method: inputMethod, ...cleanInput } = input;
1001
- const method = flags.method?.toUpperCase() || (typeof inputMethod === "string" ? inputMethod.toUpperCase() : void 0);
1293
+ const method = httpMethodFlag(flags.method) || (typeof inputMethod === "string" ? inputMethod.toUpperCase() : void 0);
1002
1294
  if (flags.code) {
1003
1295
  const result = generateCode(flags.code, { actionId: id, input: cleanInput, actionHost: cfg.actionHost, method });
1004
1296
  outputCode(result, flags);
@@ -1006,6 +1298,31 @@ async function actionCall2(args, flags) {
1006
1298
  }
1007
1299
  requireApiKey(cfg);
1008
1300
  try {
1301
+ if (stream) {
1302
+ await actionStream(id, cleanInput, cfg, method);
1303
+ return;
1304
+ }
1305
+ if (flags.output) {
1306
+ const result = await actionDownload(
1307
+ id,
1308
+ cleanInput,
1309
+ cfg,
1310
+ flags.output,
1311
+ method
1312
+ );
1313
+ output(
1314
+ {
1315
+ success: true,
1316
+ output: result.output,
1317
+ bytes: result.bytes,
1318
+ status: result.status,
1319
+ content_type: result.contentType,
1320
+ content_disposition: result.contentDisposition
1321
+ },
1322
+ flags.format
1323
+ );
1324
+ return;
1325
+ }
1009
1326
  const res = await actionCall(id, cleanInput, cfg, method);
1010
1327
  output(res, flags.format);
1011
1328
  } catch (e) {
@@ -1017,6 +1334,7 @@ async function actionCall2(args, flags) {
1017
1334
  var config_exports = {};
1018
1335
  __export(config_exports, {
1019
1336
  CONFIG_HELP: () => CONFIG_HELP,
1337
+ HEALTH_HELP: () => HEALTH_HELP,
1020
1338
  configHealth: () => configHealth,
1021
1339
  configSet: () => configSet,
1022
1340
  configShow: () => configShow
@@ -1035,14 +1353,23 @@ COMMANDS
1035
1353
  FLAGS
1036
1354
  --format json|pretty|table Output format
1037
1355
 
1356
+ ENVIRONMENT OVERRIDES
1357
+ XAPI_KEY takes precedence over XAPI_API_KEY, which takes precedence over the file.
1358
+ Saving a file key does not replace an active environment-variable key.
1359
+
1038
1360
  EXAMPLES
1039
1361
  xapi-to config show
1040
1362
  xapi-to config set apiKey=xapi_abc123
1041
1363
  echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
1042
1364
  xapi-to config health
1043
1365
  `;
1366
+ var HEALTH_HELP = `xapi-to health - Check backend connectivity
1367
+
1368
+ USAGE
1369
+ xapi-to health [--format json|pretty|table]
1370
+ `;
1044
1371
  async function configShow(args, flags) {
1045
- showConfig();
1372
+ output(showConfig(), flags.format);
1046
1373
  }
1047
1374
  async function configSet(args, flags) {
1048
1375
  if (args.length === 0) err("usage: xapi-to config set apiKey=<key>");
@@ -1060,10 +1387,22 @@ async function configSet(args, flags) {
1060
1387
  if (!value) err("apiKey is empty");
1061
1388
  updates.apiKey = value;
1062
1389
  }
1390
+ const sourceBeforeSave = getApiKeySource();
1063
1391
  saveConfig(updates);
1064
- console.log(JSON.stringify({ ok: true, updated: Object.keys(updates) }));
1392
+ const source = sourceBeforeSave === "XAPI_KEY" || sourceBeforeSave === "XAPI_API_KEY" ? sourceBeforeSave : "file";
1393
+ output({
1394
+ ok: true,
1395
+ updated: Object.keys(updates),
1396
+ effective: source === "file",
1397
+ source,
1398
+ ...source === "XAPI_KEY" || source === "XAPI_API_KEY" ? { warning: `${source} still overrides the saved file key` } : {}
1399
+ }, flags.format);
1065
1400
  }
1066
1401
  async function configHealth(args, flags) {
1402
+ if (flags.help) {
1403
+ console.log(HEALTH_HELP);
1404
+ return;
1405
+ }
1067
1406
  const cfg = getConfig();
1068
1407
  const start = Date.now();
1069
1408
  try {
@@ -1076,6 +1415,30 @@ async function configHealth(args, flags) {
1076
1415
  }
1077
1416
 
1078
1417
  // src/commands/register.ts
1418
+ var REGISTER_HELP = `xapi-to register - Create a new xAPI account
1419
+
1420
+ USAGE
1421
+ xapi-to register [referral-code] [flags]
1422
+
1423
+ FLAGS
1424
+ --referral-code <code> Submit an inviter's referral code
1425
+ --referralCode <code> Alias for --referral-code
1426
+ --force Replace an existing file-based API key
1427
+ --format json|pretty|table Output format
1428
+
1429
+ The API key is saved to ~/.xapi/config.json. If XAPI_KEY or XAPI_API_KEY is set,
1430
+ unset it before registering because environment variables override the saved file.
1431
+ `;
1432
+ function validateRegisterResponse(value) {
1433
+ const res = value;
1434
+ if (!res || typeof res.apiKey !== "string" || !res.apiKey.trim()) {
1435
+ throw new Error("invalid register response: missing apiKey");
1436
+ }
1437
+ if (typeof res.referralCode !== "string" || !res.user || typeof res.user.id !== "string") {
1438
+ throw new Error("invalid register response: missing account details");
1439
+ }
1440
+ return res;
1441
+ }
1079
1442
  async function registerAccount(referralCode) {
1080
1443
  assertAllowedHost(XAPI_API_HOST);
1081
1444
  const controller = new AbortController();
@@ -1085,39 +1448,57 @@ async function registerAccount(referralCode) {
1085
1448
  method: "POST",
1086
1449
  headers: { "Content-Type": "application/json" },
1087
1450
  body: JSON.stringify(referralCode ? { referralCode } : {}),
1088
- signal: controller.signal
1451
+ signal: controller.signal,
1452
+ redirect: "manual"
1089
1453
  });
1454
+ if (res.status >= 300 && res.status < 400) {
1455
+ throw new Error(`refusing to follow redirect to "${res.headers.get("location") ?? "?"}"`);
1456
+ }
1090
1457
  if (!res.ok) {
1091
1458
  const text = await res.text();
1092
1459
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
1093
1460
  }
1094
- return res.json();
1461
+ return validateRegisterResponse(await res.json());
1095
1462
  } finally {
1096
1463
  clearTimeout(timer);
1097
1464
  }
1098
1465
  }
1099
1466
  async function register(args, flags) {
1467
+ if (flags.help) {
1468
+ console.log(REGISTER_HELP);
1469
+ return;
1470
+ }
1100
1471
  try {
1101
1472
  const cfg = getConfig();
1102
1473
  const force = flags.force === "true" || flags.force === "1" || flags.force === "yes";
1474
+ const source = getApiKeySource();
1475
+ if (source === "XAPI_KEY" || source === "XAPI_API_KEY") {
1476
+ err(
1477
+ "register cannot replace an API key supplied by an environment variable",
1478
+ `Unset ${source} first; it would continue to override the newly saved key.`
1479
+ );
1480
+ }
1103
1481
  if (cfg.apiKey && !force) {
1104
1482
  err("register would overwrite existing apiKey", 'Run "xapi-to register --force" to create a new account and replace the saved key.');
1105
1483
  }
1106
1484
  const rawReferral = flags["referral-code"] ?? flags["referralCode"] ?? args[0];
1107
- const referralCode = typeof rawReferral === "string" && rawReferral !== "true" && rawReferral.length > 0 ? rawReferral : void 0;
1485
+ if (rawReferral === "true") {
1486
+ err("--referral-code requires a code");
1487
+ }
1488
+ const referralCode = typeof rawReferral === "string" && rawReferral.length > 0 ? rawReferral : void 0;
1108
1489
  const res = await registerAccount(referralCode);
1490
+ const bindUrl = res.bindUrl || res.claimUrl;
1109
1491
  saveConfig({ apiKey: res.apiKey });
1110
1492
  output({
1111
1493
  apiKey: res.apiKey,
1112
1494
  user: res.user,
1113
1495
  referralCode: res.referralCode,
1114
- claim: {
1115
- code: res.claimCode,
1116
- sessionId: res.claimSessionId,
1117
- url: res.claimUrl
1118
- },
1119
- tweetTemplate: res.tweetTemplate,
1120
- ...referralCode ? { referredBy: referralCode } : {},
1496
+ bindUrl,
1497
+ // Keep the backend's legacy field visible while clients migrate to bindUrl.
1498
+ claimUrl: res.claimUrl || bindUrl,
1499
+ // The backend may accept the registration while ignoring an invalid code,
1500
+ // so only report that the code was submitted, not that a referral exists.
1501
+ ...referralCode ? { referralCodeProvided: referralCode } : {},
1121
1502
  note: force && cfg.apiKey ? "apiKey replaced in ~/.xapi/config.json" : "apiKey saved to ~/.xapi/config.json"
1122
1503
  }, flags.format);
1123
1504
  } catch (e) {
@@ -1127,23 +1508,50 @@ async function register(args, flags) {
1127
1508
 
1128
1509
  // src/commands/topup.ts
1129
1510
  var TOPUP_BASE_URL = "https://www.xapi.to/topup/payment";
1511
+ var TOPUP_HELP = `xapi-to topup - Generate a private payment URL
1512
+
1513
+ USAGE
1514
+ xapi-to topup [--amount <usd>] [--method stripe|x402]
1515
+
1516
+ The generated URL can contain your API key. Do not log or share it.
1517
+ `;
1130
1518
  async function topup(args, flags) {
1519
+ if (flags.help) {
1520
+ console.log(TOPUP_HELP);
1521
+ return;
1522
+ }
1131
1523
  const cfg = getConfig();
1132
1524
  const url = new URL(TOPUP_BASE_URL);
1133
1525
  if (cfg.apiKey) url.searchParams.set("apikey", cfg.apiKey);
1134
- if (flags.method) url.searchParams.set("method", flags.method);
1526
+ if (flags.method) {
1527
+ if (!["stripe", "x402"].includes(flags.method)) {
1528
+ err("invalid --method value", "Expected stripe or x402.");
1529
+ }
1530
+ url.searchParams.set("method", flags.method);
1531
+ }
1135
1532
  const amountStr = flags.amount || args[0];
1136
1533
  if (amountStr) {
1137
- const amountUsd = parseFloat(amountStr);
1138
- if (!isNaN(amountUsd) && amountUsd > 0) {
1139
- url.searchParams.set("amount", String(amountUsd));
1534
+ const normalizedAmount = amountStr.trim();
1535
+ const amountUsd = Number(normalizedAmount);
1536
+ if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalizedAmount) || !Number.isFinite(amountUsd) || amountUsd <= 0) {
1537
+ err("invalid top-up amount", "Expected a positive USD number, e.g. --amount 10.");
1140
1538
  }
1539
+ url.searchParams.set("amount", String(amountUsd));
1141
1540
  }
1142
1541
  output({ url: url.toString() }, flags.format);
1143
1542
  }
1144
1543
 
1145
1544
  // src/commands/balance.ts
1545
+ var BALANCE_HELP = `xapi-to balance - Show the current account balance
1546
+
1547
+ USAGE
1548
+ xapi-to balance [--format json|pretty|table]
1549
+ `;
1146
1550
  async function balance(args, flags) {
1551
+ if (flags.help) {
1552
+ console.log(BALANCE_HELP);
1553
+ return;
1554
+ }
1147
1555
  const cfg = getConfig();
1148
1556
  requireApiKey(cfg);
1149
1557
  let token;
@@ -1180,9 +1588,10 @@ __export(oauth_exports, {
1180
1588
  });
1181
1589
  import { spawnSync } from "child_process";
1182
1590
  function openBrowser(url) {
1183
- const cmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
1591
+ const cmd = process.platform === "win32" ? "rundll32.exe" : process.platform === "darwin" ? "open" : "xdg-open";
1592
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
1184
1593
  try {
1185
- spawnSync(cmd, [url], { stdio: "ignore" });
1594
+ spawnSync(cmd, args, { stdio: "ignore" });
1186
1595
  } catch {
1187
1596
  }
1188
1597
  }
@@ -1226,9 +1635,11 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
1226
1635
  throw new Error("No API keys found for this account");
1227
1636
  }
1228
1637
  const prefix = plaintextKey.substring(0, 7);
1229
- const match = keys.find((k) => k.keyPreview.startsWith(prefix));
1638
+ const suffix = plaintextKey.slice(-4);
1639
+ const expectedPreview = `${prefix}****${suffix}`;
1640
+ const match = keys.find((k) => k.keyPreview === expectedPreview);
1230
1641
  if (match) return match;
1231
- if (keys.length === 1) return keys[0];
1642
+ if (keys.length === 1 && !keys[0].keyPreview.includes("****")) return keys[0];
1232
1643
  throw new Error(
1233
1644
  `Current API key (${prefix}...) was not found in your account keys. Run "xapi-to config set apiKey=<key>" with a valid key before binding OAuth.`
1234
1645
  );
@@ -1283,7 +1694,7 @@ async function selectScopesInteractive(provider) {
1283
1694
  out.write("\x1B[J");
1284
1695
  out.write(buildFrame());
1285
1696
  };
1286
- return new Promise((resolve) => {
1697
+ return new Promise((resolve2) => {
1287
1698
  const { stdin } = process;
1288
1699
  const wasRaw = stdin.isRaw;
1289
1700
  stdin.setRawMode(true);
@@ -1294,7 +1705,7 @@ async function selectScopesInteractive(provider) {
1294
1705
  stdin.pause();
1295
1706
  out.write("\x1B[?25h");
1296
1707
  out.write("\n");
1297
- resolve(result);
1708
+ resolve2(result);
1298
1709
  };
1299
1710
  const onData = (buf) => {
1300
1711
  const key = buf.toString();
@@ -1345,13 +1756,20 @@ FLAGS
1345
1756
  EXAMPLES
1346
1757
  xapi-to oauth bind
1347
1758
  xapi-to oauth bind --provider twitter
1348
- xapi-to oauth bind --scopes "tweet.read users.read"
1759
+ xapi-to oauth providers # inspect current default scopes
1760
+ xapi-to oauth bind --scopes "<scope list>" # override only when needed
1349
1761
  xapi-to oauth status
1350
1762
  xapi-to oauth status --format pretty
1351
1763
  xapi-to oauth unbind abc123
1352
1764
  xapi-to oauth providers
1353
1765
  `;
1354
1766
  async function oauthBind(args, flags) {
1767
+ if (flags.provider === "true") {
1768
+ err("--provider requires a provider name, e.g. --provider twitter");
1769
+ }
1770
+ if (flags.scopes === "true") {
1771
+ err("--scopes requires a space-separated scope list");
1772
+ }
1355
1773
  const cfg = getConfig();
1356
1774
  requireApiKey(cfg);
1357
1775
  const apiKey = cfg.apiKey;
@@ -1409,6 +1827,16 @@ async function oauthBind(args, flags) {
1409
1827
  const authorizationStartedAt = /* @__PURE__ */ new Date();
1410
1828
  const result = await initiateOAuth(keyRecord.id, provider.id, jwtToken, XAPI_API_HOST, scopes);
1411
1829
  const { authorizationUrl } = result;
1830
+ let authorizationTarget;
1831
+ try {
1832
+ authorizationTarget = new URL(authorizationUrl);
1833
+ } catch {
1834
+ throw new Error("OAuth provider returned an invalid authorization URL");
1835
+ }
1836
+ const localHttp = authorizationTarget.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(authorizationTarget.hostname);
1837
+ if (authorizationTarget.protocol !== "https:" && !localHttp) {
1838
+ throw new Error(`OAuth provider returned an unsupported authorization URL protocol: ${authorizationTarget.protocol}`);
1839
+ }
1412
1840
  if (isTTY) {
1413
1841
  if (!headerPrinted) {
1414
1842
  console.error(`
@@ -1597,7 +2025,7 @@ function parsePositiveInt(raw, flagName) {
1597
2025
  }
1598
2026
  function sleep2(ms) {
1599
2027
  if (ms <= 0) return Promise.resolve();
1600
- return new Promise((resolve) => setTimeout(resolve, ms));
2028
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1601
2029
  }
1602
2030
  function extractTaskPayload(res) {
1603
2031
  if (res && typeof res === "object") {
@@ -1754,6 +2182,8 @@ COMMANDS
1754
2182
  --source capability|api Filter by source type
1755
2183
  --category <name> Filter by category
1756
2184
  --page N --page-size N Pagination
2185
+ --sort default|relevance|price Recommended, strongest match, or comparable price
2186
+ --include-all-versions Include active non-default major versions
1757
2187
  categories List all action categories
1758
2188
  --source capability|api Filter by source type
1759
2189
  services List all services
@@ -1761,8 +2191,11 @@ COMMANDS
1761
2191
  --category <name> Filter by category
1762
2192
  get <id> [--method GET|POST|...] Get action schema (filter by HTTP method)
1763
2193
  --code <target> Generate code snippet (curl, py, js, ts, go)
2194
+ get-batch <id> [id ...] Get up to 100 action schemas
1764
2195
  call <id> --input '{"key":"val"}' Execute an action
1765
2196
  --method GET|POST|... Override HTTP method
2197
+ --output <path> Save a raw binary response to a new file
2198
+ --stream Forward HTTP SSE frames unchanged
1766
2199
  --code <target> Generate code snippet instead of executing
1767
2200
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1768
2201
 
@@ -1779,7 +2212,7 @@ COMMANDS
1779
2212
 
1780
2213
  register [referral-code] Create a new user account (apiKey saved automatically)
1781
2214
  --referral-code <code> Register with an inviter's referral code (also: --referralCode, or as positional arg)
1782
- --force Replace an existing saved apiKey
2215
+ --force Replace an existing file-based apiKey
1783
2216
  balance Show current account balance
1784
2217
  topup [--amount <usd>] [--method stripe|x402] Generate payment URL
1785
2218
 
@@ -1794,9 +2227,12 @@ GLOBAL FLAGS
1794
2227
  --help Show help (use with a command for details, e.g. xapi-to get --help)
1795
2228
 
1796
2229
  ENV VARS
1797
- XAPI_KEY API key (header: XAPI-Key)
2230
+ XAPI_KEY API key (highest precedence; header: XAPI-Key)
2231
+ XAPI_API_KEY Compatible API key alias
1798
2232
  XAPI_ACTION_HOST Action service host (default: action.xapi.to)
2233
+ XAPI_API_HOST Auth/account service host (default: api.xapi.to)
1799
2234
  XAPI_OUTPUT Default output format
2235
+ XAPI_TRANSFER_IDLE_TIMEOUT_MS SSE/download idle timeout (default: 60000)
1800
2236
 
1801
2237
  EXAMPLES
1802
2238
  xapi-to register
@@ -1806,9 +2242,11 @@ EXAMPLES
1806
2242
  xapi-to list --source capability
1807
2243
  xapi-to search twitter --source api
1808
2244
  xapi-to get twitter.tweet_detail
2245
+ xapi-to get-batch twitter.tweet_detail crypto.token.price
1809
2246
  xapi-to get twitter.tweet_detail --code curl
1810
2247
  xapi-to get twitter.tweet_detail --code py --format pretty
1811
2248
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
2249
+ xapi-to call openrouter.audio_speech --input '{"body":{"input":"Hello"}}' --output speech.mp3
1812
2250
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' --code python
1813
2251
  xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1814
2252
  xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
@@ -1823,7 +2261,16 @@ async function main() {
1823
2261
  console.log(HELP);
1824
2262
  process.exit(0);
1825
2263
  }
1826
- if (flags.format) process.env.XAPI_OUTPUT = flags.format;
2264
+ if (flags.format) {
2265
+ if (!["json", "pretty", "table"].includes(flags.format)) {
2266
+ console.error(JSON.stringify({
2267
+ error: `invalid --format value: ${flags.format}`,
2268
+ hint: "expected json, pretty, or table"
2269
+ }));
2270
+ process.exit(1);
2271
+ }
2272
+ process.env.XAPI_OUTPUT = flags.format;
2273
+ }
1827
2274
  const [cmd, ...rest] = positional;
1828
2275
  switch (cmd) {
1829
2276
  // ── Action commands (top-level) ──
@@ -1837,6 +2284,8 @@ async function main() {
1837
2284
  return actionServices2(rest, flags);
1838
2285
  case "get":
1839
2286
  return actionGet2(rest, flags);
2287
+ case "get-batch":
2288
+ return actionBatchGet(rest, flags);
1840
2289
  case "call":
1841
2290
  return actionCall2(rest, flags);
1842
2291
  case "task": {