@spectratools/xapi-cli 0.2.4 → 0.3.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 +121 -23
  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,
@@ -1045,6 +1065,7 @@ trends.command("location", {
1045
1065
  });
1046
1066
 
1047
1067
  // src/commands/users.ts
1068
+ import { readFileSync } from "fs";
1048
1069
  import { Cli as Cli6, z as z7 } from "incur";
1049
1070
  var users = Cli6.create("users", {
1050
1071
  description: "Look up X users."
@@ -1055,6 +1076,79 @@ async function resolveUser(client, usernameOrId) {
1055
1076
  }
1056
1077
  return client.getUserByUsername(usernameOrId.replace(/^@/, ""));
1057
1078
  }
1079
+ function formatUserProfile(user, verbose) {
1080
+ return {
1081
+ id: user.id,
1082
+ name: user.name,
1083
+ username: user.username,
1084
+ description: user.description ? verbose ? user.description : truncateText(user.description) : void 0,
1085
+ followers: user.public_metrics?.followers_count,
1086
+ following: user.public_metrics?.following_count,
1087
+ tweets: user.public_metrics?.tweet_count,
1088
+ joined: user.created_at ? relativeTime(user.created_at) : void 0
1089
+ };
1090
+ }
1091
+ function readSeenIds(filePath) {
1092
+ const fileContents = readFileSync(filePath, "utf8");
1093
+ return new Set(
1094
+ fileContents.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
1095
+ );
1096
+ }
1097
+ var followersOptionsSchema = z7.object({
1098
+ maxResults: z7.number().default(100).describe("Maximum followers to return"),
1099
+ seenIdsFile: z7.string().optional().describe(
1100
+ "Path to newline-delimited follower IDs used as a baseline for client-side diffing"
1101
+ ),
1102
+ newOnly: z7.boolean().default(false).describe(
1103
+ "Return only followers not found in --seen-ids-file (client-side baseline diff; not API-native since_id)"
1104
+ )
1105
+ }).refine((options) => !options.newOnly || Boolean(options.seenIdsFile), {
1106
+ path: ["seenIdsFile"],
1107
+ message: "--seen-ids-file is required when --new-only is set"
1108
+ });
1109
+ users.command("me", {
1110
+ description: "Get the authenticated user profile and metrics.",
1111
+ options: z7.object({
1112
+ verbose: z7.boolean().optional().describe("Show full bio without truncation")
1113
+ }),
1114
+ env: xApiReadEnv,
1115
+ output: z7.object({
1116
+ id: z7.string(),
1117
+ name: z7.string(),
1118
+ username: z7.string(),
1119
+ description: z7.string().optional(),
1120
+ followers: z7.number().optional(),
1121
+ following: z7.number().optional(),
1122
+ tweets: z7.number().optional(),
1123
+ joined: z7.string().optional()
1124
+ }),
1125
+ examples: [{ description: "Get your authenticated profile", options: {} }],
1126
+ async run(c) {
1127
+ const client = createXApiClient(readAuthToken(c.env));
1128
+ const res = await client.getMe();
1129
+ const user = res.data;
1130
+ return c.ok(
1131
+ formatUserProfile(user, c.options.verbose),
1132
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1133
+ cta: {
1134
+ description: "Explore your account:",
1135
+ commands: [
1136
+ {
1137
+ command: "users followers",
1138
+ args: { username: user.username },
1139
+ description: "View your followers"
1140
+ },
1141
+ {
1142
+ command: "users posts",
1143
+ args: { username: user.username },
1144
+ description: "View your recent posts"
1145
+ }
1146
+ ]
1147
+ }
1148
+ }
1149
+ );
1150
+ }
1151
+ });
1058
1152
  users.command("get", {
1059
1153
  description: "Get a user by username or ID.",
1060
1154
  args: z7.object({
@@ -1083,16 +1177,7 @@ users.command("get", {
1083
1177
  const res = await resolveUser(client, c.args.username);
1084
1178
  const user = res.data;
1085
1179
  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
- },
1180
+ formatUserProfile(user, c.options.verbose),
1096
1181
  c.format === "json" || c.format === "jsonl" ? void 0 : {
1097
1182
  cta: {
1098
1183
  description: "Explore this user:",
@@ -1114,14 +1199,12 @@ users.command("get", {
1114
1199
  }
1115
1200
  });
1116
1201
  users.command("followers", {
1117
- description: "List followers of a user.",
1202
+ description: "List followers of a user. Supports optional client-side baseline diffing for new follower detection.",
1118
1203
  args: z7.object({
1119
1204
  username: z7.string().describe("Username or user ID")
1120
1205
  }),
1121
- options: z7.object({
1122
- maxResults: z7.number().default(100).describe("Maximum followers to return")
1123
- }),
1124
- alias: { maxResults: "n" },
1206
+ options: followersOptionsSchema,
1207
+ alias: { maxResults: "n", seenIdsFile: "s" },
1125
1208
  env: xApiReadEnv,
1126
1209
  output: z7.object({
1127
1210
  users: z7.array(
@@ -1134,7 +1217,14 @@ users.command("followers", {
1134
1217
  ),
1135
1218
  count: z7.number()
1136
1219
  }),
1137
- examples: [{ args: { username: "jack" }, description: "List followers of jack" }],
1220
+ examples: [
1221
+ { args: { username: "jack" }, description: "List followers of jack" },
1222
+ {
1223
+ args: { username: "jack" },
1224
+ options: { seenIdsFile: "./seen-followers.txt", newOnly: true },
1225
+ description: "Show only followers not in your baseline file (client-side diffing; the X API does not support since_id here)"
1226
+ }
1227
+ ],
1138
1228
  async run(c) {
1139
1229
  const client = createXApiClient(readAuthToken(c.env));
1140
1230
  const userRes = await resolveUser(client, c.args.username);
@@ -1150,7 +1240,15 @@ users.command("followers", {
1150
1240
  c.options.maxResults,
1151
1241
  1e3
1152
1242
  );
1153
- return c.ok({ users: allUsers, count: allUsers.length });
1243
+ if (!c.options.newOnly) {
1244
+ return c.ok({ users: allUsers, count: allUsers.length });
1245
+ }
1246
+ if (!c.options.seenIdsFile) {
1247
+ throw new Error("--seen-ids-file is required when --new-only is set");
1248
+ }
1249
+ const seenIds = readSeenIds(c.options.seenIdsFile);
1250
+ const newUsers = allUsers.filter((user) => !seenIds.has(user.id));
1251
+ return c.ok({ users: newUsers, count: newUsers.length });
1154
1252
  }
1155
1253
  });
1156
1254
  users.command("following", {
@@ -1336,7 +1434,7 @@ users.command("search", {
1336
1434
 
1337
1435
  // src/cli.ts
1338
1436
  var __dirname = dirname(fileURLToPath(import.meta.url));
1339
- var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
1437
+ var pkg = JSON.parse(readFileSync2(resolve(__dirname, "../package.json"), "utf8"));
1340
1438
  var cli = Cli7.create("xapi", {
1341
1439
  version: pkg.version,
1342
1440
  description: "X (Twitter) API CLI for spectra-the-bot."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spectratools/xapi-cli",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "X (Twitter) API CLI for spectra-the-bot",
5
5
  "type": "module",
6
6
  "license": "MIT",