@spectratools/xapi-cli 0.2.4 → 0.4.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 (3) hide show
  1. package/README.md +7 -0
  2. package/dist/cli.js +242 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,10 +54,15 @@ xapi-cli trends location 1 --format json
54
54
  xapi-cli posts search "AI agents" --sort relevancy --max-results 20 --format json
55
55
 
56
56
  # 2) User intelligence pass
57
+ xapi-cli users me --format json
57
58
  xapi-cli users get jack --format json
58
59
  xapi-cli users posts jack --max-results 20 --format json
59
60
  xapi-cli users followers jack --max-results 100 --format json
60
61
 
62
+ # 2b) Client-side follower delta (new followers since a known baseline)
63
+ # Baseline file format: one follower ID per line
64
+ xapi-cli users followers jack --new-only --seen-ids-file ./seen-followers.txt --format json
65
+
61
66
  # 3) Moderation helper flow
62
67
  xapi-cli posts get 1234567890 --format json
63
68
  xapi-cli posts likes 1234567890 --max-results 100 --format json
@@ -75,5 +80,7 @@ xapi-cli dm send 12345 --text "hello from agent" --format json
75
80
  ## Notes
76
81
 
77
82
  - All commands support JSON output with `--format json`.
83
+ - `users followers --new-only` performs **client-side diffing** against `--seen-ids-file`; it does not use an API-native `since_id` filter for follower deltas.
84
+ - Baseline files are read-only input (newline-delimited follower IDs) and are never mutated by the CLI.
78
85
  - `X_BEARER_TOKEN` is for read-only app auth.
79
86
  - `X_ACCESS_TOKEN` is required for write actions (`posts create`, `posts delete`, `dm send`).
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync, realpathSync } from "fs";
4
+ import { readFileSync as readFileSync2, realpathSync } from "fs";
5
5
  import { dirname, resolve } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { Cli as Cli7 } from "incur";
@@ -186,6 +186,12 @@ function createXApiClient(bearerToken) {
186
186
  ...nextToken ? { pagination_token: nextToken } : {}
187
187
  });
188
188
  }
189
+ function likePost(userId, tweetId) {
190
+ return post(`/users/${userId}/likes`, { tweet_id: tweetId });
191
+ }
192
+ function retweetPost(userId, tweetId) {
193
+ return post(`/users/${userId}/retweets`, { tweet_id: tweetId });
194
+ }
189
195
  const USER_FIELDS = "id,name,username,description,public_metrics,created_at";
190
196
  function getUserByUsername(username) {
191
197
  return get(`/users/by/username/${username}`, {
@@ -211,6 +217,14 @@ function createXApiClient(bearerToken) {
211
217
  ...nextToken ? { pagination_token: nextToken } : {}
212
218
  });
213
219
  }
220
+ function followUser(sourceUserId, targetUserId) {
221
+ return post(`/users/${sourceUserId}/following`, {
222
+ target_user_id: targetUserId
223
+ });
224
+ }
225
+ function unfollowUser(sourceUserId, targetUserId) {
226
+ return del(`/users/${sourceUserId}/following/${targetUserId}`);
227
+ }
214
228
  function getUserPosts(id, maxResults, nextToken) {
215
229
  return get(`/users/${id}/tweets`, {
216
230
  max_results: maxResults,
@@ -231,18 +245,20 @@ function createXApiClient(bearerToken) {
231
245
  "user.fields": USER_FIELDS
232
246
  });
233
247
  }
234
- function getHomeTimeline(userId, maxResults, nextToken) {
248
+ function getHomeTimeline(userId, maxResults, nextToken, sinceId) {
235
249
  return get(`/users/${userId}/timelines/reverse_chronological`, {
236
250
  max_results: maxResults,
237
251
  "tweet.fields": POST_FIELDS,
238
- ...nextToken ? { pagination_token: nextToken } : {}
252
+ ...nextToken ? { pagination_token: nextToken } : {},
253
+ ...sinceId ? { since_id: sinceId } : {}
239
254
  });
240
255
  }
241
- function getMentionsTimeline(userId, maxResults, nextToken) {
256
+ function getMentionsTimeline(userId, maxResults, nextToken, sinceId) {
242
257
  return get(`/users/${userId}/mentions`, {
243
258
  max_results: maxResults,
244
259
  "tweet.fields": POST_FIELDS,
245
- ...nextToken ? { pagination_token: nextToken } : {}
260
+ ...nextToken ? { pagination_token: nextToken } : {},
261
+ ...sinceId ? { since_id: sinceId } : {}
246
262
  });
247
263
  }
248
264
  function getList(id) {
@@ -301,10 +317,14 @@ function createXApiClient(bearerToken) {
301
317
  deletePost,
302
318
  getPostLikes,
303
319
  getPostRetweets,
320
+ likePost,
321
+ retweetPost,
304
322
  getUserByUsername,
305
323
  getUserById,
306
324
  getUserFollowers,
307
325
  getUserFollowing,
326
+ followUser,
327
+ unfollowUser,
308
328
  getUserPosts,
309
329
  getUserMentions,
310
330
  searchUsers,
@@ -771,6 +791,54 @@ posts.command("delete", {
771
791
  }
772
792
  }
773
793
  });
794
+ posts.command("like", {
795
+ description: "Like a post by ID.",
796
+ args: z4.object({
797
+ id: z4.string().describe("Post ID to like")
798
+ }),
799
+ env: xApiWriteEnv,
800
+ output: z4.object({
801
+ liked: z4.boolean(),
802
+ id: z4.string()
803
+ }),
804
+ examples: [{ args: { id: "1234567890" }, description: "Like a post" }],
805
+ async run(c) {
806
+ try {
807
+ const client = createXApiClient(writeAuthToken(c.env));
808
+ const me = await client.getMe();
809
+ const res = await client.likePost(me.data.id, c.args.id);
810
+ return c.ok({ liked: res.data.liked, id: c.args.id });
811
+ } catch (error) {
812
+ const authError = toWriteAuthError("posts like", error);
813
+ if (authError) return c.error(authError);
814
+ throw error;
815
+ }
816
+ }
817
+ });
818
+ posts.command("retweet", {
819
+ description: "Retweet a post by ID.",
820
+ args: z4.object({
821
+ id: z4.string().describe("Post ID to retweet")
822
+ }),
823
+ env: xApiWriteEnv,
824
+ output: z4.object({
825
+ retweeted: z4.boolean(),
826
+ id: z4.string()
827
+ }),
828
+ examples: [{ args: { id: "1234567890" }, description: "Retweet a post" }],
829
+ async run(c) {
830
+ try {
831
+ const client = createXApiClient(writeAuthToken(c.env));
832
+ const me = await client.getMe();
833
+ const res = await client.retweetPost(me.data.id, c.args.id);
834
+ return c.ok({ retweeted: res.data.retweeted, id: c.args.id });
835
+ } catch (error) {
836
+ const authError = toWriteAuthError("posts retweet", error);
837
+ if (authError) return c.error(authError);
838
+ throw error;
839
+ }
840
+ }
841
+ });
774
842
  posts.command("likes", {
775
843
  description: "List users who liked a post.",
776
844
  args: z4.object({
@@ -1045,6 +1113,7 @@ trends.command("location", {
1045
1113
  });
1046
1114
 
1047
1115
  // src/commands/users.ts
1116
+ import { readFileSync } from "fs";
1048
1117
  import { Cli as Cli6, z as z7 } from "incur";
1049
1118
  var users = Cli6.create("users", {
1050
1119
  description: "Look up X users."
@@ -1055,6 +1124,79 @@ async function resolveUser(client, usernameOrId) {
1055
1124
  }
1056
1125
  return client.getUserByUsername(usernameOrId.replace(/^@/, ""));
1057
1126
  }
1127
+ function formatUserProfile(user, verbose) {
1128
+ return {
1129
+ id: user.id,
1130
+ name: user.name,
1131
+ username: user.username,
1132
+ description: user.description ? verbose ? user.description : truncateText(user.description) : void 0,
1133
+ followers: user.public_metrics?.followers_count,
1134
+ following: user.public_metrics?.following_count,
1135
+ tweets: user.public_metrics?.tweet_count,
1136
+ joined: user.created_at ? relativeTime(user.created_at) : void 0
1137
+ };
1138
+ }
1139
+ function readSeenIds(filePath) {
1140
+ const fileContents = readFileSync(filePath, "utf8");
1141
+ return new Set(
1142
+ fileContents.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
1143
+ );
1144
+ }
1145
+ var followersOptionsSchema = z7.object({
1146
+ maxResults: z7.number().default(100).describe("Maximum followers to return"),
1147
+ seenIdsFile: z7.string().optional().describe(
1148
+ "Path to newline-delimited follower IDs used as a baseline for client-side diffing"
1149
+ ),
1150
+ newOnly: z7.boolean().default(false).describe(
1151
+ "Return only followers not found in --seen-ids-file (client-side baseline diff; not API-native since_id)"
1152
+ )
1153
+ }).refine((options) => !options.newOnly || Boolean(options.seenIdsFile), {
1154
+ path: ["seenIdsFile"],
1155
+ message: "--seen-ids-file is required when --new-only is set"
1156
+ });
1157
+ users.command("me", {
1158
+ description: "Get the authenticated user profile and metrics.",
1159
+ options: z7.object({
1160
+ verbose: z7.boolean().optional().describe("Show full bio without truncation")
1161
+ }),
1162
+ env: xApiReadEnv,
1163
+ output: z7.object({
1164
+ id: z7.string(),
1165
+ name: z7.string(),
1166
+ username: z7.string(),
1167
+ description: z7.string().optional(),
1168
+ followers: z7.number().optional(),
1169
+ following: z7.number().optional(),
1170
+ tweets: z7.number().optional(),
1171
+ joined: z7.string().optional()
1172
+ }),
1173
+ examples: [{ description: "Get your authenticated profile", options: {} }],
1174
+ async run(c) {
1175
+ const client = createXApiClient(readAuthToken(c.env));
1176
+ const res = await client.getMe();
1177
+ const user = res.data;
1178
+ return c.ok(
1179
+ formatUserProfile(user, c.options.verbose),
1180
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1181
+ cta: {
1182
+ description: "Explore your account:",
1183
+ commands: [
1184
+ {
1185
+ command: "users followers",
1186
+ args: { username: user.username },
1187
+ description: "View your followers"
1188
+ },
1189
+ {
1190
+ command: "users posts",
1191
+ args: { username: user.username },
1192
+ description: "View your recent posts"
1193
+ }
1194
+ ]
1195
+ }
1196
+ }
1197
+ );
1198
+ }
1199
+ });
1058
1200
  users.command("get", {
1059
1201
  description: "Get a user by username or ID.",
1060
1202
  args: z7.object({
@@ -1083,16 +1225,7 @@ users.command("get", {
1083
1225
  const res = await resolveUser(client, c.args.username);
1084
1226
  const user = res.data;
1085
1227
  return c.ok(
1086
- {
1087
- id: user.id,
1088
- name: user.name,
1089
- username: user.username,
1090
- description: user.description ? c.options.verbose ? user.description : truncateText(user.description) : void 0,
1091
- followers: user.public_metrics?.followers_count,
1092
- following: user.public_metrics?.following_count,
1093
- tweets: user.public_metrics?.tweet_count,
1094
- joined: user.created_at ? relativeTime(user.created_at) : void 0
1095
- },
1228
+ formatUserProfile(user, c.options.verbose),
1096
1229
  c.format === "json" || c.format === "jsonl" ? void 0 : {
1097
1230
  cta: {
1098
1231
  description: "Explore this user:",
@@ -1113,15 +1246,77 @@ users.command("get", {
1113
1246
  );
1114
1247
  }
1115
1248
  });
1249
+ users.command("follow", {
1250
+ description: "Follow a user by username or ID.",
1251
+ args: z7.object({
1252
+ username: z7.string().describe("Username (with or without @) or user ID")
1253
+ }),
1254
+ env: xApiWriteEnv,
1255
+ output: z7.object({
1256
+ id: z7.string(),
1257
+ username: z7.string(),
1258
+ following: z7.boolean(),
1259
+ pending_follow: z7.boolean().optional()
1260
+ }),
1261
+ examples: [{ args: { username: "jack" }, description: "Follow @jack" }],
1262
+ async run(c) {
1263
+ try {
1264
+ const client = createXApiClient(writeAuthToken(c.env));
1265
+ const me = await client.getMe();
1266
+ const targetRes = await resolveUser(client, c.args.username);
1267
+ const target = targetRes.data;
1268
+ const res = await client.followUser(me.data.id, target.id);
1269
+ return c.ok({
1270
+ id: target.id,
1271
+ username: target.username,
1272
+ following: res.data.following,
1273
+ pending_follow: res.data.pending_follow
1274
+ });
1275
+ } catch (error) {
1276
+ const authError = toWriteAuthError("users follow", error);
1277
+ if (authError) return c.error(authError);
1278
+ throw error;
1279
+ }
1280
+ }
1281
+ });
1282
+ users.command("unfollow", {
1283
+ description: "Unfollow a user by username or ID.",
1284
+ args: z7.object({
1285
+ username: z7.string().describe("Username (with or without @) or user ID")
1286
+ }),
1287
+ env: xApiWriteEnv,
1288
+ output: z7.object({
1289
+ id: z7.string(),
1290
+ username: z7.string(),
1291
+ following: z7.boolean()
1292
+ }),
1293
+ examples: [{ args: { username: "jack" }, description: "Unfollow @jack" }],
1294
+ async run(c) {
1295
+ try {
1296
+ const client = createXApiClient(writeAuthToken(c.env));
1297
+ const me = await client.getMe();
1298
+ const targetRes = await resolveUser(client, c.args.username);
1299
+ const target = targetRes.data;
1300
+ const res = await client.unfollowUser(me.data.id, target.id);
1301
+ return c.ok({
1302
+ id: target.id,
1303
+ username: target.username,
1304
+ following: res.data.following
1305
+ });
1306
+ } catch (error) {
1307
+ const authError = toWriteAuthError("users unfollow", error);
1308
+ if (authError) return c.error(authError);
1309
+ throw error;
1310
+ }
1311
+ }
1312
+ });
1116
1313
  users.command("followers", {
1117
- description: "List followers of a user.",
1314
+ description: "List followers of a user. Supports optional client-side baseline diffing for new follower detection.",
1118
1315
  args: z7.object({
1119
1316
  username: z7.string().describe("Username or user ID")
1120
1317
  }),
1121
- options: z7.object({
1122
- maxResults: z7.number().default(100).describe("Maximum followers to return")
1123
- }),
1124
- alias: { maxResults: "n" },
1318
+ options: followersOptionsSchema,
1319
+ alias: { maxResults: "n", seenIdsFile: "s" },
1125
1320
  env: xApiReadEnv,
1126
1321
  output: z7.object({
1127
1322
  users: z7.array(
@@ -1134,7 +1329,14 @@ users.command("followers", {
1134
1329
  ),
1135
1330
  count: z7.number()
1136
1331
  }),
1137
- examples: [{ args: { username: "jack" }, description: "List followers of jack" }],
1332
+ examples: [
1333
+ { args: { username: "jack" }, description: "List followers of jack" },
1334
+ {
1335
+ args: { username: "jack" },
1336
+ options: { seenIdsFile: "./seen-followers.txt", newOnly: true },
1337
+ description: "Show only followers not in your baseline file (client-side diffing; the X API does not support since_id here)"
1338
+ }
1339
+ ],
1138
1340
  async run(c) {
1139
1341
  const client = createXApiClient(readAuthToken(c.env));
1140
1342
  const userRes = await resolveUser(client, c.args.username);
@@ -1150,7 +1352,15 @@ users.command("followers", {
1150
1352
  c.options.maxResults,
1151
1353
  1e3
1152
1354
  );
1153
- return c.ok({ users: allUsers, count: allUsers.length });
1355
+ if (!c.options.newOnly) {
1356
+ return c.ok({ users: allUsers, count: allUsers.length });
1357
+ }
1358
+ if (!c.options.seenIdsFile) {
1359
+ throw new Error("--seen-ids-file is required when --new-only is set");
1360
+ }
1361
+ const seenIds = readSeenIds(c.options.seenIdsFile);
1362
+ const newUsers = allUsers.filter((user) => !seenIds.has(user.id));
1363
+ return c.ok({ users: newUsers, count: newUsers.length });
1154
1364
  }
1155
1365
  });
1156
1366
  users.command("following", {
@@ -1336,12 +1546,20 @@ users.command("search", {
1336
1546
 
1337
1547
  // src/cli.ts
1338
1548
  var __dirname = dirname(fileURLToPath(import.meta.url));
1339
- var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
1549
+ var pkg = JSON.parse(readFileSync2(resolve(__dirname, "../package.json"), "utf8"));
1340
1550
  var cli = Cli7.create("xapi", {
1341
1551
  version: pkg.version,
1342
1552
  description: "X (Twitter) API CLI for spectra-the-bot."
1343
1553
  });
1344
- var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["posts create", "posts delete", "dm send"]);
1554
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set([
1555
+ "posts create",
1556
+ "posts delete",
1557
+ "posts like",
1558
+ "posts retweet",
1559
+ "users follow",
1560
+ "users unfollow",
1561
+ "dm send"
1562
+ ]);
1345
1563
  cli.use(async ({ command, error }, next) => {
1346
1564
  try {
1347
1565
  return await next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectratools/xapi-cli",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "X (Twitter) API CLI for spectra-the-bot",
5
5
  "type": "module",
6
6
  "license": "MIT",