@shreejitverma/x-axi 0.0.0-stage → 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.
@@ -0,0 +1,1611 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { errorDetail, SETUP_HINT, toApiError } from "./api.js";
3
+ import { counted, linuxApprovalText } from "./approve.js";
4
+ import { displayText, onePositional, parseArgs, parseHandle, parseLimit, parseListId, parseMaxSpend, parseOutgoingText, parsePollMinutes, parsePollOptions, parsePostId, parseShortText, shortenPath, usage } from "./args.js";
5
+ import { configPath, DEFAULT_REDIRECT_URI, parseClientId, readConfig, writeConfig } from "./config.js";
6
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { assertFollowLogReadable, clearAccount, DAY_MS, logFollow, logFollowAttempt, logUnfollow, readAccount, readFollows, recordSpend, summarizeSpend, writeAccount } from "./ledger.js";
8
+ import { chunkCount, describeMedia, inspectMedia, MEDIA_CHUNK_BYTES, readChunks } from "./media.js";
9
+ import { SCOPES } from "./oauth.js";
10
+ import { DEFAULT_SPEND_CAP_USD, estimatePostCost, RATES } from "./rates.js";
11
+ import { NPM_PACKAGE, VERSION } from "./version.js";
12
+ // X allows 50 follows per 15 minutes and 400 per day. These stay under both.
13
+ const FOLLOW_INTERVAL_MS = 20_000;
14
+ const FOLLOW_DAILY_CAP = 400;
15
+ // Sums of per-row rates carry float error, so 0.01 + 137 * 0.01 prints as 1.38 but compares above it.
16
+ const CAP_SLACK_USD = 1e-9;
17
+ const FOLLOW_BATCH_DEFAULT = 100;
18
+ // GET /2/users/{id}/following and /followers take max_results from 1 to 1000.
19
+ const USER_PAGE_MAX = 1000;
20
+ const MEDIA_WAIT_LIMIT_SECONDS = 600;
21
+ const MEDIA_ID_PLACEHOLDER = "<media id from finalize>";
22
+ const DRY_RUN = "--dry-run";
23
+ const PREVIEW = 280;
24
+ const POST_FIELDS = "tweet.fields=created_at,public_metrics,conversation_id,note_tweet,entities&expansions=author_id&user.fields=username";
25
+ // GET /2/users/{id}/muting and /blocking take max_results from 1 to 1000; list members and owned lists from 1 to 100.
26
+ const LIST_PAGE_MAX = 100;
27
+ const LIST_NAME_MAX = 25;
28
+ const LIST_DESCRIPTION_MAX = 100;
29
+ const WOEID_TRENDS_MAX = 50;
30
+ // ---- reading -----------------------------------------------------------------
31
+ /** Unlike a write, a failed read changed nothing, so its outcome is known. */
32
+ async function read(deps, path) {
33
+ try {
34
+ return await deps.client.request("GET", path);
35
+ }
36
+ catch (error) {
37
+ if (error instanceof AxiError)
38
+ throw error;
39
+ throw new AxiError(error.message, "X_UNAVAILABLE");
40
+ }
41
+ }
42
+ async function get(deps, path) {
43
+ const response = await read(deps, path);
44
+ if (response.status >= 300)
45
+ throw toApiError(response);
46
+ return response;
47
+ }
48
+ /** X reports a missing resource as a 200 whose error title says Not Found. Its other errors can carry a not-found type, as a suspension does. */
49
+ function saysNotFound(body) {
50
+ const errors = body["errors"];
51
+ return Array.isArray(errors) && errors.some((error) => /not.?found/i.test(String(error["title"])));
52
+ }
53
+ function unreadable(what, body) {
54
+ const code = Array.isArray(body["errors"]) ? "FORBIDDEN" : "X_UNAVAILABLE";
55
+ return new AxiError(`X could not return ${what}: ${errorDetail(body)}`, code);
56
+ }
57
+ function foundUser(body, handle) {
58
+ const data = body["data"];
59
+ if (data?.["id"])
60
+ return data;
61
+ if (saysNotFound(body))
62
+ throw new AxiError(`no X user named @${handle}`, "NOT_FOUND");
63
+ throw unreadable(`@${handle}`, body);
64
+ }
65
+ function money(usd) {
66
+ return `$${usd.toFixed(3)}`;
67
+ }
68
+ function usernames(body) {
69
+ const includes = body["includes"];
70
+ return new Map((includes?.users ?? []).map((u) => [String(u["id"]), String(u["username"])]));
71
+ }
72
+ function fullText(post) {
73
+ const note = post["note_tweet"];
74
+ return note?.text ?? String(post["text"] ?? "");
75
+ }
76
+ function truncate(text, full) {
77
+ if (full || text.length <= PREVIEW)
78
+ return text;
79
+ return `${text.slice(0, PREVIEW)}... (truncated, ${text.length} chars total)`;
80
+ }
81
+ function postRows(body, me, full) {
82
+ const names = usernames(body);
83
+ return (body["data"] ?? []).map((post) => {
84
+ const metrics = post["public_metrics"];
85
+ const row = {
86
+ id: String(post["id"]),
87
+ author: names.get(String(post["author_id"])) ?? String(post["author_id"] ?? "?"),
88
+ text: truncate(fullText(post), full),
89
+ replies: metrics?.reply_count ?? 0,
90
+ likes: metrics?.like_count ?? 0,
91
+ };
92
+ if (me)
93
+ row["reply_via_api"] = replyChance(post, me);
94
+ return row;
95
+ });
96
+ }
97
+ /** A hint only. X decides, and it has not documented how implicit reply mentions count. */
98
+ function replyChance(post, me) {
99
+ if (String(post["author_id"]) === me.id)
100
+ return "yes";
101
+ const entities = post["entities"];
102
+ const mentioned = entities?.mentions?.some((m) => m.username?.toLowerCase() === me.username.toLowerCase());
103
+ return mentioned ? "likely" : "unlikely";
104
+ }
105
+ function listOutput(key, rows, body, emptyMessage, cost) {
106
+ const meta = body["meta"];
107
+ // "0 results" must be X's answer, not the absence of one.
108
+ if (!Array.isArray(body["data"]) && meta?.result_count === undefined) {
109
+ throw new AxiError(`X returned no usable result: ${errorDetail(body)}`, "X_UNAVAILABLE");
110
+ }
111
+ // X can return HTTP 200 with partial errors, which must not look like a complete answer.
112
+ const partial = Array.isArray(body["errors"]) ? body["errors"].length : 0;
113
+ return {
114
+ ...(rows.length ? { count: rows.length, [key]: rows } : { [key]: emptyMessage }),
115
+ ...(partial ? { partial_errors: `${partial}: ${errorDetail(body)}` } : {}),
116
+ ...(meta?.next_token ? { next_token: meta.next_token } : {}),
117
+ ...cost,
118
+ };
119
+ }
120
+ /** What a write would do. est_cost stays what was really spent, as everywhere else: here, the reads that validated it. */
121
+ function dryRunOutput(action, account, wouldSend, usd, spent, extra = {}) {
122
+ return {
123
+ dry_run: { action, account: `@${account.username}`, ...extra, would_send: wouldSend, write_est_cost: usd === null ? "unknown" : money(usd) },
124
+ est_cost: money(spent),
125
+ help: [
126
+ "Nothing was written to X and no approval was asked; est_cost is the reads made to validate this, which X bills",
127
+ "Run it again without --dry-run to open the approval dialog",
128
+ ...(extra["linux_dialog"] ? ["On Linux, shorten the text first"] : []),
129
+ ],
130
+ };
131
+ }
132
+ function approvalRequest(action, account, body, estCostUsd, context) {
133
+ return { action, account: account.username, ...body, estCostUsd, ...(context ? { context } : {}) };
134
+ }
135
+ /** A dry run never opens the dialog, so on Linux it asks the same question the Linux dialog would. Elsewhere that dialog is never used. */
136
+ function linuxDialogReport(request, platform) {
137
+ if (platform !== "linux")
138
+ return {};
139
+ return linuxApprovalText(request) === null ? { linux_dialog: "too long: the real run refuses this with APPROVAL_TEXT_TOO_LONG and sends nothing" } : {};
140
+ }
141
+ function userRows(body) {
142
+ return (body["data"] ?? []).map((user) => ({ id: String(user["id"]), username: String(user["username"]), name: String(user["name"] ?? "") }));
143
+ }
144
+ function nextHelp(command, next) {
145
+ return typeof next === "string" ? [`Run \`${command} --next ${next}\` for more`] : [];
146
+ }
147
+ function describeList(list) {
148
+ const members = list.members === undefined ? "member count unknown" : counted(list.members, "member");
149
+ return `List "${displayText(list.name)}" (${list.private ? "private" : "public"}, ${members}, id ${list.id})`;
150
+ }
151
+ function includedUsers(body) {
152
+ return usernames(body).size;
153
+ }
154
+ function pagedQuery(limit, next) {
155
+ const token = typeof next === "string" ? `&pagination_token=${encodeURIComponent(next)}` : "";
156
+ return `max_results=${limit}${token}`;
157
+ }
158
+ export const HELP = {
159
+ me: "usage: x-axi me\nRefreshes and shows your account. Cost about $0.01.",
160
+ timeline: "usage: x-axi timeline [--limit 5-100] [--next TOKEN] [--full]\nYour recent posts.",
161
+ mentions: "usage: x-axi mentions [--limit 5-100] [--next TOKEN] [--full]\nPosts mentioning you, with a reply_via_api hint.",
162
+ replies: "usage: x-axi replies <post-id-or-url> [--limit 10-100] [--next TOKEN] [--full]\nThe post's conversation. X recent search covers the last 7 days only.",
163
+ view: "usage: x-axi view <post-id-or-url> [--full]\nThe post with its public metrics: replies, likes, reposts, and quotes, bookmarks, and impressions where X returns them. Costs about $0.005, plus $0.01 for the author.",
164
+ search: "usage: x-axi search <query> [--limit 10-100] [--next TOKEN] [--full]\nRecent search, last 7 days. Each result costs about $0.005.",
165
+ user: "usage: x-axi user <handle>",
166
+ dm: "usage:\n x-axi dm list [--limit 1-100] [--next TOKEN] [--full]\n x-axi dm read <handle> [--limit 1-100] [--next TOKEN] [--full]\n x-axi dm send <handle> <text> [--dry-run] (needs your approval)\nEncrypted XChat conversations are not covered.",
167
+ following: "usage: x-axi following [handle] [--limit 1-1000] [--next TOKEN] [--max-spend USD]\n x-axi following [handle] --all --out file.json [--next TOKEN] [--max-spend USD]\nAccounts a user follows; yours without a handle. Shows the total. Each account costs about $0.01, or $0.001 on your own list when you own the app, plus $0.01 for the lookup that gives the total. A read estimated above $1 refuses without a --max-spend that covers it, and if X's total proves too low, a page is cut short at the cap and --all stops there. --all with --next cannot be priced in advance, so it always stops at the cap and prints how to continue. --all writes a JSON array and never overwrites a file. The export is unfiltered, so it can hold accounts you already follow; build a follow queue with `x-axi follow-plan`.",
168
+ followers: "usage: x-axi followers [handle] [--limit 1-1000] [--next TOKEN] [--max-spend USD]\n x-axi followers [handle] --all --out file.json [--next TOKEN] [--max-spend USD]\nAccounts following a user; yours without a handle. Costs, caps, and the unfiltered --all export as for `following`.",
169
+ "follow-plan": "usage: x-axi follow-plan <handle> --out queue.json [--max-spend USD] [--include-protected]\nReads that account's following and your own following once, removes accounts you already follow and yourself, and writes a queue for follow-file. Protected accounts go to <out>.protected.json instead of the queue unless you pass --include-protected. Writes nothing to X. About $0.01 per account listed, $0.001 on your own list when you own the app, and $0.02 for the two lookups that give the totals. An estimate above $1 refuses without a --max-spend that covers it. Never overwrites a file. Writes no queue if either read stops early or your own list has partial errors; partial errors on their list, or X listing fewer of your follows than its count, still write the queue with a warning.",
170
+ post: "usage: x-axi post <text> [--quote POST] [--media FILE] [--poll \"A|B[|C|D]\" [--poll-minutes 5-10080]] [--dry-run]\nOpens an approval dialog, then publishes. Costs about $0.015, or $0.20 with a URL. --media attaches one JPEG, PNG, WebP (5 MiB), GIF (15 MiB), or MP4, MOV, or WebM video (16 GiB, or 8 GB without Premium); the file is checked before any spend and uploaded only after approval, and the text may then be left out. Upload needs the media.write scope and X lists no price for it. --poll adds 2 to 4 options of 1 to 25 characters, separated by |, open --poll-minutes (default 1440, one day); it needs text and cannot be combined with --media or --quote, and the dialog shows every option and the duration. X's docs say --quote needs an Enterprise plan, so expect FORBIDDEN on self-serve. --dry-run prints what would be sent, with no approval and no write; reads made to validate it are billed.",
171
+ reply: "usage: x-axi reply <post-id-or-url> <text> [--media FILE] [--dry-run]\nOpens an approval dialog showing the parent post. X allows this only on your own posts or when the author mentioned or quoted you. --media and --dry-run work as on `post`.",
172
+ thread: "usage: x-axi thread <first> <second> ... [--dry-run]\nPosts each part as a reply to the part before it, after one approval dialog showing every part and the total cost. The Linux dialog has room for about 8 short parts. Each part costs as a post of its own: about $0.015, or $0.20 with a URL. If a part fails it stops, never retries, and reports the posted IDs and that part's outcome. Do not rerun a stopped thread: the posted parts are public.",
173
+ delete: "usage: x-axi delete <post-id-or-url> [--dry-run]\nOpens an approval dialog showing the post. Already deleted is a no-op.",
174
+ hide: "usage: x-axi hide <reply-id-or-url> [--dry-run]\nHides a reply on your own conversation, after approval.",
175
+ unhide: "usage: x-axi unhide <reply-id-or-url> [--dry-run]",
176
+ repost: "usage: x-axi repost <post-id-or-url> [--dry-run]\nOpens an approval dialog showing the post. X's pricing page lists no price for reposting, so the cost is unknown. No docs page names reposts on pay-per-use specifically, so X may refuse this.",
177
+ unrepost: "usage: x-axi unrepost <post-id-or-url> [--dry-run]\nOpens an approval dialog showing the post. The cost is unknown, as for `repost`.",
178
+ follow: "usage: x-axi follow <handle> [--dry-run]\nOpens an approval dialog. Costs about $0.015.",
179
+ unfollow: "usage: x-axi unfollow <handle> [--dry-run]\nOpens an approval dialog. Costs about $0.01.",
180
+ "follow-file": "usage: x-axi follow-file <file.json> [--max 1-400] [--dry-run]\nFollows the next accounts from a JSON array of {id, username}, after one approval for the whole batch. One follow every 20 seconds, at most 400 in 24 hours, default 100 per run. Stops at the first error. Rerun to continue. Skips only accounts this login followed through x-axi; build the file with `x-axi follow-plan` so accounts you already follow are not in it. Usernames are shown as the file gives them.",
181
+ bookmarks: "usage: x-axi bookmarks [--limit 1-100] [--next TOKEN] [--full]\nYour bookmarked posts. About $0.001 per post at the Owned Read rate when you own the app, plus $0.01 per author. Needs the bookmark.read scope.",
182
+ bookmark: "usage: x-axi bookmark <post-id-or-url> [--dry-run]\nOpens an approval dialog showing the post, then bookmarks it. Costs about $0.005, plus the post read. Needs the bookmark.write scope.",
183
+ unbookmark: "usage: x-axi unbookmark <post-id-or-url> [--dry-run]\nOpens an approval dialog showing the post, then removes the bookmark. Costs about $0.005, plus the post read. Needs the bookmark.write scope.",
184
+ likes: "usage: x-axi likes [handle] [--limit 5-100] [--next TOKEN] [--full]\nPosts a user liked; yours without a handle. About $0.001 per post, plus $0.01 per author and $0.01 for the lookup of another account. Needs the like.read scope.",
185
+ lists: "usage: x-axi lists [handle] [--limit 1-100] [--next TOKEN]\nLists a user owns; yours without a handle. About $0.005 per List, or $0.001 on your own when you own the app, plus $0.01 for the lookup of another account. Needs the list.read scope.",
186
+ list: "usage:\n x-axi list view <list> [--limit 1-100] [--next TOKEN] [--full] posts, about $0.005 each plus $0.01 per author\n x-axi list members <list> [--limit 1-100] [--next TOKEN] about $0.01 per member\n x-axi list create <name> [--description TEXT] [--private] [--dry-run] about $0.010\n x-axi list delete <list> [--dry-run] about $0.005\n x-axi list add <list> <handle> [--dry-run] about $0.005\n x-axi list remove <list> <handle> [--dry-run] about $0.005\n<list> is a List ID or a URL such as https://x.com/i/lists/123. A name is 1 to 25 characters and a description at most 100. Each write opens an approval dialog naming the List and the member; delete, add, and remove first read the List, about $0.005, and add and remove look up the handle, about $0.01. Needs the list.read and list.write scopes.",
187
+ mutes: "usage: x-axi mutes [--limit 1-1000] [--next TOKEN]\nAccounts you muted. About $0.001 per account. Needs the mute.read scope.",
188
+ mute: "usage: x-axi mute <handle> [--dry-run]\nOpens an approval dialog, then mutes the account. Costs about $0.015, plus $0.01 for the lookup. Needs the mute.write scope.",
189
+ unmute: "usage: x-axi unmute <handle> [--dry-run]\nOpens an approval dialog, then unmutes the account. Costs about $0.005, plus $0.01 for the lookup. Needs the mute.write scope.",
190
+ blocks: "usage: x-axi blocks [--limit 1-1000] [--next TOKEN]\nAccounts you blocked. About $0.001 per account. Read only: X's block and unblock endpoints are Enterprise only. Needs the block.read scope.",
191
+ trends: "usage: x-axi trends [--woeid N [--limit 1-50]]\nTrends personalized for you, or for a place by its Yahoo WOEID, such as 1 for worldwide. About $0.010 per request, however many trends come back. X's docs say personalized trends need an X Premium subscription on the account, and they show WOEID trends with an app token, though the reference also lists user context, so either may return FORBIDDEN.",
192
+ spend: "usage: x-axi spend\nLocal upper-bound estimate of API spend. No API call.",
193
+ api: "usage: x-axi api </2/path?query>\nRaw GET for read endpoints without a command. Spend is not estimated. Writes are not available here.",
194
+ setup: "usage: x-axi setup\nShows login state, the keystore, and the exact X portal steps. No API call.",
195
+ login: "usage: x-axi login [--client-id ID]\nSigns in to X in your browser with OAuth 2.0 PKCE and stores the login in the OS keystore. Run it yourself in a terminal, never through an agent. The client ID comes from --client-id or the config file, and the flag's value is saved to the config file. Then fetches your account, about $0.01. A login that replaces another account's stored login revokes the old tokens; one that replaces the same account's leaves them live and says so, so run `x-axi logout` first after a suspected leak.",
196
+ logout: "usage: x-axi logout\nRevokes the tokens at X and deletes the stored login. No approval dialog; deleting is reported even when revocation fails.",
197
+ auth: "usage: x-axi auth status\nShows the account, scopes, access token expiry, and where the login is stored. Never shows a token. No API call.",
198
+ update: `usage: x-axi update\nShows the installed version and how to update, with npm (${NPM_PACKAGE}) or from a git checkout. It installs nothing.`,
199
+ };
200
+ const EARLIER_TOKENS_LIVE = "The earlier login's tokens stay live at X, because revoking them could also end this login. After a suspected leak, run `x-axi logout` before `x-axi login`, which revokes them; to end them now, revoke the app at https://x.com/settings/connected_apps and log in again";
201
+ /** Setup must take a public user from nothing to a working login, so these name the exact portal fields. */
202
+ export function setupSteps(redirectUri) {
203
+ return [
204
+ "At https://console.x.com create a project and an app on Pay-per-use and Production; add credits and set a spend limit",
205
+ "In the app's User authentication settings turn on OAuth 2.0 with App permissions 'Read and write and Direct message' and Type of App 'Native App' (a public client; x-axi never uses a client secret)",
206
+ `Set the Callback URI to exactly ${redirectUri} and any https website URL you own, then save`,
207
+ "On the app's Keys and tokens page copy the OAuth 2.0 Client ID; if the portal also shows a Client Secret, the type is not Native App",
208
+ "In your own terminal, never through an agent, run: x-axi login --client-id YOUR_CLIENT_ID",
209
+ "Approve the request in the browser; the login is stored in your OS keystore, never in a file",
210
+ "Verify with: x-axi auth status, then x-axi me",
211
+ `Scopes requested: ${SCOPES.join(" ")}`,
212
+ `Upgrading from a version that used xurl: change the app's Type of App to 'Native App', set its Callback URI to ${redirectUri}, run x-axi login --client-id YOUR_CLIENT_ID, then delete ~/.xurl if nothing else uses xurl, because it holds a plain-text token and client secret`,
213
+ ];
214
+ }
215
+ export function homeOutput() {
216
+ const account = readAccount();
217
+ return {
218
+ account: account ? `@${account.username}` : "not connected",
219
+ spend: summarizeSpend(),
220
+ help: account
221
+ ? [
222
+ "Run `x-axi mentions` to see who mentioned you",
223
+ "Run `x-axi timeline` for your posts, `x-axi replies <id>` for comments",
224
+ "Run `x-axi post <text>` or `x-axi reply <id> <text>`; each opens an approval dialog",
225
+ "Run `x-axi bookmarks`, `x-axi lists`, or `x-axi trends` for your saved posts, Lists, and trends",
226
+ ]
227
+ : [SETUP_HINT],
228
+ };
229
+ }
230
+ export function buildCommands(deps) {
231
+ const flagsForList = ["--limit", "--next"];
232
+ const platform = deps.platform ?? process.platform;
233
+ // Local bookkeeping problems surface here, because they must never hide or fail a write that X confirmed.
234
+ const warnings = new Set();
235
+ // What the running command has added to the ledger, so a read's est_cost matches it, lookups included.
236
+ let recorded = 0;
237
+ const spend = (op, usd) => {
238
+ recorded += usd ?? 0;
239
+ const warning = recordSpend(op, usd);
240
+ if (warning)
241
+ warnings.add(warning);
242
+ };
243
+ const charge = (op, usd) => {
244
+ spend(op, usd);
245
+ return { est_cost: money(recorded) };
246
+ };
247
+ const persistFollowLog = (update) => {
248
+ try {
249
+ update();
250
+ }
251
+ catch (error) {
252
+ warnings.add(`follow log not updated: ${String(error)}`);
253
+ }
254
+ };
255
+ /** Writes call this directly so the approval dialog names the account the token really belongs to. */
256
+ const refreshAccount = async (query = "") => {
257
+ const response = await get(deps, `/2/users/me${query}`);
258
+ const data = response.body["data"];
259
+ if (!data?.["id"] || !data["username"]) {
260
+ throw new AxiError(`X returned no account: ${errorDetail(response.body)}`, "X_UNAVAILABLE");
261
+ }
262
+ const account = { id: String(data["id"]), username: String(data["username"]) };
263
+ writeAccount(account);
264
+ spend("me", RATES.userRead);
265
+ return { ...account, data };
266
+ };
267
+ const me = async () => readAccount() ?? refreshAccount();
268
+ const ownedList = async (args, name, endpoint) => {
269
+ const parsed = parseArgs(args, name, flagsForList, ["--full"]);
270
+ if (parsed.positional.length)
271
+ throw usage(`\`${name}\` takes no arguments`);
272
+ const limit = parseLimit(parsed, 20, 5, 100);
273
+ const account = await me();
274
+ const q = pagedQuery(limit, parsed.flags.get("--next"));
275
+ const response = await get(deps, `/2/users/${account.id}/${endpoint}?${q}&${POST_FIELDS}`);
276
+ const rows = postRows(response.body, name === "mentions" ? account : undefined, parsed.flags.has("--full"));
277
+ const usd = rows.length * RATES.ownedRead + includedUsers(response.body) * RATES.userRead;
278
+ return { account: `@${account.username}`, ...listOutput("posts", rows, response.body, `0 ${name} found`, charge(name, usd)) };
279
+ };
280
+ const recentSearch = async (query, op, args) => {
281
+ const parsed = parseArgs(args, op, flagsForList, ["--full"]);
282
+ if (parsed.positional.length)
283
+ throw usage(`\`${op}\` got extra arguments; quote the query as one argument`);
284
+ const limit = parseLimit(parsed, 20, 10, 100);
285
+ const account = readAccount();
286
+ const token = parsed.flags.get("--next");
287
+ const page = typeof token === "string" ? `&next_token=${encodeURIComponent(token)}` : "";
288
+ const path = `/2/tweets/search/recent?query=${encodeURIComponent(query)}&max_results=${limit}${page}&sort_order=recency&${POST_FIELDS}`;
289
+ const response = await get(deps, path);
290
+ const rows = postRows(response.body, account, parsed.flags.has("--full"));
291
+ const usd = rows.length * RATES.postRead + includedUsers(response.body) * RATES.userRead;
292
+ return {
293
+ ...(account ? { account: `@${account.username}` } : {}),
294
+ ...listOutput("posts", rows, response.body, `0 posts found in the last 7 days`, charge(op, usd)),
295
+ };
296
+ };
297
+ const fetchPost = async (id) => {
298
+ const response = await read(deps, `/2/tweets/${id}?${POST_FIELDS}`);
299
+ if (response.status === 404)
300
+ return undefined;
301
+ if (response.status >= 300)
302
+ throw toApiError(response);
303
+ const post = response.body["data"];
304
+ if (!post) {
305
+ if (saysNotFound(response.body))
306
+ return undefined;
307
+ throw unreadable(`post ${id}`, response.body);
308
+ }
309
+ spend("view", RATES.postRead + includedUsers(response.body) * RATES.userRead);
310
+ const author = usernames(response.body).get(String(post["author_id"])) ?? "?";
311
+ return { post, author };
312
+ };
313
+ const resolveUser = async (handle) => {
314
+ const response = await get(deps, `/2/users/by/username/${handle}`);
315
+ const data = foundUser(response.body, handle);
316
+ spend("user", RATES.userRead);
317
+ return { id: String(data["id"]), username: String(data["username"]) };
318
+ };
319
+ const gate = async (action, account, body, estCostUsd, context) => {
320
+ const result = await deps.approve(approvalRequest(action, account, body, estCostUsd, context));
321
+ if (result === "approved")
322
+ return;
323
+ const reasons = {
324
+ cancelled: ["the user cancelled this action; nothing was sent", "NOT_APPROVED"],
325
+ timed_out: ["nobody answered the approval dialog; nothing was sent", "NOT_APPROVED"],
326
+ unavailable: ["no approval dialog is available on this machine; nothing was sent", "APPROVAL_UNAVAILABLE"],
327
+ no_graphical_session: [
328
+ "writes need a dialog on a graphical desktop, and this session has neither DISPLAY nor WAYLAND_DISPLAY; nothing was sent",
329
+ "APPROVAL_UNAVAILABLE",
330
+ "Run x-axi from a desktop session with zenity or kdialog installed; there is no terminal prompt or override",
331
+ ],
332
+ no_dialog_tool: [
333
+ "writes need zenity or kdialog, and neither is in /usr/bin, /bin, or /usr/local/bin; nothing was sent",
334
+ "APPROVAL_UNAVAILABLE",
335
+ "Ask the user to install zenity (or kdialog) from their distribution; there is no terminal prompt or override",
336
+ ],
337
+ unsupported_platform: [
338
+ `writes need a native approval dialog, which x-axi has only on macOS and Linux, not on ${platform}; nothing was sent`,
339
+ "APPROVAL_UNSUPPORTED_PLATFORM",
340
+ "Run writes on macOS or Linux; there is no terminal prompt or override",
341
+ ],
342
+ too_long: [
343
+ "this text is too long for the Linux approval dialog, which must show all of it and has room for about 19 rows of 50 columns of text once wrapped, or about 950 characters of plain text; nothing was sent",
344
+ "APPROVAL_TEXT_TOO_LONG",
345
+ "Shorten the text; parent posts and other context never cause this, and a URL wider than one row is split only for display and sent unchanged",
346
+ ],
347
+ };
348
+ const [message, code, advice] = reasons[result];
349
+ throw new AxiError(message, code, [...(advice ? [advice] : []), "Do not retry unless the user asks; never call the X API another way to get around this"]);
350
+ };
351
+ /** Writes are never retried: after a lost response, a retry could publish twice. */
352
+ const send = async (method, path, body, op, usd, check, errorContext = {}) => {
353
+ let response;
354
+ try {
355
+ response = await deps.client.request(method, path, body);
356
+ }
357
+ catch (error) {
358
+ if (error instanceof AxiError)
359
+ throw error;
360
+ spend(op, usd);
361
+ throw outcomeUnknown(String(error), check);
362
+ }
363
+ if (response.status >= 500 || (response.status >= 300 && response.status < 400)) {
364
+ spend(op, usd);
365
+ throw outcomeUnknown(`X returned ${response.status}`, check);
366
+ }
367
+ if (response.status >= 400)
368
+ throw toApiError(response, errorContext);
369
+ spend(op, usd);
370
+ return response;
371
+ };
372
+ /** Returns X's confirmation. Every unknown outcome counts against the cap, because X may have applied that follow. */
373
+ const sendFollow = async (account, target) => {
374
+ const check = checkProfile(target.username);
375
+ try {
376
+ const response = await send("POST", `/2/users/${account.id}/following`, { target_user_id: target.id }, "follow", RATES.userInteraction, check);
377
+ const data = response.body["data"];
378
+ if (data?.["following"] !== true && data?.["pending_follow"] !== true)
379
+ throw outcomeUnknown("X did not confirm the follow", check);
380
+ return data;
381
+ }
382
+ catch (error) {
383
+ if (error instanceof AxiError && error.code === "OUTCOME_UNKNOWN")
384
+ persistFollowLog(() => logFollowAttempt(account.id));
385
+ throw error;
386
+ }
387
+ };
388
+ const pause = (ms) => (deps.sleep ?? ((wait) => new Promise((done) => setTimeout(done, wait))))(ms);
389
+ /**
390
+ * Logins made before x-axi asked for a scope lack it, and X would refuse the call after a lookup was billed.
391
+ * This reads only the stored login, so it stops before any spend or dialog. A login whose scopes are unknown is let through.
392
+ */
393
+ const requireScopes = async (needed, what) => {
394
+ const { scopes } = await deps.auth.status();
395
+ const missing = scopes ? needed.filter((scope) => !scopes.includes(scope)) : [];
396
+ if (!missing.length)
397
+ return;
398
+ throw new AxiError(`this login has no ${missing.join(" ")} ${missing.length === 1 ? "scope" : "scopes"}, which ${what} needs, because it was granted before x-axi asked for ${missing.length === 1 ? "it" : "them"}`, "MISSING_SCOPE", ["Run `x-axi login` in your own terminal to grant it; nothing was sent to X and nothing was spent"]);
399
+ };
400
+ const prepareMedia = async (parsed) => {
401
+ const file = parsed.flags.get("--media");
402
+ if (file === undefined)
403
+ return undefined;
404
+ const media = inspectMedia(String(file));
405
+ await requireScopes(["media.write"], "--media");
406
+ return media;
407
+ };
408
+ const waitForMedia = async (id, finalized, name) => {
409
+ let body = finalized;
410
+ let waited = 0;
411
+ for (;;) {
412
+ const data = body["data"];
413
+ if (!data)
414
+ throw new AxiError(`X returned no media status: ${errorDetail(body)}`, "X_UNAVAILABLE");
415
+ const info = data["processing_info"];
416
+ if (!info || info.state === "succeeded")
417
+ return;
418
+ if (info.state === "failed")
419
+ throw new AxiError(`X could not process ${displayText(name)}: ${errorDetail(body)}`, "MEDIA_FAILED");
420
+ if (info.state !== "pending" && info.state !== "in_progress")
421
+ throw new AxiError(`X reported media state ${String(info.state)}`, "X_UNAVAILABLE");
422
+ const wait = Math.min(Math.max(Number(info.check_after_secs) || 5, 1), 60);
423
+ if (waited + wait > MEDIA_WAIT_LIMIT_SECONDS)
424
+ throw new AxiError(`X was still processing ${displayText(name)} after ${waited} seconds`, "MEDIA_TIMEOUT");
425
+ await pause(wait * 1000);
426
+ waited += wait;
427
+ body = (await get(deps, `/2/media/upload?command=STATUS&media_id=${id}`)).body;
428
+ spend("media_status", null);
429
+ }
430
+ };
431
+ /** Runs only after approval. Every failure here happens before the post, so its outcome is known: nothing was posted. */
432
+ const uploadMedia = async (media) => {
433
+ const op = "media_upload";
434
+ try {
435
+ const init = await send("POST", "/2/media/upload/initialize", { media_type: media.mediaType, total_bytes: media.size, media_category: media.category }, op, null, "");
436
+ const id = init.body["data"]?.["id"];
437
+ if (typeof id !== "string" || !/^\d{1,19}$/.test(id))
438
+ throw new AxiError(`X returned no media ID: ${errorDetail(init.body)}`, "X_UNAVAILABLE");
439
+ let index = 0;
440
+ for (const chunk of readChunks(media)) {
441
+ const form = new FormData();
442
+ form.append("segment_index", String(index));
443
+ form.append("media", new Blob([new Uint8Array(chunk)]), media.name);
444
+ await send("POST", `/2/media/upload/${id}/append`, form, op, null, "");
445
+ index += 1;
446
+ }
447
+ const finalized = await send("POST", `/2/media/upload/${id}/finalize`, undefined, op, null, "");
448
+ await waitForMedia(id, finalized.body, media.name);
449
+ return id;
450
+ }
451
+ catch (error) {
452
+ const known = error instanceof AxiError;
453
+ const reason = known ? error.message.replace(/; the write may or may not have gone through$/, "") : String(error);
454
+ const code = !known || error.code === "OUTCOME_UNKNOWN" ? "MEDIA_UPLOAD_FAILED" : error.code;
455
+ const suggestions = known && error.code !== "OUTCOME_UNKNOWN" ? error.suggestions : [];
456
+ throw new AxiError(`the media upload stopped, so nothing was posted: ${reason}`, code, suggestions);
457
+ }
458
+ };
459
+ const createPost = async (draft, account, usd) => {
460
+ const { text, replyTo, quote, media, poll, context } = draft;
461
+ const op = replyTo ? "reply" : "post";
462
+ const check = "Check with `x-axi timeline`";
463
+ const verb = replyTo ? "Reply" : quote ? "Quote post" : "Post";
464
+ // Options are quoted as JSON strings in the tool-written first line, so one option cannot pose as two or as the duration.
465
+ const pollShown = poll ? ` with a poll of ${counted(poll.options.length, "option")}, ${poll.options.map((option) => JSON.stringify(option)).join(" | ")}, open ${counted(poll.duration_minutes, "minute")},` : "";
466
+ const action = media ? `${verb} with media ${describeMedia(media)}, upload price not listed by X,` : `${verb}${pollShown}`;
467
+ const fields = {
468
+ ...(text !== undefined ? { text } : {}),
469
+ ...(replyTo ? { reply: { in_reply_to_tweet_id: replyTo } } : {}),
470
+ ...(quote ? { quote_tweet_id: quote } : {}),
471
+ ...(poll ? { poll: { options: poll.options, duration_minutes: poll.duration_minutes } } : {}),
472
+ };
473
+ const approvalBody = text !== undefined ? { text } : { content: "No text; only the media named above is posted." };
474
+ if (draft.dryRun) {
475
+ const upload = media
476
+ ? [
477
+ { method: "POST", path: "/2/media/upload/initialize", body: { media_type: media.mediaType, total_bytes: media.size, media_category: media.category } },
478
+ { method: "POST", path: `/2/media/upload/${MEDIA_ID_PLACEHOLDER}/append`, chunks: chunkCount(media.size), chunk_bytes: `at most ${MEDIA_CHUNK_BYTES}` },
479
+ { method: "POST", path: `/2/media/upload/${MEDIA_ID_PLACEHOLDER}/finalize` },
480
+ ]
481
+ : [];
482
+ const post = { method: "POST", path: "/2/tweets", body: { ...fields, ...(media ? { media: { media_ids: [MEDIA_ID_PLACEHOLDER] } } : {}) } };
483
+ return dryRunOutput(action, account, [...upload, post], usd, recorded, {
484
+ ...(media ? { media: describeMedia(media) } : {}),
485
+ ...(context ? { context: truncate(context, false) } : {}),
486
+ ...(poll ? { poll: { options: poll.options, duration_minutes: poll.duration_minutes } } : {}),
487
+ ...linuxDialogReport(approvalRequest(action, account, approvalBody, usd, context), platform),
488
+ });
489
+ }
490
+ await gate(action, account, approvalBody, usd, context);
491
+ const mediaId = media ? await uploadMedia(media) : undefined;
492
+ let response;
493
+ try {
494
+ response = await send("POST", "/2/tweets", { ...fields, ...(mediaId ? { media: { media_ids: [mediaId] } } : {}) }, op, usd, check, replyTo ? { replyTo, text: text ?? "" } : {});
495
+ }
496
+ catch (error) {
497
+ if (!(error instanceof AxiError))
498
+ throw error;
499
+ const hints = [];
500
+ if (quote && (error.code === "FORBIDDEN" || error.code === "APP_NOT_ENROLLED"))
501
+ hints.push("X's create-post docs say quote-posting needs an Enterprise plan and is not on self-serve tiers");
502
+ if (mediaId)
503
+ hints.push(`Media ${mediaId} finished uploading; X checks its limits again when it is attached to a post, including a video's duration`);
504
+ throw hints.length ? new AxiError(error.message, error.code, [...error.suggestions, ...hints]) : error;
505
+ }
506
+ const data = response.body["data"];
507
+ if (!data?.["id"])
508
+ throw outcomeUnknown("X accepted the request but returned no post ID", check);
509
+ return {
510
+ [op]: { id: String(data["id"]), url: `https://x.com/${account.username}/status/${String(data["id"])}`, status: "published" },
511
+ est_cost: money(usd),
512
+ };
513
+ };
514
+ const listCount = (data, side) => {
515
+ const count = data["public_metrics"]?.[`${side}_count`];
516
+ return typeof count === "number" ? count : undefined;
517
+ };
518
+ /** Your own list is priced at the Owned Read rate, which X gives when the login also owns the app. */
519
+ const listTarget = async (handle, side) => {
520
+ if (!handle) {
521
+ const account = await refreshAccount("?user.fields=public_metrics");
522
+ return { id: account.id, username: account.username, total: listCount(account.data, side), rate: RATES.ownedRead };
523
+ }
524
+ const known = readAccount();
525
+ const response = await get(deps, `/2/users/by/username/${handle}?user.fields=public_metrics`);
526
+ const data = foundUser(response.body, handle);
527
+ spend("user", RATES.userRead);
528
+ const id = String(data["id"]);
529
+ return { id, username: String(data["username"]), total: listCount(data, side), rate: id === known?.id ? RATES.ownedRead : RATES.followListRead };
530
+ };
531
+ const userPage = async (target, side, size, token, op) => {
532
+ const { body } = await get(deps, `/2/users/${target.id}/${side}?${pagedQuery(size, token)}&user.fields=protected`);
533
+ const meta = body["meta"];
534
+ if (!Array.isArray(body["data"]) && meta?.result_count === undefined)
535
+ throw unreadable(`the ${side} of @${target.username}`, body);
536
+ const rows = (body["data"] ?? []).map((user) => ({
537
+ id: String(user["id"]),
538
+ username: String(user["username"]),
539
+ name: String(user["name"] ?? ""),
540
+ protected: user["protected"] === true,
541
+ }));
542
+ spend(op, rows.length * target.rate);
543
+ return { rows, ...(meta?.next_token ? { next: meta.next_token } : {}), partial: Array.isArray(body["errors"]) ? body["errors"].length : 0, body };
544
+ };
545
+ /** How many rows the cap still allows, so even a wrong total from X cannot spend past it. */
546
+ const rowsWithinCap = (target, cap) => Math.floor((cap - recorded + CAP_SLACK_USD) / target.rate);
547
+ /** A failure keeps what was already paid for. */
548
+ const readAllUsers = async (target, side, op, cap, start) => {
549
+ const out = { rows: [], partial: 0 };
550
+ let token = start;
551
+ for (;;) {
552
+ const room = rowsWithinCap(target, cap);
553
+ if (room < 1)
554
+ return { ...out, ...(token ? { next: token } : {}), stopped: new AxiError(`the ${money(cap)} spend cap was reached`, "SPEND_CAP", ["Raise --max-spend to read further"]) };
555
+ let page;
556
+ try {
557
+ page = await userPage(target, side, Math.min(USER_PAGE_MAX, room), token, op);
558
+ }
559
+ catch (error) {
560
+ if (!(error instanceof AxiError))
561
+ throw error;
562
+ return { ...out, ...(token ? { next: token } : {}), stopped: error };
563
+ }
564
+ out.rows.push(...page.rows);
565
+ out.partial += page.partial;
566
+ if (!page.next)
567
+ return out;
568
+ token = page.next;
569
+ if (!page.rows.length)
570
+ return { ...out, next: token, stopped: new AxiError("X returned an empty page with a next token", "X_UNAVAILABLE") };
571
+ }
572
+ };
573
+ const overCap = (usd, cap) => usd > cap + CAP_SLACK_USD;
574
+ const spendCapError = (what, estimate, cap) => {
575
+ const needed = (Math.ceil((estimate - CAP_SLACK_USD) * 100) / 100).toFixed(2);
576
+ return new AxiError(`${what} would cost about ${money(estimate)}, above the ${money(cap)} cap; nothing more was read`, "SPEND_CAP", [
577
+ `Pass --max-spend ${needed} if that is acceptable`,
578
+ `Finding that out cost ${money(recorded)} in lookups`,
579
+ ]);
580
+ };
581
+ const refuseExisting = (path) => {
582
+ if (existsSync(path))
583
+ throw usage(`${path} already exists; choose a new file`);
584
+ };
585
+ /** Fails before any paid page if the file cannot be created, so paid results always have somewhere to go. */
586
+ const reserveFile = (path) => {
587
+ try {
588
+ writeFileSync(path, "", { flag: "wx", mode: 0o600 });
589
+ }
590
+ catch (error) {
591
+ throw usage(`cannot create ${path}: ${error.message}; choose a new file`);
592
+ }
593
+ };
594
+ const writeJson = (path, value) => {
595
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
596
+ };
597
+ const listUsers = async (args, side) => {
598
+ const parsed = parseArgs(args, side, ["--limit", "--next", "--out", "--max-spend"], ["--all"]);
599
+ const positional = onePositional(parsed, side);
600
+ const handle = positional === undefined ? undefined : parseHandle(positional);
601
+ const limit = parseLimit(parsed, 20, 1, USER_PAGE_MAX);
602
+ const maxSpend = parseMaxSpend(parsed);
603
+ const cap = maxSpend ?? DEFAULT_SPEND_CAP_USD;
604
+ const all = parsed.flags.has("--all");
605
+ const out = parsed.flags.get("--out");
606
+ const nextFlag = parsed.flags.get("--next");
607
+ const next = nextFlag === undefined ? undefined : String(nextFlag);
608
+ if (all !== (out !== undefined))
609
+ throw usage("--all and --out go together", [HELP[side]]);
610
+ if (all && parsed.flags.has("--limit"))
611
+ throw usage("--limit does not apply to --all; --max-spend bounds it");
612
+ if (out !== undefined)
613
+ refuseExisting(String(out));
614
+ const target = await listTarget(handle, side);
615
+ const name = `@${target.username}`;
616
+ const rerun = `x-axi ${side}${handle ? ` ${handle}` : ""}`;
617
+ const keepCap = maxSpend === undefined ? "" : ` --max-spend ${maxSpend}`;
618
+ const total = target.total ?? "unknown";
619
+ const resuming = all && next !== undefined;
620
+ if (!resuming) {
621
+ const count = all ? target.total : Math.min(limit, target.total ?? limit);
622
+ const estimate = count === undefined ? undefined : recorded + count * target.rate;
623
+ if (estimate === undefined && maxSpend === undefined) {
624
+ throw new AxiError("X did not return the size of this list, so its cost cannot be estimated; nothing more was read", "SPEND_CAP", ["Pass --max-spend USD to read it up to that amount"]);
625
+ }
626
+ if (estimate !== undefined && overCap(estimate, cap))
627
+ throw spendCapError(`Reading ${count} of the ${total} ${side} of ${name}`, estimate, cap);
628
+ }
629
+ if (out === undefined) {
630
+ const room = rowsWithinCap(target, cap);
631
+ if (room < 1)
632
+ throw spendCapError(`Reading a page of the ${total} ${side} of ${name}`, recorded + target.rate, cap);
633
+ const page = await userPage(target, side, Math.min(limit, room), next, side);
634
+ return {
635
+ target: name,
636
+ total,
637
+ ...(page.rows.length ? { count: page.rows.length, users: page.rows } : { users: "0 accounts found" }),
638
+ ...(page.partial ? { partial_errors: `${page.partial}: ${errorDetail(page.body)}` } : {}),
639
+ ...(page.next ? { next_token: page.next } : {}),
640
+ est_cost: money(recorded),
641
+ ...(page.next ? { help: [`Run \`${rerun} --limit ${limit} --next ${page.next}${keepCap}\` for more, or \`${rerun} --all --out FILE --next ${page.next}${keepCap}\` to export the rest, which stops at the ${money(cap)} cap`] } : {}),
642
+ };
643
+ }
644
+ const file = String(out);
645
+ reserveFile(file);
646
+ const result = await readAllUsers(target, side, side, cap, next);
647
+ if (result.stopped && !result.rows.length && result.stopped.code !== "SPEND_CAP") {
648
+ rmSync(file, { force: true });
649
+ throw result.stopped;
650
+ }
651
+ writeJson(file, result.rows);
652
+ return {
653
+ export: { target: name, total, written: result.rows.length, file, complete: !result.stopped && !result.partial },
654
+ ...(result.partial ? { partial_errors: result.partial } : {}),
655
+ ...(result.stopped ? { stopped: { code: result.stopped.code, reason: result.stopped.message } } : {}),
656
+ ...(result.next ? { next_token: result.next } : {}),
657
+ est_cost: money(recorded),
658
+ help: [
659
+ ...(resuming ? [`What remains after --next cannot be priced in advance, so this read was held to the ${money(cap)} cap instead of being refused up front`] : []),
660
+ ...(result.stopped ? [...result.stopped.suggestions, result.next ? `The file holds what was read. Continue into a new file with \`${rerun} --all --out NEW_FILE --next ${result.next}${keepCap}\`` : "The file holds what was read"] : []),
661
+ "This export is unfiltered, so it can hold accounts you already follow; build a follow queue with `x-axi follow-plan <handle> --out queue.json`",
662
+ ],
663
+ };
664
+ };
665
+ const followPlan = async (args) => {
666
+ const parsed = parseArgs(args, "follow-plan", ["--out", "--max-spend"], ["--include-protected"]);
667
+ const handle = parseHandle(onePositional(parsed, "follow-plan"));
668
+ const outFlag = parsed.flags.get("--out");
669
+ if (outFlag === undefined)
670
+ throw usage("follow-plan needs --out FILE", [HELP["follow-plan"]]);
671
+ const out = String(outFlag);
672
+ const holdProtected = !parsed.flags.has("--include-protected");
673
+ const protectedFile = `${out.replace(/\.json$/i, "")}.protected.json`;
674
+ const maxSpend = parseMaxSpend(parsed);
675
+ const cap = maxSpend ?? DEFAULT_SPEND_CAP_USD;
676
+ refuseExisting(out);
677
+ if (holdProtected)
678
+ refuseExisting(protectedFile);
679
+ const mine = await listTarget(undefined, "following");
680
+ const theirs = await listTarget(handle, "following");
681
+ if (theirs.id === mine.id)
682
+ throw usage("that is your own account; a plan copies another account's following");
683
+ if ((mine.total === undefined || theirs.total === undefined) && maxSpend === undefined) {
684
+ throw new AxiError("X did not return the size of these lists, so the cost cannot be estimated; nothing more was read", "SPEND_CAP", ["Pass --max-spend USD to read them up to that amount"]);
685
+ }
686
+ if (mine.total !== undefined && theirs.total !== undefined) {
687
+ const estimate = recorded + mine.total * mine.rate + theirs.total * theirs.rate;
688
+ if (overCap(estimate, cap))
689
+ throw spendCapError(`Reading the ${theirs.total} accounts @${theirs.username} follows and the ${mine.total} you follow`, estimate, cap);
690
+ }
691
+ reserveFile(out);
692
+ const incomplete = (whose, result) => {
693
+ rmSync(out, { force: true });
694
+ return new AxiError(`${whose} could not be read in full (${result.stopped?.message ?? `X reported ${result.partial} partial errors`}), so no queue was written; ${money(recorded)} was spent`, result.stopped?.code ?? "INCOMPLETE_READ", [...(result.stopped?.suggestions ?? []), "A queue from a partial list could hold accounts you already follow. X does not re-bill what it served within a UTC day, so a rerun that day should cost little"]);
695
+ };
696
+ // Your own list comes first and must be whole: it is what keeps already-followed accounts out of the queue.
697
+ const own = await readAllUsers(mine, "following", "follow_plan", cap);
698
+ if (own.stopped || own.partial)
699
+ throw incomplete("Your following list", own);
700
+ const source = await readAllUsers(theirs, "following", "follow_plan", cap);
701
+ if (source.stopped)
702
+ throw incomplete(`The following list of @${theirs.username}`, source);
703
+ if (mine.total !== undefined && own.rows.length < mine.total) {
704
+ warnings.add(`X listed ${own.rows.length} of the ${mine.total} accounts its count says you follow, so one you already follow could still be in the queue`);
705
+ }
706
+ if (source.partial)
707
+ warnings.add(`X reported ${source.partial} partial errors reading @${theirs.username}, so the queue may be missing accounts`);
708
+ const already = new Set(own.rows.map((user) => user.id));
709
+ const seen = new Set();
710
+ const queue = [];
711
+ const held = [];
712
+ let alreadyFollowed = 0;
713
+ let selfExcluded = 0;
714
+ let unusable = 0;
715
+ for (const user of source.rows) {
716
+ if (seen.has(user.id))
717
+ continue;
718
+ seen.add(user.id);
719
+ const entry = { id: user.id, username: user.username };
720
+ // follow-file refuses a whole file over one bad entry.
721
+ if (!/^\d{1,19}$/.test(user.id) || !/^\w{1,15}$/.test(user.username))
722
+ unusable += 1;
723
+ else if (user.id === mine.id)
724
+ selfExcluded += 1;
725
+ else if (already.has(user.id))
726
+ alreadyFollowed += 1;
727
+ else if (user.protected && holdProtected)
728
+ held.push(entry);
729
+ else
730
+ queue.push(entry);
731
+ }
732
+ writeJson(out, queue);
733
+ if (held.length)
734
+ writeJson(protectedFile, held);
735
+ return {
736
+ follow_plan: {
737
+ target: `@${theirs.username}`,
738
+ target_following: seen.size,
739
+ your_following: already.size,
740
+ already_followed: alreadyFollowed,
741
+ self_excluded: selfExcluded,
742
+ protected_held: held.length,
743
+ ...(unusable ? { unusable } : {}),
744
+ queued: queue.length,
745
+ queue_file: out,
746
+ ...(held.length ? { protected_file: protectedFile } : {}),
747
+ },
748
+ est_cost: money(recorded),
749
+ following_them_would_cost: money(queue.length * RATES.userInteraction),
750
+ help: queue.length
751
+ ? [`Run \`x-axi follow-file ${out} --dry-run\` to review the first batch, then without --dry-run to follow after approval`]
752
+ : ["0 accounts to follow: you already follow all of them, or they are held out"],
753
+ };
754
+ };
755
+ const hideReply = async (args, hidden) => {
756
+ const name = hidden ? "hide" : "unhide";
757
+ const parsed = parseArgs(args, name, [], [DRY_RUN]);
758
+ const id = parsePostId(onePositional(parsed, name));
759
+ const account = await refreshAccount();
760
+ const found = await fetchPost(id);
761
+ if (!found)
762
+ throw new AxiError(`post ${id} not found`, "NOT_FOUND");
763
+ const action = hidden ? "Hide reply" : "Unhide reply";
764
+ const content = `@${found.author}: ${displayText(fullText(found.post))}`;
765
+ if (parsed.flags.has(DRY_RUN))
766
+ return dryRunOutput(action, account, [{ method: "PUT", path: `/2/tweets/${id}/hidden`, body: { hidden } }], null, recorded, { target: content });
767
+ await gate(action, account, { content }, null);
768
+ const check = `Check the reply at https://x.com/i/status/${id}, because x-axi cannot read whether a reply is hidden`;
769
+ const response = await send("PUT", `/2/tweets/${id}/hidden`, { hidden }, name, null, check);
770
+ const state = response.body["data"]?.["hidden"];
771
+ if (state !== hidden)
772
+ throw outcomeUnknown(`X answered hidden=${String(state)}`, check);
773
+ return { reply: { id, hidden } };
774
+ };
775
+ const setRepost = async (args, reposted) => {
776
+ const name = reposted ? "repost" : "unrepost";
777
+ const parsed = parseArgs(args, name, [], [DRY_RUN]);
778
+ const id = parsePostId(onePositional(parsed, name));
779
+ const account = await refreshAccount();
780
+ const found = await fetchPost(id);
781
+ if (!found)
782
+ throw new AxiError(`post ${id} not found`, "NOT_FOUND");
783
+ const action = reposted ? "Repost" : "Undo repost";
784
+ const content = `@${found.author}: ${displayText(truncate(fullText(found.post), false))}`;
785
+ const method = reposted ? "POST" : "DELETE";
786
+ const path = reposted ? `/2/users/${account.id}/retweets` : `/2/users/${account.id}/retweets/${id}`;
787
+ const body = reposted ? { tweet_id: id } : undefined;
788
+ if (parsed.flags.has(DRY_RUN))
789
+ return dryRunOutput(action, account, [{ method, path, ...(body ? { body } : {}) }], null, recorded, { target: content });
790
+ await gate(action, account, { content }, null);
791
+ const check = `\`x-axi view\` shows only the post's total repost count, so check your reposts at https://x.com/${account.username}`;
792
+ const response = await send(method, path, body, name, null, check);
793
+ if (response.body["data"]?.["retweeted"] !== reposted)
794
+ throw outcomeUnknown(`X did not confirm the ${name}`, check);
795
+ return { post: { id, status: reposted ? "reposted" : "not reposted" } };
796
+ };
797
+ // Scopes from each endpoint's API reference on docs.x.com, read 2026-11-03.
798
+ const SCOPES_FOR = {
799
+ bookmarks: ["tweet.read", "users.read", "bookmark.read"],
800
+ bookmarkWrite: ["tweet.read", "users.read", "bookmark.write"],
801
+ likes: ["tweet.read", "users.read", "like.read"],
802
+ listRead: ["tweet.read", "users.read", "list.read"],
803
+ // Writes that name the List in the dialog read it first.
804
+ listWrite: ["tweet.read", "users.read", "list.read", "list.write"],
805
+ mutes: ["tweet.read", "users.read", "mute.read"],
806
+ muteWrite: ["tweet.read", "users.read", "mute.write"],
807
+ blocks: ["tweet.read", "users.read", "block.read"],
808
+ };
809
+ /** Your own account without a handle; another account costs a lookup. Owned Read pricing applies only to your own. */
810
+ const whose = async (handle) => {
811
+ if (!handle)
812
+ return { ...(await me()), own: true };
813
+ const target = await resolveUser(handle);
814
+ return { ...target, own: target.id === readAccount()?.id };
815
+ };
816
+ /** One page of posts, priced per post at `rate` plus each author X includes. */
817
+ const postPage = async (path, rate, op, empty, full, account) => {
818
+ const response = await get(deps, path);
819
+ if (!Array.isArray(response.body["data"]) && saysNotFound(response.body))
820
+ throw new AxiError(errorDetail(response.body), "NOT_FOUND");
821
+ const rows = postRows(response.body, account, full);
822
+ const usd = rows.length * rate + includedUsers(response.body) * RATES.userRead;
823
+ return listOutput("posts", rows, response.body, empty, charge(op, usd));
824
+ };
825
+ const userPageOf = async (path, rate, op, empty) => {
826
+ const response = await get(deps, path);
827
+ if (!Array.isArray(response.body["data"]) && saysNotFound(response.body))
828
+ throw new AxiError(errorDetail(response.body), "NOT_FOUND");
829
+ const rows = userRows(response.body);
830
+ return listOutput("users", rows, response.body, empty, charge(op, rows.length * rate));
831
+ };
832
+ /** Lists of accounts only X shows you: mutes and blocks. */
833
+ const ownUsers = async (args, name, endpoint, rate, scopes) => {
834
+ const parsed = parseArgs(args, name, flagsForList);
835
+ if (parsed.positional.length)
836
+ throw usage(`\`${name}\` takes no arguments`);
837
+ const limit = parseLimit(parsed, 20, 1, USER_PAGE_MAX);
838
+ await requireScopes(scopes, `\`${name}\``);
839
+ const account = await me();
840
+ const out = await userPageOf(`/2/users/${account.id}/${endpoint}?${pagedQuery(limit, parsed.flags.get("--next"))}`, rate, name, `0 accounts ${name === "mutes" ? "muted" : "blocked"}`);
841
+ return { account: `@${account.username}`, ...out, help: nextHelp(`x-axi ${name} --limit ${limit}`, out["next_token"]) };
842
+ };
843
+ /** The List a write names in its dialog, or undefined when X says it does not exist. Billed as a List read. */
844
+ const fetchList = async (id) => {
845
+ const response = await read(deps, `/2/lists/${id}?list.fields=member_count,private`);
846
+ if (response.status === 404)
847
+ return undefined;
848
+ if (response.status >= 300)
849
+ throw toApiError(response);
850
+ const data = response.body["data"];
851
+ if (!data?.["id"]) {
852
+ if (saysNotFound(response.body))
853
+ return undefined;
854
+ throw unreadable(`List ${id}`, response.body);
855
+ }
856
+ spend("list_lookup", RATES.listRead);
857
+ const members = data["member_count"];
858
+ return { id: String(data["id"]), name: String(data["name"] ?? ""), members: typeof members === "number" ? members : undefined, private: data["private"] === true };
859
+ };
860
+ const setBookmark = async (args, bookmarked) => {
861
+ const name = bookmarked ? "bookmark" : "unbookmark";
862
+ const parsed = parseArgs(args, name, [], [DRY_RUN]);
863
+ const id = parsePostId(onePositional(parsed, name));
864
+ await requireScopes(SCOPES_FOR.bookmarkWrite, `\`${name}\``);
865
+ const account = await refreshAccount();
866
+ const found = await fetchPost(id);
867
+ if (!found)
868
+ throw new AxiError(`post ${id} not found`, "NOT_FOUND");
869
+ const action = bookmarked ? "Bookmark" : "Remove bookmark";
870
+ const content = `@${found.author}: ${displayText(truncate(fullText(found.post), false))}`;
871
+ const method = bookmarked ? "POST" : "DELETE";
872
+ const path = bookmarked ? `/2/users/${account.id}/bookmarks` : `/2/users/${account.id}/bookmarks/${id}`;
873
+ const body = bookmarked ? { tweet_id: id } : undefined;
874
+ if (parsed.flags.has(DRY_RUN))
875
+ return dryRunOutput(action, account, [{ method, path, ...(body ? { body } : {}) }], RATES.bookmark, recorded, { target: content });
876
+ await gate(action, account, { content }, RATES.bookmark);
877
+ const check = "Check with `x-axi bookmarks`";
878
+ const response = await send(method, path, body, name, RATES.bookmark, check);
879
+ if (response.body["data"]?.["bookmarked"] !== bookmarked)
880
+ throw outcomeUnknown(`X did not confirm the ${name}`, check);
881
+ return { post: { id, status: bookmarked ? "bookmarked" : "not bookmarked" }, est_cost: money(RATES.bookmark) };
882
+ };
883
+ const setMute = async (args, muted) => {
884
+ const name = muted ? "mute" : "unmute";
885
+ const parsed = parseArgs(args, name, [], [DRY_RUN]);
886
+ const handle = parseHandle(onePositional(parsed, name));
887
+ await requireScopes(SCOPES_FOR.muteWrite, `\`${name}\``);
888
+ const account = await refreshAccount();
889
+ const target = await resolveUser(handle);
890
+ const usd = muted ? RATES.userInteraction : RATES.muteDelete;
891
+ const action = muted ? "Mute" : "Unmute";
892
+ const method = muted ? "POST" : "DELETE";
893
+ const path = muted ? `/2/users/${account.id}/muting` : `/2/users/${account.id}/muting/${target.id}`;
894
+ const body = muted ? { target_user_id: target.id } : undefined;
895
+ if (parsed.flags.has(DRY_RUN))
896
+ return dryRunOutput(action, account, [{ method, path, ...(body ? { body } : {}) }], usd, recorded, { target: `@${target.username}` });
897
+ await gate(action, account, { content: `@${target.username}` }, usd);
898
+ const check = "Check with `x-axi mutes`";
899
+ const response = await send(method, path, body, name, usd, check);
900
+ if (response.body["data"]?.["muting"] !== muted)
901
+ throw outcomeUnknown(`X did not confirm the ${name}`, check);
902
+ return { [name]: { handle: `@${target.username}`, status: muted ? "muted" : "not muted" }, est_cost: money(usd) };
903
+ };
904
+ const listCommand = async (args) => {
905
+ const [sub, ...rest] = args;
906
+ const command = `list ${sub ?? ""}`.trim();
907
+ if (sub === "view" || sub === "members") {
908
+ const parsed = parseArgs(rest, command, flagsForList, sub === "view" ? ["--full"] : []);
909
+ const id = parseListId(onePositional(parsed, command));
910
+ const limit = parseLimit(parsed, 20, 1, LIST_PAGE_MAX);
911
+ await requireScopes(SCOPES_FOR.listRead, `\`${command}\``);
912
+ const q = pagedQuery(limit, parsed.flags.get("--next"));
913
+ const out = sub === "view"
914
+ ? await postPage(`/2/lists/${id}/tweets?${q}&${POST_FIELDS}`, RATES.postRead, "list_view", "0 posts in this List", parsed.flags.has("--full"))
915
+ : await userPageOf(`/2/lists/${id}/members?${q}`, RATES.userRead, "list_members", "0 members in this List");
916
+ return { list: id, ...out, help: nextHelp(`x-axi ${command} ${id} --limit ${limit}`, out["next_token"]) };
917
+ }
918
+ if (sub === "create") {
919
+ const parsed = parseArgs(rest, command, ["--description"], ["--private", DRY_RUN]);
920
+ if (parsed.positional.length !== 1)
921
+ throw usage("`list create` takes one name; quote it if it contains spaces", [HELP["list"]]);
922
+ const name = parseShortText(parsed.positional[0], "the List name", LIST_NAME_MAX);
923
+ const rawDescription = parsed.flags.get("--description");
924
+ const description = rawDescription === undefined ? undefined : parseShortText(String(rawDescription), "--description", LIST_DESCRIPTION_MAX);
925
+ const isPrivate = parsed.flags.has("--private");
926
+ await requireScopes(SCOPES_FOR.listWrite, "`list create`");
927
+ const account = await refreshAccount();
928
+ const body = { name, ...(description !== undefined ? { description } : {}), private: isPrivate };
929
+ const action = `Create ${isPrivate ? "private" : "public"} List`;
930
+ const content = [`Name: ${name}`, `Description: ${description ?? "(none)"}`].join("\n");
931
+ if (parsed.flags.has(DRY_RUN))
932
+ return dryRunOutput(action, account, [{ method: "POST", path: "/2/lists", body }], RATES.listCreate, recorded, { target: content });
933
+ await gate(action, account, { content }, RATES.listCreate);
934
+ const check = "Check with `x-axi lists`";
935
+ const response = await send("POST", "/2/lists", body, "list_create", RATES.listCreate, check);
936
+ const data = response.body["data"];
937
+ if (!data?.["id"])
938
+ throw outcomeUnknown("X accepted the request but returned no List ID", check);
939
+ const id = String(data["id"]);
940
+ return {
941
+ list: { id, name: String(data["name"] ?? name), private: isPrivate, status: "created" },
942
+ est_cost: money(RATES.listCreate),
943
+ help: [`Run \`x-axi list add ${id} <handle>\` to add a member`],
944
+ };
945
+ }
946
+ if (sub === "delete") {
947
+ const parsed = parseArgs(rest, command, [], [DRY_RUN]);
948
+ const id = parseListId(onePositional(parsed, command));
949
+ await requireScopes(SCOPES_FOR.listWrite, "`list delete`");
950
+ const account = await refreshAccount();
951
+ const list = await fetchList(id);
952
+ if (!list)
953
+ return { list: { id, status: "already gone (no-op)" }, est_cost: money(recorded) };
954
+ const content = describeList(list);
955
+ if (parsed.flags.has(DRY_RUN))
956
+ return dryRunOutput("Delete List", account, [{ method: "DELETE", path: `/2/lists/${id}` }], RATES.listManage, recorded, { target: content });
957
+ await gate("Delete List", account, { content }, RATES.listManage);
958
+ const check = "Check with `x-axi lists`";
959
+ const response = await send("DELETE", `/2/lists/${id}`, undefined, "list_delete", RATES.listManage, check);
960
+ if (response.body["data"]?.["deleted"] !== true)
961
+ throw outcomeUnknown("X did not confirm the deletion", check);
962
+ return { list: { id, status: "deleted" }, est_cost: money(RATES.listManage) };
963
+ }
964
+ if (sub === "add" || sub === "remove") {
965
+ const parsed = parseArgs(rest, command, [], [DRY_RUN]);
966
+ if (parsed.positional.length !== 2)
967
+ throw usage(`\`${command}\` takes a List and a handle`, [HELP["list"]]);
968
+ const id = parseListId(parsed.positional[0]);
969
+ const handle = parseHandle(parsed.positional[1]);
970
+ await requireScopes(SCOPES_FOR.listWrite, `\`${command}\``);
971
+ const account = await refreshAccount();
972
+ const list = await fetchList(id);
973
+ if (!list)
974
+ throw new AxiError(`List ${id} not found`, "NOT_FOUND");
975
+ const target = await resolveUser(handle);
976
+ const adding = sub === "add";
977
+ const action = adding ? "Add member to List" : "Remove member from List";
978
+ const content = `@${target.username} ${adding ? "to" : "from"} ${describeList(list)}`;
979
+ const method = adding ? "POST" : "DELETE";
980
+ const path = adding ? `/2/lists/${id}/members` : `/2/lists/${id}/members/${target.id}`;
981
+ const body = adding ? { user_id: target.id } : undefined;
982
+ if (parsed.flags.has(DRY_RUN))
983
+ return dryRunOutput(action, account, [{ method, path, ...(body ? { body } : {}) }], RATES.listManage, recorded, { target: content });
984
+ await gate(action, account, { content }, RATES.listManage);
985
+ const check = `Check with \`x-axi list members ${id}\``;
986
+ const response = await send(method, path, body, adding ? "list_add" : "list_remove", RATES.listManage, check);
987
+ if (response.body["data"]?.["is_member"] !== adding)
988
+ throw outcomeUnknown(`X did not confirm the ${adding ? "addition" : "removal"}`, check);
989
+ return { list_member: { list: id, handle: `@${target.username}`, status: adding ? "member" : "not a member" }, est_cost: money(RATES.listManage) };
990
+ }
991
+ throw usage("expected `list view`, `list members`, `list create`, `list delete`, `list add`, or `list remove`", [HELP["list"]]);
992
+ };
993
+ const trends = async (args) => {
994
+ const parsed = parseArgs(args, "trends", ["--woeid", "--limit"]);
995
+ if (parsed.positional.length)
996
+ throw usage("`trends` takes no arguments; pass --woeid N for a place", [HELP["trends"]]);
997
+ const rawWoeid = parsed.flags.get("--woeid");
998
+ const woeid = rawWoeid === undefined ? undefined : Number(rawWoeid);
999
+ if (woeid !== undefined && (!Number.isInteger(woeid) || woeid < 1 || woeid > 2_147_483_647))
1000
+ throw usage("--woeid must be a positive integer, such as 1 for worldwide or 23424977 for the United States");
1001
+ if (woeid === undefined && parsed.flags.has("--limit"))
1002
+ throw usage("--limit applies only with --woeid; personalized trends come as X returns them");
1003
+ const limit = parseLimit(parsed, 20, 1, WOEID_TRENDS_MAX);
1004
+ const path = woeid === undefined
1005
+ ? "/2/users/personalized_trends?personalized_trend.fields=category,post_count,trend_name,trending_since"
1006
+ : `/2/trends/by/woeid/${woeid}?max_trends=${limit}&trend.fields=trend_name,tweet_count`;
1007
+ const { body } = await get(deps, path);
1008
+ const data = body["data"];
1009
+ // These responses have no meta to count with; a data array, or a body with neither data nor errors, is X's answer.
1010
+ if (!Array.isArray(data) && Array.isArray(body["errors"]))
1011
+ throw unreadable("trends", body);
1012
+ const rows = (Array.isArray(data) ? data : []).map((trend) => woeid === undefined
1013
+ ? { name: String(trend["trend_name"] ?? ""), category: String(trend["category"] ?? ""), posts: String(trend["post_count"] ?? ""), since: String(trend["trending_since"] ?? "") }
1014
+ : { name: String(trend["trend_name"] ?? ""), posts: typeof trend["tweet_count"] === "number" ? trend["tweet_count"] : "unknown" });
1015
+ const partial = Array.isArray(data) && Array.isArray(body["errors"]) ? body["errors"].length : 0;
1016
+ return {
1017
+ ...(woeid === undefined ? { trends_for: "you (personalized)" } : { woeid }),
1018
+ ...(rows.length ? { count: rows.length, trends: rows } : { trends: "0 trends found" }),
1019
+ ...(partial ? { partial_errors: `${partial}: ${errorDetail(body)}` } : {}),
1020
+ ...charge("trends", RATES.trends),
1021
+ help: [rows.length ? "Run `x-axi search <trend name>` for recent posts on one; each result costs about $0.005" : "X billed this request although it returned no trends"],
1022
+ };
1023
+ };
1024
+ const commands = {
1025
+ me: async (args) => {
1026
+ parseArgs(args, "me", []);
1027
+ const account = await refreshAccount();
1028
+ return { account: { id: account.id, handle: `@${account.username}` }, est_cost: money(recorded), help: ["Run `x-axi mentions` to see who mentioned you"] };
1029
+ },
1030
+ following: (args) => listUsers(args, "following"),
1031
+ followers: (args) => listUsers(args, "followers"),
1032
+ "follow-plan": followPlan,
1033
+ timeline: (args) => ownedList(args, "timeline", "tweets"),
1034
+ mentions: (args) => ownedList(args, "mentions", "mentions"),
1035
+ replies: (args) => {
1036
+ const [target, ...rest] = args;
1037
+ const id = parsePostId(target);
1038
+ return recentSearch(`conversation_id:${id}`, "replies", rest);
1039
+ },
1040
+ search: (args) => {
1041
+ const [query, ...rest] = args;
1042
+ if (!query || query.startsWith("--"))
1043
+ throw usage("search needs a query", [HELP["search"]]);
1044
+ return recentSearch(query, "search", rest);
1045
+ },
1046
+ view: async (args) => {
1047
+ const parsed = parseArgs(args, "view", [], ["--full"]);
1048
+ const id = parsePostId(onePositional(parsed, "view"));
1049
+ const found = await fetchPost(id);
1050
+ if (!found)
1051
+ throw new AxiError(`post ${id} not found`, "NOT_FOUND");
1052
+ const metrics = (found.post["public_metrics"] ?? {});
1053
+ // public_metrics comes with tweet.fields and needs no other scope or read. The reference names reposts repost_count; X has also sent retweet_count.
1054
+ const returned = (name, field) => (typeof metrics[field] === "number" ? { [name]: metrics[field] } : {});
1055
+ return {
1056
+ post: {
1057
+ id,
1058
+ author: found.author,
1059
+ created_at: found.post["created_at"],
1060
+ text: truncate(fullText(found.post), parsed.flags.has("--full")),
1061
+ replies: metrics["reply_count"] ?? 0,
1062
+ likes: metrics["like_count"] ?? 0,
1063
+ reposts: metrics["repost_count"] ?? metrics["retweet_count"] ?? 0,
1064
+ ...returned("quotes", "quote_count"),
1065
+ ...returned("bookmarks", "bookmark_count"),
1066
+ ...returned("impressions", "impression_count"),
1067
+ conversation: found.post["conversation_id"],
1068
+ },
1069
+ est_cost: money(recorded),
1070
+ help: [`Run \`x-axi replies ${id}\` for its comments`],
1071
+ };
1072
+ },
1073
+ user: async (args) => {
1074
+ const parsed = parseArgs(args, "user", []);
1075
+ const handle = parseHandle(onePositional(parsed, "user"));
1076
+ const response = await get(deps, `/2/users/by/username/${handle}?user.fields=description,public_metrics`);
1077
+ const data = foundUser(response.body, handle);
1078
+ const metrics = (data["public_metrics"] ?? {});
1079
+ return {
1080
+ user: {
1081
+ id: data["id"],
1082
+ handle: `@${String(data["username"])}`,
1083
+ name: data["name"],
1084
+ bio: truncate(String(data["description"] ?? ""), false),
1085
+ followers: metrics["followers_count"] ?? 0,
1086
+ },
1087
+ ...charge("user", RATES.userRead),
1088
+ };
1089
+ },
1090
+ dm: async (args) => {
1091
+ const [sub, ...rest] = args;
1092
+ if (sub === "send") {
1093
+ const parsed = parseArgs(rest, "dm send", [], [DRY_RUN]);
1094
+ const handle = parseHandle(parsed.positional[0]);
1095
+ const text = parseOutgoingText(parsed.positional[1]);
1096
+ if (parsed.positional.length > 2)
1097
+ throw usage("quote the message text as one argument");
1098
+ const account = await refreshAccount();
1099
+ const target = await resolveUser(handle);
1100
+ if (parsed.flags.has(DRY_RUN)) {
1101
+ return dryRunOutput(`DM to @${target.username}`, account, [{ method: "POST", path: `/2/dm_conversations/with/${target.id}/messages`, body: { text } }], RATES.dmCreate, recorded, linuxDialogReport(approvalRequest(`DM to @${target.username}`, account, { text }, RATES.dmCreate), platform));
1102
+ }
1103
+ await gate(`DM to @${target.username}`, account, { text }, RATES.dmCreate);
1104
+ const check = `Check with \`x-axi dm read ${target.username}\``;
1105
+ const response = await send("POST", `/2/dm_conversations/with/${target.id}/messages`, { text }, "dm_send", RATES.dmCreate, check);
1106
+ const data = response.body["data"];
1107
+ if (!data?.["dm_event_id"])
1108
+ throw outcomeUnknown("X accepted the request but returned no message ID", check);
1109
+ return { dm: { id: data["dm_event_id"], to: `@${target.username}`, status: "sent" }, est_cost: money(RATES.dmCreate) };
1110
+ }
1111
+ if (sub === "list" || sub === "read") {
1112
+ const parsed = parseArgs(rest, `dm ${sub}`, flagsForList, ["--full"]);
1113
+ if (sub === "list" && parsed.positional.length)
1114
+ throw usage("`dm list` takes no arguments");
1115
+ const limit = parseLimit(parsed, 20, 1, 100);
1116
+ const q = `${pagedQuery(limit, parsed.flags.get("--next"))}&dm_event.fields=created_at,sender_id,text&expansions=sender_id&user.fields=username&event_types=MessageCreate`;
1117
+ let path = `/2/dm_events?${q}`;
1118
+ if (sub === "read") {
1119
+ const target = await resolveUser(parseHandle(onePositional(parsed, "dm read")));
1120
+ path = `/2/dm_conversations/with/${target.id}/dm_events?${q}`;
1121
+ }
1122
+ const response = await get(deps, path);
1123
+ const names = usernames(response.body);
1124
+ const rows = (response.body["data"] ?? []).map((event) => ({
1125
+ id: String(event["id"]),
1126
+ author: names.get(String(event["sender_id"])) ?? String(event["sender_id"]),
1127
+ text: truncate(String(event["text"] ?? ""), parsed.flags.has("--full")),
1128
+ at: String(event["created_at"] ?? ""),
1129
+ }));
1130
+ const usd = rows.length * RATES.dmRead + includedUsers(response.body) * RATES.userRead;
1131
+ return listOutput("messages", rows, response.body, "0 messages found", charge("dm", usd));
1132
+ }
1133
+ throw usage("expected `dm list`, `dm read`, or `dm send`", [HELP["dm"]]);
1134
+ },
1135
+ post: async (args) => {
1136
+ const parsed = parseArgs(args, "post", ["--quote", "--media", "--poll", "--poll-minutes"], [DRY_RUN]);
1137
+ const count = parsed.positional.length;
1138
+ if (count > 1 || (count === 0 && !parsed.flags.has("--media")))
1139
+ throw usage("quote the post text as one argument", [HELP["post"]]);
1140
+ const text = count ? parseOutgoingText(parsed.positional[0]) : undefined;
1141
+ const quoteFlag = parsed.flags.get("--quote");
1142
+ const quote = quoteFlag === undefined ? undefined : parsePostId(String(quoteFlag));
1143
+ if (quote && text === undefined)
1144
+ throw usage("--quote needs text");
1145
+ const pollFlag = parsed.flags.get("--poll");
1146
+ if (pollFlag === undefined && parsed.flags.has("--poll-minutes"))
1147
+ throw usage("--poll-minutes needs --poll");
1148
+ if (pollFlag !== undefined && (parsed.flags.has("--media") || quote))
1149
+ throw usage("--poll cannot be combined with --media or --quote; X attaches one kind of thing to a post");
1150
+ const poll = pollFlag === undefined ? undefined : { options: parsePollOptions(String(pollFlag)), duration_minutes: parsePollMinutes(parsed.flags.get("--poll-minutes")) };
1151
+ const media = await prepareMedia(parsed);
1152
+ const account = await refreshAccount();
1153
+ let context;
1154
+ if (quote) {
1155
+ const quoted = await fetchPost(quote);
1156
+ if (!quoted)
1157
+ throw new AxiError(`post ${quote} not found`, "NOT_FOUND");
1158
+ context = `Quoting @${quoted.author}: ${displayText(fullText(quoted.post))}`;
1159
+ }
1160
+ const draft = { ...(text !== undefined ? { text } : {}), ...(quote ? { quote } : {}), ...(media ? { media } : {}), ...(poll ? { poll } : {}), ...(context ? { context } : {}), dryRun: parsed.flags.has(DRY_RUN) };
1161
+ return createPost(draft, account, estimatePostCost(text ?? "", false));
1162
+ },
1163
+ reply: async (args) => {
1164
+ const parsed = parseArgs(args, "reply", ["--media"], [DRY_RUN]);
1165
+ const count = parsed.positional.length;
1166
+ if (count < 1 || count > 2 || (count === 1 && !parsed.flags.has("--media")))
1167
+ throw usage("expected a post and one quoted text argument", [HELP["reply"]]);
1168
+ const id = parsePostId(parsed.positional[0]);
1169
+ const text = count === 2 ? parseOutgoingText(parsed.positional[1]) : undefined;
1170
+ const media = await prepareMedia(parsed);
1171
+ const account = await refreshAccount();
1172
+ const parent = await fetchPost(id);
1173
+ if (!parent)
1174
+ throw new AxiError(`post ${id} not found`, "NOT_FOUND");
1175
+ const usd = estimatePostCost(text ?? "", String(parent.post["author_id"]) !== account.id);
1176
+ const context = `Replying to @${parent.author}: ${displayText(fullText(parent.post))}`;
1177
+ return createPost({ ...(text !== undefined ? { text } : {}), replyTo: id, ...(media ? { media } : {}), context, dryRun: parsed.flags.has(DRY_RUN) }, account, usd);
1178
+ },
1179
+ thread: async (args) => {
1180
+ const parsed = parseArgs(args, "thread", [], [DRY_RUN]);
1181
+ if (parsed.positional.length < 2)
1182
+ throw usage("a thread needs at least two parts, each quoted as one argument", [HELP["thread"]]);
1183
+ const parts = parsed.positional.map((part) => parseOutgoingText(part));
1184
+ const costs = parts.map((part) => estimatePostCost(part, false));
1185
+ const total = costs.reduce((sum, usd) => sum + usd, 0);
1186
+ const content = [
1187
+ `All ${parts.length} parts are published, in this order, each replying to the one before:`,
1188
+ ...parts.flatMap((part, index) => [`Part ${index + 1} of ${parts.length} (${counted(Array.from(part).length, "character")}, ${money(costs[index])}):`, part]),
1189
+ ].join("\n");
1190
+ // The dialog must fit on screen, so the whole thread answers to the same limits as one text.
1191
+ parseOutgoingText(content);
1192
+ const action = `Thread of ${parts.length} posts`;
1193
+ const account = await refreshAccount();
1194
+ if (parsed.flags.has(DRY_RUN)) {
1195
+ const wouldSend = parts.map((text, index) => ({
1196
+ method: "POST",
1197
+ path: "/2/tweets",
1198
+ body: { text, ...(index ? { reply: { in_reply_to_tweet_id: `<id of part ${index}>` } } : {}) },
1199
+ }));
1200
+ return dryRunOutput(action, account, wouldSend, total, recorded, linuxDialogReport(approvalRequest(action, account, { text: content }, total), platform));
1201
+ }
1202
+ // As text, not content: a dialog may clip content, but every part of a thread is published, so it must show whole or be refused.
1203
+ await gate(action, account, { text: content }, total);
1204
+ const check = "Check with `x-axi timeline`";
1205
+ const posted = [];
1206
+ let billed = 0;
1207
+ let previous;
1208
+ for (const [index, text] of parts.entries()) {
1209
+ const usd = costs[index];
1210
+ const body = { text, ...(previous ? { reply: { in_reply_to_tweet_id: previous } } : {}) };
1211
+ let failure;
1212
+ try {
1213
+ const response = await send("POST", "/2/tweets", body, "thread", usd, check);
1214
+ const id = response.body["data"]?.["id"];
1215
+ if (id) {
1216
+ billed += usd;
1217
+ previous = String(id);
1218
+ posted.push({ part: index + 1, id: previous, url: `https://x.com/${account.username}/status/${previous}` });
1219
+ continue;
1220
+ }
1221
+ failure = outcomeUnknown("X accepted the request but returned no post ID", check);
1222
+ }
1223
+ catch (error) {
1224
+ if (!(error instanceof AxiError))
1225
+ throw error;
1226
+ failure = error;
1227
+ }
1228
+ // Nothing is public yet, so this is an ordinary failed post, with its usual code and exit status.
1229
+ if (!posted.length)
1230
+ throw failure;
1231
+ const unknown = failure.code === "OUTCOME_UNKNOWN";
1232
+ if (unknown)
1233
+ billed += usd;
1234
+ return {
1235
+ thread: { posted, total: parts.length, status: "stopped early" },
1236
+ stopped: { part: index + 1, outcome: unknown ? "unknown, it may have been posted" : "not posted", code: failure.code, reason: failure.message, not_attempted: parts.length - index - 1 },
1237
+ est_cost: money(billed),
1238
+ help: [...failure.suggestions, "Do not rerun the whole thread, because the posted parts are public; to continue, reply to the last posted ID"],
1239
+ };
1240
+ }
1241
+ return { thread: { posted, total: parts.length, status: "published" }, est_cost: money(billed) };
1242
+ },
1243
+ repost: (args) => setRepost(args, true),
1244
+ unrepost: (args) => setRepost(args, false),
1245
+ bookmarks: async (args) => {
1246
+ const parsed = parseArgs(args, "bookmarks", flagsForList, ["--full"]);
1247
+ if (parsed.positional.length)
1248
+ throw usage("`bookmarks` takes no arguments");
1249
+ const limit = parseLimit(parsed, 20, 1, 100);
1250
+ await requireScopes(SCOPES_FOR.bookmarks, "`bookmarks`");
1251
+ const account = await me();
1252
+ const q = pagedQuery(limit, parsed.flags.get("--next"));
1253
+ const out = await postPage(`/2/users/${account.id}/bookmarks?${q}&${POST_FIELDS}`, RATES.ownedRead, "bookmarks", "0 bookmarks found", parsed.flags.has("--full"));
1254
+ return { account: `@${account.username}`, ...out, help: [...nextHelp(`x-axi bookmarks --limit ${limit}`, out["next_token"]), "Run `x-axi unbookmark <post>` to remove one, after approval"] };
1255
+ },
1256
+ bookmark: (args) => setBookmark(args, true),
1257
+ unbookmark: (args) => setBookmark(args, false),
1258
+ likes: async (args) => {
1259
+ const parsed = parseArgs(args, "likes", flagsForList, ["--full"]);
1260
+ const positional = onePositional(parsed, "likes");
1261
+ const handle = positional === undefined ? undefined : parseHandle(positional);
1262
+ const limit = parseLimit(parsed, 20, 5, 100);
1263
+ await requireScopes(SCOPES_FOR.likes, "`likes`");
1264
+ const target = await whose(handle);
1265
+ const q = pagedQuery(limit, parsed.flags.get("--next"));
1266
+ const out = await postPage(`/2/users/${target.id}/liked_tweets?${q}&${POST_FIELDS}`, target.own ? RATES.ownedRead : RATES.likeRead, "likes", `0 liked posts found for @${target.username}`, parsed.flags.has("--full"));
1267
+ return { target: `@${target.username}`, ...out, help: nextHelp(`x-axi likes${handle ? ` ${handle}` : ""} --limit ${limit}`, out["next_token"]) };
1268
+ },
1269
+ lists: async (args) => {
1270
+ const parsed = parseArgs(args, "lists", flagsForList);
1271
+ const positional = onePositional(parsed, "lists");
1272
+ const handle = positional === undefined ? undefined : parseHandle(positional);
1273
+ const limit = parseLimit(parsed, 20, 1, LIST_PAGE_MAX);
1274
+ await requireScopes(SCOPES_FOR.listRead, "`lists`");
1275
+ const target = await whose(handle);
1276
+ const { body } = await get(deps, `/2/users/${target.id}/owned_lists?${pagedQuery(limit, parsed.flags.get("--next"))}&list.fields=member_count,private`);
1277
+ const rows = (body["data"] ?? []).map((list) => ({
1278
+ id: String(list["id"]),
1279
+ name: String(list["name"] ?? ""),
1280
+ members: typeof list["member_count"] === "number" ? list["member_count"] : "unknown",
1281
+ private: list["private"] === true,
1282
+ }));
1283
+ const out = listOutput("lists", rows, body, `0 Lists owned by @${target.username}`, charge("lists", rows.length * (target.own ? RATES.ownedRead : RATES.listRead)));
1284
+ return {
1285
+ target: `@${target.username}`,
1286
+ ...out,
1287
+ help: [...nextHelp(`x-axi lists${handle ? ` ${handle}` : ""} --limit ${limit}`, out["next_token"]), rows.length ? "Run `x-axi list view <id>` for a List's posts, or `x-axi list members <id>`" : "Run `x-axi list create <name>` to make one, after approval"],
1288
+ };
1289
+ },
1290
+ list: listCommand,
1291
+ mutes: (args) => ownUsers(args, "mutes", "muting", RATES.muteRead, SCOPES_FOR.mutes),
1292
+ mute: (args) => setMute(args, true),
1293
+ unmute: (args) => setMute(args, false),
1294
+ blocks: (args) => ownUsers(args, "blocks", "blocking", RATES.blockRead, SCOPES_FOR.blocks),
1295
+ trends,
1296
+ delete: async (args) => {
1297
+ const parsed = parseArgs(args, "delete", [], [DRY_RUN]);
1298
+ const id = parsePostId(onePositional(parsed, "delete"));
1299
+ const account = await refreshAccount();
1300
+ const found = await fetchPost(id);
1301
+ if (!found)
1302
+ return { post: { id, status: "already gone (no-op)" } };
1303
+ if (parsed.flags.has(DRY_RUN)) {
1304
+ return dryRunOutput("Delete post", account, [{ method: "DELETE", path: `/2/tweets/${id}` }], RATES.interactionDelete, recorded, { target: displayText(fullText(found.post)) });
1305
+ }
1306
+ await gate("Delete post", account, { content: displayText(fullText(found.post)) }, RATES.interactionDelete);
1307
+ const check = `Check with \`x-axi view ${id}\``;
1308
+ const response = await send("DELETE", `/2/tweets/${id}`, undefined, "delete", RATES.interactionDelete, check);
1309
+ if (response.body["data"]?.["deleted"] !== true) {
1310
+ throw outcomeUnknown("X did not confirm the deletion", check);
1311
+ }
1312
+ return { post: { id, status: "deleted" }, est_cost: money(RATES.interactionDelete) };
1313
+ },
1314
+ follow: async (args) => {
1315
+ const parsed = parseArgs(args, "follow", [], [DRY_RUN]);
1316
+ const handle = parseHandle(onePositional(parsed, "follow"));
1317
+ const account = await refreshAccount();
1318
+ assertFollowLogReadable();
1319
+ const target = await resolveUser(handle);
1320
+ if (parsed.flags.has(DRY_RUN)) {
1321
+ return dryRunOutput("Follow", account, [{ method: "POST", path: `/2/users/${account.id}/following`, body: { target_user_id: target.id } }], RATES.userInteraction, recorded, { target: `@${target.username}` });
1322
+ }
1323
+ await gate("Follow", account, { content: `@${target.username}` }, RATES.userInteraction);
1324
+ const data = await sendFollow(account, target);
1325
+ persistFollowLog(() => logFollow(account.id, target.id));
1326
+ return { follow: { handle: `@${target.username}`, status: data["pending_follow"] === true ? "requested" : "following" }, est_cost: money(RATES.userInteraction) };
1327
+ },
1328
+ unfollow: async (args) => {
1329
+ const parsed = parseArgs(args, "unfollow", [], [DRY_RUN]);
1330
+ const handle = parseHandle(onePositional(parsed, "unfollow"));
1331
+ const account = await refreshAccount();
1332
+ assertFollowLogReadable();
1333
+ const target = await resolveUser(handle);
1334
+ if (parsed.flags.has(DRY_RUN)) {
1335
+ return dryRunOutput("Unfollow", account, [{ method: "DELETE", path: `/2/users/${account.id}/following/${target.id}` }], RATES.interactionDelete, recorded, { target: `@${target.username}` });
1336
+ }
1337
+ await gate("Unfollow", account, { content: `@${target.username}` }, RATES.interactionDelete);
1338
+ const check = checkProfile(target.username);
1339
+ const response = await send("DELETE", `/2/users/${account.id}/following/${target.id}`, undefined, "unfollow", RATES.interactionDelete, check);
1340
+ if (response.body["data"]?.["following"] !== false)
1341
+ throw outcomeUnknown("X did not confirm the unfollow", check);
1342
+ persistFollowLog(() => logUnfollow(account.id, target.id));
1343
+ return { unfollow: { handle: `@${target.username}`, status: "not following" }, est_cost: money(RATES.interactionDelete) };
1344
+ },
1345
+ "follow-file": async (args) => {
1346
+ const parsed = parseArgs(args, "follow-file", ["--max"], [DRY_RUN]);
1347
+ const file = onePositional(parsed, "follow-file");
1348
+ if (!file)
1349
+ throw usage("expected a JSON file", [HELP["follow-file"]]);
1350
+ const raw = parsed.flags.get("--max");
1351
+ const max = raw === undefined ? FOLLOW_BATCH_DEFAULT : Number(raw);
1352
+ if (!Number.isInteger(max) || max < 1 || max > FOLLOW_DAILY_CAP)
1353
+ throw usage(`--max must be an integer from 1 to ${FOLLOW_DAILY_CAP}`);
1354
+ const queue = parseFollowQueue(file);
1355
+ if (!queue.length)
1356
+ return { follow_file: { followed: 0, remaining: 0, status: "the file lists no accounts (no-op)" } };
1357
+ const account = await refreshAccount();
1358
+ const log = readFollows(account.id);
1359
+ const remaining = queue.filter((u) => !log.following[u.id]);
1360
+ if (!remaining.length)
1361
+ return { follow_file: { followed: 0, remaining: 0, status: "every account in this file is already followed through x-axi (no-op)" } };
1362
+ const recent = recentFollows(log.events);
1363
+ const room = FOLLOW_DAILY_CAP - recent.length;
1364
+ if (room <= 0) {
1365
+ throw new AxiError(`the ${FOLLOW_DAILY_CAP} follows per 24 hours limit is used up until ${capReopensAt(recent)}`, "DAILY_LIMIT");
1366
+ }
1367
+ const batch = remaining.slice(0, Math.min(max, room));
1368
+ const usd = batch.length * RATES.userInteraction;
1369
+ const names = batch.slice(0, 10).map((u) => `@${u.username}`).join(", ");
1370
+ const more = batch.length > 10 ? `, and ${batch.length - 10} more` : "";
1371
+ const provenance = `Usernames come from ${shortenPath(displayText(file))} and were not verified with X.`;
1372
+ const action = `Follow ${counted(batch.length, "account")} over about ${counted(Math.ceil((batch.length * FOLLOW_INTERVAL_MS) / 60_000), "minute")}`;
1373
+ if (parsed.flags.has(DRY_RUN)) {
1374
+ return dryRunOutput(action, account, [{ method: "POST", path: `/2/users/${account.id}/following`, body: "one request per account in batch, as {target_user_id}" }], usd, recorded, { batch, remaining_after: remaining.length - batch.length, provenance });
1375
+ }
1376
+ await gate(action, account, { content: `${names}${more}\n\n${provenance}` }, usd);
1377
+ let followed = 0;
1378
+ let requested = 0;
1379
+ let stopped;
1380
+ let stoppedHelp = [];
1381
+ for (const [index, user] of batch.entries()) {
1382
+ if (index > 0)
1383
+ await pause(FOLLOW_INTERVAL_MS);
1384
+ try {
1385
+ const data = await sendFollow(account, user);
1386
+ if (data["pending_follow"] === true)
1387
+ requested += 1;
1388
+ else
1389
+ followed += 1;
1390
+ }
1391
+ catch (error) {
1392
+ if (!(error instanceof AxiError))
1393
+ throw error;
1394
+ stopped = { at: `@${user.username}`, code: error.code, reason: error.message };
1395
+ stoppedHelp = error.suggestions;
1396
+ break;
1397
+ }
1398
+ recent.push(Date.now());
1399
+ try {
1400
+ logFollow(account.id, user.id);
1401
+ }
1402
+ catch (error) {
1403
+ stopped = { at: `@${user.username}`, code: "FOLLOW_LOG_FAILED", reason: `X confirmed this follow, but the log was not updated: ${String(error)}` };
1404
+ break;
1405
+ }
1406
+ }
1407
+ const done = followed + requested;
1408
+ const billable = done + (stopped?.["code"] === "OUTCOME_UNKNOWN" ? 1 : 0);
1409
+ const left = remaining.length - done;
1410
+ const again = `Run \`x-axi follow-file ${file}\``;
1411
+ const stillRecent = recentFollows(recent);
1412
+ let help = [];
1413
+ if (stopped)
1414
+ help = [...stoppedHelp, "Fix the reason above before running again; do not retry in a loop"];
1415
+ else if (left > 0 && stillRecent.length < FOLLOW_DAILY_CAP)
1416
+ help = [`${again} to continue; ${counted(FOLLOW_DAILY_CAP - stillRecent.length, "follow")} left in the rolling 24 hours`];
1417
+ else if (left > 0)
1418
+ help = [`${again} after ${capReopensAt(stillRecent)} to continue`];
1419
+ return {
1420
+ follow_file: { followed, requested, remaining: left, status: stopped ? "stopped early" : "batch complete" },
1421
+ ...(stopped ? { stopped } : {}),
1422
+ est_cost: money(billable * RATES.userInteraction),
1423
+ ...(help.length ? { help } : {}),
1424
+ };
1425
+ },
1426
+ hide: (args) => hideReply(args, true),
1427
+ unhide: (args) => hideReply(args, false),
1428
+ spend: (args) => {
1429
+ parseArgs(args, "spend", []);
1430
+ return { spend: summarizeSpend(), help: ["This is a local upper bound. Billing truth is at https://console.x.com"] };
1431
+ },
1432
+ api: async (args) => {
1433
+ const parsed = parseArgs(args, "api", []);
1434
+ const path = parsed.positional[0];
1435
+ if (parsed.positional.length !== 1 || !path?.startsWith("/2/"))
1436
+ throw usage("expected one path starting with /2/", [HELP["api"]]);
1437
+ const response = await get(deps, path);
1438
+ if (response.body["data"] === undefined && response.body["meta"] === undefined) {
1439
+ throw new AxiError(`X returned no usable result: ${errorDetail(response.body)}`, "X_UNAVAILABLE");
1440
+ }
1441
+ spend("api", null);
1442
+ return response.body;
1443
+ },
1444
+ update: (args) => {
1445
+ parseArgs(args, "update", []);
1446
+ return {
1447
+ update: {
1448
+ installed: VERSION,
1449
+ from_npm: `npm install -g ${NPM_PACKAGE}@latest`,
1450
+ from_source: "git pull && pnpm install --frozen-lockfile && pnpm run build",
1451
+ },
1452
+ help: ["x-axi installs nothing itself; run the line for how you installed it, in your own terminal", "Run `x-axi --version` afterwards to confirm"],
1453
+ };
1454
+ },
1455
+ setup: async (args) => {
1456
+ parseArgs(args, "setup", []);
1457
+ const config = readConfig();
1458
+ const redirectUri = config.redirect_uri ?? DEFAULT_REDIRECT_URI;
1459
+ const status = await deps.auth.status();
1460
+ const account = readAccount();
1461
+ return {
1462
+ account: account ? `@${account.username}` : "not connected",
1463
+ login: status.logged_in ? "stored" : status.problem ? "unknown, see storage_problem" : "none",
1464
+ storage: status.storage,
1465
+ ...(status.problem ? { storage_problem: status.problem } : {}),
1466
+ client_id: config.client_id ? `configured in ${configPath()}` : `not configured; pass --client-id to login, saved to ${configPath()}`,
1467
+ callback_url: redirectUri,
1468
+ steps: setupSteps(redirectUri),
1469
+ ...(status.help ? { help: status.help } : {}),
1470
+ };
1471
+ },
1472
+ login: async (args) => {
1473
+ const parsed = parseArgs(args, "login", ["--client-id"]);
1474
+ if (parsed.positional.length)
1475
+ throw usage("`login` takes only --client-id", [HELP["login"]]);
1476
+ const config = readConfig();
1477
+ const flag = parsed.flags.get("--client-id");
1478
+ const clientId = flag !== undefined ? parseClientId(flag, "--client-id") : config.client_id;
1479
+ if (!clientId)
1480
+ throw usage("no client ID: pass --client-id, or put client_id in the config file", [SETUP_HINT]);
1481
+ const status = await deps.auth.login(clientId);
1482
+ const previous = readAccount();
1483
+ // From here the login is stored, so every later problem is reported as a warning or with that fact attached.
1484
+ try {
1485
+ clearAccount(); // The cache may name the previous login's account.
1486
+ }
1487
+ catch (error) {
1488
+ warnings.add(`cached account not cleared: ${String(error)}`);
1489
+ }
1490
+ if (config.client_id !== clientId) {
1491
+ try {
1492
+ writeConfig({ ...config, client_id: clientId });
1493
+ }
1494
+ catch (error) {
1495
+ warnings.add(`client ID not saved to ${configPath()}: ${String(error)}; the stored login carries it, so refreshes still work`);
1496
+ }
1497
+ }
1498
+ let account;
1499
+ try {
1500
+ account = await refreshAccount();
1501
+ }
1502
+ catch (error) {
1503
+ if (status.revokeReplaced)
1504
+ warnings.add(EARLIER_TOKENS_LIVE);
1505
+ const known = error instanceof AxiError;
1506
+ throw new AxiError(`signed in and stored the login, but could not read the account: ${known ? error.message : String(error)}`, known ? error.code : "X_UNAVAILABLE", [...(known ? error.suggestions : []), "The login is stored; run `x-axi me` once the cause above is fixed"]);
1507
+ }
1508
+ let earlierLogin;
1509
+ if (status.revokeReplaced) {
1510
+ if (previous?.id && previous.id !== account.id) {
1511
+ const { revoke_failed: failed } = await status.revokeReplaced();
1512
+ const failures = Object.entries(failed).map(([kind, reason]) => `${kind}: ${reason}`);
1513
+ earlierLogin = failures.length
1514
+ ? `could not revoke every token of @${previous.username} at X (${failures.join("; ")}); revoke the app at https://x.com/settings/connected_apps while signed in to that account`
1515
+ : `revoked the tokens of @${previous.username} at X`;
1516
+ }
1517
+ else {
1518
+ earlierLogin = EARLIER_TOKENS_LIVE;
1519
+ }
1520
+ }
1521
+ return {
1522
+ login: { account: `@${account.username}`, scopes: status.scopes, access_token_expires_at: status.access_token_expires_at, storage: status.storage },
1523
+ ...(earlierLogin ? { earlier_login: earlierLogin } : {}),
1524
+ est_cost: money(recorded),
1525
+ help: ["Run `x-axi mentions` to see who mentioned you", "Run `x-axi auth status` any time to see this login without an API call"],
1526
+ };
1527
+ },
1528
+ logout: async (args) => {
1529
+ parseArgs(args, "logout", []);
1530
+ const report = await deps.auth.logout();
1531
+ try {
1532
+ clearAccount();
1533
+ }
1534
+ catch (error) {
1535
+ warnings.add(`cached account not cleared: ${String(error)}`);
1536
+ }
1537
+ const failed = Object.entries(report.revoke_failed);
1538
+ return {
1539
+ logout: {
1540
+ stored_login: report.had_login ? "deleted" : "none was stored",
1541
+ revoked_at_x: report.revoked.length ? report.revoked.join(", ") : "nothing",
1542
+ ...(failed.length ? { revoke_failed: Object.fromEntries(failed) } : {}),
1543
+ },
1544
+ ...(failed.length ? { help: ["The local login is gone, but X may still consider the token valid until it expires; revoke the app at https://x.com/settings/connected_apps to be sure"] } : {}),
1545
+ };
1546
+ },
1547
+ auth: async (args) => {
1548
+ const [sub, ...rest] = args;
1549
+ if (sub !== "status")
1550
+ throw usage("expected `auth status`", [HELP["auth"]]);
1551
+ parseArgs(rest, "auth status", []);
1552
+ const status = await deps.auth.status();
1553
+ const account = readAccount();
1554
+ const { help, ...shown } = status;
1555
+ return { auth: { ...(account ? { account: `@${account.username}` } : {}), ...shown }, ...(help ? { help } : {}) };
1556
+ },
1557
+ };
1558
+ return Object.fromEntries(Object.entries(commands).map(([name, run]) => [
1559
+ name,
1560
+ async (args) => {
1561
+ warnings.clear();
1562
+ recorded = 0;
1563
+ try {
1564
+ const output = await run(args);
1565
+ return warnings.size ? { ...output, warning: [...warnings].join("; ") } : output;
1566
+ }
1567
+ catch (error) {
1568
+ if (error instanceof AxiError && warnings.size)
1569
+ throw new AxiError(error.message, error.code, [...error.suggestions, ...warnings]);
1570
+ throw error;
1571
+ }
1572
+ },
1573
+ ]));
1574
+ }
1575
+ function recentFollows(events) {
1576
+ const cutoff = Date.now() - DAY_MS;
1577
+ return events.map((at) => new Date(at).getTime()).filter((at) => at > cutoff).sort((a, b) => a - b);
1578
+ }
1579
+ /** The cap reopens when enough of the oldest follows age out to leave the count under it. */
1580
+ function capReopensAt(recent) {
1581
+ return new Date(recent[recent.length - FOLLOW_DAILY_CAP] + DAY_MS).toISOString();
1582
+ }
1583
+ function parseFollowQueue(file) {
1584
+ let value;
1585
+ try {
1586
+ value = JSON.parse(readFileSync(file, "utf8"));
1587
+ }
1588
+ catch (error) {
1589
+ throw usage(`cannot read ${file}: ${error.message}`);
1590
+ }
1591
+ if (!Array.isArray(value))
1592
+ throw usage(`${file} must be a JSON array`);
1593
+ const queue = new Map();
1594
+ for (const entry of value) {
1595
+ if (typeof entry?.id !== "string" || !/^\d{1,19}$/.test(entry.id) || typeof entry.username !== "string" || !/^\w{1,15}$/.test(entry.username)) {
1596
+ throw usage(`${file} has an entry without a valid id and username`);
1597
+ }
1598
+ if (!queue.has(entry.id))
1599
+ queue.set(entry.id, { id: entry.id, username: entry.username });
1600
+ }
1601
+ return [...queue.values()];
1602
+ }
1603
+ function checkProfile(username) {
1604
+ return `Check the profile at https://x.com/${username}`;
1605
+ }
1606
+ function outcomeUnknown(reason, check) {
1607
+ return new AxiError(`${reason}; the write may or may not have gone through`, "OUTCOME_UNKNOWN", [
1608
+ `${check} before trying again; a blind retry can repeat the write`,
1609
+ ]);
1610
+ }
1611
+ //# sourceMappingURL=commands.js.map