@spectratools/xapi-cli 0.2.1 → 0.2.2

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 (2) hide show
  1. package/dist/cli.js +159 -87
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,10 +1,115 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { realpathSync } from "fs";
4
+ import { readFileSync, realpathSync } from "fs";
5
+ import { dirname, resolve } from "path";
5
6
  import { fileURLToPath } from "url";
6
7
  import { Cli as Cli7 } from "incur";
7
8
 
9
+ // src/auth.ts
10
+ import { HttpError } from "@spectratools/cli-shared";
11
+ import { z } from "incur";
12
+ var bearerTokenSchema = z.string().describe("X app-only bearer token (read-only endpoints)");
13
+ var accessTokenSchema = z.string().describe("X OAuth 2.0 user access token (required for write endpoints)");
14
+ var xApiReadEnv = z.object({
15
+ X_BEARER_TOKEN: bearerTokenSchema.optional(),
16
+ X_ACCESS_TOKEN: accessTokenSchema.optional()
17
+ }).refine((env) => Boolean(env.X_ACCESS_TOKEN || env.X_BEARER_TOKEN), {
18
+ message: "Set X_ACCESS_TOKEN or X_BEARER_TOKEN to authenticate X API requests."
19
+ });
20
+ var xApiWriteEnv = z.object({
21
+ X_ACCESS_TOKEN: accessTokenSchema,
22
+ X_BEARER_TOKEN: bearerTokenSchema.optional()
23
+ });
24
+ function readAuthToken(env) {
25
+ if (env.X_ACCESS_TOKEN) {
26
+ return env.X_ACCESS_TOKEN;
27
+ }
28
+ return env.X_BEARER_TOKEN;
29
+ }
30
+ function writeAuthToken(env) {
31
+ return env.X_ACCESS_TOKEN;
32
+ }
33
+ function parseXApiErrorDetail(body) {
34
+ try {
35
+ const parsed = JSON.parse(body);
36
+ if (typeof parsed !== "object" || parsed === null) {
37
+ return void 0;
38
+ }
39
+ const candidate = parsed;
40
+ if (typeof candidate.detail === "string" && candidate.detail.trim()) return candidate.detail;
41
+ if (typeof candidate.title === "string" && candidate.title.trim()) return candidate.title;
42
+ const firstError = candidate.errors?.[0];
43
+ if (typeof firstError?.message === "string" && firstError.message.trim())
44
+ return firstError.message;
45
+ if (typeof firstError?.detail === "string" && firstError.detail.trim())
46
+ return firstError.detail;
47
+ } catch {
48
+ }
49
+ return void 0;
50
+ }
51
+ function readEndpointNeedsUserToken(detail) {
52
+ if (!detail) return false;
53
+ const normalized = detail.toLowerCase();
54
+ return normalized.includes("application-only is forbidden") || normalized.includes("oauth 2.0 application-only") || normalized.includes("app-only") || normalized.includes("user context");
55
+ }
56
+ function toReadAuthError(operation, error) {
57
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
58
+ const detail = parseXApiErrorDetail(error.body);
59
+ const requiresUserToken = readEndpointNeedsUserToken(detail);
60
+ return {
61
+ code: "INSUFFICIENT_READ_AUTH",
62
+ message: [
63
+ "Insufficient auth for read endpoint:",
64
+ `- operation: ${operation}`,
65
+ `- status: ${error.status} ${error.statusText}`,
66
+ requiresUserToken ? "- required auth: X_ACCESS_TOKEN (OAuth 2.0 user token required for this endpoint)" : "- required auth: X_ACCESS_TOKEN or X_BEARER_TOKEN (with required read scopes)",
67
+ ...detail ? [`- x_api_detail: ${detail}`] : []
68
+ ].join("\n")
69
+ };
70
+ }
71
+ return void 0;
72
+ }
73
+ function toWriteAuthError(operation, error) {
74
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
75
+ const detail = parseXApiErrorDetail(error.body);
76
+ return {
77
+ code: "INSUFFICIENT_WRITE_AUTH",
78
+ message: [
79
+ "Insufficient auth for write endpoint:",
80
+ `- operation: ${operation}`,
81
+ `- status: ${error.status} ${error.statusText}`,
82
+ "- required auth: X_ACCESS_TOKEN (OAuth 2.0 user token with write scopes)",
83
+ "- note: app-only X_BEARER_TOKEN cannot perform write actions",
84
+ ...detail ? [`- x_api_detail: ${detail}`] : []
85
+ ].join("\n")
86
+ };
87
+ }
88
+ return void 0;
89
+ }
90
+ function toXApiHttpError(operation, error) {
91
+ if (!(error instanceof HttpError)) {
92
+ return void 0;
93
+ }
94
+ const detail = parseXApiErrorDetail(error.body);
95
+ return {
96
+ code: "X_API_REQUEST_FAILED",
97
+ message: [
98
+ "X API request failed:",
99
+ `- operation: ${operation}`,
100
+ `- status: ${error.status} ${error.statusText}`,
101
+ ...detail ? [`- x_api_detail: ${detail}`] : []
102
+ ].join("\n")
103
+ };
104
+ }
105
+ function toXApiCommandError(operation, error, authScope = "read") {
106
+ const authError = authScope === "write" ? toWriteAuthError(operation, error) : toReadAuthError(operation, error);
107
+ if (authError) {
108
+ return authError;
109
+ }
110
+ return toXApiHttpError(operation, error);
111
+ }
112
+
8
113
  // src/commands/dm.ts
9
114
  import { Cli, z as z2 } from "incur";
10
115
 
@@ -231,66 +336,6 @@ function truncateText(text, max = 100) {
231
336
  return `${text.slice(0, max - 3)}...`;
232
337
  }
233
338
 
234
- // src/auth.ts
235
- import { HttpError } from "@spectratools/cli-shared";
236
- import { z } from "incur";
237
- var bearerTokenSchema = z.string().describe("X app-only bearer token (read-only endpoints)");
238
- var accessTokenSchema = z.string().describe("X OAuth 2.0 user access token (required for write endpoints)");
239
- var xApiReadEnv = z.object({
240
- X_BEARER_TOKEN: bearerTokenSchema.optional(),
241
- X_ACCESS_TOKEN: accessTokenSchema.optional()
242
- }).refine((env) => Boolean(env.X_ACCESS_TOKEN || env.X_BEARER_TOKEN), {
243
- message: "Set X_ACCESS_TOKEN or X_BEARER_TOKEN to authenticate X API requests."
244
- });
245
- var xApiWriteEnv = z.object({
246
- X_ACCESS_TOKEN: accessTokenSchema,
247
- X_BEARER_TOKEN: bearerTokenSchema.optional()
248
- });
249
- function readAuthToken(env) {
250
- if (env.X_ACCESS_TOKEN) {
251
- return env.X_ACCESS_TOKEN;
252
- }
253
- return env.X_BEARER_TOKEN;
254
- }
255
- function writeAuthToken(env) {
256
- return env.X_ACCESS_TOKEN;
257
- }
258
- function parseXApiErrorDetail(body) {
259
- try {
260
- const parsed = JSON.parse(body);
261
- if (typeof parsed !== "object" || parsed === null) {
262
- return void 0;
263
- }
264
- const candidate = parsed;
265
- if (typeof candidate.detail === "string" && candidate.detail.trim()) return candidate.detail;
266
- if (typeof candidate.title === "string" && candidate.title.trim()) return candidate.title;
267
- const firstError = candidate.errors?.[0];
268
- if (typeof firstError?.message === "string" && firstError.message.trim())
269
- return firstError.message;
270
- if (typeof firstError?.detail === "string" && firstError.detail.trim())
271
- return firstError.detail;
272
- } catch {
273
- }
274
- return void 0;
275
- }
276
- function toWriteAuthError(operation, error) {
277
- if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
278
- const detail = parseXApiErrorDetail(error.body);
279
- return {
280
- code: "INSUFFICIENT_WRITE_AUTH",
281
- message: [
282
- "Insufficient auth for write endpoint:",
283
- `- operation: ${operation}`,
284
- `- status: ${error.status} ${error.statusText}`,
285
- "- required auth: X_ACCESS_TOKEN (OAuth 2.0 user token with write scopes)",
286
- "- note: app-only X_BEARER_TOKEN cannot perform write actions",
287
- ...detail ? [`- x_api_detail: ${detail}`] : []
288
- ].join("\n")
289
- };
290
- }
291
- return void 0;
292
- }
293
-
294
339
  // src/collect-paged.ts
295
340
  import { paginateCursor } from "@spectratools/cli-shared";
296
341
  async function collectPaged(fetchFn, mapFn, maxResults, pageSize = 100) {
@@ -349,7 +394,7 @@ dm.command("conversations", {
349
394
  const firstParticipant = allConvos[0]?.participant_ids[0];
350
395
  return c.ok(
351
396
  { conversations: allConvos, count: allConvos.length },
352
- {
397
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
353
398
  cta: firstParticipant ? {
354
399
  description: "Next steps:",
355
400
  commands: [
@@ -429,12 +474,20 @@ lists.command("get", {
429
474
  owner_id: list.owner_id,
430
475
  member_count: list.member_count
431
476
  },
432
- {
477
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
433
478
  cta: {
434
479
  description: "Explore this list:",
435
480
  commands: [
436
- { command: "lists members", args: { id: c.args.id }, description: "See list members" },
437
- { command: "lists posts", args: { id: c.args.id }, description: "See list posts" }
481
+ {
482
+ command: "lists members",
483
+ args: { id: c.args.id },
484
+ description: "See list members"
485
+ },
486
+ {
487
+ command: "lists posts",
488
+ args: { id: c.args.id },
489
+ description: "See list posts"
490
+ }
438
491
  ]
439
492
  }
440
493
  }
@@ -518,7 +571,7 @@ lists.command("posts", {
518
571
  const firstId = allPosts[0]?.id;
519
572
  return c.ok(
520
573
  { posts: allPosts, count: allPosts.length },
521
- {
574
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
522
575
  cta: firstId ? {
523
576
  description: "Next steps:",
524
577
  commands: [
@@ -573,7 +626,7 @@ posts.command("get", {
573
626
  retweets: post.public_metrics?.retweet_count,
574
627
  replies: post.public_metrics?.reply_count
575
628
  },
576
- {
629
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
577
630
  cta: {
578
631
  description: "Explore this post:",
579
632
  commands: [
@@ -599,7 +652,7 @@ posts.command("search", {
599
652
  query: z4.string().describe("Search query")
600
653
  }),
601
654
  options: z4.object({
602
- maxResults: z4.number().default(10).describe("Maximum results to return (10\u2013100)"),
655
+ maxResults: z4.number().int().min(10).max(100).default(10).describe("Maximum results to return (10\u2013100)"),
603
656
  sort: z4.enum(["recency", "relevancy"]).default("recency").describe("Sort order"),
604
657
  verbose: z4.boolean().optional().describe("Show full text without truncation")
605
658
  }),
@@ -638,7 +691,7 @@ posts.command("search", {
638
691
  const firstId = items[0]?.id;
639
692
  return c.ok(
640
693
  { posts: items, count: items.length },
641
- {
694
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
642
695
  cta: firstId ? {
643
696
  description: "Next steps:",
644
697
  commands: [
@@ -673,18 +726,21 @@ posts.command("create", {
673
726
  try {
674
727
  const client = createXApiClient(writeAuthToken(c.env));
675
728
  const res = await client.createPost(c.options.text, c.options.replyTo, c.options.quote);
676
- return c.ok(res.data, {
677
- cta: {
678
- description: "View your post:",
679
- commands: [
680
- {
681
- command: "posts get",
682
- args: { id: res.data.id },
683
- description: "See the created post"
684
- }
685
- ]
729
+ return c.ok(
730
+ res.data,
731
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
732
+ cta: {
733
+ description: "View your post:",
734
+ commands: [
735
+ {
736
+ command: "posts get",
737
+ args: { id: res.data.id },
738
+ description: "See the created post"
739
+ }
740
+ ]
741
+ }
686
742
  }
687
- });
743
+ );
688
744
  } catch (error) {
689
745
  const authError = toWriteAuthError("posts create", error);
690
746
  if (authError) return c.error(authError);
@@ -751,7 +807,7 @@ posts.command("likes", {
751
807
  );
752
808
  return c.ok(
753
809
  { users: allUsers, count: allUsers.length },
754
- {
810
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
755
811
  cta: {
756
812
  description: "Next steps:",
757
813
  commands: allUsers.slice(0, 1).map((u) => ({
@@ -851,7 +907,7 @@ timeline.command("home", {
851
907
  const firstId = allPosts[0]?.id;
852
908
  return c.ok(
853
909
  { posts: allPosts, count: allPosts.length },
854
- {
910
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
855
911
  cta: firstId ? {
856
912
  description: "Next steps:",
857
913
  commands: [
@@ -930,7 +986,7 @@ trends.command("places", {
930
986
  const first = places[0];
931
987
  return c.ok(
932
988
  { places, count: places.length },
933
- {
989
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
934
990
  cta: first ? {
935
991
  description: "Next steps:",
936
992
  commands: [
@@ -972,7 +1028,7 @@ trends.command("location", {
972
1028
  const firstTrend = trendItems[0];
973
1029
  return c.ok(
974
1030
  { trends: trendItems, count: trendItems.length },
975
- {
1031
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
976
1032
  cta: firstTrend ? {
977
1033
  description: "Next steps:",
978
1034
  commands: [
@@ -1037,7 +1093,7 @@ users.command("get", {
1037
1093
  tweets: user.public_metrics?.tweet_count,
1038
1094
  joined: user.created_at ? relativeTime(user.created_at) : void 0
1039
1095
  },
1040
- {
1096
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1041
1097
  cta: {
1042
1098
  description: "Explore this user:",
1043
1099
  commands: [
@@ -1179,7 +1235,7 @@ users.command("posts", {
1179
1235
  const firstId = allPosts[0]?.id;
1180
1236
  return c.ok(
1181
1237
  { posts: allPosts, count: allPosts.length },
1182
- {
1238
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1183
1239
  cta: firstId ? {
1184
1240
  description: "Next steps:",
1185
1241
  commands: [
@@ -1262,7 +1318,7 @@ users.command("search", {
1262
1318
  const first = items[0];
1263
1319
  return c.ok(
1264
1320
  { users: items, count: items.length },
1265
- {
1321
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1266
1322
  cta: first ? {
1267
1323
  description: "Next steps:",
1268
1324
  commands: [
@@ -1279,9 +1335,25 @@ users.command("search", {
1279
1335
  });
1280
1336
 
1281
1337
  // src/cli.ts
1338
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1339
+ var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
1282
1340
  var cli = Cli7.create("xapi", {
1341
+ version: pkg.version,
1283
1342
  description: "X (Twitter) API CLI for spectra-the-bot."
1284
1343
  });
1344
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["posts create", "posts delete", "dm send"]);
1345
+ cli.use(async ({ command, error }, next) => {
1346
+ try {
1347
+ return await next();
1348
+ } catch (cause) {
1349
+ const authScope = WRITE_OPERATIONS.has(command) ? "write" : "read";
1350
+ const mapped = toXApiCommandError(command, cause, authScope);
1351
+ if (mapped) {
1352
+ return error(mapped);
1353
+ }
1354
+ throw cause;
1355
+ }
1356
+ });
1285
1357
  cli.command(posts);
1286
1358
  cli.command(users);
1287
1359
  cli.command(timeline);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectratools/xapi-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "X (Twitter) API CLI for spectra-the-bot",
5
5
  "type": "module",
6
6
  "license": "MIT",