@spectratools/xapi-cli 0.2.0 → 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 +191 -102
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,35 +1,152 @@
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
 
11
116
  // src/api.ts
12
117
  import { createHttpClient, withRetry } from "@spectratools/cli-shared";
13
- var BASE_URL = "https://api.x.com/2";
118
+ var BASE_URL_V2 = "https://api.x.com/2";
119
+ var BASE_URL_V1_1 = "https://api.x.com/1.1";
14
120
  var RETRY_OPTIONS = { maxRetries: 3, baseMs: 500, maxMs: 1e4 };
15
121
  function createXApiClient(bearerToken) {
16
- const http = createHttpClient({
17
- baseUrl: BASE_URL,
18
- defaultHeaders: {
19
- Authorization: `Bearer ${bearerToken}`
20
- }
122
+ const defaultHeaders = {
123
+ Authorization: `Bearer ${bearerToken}`
124
+ };
125
+ const httpV2 = createHttpClient({
126
+ baseUrl: BASE_URL_V2,
127
+ defaultHeaders
128
+ });
129
+ const httpV1_1 = createHttpClient({
130
+ baseUrl: BASE_URL_V1_1,
131
+ defaultHeaders
21
132
  });
22
133
  function get(path, query) {
23
134
  return withRetry(
24
- () => http.request(path, query !== void 0 ? { query } : {}),
135
+ () => httpV2.request(path, query !== void 0 ? { query } : {}),
136
+ RETRY_OPTIONS
137
+ );
138
+ }
139
+ function getV1_1(path, query) {
140
+ return withRetry(
141
+ () => httpV1_1.request(path, query !== void 0 ? { query } : {}),
25
142
  RETRY_OPTIONS
26
143
  );
27
144
  }
28
145
  function post(path, body) {
29
- return withRetry(() => http.request(path, { method: "POST", body }), RETRY_OPTIONS);
146
+ return withRetry(() => httpV2.request(path, { method: "POST", body }), RETRY_OPTIONS);
30
147
  }
31
148
  function del(path) {
32
- return withRetry(() => http.request(path, { method: "DELETE" }), RETRY_OPTIONS);
149
+ return withRetry(() => httpV2.request(path, { method: "DELETE" }), RETRY_OPTIONS);
33
150
  }
34
151
  const POST_FIELDS = "id,text,author_id,created_at,public_metrics";
35
152
  function getPost(id) {
@@ -148,14 +265,19 @@ function createXApiClient(bearerToken) {
148
265
  });
149
266
  }
150
267
  function getTrendingPlaces() {
151
- return get(
152
- "/trends/available"
153
- );
268
+ return getV1_1(
269
+ "/trends/available.json"
270
+ ).then((places) => ({ data: places }));
154
271
  }
155
272
  function getTrendsByLocation(woeid) {
156
- return get(
157
- `/trends/place/${woeid}`
158
- );
273
+ return getV1_1("/trends/place.json", { id: woeid }).then((locations) => {
274
+ const trends2 = (locations[0]?.trends ?? []).map((trend) => ({
275
+ name: trend.name,
276
+ query: trend.query,
277
+ tweet_volume: trend.tweet_volume ?? void 0
278
+ }));
279
+ return { data: trends2 };
280
+ });
159
281
  }
160
282
  function getDmConversations(userId, maxResults, nextToken) {
161
283
  return get(`/users/${userId}/dm_conversations`, {
@@ -214,66 +336,6 @@ function truncateText(text, max = 100) {
214
336
  return `${text.slice(0, max - 3)}...`;
215
337
  }
216
338
 
217
- // src/auth.ts
218
- import { HttpError } from "@spectratools/cli-shared";
219
- import { z } from "incur";
220
- var bearerTokenSchema = z.string().describe("X app-only bearer token (read-only endpoints)");
221
- var accessTokenSchema = z.string().describe("X OAuth 2.0 user access token (required for write endpoints)");
222
- var xApiReadEnv = z.object({
223
- X_BEARER_TOKEN: bearerTokenSchema.optional(),
224
- X_ACCESS_TOKEN: accessTokenSchema.optional()
225
- }).refine((env) => Boolean(env.X_ACCESS_TOKEN || env.X_BEARER_TOKEN), {
226
- message: "Set X_ACCESS_TOKEN or X_BEARER_TOKEN to authenticate X API requests."
227
- });
228
- var xApiWriteEnv = z.object({
229
- X_ACCESS_TOKEN: accessTokenSchema,
230
- X_BEARER_TOKEN: bearerTokenSchema.optional()
231
- });
232
- function readAuthToken(env) {
233
- if (env.X_ACCESS_TOKEN) {
234
- return env.X_ACCESS_TOKEN;
235
- }
236
- return env.X_BEARER_TOKEN;
237
- }
238
- function writeAuthToken(env) {
239
- return env.X_ACCESS_TOKEN;
240
- }
241
- function parseXApiErrorDetail(body) {
242
- try {
243
- const parsed = JSON.parse(body);
244
- if (typeof parsed !== "object" || parsed === null) {
245
- return void 0;
246
- }
247
- const candidate = parsed;
248
- if (typeof candidate.detail === "string" && candidate.detail.trim()) return candidate.detail;
249
- if (typeof candidate.title === "string" && candidate.title.trim()) return candidate.title;
250
- const firstError = candidate.errors?.[0];
251
- if (typeof firstError?.message === "string" && firstError.message.trim())
252
- return firstError.message;
253
- if (typeof firstError?.detail === "string" && firstError.detail.trim())
254
- return firstError.detail;
255
- } catch {
256
- }
257
- return void 0;
258
- }
259
- function toWriteAuthError(operation, error) {
260
- if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
261
- const detail = parseXApiErrorDetail(error.body);
262
- return {
263
- code: "INSUFFICIENT_WRITE_AUTH",
264
- message: [
265
- "Insufficient auth for write endpoint:",
266
- `- operation: ${operation}`,
267
- `- status: ${error.status} ${error.statusText}`,
268
- "- required auth: X_ACCESS_TOKEN (OAuth 2.0 user token with write scopes)",
269
- "- note: app-only X_BEARER_TOKEN cannot perform write actions",
270
- ...detail ? [`- x_api_detail: ${detail}`] : []
271
- ].join("\n")
272
- };
273
- }
274
- return void 0;
275
- }
276
-
277
339
  // src/collect-paged.ts
278
340
  import { paginateCursor } from "@spectratools/cli-shared";
279
341
  async function collectPaged(fetchFn, mapFn, maxResults, pageSize = 100) {
@@ -332,7 +394,7 @@ dm.command("conversations", {
332
394
  const firstParticipant = allConvos[0]?.participant_ids[0];
333
395
  return c.ok(
334
396
  { conversations: allConvos, count: allConvos.length },
335
- {
397
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
336
398
  cta: firstParticipant ? {
337
399
  description: "Next steps:",
338
400
  commands: [
@@ -412,12 +474,20 @@ lists.command("get", {
412
474
  owner_id: list.owner_id,
413
475
  member_count: list.member_count
414
476
  },
415
- {
477
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
416
478
  cta: {
417
479
  description: "Explore this list:",
418
480
  commands: [
419
- { command: "lists members", args: { id: c.args.id }, description: "See list members" },
420
- { 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
+ }
421
491
  ]
422
492
  }
423
493
  }
@@ -501,7 +571,7 @@ lists.command("posts", {
501
571
  const firstId = allPosts[0]?.id;
502
572
  return c.ok(
503
573
  { posts: allPosts, count: allPosts.length },
504
- {
574
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
505
575
  cta: firstId ? {
506
576
  description: "Next steps:",
507
577
  commands: [
@@ -556,7 +626,7 @@ posts.command("get", {
556
626
  retweets: post.public_metrics?.retweet_count,
557
627
  replies: post.public_metrics?.reply_count
558
628
  },
559
- {
629
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
560
630
  cta: {
561
631
  description: "Explore this post:",
562
632
  commands: [
@@ -582,7 +652,7 @@ posts.command("search", {
582
652
  query: z4.string().describe("Search query")
583
653
  }),
584
654
  options: z4.object({
585
- 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)"),
586
656
  sort: z4.enum(["recency", "relevancy"]).default("recency").describe("Sort order"),
587
657
  verbose: z4.boolean().optional().describe("Show full text without truncation")
588
658
  }),
@@ -621,7 +691,7 @@ posts.command("search", {
621
691
  const firstId = items[0]?.id;
622
692
  return c.ok(
623
693
  { posts: items, count: items.length },
624
- {
694
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
625
695
  cta: firstId ? {
626
696
  description: "Next steps:",
627
697
  commands: [
@@ -656,18 +726,21 @@ posts.command("create", {
656
726
  try {
657
727
  const client = createXApiClient(writeAuthToken(c.env));
658
728
  const res = await client.createPost(c.options.text, c.options.replyTo, c.options.quote);
659
- return c.ok(res.data, {
660
- cta: {
661
- description: "View your post:",
662
- commands: [
663
- {
664
- command: "posts get",
665
- args: { id: res.data.id },
666
- description: "See the created post"
667
- }
668
- ]
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
+ }
669
742
  }
670
- });
743
+ );
671
744
  } catch (error) {
672
745
  const authError = toWriteAuthError("posts create", error);
673
746
  if (authError) return c.error(authError);
@@ -734,7 +807,7 @@ posts.command("likes", {
734
807
  );
735
808
  return c.ok(
736
809
  { users: allUsers, count: allUsers.length },
737
- {
810
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
738
811
  cta: {
739
812
  description: "Next steps:",
740
813
  commands: allUsers.slice(0, 1).map((u) => ({
@@ -834,7 +907,7 @@ timeline.command("home", {
834
907
  const firstId = allPosts[0]?.id;
835
908
  return c.ok(
836
909
  { posts: allPosts, count: allPosts.length },
837
- {
910
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
838
911
  cta: firstId ? {
839
912
  description: "Next steps:",
840
913
  commands: [
@@ -913,7 +986,7 @@ trends.command("places", {
913
986
  const first = places[0];
914
987
  return c.ok(
915
988
  { places, count: places.length },
916
- {
989
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
917
990
  cta: first ? {
918
991
  description: "Next steps:",
919
992
  commands: [
@@ -955,7 +1028,7 @@ trends.command("location", {
955
1028
  const firstTrend = trendItems[0];
956
1029
  return c.ok(
957
1030
  { trends: trendItems, count: trendItems.length },
958
- {
1031
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
959
1032
  cta: firstTrend ? {
960
1033
  description: "Next steps:",
961
1034
  commands: [
@@ -1020,7 +1093,7 @@ users.command("get", {
1020
1093
  tweets: user.public_metrics?.tweet_count,
1021
1094
  joined: user.created_at ? relativeTime(user.created_at) : void 0
1022
1095
  },
1023
- {
1096
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1024
1097
  cta: {
1025
1098
  description: "Explore this user:",
1026
1099
  commands: [
@@ -1162,7 +1235,7 @@ users.command("posts", {
1162
1235
  const firstId = allPosts[0]?.id;
1163
1236
  return c.ok(
1164
1237
  { posts: allPosts, count: allPosts.length },
1165
- {
1238
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1166
1239
  cta: firstId ? {
1167
1240
  description: "Next steps:",
1168
1241
  commands: [
@@ -1245,7 +1318,7 @@ users.command("search", {
1245
1318
  const first = items[0];
1246
1319
  return c.ok(
1247
1320
  { users: items, count: items.length },
1248
- {
1321
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1249
1322
  cta: first ? {
1250
1323
  description: "Next steps:",
1251
1324
  commands: [
@@ -1262,9 +1335,25 @@ users.command("search", {
1262
1335
  });
1263
1336
 
1264
1337
  // src/cli.ts
1338
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1339
+ var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
1265
1340
  var cli = Cli7.create("xapi", {
1341
+ version: pkg.version,
1266
1342
  description: "X (Twitter) API CLI for spectra-the-bot."
1267
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
+ });
1268
1357
  cli.command(posts);
1269
1358
  cli.command(users);
1270
1359
  cli.command(timeline);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectratools/xapi-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "X (Twitter) API CLI for spectra-the-bot",
5
5
  "type": "module",
6
6
  "license": "MIT",