@spectratools/xapi-cli 0.1.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/cli.js +1202 -0
  4. package/package.json +38 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spectra-the-bot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @spectra-the-bot/xapi-cli
2
+
3
+ X (Twitter) API v2 CLI for spectra-the-bot, built with [incur](https://github.com/wevm/incur).
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ export X_BEARER_TOKEN=your_bearer_token_here
9
+ npx @spectra-the-bot/xapi-cli --help
10
+ ```
11
+
12
+ ## Commands
13
+
14
+ ### Posts
15
+
16
+ ```bash
17
+ xapi posts get <id>
18
+ xapi posts search <query> [-n 10] [--sort recency|relevancy]
19
+ xapi posts create --text "Hello world!" [--reply-to <id>] [--quote <id>]
20
+ xapi posts delete <id>
21
+ xapi posts likes <id>
22
+ xapi posts retweets <id>
23
+ ```
24
+
25
+ ### Users
26
+
27
+ ```bash
28
+ xapi users get <username|id>
29
+ xapi users followers <username> [-n 100]
30
+ xapi users following <username>
31
+ xapi users posts <username> [-n 10]
32
+ xapi users mentions <username>
33
+ xapi users search <query>
34
+ ```
35
+
36
+ ### Timeline
37
+
38
+ ```bash
39
+ xapi timeline home [-n 25]
40
+ xapi timeline mentions [-n 25]
41
+ ```
42
+
43
+ ### Lists
44
+
45
+ ```bash
46
+ xapi lists get <id>
47
+ xapi lists members <id>
48
+ xapi lists posts <id> [-n 25]
49
+ ```
50
+
51
+ ### Trends
52
+
53
+ ```bash
54
+ xapi trends places
55
+ xapi trends location <woeid>
56
+ ```
57
+
58
+ ### DMs
59
+
60
+ ```bash
61
+ xapi dm conversations [-n 20]
62
+ xapi dm send <participant-id> --text "Hello!"
63
+ ```
64
+
65
+ ## Common Options
66
+
67
+ - `--verbose` — Show full text without truncation
68
+ - `-n, --max-results` — Control result count
69
+ - `--format json` — JSON output
70
+ - `--help` — Show help
71
+
72
+ ## Auth
73
+
74
+ All read endpoints use `X_BEARER_TOKEN`. Write endpoints (create post, delete, DMs) require OAuth 2.0 user context. Requests are automatically retried on 429 rate limit responses.
package/dist/cli.js ADDED
@@ -0,0 +1,1202 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { fileURLToPath } from "url";
5
+ import { Cli as Cli7 } from "incur";
6
+
7
+ // src/commands/dm.ts
8
+ import { apiKeyAuth } from "@spectratools/cli-shared";
9
+ import { Cli, z } from "incur";
10
+
11
+ // src/api.ts
12
+ import { createHttpClient, withRetry } from "@spectratools/cli-shared";
13
+ var BASE_URL = "https://api.x.com/2";
14
+ var RETRY_OPTIONS = { maxRetries: 3, baseMs: 500, maxMs: 1e4 };
15
+ function createXApiClient(bearerToken) {
16
+ const http = createHttpClient({
17
+ baseUrl: BASE_URL,
18
+ defaultHeaders: {
19
+ Authorization: `Bearer ${bearerToken}`
20
+ }
21
+ });
22
+ function get(path, query) {
23
+ return withRetry(
24
+ () => http.request(path, query !== void 0 ? { query } : {}),
25
+ RETRY_OPTIONS
26
+ );
27
+ }
28
+ function post(path, body) {
29
+ return withRetry(() => http.request(path, { method: "POST", body }), RETRY_OPTIONS);
30
+ }
31
+ function del(path) {
32
+ return withRetry(() => http.request(path, { method: "DELETE" }), RETRY_OPTIONS);
33
+ }
34
+ const POST_FIELDS = "id,text,author_id,created_at,public_metrics";
35
+ function getPost(id) {
36
+ return get(`/tweets/${id}`, {
37
+ "tweet.fields": POST_FIELDS
38
+ });
39
+ }
40
+ function searchPosts(query, maxResults, sort, nextToken) {
41
+ return get("/tweets/search/recent", {
42
+ query,
43
+ max_results: maxResults,
44
+ sort_order: sort,
45
+ "tweet.fields": POST_FIELDS,
46
+ ...nextToken ? { next_token: nextToken } : {}
47
+ });
48
+ }
49
+ function createPost(text, replyTo, quote) {
50
+ const body = { text };
51
+ if (replyTo) body.reply = { in_reply_to_tweet_id: replyTo };
52
+ if (quote) body.quote_tweet_id = quote;
53
+ return post("/tweets", body);
54
+ }
55
+ function deletePost(id) {
56
+ return del(`/tweets/${id}`);
57
+ }
58
+ function getPostLikes(id, maxResults, nextToken) {
59
+ return get(`/tweets/${id}/liking_users`, {
60
+ max_results: maxResults,
61
+ "user.fields": "id,name,username,public_metrics",
62
+ ...nextToken ? { pagination_token: nextToken } : {}
63
+ });
64
+ }
65
+ function getPostRetweets(id, maxResults, nextToken) {
66
+ return get(`/tweets/${id}/retweeted_by`, {
67
+ max_results: maxResults,
68
+ "user.fields": "id,name,username,public_metrics",
69
+ ...nextToken ? { pagination_token: nextToken } : {}
70
+ });
71
+ }
72
+ const USER_FIELDS = "id,name,username,description,public_metrics,created_at";
73
+ function getUserByUsername(username) {
74
+ return get(`/users/by/username/${username}`, {
75
+ "user.fields": USER_FIELDS
76
+ });
77
+ }
78
+ function getUserById(id) {
79
+ return get(`/users/${id}`, {
80
+ "user.fields": USER_FIELDS
81
+ });
82
+ }
83
+ function getUserFollowers(id, maxResults, nextToken) {
84
+ return get(`/users/${id}/followers`, {
85
+ max_results: maxResults,
86
+ "user.fields": USER_FIELDS,
87
+ ...nextToken ? { pagination_token: nextToken } : {}
88
+ });
89
+ }
90
+ function getUserFollowing(id, maxResults, nextToken) {
91
+ return get(`/users/${id}/following`, {
92
+ max_results: maxResults,
93
+ "user.fields": USER_FIELDS,
94
+ ...nextToken ? { pagination_token: nextToken } : {}
95
+ });
96
+ }
97
+ function getUserPosts(id, maxResults, nextToken) {
98
+ return get(`/users/${id}/tweets`, {
99
+ max_results: maxResults,
100
+ "tweet.fields": POST_FIELDS,
101
+ ...nextToken ? { pagination_token: nextToken } : {}
102
+ });
103
+ }
104
+ function getUserMentions(id, maxResults, nextToken) {
105
+ return get(`/users/${id}/mentions`, {
106
+ max_results: maxResults,
107
+ "tweet.fields": POST_FIELDS,
108
+ ...nextToken ? { pagination_token: nextToken } : {}
109
+ });
110
+ }
111
+ function searchUsers(query) {
112
+ return get("/users/search", {
113
+ query,
114
+ "user.fields": USER_FIELDS
115
+ });
116
+ }
117
+ function getHomeTimeline(userId, maxResults, nextToken) {
118
+ return get(`/users/${userId}/timelines/reverse_chronological`, {
119
+ max_results: maxResults,
120
+ "tweet.fields": POST_FIELDS,
121
+ ...nextToken ? { pagination_token: nextToken } : {}
122
+ });
123
+ }
124
+ function getMentionsTimeline(userId, maxResults, nextToken) {
125
+ return get(`/users/${userId}/mentions`, {
126
+ max_results: maxResults,
127
+ "tweet.fields": POST_FIELDS,
128
+ ...nextToken ? { pagination_token: nextToken } : {}
129
+ });
130
+ }
131
+ function getList(id) {
132
+ return get(`/lists/${id}`, {
133
+ "list.fields": "id,name,description,owner_id,member_count"
134
+ });
135
+ }
136
+ function getListMembers(id, maxResults, nextToken) {
137
+ return get(`/lists/${id}/members`, {
138
+ max_results: maxResults,
139
+ "user.fields": USER_FIELDS,
140
+ ...nextToken ? { pagination_token: nextToken } : {}
141
+ });
142
+ }
143
+ function getListPosts(id, maxResults, nextToken) {
144
+ return get(`/lists/${id}/tweets`, {
145
+ max_results: maxResults,
146
+ "tweet.fields": POST_FIELDS,
147
+ ...nextToken ? { pagination_token: nextToken } : {}
148
+ });
149
+ }
150
+ function getTrendingPlaces() {
151
+ return get(
152
+ "/trends/available"
153
+ );
154
+ }
155
+ function getTrendsByLocation(woeid) {
156
+ return get(
157
+ `/trends/place/${woeid}`
158
+ );
159
+ }
160
+ function getDmConversations(userId, maxResults, nextToken) {
161
+ return get(`/users/${userId}/dm_conversations`, {
162
+ max_results: maxResults,
163
+ ...nextToken ? { pagination_token: nextToken } : {}
164
+ });
165
+ }
166
+ function sendDm(participantId, text) {
167
+ return post(
168
+ `/dm_conversations/with/${participantId}/messages`,
169
+ { text }
170
+ );
171
+ }
172
+ function getMe() {
173
+ return get("/users/me", { "user.fields": USER_FIELDS });
174
+ }
175
+ return {
176
+ getPost,
177
+ searchPosts,
178
+ createPost,
179
+ deletePost,
180
+ getPostLikes,
181
+ getPostRetweets,
182
+ getUserByUsername,
183
+ getUserById,
184
+ getUserFollowers,
185
+ getUserFollowing,
186
+ getUserPosts,
187
+ getUserMentions,
188
+ searchUsers,
189
+ getHomeTimeline,
190
+ getMentionsTimeline,
191
+ getList,
192
+ getListMembers,
193
+ getListPosts,
194
+ getTrendingPlaces,
195
+ getTrendsByLocation,
196
+ getDmConversations,
197
+ sendDm,
198
+ getMe
199
+ };
200
+ }
201
+ function relativeTime(iso) {
202
+ const ms = Date.now() - new Date(iso).getTime();
203
+ const s = Math.floor(ms / 1e3);
204
+ if (s < 60) return `${s}s ago`;
205
+ const m = Math.floor(s / 60);
206
+ if (m < 60) return `${m}m ago`;
207
+ const h = Math.floor(m / 60);
208
+ if (h < 24) return `${h}h ago`;
209
+ const d = Math.floor(h / 24);
210
+ return `${d}d ago`;
211
+ }
212
+ function truncateText(text, max = 100) {
213
+ if (text.length <= max) return text;
214
+ return `${text.slice(0, max - 3)}...`;
215
+ }
216
+
217
+ // src/collect-paged.ts
218
+ import { paginateCursor } from "@spectratools/cli-shared";
219
+ async function collectPaged(fetchFn, mapFn, maxResults, pageSize = 100) {
220
+ const results = [];
221
+ for await (const item of paginateCursor({
222
+ fetchPage: async (cursor) => {
223
+ const res = await fetchFn(
224
+ Math.min(maxResults - results.length, pageSize),
225
+ cursor ?? void 0
226
+ );
227
+ return {
228
+ items: res.data ?? [],
229
+ nextCursor: res.meta?.next_token ?? null
230
+ };
231
+ }
232
+ })) {
233
+ results.push(mapFn(item));
234
+ if (results.length >= maxResults) break;
235
+ }
236
+ return results;
237
+ }
238
+
239
+ // src/commands/dm.ts
240
+ var dm = Cli.create("dm", {
241
+ description: "Manage X direct messages."
242
+ });
243
+ dm.command("conversations", {
244
+ description: "List your DM conversations.",
245
+ options: z.object({
246
+ maxResults: z.number().default(20).describe("Maximum conversations to return")
247
+ }),
248
+ alias: { maxResults: "n" },
249
+ output: z.object({
250
+ conversations: z.array(
251
+ z.object({
252
+ dm_conversation_id: z.string(),
253
+ participant_ids: z.array(z.string())
254
+ })
255
+ ),
256
+ count: z.number()
257
+ }),
258
+ examples: [{ description: "List your DM conversations" }],
259
+ async run(c) {
260
+ const { apiKey } = apiKeyAuth("X_BEARER_TOKEN");
261
+ const client = createXApiClient(apiKey);
262
+ const meRes = await client.getMe();
263
+ const userId = meRes.data.id;
264
+ const allConvos = await collectPaged(
265
+ (limit, cursor) => client.getDmConversations(userId, limit, cursor),
266
+ (convo) => ({
267
+ dm_conversation_id: convo.dm_conversation_id,
268
+ participant_ids: convo.participant_ids
269
+ }),
270
+ c.options.maxResults
271
+ );
272
+ const firstParticipant = allConvos[0]?.participant_ids[0];
273
+ return c.ok(
274
+ { conversations: allConvos, count: allConvos.length },
275
+ {
276
+ cta: firstParticipant ? {
277
+ description: "Next steps:",
278
+ commands: [
279
+ {
280
+ command: "dm send",
281
+ args: { participantId: firstParticipant },
282
+ options: { text: "Hello!" },
283
+ description: "Send a message to the first conversation"
284
+ }
285
+ ]
286
+ } : void 0
287
+ }
288
+ );
289
+ }
290
+ });
291
+ dm.command("send", {
292
+ description: "Send a direct message to a user.",
293
+ args: z.object({
294
+ participantId: z.string().describe("User ID to send message to")
295
+ }),
296
+ options: z.object({
297
+ text: z.string().describe("Message text")
298
+ }),
299
+ output: z.object({
300
+ dm_conversation_id: z.string(),
301
+ dm_event_id: z.string()
302
+ }),
303
+ examples: [
304
+ {
305
+ args: { participantId: "12345" },
306
+ options: { text: "Hey there!" },
307
+ description: "Send a DM to a user"
308
+ }
309
+ ],
310
+ async run(c) {
311
+ const { apiKey } = apiKeyAuth("X_BEARER_TOKEN");
312
+ const client = createXApiClient(apiKey);
313
+ const res = await client.sendDm(c.args.participantId, c.options.text);
314
+ return c.ok(res.data);
315
+ }
316
+ });
317
+
318
+ // src/commands/lists.ts
319
+ import { apiKeyAuth as apiKeyAuth2 } from "@spectratools/cli-shared";
320
+ import { Cli as Cli2, z as z2 } from "incur";
321
+ var lists = Cli2.create("lists", {
322
+ description: "Manage and browse X lists."
323
+ });
324
+ lists.command("get", {
325
+ description: "Get a list by ID.",
326
+ args: z2.object({
327
+ id: z2.string().describe("List ID")
328
+ }),
329
+ output: z2.object({
330
+ id: z2.string(),
331
+ name: z2.string(),
332
+ description: z2.string().optional(),
333
+ owner_id: z2.string().optional(),
334
+ member_count: z2.number().optional()
335
+ }),
336
+ examples: [{ args: { id: "1234567890" }, description: "Get list details" }],
337
+ async run(c) {
338
+ const { apiKey } = apiKeyAuth2("X_BEARER_TOKEN");
339
+ const client = createXApiClient(apiKey);
340
+ const res = await client.getList(c.args.id);
341
+ const list = res.data;
342
+ return c.ok(
343
+ {
344
+ id: list.id,
345
+ name: list.name,
346
+ description: list.description,
347
+ owner_id: list.owner_id,
348
+ member_count: list.member_count
349
+ },
350
+ {
351
+ cta: {
352
+ description: "Explore this list:",
353
+ commands: [
354
+ { command: "lists members", args: { id: c.args.id }, description: "See list members" },
355
+ { command: "lists posts", args: { id: c.args.id }, description: "See list posts" }
356
+ ]
357
+ }
358
+ }
359
+ );
360
+ }
361
+ });
362
+ lists.command("members", {
363
+ description: "List members of an X list.",
364
+ args: z2.object({
365
+ id: z2.string().describe("List ID")
366
+ }),
367
+ options: z2.object({
368
+ maxResults: z2.number().default(100).describe("Maximum members to return")
369
+ }),
370
+ alias: { maxResults: "n" },
371
+ output: z2.object({
372
+ users: z2.array(
373
+ z2.object({
374
+ id: z2.string(),
375
+ name: z2.string(),
376
+ username: z2.string(),
377
+ followers: z2.number().optional()
378
+ })
379
+ ),
380
+ count: z2.number()
381
+ }),
382
+ examples: [{ args: { id: "1234567890" }, description: "List all members" }],
383
+ async run(c) {
384
+ const { apiKey } = apiKeyAuth2("X_BEARER_TOKEN");
385
+ const client = createXApiClient(apiKey);
386
+ const allUsers = await collectPaged(
387
+ (limit, cursor) => client.getListMembers(c.args.id, limit, cursor),
388
+ (user) => ({
389
+ id: user.id,
390
+ name: user.name,
391
+ username: user.username,
392
+ followers: user.public_metrics?.followers_count
393
+ }),
394
+ c.options.maxResults
395
+ );
396
+ return c.ok({ users: allUsers, count: allUsers.length });
397
+ }
398
+ });
399
+ lists.command("posts", {
400
+ description: "Get posts from an X list.",
401
+ args: z2.object({
402
+ id: z2.string().describe("List ID")
403
+ }),
404
+ options: z2.object({
405
+ maxResults: z2.number().default(25).describe("Maximum posts to return"),
406
+ verbose: z2.boolean().optional().describe("Show full text")
407
+ }),
408
+ alias: { maxResults: "n" },
409
+ output: z2.object({
410
+ posts: z2.array(
411
+ z2.object({
412
+ id: z2.string(),
413
+ text: z2.string(),
414
+ author_id: z2.string().optional(),
415
+ created_at: z2.string().optional(),
416
+ likes: z2.number().optional()
417
+ })
418
+ ),
419
+ count: z2.number()
420
+ }),
421
+ examples: [{ args: { id: "1234567890" }, description: "Get posts from a list" }],
422
+ async run(c) {
423
+ const { apiKey } = apiKeyAuth2("X_BEARER_TOKEN");
424
+ const client = createXApiClient(apiKey);
425
+ const allPosts = await collectPaged(
426
+ (limit, cursor) => client.getListPosts(c.args.id, limit, cursor),
427
+ (post) => ({
428
+ id: post.id,
429
+ text: c.options.verbose ? post.text : truncateText(post.text),
430
+ author_id: post.author_id,
431
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0,
432
+ likes: post.public_metrics?.like_count
433
+ }),
434
+ c.options.maxResults
435
+ );
436
+ const firstId = allPosts[0]?.id;
437
+ return c.ok(
438
+ { posts: allPosts, count: allPosts.length },
439
+ {
440
+ cta: firstId ? {
441
+ description: "Next steps:",
442
+ commands: [
443
+ {
444
+ command: "posts get",
445
+ args: { id: firstId },
446
+ description: "View top post in detail"
447
+ }
448
+ ]
449
+ } : void 0
450
+ }
451
+ );
452
+ }
453
+ });
454
+
455
+ // src/commands/posts.ts
456
+ import { apiKeyAuth as apiKeyAuth3 } from "@spectratools/cli-shared";
457
+ import { Cli as Cli3, z as z3 } from "incur";
458
+ var posts = Cli3.create("posts", {
459
+ description: "Manage and search X posts."
460
+ });
461
+ posts.command("get", {
462
+ description: "Get a post by ID.",
463
+ args: z3.object({
464
+ id: z3.string().describe("Post ID")
465
+ }),
466
+ options: z3.object({
467
+ verbose: z3.boolean().optional().describe("Show full text without truncation")
468
+ }),
469
+ output: z3.object({
470
+ id: z3.string(),
471
+ text: z3.string(),
472
+ author_id: z3.string().optional(),
473
+ created_at: z3.string().optional(),
474
+ likes: z3.number().optional(),
475
+ retweets: z3.number().optional(),
476
+ replies: z3.number().optional()
477
+ }),
478
+ examples: [{ args: { id: "1234567890" }, description: "Get a post by ID" }],
479
+ async run(c) {
480
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
481
+ const client = createXApiClient(apiKey);
482
+ const res = await client.getPost(c.args.id);
483
+ const post = res.data;
484
+ const text = c.options.verbose ? post.text : truncateText(post.text);
485
+ return c.ok(
486
+ {
487
+ id: post.id,
488
+ text,
489
+ author_id: post.author_id,
490
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0,
491
+ likes: post.public_metrics?.like_count,
492
+ retweets: post.public_metrics?.retweet_count,
493
+ replies: post.public_metrics?.reply_count
494
+ },
495
+ {
496
+ cta: {
497
+ description: "Explore this post:",
498
+ commands: [
499
+ {
500
+ command: "posts likes",
501
+ args: { id: c.args.id },
502
+ description: "See who liked this post"
503
+ },
504
+ {
505
+ command: "posts retweets",
506
+ args: { id: c.args.id },
507
+ description: "See who retweeted this post"
508
+ }
509
+ ]
510
+ }
511
+ }
512
+ );
513
+ }
514
+ });
515
+ posts.command("search", {
516
+ description: "Search recent posts.",
517
+ args: z3.object({
518
+ query: z3.string().describe("Search query")
519
+ }),
520
+ options: z3.object({
521
+ maxResults: z3.number().default(10).describe("Maximum results to return (10\u2013100)"),
522
+ sort: z3.enum(["recency", "relevancy"]).default("recency").describe("Sort order"),
523
+ verbose: z3.boolean().optional().describe("Show full text without truncation")
524
+ }),
525
+ alias: { maxResults: "n" },
526
+ output: z3.object({
527
+ posts: z3.array(
528
+ z3.object({
529
+ id: z3.string(),
530
+ text: z3.string(),
531
+ created_at: z3.string().optional(),
532
+ likes: z3.number().optional(),
533
+ retweets: z3.number().optional()
534
+ })
535
+ ),
536
+ count: z3.number()
537
+ }),
538
+ examples: [
539
+ { args: { query: "TypeScript" }, description: "Search for TypeScript posts" },
540
+ {
541
+ args: { query: "AI" },
542
+ options: { sort: "relevancy", maxResults: 20 },
543
+ description: "Search by relevance"
544
+ }
545
+ ],
546
+ async run(c) {
547
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
548
+ const client = createXApiClient(apiKey);
549
+ const res = await client.searchPosts(c.args.query, c.options.maxResults, c.options.sort);
550
+ const items = (res.data ?? []).map((p) => ({
551
+ id: p.id,
552
+ text: c.options.verbose ? p.text : truncateText(p.text),
553
+ created_at: p.created_at ? relativeTime(p.created_at) : void 0,
554
+ likes: p.public_metrics?.like_count,
555
+ retweets: p.public_metrics?.retweet_count
556
+ }));
557
+ const firstId = items[0]?.id;
558
+ return c.ok(
559
+ { posts: items, count: items.length },
560
+ {
561
+ cta: firstId ? {
562
+ description: "Next steps:",
563
+ commands: [
564
+ {
565
+ command: "posts get",
566
+ args: { id: firstId },
567
+ description: "View top result in detail"
568
+ }
569
+ ]
570
+ } : void 0
571
+ }
572
+ );
573
+ }
574
+ });
575
+ posts.command("create", {
576
+ description: "Create a new post.",
577
+ options: z3.object({
578
+ text: z3.string().describe("Post text"),
579
+ replyTo: z3.string().optional().describe("Reply to post ID"),
580
+ quote: z3.string().optional().describe("Quote post ID")
581
+ }),
582
+ output: z3.object({
583
+ id: z3.string(),
584
+ text: z3.string()
585
+ }),
586
+ examples: [
587
+ { options: { text: "Hello world!" }, description: "Post a simple message" },
588
+ { options: { text: "Great point!", replyTo: "1234567890" }, description: "Reply to a post" }
589
+ ],
590
+ async run(c) {
591
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
592
+ const client = createXApiClient(apiKey);
593
+ const res = await client.createPost(c.options.text, c.options.replyTo, c.options.quote);
594
+ return c.ok(res.data, {
595
+ cta: {
596
+ description: "View your post:",
597
+ commands: [
598
+ { command: "posts get", args: { id: res.data.id }, description: "See the created post" }
599
+ ]
600
+ }
601
+ });
602
+ }
603
+ });
604
+ posts.command("delete", {
605
+ description: "Delete a post by ID.",
606
+ args: z3.object({
607
+ id: z3.string().describe("Post ID to delete")
608
+ }),
609
+ output: z3.object({
610
+ deleted: z3.boolean(),
611
+ id: z3.string()
612
+ }),
613
+ examples: [{ args: { id: "1234567890" }, description: "Delete a post" }],
614
+ async run(c) {
615
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
616
+ const client = createXApiClient(apiKey);
617
+ const res = await client.deletePost(c.args.id);
618
+ return c.ok({ deleted: res.data.deleted, id: c.args.id });
619
+ }
620
+ });
621
+ posts.command("likes", {
622
+ description: "List users who liked a post.",
623
+ args: z3.object({
624
+ id: z3.string().describe("Post ID")
625
+ }),
626
+ options: z3.object({
627
+ maxResults: z3.number().default(100).describe("Maximum users to return")
628
+ }),
629
+ alias: { maxResults: "n" },
630
+ output: z3.object({
631
+ users: z3.array(
632
+ z3.object({
633
+ id: z3.string(),
634
+ name: z3.string(),
635
+ username: z3.string(),
636
+ followers: z3.number().optional()
637
+ })
638
+ ),
639
+ count: z3.number()
640
+ }),
641
+ examples: [{ args: { id: "1234567890" }, description: "See who liked a post" }],
642
+ async run(c) {
643
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
644
+ const client = createXApiClient(apiKey);
645
+ const allUsers = await collectPaged(
646
+ (limit, cursor) => client.getPostLikes(c.args.id, limit, cursor),
647
+ (user) => ({
648
+ id: user.id,
649
+ name: user.name,
650
+ username: user.username,
651
+ followers: user.public_metrics?.followers_count
652
+ }),
653
+ c.options.maxResults
654
+ );
655
+ return c.ok(
656
+ { users: allUsers, count: allUsers.length },
657
+ {
658
+ cta: {
659
+ description: "Next steps:",
660
+ commands: allUsers.slice(0, 1).map((u) => ({
661
+ command: "users get",
662
+ args: { username: u.username },
663
+ description: `View profile of @${u.username}`
664
+ }))
665
+ }
666
+ }
667
+ );
668
+ }
669
+ });
670
+ posts.command("retweets", {
671
+ description: "List users who retweeted a post.",
672
+ args: z3.object({
673
+ id: z3.string().describe("Post ID")
674
+ }),
675
+ options: z3.object({
676
+ maxResults: z3.number().default(100).describe("Maximum users to return")
677
+ }),
678
+ alias: { maxResults: "n" },
679
+ output: z3.object({
680
+ users: z3.array(
681
+ z3.object({
682
+ id: z3.string(),
683
+ name: z3.string(),
684
+ username: z3.string(),
685
+ followers: z3.number().optional()
686
+ })
687
+ ),
688
+ count: z3.number()
689
+ }),
690
+ examples: [{ args: { id: "1234567890" }, description: "See who retweeted a post" }],
691
+ async run(c) {
692
+ const { apiKey } = apiKeyAuth3("X_BEARER_TOKEN");
693
+ const client = createXApiClient(apiKey);
694
+ const allUsers = await collectPaged(
695
+ (limit, cursor) => client.getPostRetweets(c.args.id, limit, cursor),
696
+ (user) => ({
697
+ id: user.id,
698
+ name: user.name,
699
+ username: user.username,
700
+ followers: user.public_metrics?.followers_count
701
+ }),
702
+ c.options.maxResults
703
+ );
704
+ return c.ok({ users: allUsers, count: allUsers.length });
705
+ }
706
+ });
707
+
708
+ // src/commands/timeline.ts
709
+ import { apiKeyAuth as apiKeyAuth4 } from "@spectratools/cli-shared";
710
+ import { Cli as Cli4, z as z4 } from "incur";
711
+ var timeline = Cli4.create("timeline", {
712
+ description: "View your X timeline."
713
+ });
714
+ timeline.command("home", {
715
+ description: "View your home timeline.",
716
+ options: z4.object({
717
+ maxResults: z4.number().default(25).describe("Maximum posts to return (5\u2013100)"),
718
+ verbose: z4.boolean().optional().describe("Show full text without truncation")
719
+ }),
720
+ alias: { maxResults: "n" },
721
+ output: z4.object({
722
+ posts: z4.array(
723
+ z4.object({
724
+ id: z4.string(),
725
+ text: z4.string(),
726
+ author_id: z4.string().optional(),
727
+ created_at: z4.string().optional(),
728
+ likes: z4.number().optional(),
729
+ retweets: z4.number().optional()
730
+ })
731
+ ),
732
+ count: z4.number()
733
+ }),
734
+ examples: [
735
+ { description: "View your home timeline" },
736
+ { options: { maxResults: 50 }, description: "View 50 posts" }
737
+ ],
738
+ async run(c) {
739
+ const { apiKey } = apiKeyAuth4("X_BEARER_TOKEN");
740
+ const client = createXApiClient(apiKey);
741
+ const meRes = await client.getMe();
742
+ const userId = meRes.data.id;
743
+ const allPosts = await collectPaged(
744
+ (limit, cursor) => client.getHomeTimeline(userId, limit, cursor),
745
+ (post) => ({
746
+ id: post.id,
747
+ text: c.options.verbose ? post.text : truncateText(post.text),
748
+ author_id: post.author_id,
749
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0,
750
+ likes: post.public_metrics?.like_count,
751
+ retweets: post.public_metrics?.retweet_count
752
+ }),
753
+ c.options.maxResults
754
+ );
755
+ const firstId = allPosts[0]?.id;
756
+ return c.ok(
757
+ { posts: allPosts, count: allPosts.length },
758
+ {
759
+ cta: firstId ? {
760
+ description: "Next steps:",
761
+ commands: [
762
+ {
763
+ command: "posts get",
764
+ args: { id: firstId },
765
+ description: "View top post in detail"
766
+ }
767
+ ]
768
+ } : void 0
769
+ }
770
+ );
771
+ }
772
+ });
773
+ timeline.command("mentions", {
774
+ description: "View your recent mentions.",
775
+ options: z4.object({
776
+ maxResults: z4.number().default(25).describe("Maximum mentions to return"),
777
+ verbose: z4.boolean().optional().describe("Show full text without truncation")
778
+ }),
779
+ alias: { maxResults: "n" },
780
+ output: z4.object({
781
+ posts: z4.array(
782
+ z4.object({
783
+ id: z4.string(),
784
+ text: z4.string(),
785
+ author_id: z4.string().optional(),
786
+ created_at: z4.string().optional()
787
+ })
788
+ ),
789
+ count: z4.number()
790
+ }),
791
+ examples: [{ description: "View your recent mentions" }],
792
+ async run(c) {
793
+ const { apiKey } = apiKeyAuth4("X_BEARER_TOKEN");
794
+ const client = createXApiClient(apiKey);
795
+ const meRes = await client.getMe();
796
+ const userId = meRes.data.id;
797
+ const allPosts = await collectPaged(
798
+ (limit, cursor) => client.getMentionsTimeline(userId, limit, cursor),
799
+ (post) => ({
800
+ id: post.id,
801
+ text: c.options.verbose ? post.text : truncateText(post.text),
802
+ author_id: post.author_id,
803
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0
804
+ }),
805
+ c.options.maxResults
806
+ );
807
+ return c.ok({ posts: allPosts, count: allPosts.length });
808
+ }
809
+ });
810
+
811
+ // src/commands/trends.ts
812
+ import { apiKeyAuth as apiKeyAuth5 } from "@spectratools/cli-shared";
813
+ import { Cli as Cli5, z as z5 } from "incur";
814
+ var trends = Cli5.create("trends", {
815
+ description: "Explore trending topics on X."
816
+ });
817
+ trends.command("places", {
818
+ description: "List places where trending topics are available.",
819
+ output: z5.object({
820
+ places: z5.array(
821
+ z5.object({
822
+ woeid: z5.number(),
823
+ name: z5.string(),
824
+ country: z5.string()
825
+ })
826
+ ),
827
+ count: z5.number()
828
+ }),
829
+ examples: [{ description: "List all trending places" }],
830
+ async run(c) {
831
+ const { apiKey } = apiKeyAuth5("X_BEARER_TOKEN");
832
+ const client = createXApiClient(apiKey);
833
+ const res = await client.getTrendingPlaces();
834
+ const places = res.data ?? [];
835
+ const first = places[0];
836
+ return c.ok(
837
+ { places, count: places.length },
838
+ {
839
+ cta: first ? {
840
+ description: "Next steps:",
841
+ commands: [
842
+ {
843
+ command: "trends location",
844
+ args: { woeid: first.woeid },
845
+ description: `View trends for ${first.name}`
846
+ }
847
+ ]
848
+ } : void 0
849
+ }
850
+ );
851
+ }
852
+ });
853
+ trends.command("location", {
854
+ description: "Get trending topics for a specific location (WOEID).",
855
+ args: z5.object({
856
+ woeid: z5.string().describe("Where On Earth ID (from trends places)")
857
+ }),
858
+ output: z5.object({
859
+ trends: z5.array(
860
+ z5.object({
861
+ name: z5.string(),
862
+ query: z5.string(),
863
+ tweet_volume: z5.number().optional()
864
+ })
865
+ ),
866
+ count: z5.number()
867
+ }),
868
+ examples: [
869
+ { args: { woeid: "1" }, description: "Get worldwide trends" },
870
+ { args: { woeid: "2459115" }, description: "Get trends for New York" }
871
+ ],
872
+ async run(c) {
873
+ const { apiKey } = apiKeyAuth5("X_BEARER_TOKEN");
874
+ const client = createXApiClient(apiKey);
875
+ const res = await client.getTrendsByLocation(Number(c.args.woeid));
876
+ const trendItems = res.data ?? [];
877
+ const firstTrend = trendItems[0];
878
+ return c.ok(
879
+ { trends: trendItems, count: trendItems.length },
880
+ {
881
+ cta: firstTrend ? {
882
+ description: "Next steps:",
883
+ commands: [
884
+ {
885
+ command: "posts search",
886
+ args: { query: firstTrend.query },
887
+ description: `Search posts about "${firstTrend.name}"`
888
+ }
889
+ ]
890
+ } : void 0
891
+ }
892
+ );
893
+ }
894
+ });
895
+
896
+ // src/commands/users.ts
897
+ import { apiKeyAuth as apiKeyAuth6 } from "@spectratools/cli-shared";
898
+ import { Cli as Cli6, z as z6 } from "incur";
899
+ var users = Cli6.create("users", {
900
+ description: "Look up X users."
901
+ });
902
+ async function resolveUser(client, usernameOrId) {
903
+ if (/^\d+$/.test(usernameOrId)) {
904
+ return client.getUserById(usernameOrId);
905
+ }
906
+ return client.getUserByUsername(usernameOrId.replace(/^@/, ""));
907
+ }
908
+ users.command("get", {
909
+ description: "Get a user by username or ID.",
910
+ args: z6.object({
911
+ username: z6.string().describe("Username (with or without @) or user ID")
912
+ }),
913
+ options: z6.object({
914
+ verbose: z6.boolean().optional().describe("Show full bio without truncation")
915
+ }),
916
+ output: z6.object({
917
+ id: z6.string(),
918
+ name: z6.string(),
919
+ username: z6.string(),
920
+ description: z6.string().optional(),
921
+ followers: z6.number().optional(),
922
+ following: z6.number().optional(),
923
+ tweets: z6.number().optional(),
924
+ joined: z6.string().optional()
925
+ }),
926
+ examples: [
927
+ { args: { username: "jack" }, description: "Get a user by username" },
928
+ { args: { username: "12345" }, description: "Get a user by ID" }
929
+ ],
930
+ async run(c) {
931
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
932
+ const client = createXApiClient(apiKey);
933
+ const res = await resolveUser(client, c.args.username);
934
+ const user = res.data;
935
+ return c.ok(
936
+ {
937
+ id: user.id,
938
+ name: user.name,
939
+ username: user.username,
940
+ description: user.description ? c.options.verbose ? user.description : truncateText(user.description) : void 0,
941
+ followers: user.public_metrics?.followers_count,
942
+ following: user.public_metrics?.following_count,
943
+ tweets: user.public_metrics?.tweet_count,
944
+ joined: user.created_at ? relativeTime(user.created_at) : void 0
945
+ },
946
+ {
947
+ cta: {
948
+ description: "Explore this user:",
949
+ commands: [
950
+ {
951
+ command: "users posts",
952
+ args: { username: user.username },
953
+ description: "View their posts"
954
+ },
955
+ {
956
+ command: "users followers",
957
+ args: { username: user.username },
958
+ description: "View their followers"
959
+ }
960
+ ]
961
+ }
962
+ }
963
+ );
964
+ }
965
+ });
966
+ users.command("followers", {
967
+ description: "List followers of a user.",
968
+ args: z6.object({
969
+ username: z6.string().describe("Username or user ID")
970
+ }),
971
+ options: z6.object({
972
+ maxResults: z6.number().default(100).describe("Maximum followers to return")
973
+ }),
974
+ alias: { maxResults: "n" },
975
+ output: z6.object({
976
+ users: z6.array(
977
+ z6.object({
978
+ id: z6.string(),
979
+ name: z6.string(),
980
+ username: z6.string(),
981
+ followers: z6.number().optional()
982
+ })
983
+ ),
984
+ count: z6.number()
985
+ }),
986
+ examples: [{ args: { username: "jack" }, description: "List followers of jack" }],
987
+ async run(c) {
988
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
989
+ const client = createXApiClient(apiKey);
990
+ const userRes = await resolveUser(client, c.args.username);
991
+ const userId = userRes.data.id;
992
+ const allUsers = await collectPaged(
993
+ (limit, cursor) => client.getUserFollowers(userId, limit, cursor),
994
+ (user) => ({
995
+ id: user.id,
996
+ name: user.name,
997
+ username: user.username,
998
+ followers: user.public_metrics?.followers_count
999
+ }),
1000
+ c.options.maxResults,
1001
+ 1e3
1002
+ );
1003
+ return c.ok({ users: allUsers, count: allUsers.length });
1004
+ }
1005
+ });
1006
+ users.command("following", {
1007
+ description: "List accounts a user is following.",
1008
+ args: z6.object({
1009
+ username: z6.string().describe("Username or user ID")
1010
+ }),
1011
+ options: z6.object({
1012
+ maxResults: z6.number().default(100).describe("Maximum accounts to return")
1013
+ }),
1014
+ alias: { maxResults: "n" },
1015
+ output: z6.object({
1016
+ users: z6.array(
1017
+ z6.object({
1018
+ id: z6.string(),
1019
+ name: z6.string(),
1020
+ username: z6.string(),
1021
+ followers: z6.number().optional()
1022
+ })
1023
+ ),
1024
+ count: z6.number()
1025
+ }),
1026
+ examples: [{ args: { username: "jack" }, description: "List accounts jack follows" }],
1027
+ async run(c) {
1028
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
1029
+ const client = createXApiClient(apiKey);
1030
+ const userRes = await resolveUser(client, c.args.username);
1031
+ const userId = userRes.data.id;
1032
+ const allUsers = await collectPaged(
1033
+ (limit, cursor) => client.getUserFollowing(userId, limit, cursor),
1034
+ (user) => ({
1035
+ id: user.id,
1036
+ name: user.name,
1037
+ username: user.username,
1038
+ followers: user.public_metrics?.followers_count
1039
+ }),
1040
+ c.options.maxResults,
1041
+ 1e3
1042
+ );
1043
+ return c.ok({ users: allUsers, count: allUsers.length });
1044
+ }
1045
+ });
1046
+ users.command("posts", {
1047
+ description: "List a user's posts.",
1048
+ args: z6.object({
1049
+ username: z6.string().describe("Username or user ID")
1050
+ }),
1051
+ options: z6.object({
1052
+ maxResults: z6.number().default(10).describe("Maximum posts to return"),
1053
+ verbose: z6.boolean().optional().describe("Show full text without truncation")
1054
+ }),
1055
+ alias: { maxResults: "n" },
1056
+ output: z6.object({
1057
+ posts: z6.array(
1058
+ z6.object({
1059
+ id: z6.string(),
1060
+ text: z6.string(),
1061
+ created_at: z6.string().optional(),
1062
+ likes: z6.number().optional(),
1063
+ retweets: z6.number().optional()
1064
+ })
1065
+ ),
1066
+ count: z6.number()
1067
+ }),
1068
+ examples: [{ args: { username: "jack" }, description: "Get jack's recent posts" }],
1069
+ async run(c) {
1070
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
1071
+ const client = createXApiClient(apiKey);
1072
+ const userRes = await resolveUser(client, c.args.username);
1073
+ const userId = userRes.data.id;
1074
+ const allPosts = await collectPaged(
1075
+ (limit, cursor) => client.getUserPosts(userId, limit, cursor),
1076
+ (post) => ({
1077
+ id: post.id,
1078
+ text: c.options.verbose ? post.text : truncateText(post.text),
1079
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0,
1080
+ likes: post.public_metrics?.like_count,
1081
+ retweets: post.public_metrics?.retweet_count
1082
+ }),
1083
+ c.options.maxResults
1084
+ );
1085
+ const firstId = allPosts[0]?.id;
1086
+ return c.ok(
1087
+ { posts: allPosts, count: allPosts.length },
1088
+ {
1089
+ cta: firstId ? {
1090
+ description: "Next steps:",
1091
+ commands: [
1092
+ {
1093
+ command: "posts get",
1094
+ args: { id: firstId },
1095
+ description: "View top post in detail"
1096
+ }
1097
+ ]
1098
+ } : void 0
1099
+ }
1100
+ );
1101
+ }
1102
+ });
1103
+ users.command("mentions", {
1104
+ description: "List recent mentions of a user.",
1105
+ args: z6.object({
1106
+ username: z6.string().describe("Username or user ID")
1107
+ }),
1108
+ options: z6.object({
1109
+ maxResults: z6.number().default(10).describe("Maximum mentions to return"),
1110
+ verbose: z6.boolean().optional().describe("Show full text")
1111
+ }),
1112
+ alias: { maxResults: "n" },
1113
+ output: z6.object({
1114
+ posts: z6.array(
1115
+ z6.object({
1116
+ id: z6.string(),
1117
+ text: z6.string(),
1118
+ created_at: z6.string().optional()
1119
+ })
1120
+ ),
1121
+ count: z6.number()
1122
+ }),
1123
+ examples: [{ args: { username: "jack" }, description: "Get mentions of jack" }],
1124
+ async run(c) {
1125
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
1126
+ const client = createXApiClient(apiKey);
1127
+ const userRes = await resolveUser(client, c.args.username);
1128
+ const userId = userRes.data.id;
1129
+ const allPosts = await collectPaged(
1130
+ (limit, cursor) => client.getUserMentions(userId, limit, cursor),
1131
+ (post) => ({
1132
+ id: post.id,
1133
+ text: c.options.verbose ? post.text : truncateText(post.text),
1134
+ created_at: post.created_at ? relativeTime(post.created_at) : void 0
1135
+ }),
1136
+ c.options.maxResults
1137
+ );
1138
+ return c.ok({ posts: allPosts, count: allPosts.length });
1139
+ }
1140
+ });
1141
+ users.command("search", {
1142
+ description: "Search for users by keyword.",
1143
+ args: z6.object({
1144
+ query: z6.string().describe("Search query")
1145
+ }),
1146
+ output: z6.object({
1147
+ users: z6.array(
1148
+ z6.object({
1149
+ id: z6.string(),
1150
+ name: z6.string(),
1151
+ username: z6.string(),
1152
+ followers: z6.number().optional()
1153
+ })
1154
+ ),
1155
+ count: z6.number()
1156
+ }),
1157
+ examples: [{ args: { query: "TypeScript" }, description: "Search for users about TypeScript" }],
1158
+ async run(c) {
1159
+ const { apiKey } = apiKeyAuth6("X_BEARER_TOKEN");
1160
+ const client = createXApiClient(apiKey);
1161
+ const res = await client.searchUsers(c.args.query);
1162
+ const items = (res.data ?? []).map((u) => ({
1163
+ id: u.id,
1164
+ name: u.name,
1165
+ username: u.username,
1166
+ followers: u.public_metrics?.followers_count
1167
+ }));
1168
+ const first = items[0];
1169
+ return c.ok(
1170
+ { users: items, count: items.length },
1171
+ {
1172
+ cta: first ? {
1173
+ description: "Next steps:",
1174
+ commands: [
1175
+ {
1176
+ command: "users get",
1177
+ args: { username: first.username },
1178
+ description: `View @${first.username}'s profile`
1179
+ }
1180
+ ]
1181
+ } : void 0
1182
+ }
1183
+ );
1184
+ }
1185
+ });
1186
+
1187
+ // src/cli.ts
1188
+ var cli = Cli7.create("xapi", {
1189
+ description: "X (Twitter) API CLI for spectra-the-bot."
1190
+ });
1191
+ cli.command(posts);
1192
+ cli.command(users);
1193
+ cli.command(timeline);
1194
+ cli.command(lists);
1195
+ cli.command(trends);
1196
+ cli.command(dm);
1197
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
1198
+ cli.serve();
1199
+ }
1200
+ export {
1201
+ cli
1202
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@spectratools/xapi-cli",
3
+ "version": "0.1.0",
4
+ "description": "X (Twitter) API CLI for spectra-the-bot",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "spectra-the-bot",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "bin": {
12
+ "xapi-cli": "./dist/cli.js"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "incur": "^0.2.2",
22
+ "@spectratools/cli-shared": "0.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "5.7.3",
26
+ "vitest": "2.1.8"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md"
31
+ ],
32
+ "main": "./dist/cli.js",
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "typecheck": "tsc --noEmit -p tsconfig.json",
36
+ "test": "vitest run"
37
+ }
38
+ }