xapi-to 0.1.16 → 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 +19 -0
  2. package/dist/index.js +548 -57
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -66,6 +66,16 @@ xapi-to get twitter.tweet_detail # get action schema
66
66
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' # execute
67
67
  ```
68
68
 
69
+ ### Async Task Commands
70
+
71
+ Task helpers built on top of the `task.poll` capability.
72
+
73
+ ```bash
74
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000 # poll once
75
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 # wait until terminal status
76
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m
77
+ ```
78
+
69
79
  ### OAuth
70
80
 
71
81
  Bind third-party OAuth accounts (e.g. Twitter) to your API key.
@@ -112,6 +122,15 @@ xapi-to get twitter.tweet_detail
112
122
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}'
113
123
  ```
114
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
+
115
134
  ## Output Formats
116
135
 
117
136
  All output is JSON by default — designed for agent consumption.
package/dist/index.js CHANGED
@@ -100,7 +100,43 @@ import { join } from "path";
100
100
  var XAPI_ACTION_HOST = process.env.XAPI_ACTION_HOST || "action.xapi.to";
101
101
  var XAPI_API_HOST = process.env.XAPI_API_HOST || "api.xapi.to";
102
102
  function scheme(host) {
103
- return host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https";
103
+ return isLoopbackHost(host) ? "http" : "https";
104
+ }
105
+ var ALLOWED_HOST_EXACT = ["xapi.to", "xapi.xyz"];
106
+ var ALLOWED_HOST_SUFFIXES = [".xapi.to", ".xapi.xyz"];
107
+ function hostnameOf(hostOrUrl) {
108
+ const raw = hostOrUrl.includes("://") ? hostOrUrl : `http://${hostOrUrl}`;
109
+ try {
110
+ return new URL(raw).hostname.toLowerCase();
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+ function isLoopbackIPv4(h) {
116
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
117
+ if (!m) return false;
118
+ const octets = m.slice(1).map(Number);
119
+ return octets.every((o) => o <= 255) && octets[0] === 127;
120
+ }
121
+ function isLoopbackHostname(h) {
122
+ return h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "[::1]" || isLoopbackIPv4(h);
123
+ }
124
+ function isLoopbackHost(hostOrUrl) {
125
+ return isLoopbackHostname(hostnameOf(hostOrUrl));
126
+ }
127
+ function isAllowedHost(hostOrUrl) {
128
+ const h = hostnameOf(hostOrUrl);
129
+ if (!h) return false;
130
+ if (isLoopbackHostname(h)) return true;
131
+ if (ALLOWED_HOST_EXACT.includes(h)) return true;
132
+ return ALLOWED_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix));
133
+ }
134
+ function assertAllowedHost(hostOrUrl) {
135
+ if (!isAllowedHost(hostOrUrl)) {
136
+ throw new Error(
137
+ `refusing to contact untrusted host "${hostnameOf(hostOrUrl) || hostOrUrl}": the xapi API key may only be sent to *.xapi.to, *.xapi.xyz, or localhost`
138
+ );
139
+ }
104
140
  }
105
141
  var CONFIG_DIR = join(homedir(), ".xapi");
106
142
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -144,41 +180,134 @@ function showConfig() {
144
180
  }
145
181
 
146
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";
147
187
  var DEFAULT_TIMEOUT_MS = 3e4;
148
188
  var EXECUTE_TIMEOUT_MS = 6e4;
149
- async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS) {
150
- const controller = new AbortController();
151
- const timer = setTimeout(() => controller.abort(), timeoutMs);
152
- try {
153
- const res = await fetch(url, { ...options, signal: controller.signal });
154
- if (!res.ok) {
155
- const text2 = await res.text();
156
- throw new Error(`HTTP ${res.status}: ${text2.slice(0, 300)}`);
157
- }
158
- if (res.status === 204) {
159
- return void 0;
160
- }
161
- const text = await res.text();
162
- if (!text.trim()) {
163
- return void 0;
164
- }
165
- const body = JSON.parse(text);
166
- if (body && typeof body === "object" && "success" in body && body.success === false) {
167
- const data = body.data;
168
- if (data?.statusCode === 401 || data?.error === "Unauthorized") {
189
+ var IDEMPOTENT_RETRIES = 2;
190
+ var RETRY_BASE_DELAY_MS = 500;
191
+ var RETRY_MAX_DELAY_MS = 8e3;
192
+ var HttpError = class extends Error {
193
+ constructor(status, detail, retryAfterMs) {
194
+ super(`HTTP ${status}: ${detail}`);
195
+ this.status = status;
196
+ this.retryAfterMs = retryAfterMs;
197
+ this.name = "HttpError";
198
+ }
199
+ status;
200
+ retryAfterMs;
201
+ };
202
+ var RequestTimeoutError = class extends Error {
203
+ constructor(timeoutMs) {
204
+ super(`request timed out after ${timeoutMs}ms`);
205
+ this.timeoutMs = timeoutMs;
206
+ this.name = "RequestTimeoutError";
207
+ }
208
+ timeoutMs;
209
+ };
210
+ function isRetryableStatus(status) {
211
+ return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
212
+ }
213
+ function isRetryableNetworkError(e) {
214
+ if (!(e instanceof Error)) return false;
215
+ if (e instanceof HttpError || e instanceof RequestTimeoutError) return false;
216
+ if (e.name === "AbortError") return false;
217
+ return e instanceof TypeError || /network|fetch failed|econn|etimedout|eai_again|socket|dns/i.test(e.message);
218
+ }
219
+ function isRetryableRequestError(e) {
220
+ if (e instanceof HttpError) return isRetryableStatus(e.status);
221
+ if (e instanceof RequestTimeoutError) return true;
222
+ return isRetryableNetworkError(e);
223
+ }
224
+ function retryBaseDelayMs() {
225
+ const override = Number(process.env.XAPI_RETRY_BASE_MS);
226
+ return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
227
+ }
228
+ function backoffDelayMs(attempt, retryAfterMs) {
229
+ if (retryAfterMs !== void 0 && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
230
+ return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
231
+ }
232
+ const capped = Math.min(retryBaseDelayMs() * 2 ** attempt, RETRY_MAX_DELAY_MS);
233
+ return capped / 2 + Math.random() * (capped / 2);
234
+ }
235
+ function parseRetryAfterMs(res) {
236
+ const header = res.headers.get("retry-after");
237
+ if (!header) return void 0;
238
+ const seconds = Number(header);
239
+ if (Number.isFinite(seconds)) return seconds * 1e3;
240
+ const at = Date.parse(header);
241
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
242
+ }
243
+ function sleep(ms) {
244
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
245
+ }
246
+ async function request(url, options, timeoutMs = DEFAULT_TIMEOUT_MS, retries = 0) {
247
+ assertAllowedHost(url);
248
+ let attempt = 0;
249
+ while (true) {
250
+ const controller = new AbortController();
251
+ let timedOut = false;
252
+ const timer = setTimeout(() => {
253
+ timedOut = true;
254
+ controller.abort();
255
+ }, timeoutMs);
256
+ try {
257
+ const res = await fetch(url, { ...options, redirect: "manual", signal: controller.signal });
258
+ if (res.status >= 300 && res.status < 400) {
169
259
  throw new Error(
170
- "Authentication failed: " + (data.message || "Invalid or missing API key") + '. Run "npx xapi-to config set apiKey=<key>" to update your key.'
260
+ `refusing to follow redirect to "${res.headers.get("location") ?? "?"}" (would forward the API key past the host allowlist)`
171
261
  );
172
262
  }
173
- if (data?.error === "OAuth Required" || data?.statusCode === 403 && data?.message?.includes("OAuth")) {
174
- throw new Error(
175
- (data.message || "OAuth authorization required") + '. Run "xapi-to oauth bind" to connect your account.'
176
- );
263
+ if (!res.ok) {
264
+ const retryAfterMs = isRetryableStatus(res.status) ? parseRetryAfterMs(res) : void 0;
265
+ if (isRetryableStatus(res.status) && attempt < retries) {
266
+ await res.text().catch(() => "");
267
+ clearTimeout(timer);
268
+ await sleep(backoffDelayMs(attempt, retryAfterMs));
269
+ attempt++;
270
+ continue;
271
+ }
272
+ const text2 = await res.text();
273
+ throw new HttpError(res.status, text2.slice(0, 300), retryAfterMs);
274
+ }
275
+ if (res.status === 204) {
276
+ return void 0;
277
+ }
278
+ const text = await res.text();
279
+ if (!text.trim()) {
280
+ return void 0;
281
+ }
282
+ const body = JSON.parse(text);
283
+ if (body && typeof body === "object" && "success" in body && body.success === false) {
284
+ const data = body.data;
285
+ if (data?.statusCode === 401 || data?.error === "Unauthorized") {
286
+ throw new Error(
287
+ "Authentication failed: " + (data.message || "Invalid or missing API key") + '. Run "npx xapi-to config set apiKey=<key>" to update your key.'
288
+ );
289
+ }
290
+ if (data?.error === "OAuth Required" || data?.statusCode === 403 && data?.message?.includes("OAuth")) {
291
+ throw new Error(
292
+ (data.message || "OAuth authorization required") + '. Run "xapi-to oauth bind" to connect your account.'
293
+ );
294
+ }
177
295
  }
296
+ return body;
297
+ } catch (e) {
298
+ if (timedOut) {
299
+ throw new RequestTimeoutError(timeoutMs);
300
+ }
301
+ if (isRetryableNetworkError(e) && attempt < retries) {
302
+ clearTimeout(timer);
303
+ await sleep(backoffDelayMs(attempt));
304
+ attempt++;
305
+ continue;
306
+ }
307
+ throw e;
308
+ } finally {
309
+ clearTimeout(timer);
178
310
  }
179
- return body;
180
- } finally {
181
- clearTimeout(timer);
182
311
  }
183
312
  }
184
313
  function headers(apiKey) {
@@ -198,7 +327,9 @@ async function actionList(opts, params = {}) {
198
327
  if (params.service_id) url.searchParams.set("service_id", params.service_id);
199
328
  return request(
200
329
  url.toString(),
201
- { method: "GET", headers: headers(opts.apiKey) }
330
+ { method: "GET", headers: headers(opts.apiKey) },
331
+ DEFAULT_TIMEOUT_MS,
332
+ IDEMPOTENT_RETRIES
202
333
  );
203
334
  }
204
335
  async function actionSearch(query, opts, params = {}) {
@@ -210,7 +341,9 @@ async function actionSearch(query, opts, params = {}) {
210
341
  if (params.page_size) url.searchParams.set("page_size", String(params.page_size));
211
342
  return request(
212
343
  url.toString(),
213
- { method: "GET", headers: headers(opts.apiKey) }
344
+ { method: "GET", headers: headers(opts.apiKey) },
345
+ DEFAULT_TIMEOUT_MS,
346
+ IDEMPOTENT_RETRIES
214
347
  );
215
348
  }
216
349
  async function actionCategories(opts, params = {}) {
@@ -218,16 +351,20 @@ async function actionCategories(opts, params = {}) {
218
351
  if (params.source) url.searchParams.set("source", params.source);
219
352
  return request(
220
353
  url.toString(),
221
- { method: "GET", headers: headers(opts.apiKey) }
354
+ { method: "GET", headers: headers(opts.apiKey) },
355
+ DEFAULT_TIMEOUT_MS,
356
+ IDEMPOTENT_RETRIES
222
357
  );
223
358
  }
224
359
  async function actionGet(id, opts) {
225
360
  return request(
226
361
  `${baseUrl(opts)}/v1/actions/${encodeURIComponent(id)}`,
227
- { method: "GET", headers: headers(opts.apiKey) }
362
+ { method: "GET", headers: headers(opts.apiKey) },
363
+ DEFAULT_TIMEOUT_MS,
364
+ IDEMPOTENT_RETRIES
228
365
  );
229
366
  }
230
- async function actionCall(actionId, input, opts, httpMethod) {
367
+ async function actionCall(actionId, input, opts, httpMethod, retries = 0, timeoutMs = EXECUTE_TIMEOUT_MS) {
231
368
  return request(
232
369
  `${baseUrl(opts)}/v1/actions/execute`,
233
370
  {
@@ -235,9 +372,88 @@ async function actionCall(actionId, input, opts, httpMethod) {
235
372
  headers: headers(opts.apiKey),
236
373
  body: JSON.stringify({ action_id: actionId, ...httpMethod ? { method: httpMethod } : {}, input })
237
374
  },
238
- EXECUTE_TIMEOUT_MS
375
+ Math.min(timeoutMs, EXECUTE_TIMEOUT_MS),
376
+ retries
239
377
  );
240
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
+ }
241
457
  async function actionServices(opts, params = {}) {
242
458
  const url = new URL(`${baseUrl(opts)}/v1/actions/services`);
243
459
  if (params.page) url.searchParams.set("page", String(params.page));
@@ -245,14 +461,18 @@ async function actionServices(opts, params = {}) {
245
461
  if (params.category) url.searchParams.set("category", params.category);
246
462
  return request(
247
463
  url.toString(),
248
- { method: "GET", headers: headers(opts.apiKey) }
464
+ { method: "GET", headers: headers(opts.apiKey) },
465
+ DEFAULT_TIMEOUT_MS,
466
+ IDEMPOTENT_RETRIES
249
467
  );
250
468
  }
251
469
  async function healthCheck(opts) {
252
470
  return request(
253
471
  `${baseUrl(opts)}/health`,
254
472
  { method: "GET", headers: headers(opts.apiKey) },
255
- 5e3
473
+ 5e3,
474
+ 0
475
+ // health is a quick connectivity probe — fail fast, don't retry
256
476
  );
257
477
  }
258
478
  async function loginWithApiKey(apiKey, apiHost) {
@@ -262,7 +482,10 @@ async function loginWithApiKey(apiKey, apiHost) {
262
482
  method: "POST",
263
483
  headers: { "Content-Type": "application/json" },
264
484
  body: JSON.stringify({ apiKey })
265
- }
485
+ },
486
+ DEFAULT_TIMEOUT_MS,
487
+ IDEMPOTENT_RETRIES
488
+ // auth exchange has no side effect — safe to retry
266
489
  );
267
490
  }
268
491
  function jwtHeaders(jwtToken) {
@@ -271,7 +494,9 @@ function jwtHeaders(jwtToken) {
271
494
  async function listKeys(jwtToken, apiHost) {
272
495
  return request(
273
496
  `${scheme(apiHost)}://${apiHost}/api/keys`,
274
- { method: "GET", headers: jwtHeaders(jwtToken) }
497
+ { method: "GET", headers: jwtHeaders(jwtToken) },
498
+ DEFAULT_TIMEOUT_MS,
499
+ IDEMPOTENT_RETRIES
275
500
  );
276
501
  }
277
502
  async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
@@ -287,7 +512,9 @@ async function enableOAuthForKey(keyId, plaintextKey, jwtToken, apiHost) {
287
512
  async function listOAuthProviders(apiHost) {
288
513
  return request(
289
514
  `${scheme(apiHost)}://${apiHost}/api/oauth/providers`,
290
- { method: "GET", headers: { "Content-Type": "application/json" } }
515
+ { method: "GET", headers: { "Content-Type": "application/json" } },
516
+ DEFAULT_TIMEOUT_MS,
517
+ IDEMPOTENT_RETRIES
291
518
  );
292
519
  }
293
520
  async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
@@ -305,7 +532,9 @@ async function initiateOAuth(apiKeyId, providerId, jwtToken, apiHost, scopes) {
305
532
  async function listOAuthBindings(jwtToken, apiHost) {
306
533
  return request(
307
534
  `${scheme(apiHost)}://${apiHost}/api/oauth/bindings`,
308
- { method: "GET", headers: jwtHeaders(jwtToken) }
535
+ { method: "GET", headers: jwtHeaders(jwtToken) },
536
+ DEFAULT_TIMEOUT_MS,
537
+ IDEMPOTENT_RETRIES
309
538
  );
310
539
  }
311
540
  async function deleteOAuthBinding(bindingId, jwtToken, apiHost) {
@@ -648,6 +877,7 @@ USAGE
648
877
  FLAGS
649
878
  --input <json> Input payload as JSON (required for execution)
650
879
  --method GET|POST|... Override HTTP method
880
+ --output <path> Save a raw binary response to a new file
651
881
  --code <target> Generate code snippet instead of executing
652
882
  --format json|pretty|table Output format
653
883
 
@@ -670,6 +900,7 @@ CODE TARGETS
670
900
 
671
901
  EXAMPLES
672
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
673
904
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code py
674
905
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"123"}' --code curl --format pretty
675
906
  `;
@@ -838,6 +1069,10 @@ async function actionCall2(args, flags) {
838
1069
  const id = args[0];
839
1070
  if (!id) err(`usage: xapi-to call <id> --input '{"key":"val"}'`);
840
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
+ }
841
1076
  const cfg = getConfig();
842
1077
  let input = {};
843
1078
  if (flags.input) {
@@ -859,6 +1094,27 @@ async function actionCall2(args, flags) {
859
1094
  }
860
1095
  requireApiKey(cfg);
861
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
+ }
862
1118
  const res = await actionCall(id, cleanInput, cfg, method);
863
1119
  output(res, flags.format);
864
1120
  } catch (e) {
@@ -874,6 +1130,7 @@ __export(config_exports, {
874
1130
  configSet: () => configSet,
875
1131
  configShow: () => configShow
876
1132
  });
1133
+ import { readFileSync as readFileSync2 } from "fs";
877
1134
  var CONFIG_HELP = `xapi-to config - Manage CLI configuration
878
1135
 
879
1136
  USAGE
@@ -881,7 +1138,7 @@ USAGE
881
1138
 
882
1139
  COMMANDS
883
1140
  show Show current config (host, apiKey path, etc.)
884
- set apiKey=<key> Save API key to ~/.xapi/config.json
1141
+ set apiKey=<key> Save API key to ~/.xapi/config.json (apiKey=- reads from stdin)
885
1142
  health Check backend connectivity (alias: xapi-to health)
886
1143
 
887
1144
  FLAGS
@@ -890,6 +1147,7 @@ FLAGS
890
1147
  EXAMPLES
891
1148
  xapi-to config show
892
1149
  xapi-to config set apiKey=xapi_abc123
1150
+ echo "$XAPI_KEY" | xapi-to config set apiKey=- # keeps the key out of shell history
893
1151
  xapi-to config health
894
1152
  `;
895
1153
  async function configShow(args, flags) {
@@ -904,7 +1162,12 @@ async function configSet(args, flags) {
904
1162
  const key = arg.slice(0, eq);
905
1163
  if (key === "host") err("host is built-in and cannot be configured");
906
1164
  if (key !== "apiKey") err(`unknown config key: ${key} (only apiKey is configurable)`);
907
- updates.apiKey = arg.slice(eq + 1);
1165
+ let value = arg.slice(eq + 1);
1166
+ if (value === "-") {
1167
+ value = readFileSync2(0, "utf-8").trim();
1168
+ }
1169
+ if (!value) err("apiKey is empty");
1170
+ updates.apiKey = value;
908
1171
  }
909
1172
  saveConfig(updates);
910
1173
  console.log(JSON.stringify({ ok: true, updated: Object.keys(updates) }));
@@ -923,6 +1186,7 @@ async function configHealth(args, flags) {
923
1186
 
924
1187
  // src/commands/register.ts
925
1188
  async function registerAccount(referralCode) {
1189
+ assertAllowedHost(XAPI_API_HOST);
926
1190
  const controller = new AbortController();
927
1191
  const timer = setTimeout(() => controller.abort(), 15e3);
928
1192
  try {
@@ -1070,15 +1334,13 @@ async function findCurrentKeyRecord(plaintextKey, jwtToken) {
1070
1334
  if (!Array.isArray(keys) || keys.length === 0) {
1071
1335
  throw new Error("No API keys found for this account");
1072
1336
  }
1073
- if (keys.length === 1) return keys[0];
1074
1337
  const prefix = plaintextKey.substring(0, 7);
1075
1338
  const match = keys.find((k) => k.keyPreview.startsWith(prefix));
1076
- if (!match) {
1077
- throw new Error(
1078
- `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.`
1079
- );
1080
- }
1081
- return match;
1339
+ if (match) return match;
1340
+ if (keys.length === 1) return keys[0];
1341
+ throw new Error(
1342
+ `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
+ );
1082
1344
  }
1083
1345
  function resolveScopeDefs(provider) {
1084
1346
  if (Array.isArray(provider.scopeDefinitions) && provider.scopeDefinitions.length > 0) {
@@ -1130,7 +1392,7 @@ async function selectScopesInteractive(provider) {
1130
1392
  out.write("\x1B[J");
1131
1393
  out.write(buildFrame());
1132
1394
  };
1133
- return new Promise((resolve) => {
1395
+ return new Promise((resolve2) => {
1134
1396
  const { stdin } = process;
1135
1397
  const wasRaw = stdin.isRaw;
1136
1398
  stdin.setRawMode(true);
@@ -1141,7 +1403,7 @@ async function selectScopesInteractive(provider) {
1141
1403
  stdin.pause();
1142
1404
  out.write("\x1B[?25h");
1143
1405
  out.write("\n");
1144
- resolve(result);
1406
+ resolve2(result);
1145
1407
  };
1146
1408
  const onData = (buf) => {
1147
1409
  const key = buf.toString();
@@ -1358,19 +1620,217 @@ async function oauthProviders(args, flags) {
1358
1620
  }
1359
1621
  }
1360
1622
 
1361
- // src/index.ts
1362
- var { CONFIG_HELP: CONFIG_HELP2 } = config_exports;
1363
- var { OAUTH_HELP: OAUTH_HELP2 } = oauth_exports;
1623
+ // src/commands/task.ts
1624
+ var POLL_HELP = `xapi-to task poll - Poll an async task once
1625
+
1626
+ USAGE
1627
+ xapi-to task poll <task_id> [flags]
1628
+
1629
+ FLAGS
1630
+ --format json|pretty|table Output format
1631
+
1632
+ EXAMPLES
1633
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1634
+ `;
1635
+ var WAIT_HELP = `xapi-to task wait - Wait until async task reaches terminal status
1636
+
1637
+ USAGE
1638
+ xapi-to task wait <task_id> [flags]
1639
+
1640
+ FLAGS
1641
+ --interval <duration> Poll interval (default: 2s). Supports ms/s/m/h
1642
+ --timeout <duration> Max wait duration (optional). Supports ms/s/m/h
1643
+ --max-attempts <number> Max poll attempts (optional)
1644
+ --format json|pretty|table Output format
1645
+
1646
+ EXAMPLES
1647
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000
1648
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 1s --timeout 10m
1649
+ `;
1650
+ var TASK_HELP = `xapi-to task - Async task helpers
1651
+
1652
+ USAGE
1653
+ xapi-to task <command> [args] [flags]
1654
+
1655
+ COMMANDS
1656
+ poll <task_id> Poll task status once (wraps action: task.poll)
1657
+ wait <task_id> Poll repeatedly until terminal status
1658
+
1659
+ EXAMPLES
1660
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1661
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
1662
+ `;
1663
+ function showHelpIfRequested2(flags, helpText) {
1664
+ if (flags.help) {
1665
+ console.log(helpText);
1666
+ process.exit(0);
1667
+ }
1668
+ }
1669
+ function parseDurationMs(raw) {
1670
+ const text = raw.trim().toLowerCase();
1671
+ const match = text.match(/^(\d+)(ms|s|m|h)?$/);
1672
+ if (!match) {
1673
+ err(`invalid duration "${raw}". Use formats like 500ms, 2s, 5m, 1h.`);
1674
+ }
1675
+ const value = Number(match[1]);
1676
+ const unit = match[2] || "ms";
1677
+ if (!Number.isFinite(value) || value < 0) {
1678
+ err(`invalid duration "${raw}". Duration must be >= 0.`);
1679
+ }
1680
+ switch (unit) {
1681
+ case "ms":
1682
+ return value;
1683
+ case "s":
1684
+ return value * 1e3;
1685
+ case "m":
1686
+ return value * 6e4;
1687
+ case "h":
1688
+ return value * 36e5;
1689
+ default:
1690
+ return value;
1691
+ }
1692
+ }
1693
+ function parsePositiveDurationMs(raw, flagName) {
1694
+ const ms = parseDurationMs(raw);
1695
+ if (ms <= 0) {
1696
+ err(`${flagName} must be greater than 0`);
1697
+ }
1698
+ return ms;
1699
+ }
1700
+ function parsePositiveInt(raw, flagName) {
1701
+ const n = Number(raw);
1702
+ if (!Number.isInteger(n) || n <= 0) {
1703
+ err(`${flagName} must be a positive integer`);
1704
+ }
1705
+ return n;
1706
+ }
1707
+ function sleep2(ms) {
1708
+ if (ms <= 0) return Promise.resolve();
1709
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1710
+ }
1711
+ function extractTaskPayload(res) {
1712
+ if (res && typeof res === "object") {
1713
+ const obj = res;
1714
+ if (typeof obj.status === "string") return obj;
1715
+ if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
1716
+ const dataObj = obj.data;
1717
+ if (typeof dataObj.status === "string") return dataObj;
1718
+ }
1719
+ }
1720
+ return res ?? {};
1721
+ }
1722
+ function getStatus(payload) {
1723
+ const status = payload.status;
1724
+ if (status === "pending" || status === "processing" || status === "succeeded" || status === "failed" || status === "expired") {
1725
+ return status;
1726
+ }
1727
+ return void 0;
1728
+ }
1729
+ async function taskPoll(args, flags) {
1730
+ showHelpIfRequested2(flags, POLL_HELP);
1731
+ const taskId = args[0];
1732
+ if (!taskId) err("usage: xapi-to task poll <task_id>");
1733
+ const cfg = getConfig();
1734
+ requireApiKey(cfg);
1735
+ try {
1736
+ const res = await actionCall("task.poll", { task_id: taskId }, cfg, void 0, 2);
1737
+ const payload = extractTaskPayload(res);
1738
+ output(payload, flags.format);
1739
+ } catch (e) {
1740
+ err("task poll failed", e.message);
1741
+ }
1742
+ }
1743
+ async function taskWait(args, flags) {
1744
+ showHelpIfRequested2(flags, WAIT_HELP);
1745
+ const taskId = args[0];
1746
+ if (!taskId) err("usage: xapi-to task wait <task_id>");
1747
+ const intervalMs = parsePositiveDurationMs(flags.interval || "2s", "--interval");
1748
+ const timeoutMs = flags.timeout ? parseDurationMs(flags.timeout) : void 0;
1749
+ const maxAttempts = flags["max-attempts"] ? parsePositiveInt(flags["max-attempts"], "--max-attempts") : void 0;
1750
+ const cfg = getConfig();
1751
+ requireApiKey(cfg);
1752
+ const startedAt = Date.now();
1753
+ const deadline = timeoutMs !== void 0 ? startedAt + timeoutMs : void 0;
1754
+ let attempts = 0;
1755
+ while (true) {
1756
+ if (deadline !== void 0 && Date.now() >= deadline) {
1757
+ err(
1758
+ "task wait timeout",
1759
+ `task_id=${taskId}, elapsed_ms=${Date.now() - startedAt}, timeout_ms=${timeoutMs}`
1760
+ );
1761
+ }
1762
+ attempts += 1;
1763
+ let retryDelayMs;
1764
+ try {
1765
+ const remaining = deadline !== void 0 ? Math.max(1, deadline - Date.now()) : void 0;
1766
+ const res = await actionCall("task.poll", { task_id: taskId }, cfg, void 0, 0, remaining);
1767
+ const payload = extractTaskPayload(res);
1768
+ const status = getStatus(payload);
1769
+ if (!status) {
1770
+ err("task wait failed", "task.poll returned invalid response: missing status");
1771
+ }
1772
+ if (status === "succeeded") {
1773
+ output(payload, flags.format);
1774
+ return;
1775
+ }
1776
+ if (status === "failed" || status === "expired") {
1777
+ output(payload, flags.format);
1778
+ process.exit(1);
1779
+ }
1780
+ } catch (e) {
1781
+ if (e instanceof Error && e.message === "process.exit") {
1782
+ throw e;
1783
+ }
1784
+ if (deadline !== void 0 && Date.now() >= deadline) {
1785
+ err(
1786
+ "task wait timeout",
1787
+ `task_id=${taskId}, elapsed_ms=${Date.now() - startedAt}, timeout_ms=${timeoutMs}`
1788
+ );
1789
+ }
1790
+ if (isRetryableRequestError(e)) {
1791
+ retryDelayMs = e instanceof HttpError ? e.retryAfterMs : void 0;
1792
+ } else {
1793
+ err("task wait failed", e.message);
1794
+ }
1795
+ }
1796
+ if (maxAttempts && attempts >= maxAttempts) {
1797
+ err(
1798
+ "task wait exceeded max attempts",
1799
+ `task_id=${taskId}, attempts=${attempts}, max_attempts=${maxAttempts}`
1800
+ );
1801
+ }
1802
+ const desiredWaitMs = retryDelayMs ?? intervalMs;
1803
+ const waitMs = deadline !== void 0 ? Math.min(desiredWaitMs, Math.max(0, deadline - Date.now())) : desiredWaitMs;
1804
+ await sleep2(waitMs);
1805
+ }
1806
+ }
1807
+ function taskHelp() {
1808
+ return TASK_HELP;
1809
+ }
1810
+
1811
+ // src/args.ts
1364
1812
  function parseArgs(argv) {
1365
1813
  const positional = [];
1366
1814
  const flags = {};
1367
1815
  let i = 0;
1816
+ let onlyPositional = false;
1368
1817
  while (i < argv.length) {
1369
1818
  const arg = argv[i];
1370
- if (arg.startsWith("--")) {
1819
+ if (!onlyPositional && arg === "--") {
1820
+ onlyPositional = true;
1821
+ i++;
1822
+ continue;
1823
+ }
1824
+ if (!onlyPositional && arg.startsWith("--")) {
1825
+ const eq = arg.indexOf("=");
1826
+ if (eq !== -1) {
1827
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
1828
+ i++;
1829
+ continue;
1830
+ }
1371
1831
  const key = arg.slice(2);
1372
1832
  const next = argv[i + 1];
1373
- if (next && !next.startsWith("--")) {
1833
+ if (next !== void 0 && !next.startsWith("--")) {
1374
1834
  flags[key] = next;
1375
1835
  i += 2;
1376
1836
  } else {
@@ -1384,6 +1844,10 @@ function parseArgs(argv) {
1384
1844
  }
1385
1845
  return { positional, flags };
1386
1846
  }
1847
+
1848
+ // src/index.ts
1849
+ var { CONFIG_HELP: CONFIG_HELP2 } = config_exports;
1850
+ var { OAUTH_HELP: OAUTH_HELP2 } = oauth_exports;
1387
1851
  var HELP = `xapi-to - agent-friendly CLI for xapi
1388
1852
 
1389
1853
  USAGE
@@ -1408,9 +1872,16 @@ COMMANDS
1408
1872
  --code <target> Generate code snippet (curl, py, js, ts, go)
1409
1873
  call <id> --input '{"key":"val"}' Execute an action
1410
1874
  --method GET|POST|... Override HTTP method
1875
+ --output <path> Save a raw binary response to a new file
1411
1876
  --code <target> Generate code snippet instead of executing
1412
1877
  Variants: python.requests, python.httpx, javascript.fetch, javascript.axios
1413
1878
 
1879
+ task poll <task_id> Poll async task once (wraps task.poll)
1880
+ task wait <task_id> Wait until async task completes
1881
+ --interval <duration> Poll interval, e.g. 2s (default: 2s)
1882
+ --timeout <duration> Max wait duration, e.g. 10m
1883
+ --max-attempts <number> Max poll attempts
1884
+
1414
1885
  oauth bind [--provider twitter] Bind Twitter OAuth to your API key
1415
1886
  oauth status List current OAuth bindings
1416
1887
  oauth unbind <binding-id> Remove an OAuth binding
@@ -1448,7 +1919,10 @@ EXAMPLES
1448
1919
  xapi-to get twitter.tweet_detail --code curl
1449
1920
  xapi-to get twitter.tweet_detail --code py --format pretty
1450
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
1451
1923
  xapi-to call twitter.tweet_detail --input '{"tweet_id":"1234567890"}' --code python
1924
+ xapi-to task poll 550e8400-e29b-41d4-a716-446655440000
1925
+ xapi-to task wait 550e8400-e29b-41d4-a716-446655440000 --interval 2s --timeout 10m
1452
1926
  xapi-to categories
1453
1927
  xapi-to services --format table
1454
1928
  xapi-to config set apiKey=xapi_abc123
@@ -1476,6 +1950,23 @@ async function main() {
1476
1950
  return actionGet2(rest, flags);
1477
1951
  case "call":
1478
1952
  return actionCall2(rest, flags);
1953
+ case "task": {
1954
+ if (rest.length === 0) {
1955
+ console.log(taskHelp());
1956
+ process.exit(0);
1957
+ }
1958
+ const [subCmd, ...subRest] = rest;
1959
+ switch (subCmd) {
1960
+ case "poll":
1961
+ return taskPoll(subRest, flags);
1962
+ case "wait":
1963
+ return taskWait(subRest, flags);
1964
+ default:
1965
+ console.error(JSON.stringify({ error: `unknown task command: ${subCmd}`, hint: "valid commands: poll, wait" }));
1966
+ process.exit(1);
1967
+ }
1968
+ break;
1969
+ }
1479
1970
  // ── OAuth commands ──
1480
1971
  case "oauth": {
1481
1972
  if (flags.help || rest.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xapi-to",
3
- "version": "0.1.16",
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": {
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsup src/index.ts --format esm --target node18 --clean --out-dir dist --tsconfig tsconfig.build.json",
16
+ "typecheck": "tsc --noEmit",
16
17
  "start": "node dist/index.js",
17
18
  "dev": "XAPI_ACTION_HOST=localhost:3003 bun run src/index.ts",
18
19
  "prepublishOnly": "npm run build",