@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shreejit Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,402 @@
1
- # Temporary Holding Version
1
+ # x-axi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [AXI](https://axi.md) CLI for reading X data and managing your X account from an agent session.
4
+ It reads posts, mentions, bookmarks, likes, Lists, mutes, blocks, trends, and DMs, and it posts, replies, threads, bookmarks, mutes, and manages Lists, each write only after you approve it in a native dialog.
5
+ It signs in to X itself with OAuth 2.0 Authorization Code and PKCE as a public client, and keeps the login in your operating system's keystore.
6
+ There is no client secret, no plain-text token file, and no dependency on another CLI.
7
+ Output is [TOON](https://github.com/toon-format/toon), errors carry a code and an exit status, and every priced result shows its estimated cost.
8
+
9
+ ## Quickstart
10
+
11
+ ```sh
12
+ npm install -g @shreejitverma/x-axi
13
+ x-axi setup # prints the X developer portal steps and your current state
14
+ x-axi login --client-id YOUR_CLIENT_ID # in your own terminal; opens X's authorize page
15
+ x-axi auth status # the account, scopes, and expiry, with no API call
16
+ x-axi mentions --limit 5 # $0.001 per post plus $0.01 per author, at most $0.055
17
+ x-axi post "hello from x-axi" --dry-run # the exact request and cost, with no dialog and no write
18
+ ```
19
+
20
+ The npm package exists only after the first publish, which has not happened yet; until then, install from source as [Install](#install) shows.
21
+ The client ID comes from an app you create once at <https://console.x.com>, as [Connect your account](#connect-your-account) describes.
22
+ Pay-per-use API calls are billed to that app, so set a spend limit there first.
23
+
24
+ ## Commands
25
+
26
+ | Group | Commands |
27
+ |---|---|
28
+ | Read | `me`, `timeline`, `mentions`, `replies`, `view`, `search`, `user`, `following`, `followers`, `likes`, `bookmarks`, `lists`, `list view`, `list members`, `mutes`, `blocks`, `trends`, `dm list`, `dm read`, `api`, `spend` |
29
+ | Write, approval required, each takes `--dry-run` | `post` (with `--media`, `--poll`, or `--quote`), `reply`, `thread`, `delete`, `hide`, `unhide`, `repost`, `unrepost`, `follow`, `unfollow`, `follow-file`, `bookmark`, `unbookmark`, `list create`, `list delete`, `list add`, `list remove`, `mute`, `unmute`, `dm send` |
30
+ | Plan, reads X and writes a local file | `follow-plan` |
31
+ | Account | `setup`, `login`, `logout`, `auth status`, `update` |
32
+
33
+ `x-axi` with no arguments shows the cached account and local spend without an API call.
34
+ `x-axi --help` lists the commands, and `x-axi <command> --help` shows its usage and cost.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ npm install -g @shreejitverma/x-axi
40
+ ```
41
+
42
+ The npm package exists only after the first publish, which has not happened yet, so until then install from source as shown below.
43
+ Node 20 or newer.
44
+ The package installs one command, `x-axi`.
45
+ `x-axi update` prints the installed version and the update command for npm and for a git checkout; it installs nothing itself.
46
+
47
+ From source:
48
+
49
+ ```sh
50
+ git clone https://github.com/shreejitverma/x-axi.git
51
+ cd x-axi && pnpm install --frozen-lockfile && pnpm run build && npm link
52
+ ```
53
+
54
+ Building from source uses pnpm 11, which itself needs Node 22.13 or newer; on an older Node, install pnpm's standalone `@pnpm/exe` build, which brings its own Node.
55
+ To update a checkout, run `git pull && pnpm install --frozen-lockfile && pnpm run build`.
56
+
57
+ ## Connect your account
58
+
59
+ `x-axi setup` prints these steps with your current state.
60
+
61
+ 1. At <https://console.x.com> create a project and an app on Pay-per-use and Production.
62
+ Add credits and set a spend limit; the limit is the only hard cap on what the API can bill.
63
+ 2. In the app's User authentication settings turn on OAuth 2.0.
64
+ Set App permissions to "Read and write and Direct message" and Type of App to "Native App", which is a public client.
65
+ 3. Set the Callback URI to exactly `http://127.0.0.1:8711/callback`, add any https website URL you own, and save.
66
+ X matches the callback character for character, so a different port or `localhost` in place of `127.0.0.1` fails the login.
67
+ 4. On the Keys and tokens page copy the OAuth 2.0 Client ID.
68
+ If the portal also shows a Client Secret, the type is not Native App; x-axi never uses a secret.
69
+ 5. In your own terminal, not through an agent, run `x-axi login --client-id YOUR_CLIENT_ID`.
70
+ A browser opens on X's authorize page; approve it, and the tab says you can close it.
71
+ 6. Verify with `x-axi auth status`, then `x-axi me`.
72
+
73
+ The client ID is not a secret.
74
+ `login` saves it to `~/.config/x-axi/config.json` (or `$XDG_CONFIG_HOME/x-axi/config.json`), so later runs need no flag.
75
+ If port 8711 is taken, set `redirect_uri` in the same file to another `http://127.0.0.1` URL with a port from 1024 to 65535.
76
+ Register that exact URI as the Callback URI in the X portal as well, because X refuses a login whose callback differs; `x-axi setup` prints the URI in use.
77
+
78
+ ### Scopes
79
+
80
+ x-axi requests these scopes: `tweet.read tweet.write tweet.moderate.write users.read follows.read follows.write media.write dm.read dm.write bookmark.read bookmark.write like.read list.read list.write mute.read mute.write block.read offline.access`.
81
+ The commands use all of them, and the raw `x-axi api` command can reach only endpoints they cover.
82
+
83
+ | Scope | Used by |
84
+ |---|---|
85
+ | `tweet.read`, `users.read` | every command that reads or writes |
86
+ | `tweet.write` | `post`, `reply`, `thread`, `delete`, `repost`, `unrepost` |
87
+ | `tweet.moderate.write` | `hide`, `unhide` |
88
+ | `follows.read`, `follows.write` | `following`, `followers`, `follow-plan`; `follow`, `unfollow`, `follow-file` |
89
+ | `media.write` | `post --media`, `reply --media` |
90
+ | `dm.read`, `dm.write` | `dm list`, `dm read`; `dm send` |
91
+ | `bookmark.read`, `bookmark.write` | `bookmarks`; `bookmark`, `unbookmark` |
92
+ | `like.read` | `likes` |
93
+ | `list.read`, `list.write` | `lists`, `list view`, `list members`; `list create`, `list delete`, `list add`, `list remove` |
94
+ | `mute.read`, `mute.write` | `mutes`; `mute`, `unmute` |
95
+ | `block.read` | `blocks` |
96
+ | `offline.access` | the refresh token |
97
+
98
+ A login made before a scope was added keeps working for everything else.
99
+ A command that needs a scope the login lacks stops with `MISSING_SCOPE` before any API call, spend, or dialog, and says to run `x-axi login` again in your own terminal.
100
+ This applies to `media.write` and to the bookmark, like, List, mute, and block scopes, which version 0.3.0 added.
101
+
102
+ ### Upgrading from a version that used xurl
103
+
104
+ Earlier versions called the `xurl` CLI, which keeps a token and a client secret in plain text at `~/.xurl/auth.yml`.
105
+ x-axi no longer reads or writes that directory.
106
+ Those versions registered a Web App (confidential client) with the callback `http://localhost:8080/callback`, and x-axi cannot sign in through that app as it stands.
107
+
108
+ 1. In the app's User authentication settings change Type of App to "Native App", and set the Callback URI to the one `x-axi setup` prints.
109
+ 2. Copy the OAuth 2.0 Client ID from the Keys and tokens page, and run `x-axi login --client-id YOUR_CLIENT_ID` in your own terminal.
110
+ 3. Delete `~/.xurl` if nothing else on the machine uses xurl.
111
+
112
+ The spend ledger, cached account, and follow log in `~/.local/state/x-axi` keep working unchanged.
113
+
114
+ ## Using the commands
115
+
116
+ Reads use the account cached by `login`, the last `me`, or the last write, and timeline, mentions, search, bookmarks, mutes, and blocks output name it in `account`.
117
+ `auth status` shows the account, scopes, access token expiry, and the storage backend without an API call, and never a token.
118
+ `logout` revokes the access and refresh tokens at `https://api.x.com/2/oauth2/revoke`, then deletes the stored login and the cached account.
119
+ If revocation fails the deletion still happens and the output says which revocation failed.
120
+
121
+ List results show a few fields per item and a `count`, an empty result says so in words, such as `0 bookmarks found`, and a page with more after it gives a `next_token` and the command to continue.
122
+ Long post and DM text is truncated with its full length; `--full` shows all of it.
123
+
124
+ ### Posts and metrics
125
+
126
+ `x-axi view <post>` shows the post with its public metrics: replies, likes, and reposts, and quotes, bookmarks, and impressions when X returns them.
127
+ They come with the post read itself, at no extra cost and with no extra scope.
128
+ `timeline`, `mentions`, `search`, `replies`, `bookmarks`, `likes`, and `list view` show replies and likes per post.
129
+
130
+ ### Bookmarks and likes
131
+
132
+ `x-axi bookmarks` shows your bookmarked posts, `--limit` from 1 to 100.
133
+ `x-axi bookmark <post>` and `x-axi unbookmark <post>` add and remove one, each after a dialog that shows the post.
134
+ `x-axi likes [handle]` shows posts an account liked, yours without a handle, `--limit` from 5 to 100.
135
+ x-axi cannot like or unlike, as [X platform limits](#x-platform-limits) explains.
136
+
137
+ ### Lists
138
+
139
+ `x-axi lists [handle]` shows the Lists an account owns, yours without a handle, with each List's ID, name, member count, and whether it is private.
140
+ `x-axi list view <list>` shows a List's posts and `x-axi list members <list>` its members, `--limit` from 1 to 100.
141
+ A `<list>` is a List ID or a URL such as `https://x.com/i/lists/123`.
142
+
143
+ - `x-axi list create <name> [--description TEXT] [--private]` creates a List; the name is 1 to 25 characters and the description at most 100, each on one line.
144
+ Without `--private` the List is public, and the request says `private: false`.
145
+ - `x-axi list delete <list>` deletes one of your Lists; a List that is already gone is a no-op.
146
+ - `x-axi list add <list> <handle>` and `x-axi list remove <list> <handle>` change its members.
147
+
148
+ Delete, add, and remove first read the List so the dialog can show its name, visibility, and member count, and add and remove look up the handle so the dialog names the member.
149
+
150
+ ### Mutes and blocks
151
+
152
+ `x-axi mutes` and `x-axi blocks` show the accounts you muted and blocked, `--limit` from 1 to 1000.
153
+ `x-axi mute <handle>` and `x-axi unmute <handle>` change a mute after approval.
154
+ There is no `block` or `unblock`, because X offers them only on Enterprise.
155
+
156
+ ### Trends
157
+
158
+ `x-axi trends` shows trends personalized for you, with each trend's name, category, post count, and when it started trending.
159
+ `x-axi trends --woeid N [--limit 1-50]` shows trends for a place by its Yahoo WOEID, such as `1` for worldwide or `23424977` for the United States, with each trend's name and post count.
160
+ X bills each request the same, however many trends come back.
161
+
162
+ ### Polls
163
+
164
+ `x-axi post <text> --poll "Tea|Coffee|Water" [--poll-minutes 5-10080]` attaches a poll of 2 to 4 options, each 1 to 25 characters, open for `--poll-minutes`, default 1440, which is one day.
165
+ Options are separated by `|`, the spaces around each are trimmed, and an option cannot itself contain `|`.
166
+ A poll needs text and cannot be combined with `--media` or `--quote`.
167
+ The dialog's first line shows every option, quoted, and the duration, and a dry run shows them under `dry_run.poll`.
168
+
169
+ ### Reading following and followers
170
+
171
+ `x-axi following [handle]` and `x-axi followers [handle]` show the list's total and one page, `--limit` from 1 to 1000, continued with `--next`.
172
+ Without a handle they read your own list.
173
+ `--all --out file.json` exports every page to a JSON array of `{id, username, name, protected}`.
174
+ The export is unfiltered, so it can hold accounts you already follow; build a follow queue with `follow-plan`, not from an export.
175
+ A read estimated above $1 refuses before reading any page, and names the total and the cost, unless `--max-spend <usd>` covers it.
176
+ `--all` with `--next` cannot be priced in advance, so it does not refuse up front; it stops at the cap, keeps what it read, and prints the command to continue.
177
+ Every read sizes its page to what the cap still allows, so a wrong total from X cannot spend past it, and a page cut short this way still gives a `next_token`.
178
+ If the cap, a rate limit, or an error stops an export after its first page, the file holds what was paid for and the output gives the `next_token` to continue into a new file.
179
+ The export reports `complete: true` only if the read did not stop and X reported no partial errors.
180
+ `--out` never overwrites a file, and it creates exports and queues readable only by you, because they can hold lists X shows only to approved viewers.
181
+
182
+ ### Building a follow queue
183
+
184
+ `x-axi follow-plan <handle> --out queue.json` builds a queue for `follow-file`, and it is how "do nothing if I already follow the account" is met.
185
+ It reads that account's following and your own following once, removes accounts you already follow and yourself, and writes the rest in the format `follow-file` reads.
186
+ It sends nothing to X and asks for no approval, because it only reads.
187
+
188
+ - The two lookups that give the totals cost about $0.02, and the output and any refusal state the cost.
189
+ - Above $1 it refuses without a `--max-spend <usd>` that covers the estimate, before reading either list.
190
+ - Protected accounts go to `queue.protected.json` rather than the queue, unless you pass `--include-protected`.
191
+ - A read of either list that stops early writes no queue, and neither do partial errors on your own list, because such a queue could hold accounts you already follow.
192
+ - If X lists fewer of your follows than its own count, or reports partial errors on the other account's list, the queue is still written and the output carries a warning.
193
+
194
+ The queue is correct when it is built.
195
+ Follows you make elsewhere afterwards are not in it, and `follow-file` skips only accounts this login followed through x-axi.
196
+
197
+ ### Threads
198
+
199
+ `x-axi thread "first" "second" ...` posts each part as a reply to the part before it, after one approval dialog that shows every part and the total cost.
200
+ Each part is priced on its own, so a part with a URL costs $0.20.
201
+ The whole thread must fit the same review limits as one text, 4000 characters and 40 lines of dialog, which is about 19 short parts.
202
+ The Linux dialog has less room, about 8 short parts, because it may not scroll.
203
+ If a part fails, nothing further is sent and nothing is retried.
204
+ The output lists the posted IDs, the failed part, and whether it was `not posted` or `unknown`.
205
+ Do not rerun a stopped thread, because the posted parts are public.
206
+
207
+ ### Media
208
+
209
+ `post --media <file>` and `reply <id> <text> --media <file>` attach one file; the text may be left out.
210
+
211
+ | Type | Detected from the file's bytes | Limit checked locally |
212
+ |---|---|---|
213
+ | Image | JPEG, PNG, WebP | 5 MiB |
214
+ | Animated GIF | GIF | 15 MiB |
215
+ | Video | MP4, MOV, WebM | 16 GiB |
216
+
217
+ A file's extension must agree with its bytes.
218
+ MP4 means a plain MP4 brand and WebM means the WebM DocType, so HEIC, AVIF, M4A, 3GP, and other Matroska files are refused.
219
+ These checks run before any API call, and the dialog shows the file's path, type, and size, eliding the middle of a path over 120 characters.
220
+ The upload runs only after approval, through `POST /2/media/upload/initialize`, then `POST /2/media/upload/{id}/append` in multipart chunks of at most 5,000,000 bytes indexed from 0, then `POST /2/media/upload/{id}/finalize`, then `GET /2/media/upload` while X reports processing, for at most 10 minutes.
221
+ A failed chunk stops the upload and is not retried, and the error says nothing was posted.
222
+ A file that changes after approval is refused.
223
+ x-axi does not know a video's duration or the account's Premium status, so X judges those: 8 GB and 20 minutes by default, 16 GB and 125 minutes with Premium or verification.
224
+ X checks limits again when media is attached, so a finished upload can still be refused at the post, as the error then says.
225
+
226
+ ### Dry runs
227
+
228
+ `--dry-run` on any write runs the validation and prints the exact requests and the write's estimated cost, with no approval and no write.
229
+ On Linux, if the text would not fit the Linux dialog, the output carries `dry_run.linux_dialog`, because the real run refuses that text with `APPROVAL_TEXT_TOO_LONG`.
230
+ macOS and other systems never show that dialog, so their dry runs leave it out.
231
+ Reads needed to validate it still happen and are billed, such as the account read, the parent post on a reply, the post on a bookmark, and the List and member lookups on a List write, and they appear as `est_cost`.
232
+ Media is inspected locally and no byte is uploaded.
233
+
234
+ ## Where the login lives
235
+
236
+ The access token, refresh token, expiry, scopes, and client ID are stored as one record in the OS keystore, behind a small interface with no plain-file implementation.
237
+
238
+ - macOS: the login keychain, through `/usr/bin/security` at its absolute path.
239
+ The record is written through `security -i`, which reads the command from stdin, so it never appears in `ps` or shell history.
240
+ - Linux: the Secret Service (GNOME Keyring, KeePassXC, and others) through `secret-tool`, which reads the secret from stdin.
241
+ Without `secret-tool` or a running Secret Service, every command that needs a token fails with `KEYSTORE_UNAVAILABLE` and the install steps; nothing falls back to a file.
242
+ - Windows: not supported; the error says so.
243
+
244
+ Access tokens last two hours.
245
+ Before each API call x-axi reads the record and, within two minutes of expiry, refreshes it at `https://api.x.com/2/oauth2/token` with `client_id` in the form body.
246
+ A file lock in the state directory (`auth.lock`, carrying a random owner token and touched every five seconds while held) serialises refreshes, logins, and logouts across x-axi processes, so a single-use refresh token is spent once and a logout cannot be undone by a refresh that was already in flight.
247
+ A lock untouched for a minute is treated as left by a crashed process and claimed atomically.
248
+ The new record is written to the keystore before the new token is used; if that write fails after X has already rotated the token, the command says so and asks for a new login rather than hiding it, and makes one attempt to revoke the tokens it could not keep.
249
+ When `login` replaces a stored login for a different account, it revokes the old tokens after saving the new ones and reports whether that worked.
250
+ When it replaces a login for the same account, or the old account is not known because no account is cached or the new one could not be read, it leaves the old tokens live and says so, because whether revoking them would also end the new grant is unverified.
251
+ After a suspected leak, run `x-axi logout` before `x-axi login`, which revokes the tokens while x-axi still holds them; once they are replaced, only revoking the app at <https://x.com/settings/connected_apps> ends them.
252
+ `login` reads the keystore before it opens the browser, so a missing or locked keystore stops it before any consent, and if the final save still fails it makes the same attempt to revoke and reports the result.
253
+ If X rejects the refresh token, the command fails with `AUTH_REQUIRED` and the stored login is left in place for you to inspect with `auth status` or remove with `logout`; nothing is cleared silently and nothing is retried.
254
+ A network failure, a rate limit, or an unusable answer from the token endpoint is reported as `X_UNAVAILABLE` and changes nothing.
255
+ Error messages have every known token removed before they are printed.
256
+
257
+ ### Threat model
258
+
259
+ The keystore protects the tokens at rest.
260
+ On macOS they are encrypted in the keychain database rather than sitting in a readable file, so a copied home directory, a backup, a stray `cat`, or a tool that uploads dotfiles does not expose them.
261
+ Items added by `security` are not marked synchronizable, so they are not sent to iCloud Keychain.
262
+
263
+ The keystore does not protect the tokens from other code running as you.
264
+ x-axi stores the item with the keychain's default access control, which trusts the creating application, `/usr/bin/security`.
265
+ In an isolated test keychain created under a temporary directory, a different process (`python3` spawning `security find-generic-password -w`) read the item with no prompt.
266
+ Any same-user process can do the same, because the trusted application is a general-purpose system tool.
267
+ The alternative, an item with no trusted application (`-T ""`), would make macOS ask for confirmation on every read, including x-axi's own, which agent runs cannot answer and which trains the user to click Allow; that behaviour is taken from the `security` manual and was not exercised, because exercising it would have opened a dialog.
268
+ Direct reads through the Security framework from a binary that is not `/usr/bin/security` should prompt under the default access control according to Apple's documentation; this was not verified for the same reason.
269
+ The item is also only as locked as the login keychain, which is unlocked while you are logged in.
270
+
271
+ On Linux the same holds: any process in your session that can talk to the Secret Service can read the item.
272
+
273
+ The approval dialog on writes is therefore the gate, and it is not a sandbox.
274
+ A process that can run `security` or `secret-tool` as you can act as you on X with these scopes.
275
+ Keep spend limits set in the console.
276
+ Report a vulnerability as [SECURITY.md](SECURITY.md) describes.
277
+
278
+ ## Safety model
279
+
280
+ Every write opens a native dialog showing the account, the target, and estimated cost: the parent post on a reply, the post on a bookmark or repost, the List and member on a List write, and the account on a follow or mute.
281
+ Outgoing text appears in full under a tool-written header with its exact character and line counts, so all of those lines are sent, whatever they say about themselves.
282
+ Invisible characters are refused except where a language or emoji needs them: zero-width joiners in emoji and Indic script text, zero-width non-joiners in Arabic and Indic script text, tag characters in subdivision flags, and direction marks in right-to-left text.
283
+ Bidi embeddings, overrides, and isolates are always refused, in posts, DMs, poll options, and List names and descriptions alike.
284
+ The gate lives in the tool, so it applies under every harness and permission mode, including unattended runs.
285
+ It fails closed: Cancel, a 120-second timeout, a crash, or a missing dialog sends nothing.
286
+ There is no terminal prompt, flag, or environment override, because an agent could answer or set those.
287
+ Writes are never retried, because a retry after a lost response could publish twice.
288
+ A failed response that might have been applied returns `OUTCOME_UNKNOWN`, and so does a success that does not confirm the change, such as a bookmark answer without `bookmarked: true`.
289
+
290
+ ## X platform limits
291
+
292
+ Checked on 2026-11-03 against the [changelog](https://docs.x.com/changelog), [pricing](https://docs.x.com/x-api/getting-started/pricing), [OAuth 2.0 documentation](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code), and the API reference page of each endpoint x-axi calls, such as [create post](https://docs.x.com/x-api/posts/create-post).
293
+
294
+ - API replies work only on your own posts, or when the post's author mentioned or quoted you.
295
+ Otherwise `reply` returns `REPLY_NOT_SUMMONED` with a browser link holding your text.
296
+ - The April 16, 2026 changelog entry says following, likes, and quote-posts were removed from all self-serve tiers.
297
+ A live follow on a Pay-per-use app still succeeded on 2026-10-31, so following may stop working without notice.
298
+ - x-axi has no `like` or `unlike`, because of that entry.
299
+ Reading liked posts with `likes` is still documented and priced (Like: Read, and Owned Reads for your own), so it stays, though X may refuse it on self-serve.
300
+ - `post --quote` exists, but the create post reference says `quote_tweet_id` requires an Enterprise plan, so expect `FORBIDDEN` on self-serve; it is untested live.
301
+ - `repost` and `unrepost` use documented endpoints that the removal entry does not name, and the [API overview](https://docs.x.com/x-api/overview) says its endpoints are on pay-per-use, though it also lists likes.
302
+ No page names reposts on pay-per-use specifically and the pricing page has no line for them, so they are untested and unpriced.
303
+ - x-axi has no `block` or `unblock`.
304
+ The [Blocks introduction](https://docs.x.com/x-api/users/blocks/introduction) lists `GET /2/users/:id/blocking` as Pay-per-use and Enterprise, and `POST /2/users/:id/blocking` and `DELETE /2/users/:source_user_id/blocking/:target_user_id` as Enterprise only; the [Block User reference](https://docs.x.com/x-api/users/block-user) says the same in the [API index](https://docs.x.com/x-api/llms.txt).
305
+ - The [Personalized Trends introduction](https://docs.x.com/x-api/trends/personalized-trends/introduction) lists a Premium user subscription as a requirement, so `trends` without `--woeid` may return `FORBIDDEN` on an account without X Premium.
306
+ - The [Trends by WOEID reference](https://docs.x.com/x-api/trends/get-trends-by-woeid) lists OAuth 2.0 user context and an app Bearer Token, while its [introduction](https://docs.x.com/x-api/trends/trends-by-woeid/introduction) shows only the app token; x-axi sends the user token, which is untested live.
307
+ - Bookmarks, liked posts, Lists, mutes, blocks, trends, `post --poll`, and the quote, bookmark, and impression counts in `view` were added on 2026-11-03 from their API references, and none of them has been run against live X.
308
+ - [Following](https://help.x.com/en/using-x/x-follow-limit) is limited to 400 per day and 50 per 15 minutes, and X may lock accounts for aggressive following.
309
+ `follow-file` asks once per batch, follows every 20 seconds, caps itself at 400 in 24 hours, and stops at the first error.
310
+ Unfollowing does not give back room under that cap, and a follow with an unknown outcome uses room because X may have applied it.
311
+ - `follow-file` treats an account as already followed only if the logged-in account followed it through x-axi, and it does not read X's following list, because each listed account is billed.
312
+ Build the queue with `x-axi follow-plan`, which removes accounts you already follow, or those accounts are followed and billed again.
313
+ The approval dialog shows usernames from the file, which are not verified with X; the follow goes to each entry's `id`.
314
+ - `replies` and `search` use recent search, which covers 7 days.
315
+ - `dm` uses the legacy DM endpoints and does not cover encrypted XChat conversations.
316
+ - The OAuth documentation does not say whether refresh tokens rotate, so x-axi treats each as single-use and stores whatever X returns.
317
+
318
+ ## Platform support for writes
319
+
320
+ | Platform | Approval dialog | Default button |
321
+ |---|---|---|
322
+ | macOS | `/usr/bin/osascript` | Cancel |
323
+ | Linux desktop | `zenity`, or `kdialog` if `zenity` is missing | Cancel on `zenity`; not Cancel on `kdialog` |
324
+ | Linux without a graphical session, such as plain SSH or CI | None, so writes return `APPROVAL_UNAVAILABLE` naming what to install | |
325
+ | Windows and other systems | None, so writes return `APPROVAL_UNSUPPORTED_PLATFORM` | |
326
+
327
+ Reads work on every platform that has a keystore, so on Linux they need the Secret Service but no dialog.
328
+
329
+ On Linux:
330
+
331
+ - A graphical session means `DISPLAY` or `WAYLAND_DISPLAY` is set, so SSH with X forwarding counts and shows the dialog on the forwarded display.
332
+ - The tools are found only in `/usr/bin`, `/bin`, and `/usr/local/bin`, never through `PATH`, so copies from Nix, Flatpak, or Snap are not used.
333
+ - The tool gets a short list of display and locale variables and nothing else, because variables such as `ZENITY_CANCEL=0` or `LD_PRELOAD` could turn a Cancel into an approval.
334
+ - `kdialog` has no flag for the default button, so pressing Enter may approve; install `zenity` if you can.
335
+ - `kdialog` has no plain text mode, so x-axi sends it escaped rich text, and it has no timeout flag, so x-axi kills it at the timeout.
336
+ - The dialog holds about 25 lines of 50 columns.
337
+ That is a conservative estimate rather than a measurement of the window, and macOS does not apply it.
338
+ - A word or URL wider than a line is split in the dialog with a `↵` mark at each break, and the dialog counts the breaks; the text sent to X is unchanged.
339
+ - Text you did not write for this action, such as a parent post, a post you are deleting, hiding, or bookmarking, or the path of a `follow-file` queue, is cut to fit with a marker giving its full character count, and never blocks the write.
340
+ - Text to send must be shown in full to be approved, so text too long to fit once wrapped returns `APPROVAL_TEXT_TOO_LONG` instead of a dialog.
341
+ The text itself has room for about 19 rows, or about 950 characters of plain text, because the rest of the dialog is held for its other lines and any context.
342
+ The rows of a split URL count toward that limit.
343
+ A `--media` path is shown in the dialog's first lines, so it takes from that room: about 17 rows are left with a short path, and about 13 with a 120-character path.
344
+ A poll's options are shown in those first lines too, and take from that room the same way.
345
+ - A `zenity` that cannot open its display exits with the same code as Cancel, so x-axi reads its error output and returns `APPROVAL_UNAVAILABLE` rather than reporting a cancel nobody made.
346
+ - The gate is weaker than on macOS.
347
+ Any process running as you can click in an X11 dialog, or set `DISPLAY` to an X server it controls and click there.
348
+
349
+ CI runs the suite on Ubuntu and macOS on Node 20 and 24.
350
+ It tests the Linux dialogs with fake executables that record their arguments and exit with a chosen code, so no real click reaches `zenity` or `kdialog` in CI.
351
+ The flags were checked against the help output of `zenity` 4.0.1 and `kdialog` 23.08 and against their upstream source on 2026-11-01.
352
+ Neither real Linux dialog has been opened by the test suite, so how they look on your desktop is unverified.
353
+ CI installs the standalone pnpm build described under [Install](#install).
354
+
355
+ ## Cost
356
+
357
+ Each priced result shows `est_cost`, and `x-axi spend` totals them locally.
358
+ A write's `est_cost` is the write alone; the lookups before its dialog appear only in `spend`.
359
+ These are upper bounds, because X does not re-bill a resource within a UTC day and may change its rates.
360
+
361
+ | What | Rate used |
362
+ |---|---|
363
+ | A post read (`view`, `search`, `replies`, `list view`, and the post shown on a write) | $0.005 per post |
364
+ | A user read (`me`, `user`, a handle lookup, each author X includes, `list members`) | $0.010 per account |
365
+ | Following and followers lists | $0.010 per account |
366
+ | Another account's liked posts | $0.001 per post (Like: Read) |
367
+ | Another account's Lists, and the List read before a List write | $0.005 per List |
368
+ | Mutes and blocks | $0.001 per account |
369
+ | Your own timeline, mentions, bookmarks, liked posts, Lists, and following and followers | $0.001 per item (Owned Reads) |
370
+ | `trends` | $0.010 per request |
371
+ | `post`, `reply`, each `thread` part | $0.015, $0.20 with a URL, $0.010 for a summoned reply |
372
+ | `follow`, `mute` | $0.015 |
373
+ | `delete`, `unfollow` | $0.010 |
374
+ | `bookmark`, `unbookmark` | $0.005 |
375
+ | `list create` | $0.010 |
376
+ | `list delete`, `list add`, `list remove` | $0.005 (List: Manage) |
377
+ | `unmute` | $0.005 (Mute: Delete) |
378
+ | `dm send`; DM reads | $0.015; $0.010 per message |
379
+
380
+ The `hide`, `unhide`, `repost`, `unrepost`, media upload, and `api` calls have no estimate, so they add nothing to these totals and `spend` counts them in `unpriced_calls_30d`.
381
+ The [pricing page](https://docs.x.com/x-api/getting-started/pricing) lists no price for media upload or reposting, so a post with media shows the price of the post alone.
382
+ X gives the Owned Read rate only when `{id}` is the logged-in account and that account also owns the app, so under another app's login those estimates are low.
383
+ A write with an unknown outcome is counted, because it may have been billed.
384
+ `login` ends with one account read, about $0.01, so the approval dialogs can name the account the token belongs to.
385
+ The console is the billing truth, and its spend limit is the only hard cap.
386
+
387
+ ## Test
388
+
389
+ ```sh
390
+ pnpm run build && pnpm test
391
+ X_AXI_TEST_KEYCHAIN=1 pnpm test # macOS: also round-trips a record through a temporary keychain under a temp directory, never the login keychain
392
+ X_AXI_TEST_DIALOG=1 pnpm test # macOS: also opens a real dialog for one second and lets it expire
393
+ ```
394
+
395
+ The suite runs the login flow against a local fake of X's OAuth endpoints with an injected in-memory keystore, checks PKCE against the RFC 7636 appendix B vector, and asserts that the real backends' commands carry the secret on stdin and never on argv.
396
+ Commands run against fakes of X that answer with the shapes in each endpoint's API reference, and no test calls live X.
397
+ Under `X_AXI_API_BASE`, which may only point at loopback, the keystore is never read, a fixed test token is sent, and the login's scopes are unknown, so `MISSING_SCOPE` checks let commands through.
398
+ [CONTRIBUTING.md](CONTRIBUTING.md) has the rules for tests, commits, and releases.
399
+
400
+ ## License
401
+
402
+ [MIT](LICENSE)
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+ await main();
4
+ //# sourceMappingURL=x-axi.js.map
@@ -0,0 +1,112 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ export const SETUP_HINT = "Run `x-axi setup` for the login steps";
3
+ const X_API_BASE = "https://api.x.com";
4
+ /** The override exists for the end-to-end test. It may only target loopback, and it never receives the real token. */
5
+ function overrideBase() {
6
+ const override = process.env["X_AXI_API_BASE"];
7
+ if (!override)
8
+ return undefined;
9
+ if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(override)) {
10
+ throw new AxiError("X_AXI_API_BASE may only point at loopback", "VALIDATION_ERROR");
11
+ }
12
+ return override;
13
+ }
14
+ function parseBody(text) {
15
+ if (!text)
16
+ return {};
17
+ try {
18
+ const value = JSON.parse(text);
19
+ if (value && typeof value === "object" && !Array.isArray(value))
20
+ return value;
21
+ }
22
+ catch {
23
+ // Reported below like any other unusable body.
24
+ }
25
+ return { detail: `unusable response: ${text.slice(0, 200)}` };
26
+ }
27
+ /** `accessToken` is asked for a token only when the request really goes to X; under the loopback override it is never called. */
28
+ export function createHttpClient(accessToken) {
29
+ return {
30
+ async request(method, path, body) {
31
+ const override = overrideBase();
32
+ const base = override ?? X_API_BASE;
33
+ const token = override ? "x-axi-test-token" : await accessToken();
34
+ // A malformed value must never reach a header, whose errors echo it.
35
+ if (!/^[\x21-\x7e]+$/.test(token))
36
+ throw new AxiError("the stored access token is malformed", "AUTH_REQUIRED", ["Run `x-axi logout`, then `x-axi login`"]);
37
+ return send(method, `${base}${path}`, token, body);
38
+ },
39
+ };
40
+ }
41
+ async function send(method, url, token, body) {
42
+ let response;
43
+ let text;
44
+ const multipart = body instanceof FormData;
45
+ try {
46
+ response = await fetch(url, {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ // fetch writes the multipart header itself, because it carries the boundary.
51
+ ...(body && !multipart ? { "Content-Type": "application/json" } : {}),
52
+ },
53
+ ...(body ? { body: multipart ? body : JSON.stringify(body) } : {}),
54
+ // A followed redirect would replay a write and carry the token elsewhere.
55
+ redirect: "manual",
56
+ // A 5 MB chunk on a slow uplink takes far longer than a JSON request.
57
+ signal: AbortSignal.timeout(multipart ? 300_000 : 30_000),
58
+ });
59
+ text = await response.text();
60
+ }
61
+ catch (error) {
62
+ // Only the failure's name: fetch errors can quote request headers.
63
+ const cause = error.cause?.code;
64
+ throw new Error(`network failure: ${cause ?? error.name}`);
65
+ }
66
+ const parsed = parseBody(text);
67
+ if (response.status === 429) {
68
+ const reset = Number(response.headers.get("x-rate-limit-reset"));
69
+ parsed["reset_at"] = reset ? new Date(reset * 1000).toISOString() : "unknown";
70
+ }
71
+ return { status: response.status, body: parsed };
72
+ }
73
+ export function errorDetail(body) {
74
+ const nested = Array.isArray(body["errors"]) ? body["errors"][0] : undefined;
75
+ const value = body["detail"] ?? body["title"] ?? nested?.["message"] ?? nested?.["detail"];
76
+ return typeof value === "string" ? value : JSON.stringify(body).slice(0, 300);
77
+ }
78
+ /** Translates X failures into codes an agent can act on. */
79
+ export function toApiError(response, context = {}) {
80
+ const detail = errorDetail(response.body);
81
+ const { status } = response;
82
+ if (status === 401) {
83
+ return new AxiError("X rejected the access token", "AUTH_REQUIRED", [
84
+ "Run `x-axi auth status`; if the login was revoked or its scopes changed, run `x-axi login` in your own terminal",
85
+ ]);
86
+ }
87
+ if (status === 402 || /credit/i.test(detail)) {
88
+ return new AxiError(detail, "CREDITS_DEPLETED", ["Add credits or raise the spend limit at https://console.x.com"]);
89
+ }
90
+ if (status === 429) {
91
+ return new AxiError(`rate limited until ${String(response.body["reset_at"])}`, "RATE_LIMITED", [
92
+ "Wait until the reset time; do not retry in a loop",
93
+ ]);
94
+ }
95
+ if (/client-not-enrolled|client-forbidden/.test(JSON.stringify(response.body))) {
96
+ return new AxiError(detail, "APP_NOT_ENROLLED", [
97
+ "Move the app to the Pay-per-use package and Production environment at https://console.x.com",
98
+ ]);
99
+ }
100
+ if (status === 403 && /duplicate/i.test(detail))
101
+ return new AxiError(detail, "DUPLICATE_POST");
102
+ if (status === 403 && /mentioned or otherwise engaged|reply to this conversation is not allowed/i.test(detail) && context.replyTo) {
103
+ const intent = new URLSearchParams({ in_reply_to: context.replyTo, text: context.text ?? "" });
104
+ return new AxiError("X only allows an API reply when the post's author mentioned or quoted you", "REPLY_NOT_SUMMONED", [`Send it by hand in the browser instead: https://x.com/intent/post?${intent.toString()}`]);
105
+ }
106
+ if (status === 403)
107
+ return new AxiError(detail, "FORBIDDEN");
108
+ if (status === 404)
109
+ return new AxiError(detail, "NOT_FOUND");
110
+ return new AxiError(`X returned ${status}: ${detail}`, status >= 400 && status < 500 ? "VALIDATION_ERROR" : "X_UNAVAILABLE");
111
+ }
112
+ //# sourceMappingURL=api.js.map