xapi-to 0.1.18 → 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,28 +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
183
191
  import { open, rm } from "fs/promises";
192
+ import { once } from "events";
184
193
  import { resolve } from "path";
185
194
  import { Readable, Transform } from "stream";
186
195
  import { pipeline } from "stream/promises";
187
196
  var DEFAULT_TIMEOUT_MS = 3e4;
188
197
  var EXECUTE_TIMEOUT_MS = 6e4;
198
+ var TRANSFER_IDLE_TIMEOUT_MS = 6e4;
189
199
  var IDEMPOTENT_RETRIES = 2;
190
200
  var RETRY_BASE_DELAY_MS = 500;
191
201
  var RETRY_MAX_DELAY_MS = 8e3;
@@ -225,6 +235,10 @@ function retryBaseDelayMs() {
225
235
  const override = Number(process.env.XAPI_RETRY_BASE_MS);
226
236
  return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
227
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
+ }
228
242
  function backoffDelayMs(attempt, retryAfterMs) {
229
243
  if (retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
230
244
  return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
@@ -296,7 +310,13 @@ async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0
296
310
  return body;
297
311
  } catch (e) {
298
312
  if (timedOut) {
299
- 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;
300
320
  }
301
321
  if (isRetryableNetworkError(e) && attempt < retries) {
302
322
  clearTimeout(timer);
@@ -339,6 +359,8 @@ async function actionSearch(query, opts, params = {}) {
339
359
  if (params.source) url.searchParams.set("source", params.source);
340
360
  if (params.page) url.searchParams.set("page", String(params.page));
341
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);
342
364
  return request(
343
365
  url.toString(),
344
366
  { method: "GET", headers: headers(opts.apiKey) },
@@ -364,6 +386,19 @@ async function actionGet(id, opts) {
364
386
  IDEMPOTENT_RETRIES
365
387
  );
366
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
+ }
367
402
  async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
368
403
  return request(
369
404
  `${baseUrl(opts)}/v1/actions/execute`,
@@ -376,13 +411,87 @@ async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeou
376
411
  retries
377
412
  );
378
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
+ }
379
481
  async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
380
482
  const controller = new AbortController();
381
483
  let timedOut = false;
382
- const timer = setTimeout(() => {
383
- timedOut = true;
384
- controller.abort();
385
- }, EXECUTE_TIMEOUT_MS);
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);
386
495
  const target = resolve(outputPath);
387
496
  let file;
388
497
  let complete = false;
@@ -424,9 +533,12 @@ async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
424
533
  }
425
534
  let bytes = 0;
426
535
  if (res.body) {
536
+ const idleTimeoutMs = transferIdleTimeoutMs();
537
+ resetTimeout(idleTimeoutMs);
427
538
  const source = Readable.fromWeb(res.body);
428
539
  const counter = new Transform({
429
540
  transform(chunk, _encoding, callback) {
541
+ resetTimeout(idleTimeoutMs);
430
542
  bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
431
543
  callback(null, chunk);
432
544
  }
@@ -444,10 +556,10 @@ async function actionDownload(actionId, input, opts, outputPath, httpMethod) {
444
556
  status: res.status
445
557
  };
446
558
  } catch (error) {
447
- if (timedOut) throw new RequestTimeoutError(EXECUTE_TIMEOUT_MS);
559
+ if (timedOut) throw new RequestTimeoutError(activeTimeoutMs);
448
560
  throw error;
449
561
  } finally {
450
- clearTimeout(timer);
562
+ if (timer) clearTimeout(timer);
451
563
  if (!complete && file) {
452
564
  await file.close().catch(() => void 0);
453
565
  await rm(target, { force: true }).catch(() => void 0);
@@ -801,6 +913,7 @@ function generateCode(target, params) {
801
913
 
802
914
  // src/commands/action.ts
803
915
  var VALID_SOURCES = ["capability", "api"];
916
+ var VALID_SEARCH_SORTS = ["default", "relevance", "price"];
804
917
  var LIST_HELP = `xapi-to list - List all actions
805
918
 
806
919
  USAGE
@@ -829,12 +942,50 @@ FLAGS
829
942
  --category <name> Filter by category
830
943
  --page N Page number (default: 1)
831
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
832
949
  --format json|pretty|table Output format
833
950
 
834
951
  EXAMPLES
835
952
  xapi-to search twitter
836
953
  xapi-to search "tweet detail" --source api
954
+ xapi-to search "tweet detail" --sort relevance
955
+ xapi-to search weather --sort price
837
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
838
989
  `;
839
990
  var GET_HELP = `xapi-to get - Get action schema
840
991
 
@@ -878,6 +1029,7 @@ FLAGS
878
1029
  --input <json> Input payload as JSON (required for execution)
879
1030
  --method GET|POST|... Override HTTP method
880
1031
  --output <path> Save a raw binary response to a new file
1032
+ --stream Forward the action's HTTP SSE response unchanged
881
1033
  --code <target> Generate code snippet instead of executing
882
1034
  --format json|pretty|table Output format
883
1035
 
@@ -901,6 +1053,7 @@ CODE TARGETS
901
1053
  EXAMPLES
902
1054
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
903
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
904
1057
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code py
905
1058
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code curl --format pretty
906
1059
  `;
@@ -931,6 +1084,31 @@ function getSource(flags) {
931
1084
  }
932
1085
  return flags.source;
933
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
+ }
934
1112
  async function actionList2(args, flags) {
935
1113
  showHelpIfRequested(flags, LIST_HELP);
936
1114
  const cfg = getConfig();
@@ -938,8 +1116,8 @@ async function actionList2(args, flags) {
938
1116
  try {
939
1117
  const res = await actionList(cfg, {
940
1118
  source: getSource(flags),
941
- page: flags.page ? parseInt(flags.page) : void 0,
942
- 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"),
943
1121
  category: flags.category,
944
1122
  service_id: flags["service-id"]
945
1123
  });
@@ -965,15 +1143,23 @@ async function actionSearch2(args, flags) {
965
1143
  showHelpIfRequested(flags, SEARCH_HELP);
966
1144
  const query = args[0];
967
1145
  if (!query) err("usage: xapi-to search <query>");
1146
+ const requestedSort = getSearchSort(flags);
968
1147
  const cfg = getConfig();
969
1148
  const fmt = flags.format || getFormat();
970
1149
  try {
971
1150
  const res = await actionSearch(query, cfg, {
972
1151
  source: getSource(flags),
973
1152
  category: flags.category,
974
- page: flags.page ? parseInt(flags.page) : void 0,
975
- 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
976
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
+ }
977
1163
  const results = res.results || [];
978
1164
  if (fmt === "table") {
979
1165
  output(results.map((a) => ({
@@ -983,7 +1169,8 @@ async function actionSearch2(args, flags) {
983
1169
  source: a.source ?? "",
984
1170
  category: a.meta?.category ?? "",
985
1171
  status: a.status ?? "",
986
- cost: a.meta?.cost ?? ""
1172
+ price: a.meta?.pricing?.comparable ? a.meta.pricing.listed_price : "",
1173
+ pricing: a.meta?.pricing?.billing_type ?? ""
987
1174
  })), "table");
988
1175
  } else {
989
1176
  output(res, flags.format);
@@ -993,6 +1180,7 @@ async function actionSearch2(args, flags) {
993
1180
  }
994
1181
  }
995
1182
  async function actionCategories2(args, flags) {
1183
+ showHelpIfRequested(flags, CATEGORIES_HELP);
996
1184
  const cfg = getConfig();
997
1185
  const fmt = flags.format || getFormat();
998
1186
  try {
@@ -1007,12 +1195,13 @@ async function actionCategories2(args, flags) {
1007
1195
  }
1008
1196
  }
1009
1197
  async function actionServices2(args, flags) {
1198
+ showHelpIfRequested(flags, SERVICES_HELP);
1010
1199
  const cfg = getConfig();
1011
1200
  const fmt = flags.format || getFormat();
1012
1201
  try {
1013
1202
  const res = await actionServices(cfg, {
1014
- page: flags.page ? parseInt(flags.page) : void 0,
1015
- 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"),
1016
1205
  category: flags.category
1017
1206
  });
1018
1207
  const services = res.services || [];
@@ -1037,11 +1226,11 @@ async function actionGet2(args, flags) {
1037
1226
  const id = args[0];
1038
1227
  if (!id) err("usage: xapi-to get <id> [--method GET|POST|DELETE|...]");
1039
1228
  if (flags.code) validateCodeFlag(flags);
1229
+ const methodFilter = httpMethodFlag(flags.method);
1040
1230
  const cfg = getConfig();
1041
1231
  try {
1042
1232
  const res = await actionGet(id, cfg);
1043
1233
  const actions = Array.isArray(res) ? res : [res];
1044
- const methodFilter = flags.method?.toUpperCase();
1045
1234
  const filtered = methodFilter ? actions.filter((a) => a.method?.toUpperCase() === methodFilter) : actions;
1046
1235
  if (filtered.length === 0) {
1047
1236
  err(`no endpoint found for method "${methodFilter}" in action "${id}"`);
@@ -1064,15 +1253,30 @@ async function actionGet2(args, flags) {
1064
1253
  err("get failed", e.message);
1065
1254
  }
1066
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
+ }
1067
1268
  async function actionCall2(args, flags) {
1068
1269
  showHelpIfRequested(flags, CALL_HELP);
1069
1270
  const id = args[0];
1070
1271
  if (!id) err(`usage: xapi-to call <id> --input '{"key":"val"}'`);
1071
1272
  if (flags.code) validateCodeFlag(flags);
1072
1273
  if (flags.output === "true") err("--output requires a file path");
1274
+ const stream = flags.stream === "true" || flags.stream === "1" || flags.stream === "yes";
1073
1275
  if (flags.code && flags.output) {
1074
1276
  err("--output cannot be combined with --code");
1075
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");
1076
1280
  const cfg = getConfig();
1077
1281
  let input = {};
1078
1282
  if (flags.input) {
@@ -1086,7 +1290,7 @@ async function actionCall2(args, flags) {
1086
1290
  }
1087
1291
  }
1088
1292
  const { method: inputMethod, ...cleanInput } = input;
1089
- 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);
1090
1294
  if (flags.code) {
1091
1295
  const result = generateCode(flags.code, { actionId: id, input: cleanInput, actionHost: cfg.actionHost, method });
1092
1296
  outputCode(result, flags);
@@ -1094,6 +1298,10 @@ async function actionCall2(args, flags) {
1094
1298
  }
1095
1299
  requireApiKey(cfg);
1096
1300
  try {
1301
+ if (stream) {
1302
+ await actionStream(id, cleanInput, cfg, method);
1303
+ return;
1304
+ }
1097
1305
  if (flags.output) {
1098
1306
  const result = await actionDownload(
1099
1307
  id,
@@ -1126,6 +1334,7 @@ async function actionCall2(args, flags) {
1126
1334
  var config_exports = {};
1127
1335
  __export(config_exports, {
1128
1336
  CONFIG_HELP: () => CONFIG_HELP,
1337
+ HEALTH_HELP: () => HEALTH_HELP,
1129
1338
  configHealth: () => configHealth,
1130
1339
  configSet: () => configSet,
1131
1340
  configShow: () => configShow
@@ -1144,14 +1353,23 @@ COMMANDS
1144
1353
  FLAGS
1145
1354
  --format json|pretty|table Output format
1146
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
+
1147
1360
  EXAMPLES
1148
1361
  xapi-to config show
1149
1362
  xapi-to config set apiKey=xapi_abc123
1150
1363
  echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
1151
1364
  xapi-to config health
1152
1365
  `;
1366
+ var HEALTH_HELP = `xapi-to health - Check backend connectivity
1367
+
1368
+ USAGE
1369
+ xapi-to health [--format json|pretty|table]
1370
+ `;
1153
1371
  async function configShow(args, flags) {
1154
- showConfig();
1372
+ output(showConfig(), flags.format);
1155
1373
  }
1156
1374
  async function configSet(args, flags) {
1157
1375
  if (args.length === 0) err("usage: xapi-to config set apiKey=<key>");
@@ -1169,10 +1387,22 @@ async function configSet(args, flags) {
1169
1387
  if (!value) err("apiKey is empty");
1170
1388
  updates.apiKey = value;
1171
1389
  }
1390
+ const sourceBeforeSave = getApiKeySource();
1172
1391
  saveConfig(updates);
1173
- 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);
1174
1400
  }
1175
1401
  async function configHealth(args, flags) {
1402
+ if (flags.help) {
1403
+ console.log(HEALTH_HELP);
1404
+ return;
1405
+ }
1176
1406
  const cfg = getConfig();
1177
1407
  const start = Date.now();
1178
1408
  try {
@@ -1185,6 +1415,30 @@ async function configHealth(args, flags) {
1185
1415
  }
1186
1416
 
1187
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
+ }
1188
1442
  async function registerAccount(referralCode) {
1189
1443
  assertAllowedHost(XAPI_API_HOST);
1190
1444
  const controller = new AbortController();
@@ -1194,39 +1448,57 @@ async function registerAccount(referralCode) {
1194
1448
  method: "POST",
1195
1449
  headers: { "Content-Type": "application/json" },
1196
1450
  body: JSON.stringify(referralCode ? { referralCode } : {}),
1197
- signal: controller.signal
1451
+ signal: controller.signal,
1452
+ redirect: "manual"
1198
1453
  });
1454
+ if (res.status >= 300 && res.status < 400) {
1455
+ throw new Error(`refusing to follow redirect to "${res.headers.get("location") ?? "?"}"`);
1456
+ }
1199
1457
  if (!res.ok) {
1200
1458
  const text = await res.text();
1201
1459
  throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
1202
1460
  }
1203
- return res.json();
1461
+ return validateRegisterResponse(await res.json());
1204
1462
  } finally {
1205
1463
  clearTimeout(timer);
1206
1464
  }
1207
1465
  }
1208
1466
  async function register(args, flags) {
1467
+ if (flags.help) {
1468
+ console.log(REGISTER_HELP);
1469
+ return;
1470
+ }
1209
1471
  try {
1210
1472
  const cfg = getConfig();
1211
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
+ }
1212
1481
  if (cfg.apiKey && !force) {
1213
1482
  err("register would overwrite existing apiKey", 'Run "xapi-to register --force" to create a new account and replace the saved key.');
1214
1483
  }
1215
1484
  const rawReferral = flags["referral-code"] ?? flags["referralCode"] ?? args[0];
1216
- 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;
1217
1489
  const res = await registerAccount(referralCode);
1490
+ const bindUrl = res.bindUrl || res.claimUrl;
1218
1491
  saveConfig({ apiKey: res.apiKey });
1219
1492
  output({
1220
1493
  apiKey: res.apiKey,
1221
1494
  user: res.user,
1222
1495
  referralCode: res.referralCode,
1223
- claim: {
1224
- code: res.claimCode,
1225
- sessionId: res.claimSessionId,
1226
- url: res.claimUrl
1227
- },
1228
- tweetTemplate: res.tweetTemplate,
1229
- ...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 } : {},
1230
1502
  note: force && cfg.apiKey ? "apiKey replaced in ~/.xapi/config.json" : "apiKey saved to ~/.xapi/config.json"
1231
1503
  }, flags.format);
1232
1504
  } catch (e) {
@@ -1236,23 +1508,50 @@ async function register(args, flags) {
1236
1508
 
1237
1509
  // src/commands/topup.ts
1238
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
+ `;
1239
1518
  async function topup(args, flags) {
1519
+ if (flags.help) {
1520
+ console.log(TOPUP_HELP);
1521
+ return;
1522
+ }
1240
1523
  const cfg = getConfig();
1241
1524
  const url = new URL(TOPUP_BASE_URL);
1242
1525
  if (cfg.apiKey) url.searchParams.set("apikey", cfg.apiKey);
1243
- 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
+ }
1244
1532
  const amountStr = flags.amount || args[0];
1245
1533
  if (amountStr) {
1246
- const amountUsd = parseFloat(amountStr);
1247
- if (!isNaN(amountUsd) && amountUsd > 0) {
1248
- 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.");
1249
1538
  }
1539
+ url.searchParams.set("amount", String(amountUsd));
1250
1540
  }
1251
1541
  output({ url: url.toString() }, flags.format);
1252
1542
  }
1253
1543
 
1254
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
+ `;
1255
1550
  async function balance(args, flags) {
1551
+ if (flags.help) {
1552
+ console.log(BALANCE_HELP);
1553
+ return;
1554
+ }
1256
1555
  const cfg = getConfig();
1257
1556
  requireApiKey(cfg);
1258
1557
  let token;
@@ -1289,9 +1588,10 @@ __export(oauth_exports, {
1289
1588
  });
1290
1589
  import { spawnSync } from "child_process";
1291
1590
  function openBrowser(url) {
1292
- 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];
1293
1593
  try {
1294
- spawnSync(cmd, [url], { stdio: "ignore" });
1594
+ spawnSync(cmd, args, { stdio: "ignore" });
1295
1595
  } catch {
1296
1596
  }
1297
1597
  }
@@ -1335,9 +1635,11 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
1335
1635
  throw new Error("No API keys found for this account");
1336
1636
  }
1337
1637
  const prefix = plaintextKey.substring(0, 7);
1338
- 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);
1339
1641
  if (match) return match;
1340
- if (keys.length === 1) return keys[0];
1642
+ if (keys.length === 1 && !keys[0].keyPreview.includes("****")) return keys[0];
1341
1643
  throw new Error(
1342
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.`
1343
1645
  );
@@ -1454,13 +1756,20 @@ FLAGS
1454
1756
  EXAMPLES
1455
1757
  xapi-to oauth bind
1456
1758
  xapi-to oauth bind --provider twitter
1457
- 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
1458
1761
  xapi-to oauth status
1459
1762
  xapi-to oauth status --format pretty
1460
1763
  xapi-to oauth unbind abc123
1461
1764
  xapi-to oauth providers
1462
1765
  `;
1463
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
+ }
1464
1773
  const cfg = getConfig();
1465
1774
  requireApiKey(cfg);
1466
1775
  const apiKey = cfg.apiKey;
@@ -1518,6 +1827,16 @@ async function oauthBind(args, flags) {
1518
1827
  const authorizationStartedAt = /* @__PURE__ */ new Date();
1519
1828
  const result = await initiateOAuth(keyRecord.id, provider.id, jwtToken, XAPI_API_HOST, scopes);
1520
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
+ }
1521
1840
  if (isTTY) {
1522
1841
  if (!headerPrinted) {
1523
1842
  console.error(`
@@ -1863,6 +2182,8 @@ COMMANDS
1863
2182
  --source capability|api Filter by source type
1864
2183
  --category <name> Filter by category
1865
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
1866
2187
  categories List all action categories
1867
2188
  --source capability|api Filter by source type
1868
2189
  services List all services
@@ -1870,9 +2191,11 @@ COMMANDS
1870
2191
  --category <name> Filter by category
1871
2192
  get <id> [--method GET|POST|...] Get action schema (filter by HTTP method)
1872
2193
  --code <target> Generate code snippet (curl, py, js, ts, go)
2194
+ get-batch <id> [id ...] Get up to 100 action schemas
1873
2195
  call <id> --input '{"key":"val"}' Execute an action
1874
2196
  --method GET|POST|... Override HTTP method
1875
2197
  --output <path> Save a raw binary response to a new file
2198
+ --stream Forward HTTP SSE frames unchanged
1876
2199
  --code <target> Generate code snippet instead of executing
1877
2200
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1878
2201
 
@@ -1889,7 +2212,7 @@ COMMANDS
1889
2212
 
1890
2213
  register [referral-code] Create a new user account (apiKey saved automatically)
1891
2214
  --referral-code <code> Register with an inviter's referral code (also: --referralCode, or as positional arg)
1892
- --force Replace an existing saved apiKey
2215
+ --force Replace an existing file-based apiKey
1893
2216
  balance Show current account balance
1894
2217
  topup [--amount <usd>] [--method stripe|x402] Generate payment URL
1895
2218
 
@@ -1904,9 +2227,12 @@ GLOBAL FLAGS
1904
2227
  --help Show help (use with a command for details, e.g. xapi-to get --help)
1905
2228
 
1906
2229
  ENV VARS
1907
- 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
1908
2232
  XAPI_ACTION_HOST Action service host (default: action.xapi.to)
2233
+ XAPI_API_HOST Auth/account service host (default: api.xapi.to)
1909
2234
  XAPI_OUTPUT Default output format
2235
+ XAPI_TRANSFER_IDLE_TIMEOUT_MS SSE/download idle timeout (default: 60000)
1910
2236
 
1911
2237
  EXAMPLES
1912
2238
  xapi-to register
@@ -1916,6 +2242,7 @@ EXAMPLES
1916
2242
  xapi-to list --source capability
1917
2243
  xapi-to search twitter --source api
1918
2244
  xapi-to get twitter.tweet_detail
2245
+ xapi-to get-batch twitter.tweet_detail crypto.token.price
1919
2246
  xapi-to get twitter.tweet_detail --code curl
1920
2247
  xapi-to get twitter.tweet_detail --code py --format pretty
1921
2248
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
@@ -1934,7 +2261,16 @@ async function main() {
1934
2261
  console.log(HELP);
1935
2262
  process.exit(0);
1936
2263
  }
1937
- 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
+ }
1938
2274
  const [cmd, ...rest] = positional;
1939
2275
  switch (cmd) {
1940
2276
  // ── Action commands (top-level) ──
@@ -1948,6 +2284,8 @@ async function main() {
1948
2284
  return actionServices2(rest, flags);
1949
2285
  case "get":
1950
2286
  return actionGet2(rest, flags);
2287
+ case "get-batch":
2288
+ return actionBatchGet(rest, flags);
1951
2289
  case "call":
1952
2290
  return actionCall2(rest, flags);
1953
2291
  case "task": {