frigatebird 0.2.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-02-09
4
+
5
+ ### Added
6
+ - Long-form article publishing command: `article <title> [body] [--body-file <path>]`.
7
+ - Dedicated live mutation CI workflow: `.github/workflows/live-e2e.yml`.
8
+ - Browser session identity hardening with multi-selector `whoami` fallback coverage.
9
+ - Additional Playwright client method tests for retweet and mutation resilience.
10
+
11
+ ### Changed
12
+ - Mutation `retweet` flow now uses bounded resilient click handling and explicit completion checks.
13
+ - Read-only fixture e2e was optimized to run significantly faster while preserving command coverage.
14
+ - CI and release verification workflows now include fixture e2e smoke runs.
15
+ - Live mutation e2e now requires `FRIGATEBIRD_LIVE_LIST_NAME` and always validates list `add/remove/batch` paths.
16
+
17
+ ### Tests
18
+ - Expanded unit coverage for session identity parsing and retweet mutation branches.
19
+ - Verified end-to-end live mutation flow (tweet/reply/like/retweet/follow/list mutations) with Safari cookie source.
20
+
3
21
  ## 0.2.0 - 2026-02-08
4
22
 
5
23
  ### Added
package/README.md CHANGED
@@ -1,28 +1,21 @@
1
- # frigatebird
1
+ # frigatebird 🐦 — resilient X CLI for posting, articles, replies, reading, and list automation
2
2
 
3
- `frigatebird` is a Playwright-first X CLI that targets `bird` command parity and pulls in `x-list-manager` list automation.
3
+ ![Frigatebird logo](images/frigatebird_logo.jpeg)
4
4
 
5
- It keeps the `bird` UX, but replaces deprecated/non-open GraphQL internals with browser automation against X web UI.
5
+ `frigatebird` is a Playwright-first X CLI that preserves the familiar `bird` command-line experience while running on browser automation instead of private GraphQL internals.
6
6
 
7
- ## Intent
7
+ ## Why This Exists
8
8
 
9
- Frigatebird exists to keep the `bird` workflow alive after deprecation and closure:
9
+ `bird` set a high bar for fast, scriptable X workflows. After deprecation and de-open-sourcing, teams still needed the same CLI ergonomics without depending on internal API behavior.
10
10
 
11
- - Preserve familiar command ergonomics from `bird`.
12
- - Keep zero-API-key operation via local browser session cookies.
13
- - Add first-class list management from `x-list-manager` (`add`, `remove`, `batch`, `refresh`).
14
- - Provide one CLI for read workflows, account actions, and list operations.
11
+ `frigatebird` is the continuity path:
12
+ - Keep the `bird`-style command surface.
13
+ - Keep API-key-free operation via browser session cookies.
14
+ - Keep practical day-to-day workflows for posting, reading, follows, lists, and timeline operations.
15
15
 
16
- ## Frigatebird vs Bird
16
+ ## Disclaimer
17
17
 
18
- | Area | `bird` | `frigatebird` |
19
- |---|---|---|
20
- | Primary engine | X internal GraphQL + query IDs | Playwright web automation |
21
- | API keys | Not required | Not required |
22
- | Auth | Browser cookies / env tokens | Browser cookies / env tokens |
23
- | `query-ids` | Active GraphQL cache/refresh behavior | Compatibility command in Playwright mode |
24
- | List manager commands | External project (`x-list-manager`) | Built in (`refresh`, `add`, `remove`, `batch`) |
25
- | Selector/query fragility | Query ID churn | DOM selector churn |
18
+ Frigatebird automates X’s web UI and relies on selectors/flows that X can change at any time. Expect occasional breakage when X ships UI changes.
26
19
 
27
20
  ## Install
28
21
 
@@ -31,63 +24,91 @@ npm install
31
24
  npx playwright install chromium
32
25
  ```
33
26
 
34
- ## Usage
27
+ When published to npm:
35
28
 
36
29
  ```bash
37
- # One-off
38
- npx tsx src/cli.ts whoami
30
+ npm install -g frigatebird
31
+ ```
39
32
 
40
- # Built binary
41
- npm run build
42
- node dist/cli.js --help
33
+ ## Quickstart
34
+
35
+ ```bash
36
+ # Show authenticated account
37
+ frigatebird whoami
38
+
39
+ # Read a tweet (URL or ID)
40
+ frigatebird read https://x.com/user/status/1234567890123456789
41
+ frigatebird 1234567890123456789 --json
42
+
43
+ # Post and reply
44
+ frigatebird tweet "hello from frigatebird"
45
+ frigatebird reply 1234567890123456789 "thanks"
46
+
47
+ # Publish a long-form article
48
+ frigatebird article "Launch notes" "Today we shipped..."
49
+ frigatebird article "Draft from file" --body-file ./article.md
50
+
51
+ # Search and mentions
52
+ frigatebird search "from:openai" -n 5
53
+ frigatebird mentions -n 5
54
+
55
+ # Lists and list timeline
56
+ frigatebird lists --json
57
+ frigatebird list-timeline 1234567890 -n 20
58
+
59
+ # Follow graph
60
+ frigatebird following -n 20 --json
61
+ frigatebird followers -n 20 --json
62
+
63
+ # List membership automation
64
+ frigatebird add "AI News" @openai @anthropicai
65
+ frigatebird remove @openai "AI News"
66
+ frigatebird batch accounts.json
43
67
  ```
44
68
 
45
69
  ## Commands
46
70
 
47
- ### bird-style parity commands
48
-
49
- - `tweet <text>`
50
- - `post <text>`
51
- - `reply <tweet-id-or-url> <text>`
52
- - `read <tweet-id-or-url> [--json] [--json-full]`
53
- - `replies <tweet-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
54
- - `thread <tweet-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
55
- - `search <query> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
56
- - `mentions [-u @handle] [-n count] [--json] [--json-full]`
57
- - `user-tweets <@handle> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
58
- - `home [-n count] [--following] [--all] [--max-pages n] [--delay ms] [--json] [--json-full]`
59
- - `bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor str] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--delay ms] [--json] [--json-full]`
60
- - `unbookmark <tweet-id-or-url...> [--json]`
61
- - `like <tweet-id-or-url>`
62
- - `retweet <tweet-id-or-url>`
63
- - `likes [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
64
- - `follow <username-or-id>`
65
- - `unfollow <username-or-id>`
66
- - `following [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
67
- - `followers [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
68
- - `lists [--member-of] [-n count] [--json] [--json-full]`
69
- - `list` (alias for `lists`)
70
- - `list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]`
71
- - `news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json] [--json-full]`
72
- - `trending` (alias for `news`)
73
- - `about <@handle> [--json]`
74
- - `query-ids [--fresh] [--json]` (compatibility mode in Playwright engine)
75
- - `whoami [--json]`
76
- - `check`
77
- - `help [command]`
78
-
79
- ### x-list-manager parity commands
80
-
81
- - `refresh [--json]`
82
- - `add <listName> <handles...> [--no-headless] [--json]`
83
- - `remove <handle> <listName> [--no-headless] [--json]`
84
- - `batch <file.json> [--no-headless] [--json]`
71
+ - `frigatebird tweet "<text>"` — post a tweet.
72
+ - `frigatebird post "<text>"` — alias for `tweet`.
73
+ - `frigatebird article "<title>" [body] [--body-file path]` — publish a long-form article.
74
+ - `frigatebird reply <tweet-id-or-url> "<text>"` — reply to a tweet.
75
+ - `frigatebird read <tweet-id-or-url> [--json] [--json-full]` — read one tweet.
76
+ - `frigatebird <tweet-id-or-url> [--json]` — shorthand for `read`.
77
+ - `frigatebird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor str] [--delay ms] [-n count] [--json] [--json-full]` — list replies.
78
+ - `frigatebird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor str] [--delay ms] [-n count] [--json] [--json-full]` — show thread/conversation tweets.
79
+ - `frigatebird search "<query>" [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — search tweets.
80
+ - `frigatebird mentions [--user @handle] [-n count] [--json] [--json-full]` — mention timeline/search.
81
+ - `frigatebird user-tweets <@handle> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — profile tweets.
82
+ - `frigatebird home [-n count] [--following] [--all] [--max-pages n] [--delay ms] [--json] [--json-full]` — home timeline.
83
+ - `frigatebird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor str] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--delay ms] [--json] [--json-full]` — bookmarks + optional thread expansion.
84
+ - `frigatebird unbookmark <tweet-id-or-url...> [--json]` — remove bookmark(s).
85
+ - `frigatebird like <tweet-id-or-url>` — like a tweet.
86
+ - `frigatebird retweet <tweet-id-or-url>` — repost a tweet.
87
+ - `frigatebird likes [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — liked tweets.
88
+ - `frigatebird follow <username-or-id>` — follow user.
89
+ - `frigatebird unfollow <username-or-id>` — unfollow user.
90
+ - `frigatebird following [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — accounts a user follows.
91
+ - `frigatebird followers [--user userId] [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — accounts following a user.
92
+ - `frigatebird lists [--member-of] [-n count] [--json] [--json-full]` — list your lists.
93
+ - `frigatebird list [--member-of] [-n count] [--json] [--json-full]` alias for `lists`.
94
+ - `frigatebird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor str] [--delay ms] [--json] [--json-full]` — timeline for a list.
95
+ - `frigatebird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json] [--json-full]` — explore/news aggregation.
96
+ - `frigatebird trending` alias for `news`.
97
+ - `frigatebird about <@handle> [--json]` — profile origin/location metadata.
98
+ - `frigatebird query-ids [--fresh] [--json]` compatibility command (Playwright mode does not require GraphQL query IDs).
99
+ - `frigatebird whoami [--json]` — active authenticated account.
100
+ - `frigatebird check` — credential/session status.
101
+ - `frigatebird refresh [--json]` — refresh local auth cookie cache.
102
+ - `frigatebird add <listName> <handles...> [--no-headless] [--json]` — add handles to list.
103
+ - `frigatebird remove <handle> <listName> [--no-headless] [--json]` — remove handle from list.
104
+ - `frigatebird batch <file.json> [--no-headless] [--json]` — batch list updates from JSON.
105
+ - `frigatebird help [command]` — command help.
85
106
 
86
107
  ## Global Options
87
108
 
88
109
  - `--auth-token <token>`
89
110
  - `--ct0 <token>`
90
- - `--base-url <url>` (testing override, default `https://x.com`)
111
+ - `--base-url <url>` (default `https://x.com`, useful for fixture/e2e)
91
112
  - `--cookie-source <chrome|firefox|safari|edge>` (repeatable)
92
113
  - `--chrome-profile <name>`
93
114
  - `--chrome-profile-dir <path>`
@@ -102,85 +123,99 @@ node dist/cli.js --help
102
123
  - `--no-color`
103
124
  - `--no-headless`
104
125
 
105
- `tweet` and `reply` both consume `--media`/`--alt`.
106
-
107
- Media constraints:
126
+ Media rules:
108
127
  - up to 4 attachments
109
- - only one video
128
+ - one video maximum
110
129
  - video cannot be mixed with other media
111
- - supported extensions: `jpg`, `jpeg`, `png`, `webp`, `gif`, `mp4`, `m4v`, `mov`
130
+ - supported: `jpg`, `jpeg`, `png`, `webp`, `gif`, `mp4`, `m4v`, `mov`
112
131
 
113
- ## Config + Env
132
+ ## Authentication
133
+
134
+ Frigatebird uses your existing X web session and cookie credentials. No X API key required.
135
+
136
+ Resolution order:
137
+ 1. CLI flags (`--auth-token`, `--ct0`)
138
+ 2. env vars (`AUTH_TOKEN`, `CT0`, fallbacks below)
139
+ 3. browser cookie extraction via `@steipete/sweet-cookie`
140
+
141
+ When `--cookie-source` is explicitly set and differs from cached auth source metadata, Frigatebird clears cached auth and re-resolves cookies from the requested source order.
142
+
143
+ If auth fails:
144
+ ```bash
145
+ frigatebird refresh
146
+ frigatebird check
147
+ frigatebird whoami
148
+ ```
149
+
150
+ ## Config (JSON5)
114
151
 
115
152
  Precedence: **CLI flags > env vars > project config > global config**.
116
153
 
117
154
  Config files:
118
- - Global (bird): `~/.config/bird/config.json5`
119
- - Global (frigatebird): `~/.config/frigatebird/config.json5`
120
- - Project (bird): `./.birdrc.json5`
121
- - Project (frigatebird): `./.frigatebirdrc.json5`
155
+ - `~/.config/bird/config.json5`
156
+ - `~/.config/frigatebird/config.json5`
157
+ - `./.birdrc.json5`
158
+ - `./.frigatebirdrc.json5`
122
159
 
123
- Supported config keys:
160
+ Supported keys:
124
161
  - `authToken`, `ct0`, `baseUrl`
125
162
  - `cookieSource`
126
163
  - `chromeProfile`, `chromeProfileDir`, `firefoxProfile`
127
164
  - `cookieTimeoutMs`, `timeoutMs`, `quoteDepth`
128
165
 
129
- Supported env vars:
130
- - Auth: `AUTH_TOKEN`, `CT0`, `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`
131
- - Cookie source: `BIRD_COOKIE_SOURCE`, `FRIGATEBIRD_COOKIE_SOURCE`
132
- - Base URL: `BIRD_BASE_URL`, `FRIGATEBIRD_BASE_URL`
133
- - Profiles: `BIRD_CHROME_PROFILE`, `BIRD_CHROME_PROFILE_DIR`, `BIRD_FIREFOX_PROFILE` (plus `FRIGATEBIRD_*` variants)
134
- - Timeouts/depth: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` (plus `FRIGATEBIRD_*` variants)
135
- - Output: `NO_COLOR`, `BIRD_PLAIN`, `FRIGATEBIRD_PLAIN`
136
-
137
- ## Shorthand Invocation
166
+ Example:
138
167
 
139
- If first argument is a tweet URL or ID, Frigatebird rewrites to `read`:
140
-
141
- ```bash
142
- npx tsx src/cli.ts 1891234567890123456 --json
168
+ ```json5
169
+ {
170
+ cookieSource: ["chrome", "safari"],
171
+ chromeProfile: "Default",
172
+ timeoutMs: 20000,
173
+ quoteDepth: 1
174
+ }
143
175
  ```
144
176
 
145
- ## Architecture
146
-
147
- - `src/cli/`: commander program assembly and global option wiring
148
- - `src/commands/`: handler layer and output orchestration
149
- - `src/client/`: `FrigatebirdClient` interface and Playwright implementation
150
- - `src/browser/`: auth store, session lifecycle, scrape primitives
151
- - `src/lib/`: identifiers, option parsing, invocation normalization, config, output
152
-
153
- ## Skills Files
177
+ Environment vars:
178
+ - auth: `AUTH_TOKEN`, `CT0`, `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`
179
+ - cookie source: `BIRD_COOKIE_SOURCE`, `FRIGATEBIRD_COOKIE_SOURCE`
180
+ - base URL: `BIRD_BASE_URL`, `FRIGATEBIRD_BASE_URL`
181
+ - profiles: `BIRD_CHROME_PROFILE`, `BIRD_CHROME_PROFILE_DIR`, `BIRD_FIREFOX_PROFILE` and `FRIGATEBIRD_*` variants
182
+ - timeouts/depth: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` and `FRIGATEBIRD_*` variants
183
+ - output: `NO_COLOR`, `BIRD_PLAIN`, `FRIGATEBIRD_PLAIN`
154
184
 
155
- Frigatebird now includes skill-oriented project files (same ecosystem style used by `x-list-manager`):
185
+ ## Output
156
186
 
157
- - `SKILL.md`: AI-agent usage contract for this CLI
158
- - `SPEC.md`: technical architecture and command behavior
159
- - `TASKS.md`: parity/release checklist
187
+ - `--json` gives machine-readable output for read/timeline/list commands.
188
+ - `--json-full` includes raw compatibility payloads where available.
189
+ - `--plain` disables emoji + color for stable scripts.
160
190
 
161
- ## Release Readiness
162
-
163
- - `CHANGELOG.md` tracks release notes.
164
- - `RELEASE.md` contains the release checklist and publish flow.
165
- - GitHub workflows enforce CI + publish gates:
166
- - `.github/workflows/ci.yml`
167
- - `.github/workflows/release.yml`
168
- - npm publish is triggered by GitHub Release `published` events using npm trusted publishing (OIDC, no npm token secret).
169
- - `npm run release:check` runs lint + build + full tests + coverage.
170
- - `npm run smoke:pack-install` validates clean install + binary entrypoint from a packed tarball.
171
-
172
- ## Test
191
+ ## Development
173
192
 
174
193
  ```bash
175
- npm run test:run
176
- npm run test:coverage
177
- npm run test:e2e
194
+ npm install
195
+ npx playwright install chromium
196
+ npm run build
197
+ npm run lint
198
+ npm run test:run # unit/integration
199
+ npm run test:coverage # unit/integration + coverage
200
+ npm run test:e2e # e2e only
201
+ npm run test:e2e:live # opt-in live mutation e2e (requires env vars below)
202
+ npm run smoke:pack-install
178
203
  ```
179
204
 
180
- `test:run` and `test:coverage` run unit/integration suites only.
181
- `test:e2e` runs end-to-end read-only tests against empty-account fixtures.
205
+ Live mutation e2e env requirements:
206
+ - `FRIGATEBIRD_AUTH_TOKEN`
207
+ - `FRIGATEBIRD_CT0`
208
+ - `FRIGATEBIRD_LIVE_LIST_NAME`
209
+ - optional: `FRIGATEBIRD_LIVE_COOKIE_SOURCE`, `FRIGATEBIRD_LIVE_EXPECTED_HANDLE_PREFIX`, `FRIGATEBIRD_LIVE_TARGET_HANDLE`
210
+
211
+ ## Release
212
+
213
+ - CI: `.github/workflows/ci.yml`
214
+ - npm publish: `.github/workflows/release.yml` (triggered by GitHub Release `published`, trusted publishing/OIDC)
215
+ - live mutation CI: `.github/workflows/live-e2e.yml` (manual trigger; validates `FRIGATEBIRD_LIVE_LIST_NAME` + auth secrets before running)
216
+ - release checklist: `RELEASE.md`
182
217
 
183
218
  ## Notes
184
219
 
185
- - Frigatebird automates X web UI and depends on selectors that may change.
186
- - Some deep `bird` GraphQL-only semantics are represented as compatibility flags in Playwright mode.
220
+ - Selector drift is the primary maintenance cost.
221
+ - `query-ids` is intentionally kept for CLI compatibility with historical `bird` workflows.
package/TASKS.md CHANGED
@@ -26,4 +26,4 @@
26
26
  - [x] Add `CHANGELOG.md`.
27
27
  - [x] Add `RELEASE.md` release checklist and publish flow.
28
28
  - [x] Add `SKILL.md` + `SPEC.md` + `TASKS.md` for agent-oriented usage.
29
- - [ ] Add mutation-focused e2e coverage against disposable test accounts (post-release hardening).
29
+ - [x] Add mutation-focused e2e coverage against disposable test accounts, including list add/remove/batch checks.
@@ -1,6 +1,18 @@
1
1
  import fs from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import path from "node:path";
3
4
  import { getCookies } from "@steipete/sweet-cookie";
5
+ function parseBrowserSourceList(source) {
6
+ if (!source.startsWith("browser:"))
7
+ return null;
8
+ const suffix = source.slice("browser:".length).trim();
9
+ if (!suffix)
10
+ return null;
11
+ return suffix
12
+ .split(",")
13
+ .map((part) => part.trim())
14
+ .filter(Boolean);
15
+ }
4
16
  function normalizeDomain(domain) {
5
17
  if (!domain)
6
18
  return ".x.com";
@@ -21,10 +33,46 @@ function toPlaywrightCookie(cookie) {
21
33
  function mapSource(value) {
22
34
  return value;
23
35
  }
36
+ function formatError(error) {
37
+ if (error instanceof Error)
38
+ return error.message;
39
+ return String(error);
40
+ }
41
+ const SAFARI_COOKIE_STORE_PATH = path.join(homedir(), "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", "Cookies.binarycookies");
24
42
  export class AuthStore {
25
43
  authFile;
26
- constructor(authFile = path.join(process.cwd(), "auth.json")) {
44
+ cookieExtractor;
45
+ lastDiagnostics = [];
46
+ constructor(authFile = path.join(process.cwd(), "auth.json"), cookieExtractor = getCookies) {
27
47
  this.authFile = authFile;
48
+ this.cookieExtractor = cookieExtractor;
49
+ }
50
+ getLastDiagnostics() {
51
+ return [...this.lastDiagnostics];
52
+ }
53
+ setDiagnostics(messages) {
54
+ this.lastDiagnostics = Array.from(new Set(messages.filter(Boolean)));
55
+ }
56
+ diagnoseSafariCookieAccess() {
57
+ if (process.platform !== "darwin")
58
+ return [];
59
+ if (!fs.existsSync(SAFARI_COOKIE_STORE_PATH)) {
60
+ return [`Safari cookie store not found at ${SAFARI_COOKIE_STORE_PATH}.`];
61
+ }
62
+ try {
63
+ const fileDescriptor = fs.openSync(SAFARI_COOKIE_STORE_PATH, "r");
64
+ fs.closeSync(fileDescriptor);
65
+ return [];
66
+ }
67
+ catch (error) {
68
+ const nodeError = error;
69
+ if (nodeError.code === "EPERM" || nodeError.code === "EACCES") {
70
+ return [
71
+ "Safari cookies are blocked by macOS privacy controls for this process. Grant Full Disk Access to your terminal/Codex app, then retry.",
72
+ ];
73
+ }
74
+ return [`Safari cookie store could not be read: ${formatError(error)}.`];
75
+ }
28
76
  }
29
77
  loadFromDisk() {
30
78
  if (!fs.existsSync(this.authFile))
@@ -55,21 +103,53 @@ export class AuthStore {
55
103
  fs.unlinkSync(this.authFile);
56
104
  }
57
105
  }
106
+ isSavedSourceCompatible(source, options) {
107
+ const savedBrowsers = parseBrowserSourceList(source);
108
+ if (!savedBrowsers)
109
+ return true;
110
+ const requested = options.cookieSource;
111
+ if (savedBrowsers.length !== requested.length)
112
+ return false;
113
+ return savedBrowsers.every((value, index) => value === requested[index]);
114
+ }
58
115
  async extractFromBrowser(options) {
59
116
  const browsers = options.cookieSource.map(mapSource);
60
- const extraction = await getCookies({
61
- url: "https://x.com",
62
- browsers,
63
- chromeProfile: options.chromeProfileDir ?? options.chromeProfile,
64
- firefoxProfile: options.firefoxProfile,
65
- timeoutMs: options.cookieTimeout,
66
- }).catch(() => null);
117
+ let extraction = null;
118
+ try {
119
+ extraction = await this.cookieExtractor({
120
+ url: "https://x.com",
121
+ browsers,
122
+ chromeProfile: options.chromeProfileDir ?? options.chromeProfile,
123
+ firefoxProfile: options.firefoxProfile,
124
+ timeoutMs: options.cookieTimeout,
125
+ });
126
+ }
127
+ catch (error) {
128
+ const diagnostics = [
129
+ `Browser cookie extraction failed: ${formatError(error)}.`,
130
+ ];
131
+ if (options.cookieSource.includes("safari")) {
132
+ diagnostics.push(...this.diagnoseSafariCookieAccess());
133
+ }
134
+ this.setDiagnostics(diagnostics);
135
+ return null;
136
+ }
67
137
  if (!extraction || extraction.cookies.length === 0) {
138
+ const diagnostics = [
139
+ `No cookies were extracted from sources: ${options.cookieSource.join(", ")}.`,
140
+ ];
141
+ if (options.cookieSource.includes("safari")) {
142
+ diagnostics.push(...this.diagnoseSafariCookieAccess());
143
+ }
144
+ this.setDiagnostics(diagnostics);
68
145
  return null;
69
146
  }
70
147
  const authToken = extraction.cookies.find((cookie) => cookie.name === "auth_token");
71
148
  const ct0 = extraction.cookies.find((cookie) => cookie.name === "ct0");
72
149
  if (!authToken || !ct0) {
150
+ this.setDiagnostics([
151
+ `Cookies were extracted, but required auth cookies are missing (auth_token=${Boolean(authToken)}, ct0=${Boolean(ct0)}).`,
152
+ ]);
73
153
  return null;
74
154
  }
75
155
  const playwrightCookies = [
@@ -127,19 +207,34 @@ export class AuthStore {
127
207
  };
128
208
  }
129
209
  async resolve(options, forceRefresh = false) {
210
+ this.setDiagnostics([]);
130
211
  const manual = this.fromManualTokens(options);
131
212
  if (manual)
132
213
  return manual;
214
+ const sourceOverrideNotes = [];
133
215
  if (!forceRefresh) {
134
216
  const saved = this.loadFromDisk();
135
- if (saved)
136
- return saved;
217
+ if (saved) {
218
+ if (!options.cookieSourceExplicit ||
219
+ this.isSavedSourceCompatible(saved.source, options)) {
220
+ return saved;
221
+ }
222
+ sourceOverrideNotes.push(`Cached auth source "${saved.source}" was ignored because --cookie-source was explicitly set to "${options.cookieSource.join(",")}".`);
223
+ this.clear();
224
+ }
137
225
  }
138
226
  const extracted = await this.extractFromBrowser(options);
139
227
  if (extracted) {
228
+ this.setDiagnostics([]);
140
229
  this.save(extracted.cookies, extracted.source);
141
230
  return extracted;
142
231
  }
232
+ if (sourceOverrideNotes.length > 0) {
233
+ this.setDiagnostics([
234
+ ...sourceOverrideNotes,
235
+ ...this.getLastDiagnostics(),
236
+ ]);
237
+ }
143
238
  return null;
144
239
  }
145
240
  }
@@ -1,5 +1,64 @@
1
1
  import { chromium, } from "playwright";
2
2
  import { AuthStore } from "./auth-store.js";
3
+ const HANDLE_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
4
+ const RESERVED_HANDLE_SEGMENTS = new Set([
5
+ "compose",
6
+ "download",
7
+ "explore",
8
+ "hashtag",
9
+ "home",
10
+ "i",
11
+ "intent",
12
+ "login",
13
+ "logout",
14
+ "messages",
15
+ "notifications",
16
+ "search",
17
+ "settings",
18
+ "signup",
19
+ "tos",
20
+ ]);
21
+ function normalizeHandleCandidate(value) {
22
+ if (!value)
23
+ return null;
24
+ const trimmed = value.trim();
25
+ if (!trimmed)
26
+ return null;
27
+ const handleMatch = trimmed.match(/@([A-Za-z0-9_]{1,15})/);
28
+ if (handleMatch?.[1])
29
+ return handleMatch[1];
30
+ let segment = trimmed;
31
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
32
+ try {
33
+ segment = new URL(trimmed).pathname;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (segment.startsWith("/")) {
40
+ segment = segment.replace(/^\/+/, "").split("/")[0] ?? "";
41
+ }
42
+ segment = segment.split("?")[0]?.split("#")[0]?.trim() ?? "";
43
+ if (!segment)
44
+ return null;
45
+ if (segment.startsWith("@"))
46
+ segment = segment.slice(1);
47
+ if (!segment)
48
+ return null;
49
+ if (RESERVED_HANDLE_SEGMENTS.has(segment.toLowerCase()))
50
+ return null;
51
+ if (!HANDLE_PATTERN.test(segment))
52
+ return null;
53
+ return segment;
54
+ }
55
+ function extractNameFromAccountText(value) {
56
+ const parts = value
57
+ .split("\n")
58
+ .map((item) => item.trim())
59
+ .filter(Boolean);
60
+ return parts.find((item) => !item.startsWith("@"));
61
+ }
3
62
  export class BrowserSessionManager {
4
63
  authStore;
5
64
  constructor(authStore = new AuthStore()) {
@@ -69,34 +128,55 @@ export class BrowserSessionManager {
69
128
  return url.includes("/home") && !url.includes("/login");
70
129
  }
71
130
  async whoAmI(page) {
72
- const profileLink = await page
73
- .getAttribute('[data-testid="AppTabBar_Profile_Link"]', "href")
74
- .catch(() => null);
75
- const handle = profileLink?.replace(/^\//, "").split("/")[0];
131
+ const profileSelectors = [
132
+ '[data-testid="AppTabBar_Profile_Link"]',
133
+ 'a[data-testid$="_Profile_Link"]',
134
+ '[data-testid="SideNav_AccountSwitcher_Button"] a[href]',
135
+ ];
136
+ let handle;
137
+ for (const selector of profileSelectors) {
138
+ const href = await page.getAttribute(selector, "href").catch(() => null);
139
+ const candidate = normalizeHandleCandidate(href);
140
+ if (candidate) {
141
+ handle = candidate;
142
+ break;
143
+ }
144
+ }
145
+ const accountSwitcherSelector = '[data-testid="SideNav_AccountSwitcher_Button"]';
76
146
  const accountText = await page
77
- .locator('[data-testid="SideNav_AccountSwitcher_Button"]')
147
+ .locator(accountSwitcherSelector)
78
148
  .first()
79
149
  .innerText()
80
150
  .catch(() => "");
81
- const name = accountText
82
- .split("\n")
83
- .map((item) => item.trim())
84
- .filter(Boolean)[0];
151
+ const accountAriaLabel = await page
152
+ .getAttribute(accountSwitcherSelector, "aria-label")
153
+ .catch(() => null);
154
+ if (!handle) {
155
+ handle =
156
+ normalizeHandleCandidate(accountText) ??
157
+ normalizeHandleCandidate(accountAriaLabel ?? "") ??
158
+ undefined;
159
+ }
160
+ const name = extractNameFromAccountText(accountText) ??
161
+ extractNameFromAccountText(accountAriaLabel ?? "");
85
162
  return { handle, name };
86
163
  }
87
164
  async createCookieProbe(options) {
88
165
  const resolved = await this.authStore.resolve(options);
166
+ const diagnostics = this.authStore.getLastDiagnostics();
89
167
  if (!resolved) {
90
168
  return {
91
169
  hasAuthToken: false,
92
170
  hasCt0: false,
93
171
  source: "none",
172
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
94
173
  };
95
174
  }
96
175
  return {
97
176
  hasAuthToken: resolved.cookies.some((cookie) => cookie.name === "auth_token"),
98
177
  hasCt0: resolved.cookies.some((cookie) => cookie.name === "ct0"),
99
178
  source: resolved.source,
179
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
100
180
  };
101
181
  }
102
182
  }
@@ -47,6 +47,11 @@ export function createProgram(handlers, version = "0.2.0") {
47
47
  .command("post <text>")
48
48
  .description("Alias for tweet")
49
49
  .action(run((text) => handlers.tweet(text)));
50
+ program
51
+ .command("article <title> [body]")
52
+ .description("Publish a long-form article on X")
53
+ .option("--body-file <path>", "Read article body text from a UTF-8 file")
54
+ .action(run((title, body, options) => handlers.article(title, body, options)));
50
55
  program
51
56
  .command("reply <tweet-id-or-url> <text>")
52
57
  .description("Reply to a tweet")
package/dist/cli.js CHANGED
@@ -30,6 +30,8 @@ function collectExplicitCliBooleans(args) {
30
30
  explicit.emoji = false;
31
31
  if (args.includes("--no-headless"))
32
32
  explicit.headless = false;
33
+ if (args.includes("--cookie-source"))
34
+ explicit.cookieSourceExplicit = true;
33
35
  return explicit;
34
36
  }
35
37
  function compactObject(input) {
@@ -57,10 +57,15 @@ function resolveMediaSpecs(media, alt) {
57
57
  }
58
58
  return specs;
59
59
  }
60
+ const HEADLESS_RETRY_MARKER = "[FRIGATEBIRD_HEADLESS_RETRY]";
61
+ function markHeadlessRetry(message) {
62
+ return new Error(`${HEADLESS_RETRY_MARKER} ${message}`);
63
+ }
60
64
  export class PlaywrightXClient {
61
65
  options;
62
66
  sessions;
63
67
  baseUrl;
68
+ currentSessionHeadless = null;
64
69
  constructor(options, sessions = new BrowserSessionManager()) {
65
70
  this.options = options;
66
71
  this.sessions = sessions;
@@ -74,10 +79,49 @@ export class PlaywrightXClient {
74
79
  return this.options;
75
80
  return { ...this.options, headless: headlessOverride };
76
81
  }
82
+ isHeadlessRetryError(error) {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ return message.includes(HEADLESS_RETRY_MARKER);
85
+ }
86
+ normalizeError(error) {
87
+ if (error instanceof Error) {
88
+ if (this.isHeadlessRetryError(error)) {
89
+ return new Error(error.message.replace(`${HEADLESS_RETRY_MARKER} `, "").trim());
90
+ }
91
+ return error;
92
+ }
93
+ return new Error(String(error));
94
+ }
77
95
  async withPage(task, headlessOverride) {
78
- return this.sessions.withSession(this.mergedOptions(headlessOverride), async ({ page }) => {
79
- return task(page);
80
- });
96
+ const effectiveOptions = this.mergedOptions(headlessOverride);
97
+ const runWithMode = async (headless) => {
98
+ return this.sessions.withSession({ ...effectiveOptions, headless }, async ({ page }) => {
99
+ const previous = this.currentSessionHeadless;
100
+ this.currentSessionHeadless = headless;
101
+ try {
102
+ return await task(page);
103
+ }
104
+ finally {
105
+ this.currentSessionHeadless = previous;
106
+ }
107
+ });
108
+ };
109
+ try {
110
+ return await runWithMode(effectiveOptions.headless);
111
+ }
112
+ catch (error) {
113
+ if (effectiveOptions.headless &&
114
+ headlessOverride === undefined &&
115
+ this.isHeadlessRetryError(error)) {
116
+ try {
117
+ return await runWithMode(false);
118
+ }
119
+ catch (secondError) {
120
+ throw this.normalizeError(secondError);
121
+ }
122
+ }
123
+ throw this.normalizeError(error);
124
+ }
81
125
  }
82
126
  async ensureAuth(page) {
83
127
  const loggedIn = await this.sessions.ensureLoggedIn(page, this.baseUrl);
@@ -96,7 +140,41 @@ export class PlaywrightXClient {
96
140
  throw new Error("Invalid tweet reference. Provide a tweet URL or numeric tweet ID.");
97
141
  }
98
142
  await page.goto(url, { waitUntil: "domcontentloaded" });
99
- await page.waitForSelector('[data-testid="tweet"]', { timeout: 15000 });
143
+ try {
144
+ await page.waitForSelector('[data-testid="tweet"]', { timeout: 15000 });
145
+ }
146
+ catch (error) {
147
+ const isHeadlessSession = this.currentSessionHeadless ?? this.options.headless;
148
+ const hasTweet = (await page
149
+ .locator('[data-testid="tweet"]')
150
+ .count()
151
+ .catch(() => 0)) > 0;
152
+ const errorPageVisible = (await page
153
+ .locator("text=Something went wrong, but don’t fret")
154
+ .first()
155
+ .isVisible()
156
+ .catch(() => false)) ||
157
+ (await page
158
+ .locator("text=Something went wrong, but don't fret")
159
+ .first()
160
+ .isVisible()
161
+ .catch(() => false)) ||
162
+ (await page
163
+ .locator("text=Something went wrong")
164
+ .first()
165
+ .isVisible()
166
+ .catch(() => false));
167
+ if (!hasTweet && errorPageVisible && isHeadlessSession) {
168
+ throw markHeadlessRetry("X returned a temporary error page while loading the tweet in headless mode. Retrying in headed mode.");
169
+ }
170
+ if (!hasTweet && isHeadlessSession) {
171
+ throw markHeadlessRetry("Tweet content did not render in headless mode. Retrying in headed mode.");
172
+ }
173
+ if (!hasTweet) {
174
+ throw new Error("Tweet content did not render. X may be returning a transient error page or blocking this route right now.");
175
+ }
176
+ throw error;
177
+ }
100
178
  return url;
101
179
  }
102
180
  async openList(page, listRef) {
@@ -162,45 +240,157 @@ export class PlaywrightXClient {
162
240
  }
163
241
  return null;
164
242
  }
243
+ isPointerInterceptionError(error) {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+ return (message.includes("intercepts pointer events") ||
246
+ (message.includes("Timeout") && message.includes("locator.click")));
247
+ }
248
+ async dismissBlockingLayers(page) {
249
+ const mask = page.locator('[data-testid="twc-cc-mask"]').first();
250
+ if (!(await mask.isVisible().catch(() => false))) {
251
+ return;
252
+ }
253
+ const consentSelectors = [
254
+ '[role="dialog"] [role="button"]:has-text("Accept all cookies")',
255
+ '[role="dialog"] [role="button"]:has-text("Accept all")',
256
+ '[role="button"]:has-text("Accept all cookies")',
257
+ '[role="button"]:has-text("Accept all")',
258
+ '[role="button"]:has-text("Agree and continue")',
259
+ '[role="button"]:has-text("Close")',
260
+ ];
261
+ for (const selector of consentSelectors) {
262
+ const button = page.locator(selector).first();
263
+ if (!(await button.isVisible().catch(() => false)))
264
+ continue;
265
+ await button.click().catch(() => { });
266
+ await page.waitForTimeout(120);
267
+ }
268
+ if (await mask.isVisible().catch(() => false)) {
269
+ await page.keyboard.press("Escape").catch(() => { });
270
+ await page.waitForTimeout(120);
271
+ }
272
+ if (await mask.isVisible().catch(() => false)) {
273
+ await mask.click({ force: true }).catch(() => { });
274
+ }
275
+ const deadline = Date.now() + 3000;
276
+ while (Date.now() < deadline) {
277
+ if (!(await mask.isVisible().catch(() => false)))
278
+ break;
279
+ await page.waitForTimeout(120);
280
+ }
281
+ }
165
282
  async clickFirstVisible(page, selectors, timeoutMs = 5000) {
166
283
  const locator = await this.firstVisibleLocator(page, selectors, timeoutMs);
167
284
  if (!locator)
168
285
  return false;
169
- await locator.click();
170
- return true;
286
+ const clickTimeout = Math.max(800, Math.min(timeoutMs, 2500));
287
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
288
+ await this.dismissBlockingLayers(page);
289
+ try {
290
+ await locator.click({ timeout: clickTimeout });
291
+ return true;
292
+ }
293
+ catch (error) {
294
+ if (!this.isPointerInterceptionError(error) || attempt === 3) {
295
+ throw error;
296
+ }
297
+ const forced = await locator
298
+ .click({ force: true, timeout: 2000 })
299
+ .then(() => true)
300
+ .catch(() => false);
301
+ if (forced) {
302
+ return true;
303
+ }
304
+ await page.waitForTimeout(200 * attempt);
305
+ }
306
+ }
307
+ return false;
171
308
  }
172
309
  async waitForComposer(page, timeoutMs = 15000) {
173
- const composer = await this.firstVisibleLocator(page, [
310
+ const composerSelectors = [
174
311
  '[data-testid="tweetTextarea_0"]',
175
312
  'div[role="textbox"][data-testid="tweetTextarea_0"]',
176
313
  'div[role="textbox"][aria-label*="Post text"]',
177
314
  'div[role="textbox"][aria-label*="What is happening"]',
178
315
  'div[role="textbox"][aria-label*="What’s happening"]',
179
- ], timeoutMs);
316
+ ];
317
+ const composer = await this.firstVisibleLocator(page, composerSelectors, timeoutMs);
180
318
  if (!composer) {
181
319
  throw new Error("Composer text area not found. X may have changed selectors.");
182
320
  }
183
321
  }
184
322
  async fillComposer(page, text) {
185
- const composer = await this.firstVisibleLocator(page, [
323
+ const composerSelectors = [
186
324
  '[data-testid="tweetTextarea_0"]',
187
325
  'div[role="textbox"][data-testid="tweetTextarea_0"]',
188
326
  'div[role="textbox"][aria-label*="Post text"]',
189
327
  'div[role="textbox"][aria-label*="What is happening"]',
190
328
  'div[role="textbox"][aria-label*="What’s happening"]',
191
- ]);
192
- if (!composer) {
329
+ ];
330
+ const candidateLocators = [];
331
+ for (const selector of composerSelectors) {
332
+ const locators = page.locator(selector);
333
+ const count = await locators.count().catch(() => 0);
334
+ for (let index = 0; index < count; index += 1) {
335
+ const candidate = locators.nth(index);
336
+ if (await candidate.isVisible().catch(() => false)) {
337
+ candidateLocators.push(candidate);
338
+ }
339
+ }
340
+ }
341
+ if (candidateLocators.length === 0) {
193
342
  throw new Error("Composer text area not found.");
194
343
  }
195
- await composer.fill(text);
344
+ const submitEnabledSelectors = [
345
+ '[data-testid="tweetButton"]:not([aria-disabled="true"])',
346
+ '[data-testid="tweetButtonInline"]:not([aria-disabled="true"])',
347
+ '[role="button"]:has-text("Post"):not([aria-disabled="true"])',
348
+ '[role="button"]:has-text("Reply"):not([aria-disabled="true"])',
349
+ ];
350
+ const isSubmitEnabled = async () => {
351
+ const enabled = await this.firstVisibleLocator(page, submitEnabledSelectors, 800, 100);
352
+ return Boolean(enabled);
353
+ };
354
+ for (const composer of candidateLocators) {
355
+ await composer.click().catch(() => { });
356
+ await composer.fill(text).catch(() => { });
357
+ const injected = await composer
358
+ .evaluate((node) => {
359
+ const element = node;
360
+ return (element.innerText ||
361
+ element.textContent ||
362
+ element.value ||
363
+ "").trim();
364
+ })
365
+ .catch(() => "");
366
+ if (!injected || injected.length < Math.min(6, text.length)) {
367
+ await page.keyboard.type(text).catch(() => { });
368
+ }
369
+ if (await isSubmitEnabled()) {
370
+ return;
371
+ }
372
+ }
196
373
  }
197
374
  async submitComposer(page, action) {
375
+ await page.keyboard.press("Meta+Enter").catch(async () => {
376
+ await page.keyboard.press("Control+Enter").catch(() => { });
377
+ });
378
+ await page.waitForTimeout(500);
379
+ const sent = await this.firstVisibleLocator(page, [
380
+ '[role="alert"]:has-text("Your post was sent")',
381
+ '[role="alert"]:has-text("post was sent")',
382
+ '[role="alert"]:has-text("sent")',
383
+ '[data-testid="toast"]',
384
+ ], 1800, 100);
385
+ if (sent) {
386
+ return;
387
+ }
198
388
  const clicked = await this.clickFirstVisible(page, [
199
- '[data-testid="tweetButton"]',
200
- '[data-testid="tweetButtonInline"]',
201
- 'button[data-testid="tweetButton"]',
202
- '[role="button"]:has-text("Post")',
203
- '[role="button"]:has-text("Reply")',
389
+ '[data-testid="tweetButton"]:not([aria-disabled="true"])',
390
+ '[data-testid="tweetButtonInline"]:not([aria-disabled="true"])',
391
+ 'button[data-testid="tweetButton"]:not([aria-disabled="true"])',
392
+ '[role="button"]:has-text("Post"):not([aria-disabled="true"])',
393
+ '[role="button"]:has-text("Reply"):not([aria-disabled="true"])',
204
394
  ]);
205
395
  if (!clicked) {
206
396
  throw new Error(`Could not locate ${action} submit button. X may have changed selectors.`);
@@ -368,26 +558,53 @@ export class PlaywrightXClient {
368
558
  await page.goto(this.absolute(`/${normalized}`), {
369
559
  waitUntil: "domcontentloaded",
370
560
  });
371
- const actionsClicked = await this.clickFirstVisible(page, [
372
- '[data-testid="userActions"]',
373
- 'button[aria-label*="More"]',
374
- 'button[aria-label*="more"]',
375
- ], 15000);
376
- if (!actionsClicked) {
377
- throw new Error(`Could not open actions menu for @${normalized}. X may have changed selectors.`);
378
- }
379
- await page.waitForTimeout(300);
380
- const menuClicked = await this.clickFirstVisible(page, [
561
+ const listMenuSelectors = [
381
562
  '[role="menuitem"]:has-text("Add/remove from Lists")',
563
+ '[role="menuitem"]:has-text("Add/remove")',
382
564
  '[role="menuitem"]:has-text("Lists")',
383
565
  'button:has-text("Add/remove from Lists")',
384
- 'button:has-text("Lists")',
566
+ 'button:has-text("Add/remove")',
385
567
  'a:has-text("Add/remove from Lists")',
386
- ], 5000);
568
+ ];
569
+ const openProfileMenuWithButton = async (button) => {
570
+ await button.click();
571
+ await page.waitForTimeout(250);
572
+ const menuItem = await this.firstVisibleLocator(page, listMenuSelectors, 1200, 100);
573
+ if (menuItem)
574
+ return true;
575
+ await page.keyboard.press("Escape").catch(() => { });
576
+ await page.waitForTimeout(120);
577
+ return false;
578
+ };
579
+ // Prefer the dedicated profile actions button when available.
580
+ const profileActions = await this.firstVisibleLocator(page, ['main [data-testid="userActions"]', '[data-testid="userActions"]'], 15000);
581
+ let openedListMenu = false;
582
+ if (profileActions) {
583
+ openedListMenu = await openProfileMenuWithButton(profileActions);
584
+ }
585
+ // Fallback for UI variants where userActions is late/missing.
586
+ if (!openedListMenu) {
587
+ const fallbackButtons = page.locator('main button[aria-haspopup="menu"][aria-label*="More"]');
588
+ const fallbackCount = await fallbackButtons.count();
589
+ const scanLimit = Math.min(fallbackCount, 8);
590
+ for (let index = 0; index < scanLimit; index += 1) {
591
+ const button = fallbackButtons.nth(index);
592
+ if (!(await button.isVisible().catch(() => false)))
593
+ continue;
594
+ if (await openProfileMenuWithButton(button)) {
595
+ openedListMenu = true;
596
+ break;
597
+ }
598
+ }
599
+ }
600
+ if (!openedListMenu) {
601
+ throw new Error(`Could not locate profile list actions for @${normalized}. X may have changed selectors.`);
602
+ }
603
+ const menuClicked = await this.clickFirstVisible(page, listMenuSelectors, 5000);
387
604
  if (!menuClicked) {
388
605
  throw new Error(`Could not locate "Add/remove from Lists" menu item for @${normalized}.`);
389
606
  }
390
- const dialogSelector = '[role="dialog"]:has(div[role="checkbox"])';
607
+ const dialogSelector = '[role="dialog"]:has([role="checkbox"]):visible';
391
608
  await page.waitForSelector(dialogSelector, { timeout: 15000 });
392
609
  return dialogSelector;
393
610
  }
@@ -395,16 +612,18 @@ export class PlaywrightXClient {
395
612
  try {
396
613
  const outcome = await this.retryWithBackoff(async () => {
397
614
  const dialogSelector = await this.openListMembershipDialog(page, handle);
398
- const listCheckboxes = page.locator(`${dialogSelector} div[role="checkbox"]`);
615
+ const listCheckboxes = page.locator(`${dialogSelector} [role="checkbox"]`);
399
616
  const count = await listCheckboxes.count();
400
617
  if (count === 0) {
401
618
  throw new Error("List dialog opened without visible checkboxes.");
402
619
  }
403
620
  let checkbox = null;
621
+ const desiredName = listName.trim().toLowerCase();
404
622
  for (let index = 0; index < count; index += 1) {
405
623
  const candidate = listCheckboxes.nth(index);
406
624
  const text = await candidate.innerText().catch(() => "");
407
- if (text.trim().toLowerCase() === listName.trim().toLowerCase()) {
625
+ const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
626
+ if (normalized === desiredName || normalized.includes(desiredName)) {
408
627
  checkbox = candidate;
409
628
  break;
410
629
  }
@@ -447,6 +666,7 @@ export class PlaywrightXClient {
447
666
  hasAuthToken: probe.hasAuthToken,
448
667
  hasCt0: probe.hasCt0,
449
668
  authFile: this.sessions.getAuthStore().authFile,
669
+ diagnostics: probe.diagnostics,
450
670
  };
451
671
  }
452
672
  async whoami() {
@@ -485,6 +705,87 @@ export class PlaywrightXClient {
485
705
  return ok("Tweet posted.");
486
706
  });
487
707
  }
708
+ async publishArticle(title, body) {
709
+ const cleanTitle = title.trim();
710
+ const cleanBody = body.trim();
711
+ if (!cleanTitle) {
712
+ return fail("Article title cannot be empty.");
713
+ }
714
+ if (!cleanBody) {
715
+ return fail("Article body cannot be empty.");
716
+ }
717
+ return this.withPage(async (page) => {
718
+ await this.ensureAuth(page);
719
+ const composePaths = [
720
+ "/i/articles/new",
721
+ "/compose/article",
722
+ "/write",
723
+ "/i/write",
724
+ "/i/notes/new",
725
+ "/compose/post",
726
+ ];
727
+ const titleSelectors = [
728
+ 'input[data-testid="articleTitleInput"]',
729
+ 'textarea[data-testid="articleTitleInput"]',
730
+ '[data-testid="article-editor-title"] [contenteditable="true"]',
731
+ '[role="textbox"][aria-label*="Title"]',
732
+ 'input[placeholder*="Title"]',
733
+ 'textarea[placeholder*="Title"]',
734
+ '[contenteditable="true"][aria-label*="Title"]',
735
+ ];
736
+ const bodySelectors = [
737
+ '[data-testid="articleBodyInput"]',
738
+ '[data-testid="article-editor"] [contenteditable="true"]',
739
+ '.ProseMirror[contenteditable="true"]',
740
+ '[role="textbox"][aria-label*="Write"]',
741
+ '[contenteditable="true"][aria-label*="Write"]',
742
+ '[role="textbox"][aria-label*="Body"]',
743
+ 'textarea[placeholder*="Write"]',
744
+ ];
745
+ let titleField = null;
746
+ let bodyField = null;
747
+ for (const path of composePaths) {
748
+ await page
749
+ .goto(this.absolute(path), { waitUntil: "domcontentloaded" })
750
+ .catch(() => { });
751
+ await this.dismissBlockingLayers(page);
752
+ await this.clickFirstVisible(page, [
753
+ '[role="tab"]:has-text("Article")',
754
+ '[role="button"]:has-text("Write article")',
755
+ '[data-testid="articleComposerButton"]',
756
+ 'a[href*="/i/articles/new"]',
757
+ ], 1500).catch(() => false);
758
+ titleField = await this.firstVisibleLocator(page, titleSelectors, 3500, 120);
759
+ bodyField = await this.firstVisibleLocator(page, bodySelectors, 3500, 120);
760
+ if (titleField && bodyField) {
761
+ break;
762
+ }
763
+ }
764
+ if (!titleField || !bodyField) {
765
+ return fail("Could not locate article composer. Articles may be unavailable for this account or X changed selectors.");
766
+ }
767
+ await titleField.click().catch(() => { });
768
+ await titleField.fill(cleanTitle);
769
+ await bodyField.click().catch(() => { });
770
+ await bodyField.fill(cleanBody);
771
+ const published = await this.clickFirstVisible(page, [
772
+ '[data-testid="articlePublishButton"]:not([aria-disabled="true"])',
773
+ '[data-testid="publishButton"]:not([aria-disabled="true"])',
774
+ '[role="button"]:has-text("Publish"):not([aria-disabled="true"])',
775
+ '[role="button"]:has-text("Post"):not([aria-disabled="true"])',
776
+ ], 12000).catch(() => false);
777
+ if (!published) {
778
+ return fail("Could not locate article publish button.");
779
+ }
780
+ await this.clickFirstVisible(page, [
781
+ '[data-testid="confirmationSheetConfirm"]',
782
+ '[role="button"]:has-text("Publish")',
783
+ '[role="button"]:has-text("Post")',
784
+ ], 3000).catch(() => false);
785
+ await page.waitForTimeout(1000);
786
+ return ok("Article published.");
787
+ });
788
+ }
488
789
  async reply(tweetRef, text) {
489
790
  let mediaSpecs = [];
490
791
  try {
@@ -537,26 +838,33 @@ export class PlaywrightXClient {
537
838
  return this.withPage(async (page) => {
538
839
  await this.ensureAuth(page);
539
840
  await this.openTweet(page, tweetRef);
540
- const undo = page
541
- .locator('[data-testid="unretweet"], button[aria-label*="Undo repost"], div[aria-label*="Undo repost"]')
542
- .first();
543
- if (await undo.isVisible().catch(() => false)) {
841
+ const undoSelectors = [
842
+ '[data-testid="unretweet"]',
843
+ 'button[aria-label*="Undo repost"]',
844
+ 'div[aria-label*="Undo repost"]',
845
+ ];
846
+ if (await this.firstVisibleLocator(page, undoSelectors, 1200, 150)) {
544
847
  return ok("Tweet already reposted.");
545
848
  }
546
- const button = page
547
- .locator('[data-testid="retweet"], button[aria-label*="Repost"], div[aria-label*="Repost"]')
548
- .first();
549
- if (!(await button.isVisible().catch(() => false))) {
849
+ const repostClicked = await this.clickFirstVisible(page, [
850
+ '[data-testid="retweet"]',
851
+ 'button[aria-label*="Repost"]',
852
+ 'div[aria-label*="Repost"]',
853
+ ]).catch(() => false);
854
+ if (!repostClicked) {
550
855
  return fail("Could not locate repost button.");
551
856
  }
552
- await button.click();
553
- const confirm = page
554
- .locator('[data-testid="retweetConfirm"], [role="menuitem"]:has-text("Repost"), [role="button"]:has-text("Repost")')
555
- .first();
556
- if (await confirm.isVisible().catch(() => false)) {
557
- await confirm.click();
857
+ await page.waitForTimeout(250);
858
+ const confirmClicked = await this.clickFirstVisible(page, [
859
+ '[data-testid="retweetConfirm"]',
860
+ '[role="menuitem"]:has-text("Repost")',
861
+ '[role="dialog"] [role="button"]:has-text("Repost")',
862
+ ], 2500).catch(() => false);
863
+ const nowReposted = await this.firstVisibleLocator(page, undoSelectors, 5000, 150);
864
+ if (nowReposted || confirmClicked) {
865
+ return ok("Tweet reposted.");
558
866
  }
559
- return ok("Tweet reposted.");
867
+ return fail("Repost action did not complete. X may require additional confirmation.");
560
868
  });
561
869
  }
562
870
  async read(tweetRef) {
@@ -963,12 +1271,14 @@ export class PlaywrightXClient {
963
1271
  async refresh() {
964
1272
  const refreshed = await this.sessions.refreshAuth(this.options);
965
1273
  const loggedIn = await this.withPage(async (page) => this.sessions.ensureLoggedIn(page, this.baseUrl));
1274
+ const diagnostics = this.sessions.getAuthStore().getLastDiagnostics();
966
1275
  return {
967
1276
  loggedIn,
968
1277
  source: refreshed?.source ?? "none",
969
1278
  hasAuthToken: Boolean(refreshed?.cookies.some((cookie) => cookie.name === "auth_token")),
970
1279
  hasCt0: Boolean(refreshed?.cookies.some((cookie) => cookie.name === "ct0")),
971
1280
  authFile: this.sessions.getAuthStore().authFile,
1281
+ diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
972
1282
  };
973
1283
  }
974
1284
  async addToList(listName, handles, headlessOverride) {
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import { parseBookmarkOptions, parseFollowListOptions, parseJsonFlag, parseListOptions, parseMentionOptions, parseNewsOptions, parsePaginationOptions, parseTimelineOptions, } from "../lib/options.js";
2
3
  function parseRawArgs(options) {
3
4
  if (options && typeof options === "object") {
@@ -84,6 +85,11 @@ export function createHandlers({ client, output }) {
84
85
  output.info(`auth_token: ${status.hasAuthToken ? "present" : "missing"}`);
85
86
  output.info(`ct0: ${status.hasCt0 ? "present" : "missing"}`);
86
87
  output.info(`Auth file: ${status.authFile}`);
88
+ if (status.diagnostics) {
89
+ for (const diagnostic of status.diagnostics) {
90
+ output.warn(diagnostic);
91
+ }
92
+ }
87
93
  },
88
94
  whoami: async (options) => {
89
95
  const json = parseJsonFlag(parseRawArgs(options));
@@ -98,6 +104,22 @@ export function createHandlers({ client, output }) {
98
104
  tweet: async (text) => {
99
105
  output.mutation(await client.tweet(text));
100
106
  },
107
+ article: async (title, body, options) => {
108
+ const raw = parseRawArgs(options);
109
+ const bodyFile = typeof raw.bodyFile === "string" ? raw.bodyFile.trim() : "";
110
+ let content = body?.trim() ?? "";
111
+ if (bodyFile) {
112
+ const fromFile = fs.readFileSync(bodyFile, "utf8").trim();
113
+ if (!fromFile) {
114
+ throw new Error(`Article body file is empty: ${bodyFile}`);
115
+ }
116
+ content = content ? `${content}\n\n${fromFile}` : fromFile;
117
+ }
118
+ if (!content) {
119
+ throw new Error("Article body is required. Provide [body] or --body-file <path>.");
120
+ }
121
+ output.mutation(await client.publishArticle(title, content));
122
+ },
101
123
  reply: async (tweetRef, text) => {
102
124
  output.mutation(await client.reply(tweetRef, text));
103
125
  },
@@ -268,6 +290,11 @@ export function createHandlers({ client, output }) {
268
290
  output.info(`Source: ${result.source}`);
269
291
  output.info(`auth_token: ${result.hasAuthToken ? "present" : "missing"}`);
270
292
  output.info(`ct0: ${result.hasCt0 ? "present" : "missing"}`);
293
+ if (result.diagnostics) {
294
+ for (const diagnostic of result.diagnostics) {
295
+ output.warn(diagnostic);
296
+ }
297
+ }
271
298
  }
272
299
  },
273
300
  add: async (listName, handles, options) => {
@@ -2,6 +2,7 @@ import { looksLikeTweetReference } from "./identifiers.js";
2
2
  export const KNOWN_COMMANDS = new Set([
3
3
  "tweet",
4
4
  "post",
5
+ "article",
5
6
  "reply",
6
7
  "read",
7
8
  "replies",
@@ -56,6 +56,7 @@ export function parseGlobalOptions(raw) {
56
56
  firefoxProfile: raw.firefoxProfile ? String(raw.firefoxProfile) : undefined,
57
57
  cookieTimeout: parseOptionalPositiveInt(raw.cookieTimeout),
58
58
  cookieSource: parseCookieSources(raw.cookieSource),
59
+ cookieSourceExplicit: Boolean(raw.cookieSourceExplicit),
59
60
  timeout: parseOptionalPositiveInt(raw.timeout),
60
61
  quoteDepth: parseOptionalPositiveInt(raw.quoteDepth),
61
62
  plain: Boolean(raw.plain),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "frigatebird",
3
- "version": "0.2.0",
4
- "description": "Playwright-first X CLI with bird and x-list-manager parity",
3
+ "version": "0.3.0",
4
+ "description": "Playwright-first X CLI with bird-style parity and resilient web automation",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
7
7
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "test:run": "vitest run --config vitest.config.ts",
43
43
  "test:coverage": "vitest run --config vitest.config.ts --coverage",
44
44
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
45
+ "test:e2e:live": "FRIGATEBIRD_LIVE_E2E=1 vitest run --config vitest.e2e.config.ts tests/e2e/live-mutation-account.e2e.test.ts",
45
46
  "lint": "biome check .",
46
47
  "lint:fix": "biome check --write .",
47
48
  "release:check": "npm run lint && npm run build && npm run test:coverage",