xbird-mcp 0.1.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.

Potentially problematic release.


This version of xbird-mcp might be problematic. Click here for more details.

Files changed (85) hide show
  1. package/docs/listing.md +42 -0
  2. package/docs/mcp-setup.md +146 -0
  3. package/package.json +50 -0
  4. package/src/cli/context.ts +80 -0
  5. package/src/cli/output.ts +33 -0
  6. package/src/cli/program.ts +63 -0
  7. package/src/cli.ts +8 -0
  8. package/src/commands/about.ts +30 -0
  9. package/src/commands/bookmark.ts +40 -0
  10. package/src/commands/bookmarks-list.ts +108 -0
  11. package/src/commands/check.ts +27 -0
  12. package/src/commands/engagement.ts +31 -0
  13. package/src/commands/follow.ts +40 -0
  14. package/src/commands/followers.ts +132 -0
  15. package/src/commands/home.ts +59 -0
  16. package/src/commands/likes.ts +73 -0
  17. package/src/commands/lists.ts +101 -0
  18. package/src/commands/mentions.ts +32 -0
  19. package/src/commands/news.ts +38 -0
  20. package/src/commands/read.ts +36 -0
  21. package/src/commands/replies.ts +59 -0
  22. package/src/commands/search.ts +60 -0
  23. package/src/commands/tweet.ts +128 -0
  24. package/src/commands/user-tweets.ts +73 -0
  25. package/src/commands/user.ts +20 -0
  26. package/src/commands/whoami.ts +26 -0
  27. package/src/formatters/common.ts +49 -0
  28. package/src/formatters/list.ts +22 -0
  29. package/src/formatters/news.ts +23 -0
  30. package/src/formatters/tweet.ts +38 -0
  31. package/src/formatters/user.ts +20 -0
  32. package/src/index.ts +26 -0
  33. package/src/lib/auth.ts +105 -0
  34. package/src/lib/client-bookmarks.ts +62 -0
  35. package/src/lib/client-engagement.ts +152 -0
  36. package/src/lib/client-follow.ts +90 -0
  37. package/src/lib/client-full.ts +48 -0
  38. package/src/lib/client-likes.ts +62 -0
  39. package/src/lib/client-lists.ts +227 -0
  40. package/src/lib/client-media.ts +162 -0
  41. package/src/lib/client-news.ts +185 -0
  42. package/src/lib/client-posting.ts +163 -0
  43. package/src/lib/client-reading.ts +452 -0
  44. package/src/lib/client-search.ts +156 -0
  45. package/src/lib/client-timeline.ts +98 -0
  46. package/src/lib/client-user.ts +518 -0
  47. package/src/lib/client.ts +134 -0
  48. package/src/lib/config.ts +22 -0
  49. package/src/lib/constants.ts +55 -0
  50. package/src/lib/cookies.ts +132 -0
  51. package/src/lib/features.ts +175 -0
  52. package/src/lib/headers.ts +28 -0
  53. package/src/lib/paginate.ts +39 -0
  54. package/src/lib/query-ids.ts +190 -0
  55. package/src/lib/types.ts +147 -0
  56. package/src/lib/utils.ts +176 -0
  57. package/src/mcp/executor.ts +178 -0
  58. package/src/mcp/payment.ts +38 -0
  59. package/src/mcp/server.ts +53 -0
  60. package/src/mcp/tools.ts +389 -0
  61. package/src/server/app.ts +117 -0
  62. package/src/server/config/accounts.ts +137 -0
  63. package/src/server/config/pricing.ts +217 -0
  64. package/src/server/erc8004/register.ts +77 -0
  65. package/src/server/middleware/account-pool.ts +101 -0
  66. package/src/server/middleware/error-handler.ts +27 -0
  67. package/src/server/middleware/payer-extract.ts +43 -0
  68. package/src/server/middleware/x402.ts +61 -0
  69. package/src/server/routes/accounts.ts +93 -0
  70. package/src/server/routes/authorize.ts +19 -0
  71. package/src/server/routes/bookmarks.ts +21 -0
  72. package/src/server/routes/engagement.ts +84 -0
  73. package/src/server/routes/follow.ts +32 -0
  74. package/src/server/routes/health.ts +14 -0
  75. package/src/server/routes/lists.ts +38 -0
  76. package/src/server/routes/media.ts +44 -0
  77. package/src/server/routes/mentions.ts +20 -0
  78. package/src/server/routes/news.ts +23 -0
  79. package/src/server/routes/search.ts +26 -0
  80. package/src/server/routes/timeline.ts +21 -0
  81. package/src/server/routes/tweets.ts +82 -0
  82. package/src/server/routes/users.ts +92 -0
  83. package/src/server/storage/accounts-db.ts +84 -0
  84. package/src/server.ts +34 -0
  85. package/tsconfig.json +29 -0
@@ -0,0 +1,128 @@
1
+ import type { Command } from "commander";
2
+ import { createContext } from "../cli/context.ts";
3
+ import { XClient } from "../lib/client-full.ts";
4
+ import { output, error } from "../cli/output.ts";
5
+ import { extractTweetId } from "../lib/utils.ts";
6
+ import { dim, emoji, green } from "../formatters/common.ts";
7
+ import { readFileSync } from "node:fs";
8
+ import { extname } from "node:path";
9
+
10
+ const MIME_TYPES: Record<string, string> = {
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".png": "image/png",
14
+ ".gif": "image/gif",
15
+ ".webp": "image/webp",
16
+ ".mp4": "video/mp4",
17
+ ".mov": "video/quicktime",
18
+ };
19
+
20
+ const VIDEO_EXTS = new Set([".mp4", ".mov"]);
21
+
22
+ async function uploadMediaFile(
23
+ client: InstanceType<typeof XClient>,
24
+ filePath: string,
25
+ alt?: string
26
+ ): Promise<string> {
27
+ const ext = extname(filePath).toLowerCase();
28
+ const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
29
+ const data = readFileSync(filePath);
30
+ const result = await client.uploadMedia({ data: new Uint8Array(data), mimeType, alt });
31
+ if (!result.success || !result.mediaId) {
32
+ throw new Error(result.error ?? "Failed to upload media");
33
+ }
34
+ return result.mediaId;
35
+ }
36
+
37
+ function validateMediaFiles(files: string[]): void {
38
+ const hasVideo = files.some(f => VIDEO_EXTS.has(extname(f).toLowerCase()));
39
+ if (hasVideo && files.length > 1) {
40
+ throw new Error("Only 1 video file is allowed. Cannot mix video with other media.");
41
+ }
42
+ if (files.length > 4) {
43
+ throw new Error("Maximum 4 image files allowed.");
44
+ }
45
+ }
46
+
47
+ async function uploadAllMedia(
48
+ client: InstanceType<typeof XClient>,
49
+ files: string[],
50
+ altTexts?: string[]
51
+ ): Promise<string[]> {
52
+ validateMediaFiles(files);
53
+ const mediaIds: string[] = [];
54
+ for (let i = 0; i < files.length; i++) {
55
+ const mediaId = await uploadMediaFile(client, files[i]!, altTexts?.[i]);
56
+ mediaIds.push(mediaId);
57
+ }
58
+ return mediaIds;
59
+ }
60
+
61
+ export function registerTweet(program: Command): void {
62
+ program
63
+ .command("tweet <text>")
64
+ .description("Post a new tweet")
65
+ .option("--media <files...>", "Attach media files (images or video)")
66
+ .option("--alt <texts...>", "Alt text for each media file")
67
+ .action(async function (this: Command, text: string) {
68
+ const ctx = await createContext(this);
69
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
70
+ const opts = this.opts();
71
+
72
+ let mediaIds: string[] | undefined;
73
+ if (opts.media) {
74
+ mediaIds = await uploadAllMedia(client, opts.media, opts.alt);
75
+ }
76
+
77
+ const result = await client.tweet(text, mediaIds);
78
+ if (!result.success) error(result.error ?? "Failed to post tweet");
79
+ if (ctx.format === "json" || ctx.format === "json-full") {
80
+ output({ tweetId: result.tweetId, url: `https://x.com/i/status/${result.tweetId}` }, ctx.format);
81
+ } else {
82
+ console.log(`${green(emoji("✓"))} Tweet posted: ${dim(`https://x.com/i/status/${result.tweetId}`)}`);
83
+ }
84
+ });
85
+
86
+ program
87
+ .command("reply <id> <text>")
88
+ .description("Reply to a tweet")
89
+ .option("--media <files...>", "Attach media files (images or video)")
90
+ .option("--alt <texts...>", "Alt text for each media file")
91
+ .action(async function (this: Command, id: string, text: string) {
92
+ const ctx = await createContext(this);
93
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
94
+ const opts = this.opts();
95
+ const tweetId = extractTweetId(id);
96
+
97
+ let mediaIds: string[] | undefined;
98
+ if (opts.media) {
99
+ mediaIds = await uploadAllMedia(client, opts.media, opts.alt);
100
+ }
101
+
102
+ const result = await client.reply(text, tweetId, mediaIds);
103
+ if (!result.success) error(result.error ?? "Failed to post reply");
104
+ if (ctx.format === "json" || ctx.format === "json-full") {
105
+ output({ tweetId: result.tweetId, url: `https://x.com/i/status/${result.tweetId}` }, ctx.format);
106
+ } else {
107
+ console.log(`${green(emoji("✓"))} Reply posted: ${dim(`https://x.com/i/status/${result.tweetId}`)}`);
108
+ }
109
+ });
110
+
111
+ program
112
+ .command("thread <texts...>")
113
+ .description("Post a thread (multiple tweets)")
114
+ .action(async function (this: Command, texts: string[]) {
115
+ const ctx = await createContext(this);
116
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
117
+ const result = await client.thread(texts);
118
+ if (!result.success) error(result.error ?? "Failed to post thread");
119
+ if (ctx.format === "json") {
120
+ output({ tweetIds: result.tweetIds, urls: result.tweetIds?.map(id => `https://x.com/i/status/${id}`) }, ctx.format);
121
+ } else {
122
+ console.log(`${green(emoji("✓"))} Thread posted (${result.tweetIds?.length} tweets)`);
123
+ for (const tid of result.tweetIds ?? []) {
124
+ console.log(` ${dim(`https://x.com/i/status/${tid}`)}`);
125
+ }
126
+ }
127
+ });
128
+ }
@@ -0,0 +1,73 @@
1
+ import type { Command } from "commander";
2
+ import { createContext } from "../cli/context.ts";
3
+ import { XClient } from "../lib/client-full.ts";
4
+ import { output, error } from "../cli/output.ts";
5
+ import { normalizeHandle } from "../lib/utils.ts";
6
+ import { formatTweet } from "../formatters/tweet.ts";
7
+ import { autoPaginate } from "../lib/paginate.ts";
8
+
9
+ export function registerUserTweets(program: Command): void {
10
+ program
11
+ .command("user-tweets <handle>")
12
+ .description("Show tweets from a user")
13
+ .option("-n, --count <number>", "Number of tweets", "20")
14
+ .option("--cursor <cursor>", "Pagination cursor")
15
+ .option("--all", "Fetch all pages")
16
+ .option("--max-pages <n>", "Max pages to fetch")
17
+ .option("--delay <ms>", "Delay between pages in ms")
18
+ .action(async function (this: Command, handle: string) {
19
+ const ctx = await createContext(this);
20
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
21
+ const opts = this.opts();
22
+ const count = parseInt(opts.count, 10);
23
+ const screenName = normalizeHandle(handle);
24
+
25
+ // Resolve user ID
26
+ const userResult = await client.getUserByHandle(screenName);
27
+ if (!userResult.success || !userResult.user) {
28
+ error(userResult.error ?? `User @${screenName} not found`);
29
+ }
30
+
31
+ if (opts.all || opts.maxPages) {
32
+ const result = await autoPaginate(
33
+ async (cursor?) => {
34
+ const r = await client.getUserTweets(userResult.user!.id, count, cursor);
35
+ return { items: r.tweets, cursor: r.cursor };
36
+ },
37
+ {
38
+ all: opts.all,
39
+ maxPages: opts.maxPages ? parseInt(opts.maxPages, 10) : undefined,
40
+ delay: opts.delay ? parseInt(opts.delay, 10) : undefined,
41
+ }
42
+ );
43
+
44
+ if (ctx.format === "json" || ctx.format === "json-full") {
45
+ output({ tweets: result.items, cursor: result.cursor }, "json");
46
+ } else {
47
+ for (const tweet of result.items) {
48
+ console.log(formatTweet(tweet));
49
+ console.log("---");
50
+ }
51
+ if (result.items.length === 0) console.log("No tweets found.");
52
+ }
53
+ } else {
54
+ const result = await client.getUserTweets(
55
+ userResult.user!.id,
56
+ count,
57
+ opts.cursor
58
+ );
59
+ if (!result.success) error(result.error ?? "Failed to load user tweets");
60
+
61
+ if (ctx.format === "json") {
62
+ output({ tweets: result.tweets, cursor: result.cursor }, ctx.format);
63
+ } else {
64
+ for (const tweet of result.tweets) {
65
+ console.log(formatTweet(tweet));
66
+ console.log("---");
67
+ }
68
+ if (result.tweets.length === 0) console.log("No tweets found.");
69
+ if (result.cursor) console.log(`\nNext cursor: ${result.cursor}`);
70
+ }
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,20 @@
1
+ import type { Command } from "commander";
2
+ import { createContext } from "../cli/context.ts";
3
+ import { XClient } from "../lib/client-full.ts";
4
+ import { output, error } from "../cli/output.ts";
5
+ import { normalizeHandle } from "../lib/utils.ts";
6
+ import { formatUser } from "../formatters/user.ts";
7
+
8
+ export function registerUser(program: Command): void {
9
+ program
10
+ .command("user <handle>")
11
+ .description("Show user profile info")
12
+ .action(async function (this: Command, handle: string) {
13
+ const ctx = await createContext(this);
14
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
15
+ const normalized = normalizeHandle(handle);
16
+ const result = await client.getUserByHandle(normalized);
17
+ if (!result.success || !result.user) error(result.error ?? "User not found");
18
+ output(result.user, ctx.format, (data) => formatUser(data as any));
19
+ });
20
+ }
@@ -0,0 +1,26 @@
1
+ import type { Command } from "commander";
2
+ import { createContext } from "../cli/context.ts";
3
+ import { XClient } from "../lib/client-full.ts";
4
+ import { output, error } from "../cli/output.ts";
5
+ import { formatUser } from "../formatters/user.ts";
6
+ import { bold, dim } from "../formatters/common.ts";
7
+
8
+ export function registerWhoami(program: Command): void {
9
+ program
10
+ .command("whoami")
11
+ .description("Show the authenticated account")
12
+ .action(async function (this: Command) {
13
+ const ctx = await createContext(this);
14
+ const client = new XClient(ctx.credentials, ctx.timeoutMs);
15
+ const result = await client.getCurrentUser();
16
+ if (!result.success || !result.user) {
17
+ error(result.error ?? "Failed to get current user");
18
+ }
19
+ const user = result.user!;
20
+ if (ctx.format === "json") {
21
+ output(user, ctx.format);
22
+ } else {
23
+ console.log(`${bold(user.name)} ${dim("@" + user.screenName)} ${dim("(id: " + user.id + ")")}`);
24
+ }
25
+ });
26
+ }
@@ -0,0 +1,49 @@
1
+ import kleur from "kleur";
2
+
3
+ let emojiEnabled = true;
4
+
5
+ export function setEmojiEnabled(enabled: boolean): void {
6
+ emojiEnabled = enabled;
7
+ }
8
+
9
+ const EMOJI_MAP: Record<string, string> = {
10
+ "❤": "[L]",
11
+ "🔁": "[RT]",
12
+ "💬": "[R]",
13
+ "👁": "[V]",
14
+ "✓": "[v]",
15
+ "📍": "[loc]",
16
+ };
17
+
18
+ export function emoji(char: string, fallback?: string): string {
19
+ if (emojiEnabled) return char;
20
+ return fallback ?? EMOJI_MAP[char] ?? char;
21
+ }
22
+
23
+ export function dim(text: string): string {
24
+ return kleur.dim(text);
25
+ }
26
+
27
+ export function bold(text: string): string {
28
+ return kleur.bold(text);
29
+ }
30
+
31
+ export function cyan(text: string): string {
32
+ return kleur.cyan(text);
33
+ }
34
+
35
+ export function green(text: string): string {
36
+ return kleur.green(text);
37
+ }
38
+
39
+ export function red(text: string): string {
40
+ return kleur.red(text);
41
+ }
42
+
43
+ export function yellow(text: string): string {
44
+ return kleur.yellow(text);
45
+ }
46
+
47
+ export function gray(text: string): string {
48
+ return kleur.gray(text);
49
+ }
@@ -0,0 +1,22 @@
1
+ import type { TwitterList } from "../lib/types.ts";
2
+ import { formatCount } from "../lib/utils.ts";
3
+ import { bold, dim } from "./common.ts";
4
+
5
+ export function formatList(list: TwitterList): string {
6
+ const header = `${bold(list.name)} ${dim(list.isPrivate ? "(private)" : "(public)")}`;
7
+ const desc = list.description || dim("No description");
8
+ const stats = dim(
9
+ `Members: ${formatCount(list.memberCount)} Subscribers: ${formatCount(list.subscriberCount)}`
10
+ );
11
+ const owner = list.owner
12
+ ? dim(`Owner: @${list.owner.screenName}`)
13
+ : "";
14
+ const parts = [header, desc, stats];
15
+ if (owner) parts.push(owner);
16
+ return parts.join("\n");
17
+ }
18
+
19
+ export function formatListCompact(list: TwitterList): string {
20
+ const privacy = list.isPrivate ? " (private)" : "";
21
+ return `${bold(list.name)}${privacy} — ${formatCount(list.memberCount)} members`;
22
+ }
@@ -0,0 +1,23 @@
1
+ import type { NewsItem } from "../lib/types.ts";
2
+ import { formatCount } from "../lib/utils.ts";
3
+ import { bold, dim } from "./common.ts";
4
+
5
+ export function formatNewsItem(item: NewsItem): string {
6
+ const headline = bold(item.headline);
7
+ const category = dim(item.category);
8
+ const posts = item.postCount != null
9
+ ? dim(`${formatCount(item.postCount)} posts`)
10
+ : "";
11
+ const desc = item.description ? dim(item.description) : "";
12
+ const parts = [headline, category];
13
+ if (posts) parts.push(posts);
14
+ if (desc && desc !== posts) parts.push(desc);
15
+ return parts.join("\n");
16
+ }
17
+
18
+ export function formatNewsItemCompact(item: NewsItem): string {
19
+ const posts = item.postCount != null
20
+ ? ` (${formatCount(item.postCount)} posts)`
21
+ : "";
22
+ return `${bold(item.headline)} — ${item.category}${posts}`;
23
+ }
@@ -0,0 +1,38 @@
1
+ import type { Tweet } from "../lib/types.ts";
2
+ import { formatCount, formatDate, tweetUrl } from "../lib/utils.ts";
3
+ import { bold, cyan, dim, emoji, gray } from "./common.ts";
4
+
5
+ let defaultQuoteDepth = 1;
6
+
7
+ export function setQuoteDepth(depth: number): void {
8
+ defaultQuoteDepth = Math.max(0, Math.min(3, depth));
9
+ }
10
+
11
+ export function formatTweet(tweet: Tweet, depth?: number, current = 0): string {
12
+ const header = `${bold(tweet.authorName)} ${dim("@" + tweet.authorHandle)} · ${dim(formatDate(tweet.createdAt))}`;
13
+ const text = tweet.text;
14
+ const stats = dim(
15
+ `${emoji("❤")} ${formatCount(tweet.likeCount)} ${emoji("🔁")} ${formatCount(tweet.retweetCount)} ${emoji("💬")} ${formatCount(tweet.replyCount)} ${emoji("👁")} ${formatCount(tweet.viewCount)}`
16
+ );
17
+ const url = dim(tweetUrl(tweet.authorHandle, tweet.id));
18
+ let result = `${header}\n${text}\n${stats}\n${url}`;
19
+
20
+ const d = depth ?? defaultQuoteDepth;
21
+ if (tweet.quotedTweet && current < d) {
22
+ const quoted = formatTweet(tweet.quotedTweet, d, current + 1);
23
+ const indented = quoted.split("\n").map(l => ` ${dim("│")} ${l}`).join("\n");
24
+ result += "\n" + indented;
25
+ }
26
+
27
+ return result;
28
+ }
29
+
30
+ export function formatTweetCompact(tweet: Tweet): string {
31
+ const prefix = dim(`@${tweet.authorHandle}`);
32
+ const text = tweet.text.replace(/\n/g, " ").slice(0, 100);
33
+ return `${prefix}: ${text}`;
34
+ }
35
+
36
+ export function formatTweetPlain(tweet: Tweet): string {
37
+ return `@${tweet.authorHandle}: ${tweet.text}`;
38
+ }
@@ -0,0 +1,20 @@
1
+ import type { TwitterUser } from "../lib/types.ts";
2
+ import { formatCount, profileUrl } from "../lib/utils.ts";
3
+ import { bold, cyan, dim, emoji, gray } from "./common.ts";
4
+
5
+ export function formatUser(user: TwitterUser): string {
6
+ const header = `${bold(user.name)} ${dim("@" + user.screenName)}`;
7
+ const verified = user.isBlueVerified ? " " + emoji("✓") : "";
8
+ const bio = user.description || dim("No bio");
9
+ const stats = dim(
10
+ `Followers: ${formatCount(user.followersCount)} Following: ${formatCount(user.followingCount)} Tweets: ${formatCount(user.tweetCount)}`
11
+ );
12
+ const url = dim(profileUrl(user.screenName));
13
+ const parts = [`${header}${verified}`, bio, stats, url];
14
+ if (user.location) parts.splice(2, 0, dim(`${emoji("📍")} ${user.location}`));
15
+ return parts.join("\n");
16
+ }
17
+
18
+ export function formatUserCompact(user: TwitterUser): string {
19
+ return `${bold(user.name)} @${user.screenName} — ${formatCount(user.followersCount)} followers`;
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Library exports
2
+ export { XClient } from "./lib/client-full.ts";
3
+ export { TwitterClient } from "./lib/client.ts";
4
+ export { resolveCredentials } from "./lib/auth.ts";
5
+ export { extractBrowserCookies } from "./lib/cookies.ts";
6
+ export { getQueryId, refreshQueryIds } from "./lib/query-ids.ts";
7
+ export { extractCursorFromInstructions, parseUsersFromInstructions } from "./lib/utils.ts";
8
+ export { autoPaginate } from "./lib/paginate.ts";
9
+ export type { AutoPaginateOpts } from "./lib/paginate.ts";
10
+
11
+ export type {
12
+ Credentials,
13
+ Tweet,
14
+ TwitterUser,
15
+ TweetMedia,
16
+ TweetUrl,
17
+ SearchResult,
18
+ TimelineResult,
19
+ ThreadResult,
20
+ TwitterList,
21
+ NewsItem,
22
+ PaginatedResult,
23
+ OutputFormat,
24
+ CLIContext,
25
+ AboutInfo,
26
+ } from "./lib/types.ts";
@@ -0,0 +1,105 @@
1
+ import type { BrowserName } from "@steipete/sweet-cookie";
2
+ import type { Credentials } from "./types.ts";
3
+ import { extractBrowserCookies } from "./cookies.ts";
4
+
5
+ export interface ResolveOptions {
6
+ authToken?: string;
7
+ ct0?: string;
8
+ cookieSource?: BrowserName | BrowserName[];
9
+ chromeProfile?: string;
10
+ }
11
+
12
+ export interface ResolveResult {
13
+ credentials: Credentials | null;
14
+ warnings: string[];
15
+ }
16
+
17
+ /**
18
+ * Resolve Twitter credentials from multiple sources.
19
+ * Priority: CLI args -> env vars -> browser cookies.
20
+ */
21
+ export async function resolveCredentials(
22
+ options: ResolveOptions = {}
23
+ ): Promise<ResolveResult> {
24
+ const warnings: string[] = [];
25
+
26
+ // 1. CLI args
27
+ if (options.authToken && options.ct0) {
28
+ return {
29
+ credentials: {
30
+ authToken: options.authToken,
31
+ ct0: options.ct0,
32
+ source: "cli",
33
+ },
34
+ warnings,
35
+ };
36
+ }
37
+
38
+ // 2. Env vars
39
+ const envAuthToken =
40
+ options.authToken ||
41
+ process.env.TWITTER_AUTH_TOKEN ||
42
+ process.env.AUTH_TOKEN;
43
+ const envCt0 = options.ct0 || process.env.TWITTER_CT0 || process.env.CT0;
44
+
45
+ if (envAuthToken && envCt0) {
46
+ return {
47
+ credentials: {
48
+ authToken: envAuthToken,
49
+ ct0: envCt0,
50
+ source: options.authToken || options.ct0 ? "cli" : "env",
51
+ },
52
+ warnings,
53
+ };
54
+ }
55
+
56
+ // 3. Browser cookies
57
+ const browsers = options.cookieSource
58
+ ? Array.isArray(options.cookieSource)
59
+ ? options.cookieSource
60
+ : [options.cookieSource]
61
+ : undefined;
62
+
63
+ const result = await extractBrowserCookies(browsers, options.chromeProfile);
64
+ warnings.push(...result.warnings);
65
+
66
+ if (result.authToken && result.ct0) {
67
+ return {
68
+ credentials: {
69
+ authToken: result.authToken,
70
+ ct0: result.ct0,
71
+ source: "browser",
72
+ browser: result.source ?? undefined,
73
+ },
74
+ warnings,
75
+ };
76
+ }
77
+
78
+ // Partial credentials — try to combine env + browser
79
+ const finalAuthToken = envAuthToken || result.authToken;
80
+ const finalCt0 = envCt0 || result.ct0;
81
+
82
+ if (finalAuthToken && finalCt0) {
83
+ return {
84
+ credentials: {
85
+ authToken: finalAuthToken,
86
+ ct0: finalCt0,
87
+ source: "env",
88
+ },
89
+ warnings,
90
+ };
91
+ }
92
+
93
+ if (!finalAuthToken) {
94
+ warnings.push(
95
+ "Missing auth_token — provide via --auth-token, TWITTER_AUTH_TOKEN env var, or login to x.com in a browser"
96
+ );
97
+ }
98
+ if (!finalCt0) {
99
+ warnings.push(
100
+ "Missing ct0 — provide via --ct0, TWITTER_CT0 env var, or login to x.com in a browser"
101
+ );
102
+ }
103
+
104
+ return { credentials: null, warnings };
105
+ }
@@ -0,0 +1,62 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { bookmarksFeatures, fieldToggles } from "./features.ts";
3
+ import { parseTweetsFromInstructions } from "./client-reading.ts";
4
+ import { extractCursorFromInstructions } from "./utils.ts";
5
+ import type { Tweet } from "./types.ts";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
9
+
10
+ export function withBookmarks<T extends Constructor>(Base: T) {
11
+ return class extends Base {
12
+ async getBookmarks(
13
+ count?: number,
14
+ cursor?: string,
15
+ folderId?: string
16
+ ): Promise<{
17
+ success: boolean;
18
+ tweets: Tweet[];
19
+ cursor?: string;
20
+ error?: string;
21
+ }> {
22
+ try {
23
+ const variables: Record<string, unknown> = {
24
+ count: count ?? 20,
25
+ includePromotedContent: false,
26
+ withDownvotePerspective: false,
27
+ withReactionsMetadata: false,
28
+ withReactionsPerspective: false,
29
+ };
30
+ if (cursor) variables.cursor = cursor;
31
+ if (folderId) variables.bookmark_collection_id = folderId;
32
+
33
+ const { data, errors } = await this.graphqlGet(
34
+ "Bookmarks",
35
+ variables,
36
+ bookmarksFeatures(),
37
+ fieldToggles()
38
+ );
39
+
40
+ if (errors?.length) {
41
+ return { success: false, tweets: [], error: this.formatErrors(errors) };
42
+ }
43
+
44
+ const bookmarkTimeline = (data as Record<string, unknown>)?.bookmark_timeline_v2 as Record<string, unknown> | undefined;
45
+ const timeline = bookmarkTimeline?.timeline as Record<string, unknown> | undefined;
46
+ const instructions = timeline?.instructions as unknown[] | undefined;
47
+
48
+ if (!instructions) return { success: true, tweets: [] };
49
+
50
+ const tweets = parseTweetsFromInstructions(instructions);
51
+ const nextCursor = extractCursorFromInstructions(instructions);
52
+ return { success: true, tweets, cursor: nextCursor };
53
+ } catch (err) {
54
+ return {
55
+ success: false,
56
+ tweets: [],
57
+ error: err instanceof Error ? err.message : String(err),
58
+ };
59
+ }
60
+ }
61
+ };
62
+ }