@spectratools/xapi-cli 0.2.1 → 0.2.3
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/cli.js +159 -87
- 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
|
-
{
|
|
437
|
-
|
|
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(
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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);
|