x2llm 0.1.0__py3-none-any.whl

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.
x2llm/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """x2llm - read-only x.com (Twitter) API client for LLM agents.
2
+
3
+ Library + CLI + stdio MCP server. Credentials come exclusively from the
4
+ environment (12-factor); the HTTP/RPC layer is encapsulated in XClient.
5
+ """
x2llm/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
x2llm/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ """Click CLI. Every command prints one JSON document; errors print {"error": ...} and exit 1."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ import click
9
+
10
+ from . import cores
11
+ from .client import XApiError, XClient
12
+ from .config import Config, ConfigError
13
+
14
+
15
+ def out(obj) -> None:
16
+ print(json.dumps(obj, default=str, ensure_ascii=False, indent=1))
17
+
18
+
19
+ async def _run(make_coro: Callable[[XClient], Awaitable]) -> object:
20
+ config = Config.from_env()
21
+ client = XClient(config)
22
+ try:
23
+ return await make_coro(client)
24
+ finally:
25
+ await client.aclose()
26
+
27
+
28
+ def invoke(make_coro) -> None:
29
+ try:
30
+ result = asyncio.run(_run(make_coro))
31
+ except (ConfigError, XApiError) as e:
32
+ out({"error": str(e)})
33
+ raise SystemExit(1) from None
34
+ out(result)
35
+
36
+
37
+ @click.group()
38
+ @click.version_option()
39
+ def cli() -> None:
40
+ """x2llm - read-only x.com (Twitter) API client for agents. JSON out."""
41
+
42
+
43
+ @cli.command()
44
+ @click.option("--limit", type=int, default=20, show_default=True)
45
+ @click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
46
+ @click.option("--tab", type=click.Choice(["for_you", "following"]), default="for_you", show_default=True)
47
+ def home(limit: int, cursor: str | None, tab: str) -> None:
48
+ """Your home timeline (for_you or following); scroll with the returned cursor."""
49
+ invoke(lambda c: cores.home(c, limit=limit, cursor=cursor, tab=tab))
50
+
51
+
52
+ @cli.command()
53
+ def badges() -> None:
54
+ """Unread notification/DM counts. Cheapest auth check."""
55
+ invoke(lambda c: cores.badges(c))
56
+
57
+
58
+ @cli.command()
59
+ @click.argument("screen_name")
60
+ def user(screen_name: str) -> None:
61
+ """Profile of a user by screen name: bio, follower counts, verification."""
62
+ invoke(lambda c: cores.user(c, screen_name))
63
+
64
+
65
+ @cli.command()
66
+ def me() -> None:
67
+ """Viewer identity + follower counts."""
68
+ invoke(lambda c: cores.me(c))
69
+
70
+
71
+ @cli.command()
72
+ @click.argument("query")
73
+ def suggest(query: str) -> None:
74
+ """Search typeahead: topic and user suggestions for a prefix."""
75
+ invoke(lambda c: cores.suggest(c, query))
76
+
77
+
78
+ @cli.command()
79
+ @click.argument("query")
80
+ @click.option("--limit", type=int, default=20, show_default=True)
81
+ @click.option("--product", type=click.Choice(["Top", "Latest", "Media"]), default="Top", show_default=True)
82
+ @click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
83
+ def search(query: str, limit: int, product: str, cursor: str | None) -> None:
84
+ """Full search. Supports operators: from:user, since:, lang:, min_faves: ..."""
85
+ invoke(lambda c: cores.search(c, query, limit=limit, product=product, cursor=cursor))
86
+
87
+
88
+ @cli.command()
89
+ @click.argument("tweet_id")
90
+ @click.option("--cursor", default=None, help="Pagination cursor for more replies.")
91
+ def tweet(tweet_id: str, cursor: str | None) -> None:
92
+ """One tweet plus its reply thread."""
93
+ invoke(lambda c: cores.tweet(c, tweet_id, cursor=cursor))
94
+
95
+
96
+ @cli.command()
97
+ @click.option("--limit", type=int, default=20, show_default=True)
98
+ @click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
99
+ def notifications(limit: int, cursor: str | None) -> None:
100
+ """Your notification tab."""
101
+ invoke(lambda c: cores.notifications(c, limit=limit, cursor=cursor))
102
+
103
+
104
+ @cli.command()
105
+ @click.option("--days", type=int, default=7, show_default=True, help="Window length (1-28).")
106
+ def analytics(days: int) -> None:
107
+ """Your engagement statistics (impressions, likes, follows ...)."""
108
+ invoke(lambda c: cores.analytics(c, days=days))
109
+
110
+
111
+ @cli.command()
112
+ @click.argument("user")
113
+ @click.option("--limit", type=int, default=20, show_default=True)
114
+ @click.option("--cursor", default=None, help="Pagination cursor from a previous page.")
115
+ def following(user: str, limit: int, cursor: str | None) -> None:
116
+ """Accounts USER follows. USER is a numeric id or a screen name."""
117
+ invoke(lambda c: cores.following(c, user, limit=limit, cursor=cursor))
118
+
119
+
120
+ @cli.command()
121
+ def mcp() -> None:
122
+ """Run as a stdio MCP server (same tool surface, read-only)."""
123
+ from .mcp import serve
124
+
125
+ asyncio.run(serve())
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ try:
130
+ cli(args=argv, standalone_mode=True)
131
+ except click.exceptions.Abort:
132
+ return 130
133
+ return 0
134
+
135
+
136
+
137
+ @cli.command()
138
+ @click.argument("tweet_id")
139
+ def like(tweet_id: str) -> None:
140
+ """Like a tweet. REAL action: publicly visible, notifies the author."""
141
+ invoke(lambda c: cores.like(c, tweet_id))
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
x2llm/client.py ADDED
@@ -0,0 +1,133 @@
1
+ """XClient: the only module that knows how x.com RPC works.
2
+
3
+ Everything above this layer deals in plain dicts. Three primitives:
4
+
5
+ - graphql(key, variables) -> dict (GET /i/api/graphql/<queryId>/<Op>)
6
+ - graphql_post(key, variables) -> dict (POST /i/api/graphql/... — feeds + writes)
7
+ - rest(path, params) -> dict (GET /i/api/1.1/... legacy REST)
8
+
9
+ Auth = public web bearer + cookie session (auth_token + ct0) + x-csrf-token
10
+ mirroring ct0. Some endpoints additionally validate x-client-transaction-id
11
+ (per operation in the X_TXIDS env var, path-bound, expire server-side).
12
+
13
+ A client-side rate limiter spaces requests at most X_RATE_LIMIT per minute
14
+ (default 30, 0 disables) so the tool stays inside power-user territory.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import time
21
+
22
+ import httpx
23
+
24
+ from .config import Config
25
+ from .endpoints import GRAPHQL
26
+
27
+
28
+ class XApiError(Exception):
29
+ def __init__(self, message: str, status: int | None = None):
30
+ super().__init__(message)
31
+ self.status = status
32
+
33
+
34
+ class XClient:
35
+ def __init__(self, config: Config, transport: httpx.AsyncBaseTransport | None = None):
36
+ self.config = config
37
+ self._http = httpx.AsyncClient(timeout=config.timeout, transport=transport)
38
+ self._min_interval = 60.0 / config.rate_per_min if config.rate_per_min > 0 else 0.0
39
+ self._rl_lock = asyncio.Lock()
40
+ self._next_slot = 0.0
41
+
42
+ async def aclose(self) -> None:
43
+ await self._http.aclose()
44
+
45
+ async def graphql(self, key: str, variables: dict) -> dict:
46
+ """Read: GET with variables (+ per-endpoint features/fieldToggles) in the query string."""
47
+ ep = GRAPHQL[key]
48
+ params = {"variables": json.dumps(variables, separators=(",", ":"))}
49
+ if ep.get("features"):
50
+ params["features"] = json.dumps(ep["features"], separators=(",", ":"))
51
+ if ep.get("toggles"):
52
+ params["fieldToggles"] = json.dumps(ep["toggles"], separators=(",", ":"))
53
+ await self._throttle()
54
+ response = await self._http.get(
55
+ f"{self.config.api_base}/graphql/{ep['query_id']}/{ep['name']}",
56
+ params=params,
57
+ headers=self._headers(self.config.txids.get(key)),
58
+ )
59
+ return self._json(response, ep["name"])
60
+
61
+ async def graphql_post(self, key: str, variables: dict) -> dict:
62
+ """POST GraphQL: body carries variables+queryId (+features/fieldToggles
63
+ from the registry). Used for POST-fed timelines (home) and the like write."""
64
+ ep = GRAPHQL[key]
65
+ body = {"variables": variables, "queryId": ep["query_id"]}
66
+ if ep.get("features"):
67
+ body["features"] = ep["features"]
68
+ if ep.get("toggles"):
69
+ body["fieldToggles"] = ep["toggles"]
70
+ await self._throttle()
71
+ response = await self._http.post(
72
+ f"{self.config.api_base}/graphql/{ep['query_id']}/{ep['name']}",
73
+ json=body,
74
+ headers=self._headers(self.config.txids.get(key)),
75
+ )
76
+ return self._json(response, ep["name"])
77
+
78
+ async def rest(self, path: str, params: dict) -> dict:
79
+ await self._throttle()
80
+ response = await self._http.get(
81
+ f"{self.config.api_base}{path}", params=params, headers=self._headers(None)
82
+ )
83
+ return self._json(response, path)
84
+
85
+ async def _throttle(self) -> None:
86
+ if not self._min_interval:
87
+ return
88
+ async with self._rl_lock:
89
+ now = time.monotonic()
90
+ wait = self._next_slot - now
91
+ if wait > 0:
92
+ await asyncio.sleep(wait)
93
+ now = time.monotonic()
94
+ self._next_slot = now + self._min_interval
95
+
96
+ def _headers(self, txid: str | None) -> dict[str, str]:
97
+ headers = {
98
+ "authorization": self.config.bearer,
99
+ "cookie": self.config.cookie_header,
100
+ "x-csrf-token": self.config.ct0,
101
+ "x-twitter-auth-type": "OAuth2Session",
102
+ "x-twitter-active-user": "yes",
103
+ "x-twitter-client-language": "en",
104
+ "user-agent": self.config.user_agent,
105
+ "accept": "*/*",
106
+ }
107
+ if txid:
108
+ headers["x-client-transaction-id"] = txid
109
+ return headers
110
+
111
+ @staticmethod
112
+ def _json(response: httpx.Response, what: str) -> dict:
113
+ if response.status_code != 200:
114
+ hint = ""
115
+ if response.status_code == 404 and not response.content:
116
+ hint = (
117
+ " | hint: empty 404 usually means this endpoint's "
118
+ "x-client-transaction-id expired - refresh X_TXIDS from a "
119
+ "fresh capture (README - Refreshing captured tokens)"
120
+ )
121
+ elif response.status_code in (401, 403):
122
+ hint = (
123
+ " | hint: credentials rejected - check X_AUTH_TOKEN / X_CT0, "
124
+ "or set X_COOKIE to the full browser Cookie header "
125
+ "(incl. cf_clearance)"
126
+ )
127
+ elif response.status_code == 429:
128
+ hint = " | hint: rate limited by x.com - slow down or raise the wait"
129
+ raise XApiError(
130
+ f"{what}: HTTP {response.status_code} {response.text[:200]}{hint}",
131
+ status=response.status_code,
132
+ )
133
+ return response.json()
x2llm/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """Environment-driven configuration (12-factor: all config from the env)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+
9
+ from .endpoints import BEARER
10
+
11
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
12
+ REQUIRED = ("X_AUTH_TOKEN", "X_CT0")
13
+
14
+
15
+ class ConfigError(Exception):
16
+ pass
17
+
18
+
19
+ def _parse_txids(raw: str | None) -> dict[str, str]:
20
+ if not raw:
21
+ return {}
22
+ raw = raw.strip()
23
+ if len(raw) > 1 and raw[0] == raw[-1] and raw[0] in "'\"":
24
+ raw = raw[1:-1] # tolerate quoting from `source .env` / make -include
25
+ try:
26
+ parsed = json.loads(raw)
27
+ except ValueError as e:
28
+ raise ConfigError(f"X_TXIDS is not valid JSON: {e}") from e
29
+ if not isinstance(parsed, dict) or not all(
30
+ isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
31
+ ):
32
+ raise ConfigError("X_TXIDS must be a JSON object of {operation: transaction-id}")
33
+ return parsed
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Config:
38
+ auth_token: str
39
+ ct0: str
40
+ cookie: str | None
41
+ bearer: str
42
+ user_agent: str
43
+ api_base: str
44
+ timeout: float
45
+ rate_per_min: int
46
+ txids: dict[str, str]
47
+
48
+ @classmethod
49
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Config:
50
+ env = os.environ if env is None else env
51
+ missing = [k for k in REQUIRED if not env.get(k)]
52
+ if missing:
53
+ raise ConfigError(
54
+ f"missing environment variables: {', '.join(missing)} "
55
+ "(see README - Credentials)"
56
+ )
57
+ return cls(
58
+ auth_token=env["X_AUTH_TOKEN"],
59
+ ct0=env["X_CT0"],
60
+ cookie=env.get("X_COOKIE") or None,
61
+ bearer=env.get("X_BEARER") or BEARER,
62
+ user_agent=env.get("X_USER_AGENT") or DEFAULT_USER_AGENT,
63
+ api_base=env.get("X_API_BASE", "https://x.com/i/api"),
64
+ timeout=float(env.get("X_TIMEOUT", "20")),
65
+ rate_per_min=int(env.get("X_RATE_LIMIT", "30")),
66
+ txids=_parse_txids(env.get("X_TXIDS")),
67
+ )
68
+
69
+ @property
70
+ def cookie_header(self) -> str:
71
+ if self.cookie:
72
+ return self.cookie
73
+ return f"auth_token={self.auth_token}; ct0={self.ct0}"
x2llm/cores.py ADDED
@@ -0,0 +1,218 @@
1
+ """Service cores - shared by CLI and MCP server. Reads + the like mutation."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from datetime import UTC, datetime
6
+
7
+ from .client import XApiError
8
+ from .parse import collect_cursors, collect_tweets, notification_rows, user_row
9
+
10
+ DAY_MS = 86_400_000
11
+
12
+
13
+ async def badges(c) -> dict:
14
+ data = await c.graphql("badges", {})
15
+ result = data["data"]["viewer_v2"]["user_results"]["result"]
16
+ return {"badges": result.get("badge_counts") or {}}
17
+
18
+
19
+ async def suggest(c, query: str, lang: str = "en") -> dict:
20
+ data = await c.rest(
21
+ "/1.1/search/typeahead.json", {"q": query, "src": "search_box", "lang": lang}
22
+ )
23
+ return {
24
+ "topics": [t.get("topic") for t in data.get("topics") or []],
25
+ "users": [
26
+ {
27
+ "id": u.get("id_str"),
28
+ "screen_name": u.get("screen_name"),
29
+ "name": u.get("name"),
30
+ }
31
+ for u in data.get("users") or []
32
+ ],
33
+ }
34
+
35
+
36
+ async def search(
37
+ c, query: str, limit: int = 20, product: str = "Top", cursor: str | None = None
38
+ ) -> dict:
39
+ variables = {
40
+ "rawQuery": query,
41
+ "count": limit,
42
+ "querySource": "typed_query",
43
+ "product": product,
44
+ "withGrokTranslatedBio": False,
45
+ "withQuickPromoteEligibilityTweetFields": False,
46
+ }
47
+ if cursor:
48
+ variables["cursor"] = cursor
49
+ data = await c.graphql("search", variables)
50
+ return {
51
+ "tweets": collect_tweets(data)[:limit],
52
+ "cursor": collect_cursors(data).get("bottom"),
53
+ }
54
+
55
+
56
+ async def tweet(c, tweet_id: str, cursor: str | None = None) -> dict:
57
+ variables = {
58
+ "focalTweetId": str(tweet_id),
59
+ "referrer": "search",
60
+ "with_rux_injections": False,
61
+ "rankingMode": "Relevance",
62
+ "includePromotedContent": True,
63
+ "withCommunity": True,
64
+ "withQuickPromoteEligibilityTweetFields": True,
65
+ "withBirdwatchNotes": True,
66
+ "withVoice": True,
67
+ }
68
+ if cursor:
69
+ variables["cursor"] = cursor
70
+ data = await c.graphql("tweet", variables)
71
+ tweets = collect_tweets(data)
72
+ return {
73
+ "tweet": tweets[0] if tweets else None,
74
+ "replies": tweets[1:],
75
+ "cursor": collect_cursors(data).get("bottom"),
76
+ }
77
+
78
+
79
+ async def notifications(c, limit: int = 20, cursor: str | None = None) -> dict:
80
+ variables = {"timeline_type": "All", "count": limit}
81
+ if cursor:
82
+ variables["cursor"] = cursor
83
+ data = await c.graphql("notifications", variables)
84
+ return {
85
+ "notifications": notification_rows(data)[:limit],
86
+ "cursor": collect_cursors(data).get("bottom"),
87
+ }
88
+
89
+
90
+ def _analytics_variables(days: int) -> dict:
91
+ now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
92
+ to_ms, span = int(now.timestamp() * 1000), days * DAY_MS
93
+ prev_to_ms = to_ms - span
94
+
95
+ def iso(ms: int) -> str:
96
+ return datetime.fromtimestamp(ms / 1000, tz=UTC).isoformat().replace("+00:00", "Z")
97
+
98
+ return {
99
+ "current_from": to_ms - span,
100
+ "current_from_iso": iso(to_ms - span),
101
+ "current_to": to_ms,
102
+ "current_to_iso": now.isoformat().replace("+00:00", "Z"),
103
+ "prev_from": prev_to_ms - span,
104
+ "prev_from_iso": iso(prev_to_ms - span),
105
+ "prev_to": prev_to_ms,
106
+ "prev_to_iso": iso(prev_to_ms),
107
+ "backfill_from": to_ms - 2 * DAY_MS,
108
+ "backfill_to": to_ms,
109
+ "show_realtime_active_followers": True,
110
+ "show_verified_followers": True,
111
+ }
112
+
113
+
114
+ def _viewer_id(encoded: str) -> str | None:
115
+ try:
116
+ decoded = base64.b64decode(encoded).decode()
117
+ return decoded.split(":")[-1]
118
+ except Exception: # noqa: BLE001 - malformed id must not kill analytics
119
+ return None
120
+
121
+
122
+ async def analytics(c, days: int = 7) -> dict:
123
+ days = max(1, min(days, 28))
124
+ data = await c.graphql("analytics", _analytics_variables(days))
125
+ user_results = data["data"]["viewer_v2"]["user_results"]
126
+ result = user_results.get("result") or {}
127
+ series: dict[str, int] = {}
128
+ for point in result.get("current_time_series") or []:
129
+ kind = point.get("engagement_type")
130
+ if kind:
131
+ series[kind] = series.get(kind, 0) + (point.get("count") or 0)
132
+ relationship = result.get("relationship_counts") or {}
133
+ return {
134
+ "viewer": {
135
+ "id": _viewer_id(user_results.get("id") or ""),
136
+ "followers": relationship.get("followers"),
137
+ "verified_followers": result.get("verified_follower_count"),
138
+ "realtime_active_followers": (result.get("realtime_active_followers") or {}).get(
139
+ "active_count"
140
+ ),
141
+ },
142
+ "series": dict(sorted(series.items())),
143
+ "window_days": days,
144
+ }
145
+
146
+
147
+ async def me(c) -> dict:
148
+ return (await analytics(c, days=1))["viewer"]
149
+
150
+
151
+ async def following(
152
+ c,
153
+ user: str,
154
+ limit: int = 20,
155
+ cursor: str | None = None,
156
+ ) -> dict:
157
+ params: dict[str, str | int] = {"count": limit, "skip_status": "1", "cursor": cursor or "-1"}
158
+ if str(user).isdigit():
159
+ params["user_id"] = user
160
+ else:
161
+ params["screen_name"] = user
162
+ data = await c.rest("/1.1/friends/following/list.json", params)
163
+ users = [
164
+ {
165
+ "id": u.get("id_str"),
166
+ "screen_name": u.get("screen_name"),
167
+ "name": u.get("name"),
168
+ "followers": u.get("followers_count"),
169
+ }
170
+ for u in data.get("users") or []
171
+ ]
172
+ next_cursor = data.get("next_cursor")
173
+ return {"users": users, "cursor": str(next_cursor) if next_cursor else None}
174
+
175
+
176
+ async def like(c, tweet_id: str) -> dict:
177
+ data = await c.graphql_post("like", {"tweet_id": str(tweet_id)})
178
+ errors = data.get("errors") or []
179
+ if errors:
180
+ return {
181
+ "liked": str(tweet_id),
182
+ "result": "error",
183
+ "detail": errors[0].get("message"),
184
+ }
185
+ return {"liked": str(tweet_id), "result": (data.get("data") or {}).get("favorite_tweet")}
186
+
187
+
188
+ async def user(c, screen_name: str) -> dict:
189
+ name = str(screen_name).lstrip("@")
190
+ data = await c.graphql("user", {"screen_name": name, "withGrokTranslatedBio": True})
191
+ result = ((data.get("data") or {}).get("user") or {}).get("result")
192
+ if not result:
193
+ raise XApiError(f"user not found: {name}")
194
+ return user_row(result)
195
+
196
+
197
+ async def home(c, limit: int = 20, cursor: str | None = None, tab: str = "for_you") -> dict:
198
+ if tab == "following":
199
+ key, variables = "home_following", {
200
+ "count": limit,
201
+ "enableRanking": True,
202
+ "includePromotedContent": True,
203
+ }
204
+ else:
205
+ key, variables = "home", {
206
+ "count": limit,
207
+ "includePromotedContent": True,
208
+ "withCommunity": True,
209
+ "requestContext": "ptr",
210
+ }
211
+ if cursor:
212
+ variables["cursor"] = cursor
213
+ data = await c.graphql_post(key, variables)
214
+ return {
215
+ "tab": tab,
216
+ "tweets": collect_tweets(data)[:limit],
217
+ "cursor": collect_cursors(data).get("bottom"),
218
+ }
x2llm/endpoints.py ADDED
@@ -0,0 +1,6 @@
1
+ # GENERATED by scripts/har_to_endpoints.py from browser HAR captures. Do not edit.
2
+ # Public, session-independent constants only (query ids, features, toggles).
3
+ # Session-scoped values (transaction ids) live in the X_TXIDS env var.
4
+ # Regenerate: uv run python scripts/har_to_endpoints.py <archive.har> [...]
5
+ BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
6
+ GRAPHQL = {'badges': {'query_id': 'q4Npr1-FYRWyXsRzPckwEA', 'name': 'ViewerBadgeCounts', 'method': 'get'}, 'notifications': {'query_id': '4TuDRWeusve2-BH2IqldBg', 'name': 'NotificationsTimeline', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}}, 'home': {'query_id': 'V0wMxbYBxdrkfmV3kJSyRQ', 'name': 'HomeTimeline', 'method': 'post', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False}}, 'search': {'query_id': 'ph2fARFabkwfxqmSKQ1OPw', 'name': 'SearchTimeline', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}}, 'tweet': {'query_id': 'z-3ZLa-NQ8Sp09diHkJNBg', 'name': 'TweetDetail', 'method': 'get', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False, 'withDmBlocks': False, 'withArticleRichContentState': True, 'withArticlePlainText': False, 'withArticleSummaryText': True, 'withArticleVoiceOver': True, 'withGrokAnalyze': False, 'withDisallowedReplyControls': False}}, 'analytics': {'query_id': '2hqAR3h2xhN1cUrUBZypyg', 'name': 'accountOverviewDailyQuery', 'method': 'get'}, 'like': {'query_id': 'lI07N6Otwv1PhnEgXILM7A', 'name': 'FavoriteTweet', 'method': 'post'}, 'user': {'query_id': 'AMIBMjtxEEATh4z8V9GtRg', 'name': 'UserByScreenName', 'method': 'get', 'features': {'hidden_profile_subscriptions_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'subscriptions_verification_info_is_identity_verified_enabled': True, 'subscriptions_verification_info_verified_since_enabled': True, 'highlights_tweets_tab_ui_enabled': True, 'responsive_web_twitter_article_notes_tab_enabled': True, 'subscriptions_feature_can_gift_premium': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True}, 'toggles': {'withPayments': False, 'withAuxiliaryUserLabels': True}}, 'home_following': {'query_id': '5URyiXQyz6_8NZnoV37OVQ', 'name': 'HomeLatestTimeline', 'method': 'post', 'features': {'rweb_video_screen_enabled': False, 'rweb_cashtags_enabled': True, 'profile_label_improvements_pcf_label_in_post_enabled': True, 'responsive_web_profile_redirect_enabled': True, 'rweb_tipjar_consumption_enabled': False, 'verified_phone_label_enabled': False, 'creator_subscriptions_tweet_preview_api_enabled': True, 'responsive_web_graphql_timeline_navigation_enabled': True, 'premium_content_api_read_enabled': False, 'communities_web_enable_tweet_community_results_fetch': True, 'c9s_tweet_anatomy_moderator_badge_enabled': True, 'responsive_web_grok_analyze_button_fetch_trends_enabled': False, 'responsive_web_grok_analyze_post_followups_enabled': True, 'rweb_cashtags_composer_attachment_enabled': True, 'responsive_web_jetfuel_frame': True, 'rweb_sports_post_context_enabled': True, 'responsive_web_grok_share_attachment_enabled': True, 'responsive_web_grok_annotations_enabled': True, 'articles_preview_enabled': True, 'responsive_web_edit_tweet_api_enabled': True, 'rweb_conversational_replies_downvote_enabled': False, 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True, 'view_counts_everywhere_api_enabled': True, 'longform_notetweets_consumption_enabled': True, 'responsive_web_twitter_article_tweet_consumption_enabled': True, 'content_disclosure_indicator_enabled': True, 'content_disclosure_ai_generated_indicator_enabled': True, 'responsive_web_grok_show_grok_translated_post': True, 'responsive_web_grok_analysis_button_from_backend': True, 'post_ctas_fetch_enabled': False, 'freedom_of_speech_not_reach_fetch_enabled': True, 'standardized_nudges_misinfo': True, 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True, 'longform_notetweets_rich_text_read_enabled': True, 'longform_notetweets_inline_media_enabled': False, 'responsive_web_nested_quote_preview_enabled': True, 'responsive_web_grok_image_annotation_enabled': True, 'responsive_web_grok_imagine_annotation_enabled': True, 'responsive_web_grok_community_note_auto_translation_is_enabled': True, 'responsive_web_enhance_cards_enabled': False}, 'toggles': {'withPayments': False}}}
x2llm/mcp.py ADDED
@@ -0,0 +1,173 @@
1
+ """Stdio MCP server exposing the same cores as the CLI (reads + like_tweet)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import inspect
6
+
7
+ from . import cores
8
+ from .client import XApiError, XClient
9
+ from .config import Config, ConfigError
10
+
11
+ MCP_INSTRUCTIONS = (
12
+ "Access to a logged-in x.com (Twitter) account via the private web API. "
13
+ "Credentials come from X_AUTH_TOKEN/X_CT0 environment variables - if they "
14
+ "expire every tool returns an auth error. All tools are read-only except "
15
+ "like_tweet, which performs a REAL, publicly visible action and notifies "
16
+ "the author - never call it unless the user explicitly asked to like that "
17
+ "specific post. search supports X operators (from:user, "
18
+ "since:YYYY-MM-DD, lang:, min_faves:, min_retweets:). get_tweet returns "
19
+ "the tweet plus its reply thread; paginate long threads and searches with "
20
+ "the returned cursor. analytics returns the account's own engagement "
21
+ "statistics. If search or like fails with an empty 404, the captured "
22
+ "x-client-transaction-id expired - see the x2llm README."
23
+ )
24
+
25
+ # (tool name, description, core, read_only)
26
+ MCP_TOOLS = [
27
+ (
28
+ "get_home_timeline",
29
+ (
30
+ "The account's home timeline. tab: 'for_you' (algorithmic) or "
31
+ "'following' (chronological). Scroll deeper by passing the "
32
+ "returned cursor."
33
+ ),
34
+ cores.home,
35
+ True,
36
+ ),
37
+ (
38
+ "get_user",
39
+ (
40
+ "Profile of a user by screen name: bio, url, follower/following "
41
+ "counts, tweet counts, verification. Use to qualify authors "
42
+ "before engaging."
43
+ ),
44
+ cores.user,
45
+ True,
46
+ ),
47
+ (
48
+ "get_badges",
49
+ ("Unread notification and DM counts. Cheapest way to verify credentials work."),
50
+ cores.badges,
51
+ True,
52
+ ),
53
+ (
54
+ "me",
55
+ (
56
+ "Viewer identity: numeric user id, follower, verified-follower and "
57
+ "realtime-active-follower counts."
58
+ ),
59
+ cores.me,
60
+ True,
61
+ ),
62
+ (
63
+ "suggest",
64
+ ("Search typeahead for a prefix: matching topics and users (with ids)."),
65
+ cores.suggest,
66
+ True,
67
+ ),
68
+ (
69
+ "search",
70
+ (
71
+ "Search posts. Supports X operators: from:user, since:YYYY-MM-DD, "
72
+ "lang:, min_faves:, min_retweets:. Returns posts and a cursor for the "
73
+ "next page."
74
+ ),
75
+ cores.search,
76
+ True,
77
+ ),
78
+ (
79
+ "get_tweet",
80
+ ("One post by id plus its reply thread; cursor paginates deeper replies."),
81
+ cores.tweet,
82
+ True,
83
+ ),
84
+ (
85
+ "get_notifications",
86
+ ("The account's notification tab (mentions, likes, follows, reposts)."),
87
+ cores.notifications,
88
+ True,
89
+ ),
90
+ (
91
+ "get_analytics",
92
+ (
93
+ "The account's own engagement statistics aggregated over a window "
94
+ "(impressions, likes, reposts, follows, ...). days: 1-28."
95
+ ),
96
+ cores.analytics,
97
+ True,
98
+ ),
99
+ (
100
+ "get_following",
101
+ (
102
+ "Accounts a user follows. user is a numeric id or screen name; cursor paginates."
103
+ ),
104
+ cores.following,
105
+ True,
106
+ ),
107
+ (
108
+ "like_tweet",
109
+ (
110
+ "Like a post as the logged-in user. REAL action: publicly visible, "
111
+ "notifies the author, not undoable via this tool. Only call when "
112
+ "the user explicitly asked to like that specific post."
113
+ ),
114
+ cores.like,
115
+ False,
116
+ ),
117
+ ]
118
+
119
+ _MCP_CLIENT: XClient | None = None
120
+ _MCP_LOCK = asyncio.Lock()
121
+
122
+
123
+ async def mcp_client() -> XClient:
124
+ global _MCP_CLIENT
125
+ async with _MCP_LOCK:
126
+ if _MCP_CLIENT is None:
127
+ try:
128
+ _MCP_CLIENT = XClient(Config.from_env())
129
+ except ConfigError as e:
130
+ raise RuntimeError(str(e)) from e
131
+ return _MCP_CLIENT
132
+
133
+
134
+ def bind_tool(fn):
135
+ sig = inspect.signature(fn)
136
+ params = list(sig.parameters.values())[1:]
137
+
138
+ async def bound(**kwargs):
139
+ from mcp.server.mcpserver.exceptions import ToolError
140
+
141
+ try:
142
+ return await fn(await mcp_client(), **kwargs)
143
+ except XApiError as e:
144
+ raise ToolError(str(e)) from e
145
+ except TypeError as e:
146
+ raise ToolError(f"bad arguments: {e}") from e
147
+
148
+ bound.__signature__ = inspect.Signature(params, return_annotation=dict)
149
+ return bound
150
+
151
+
152
+ def build_mcp_server():
153
+ from mcp.server.mcpserver import MCPServer
154
+ from mcp.types import ToolAnnotations
155
+
156
+ server = MCPServer(name="x2llm", instructions=MCP_INSTRUCTIONS)
157
+ for name, description, fn, read_only in MCP_TOOLS:
158
+ server.add_tool(
159
+ bind_tool(fn),
160
+ name=name,
161
+ description=description,
162
+ annotations=ToolAnnotations(read_only_hint=read_only),
163
+ )
164
+ return server
165
+
166
+
167
+ async def serve() -> None:
168
+ server = build_mcp_server()
169
+ try:
170
+ await server.run_stdio_async()
171
+ finally:
172
+ if _MCP_CLIENT is not None:
173
+ await _MCP_CLIENT.aclose()
x2llm/parse.py ADDED
@@ -0,0 +1,120 @@
1
+ """Parsers: x.com timeline/negotiation blobs -> flat LLM-friendly dicts."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterator
5
+ from typing import Any
6
+
7
+
8
+ def walk(obj: Any) -> Iterator[dict]:
9
+ if isinstance(obj, dict):
10
+ yield obj
11
+ for v in obj.values():
12
+ yield from walk(v)
13
+ elif isinstance(obj, list):
14
+ for v in obj:
15
+ yield from walk(v)
16
+
17
+
18
+ def _tweet_result(wrapper: dict | None) -> dict | None:
19
+ result = (wrapper or {}).get("result") or {}
20
+ if result.get("__typename") == "TweetWithVisibilityResults":
21
+ result = result.get("tweet") or {}
22
+ if result.get("__typename") != "Tweet" or not result.get("rest_id"):
23
+ return None
24
+ return result
25
+
26
+
27
+ def _user_fields(user: dict) -> dict:
28
+ core = user.get("core") or {}
29
+ legacy = user.get("legacy") or {}
30
+ return {
31
+ "author": core.get("screen_name") or legacy.get("screen_name"),
32
+ "author_name": core.get("name") or legacy.get("name"),
33
+ "author_id": user.get("rest_id"),
34
+ }
35
+
36
+
37
+ def tweet_row(result: dict) -> dict:
38
+ legacy = result.get("legacy") or {}
39
+ user = ((result.get("core") or {}).get("user_results") or {}).get("result") or {}
40
+ note = ((result.get("note_tweet") or {}).get("note_tweet_results") or {}).get("result") or {}
41
+ views = (result.get("views") or {}).get("count")
42
+ return {
43
+ "id": result.get("rest_id"),
44
+ "created_at": legacy.get("created_at"),
45
+ **_user_fields(user),
46
+ "text": note.get("text") or legacy.get("full_text"),
47
+ "lang": legacy.get("lang"),
48
+ "replies": legacy.get("reply_count"),
49
+ "reposts": legacy.get("retweet_count"),
50
+ "likes": legacy.get("favorite_count"),
51
+ "views": int(views) if isinstance(views, str) and views.isdigit() else views,
52
+ "bookmarks": legacy.get("bookmark_count"),
53
+ }
54
+
55
+
56
+ def collect_tweets(obj: Any) -> list[dict]:
57
+ rows: list[dict] = []
58
+ seen: set[str] = set()
59
+ for node in walk(obj):
60
+ if "tweet_results" not in node:
61
+ continue
62
+ result = _tweet_result(node["tweet_results"])
63
+ if result and result["rest_id"] not in seen:
64
+ seen.add(result["rest_id"])
65
+ rows.append(tweet_row(result))
66
+ return rows
67
+
68
+
69
+ def collect_cursors(obj: Any) -> dict[str, str]:
70
+ cursors: dict[str, str] = {}
71
+ for node in walk(obj):
72
+ if node.get("entryType") == "TimelineTimelineCursor" and node.get("value"):
73
+ kind = node.get("cursorType")
74
+ if kind in ("Top", "Bottom"):
75
+ cursors[kind.lower()] = node["value"]
76
+ return cursors
77
+
78
+
79
+ def notification_rows(obj: Any) -> list[dict]:
80
+ rows: list[dict] = []
81
+ seen: set[str] = set()
82
+ for node in walk(obj):
83
+ item = node.get("itemContent")
84
+ if not isinstance(item, dict) or item.get("__typename") != "TimelineNotification":
85
+ continue
86
+ if item.get("id") in seen:
87
+ continue
88
+ seen.add(item.get("id"))
89
+ rich = item.get("rich_message") or {}
90
+ rows.append(
91
+ {
92
+ "id": item.get("id"),
93
+ "icon": item.get("notification_icon"),
94
+ "text": rich.get("text"),
95
+ "url": (item.get("notification_url") or {}).get("url"),
96
+ }
97
+ )
98
+ return rows
99
+
100
+
101
+ def user_row(result: dict) -> dict:
102
+ core = result.get("core") or {}
103
+ bio = result.get("profile_bio") or {}
104
+ relationship = result.get("relationship_counts") or {}
105
+ counts = result.get("tweet_counts") or {}
106
+ urls = ((bio.get("entities") or {}).get("url") or {}).get("urls") or []
107
+ return {
108
+ "id": result.get("rest_id"),
109
+ "screen_name": core.get("screen_name"),
110
+ "name": core.get("name"),
111
+ "created_at": core.get("created_at"),
112
+ "bio": bio.get("description"),
113
+ "url": urls[0].get("expanded_url") if urls else None,
114
+ "location": (result.get("location") or {}).get("location"),
115
+ "followers": relationship.get("followers"),
116
+ "following": relationship.get("following"),
117
+ "tweets": counts.get("tweets"),
118
+ "media_tweets": counts.get("media_tweets"),
119
+ "blue_verified": result.get("is_blue_verified"),
120
+ }
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: x2llm
3
+ Version: 0.1.0
4
+ Summary: Read-only x.com (Twitter) API client for LLM agents: library, CLI, and stdio MCP server
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: click>=8
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: mcp>=2
@@ -0,0 +1,13 @@
1
+ x2llm/__init__.py,sha256=8LIrJ89MZnx-iwrs5_EfBaw_IFFUhdbtiXXQcb9Funo,213
2
+ x2llm/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ x2llm/cli.py,sha256=EFhLfc6Vnb2qTLHeYULse-ysW6oc40hYk2IXkK0RKr0,4586
4
+ x2llm/client.py,sha256=czvikbPQhUY_HcXhVfSEszRXH0ApMlt7BhjIw-mCNBI,5377
5
+ x2llm/config.py,sha256=8KZNRui14bqHFxNxWejB8_myffoiYzPa7b4cECCLfOE,2311
6
+ x2llm/cores.py,sha256=AXiHTwmxiRHh7_TiMYcJPPkFJ9wmaEfQC1hNHKFdUKQ,7069
7
+ x2llm/endpoints.py,sha256=Wvlvj_zrm4PU2DKeF6KszWdwr2P7oCD1sYh-vl0LvVs,12734
8
+ x2llm/mcp.py,sha256=WmZeZW8lpu7h2Jq8eJCBKAwAOVH3DnAk5v3uCu1KsOc,5117
9
+ x2llm/parse.py,sha256=nlzpVy6aR4usHn40xgrIcwr0RAR1mz8mh9_2mB-6qB0,4211
10
+ x2llm-0.1.0.dist-info/METADATA,sha256=QUBMPsS_Qkyqula4PL3T2xhtU9z7WU_E-Pjqy7_DfPY,243
11
+ x2llm-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ x2llm-0.1.0.dist-info/entry_points.txt,sha256=Sbp0vDeQrQDW2yT12_h10jz98wyhq_Bp2Oq6F6P4mTc,41
13
+ x2llm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ x2llm = x2llm.cli:main