x-relay-mcp 1.2.0 → 1.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.
@@ -116,6 +116,8 @@ xrelay quoters <id|url> [--limit N] # tweets quoting a tweet (reactions;
116
116
  xrelay trends [--woeid N] [--limit N] # what's hot now (woeid 1 = worldwide, default)
117
117
  xrelay article <id|url> # a long-form X Article → Markdown
118
118
  xrelay media <id|url> [--out <dir>] # a tweet's image/video URLs; --out downloads the files
119
+ xrelay community <community-id> [--limit N] # a community's tweet feed (topical, moderated sub-network)
120
+ xrelay community-info <community-id> # community metadata: name, members, rules, topic, creator
119
121
  ```
120
122
 
121
123
  - **`retweeters`/`followers`/`following`** return `{ users:[<profile>...], nextCursor }` — the
@@ -127,6 +129,11 @@ xrelay media <id|url> [--out <dir>] # a tweet's image/video URLs; --out
127
129
  - **`article`** returns `{ id, title, markdown, url }` — the full long-form read for a finalist.
128
130
  - **`media`** returns `{ tweetId, media:[{type,url,...}], files? }`; `--out <dir>` saves the actual
129
131
  image/video files (for OCR / transcription / multimodal analysis).
132
+ - **`community`** returns the community's feed as a normal `{ tweets[], nextCursor }` — a focused,
133
+ on-topic corpus that's often higher-signal than open search for a niche. **`community-info`** returns
134
+ `{ name, description, memberCount, moderatorCount, rules[], topic, tags[], creator, url }`. Get the
135
+ `community-id` from a community URL (`x.com/i/communities/<id>`). Note: X exposes no stable endpoint for
136
+ a full member roster or within-community search, so those aren't provided — use the feed + `search`.
130
137
 
131
138
  ---
132
139
 
@@ -154,3 +161,28 @@ npm i -g x-relay-mcp
154
161
  ```
155
162
  Cookies are read automatically from your logged-in browser (macOS Keychain). The first run may show a one-time
156
163
  Keychain "Always Allow" prompt. Assumes a residential IP (run locally); datacenter IPs are blocked by X.
164
+
165
+ ### Account pool + proxy (optional — for heavy/sustained use)
166
+
167
+ X rate-limits per account and blocks datacenter IPs. For deep sweeps, give the tool **several sessions, each
168
+ behind its own residential proxy**; it transparently fails over to the next session when one hits a rate-limit
169
+ (429) or its cookies expire — so a long research run survives a single account getting throttled. All optional;
170
+ the default single-browser-session path needs none of this.
171
+
172
+ ```
173
+ # Several accounts, each pinned to its own proxy (JSON array — the robust form):
174
+ export XRELAY_ACCOUNTS='[
175
+ {"cookies":"auth_token=..; ct0=..","proxy":"http://user:pass@host1:port","label":"main"},
176
+ {"cookies":"auth_token=..; ct0=..","proxy":"socks5://user:pass@host2:port"}
177
+ ]'
178
+
179
+ # Or a simpler newline list of cookie strings, with proxies round-robined onto them:
180
+ export XRELAY_ACCOUNTS=$'auth_token=..; ct0=..\nauth_token=..; ct0=..'
181
+ export XRELAY_PROXIES='http://host1:port, http://host2:port'
182
+
183
+ # Single browser session, just routed through one proxy:
184
+ export XRELAY_PROXY='http://user:pass@host:port'
185
+ ```
186
+
187
+ Rotation triggers on `RATE_LIMITED` / `AUTH_FAILED` only; other errors fail fast. Pair an equal number of
188
+ accounts and proxies for a clean 1:1 mapping. http(s) and socks proxies are both supported.
@@ -115,6 +115,24 @@ type Article = {
115
115
  url: string;
116
116
  author?: Author;
117
117
  };
118
+ /** An X Community's metadata (the `community-info` command). */
119
+ type Community = {
120
+ id: string;
121
+ name: string;
122
+ description?: string;
123
+ memberCount?: number;
124
+ moderatorCount?: number;
125
+ /** ISO timestamp (X reports created_at as epoch ms). */
126
+ createdAt?: string;
127
+ /** The viewer's role in the community (Member / Moderator / Admin / NonMember). */
128
+ role?: string;
129
+ joinPolicy?: string;
130
+ topic?: string;
131
+ rules?: string[];
132
+ tags?: string[];
133
+ creator?: Author;
134
+ url: string;
135
+ };
118
136
 
119
137
  /** The two load-bearing cookies, plus any others the jar carries. */
120
138
  type Cookies = {
@@ -197,6 +215,14 @@ declare const OPS: {
197
215
  readonly queryId: "Xl5pC_lBk_gcO2ItU39DQw";
198
216
  readonly operationName: "TweetResultByRestId";
199
217
  };
218
+ readonly CommunityByRestId: {
219
+ readonly queryId: "vLS7mhOqMLtGZdXqFP1DEg";
220
+ readonly operationName: "CommunityByRestId";
221
+ };
222
+ readonly CommunityTweetsTimeline: {
223
+ readonly queryId: "pXYASW5kVylF3YMrGJovLg";
224
+ readonly operationName: "CommunityTweetsTimeline";
225
+ };
200
226
  };
201
227
  type OpName = keyof typeof OPS;
202
228
  type Vars = Record<string, unknown>;
@@ -266,6 +292,8 @@ interface Engine {
266
292
  }): Promise<Trend[]>;
267
293
  article(tweetId: string): Promise<Article | null>;
268
294
  media(tweetId: string): Promise<MediaItem[]>;
295
+ community(communityId: string, opts?: PageOpts): Promise<TweetPage>;
296
+ communityInfo(communityId: string): Promise<Community | null>;
269
297
  /** The authenticated user's own @handle (from the session), or null. Memoized. */
270
298
  me(): Promise<string | null>;
271
299
  }
@@ -277,6 +305,8 @@ interface EngineDeps {
277
305
  sleep?: (ms: number) => Promise<void>;
278
306
  /** Injectable transport (tests). Defaults to a real client over the X API. */
279
307
  client?: EngineClient;
308
+ /** Injectable transport lanes (tests) — drives account-pool rotation. Overrides `client`. */
309
+ clients?: EngineClient[];
280
310
  /** Injectable transaction provider (tests). Defaults to the xctid generator. */
281
311
  transaction?: TransactionProvider;
282
312
  }
@@ -293,4 +323,4 @@ declare function parseArgs(argv: string[]): ParsedArgs;
293
323
  declare function dispatch(parsed: ParsedArgs, engine: Engine): Promise<Envelope<unknown>>;
294
324
  declare function run(argv: string[], engine?: Engine): Promise<number>;
295
325
 
296
- export { type Article as A, type Cookies as C, type Err as E, type MediaItem as M, type Ok as O, type PageOpts as P, type SearchResult as S, type Tweet as T, type UserPage as U, type Engine as a, type Envelope as b, type TweetPage as c, type SearchProduct as d, type ThreadResult as e, type Trend as f, type UserProfile as g, type Author as h, type EngineDeps as i, EngineError as j, type MediaKind as k, type Metrics as l, type SearchOpts as m, type SearchProduct$1 as n, type UserTweetsOpts as o, createEngine as p, dispatch as q, parseArgs as r, parseCookies as s, run as t, type ParsedArgs as u };
326
+ export { type Article as A, type Cookies as C, type Err as E, type MediaItem as M, type Ok as O, type PageOpts as P, type SearchResult as S, type Tweet as T, type UserPage as U, type Engine as a, type Envelope as b, type TweetPage as c, type SearchProduct as d, type ThreadResult as e, type Trend as f, type UserProfile as g, type Author as h, type Community as i, type EngineDeps as j, EngineError as k, type MediaKind as l, type Metrics as m, type SearchOpts as n, type SearchProduct$1 as o, type UserTweetsOpts as p, createEngine as q, dispatch as r, parseArgs as s, parseCookies as t, run as u, type ParsedArgs as v };
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export { u as ParsedArgs, q as dispatch, r as parseArgs, t as run } from './cli-B4ohNfa7.js';
2
+ export { v as ParsedArgs, r as dispatch, s as parseArgs, u as run } from './cli-DDbxyp9v.js';
package/dist/cli.js CHANGED
@@ -106,6 +106,18 @@ var COMMANDS = [
106
106
  cost: "medium",
107
107
  summary: "A tweet's image/video assets (URLs); --out <dir> downloads the files.",
108
108
  usage: "xrelay media <id|url> [--out <dir>]"
109
+ },
110
+ {
111
+ name: "community",
112
+ cost: "medium",
113
+ summary: "A community's tweet feed (a topical, moderated sub-network).",
114
+ usage: "xrelay community <community-id> [--limit N]"
115
+ },
116
+ {
117
+ name: "community-info",
118
+ cost: "1 call",
119
+ summary: "Community metadata: name, description, member/mod counts, rules, topic, creator.",
120
+ usage: "xrelay community-info <community-id>"
109
121
  }
110
122
  ];
111
123
  var commandNames = COMMANDS.map((c) => c.name);
@@ -364,7 +376,12 @@ var OPS = {
364
376
  Retweeters: { queryId: "TZsWuSj7vGmncVnq7KWDUQ", operationName: "Retweeters" },
365
377
  Favoriters: { queryId: "LLkw5EcVutJL6y-2gkz22A", operationName: "Favoriters" },
366
378
  GenericTimelineById: { queryId: "_dGVIf1cY6xFanFNPsAzPQ", operationName: "GenericTimelineById" },
367
- TweetResultByRestId: { queryId: "Xl5pC_lBk_gcO2ItU39DQw", operationName: "TweetResultByRestId" }
379
+ TweetResultByRestId: { queryId: "Xl5pC_lBk_gcO2ItU39DQw", operationName: "TweetResultByRestId" },
380
+ CommunityByRestId: { queryId: "vLS7mhOqMLtGZdXqFP1DEg", operationName: "CommunityByRestId" },
381
+ CommunityTweetsTimeline: {
382
+ queryId: "pXYASW5kVylF3YMrGJovLg",
383
+ operationName: "CommunityTweetsTimeline"
384
+ }
368
385
  };
369
386
  var FEATURES = {
370
387
  rweb_video_screen_enabled: false,
@@ -535,6 +552,24 @@ function tweetResultRequest(params) {
535
552
  fieldToggles: { withArticleRichContentState: true, withArticlePlainText: true }
536
553
  };
537
554
  }
555
+ function communityRequest(params) {
556
+ const { communityId, kv, ft } = params;
557
+ return {
558
+ variables: { communityId, ...kv },
559
+ features: { ...FEATURES, ...ft }
560
+ };
561
+ }
562
+ function communityTweetsRequest(params) {
563
+ const { communityId, count = 20, cursor, rankingMode = "Relevance", kv, ft } = params;
564
+ const variables = withCursor(
565
+ { communityId, count, displayLocation: "Community", rankingMode, withCommunity: true },
566
+ cursor
567
+ );
568
+ return {
569
+ variables: { ...variables, ...kv },
570
+ features: { ...FEATURES, ...ft }
571
+ };
572
+ }
538
573
  function stableStringify(value) {
539
574
  if (Array.isArray(value)) {
540
575
  return `[${value.map(stableStringify).join(",")}]`;
@@ -1394,6 +1429,118 @@ function articleAuthor(tweetResult) {
1394
1429
  return void 0;
1395
1430
  }
1396
1431
  }
1432
+ function authorFromProfile2(profile) {
1433
+ const author = {
1434
+ id: profile.id,
1435
+ handle: profile.handle,
1436
+ name: profile.name,
1437
+ verified: profile.verified,
1438
+ followers: profile.followers
1439
+ };
1440
+ if (profile.avatar !== void 0) author.avatar = profile.avatar;
1441
+ return author;
1442
+ }
1443
+ function ruleNames(raw) {
1444
+ if (!Array.isArray(raw)) return void 0;
1445
+ const out = [];
1446
+ for (const rule of raw) {
1447
+ const name = isRecord2(rule) ? asString2(rule.name) : void 0;
1448
+ if (name !== void 0) out.push(name);
1449
+ }
1450
+ return out.length > 0 ? out : void 0;
1451
+ }
1452
+ function stringArray(raw) {
1453
+ if (!Array.isArray(raw)) return void 0;
1454
+ const out = raw.filter((v) => typeof v === "string");
1455
+ return out.length > 0 ? out : void 0;
1456
+ }
1457
+ function communityCreator(result) {
1458
+ const node = child2(result, "creator_results")?.result;
1459
+ const profile = parseUserResult(node);
1460
+ return profile ? authorFromProfile2(profile) : void 0;
1461
+ }
1462
+ function applyCommunityOptionals(community, result) {
1463
+ const description = asString2(result.description);
1464
+ if (description !== void 0) community.description = description;
1465
+ const memberCount = asNumber2(result.member_count);
1466
+ if (memberCount !== void 0) community.memberCount = memberCount;
1467
+ const moderatorCount = asNumber2(result.moderator_count);
1468
+ if (moderatorCount !== void 0) community.moderatorCount = moderatorCount;
1469
+ const createdMs = asNumber2(result.created_at);
1470
+ if (createdMs !== void 0) community.createdAt = new Date(createdMs).toISOString();
1471
+ const role = asString2(result.role);
1472
+ if (role !== void 0) community.role = role;
1473
+ const joinPolicy = asString2(result.join_policy);
1474
+ if (joinPolicy !== void 0) community.joinPolicy = joinPolicy;
1475
+ const topic = asString2(child2(result, "primary_community_topic")?.topic_name);
1476
+ if (topic !== void 0) community.topic = topic;
1477
+ const rules = ruleNames(result.rules);
1478
+ if (rules !== void 0) community.rules = rules;
1479
+ const tags = stringArray(result.search_tags);
1480
+ if (tags !== void 0) community.tags = tags;
1481
+ const creator = communityCreator(result);
1482
+ if (creator !== void 0) community.creator = creator;
1483
+ }
1484
+ function parseCommunity(json) {
1485
+ const wrapper = findDict(json, "communityResults", true)[0];
1486
+ const result = isRecord2(wrapper) ? wrapper.result : void 0;
1487
+ if (!isRecord2(result)) return null;
1488
+ const id = asString2(result.id_str) ?? asString2(result.rest_id);
1489
+ const name = asString2(result.name);
1490
+ if (id === void 0 || name === void 0) return null;
1491
+ const community = { id, name, url: `https://x.com/i/communities/${id}` };
1492
+ applyCommunityOptionals(community, result);
1493
+ return community;
1494
+ }
1495
+
1496
+ // src/engine/pool.ts
1497
+ function parseProxyList(raw) {
1498
+ return raw.split(/[\n,]/).map((s) => s.trim()).filter((s) => s.length > 0);
1499
+ }
1500
+ function specFromJsonItem(item, index) {
1501
+ const cookieStr = typeof item === "string" ? item : String(item?.cookies ?? "");
1502
+ const spec = {
1503
+ cookies: parseCookies(cookieStr),
1504
+ label: typeof item === "object" && item !== null && typeof item.label === "string" ? String(item.label) : `acct${index + 1}`
1505
+ };
1506
+ const proxy = typeof item === "object" && item !== null ? item.proxy : void 0;
1507
+ if (typeof proxy === "string" && proxy.length > 0) spec.proxy = proxy;
1508
+ return spec;
1509
+ }
1510
+ function parseAccounts(raw) {
1511
+ const trimmed = raw.trim();
1512
+ if (trimmed.length === 0) return [];
1513
+ if (trimmed.startsWith("[")) {
1514
+ const parsed = JSON.parse(trimmed);
1515
+ if (!Array.isArray(parsed)) throw new Error("XRELAY_ACCOUNTS JSON must be an array");
1516
+ return parsed.map(specFromJsonItem);
1517
+ }
1518
+ return trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line, i) => ({ cookies: parseCookies(line), label: `acct${i + 1}` }));
1519
+ }
1520
+ function assignProxies(specs, proxies) {
1521
+ if (proxies.length === 0) return specs;
1522
+ return specs.map((spec, i) => {
1523
+ if (spec.proxy !== void 0) return spec;
1524
+ const proxy = proxies[i % proxies.length];
1525
+ return proxy === void 0 ? spec : { ...spec, proxy };
1526
+ });
1527
+ }
1528
+ function makeFetch(proxy, base) {
1529
+ if (proxy === void 0 || proxy.length === 0) return base;
1530
+ let dispatcher;
1531
+ const getDispatcher = () => {
1532
+ if (dispatcher === void 0) {
1533
+ dispatcher = import("undici").then((m) => new m.ProxyAgent(proxy));
1534
+ }
1535
+ return dispatcher;
1536
+ };
1537
+ const proxied = async (input, init) => {
1538
+ const d = await getDispatcher();
1539
+ const widened = { ...init ?? {}, dispatcher: d };
1540
+ return base(input, widened);
1541
+ };
1542
+ return proxied;
1543
+ }
1397
1544
 
1398
1545
  // src/engine/xctid/transaction.ts
1399
1546
  import { createHash } from "crypto";
@@ -1870,6 +2017,7 @@ function idLte(a, b) {
1870
2017
  return a.length === b.length ? a <= b : a.length < b.length;
1871
2018
  }
1872
2019
  }
2020
+ var ROTATE_CODES = /* @__PURE__ */ new Set(["RATE_LIMITED", "AUTH_FAILED"]);
1873
2021
  var MAX_NOTFOUND_RETRIES = 2;
1874
2022
  var RETRY_BACKOFF_MS = 400;
1875
2023
  function createTransactionProvider(fetchImpl) {
@@ -1948,37 +2096,72 @@ async function paginateUsers(fetchPage, limit) {
1948
2096
  return out;
1949
2097
  }
1950
2098
  function createEngine(deps) {
1951
- const fetchImpl = deps.fetchImpl ?? fetch;
2099
+ const baseFetch = deps.fetchImpl ?? fetch;
1952
2100
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1953
2101
  const txn = deps.transaction ? { provider: deps.transaction, refresh: async () => {
1954
2102
  } } : createTransactionProvider(deps.fetchImpl);
1955
- let cookiesCache = deps.cookies;
1956
- const resolveCookies = () => {
1957
- if (cookiesCache === void 0) cookiesCache = getCookies();
1958
- return cookiesCache;
1959
- };
1960
- const client = deps.client ?? createClient({
1961
- cookies: resolveCookies(),
1962
- transaction: txn.provider,
1963
- ...deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}
1964
- });
1965
- async function call(op, request, attempt = 0) {
1966
- const res = await client.get(op, request);
1967
- if (res.ok) return res.value;
2103
+ function resolveSpecs() {
2104
+ const accountsRaw = (process.env.XRELAY_ACCOUNTS ?? "").trim();
2105
+ const proxies = parseProxyList(process.env.XRELAY_PROXIES ?? "");
2106
+ let specs = accountsRaw.length > 0 ? parseAccounts(accountsRaw) : [];
2107
+ if (specs.length === 0) {
2108
+ const spec = { cookies: deps.cookies ?? getCookies(), label: "default" };
2109
+ const singleProxy = (process.env.XRELAY_PROXY ?? "").trim();
2110
+ if (singleProxy.length > 0) spec.proxy = singleProxy;
2111
+ specs = [spec];
2112
+ }
2113
+ return assignProxies(specs, proxies);
2114
+ }
2115
+ function buildLanes() {
2116
+ const injected = deps.clients ?? (deps.client ? [deps.client] : void 0);
2117
+ const fallbackCookies = deps.cookies ?? { authToken: "", ct0: "" };
2118
+ if (injected !== void 0) {
2119
+ return injected.map((client) => ({ client, cookies: fallbackCookies, fetchImpl: baseFetch }));
2120
+ }
2121
+ return resolveSpecs().map((spec) => {
2122
+ const fetchImpl = makeFetch(spec.proxy, baseFetch);
2123
+ return {
2124
+ client: createClient({ cookies: spec.cookies, transaction: txn.provider, fetchImpl }),
2125
+ cookies: spec.cookies,
2126
+ fetchImpl
2127
+ };
2128
+ });
2129
+ }
2130
+ const lanes = buildLanes();
2131
+ let current = 0;
2132
+ const activeLane = () => lanes[current] ?? lanes[0] ?? {};
2133
+ async function callLane(lane, op, request, attempt = 0) {
2134
+ const res = await lane.client.get(op, request);
2135
+ if (res.ok) return res;
1968
2136
  if (res.error.code === "NOT_FOUND" && attempt < MAX_NOTFOUND_RETRIES) {
1969
2137
  await txn.refresh();
1970
2138
  await sleep(RETRY_BACKOFF_MS * (attempt + 1));
1971
- return call(op, request, attempt + 1);
2139
+ return callLane(lane, op, request, attempt + 1);
1972
2140
  }
1973
- throw new EngineError(res.error.code, res.error.message, res.error.status);
2141
+ return res;
2142
+ }
2143
+ async function call(op, request) {
2144
+ let lastError = {
2145
+ code: "FETCH_FAILED",
2146
+ message: "no lanes available"
2147
+ };
2148
+ for (let laneTry = 0; laneTry < lanes.length; laneTry++) {
2149
+ const res = await callLane(activeLane(), op, request);
2150
+ if (res.ok) return res.value;
2151
+ lastError = res.error;
2152
+ if (!ROTATE_CODES.has(res.error.code)) break;
2153
+ current = (current + 1) % lanes.length;
2154
+ }
2155
+ throw new EngineError(lastError.code, lastError.message, lastError.status);
1974
2156
  }
1975
2157
  let mePromise;
1976
2158
  async function fetchMe() {
1977
2159
  const path = "/1.1/account/settings.json";
1978
2160
  try {
2161
+ const lane = activeLane();
1979
2162
  const txid = await txn.provider("GET", path);
1980
- const res = await fetchImpl(`https://api.x.com${path}`, {
1981
- headers: buildHeaders({ cookies: resolveCookies(), transactionId: txid })
2163
+ const res = await lane.fetchImpl(`https://api.x.com${path}`, {
2164
+ headers: buildHeaders({ cookies: lane.cookies, transactionId: txid })
1982
2165
  });
1983
2166
  if (!res.ok) return null;
1984
2167
  const data = await res.json();
@@ -2098,9 +2281,10 @@ function createEngine(deps) {
2098
2281
  async trends(opts) {
2099
2282
  const woeid = opts?.woeid ?? 1;
2100
2283
  const path = "/1.1/trends/place.json";
2284
+ const lane = activeLane();
2101
2285
  const txid = await txn.provider("GET", path);
2102
- const res = await fetchImpl(`https://api.x.com${path}?id=${woeid}`, {
2103
- headers: buildHeaders({ cookies: resolveCookies(), transactionId: txid })
2286
+ const res = await lane.fetchImpl(`https://api.x.com${path}?id=${woeid}`, {
2287
+ headers: buildHeaders({ cookies: lane.cookies, transactionId: txid })
2104
2288
  });
2105
2289
  if (!res.ok) {
2106
2290
  throw new EngineError("FETCH_FAILED", `trends request failed (${res.status})`, res.status);
@@ -2126,6 +2310,24 @@ function createEngine(deps) {
2126
2310
  const value = await call("TweetResultByRestId", tweetResultRequest({ tweetId }));
2127
2311
  const result = findDict(value, "result", true)[0];
2128
2312
  return extractTweetMedia(result);
2313
+ },
2314
+ async community(communityId, opts) {
2315
+ const limit = opts?.limit ?? DEFAULT_LIMIT2;
2316
+ return paginate(
2317
+ async (cursor) => {
2318
+ const value = await call(
2319
+ "CommunityTweetsTimeline",
2320
+ communityTweetsRequest({ communityId, cursor })
2321
+ );
2322
+ return parseTimeline(value);
2323
+ },
2324
+ limit,
2325
+ opts?.stopAtId
2326
+ );
2327
+ },
2328
+ async communityInfo(communityId) {
2329
+ const value = await call("CommunityByRestId", communityRequest({ communityId }));
2330
+ return parseCommunity(value);
2129
2331
  }
2130
2332
  };
2131
2333
  function usersByHandle(op, handle, limit) {
@@ -2251,6 +2453,20 @@ function runTrends(engine, opts = {}) {
2251
2453
  })
2252
2454
  );
2253
2455
  }
2456
+ function runCommunity(engine, communityId, limit) {
2457
+ if (!communityId)
2458
+ return Promise.resolve(err("community", "INVALID_INPUT", "missing community id"));
2459
+ return guard("community", () => engine.community(communityId, lim(limit)));
2460
+ }
2461
+ function runCommunityInfo(engine, communityId) {
2462
+ if (!communityId)
2463
+ return Promise.resolve(err("community-info", "INVALID_INPUT", "missing community id"));
2464
+ return guard("community-info", async () => {
2465
+ const info = await engine.communityInfo(communityId);
2466
+ if (!info) throw new EngineError("NOT_FOUND", "community not found");
2467
+ return info;
2468
+ });
2469
+ }
2254
2470
  function runArticle(engine, tweetId) {
2255
2471
  if (!tweetId) return Promise.resolve(err("article", "INVALID_INPUT", "missing tweet id/url"));
2256
2472
  return guard("article", async () => {
@@ -2509,6 +2725,40 @@ function buildSearchOpts(parsed) {
2509
2725
  ...parsed.flags.filter ? { filter: parsed.flags.filter } : {}
2510
2726
  };
2511
2727
  }
2728
+ function dispatchReadOps(parsed, engine, command, target) {
2729
+ const limit = num(parsed, "limit");
2730
+ switch (command) {
2731
+ case "list":
2732
+ return runList(engine, target, limit);
2733
+ case "user-media":
2734
+ return runUserMedia(engine, extractHandle(target) ?? target, limit);
2735
+ case "followers":
2736
+ return runFollowers(engine, extractHandle(target) ?? target, limit);
2737
+ case "following":
2738
+ return runFollowing(engine, extractHandle(target) ?? target, limit);
2739
+ case "retweeters":
2740
+ return runRetweeters(engine, extractTweetId(target) ?? target, limit);
2741
+ case "likers":
2742
+ return runLikers(engine, extractTweetId(target) ?? target, limit);
2743
+ case "quoters":
2744
+ return runQuoters(engine, extractTweetId(target) ?? target, limit);
2745
+ case "trends":
2746
+ return runTrends(engine, {
2747
+ ...num(parsed, "woeid") !== void 0 ? { woeid: num(parsed, "woeid") } : {},
2748
+ ...limit !== void 0 ? { limit } : {}
2749
+ });
2750
+ case "article":
2751
+ return runArticle(engine, extractTweetId(target) ?? target);
2752
+ case "media":
2753
+ return runMedia(engine, extractTweetId(target) ?? target, first(parsed, "out"));
2754
+ case "community":
2755
+ return runCommunity(engine, target, limit);
2756
+ case "community-info":
2757
+ return runCommunityInfo(engine, target);
2758
+ default:
2759
+ return null;
2760
+ }
2761
+ }
2512
2762
  async function dispatch(parsed, engine) {
2513
2763
  const { command } = parsed;
2514
2764
  const target = parsed.positionals[0] ?? "";
@@ -2541,31 +2791,8 @@ async function dispatch(parsed, engine) {
2541
2791
  ...num(parsed, "max") !== void 0 ? { max: num(parsed, "max") } : {}
2542
2792
  });
2543
2793
  }
2544
- case "list":
2545
- return runList(engine, target, num(parsed, "limit"));
2546
- case "user-media":
2547
- return runUserMedia(engine, extractHandle(target) ?? target, num(parsed, "limit"));
2548
- case "followers":
2549
- return runFollowers(engine, extractHandle(target) ?? target, num(parsed, "limit"));
2550
- case "following":
2551
- return runFollowing(engine, extractHandle(target) ?? target, num(parsed, "limit"));
2552
- case "retweeters":
2553
- return runRetweeters(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
2554
- case "likers":
2555
- return runLikers(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
2556
- case "quoters":
2557
- return runQuoters(engine, extractTweetId(target) ?? target, num(parsed, "limit"));
2558
- case "trends":
2559
- return runTrends(engine, {
2560
- ...num(parsed, "woeid") !== void 0 ? { woeid: num(parsed, "woeid") } : {},
2561
- ...num(parsed, "limit") !== void 0 ? { limit: num(parsed, "limit") } : {}
2562
- });
2563
- case "article":
2564
- return runArticle(engine, extractTweetId(target) ?? target);
2565
- case "media":
2566
- return runMedia(engine, extractTweetId(target) ?? target, first(parsed, "out"));
2567
2794
  default:
2568
- return err("cli", "UNKNOWN_COMMAND", `unknown command: ${command ?? "(none)"}`);
2795
+ return dispatchReadOps(parsed, engine, command, target) ?? Promise.resolve(err("cli", "UNKNOWN_COMMAND", `unknown command: ${command ?? "(none)"}`));
2569
2796
  }
2570
2797
  }
2571
2798
  async function run(argv, engine) {