@zenalexa/unicli 0.223.4 → 0.224.1

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.
Files changed (130) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +4 -4
  3. package/README.zh-CN.md +4 -4
  4. package/dist/adapters/marxists-cn/archive.d.ts +61 -0
  5. package/dist/adapters/marxists-cn/archive.d.ts.map +1 -0
  6. package/dist/adapters/marxists-cn/archive.js +861 -0
  7. package/dist/adapters/marxists-cn/archive.js.map +1 -0
  8. package/dist/adapters/twitter/lists-extra.d.ts +17 -1
  9. package/dist/adapters/twitter/lists-extra.d.ts.map +1 -1
  10. package/dist/adapters/twitter/lists-extra.js +123 -21
  11. package/dist/adapters/twitter/lists-extra.js.map +1 -1
  12. package/dist/adapters/twitter/post.js +1 -0
  13. package/dist/adapters/twitter/post.js.map +1 -1
  14. package/dist/adapters/twitter/thread.d.ts +13 -1
  15. package/dist/adapters/twitter/thread.d.ts.map +1 -1
  16. package/dist/adapters/twitter/thread.js +76 -33
  17. package/dist/adapters/twitter/thread.js.map +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +3 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/architecture.d.ts +21 -0
  22. package/dist/commands/architecture.d.ts.map +1 -0
  23. package/dist/commands/architecture.js +47 -0
  24. package/dist/commands/architecture.js.map +1 -0
  25. package/dist/commands/compute.d.ts.map +1 -1
  26. package/dist/commands/compute.js +31 -6
  27. package/dist/commands/compute.js.map +1 -1
  28. package/dist/commands/doctor-compute.d.ts.map +1 -1
  29. package/dist/commands/doctor-compute.js +88 -1
  30. package/dist/commands/doctor-compute.js.map +1 -1
  31. package/dist/compute/action-execution.d.ts +30 -0
  32. package/dist/compute/action-execution.d.ts.map +1 -0
  33. package/dist/compute/action-execution.js +112 -0
  34. package/dist/compute/action-execution.js.map +1 -0
  35. package/dist/compute/capture-reference.d.ts.map +1 -1
  36. package/dist/compute/capture-reference.js +6 -1
  37. package/dist/compute/capture-reference.js.map +1 -1
  38. package/dist/compute/capture.d.ts +2 -0
  39. package/dist/compute/capture.d.ts.map +1 -1
  40. package/dist/compute/capture.js +6 -1
  41. package/dist/compute/capture.js.map +1 -1
  42. package/dist/compute/cursor-visual-style.d.ts +35 -0
  43. package/dist/compute/cursor-visual-style.d.ts.map +1 -0
  44. package/dist/compute/cursor-visual-style.js +39 -0
  45. package/dist/compute/cursor-visual-style.js.map +1 -0
  46. package/dist/compute/linux-overlay.d.ts +38 -0
  47. package/dist/compute/linux-overlay.d.ts.map +1 -0
  48. package/dist/compute/linux-overlay.js +274 -0
  49. package/dist/compute/linux-overlay.js.map +1 -0
  50. package/dist/compute/macos-overlay.d.ts +64 -0
  51. package/dist/compute/macos-overlay.d.ts.map +1 -0
  52. package/dist/compute/macos-overlay.js +590 -0
  53. package/dist/compute/macos-overlay.js.map +1 -0
  54. package/dist/compute/overlay-daemon.d.ts +47 -0
  55. package/dist/compute/overlay-daemon.d.ts.map +1 -0
  56. package/dist/compute/overlay-daemon.js +206 -0
  57. package/dist/compute/overlay-daemon.js.map +1 -0
  58. package/dist/compute/overlay.d.ts +42 -0
  59. package/dist/compute/overlay.d.ts.map +1 -0
  60. package/dist/compute/overlay.js +111 -0
  61. package/dist/compute/overlay.js.map +1 -0
  62. package/dist/compute/platform-overlays.d.ts +20 -0
  63. package/dist/compute/platform-overlays.d.ts.map +1 -0
  64. package/dist/compute/platform-overlays.js +31 -0
  65. package/dist/compute/platform-overlays.js.map +1 -0
  66. package/dist/compute/visual-timeline.d.ts +132 -0
  67. package/dist/compute/visual-timeline.d.ts.map +1 -0
  68. package/dist/compute/visual-timeline.js +431 -0
  69. package/dist/compute/visual-timeline.js.map +1 -0
  70. package/dist/compute/windows-overlay.d.ts +38 -0
  71. package/dist/compute/windows-overlay.d.ts.map +1 -0
  72. package/dist/compute/windows-overlay.js +282 -0
  73. package/dist/compute/windows-overlay.js.map +1 -0
  74. package/dist/core/architecture-tree.d.ts +68 -0
  75. package/dist/core/architecture-tree.d.ts.map +1 -0
  76. package/dist/core/architecture-tree.js +215 -0
  77. package/dist/core/architecture-tree.js.map +1 -0
  78. package/dist/discovery/aliases.d.ts.map +1 -1
  79. package/dist/discovery/aliases.js +93 -0
  80. package/dist/discovery/aliases.js.map +1 -1
  81. package/dist/discovery/core-catalog.d.ts.map +1 -1
  82. package/dist/discovery/core-catalog.js +14 -0
  83. package/dist/discovery/core-catalog.js.map +1 -1
  84. package/dist/discovery/intents.d.ts.map +1 -1
  85. package/dist/discovery/intents.js +124 -0
  86. package/dist/discovery/intents.js.map +1 -1
  87. package/dist/discovery/loader.d.ts +12 -6
  88. package/dist/discovery/loader.d.ts.map +1 -1
  89. package/dist/discovery/loader.js +37 -10
  90. package/dist/discovery/loader.js.map +1 -1
  91. package/dist/discovery/search.d.ts +27 -28
  92. package/dist/discovery/search.d.ts.map +1 -1
  93. package/dist/discovery/search.js +118 -120
  94. package/dist/discovery/search.js.map +1 -1
  95. package/dist/engine/text-normalize.d.ts +14 -0
  96. package/dist/engine/text-normalize.d.ts.map +1 -1
  97. package/dist/engine/text-normalize.js +64 -0
  98. package/dist/engine/text-normalize.js.map +1 -1
  99. package/dist/fast-path/handlers/discovery.d.ts +12 -5
  100. package/dist/fast-path/handlers/discovery.d.ts.map +1 -1
  101. package/dist/fast-path/handlers/discovery.js +42 -7
  102. package/dist/fast-path/handlers/discovery.js.map +1 -1
  103. package/dist/manifest-compact.txt +2 -2
  104. package/dist/manifest.json +352 -3
  105. package/dist/mcp/profiles/computer-use.d.ts.map +1 -1
  106. package/dist/mcp/profiles/computer-use.js +76 -8
  107. package/dist/mcp/profiles/computer-use.js.map +1 -1
  108. package/dist/registry.d.ts +14 -5
  109. package/dist/registry.d.ts.map +1 -1
  110. package/dist/registry.js +33 -6
  111. package/dist/registry.js.map +1 -1
  112. package/dist/transport/cascade.d.ts +1 -0
  113. package/dist/transport/cascade.d.ts.map +1 -1
  114. package/dist/transport/cascade.js +2 -2
  115. package/dist/transport/cascade.js.map +1 -1
  116. package/docs/operate/compute.md +66 -1
  117. package/docs/operate/troubleshooting.md +42 -0
  118. package/package.json +9 -5
  119. package/server.json +3 -3
  120. package/skills/unicli/SKILL.md +1 -1
  121. package/skills/unicli-claude-code/SKILL.md +1 -1
  122. package/skills/unicli-hermes/SKILL.md +1 -1
  123. package/src/adapters/marxists-cn/archive.test.ts +173 -0
  124. package/src/adapters/marxists-cn/archive.ts +1049 -0
  125. package/src/adapters/twitter/lists-extra.test.ts +115 -0
  126. package/src/adapters/twitter/lists-extra.ts +146 -26
  127. package/src/adapters/twitter/post.ts +1 -0
  128. package/src/adapters/twitter/thread.test.ts +25 -1
  129. package/src/adapters/twitter/thread.ts +99 -47
  130. package/dist/manifest-search.json +0 -1
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { resolveCommand } from "../../registry.js";
4
+ import {
5
+ buildTwitterTweetExtractionScript,
6
+ extractTweets,
7
+ } from "./lists-extra.js";
8
+ import type { IPage } from "../../types.js";
9
+
10
+ class FakeTwitterPage implements Partial<IPage> {
11
+ public readonly navigatedUrls: string[] = [];
12
+ public autoScrollCalls = 0;
13
+ private evaluateCalls = 0;
14
+
15
+ async goto(url: string): Promise<void> {
16
+ this.navigatedUrls.push(url);
17
+ }
18
+
19
+ async wait(): Promise<void> {}
20
+
21
+ async autoScroll(): Promise<void> {
22
+ this.autoScrollCalls += 1;
23
+ }
24
+
25
+ async evaluate(): Promise<unknown> {
26
+ this.evaluateCalls += 1;
27
+ if (this.evaluateCalls === 1) {
28
+ return {
29
+ url: this.navigatedUrls.at(-1),
30
+ title: "X",
31
+ text: "alice: hello",
32
+ };
33
+ }
34
+ return [
35
+ {
36
+ id: "1",
37
+ author: "alice",
38
+ text: "hello",
39
+ likes: "2",
40
+ retweets: "3",
41
+ views: "4",
42
+ url: "https://x.com/alice/status/1",
43
+ },
44
+ ];
45
+ }
46
+ }
47
+
48
+ describe("twitter user timeline commands", () => {
49
+ it("registers natural aliases for reading a user's tweets", () => {
50
+ for (const name of ["tweets", "user-tweets", "user-timeline"]) {
51
+ expect(resolveCommand("twitter", name)?.command.columns).toEqual([
52
+ "id",
53
+ "author",
54
+ "text",
55
+ "likes",
56
+ "retweets",
57
+ "views",
58
+ "url",
59
+ ]);
60
+ }
61
+ });
62
+
63
+ it("normalizes @handles before navigating to a user timeline", async () => {
64
+ // REASON: IPage is the browser boundary; this fake records navigation and
65
+ // returns DOM-extracted rows without mocking owned adapter code.
66
+ const page = new FakeTwitterPage() as IPage;
67
+
68
+ await extractTweets(page, "https://x.com/@yetone", 1, "user-tweets");
69
+
70
+ expect(page.navigatedUrls).toEqual(["https://x.com/yetone"]);
71
+ expect(page.autoScrollCalls).toBe(1);
72
+ });
73
+
74
+ it("extracts /i/status tweet links without treating i as the author", () => {
75
+ const article = {
76
+ querySelector(selector: string) {
77
+ if (selector === 'a[href*="/status/"]') {
78
+ return { getAttribute: () => "/i/status/123" };
79
+ }
80
+ if (selector === '[data-testid="User-Name"]') {
81
+ return { textContent: "Alice @alice" };
82
+ }
83
+ return { textContent: "" };
84
+ },
85
+ querySelectorAll(selector: string) {
86
+ if (selector === '[data-testid="tweetText"]') {
87
+ return [{ textContent: "hello" }];
88
+ }
89
+ return [];
90
+ },
91
+ };
92
+ const previousDocument = globalThis.document;
93
+ Object.defineProperty(globalThis, "document", {
94
+ configurable: true,
95
+ value: { querySelectorAll: () => [article] },
96
+ });
97
+
98
+ try {
99
+ const rows = Function(`return ${buildTwitterTweetExtractionScript(1)}`)();
100
+
101
+ expect(rows).toEqual([
102
+ expect.objectContaining({
103
+ id: "123",
104
+ author: "alice",
105
+ url: "https://x.com/i/status/123",
106
+ }),
107
+ ]);
108
+ } finally {
109
+ Object.defineProperty(globalThis, "document", {
110
+ configurable: true,
111
+ value: previousDocument,
112
+ });
113
+ }
114
+ });
115
+ });
@@ -1,27 +1,113 @@
1
+ /**
2
+ * @owner src/adapters/twitter/lists-extra.ts
3
+ * @does Register browser-backed Twitter/X user timeline and list membership commands.
4
+ * @needs User-owned browser session on x.com, Twitter page readability checks, shared browser DOM extraction helpers.
5
+ * @feeds twitter.tweets, twitter.user-tweets, twitter.user-timeline, twitter.list-tweets, twitter.list-add, twitter.list-remove.
6
+ * @breaks X/Twitter DOM or URL-shape drift can make timeline rows empty or misidentify tweet authors.
7
+ * @invariants User timeline commands normalize @handles and emit the standard tweet row shape.
8
+ * @side-effects Navigates the browser to X/Twitter profile, list, and list-management pages.
9
+ * @perf Scrolls at most two viewport batches for read commands.
10
+ * @concurrency Browser session state is shared by the command runtime.
11
+ * @test src/adapters/twitter/lists-extra.test.ts
12
+ * @stability stable
13
+ * @since 2026-05-27
14
+ */
15
+
1
16
  import { cli, Strategy } from "../../registry.js";
2
17
  import type { IPage } from "../../types.js";
3
18
  import { clickFirst, intArg, js, str } from "../_shared/browser-tools.js";
19
+ import { socialEmptyError } from "../../social/browser-errors.js";
20
+ import { assertTwitterReadable, gotoTwitterPage } from "./browser-state.js";
21
+
22
+ const TWEET_COLUMNS = [
23
+ "id",
24
+ "author",
25
+ "text",
26
+ "likes",
27
+ "retweets",
28
+ "views",
29
+ "url",
30
+ ];
31
+
32
+ function normalizeTwitterPageUrl(url: string): string {
33
+ const parsed = new URL(url);
34
+ const parts = parsed.pathname.split("/");
35
+ if (parts[1]?.startsWith("@")) {
36
+ parts[1] = parts[1].slice(1);
37
+ parsed.pathname = parts.join("/");
38
+ }
39
+ return parsed.toString().replace(/\/$/, "");
40
+ }
41
+
42
+ function twitterUserTimelineUrl(user: string): string {
43
+ return `https://x.com/${encodeURIComponent(user.replace(/^@/, ""))}`;
44
+ }
4
45
 
5
- async function extractTweets(
46
+ export function buildTwitterTweetExtractionScript(limit: number): string {
47
+ return `(() => {
48
+ const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
49
+ const rows = [];
50
+ const seen = new Set();
51
+ for (const article of document.querySelectorAll('article[data-testid="tweet"]')) {
52
+ const link = article.querySelector('a[href*="/status/"]');
53
+ const href = link?.getAttribute('href') || '';
54
+ const iStatusMatch = href.match(/^\\/i\\/status\\/(\\d+)/);
55
+ const userStatusMatch = href.match(/^\\/(?!i\\/status\\/)([^/?#]+)\\/status\\/(\\d+)/);
56
+ const id = iStatusMatch?.[1] || userStatusMatch?.[2] || '';
57
+ if (!id || seen.has(id)) continue;
58
+ seen.add(id);
59
+ const fallbackAuthor = clean(article.querySelector('[data-testid="User-Name"]')?.textContent || '').split('@').pop() || 'unknown';
60
+ const author = userStatusMatch ? decodeURIComponent(userStatusMatch[1]) : fallbackAuthor;
61
+ const text = Array.from(article.querySelectorAll('[data-testid="tweetText"]'))
62
+ .map((el) => clean(el.textContent || ''))
63
+ .filter(Boolean)
64
+ .join('\\n');
65
+ const metric = (name) => clean(article.querySelector('[data-testid="' + name + '"]')?.textContent || '');
66
+ if (!text) continue;
67
+ rows.push({
68
+ id,
69
+ author,
70
+ text,
71
+ likes: metric('like'),
72
+ retweets: metric('retweet'),
73
+ views: clean(article.querySelector('a[href$="/analytics"]')?.textContent || ''),
74
+ url: 'https://x.com' + href.split('?')[0],
75
+ });
76
+ if (rows.length >= ${js(limit)}) break;
77
+ }
78
+ return rows;
79
+ })()`;
80
+ }
81
+
82
+ export async function extractTweets(
6
83
  page: IPage,
7
84
  url: string,
8
85
  limit: number,
86
+ command: string,
9
87
  ): Promise<Record<string, unknown>[]> {
10
- await page.goto(url, { settleMs: 2500 });
11
- const rows = await page.evaluate(`(() => {
12
- const tweets = [...document.querySelectorAll('article[data-testid="tweet"]')];
13
- return tweets.map((article) => {
14
- const user = article.querySelector('[data-testid="User-Name"]')?.textContent || '';
15
- const text = article.querySelector('[data-testid="tweetText"]')?.textContent || '';
16
- const link = [...article.querySelectorAll('a[href*="/status/"]')].pop();
17
- return {
18
- author: user.replace(/\\s+/g, ' ').trim(),
19
- text: text.replace(/\\s+/g, ' ').trim(),
20
- url: link ? new URL(link.getAttribute('href') || '', location.href).href : ''
21
- };
22
- }).filter((row) => row.text).slice(0, ${js(limit)});
23
- })()`);
24
- return Array.isArray(rows) ? (rows as Record<string, unknown>[]) : [];
88
+ await gotoTwitterPage(page, normalizeTwitterPageUrl(url), command);
89
+ await page.autoScroll({ maxScrolls: 2, delay: 1000 });
90
+ await assertTwitterReadable(page, command);
91
+ const rows = await page.evaluate(buildTwitterTweetExtractionScript(limit));
92
+ const parsedRows = Array.isArray(rows)
93
+ ? (rows as Record<string, unknown>[])
94
+ : [];
95
+ if (parsedRows.length > 0) return parsedRows;
96
+ throw socialEmptyError(
97
+ "twitter",
98
+ command,
99
+ `Twitter/X ${command} loaded no parseable tweets from ${url}.`,
100
+ );
101
+ }
102
+
103
+ function userTimelineFunc(command: string) {
104
+ return async (page: unknown, kwargs: Record<string, unknown>) =>
105
+ extractTweets(
106
+ page as IPage,
107
+ twitterUserTimelineUrl(str(kwargs.user)),
108
+ intArg(kwargs.limit, 20, 100),
109
+ command,
110
+ );
25
111
  }
26
112
 
27
113
  cli({
@@ -35,19 +121,47 @@ cli({
35
121
  { name: "user", type: "str", required: true, positional: true },
36
122
  { name: "limit", type: "int", default: 20 },
37
123
  ],
38
- columns: ["author", "text", "url"],
39
- func: async (page, kwargs) =>
40
- extractTweets(
41
- page as IPage,
42
- `https://x.com/${encodeURIComponent(str(kwargs.user).replace(/^@/, ""))}`,
43
- intArg(kwargs.limit, 20, 100),
44
- ),
124
+ columns: TWEET_COLUMNS,
125
+ socialCapabilities: ["read", "author", "user_content"],
126
+ func: userTimelineFunc("tweets"),
127
+ });
128
+
129
+ cli({
130
+ site: "twitter",
131
+ name: "user-tweets",
132
+ description: "Read recent tweets from a Twitter/X user profile",
133
+ domain: "x.com",
134
+ strategy: Strategy.COOKIE,
135
+ browser: true,
136
+ args: [
137
+ { name: "user", type: "str", required: true, positional: true },
138
+ { name: "limit", type: "int", default: 20 },
139
+ ],
140
+ columns: TWEET_COLUMNS,
141
+ socialCapabilities: ["read", "author", "user_content"],
142
+ func: userTimelineFunc("user-tweets"),
143
+ });
144
+
145
+ cli({
146
+ site: "twitter",
147
+ name: "user-timeline",
148
+ description: "Read a Twitter/X user's tweet timeline",
149
+ domain: "x.com",
150
+ strategy: Strategy.COOKIE,
151
+ browser: true,
152
+ args: [
153
+ { name: "user", type: "str", required: true, positional: true },
154
+ { name: "limit", type: "int", default: 20 },
155
+ ],
156
+ columns: TWEET_COLUMNS,
157
+ socialCapabilities: ["read", "author", "user_content"],
158
+ func: userTimelineFunc("user-timeline"),
45
159
  });
46
160
 
47
161
  cli({
48
162
  site: "twitter",
49
163
  name: "list-tweets",
50
- description: "Read tweets from a Twitter/X list",
164
+ description: "Read tweets from a Twitter/X list timeline",
51
165
  domain: "x.com",
52
166
  strategy: Strategy.COOKIE,
53
167
  browser: true,
@@ -55,13 +169,19 @@ cli({
55
169
  { name: "list", type: "str", required: true, positional: true },
56
170
  { name: "limit", type: "int", default: 20 },
57
171
  ],
58
- columns: ["author", "text", "url"],
172
+ columns: TWEET_COLUMNS,
173
+ socialCapabilities: ["read", "lists", "user_content"],
59
174
  func: async (page, kwargs) => {
60
175
  const list = str(kwargs.list);
61
176
  const url = list.startsWith("http")
62
177
  ? list
63
178
  : `https://x.com/i/lists/${encodeURIComponent(list)}`;
64
- return extractTweets(page as IPage, url, intArg(kwargs.limit, 20, 100));
179
+ return extractTweets(
180
+ page as IPage,
181
+ url,
182
+ intArg(kwargs.limit, 20, 100),
183
+ "list-tweets",
184
+ );
65
185
  },
66
186
  });
67
187
 
@@ -15,6 +15,7 @@ cli({
15
15
  description: "Post a new tweet",
16
16
  domain: "x.com",
17
17
  strategy: Strategy.COOKIE,
18
+ socialCapabilities: ["write_post"],
18
19
  args: [
19
20
  {
20
21
  name: "text",
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { resolveCommand } from "../../registry.js";
2
3
 
3
- import { normalizeTwitterThreadRows } from "./thread.js";
4
+ import {
5
+ normalizeTwitterThreadRows,
6
+ resolveTwitterThreadTweetId,
7
+ } from "./thread.js";
4
8
 
5
9
  describe("normalizeTwitterThreadRows", () => {
6
10
  it("adds normalized comment hierarchy fields to thread rows", () => {
@@ -41,3 +45,23 @@ describe("normalizeTwitterThreadRows", () => {
41
45
  ]);
42
46
  });
43
47
  });
48
+
49
+ describe("twitter thread and comments commands", () => {
50
+ it("accepts either a numeric tweet id or a Twitter/X status URL", () => {
51
+ expect(resolveTwitterThreadTweetId("123")).toBe("123");
52
+ expect(
53
+ resolveTwitterThreadTweetId(
54
+ "https://x.com/alice/status/2040254679301718161?s=20",
55
+ ),
56
+ ).toBe("2040254679301718161");
57
+ });
58
+
59
+ it("registers a comments command for tweet replies", () => {
60
+ const comments = resolveCommand("twitter", "comments")?.command;
61
+
62
+ expect(comments?.adapterArgs?.map((arg) => arg.name)).toEqual(["url"]);
63
+ expect(comments?.socialCapabilities).toEqual(
64
+ expect.arrayContaining(["comments", "comment_replies"]),
65
+ );
66
+ });
67
+ });
@@ -1,14 +1,27 @@
1
1
  /**
2
- * Twitter thread — fetch a tweet and its conversation thread via GraphQL TweetDetail.
2
+ * @owner src/adapters/twitter/thread.ts
3
+ * @does Register Twitter/X tweet thread and comments commands over the GraphQL TweetDetail operation.
4
+ * @needs Twitter cookie auth with ct0/auth_token, TweetDetail GraphQL operation, tweet ID or status URL input.
5
+ * @feeds twitter.thread, twitter.comments, social.comments twitter.
6
+ * @breaks TweetDetail query-id drift or invalid tweet target parsing returns empty comment trees.
7
+ * @invariants Thread rows preserve root/reply hierarchy fields over the standard tweet row shape.
8
+ * @side-effects Performs authenticated read requests to x.com.
9
+ * @perf One GraphQL request per command invocation.
10
+ * @concurrency Stateless per invocation.
11
+ * @test src/adapters/twitter/thread.test.ts
12
+ * @stability stable
13
+ * @since 2026-05-27
3
14
  */
4
15
 
5
16
  import { cli } from "../../registry.js";
6
17
  import { Strategy } from "../../types.js";
18
+ import type { SocialCapability } from "../../types.js";
7
19
  import {
8
20
  twitterFetch,
9
21
  FEATURES,
10
22
  extractTweetsFromInstructions,
11
23
  } from "./client.js";
24
+ import { parseTwitterTweetUrl } from "./tweet-url.js";
12
25
 
13
26
  const QUERY_ID = "B9_KmbkLhXt6jRwGjJrweg";
14
27
  const ENDPOINT = "TweetDetail";
@@ -40,64 +53,103 @@ export function normalizeTwitterThreadRows(
40
53
  });
41
54
  }
42
55
 
56
+ export function resolveTwitterThreadTweetId(value: unknown): string {
57
+ const raw = String(value ?? "").trim();
58
+ if (/^\d+$/.test(raw)) return raw;
59
+ return parseTwitterTweetUrl(raw).id;
60
+ }
61
+
62
+ async function fetchTwitterThread(
63
+ tweetTarget: unknown,
64
+ ): Promise<
65
+ Array<TweetRow & { parent_id: string; depth: number; path: string }>
66
+ > {
67
+ const tweetId = resolveTwitterThreadTweetId(tweetTarget);
68
+
69
+ const variables = {
70
+ focalTweetId: tweetId,
71
+ with_rux_injections: false,
72
+ includePromotedContent: false,
73
+ withCommunity: true,
74
+ withQuickPromoteEligibilityTweetFields: false,
75
+ withBirdwatchNotes: true,
76
+ withVoice: true,
77
+ withV2Timeline: true,
78
+ };
79
+
80
+ const data = (await twitterFetch(
81
+ ENDPOINT,
82
+ QUERY_ID,
83
+ variables,
84
+ FEATURES,
85
+ )) as Record<string, unknown>;
86
+
87
+ // Navigate: data.threaded_conversation_with_injections_v2.instructions
88
+ const root = data.data as Record<string, unknown> | undefined;
89
+ const conversation = root?.threaded_conversation_with_injections_v2 as
90
+ | Record<string, unknown>
91
+ | undefined;
92
+ const instructions = (conversation?.instructions as unknown[]) ?? [];
93
+
94
+ return normalizeTwitterThreadRows(
95
+ tweetId,
96
+ extractTweetsFromInstructions(instructions),
97
+ );
98
+ }
99
+
100
+ const THREAD_SOCIAL_CAPABILITIES: SocialCapability[] = [
101
+ "read",
102
+ "comments",
103
+ "comment_replies",
104
+ ];
105
+
106
+ const THREAD_COLUMNS = [
107
+ "id",
108
+ "parent_id",
109
+ "author",
110
+ "text",
111
+ "likes",
112
+ "retweets",
113
+ "views",
114
+ "url",
115
+ "depth",
116
+ "path",
117
+ ];
118
+
43
119
  cli({
44
120
  site: "twitter",
45
121
  name: "thread",
46
122
  description: "Get a tweet and its conversation thread",
47
123
  domain: "x.com",
48
124
  strategy: Strategy.COOKIE,
49
- socialCapabilities: ["read", "comments", "comment_replies"],
125
+ socialCapabilities: THREAD_SOCIAL_CAPABILITIES,
50
126
  args: [
51
127
  {
52
128
  name: "tweet_id",
53
129
  required: true,
54
130
  positional: true,
55
- description: "Tweet ID (numeric)",
131
+ description: "Tweet ID or Twitter/X status URL",
56
132
  },
57
133
  ],
58
- columns: [
59
- "id",
60
- "parent_id",
61
- "author",
62
- "text",
63
- "likes",
64
- "retweets",
65
- "views",
66
- "url",
67
- "depth",
68
- "path",
69
- ],
70
- func: async (_page, kwargs) => {
71
- const tweetId = String(kwargs.tweet_id);
72
-
73
- const variables = {
74
- focalTweetId: tweetId,
75
- with_rux_injections: false,
76
- includePromotedContent: false,
77
- withCommunity: true,
78
- withQuickPromoteEligibilityTweetFields: false,
79
- withBirdwatchNotes: true,
80
- withVoice: true,
81
- withV2Timeline: true,
82
- };
83
-
84
- const data = (await twitterFetch(
85
- ENDPOINT,
86
- QUERY_ID,
87
- variables,
88
- FEATURES,
89
- )) as Record<string, unknown>;
90
-
91
- // Navigate: data.threaded_conversation_with_injections_v2.instructions
92
- const root = data.data as Record<string, unknown> | undefined;
93
- const conversation = root?.threaded_conversation_with_injections_v2 as
94
- | Record<string, unknown>
95
- | undefined;
96
- const instructions = (conversation?.instructions as unknown[]) ?? [];
134
+ columns: THREAD_COLUMNS,
135
+ func: async (_page, kwargs) => fetchTwitterThread(kwargs.tweet_id),
136
+ });
97
137
 
98
- return normalizeTwitterThreadRows(
99
- tweetId,
100
- extractTweetsFromInstructions(instructions),
101
- );
102
- },
138
+ cli({
139
+ site: "twitter",
140
+ name: "comments",
141
+ description: "Get replies/comments for a Twitter/X tweet",
142
+ domain: "x.com",
143
+ strategy: Strategy.COOKIE,
144
+ socialCapabilities: THREAD_SOCIAL_CAPABILITIES,
145
+ args: [
146
+ {
147
+ name: "url",
148
+ required: true,
149
+ positional: true,
150
+ description: "Tweet ID or Twitter/X status URL",
151
+ },
152
+ ],
153
+ columns: THREAD_COLUMNS,
154
+ func: async (_page, kwargs) => fetchTwitterThread(kwargs.url),
103
155
  });