@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -0,0 +1,337 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { resolveTwitterQueryId } from './shared.js';
4
+ import { parseListsManagement } from './lists.js';
5
+
6
+ const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
+ const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
+
10
+ const LISTS_MANAGEMENT_FEATURES = {
11
+ rweb_video_screen_enabled: false,
12
+ profile_label_improvements_pcf_label_in_post_enabled: true,
13
+ rweb_tipjar_consumption_enabled: true,
14
+ verified_phone_label_enabled: false,
15
+ creator_subscriptions_tweet_preview_api_enabled: true,
16
+ responsive_web_graphql_timeline_navigation_enabled: true,
17
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
18
+ premium_content_api_read_enabled: false,
19
+ communities_web_enable_tweet_community_results_fetch: true,
20
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
21
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
22
+ responsive_web_grok_analyze_post_followups_enabled: true,
23
+ responsive_web_jetfuel_frame: false,
24
+ responsive_web_grok_share_attachment_enabled: true,
25
+ articles_preview_enabled: true,
26
+ responsive_web_edit_tweet_api_enabled: true,
27
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
28
+ view_counts_everywhere_api_enabled: true,
29
+ longform_notetweets_consumption_enabled: true,
30
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
31
+ tweet_awards_web_tipping_enabled: false,
32
+ responsive_web_grok_show_grok_translated_post: false,
33
+ responsive_web_grok_analysis_button_from_backend: false,
34
+ creator_subscriptions_quote_tweet_preview_enabled: false,
35
+ freedom_of_speech_not_reach_fetch_enabled: true,
36
+ standardized_nudges_misinfo: true,
37
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
38
+ longform_notetweets_rich_text_read_enabled: true,
39
+ longform_notetweets_inline_media_enabled: true,
40
+ responsive_web_grok_image_annotation_enabled: true,
41
+ responsive_web_enhance_cards_enabled: false,
42
+ };
43
+
44
+ function buildUserByScreenNameUrl(queryId, screenName) {
45
+ const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
46
+ const feats = JSON.stringify({
47
+ hidden_profile_subscriptions_enabled: true,
48
+ rweb_tipjar_consumption_enabled: true,
49
+ responsive_web_graphql_exclude_directive_enabled: true,
50
+ verified_phone_label_enabled: false,
51
+ subscriptions_verification_info_is_identity_verified_enabled: true,
52
+ subscriptions_verification_info_verified_since_enabled: true,
53
+ highlights_tweets_tab_ui_enabled: true,
54
+ responsive_web_twitter_article_notes_tab_enabled: true,
55
+ subscriptions_feature_can_gift_premium: true,
56
+ creator_subscriptions_tweet_preview_api_enabled: true,
57
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
58
+ responsive_web_graphql_timeline_navigation_enabled: true,
59
+ });
60
+ return `/i/api/graphql/${queryId}/UserByScreenName`
61
+ + `?variables=${encodeURIComponent(vars)}`
62
+ + `&features=${encodeURIComponent(feats)}`;
63
+ }
64
+
65
+ cli({
66
+ site: 'twitter',
67
+ name: 'list-add',
68
+ description: 'Add a user to a Twitter/X list you own (no-op if already a member)',
69
+ domain: 'x.com',
70
+ strategy: Strategy.UI,
71
+ browser: true,
72
+ args: [
73
+ { name: 'listId', positional: true, type: 'string', required: true },
74
+ { name: 'username', positional: true, type: 'string', required: true },
75
+ ],
76
+ columns: ['listId', 'username', 'userId', 'status', 'message'],
77
+ func: async (page, kwargs) => {
78
+ const listId = String(kwargs.listId || '').trim();
79
+ const username = String(kwargs.username || '').replace(/^@/, '').trim();
80
+ if (!listId || !/^\d+$/.test(listId)) {
81
+ throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID (see \`opencli twitter lists\`).`);
82
+ }
83
+ if (!username) {
84
+ throw new CommandExecutionError('Username is required');
85
+ }
86
+ await page.goto('https://x.com');
87
+ await page.wait(3);
88
+ const ct0 = await page.evaluate(`() => {
89
+ return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
90
+ }`);
91
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
92
+
93
+ const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
94
+
95
+ const headers = JSON.stringify({
96
+ 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
97
+ 'X-Csrf-Token': ct0,
98
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
99
+ 'X-Twitter-Active-User': 'yes',
100
+ });
101
+
102
+ const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
103
+ const userId = await page.evaluate(`async () => {
104
+ const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
105
+ if (!resp.ok) return null;
106
+ const d = await resp.json();
107
+ return d.data?.user?.result?.rest_id || null;
108
+ }`);
109
+ if (!userId) {
110
+ throw new CommandExecutionError(`Could not resolve user @${username}`);
111
+ }
112
+
113
+ // ListsManagementPageTimeline — used both for id→name resolution and post-op verification.
114
+ const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
115
+ const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
116
+ const listsData = await page.evaluate(`async () => {
117
+ const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
118
+ if (!r.ok) return { __error: 'HTTP ' + r.status };
119
+ return await r.json();
120
+ }`);
121
+ const parsedLists = listsData && !listsData.__error
122
+ ? parseListsManagement(listsData, new Set())
123
+ : [];
124
+ if (listsData && listsData.__error) {
125
+ throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`);
126
+ }
127
+ const targetList = parsedLists.find((l) => l.id === listId);
128
+ if (!targetList) {
129
+ throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`);
130
+ }
131
+
132
+ // Use UI strategy — programmatically open "Add/Remove from Lists" dialog and toggle the target list.
133
+ await page.goto(`https://x.com/${username}`);
134
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
135
+ const targetName = targetList.name;
136
+ const uiResult = await page.evaluate(`(async () => {
137
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
138
+ const findOne = (sel, root = document) => root.querySelector(sel);
139
+ const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => {
140
+ const t0 = Date.now();
141
+ while (Date.now() - t0 < timeoutMs) {
142
+ const v = fn();
143
+ if (v) return v;
144
+ await sleep(intervalMs);
145
+ }
146
+ return null;
147
+ };
148
+ try {
149
+ // Install fetch + XHR interceptors to observe list-membership mutations.
150
+ const MUTATION_RE = /ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)|ListManagement.*Add|ListManagement.*Remove|\\/add_member|\\/remove_member|ListAddMembers|ListRemoveMembers|list.*member.*create|list.*member.*destroy/i;
151
+ if (!window.__opencliListMutations) {
152
+ window.__opencliListMutations = [];
153
+ window.__opencliAllRequests = [];
154
+ const origFetch = window.fetch.bind(window);
155
+ window.fetch = async function(...args) {
156
+ const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
157
+ const method = (args[1] && args[1].method) || 'GET';
158
+ let resp;
159
+ try { resp = await origFetch(...args); }
160
+ catch (err) {
161
+ if (MUTATION_RE.test(url)) window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now(), via: 'fetch' });
162
+ throw err;
163
+ }
164
+ if (method !== 'GET' && method !== 'HEAD') {
165
+ window.__opencliAllRequests.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
166
+ }
167
+ if (MUTATION_RE.test(url)) {
168
+ window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
169
+ }
170
+ return resp;
171
+ };
172
+ // Also hook XMLHttpRequest
173
+ const OrigXhrOpen = XMLHttpRequest.prototype.open;
174
+ const OrigXhrSend = XMLHttpRequest.prototype.send;
175
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
176
+ this.__opencliMethod = method;
177
+ this.__opencliUrl = url;
178
+ return OrigXhrOpen.call(this, method, url, ...rest);
179
+ };
180
+ XMLHttpRequest.prototype.send = function(...args) {
181
+ const xhr = this;
182
+ xhr.addEventListener('loadend', () => {
183
+ const url = xhr.__opencliUrl || '';
184
+ const method = xhr.__opencliMethod || 'GET';
185
+ if (method !== 'GET' && method !== 'HEAD') {
186
+ window.__opencliAllRequests.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
187
+ }
188
+ if (MUTATION_RE.test(url)) {
189
+ window.__opencliListMutations.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
190
+ }
191
+ });
192
+ return OrigXhrSend.apply(this, args);
193
+ };
194
+ }
195
+ window.__opencliListMutations.length = 0;
196
+ window.__opencliAllRequests.length = 0;
197
+
198
+ const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
199
+ if (!caret) return { ok: false, message: 'Could not find user actions (…) button. Are you logged in?' };
200
+ caret.click();
201
+ await sleep(600);
202
+ const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
203
+ const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
204
+ if (!addToListItem) {
205
+ return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
206
+ }
207
+ addToListItem.click();
208
+ await sleep(1200);
209
+ const dialog = await waitFor(() => findOne('[role="dialog"]'));
210
+ if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
211
+
212
+ const targetName = ${JSON.stringify(targetName)};
213
+ // Find the real scroll container (virtualized list). Try a few candidates.
214
+ const scrollCandidates = [
215
+ dialog.querySelector('[data-viewportview="true"]'),
216
+ dialog.querySelector('[aria-label]')?.parentElement,
217
+ ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
218
+ ].filter(Boolean);
219
+ let row = null;
220
+ let scrollEl = scrollCandidates[0] || dialog;
221
+ for (const se of scrollCandidates) {
222
+ if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
223
+ }
224
+ let lastScrollTop = -1;
225
+ for (let i = 0; i < 12; i++) {
226
+ const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
227
+ row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
228
+ if (row) break;
229
+ // Incremental scroll within the container
230
+ const prev = scrollEl.scrollTop;
231
+ scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
232
+ if (scrollEl.scrollTop === prev) {
233
+ // Couldn't scroll further. Give up.
234
+ if (scrollEl.scrollTop === lastScrollTop) break;
235
+ }
236
+ lastScrollTop = scrollEl.scrollTop;
237
+ await sleep(500);
238
+ }
239
+ if (!row) {
240
+ const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
241
+ .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
242
+ const dialogText = (dialog.innerText || '').slice(0, 500);
243
+ return { ok: false, message: 'List "' + targetName + '" not found. Cells: [' + names.join(' | ') + ']. DialogText: ' + dialogText };
244
+ }
245
+ const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
246
+ const readChecked = () => {
247
+ const v = listCell.getAttribute('aria-checked');
248
+ return v === 'true' || v === 'false' ? v : null;
249
+ };
250
+ await sleep(600);
251
+ let ariaChecked = readChecked();
252
+ for (let i = 0; i < 8; i++) {
253
+ await sleep(500);
254
+ const next = readChecked();
255
+ if (next && next === ariaChecked) break;
256
+ ariaChecked = next || ariaChecked;
257
+ }
258
+ const isMember = ariaChecked === 'true';
259
+ if (isMember) {
260
+ const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
261
+ if (closeBtn) closeBtn.click();
262
+ return { ok: true, noop: true };
263
+ }
264
+ try { listCell.scrollIntoView({ block: 'center' }); } catch {}
265
+ await sleep(400);
266
+ const mutationsBefore = window.__opencliListMutations.length;
267
+ const rowRect = listCell.getBoundingClientRect();
268
+ // Find the Save button (top-right of dialog). Match by text "Save" / "Done" / CJK equivalents.
269
+ const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
270
+ const txt = (b.innerText || '').trim();
271
+ return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
272
+ });
273
+ const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
274
+ return {
275
+ ok: true,
276
+ needsNativeInteraction: true,
277
+ rowClickX: Math.round(rowRect.left + rowRect.width / 2),
278
+ rowClickY: Math.round(rowRect.top + rowRect.height / 2),
279
+ saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
280
+ saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
281
+ saveText: saveButton ? (saveButton.innerText || '').trim() : null,
282
+ mutationsBefore,
283
+ ariaBefore: ariaChecked,
284
+ };
285
+ } catch (e) {
286
+ return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
287
+ }
288
+ })()`);
289
+
290
+ if (!uiResult.ok) {
291
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${uiResult.message}`);
292
+ }
293
+
294
+ let verifiedBy = null;
295
+ if (uiResult.needsNativeInteraction) {
296
+ if (typeof page.nativeClick !== 'function' || typeof page.nativeKeyPress !== 'function') {
297
+ throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick + nativeKeyPress).');
298
+ }
299
+ if (!uiResult.saveClickX) {
300
+ throw new CommandExecutionError(`Save button not found in dialog (X expected text Save/Done). Dialog structure may have changed.`);
301
+ }
302
+ const memberCountBefore = Number(targetList.members) || 0;
303
+ // 1. Trusted click on row → aria flips false→true (optimistic UI)
304
+ await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
305
+ await new Promise((r) => setTimeout(r, 800));
306
+ // 2. Trusted click on Save button → X commits to server
307
+ await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
308
+ await new Promise((r) => setTimeout(r, 3500));
309
+ // Ground truth: re-fetch ListsManagementPageTimeline and compare member_count
310
+ const listsAfter = await page.evaluate(`async () => {
311
+ const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
312
+ if (!r.ok) return { __error: 'HTTP ' + r.status };
313
+ return await r.json();
314
+ }`);
315
+ const parsedAfter = listsAfter && !listsAfter.__error
316
+ ? parseListsManagement(listsAfter, new Set())
317
+ : [];
318
+ const afterList = parsedAfter.find((l) => l.id === listId);
319
+ const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
320
+ if (memberCountAfter > memberCountBefore) {
321
+ verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
322
+ } else {
323
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}). X's UI flipped but did not commit — try reloading page/extension.`);
324
+ }
325
+ }
326
+
327
+ return [{
328
+ listId,
329
+ username,
330
+ userId: String(userId),
331
+ status: uiResult.noop ? 'noop' : 'success',
332
+ message: uiResult.noop
333
+ ? `@${username} is already a member of list ${listId}`
334
+ : `Added @${username} to list ${listId} (verified via ${verifiedBy})`,
335
+ }];
336
+ },
337
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './list-add.js';
4
+
5
+ describe('twitter list-add registration', () => {
6
+ it('registers the list-add command with the expected shape', () => {
7
+ const cmd = getRegistry().get('twitter/list-add');
8
+ expect(cmd?.func).toBeTypeOf('function');
9
+ expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']);
10
+ const listIdArg = cmd?.args?.find((a) => a.name === 'listId');
11
+ expect(listIdArg).toBeTruthy();
12
+ expect(listIdArg?.required).toBe(true);
13
+ expect(listIdArg?.positional).toBe(true);
14
+ });
15
+ });
@@ -0,0 +1,297 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { resolveTwitterQueryId } from './shared.js';
4
+ import { parseListsManagement } from './lists.js';
5
+
6
+ const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
+ const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
+
10
+ const LISTS_MANAGEMENT_FEATURES = {
11
+ rweb_video_screen_enabled: false,
12
+ profile_label_improvements_pcf_label_in_post_enabled: true,
13
+ rweb_tipjar_consumption_enabled: true,
14
+ verified_phone_label_enabled: false,
15
+ creator_subscriptions_tweet_preview_api_enabled: true,
16
+ responsive_web_graphql_timeline_navigation_enabled: true,
17
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
18
+ premium_content_api_read_enabled: false,
19
+ communities_web_enable_tweet_community_results_fetch: true,
20
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
21
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
22
+ responsive_web_grok_analyze_post_followups_enabled: true,
23
+ responsive_web_jetfuel_frame: false,
24
+ responsive_web_grok_share_attachment_enabled: true,
25
+ articles_preview_enabled: true,
26
+ responsive_web_edit_tweet_api_enabled: true,
27
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
28
+ view_counts_everywhere_api_enabled: true,
29
+ longform_notetweets_consumption_enabled: true,
30
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
31
+ tweet_awards_web_tipping_enabled: false,
32
+ responsive_web_grok_show_grok_translated_post: false,
33
+ responsive_web_grok_analysis_button_from_backend: false,
34
+ creator_subscriptions_quote_tweet_preview_enabled: false,
35
+ freedom_of_speech_not_reach_fetch_enabled: true,
36
+ standardized_nudges_misinfo: true,
37
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
38
+ longform_notetweets_rich_text_read_enabled: true,
39
+ longform_notetweets_inline_media_enabled: true,
40
+ responsive_web_grok_image_annotation_enabled: true,
41
+ responsive_web_enhance_cards_enabled: false,
42
+ };
43
+
44
+ function buildUserByScreenNameUrl(queryId, screenName) {
45
+ const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
46
+ const feats = JSON.stringify({
47
+ hidden_profile_subscriptions_enabled: true,
48
+ rweb_tipjar_consumption_enabled: true,
49
+ responsive_web_graphql_exclude_directive_enabled: true,
50
+ verified_phone_label_enabled: false,
51
+ subscriptions_verification_info_is_identity_verified_enabled: true,
52
+ subscriptions_verification_info_verified_since_enabled: true,
53
+ highlights_tweets_tab_ui_enabled: true,
54
+ responsive_web_twitter_article_notes_tab_enabled: true,
55
+ subscriptions_feature_can_gift_premium: true,
56
+ creator_subscriptions_tweet_preview_api_enabled: true,
57
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
58
+ responsive_web_graphql_timeline_navigation_enabled: true,
59
+ });
60
+ return `/i/api/graphql/${queryId}/UserByScreenName`
61
+ + `?variables=${encodeURIComponent(vars)}`
62
+ + `&features=${encodeURIComponent(feats)}`;
63
+ }
64
+
65
+ export function interpretRemoveResponse(status, json) {
66
+ if (status === 200 && json && (json.id_str || json.id || json.slug)) return { ok: true };
67
+ if (json && Array.isArray(json.errors) && json.errors.length > 0) {
68
+ const err = json.errors[0];
69
+ return { ok: false, error: `${err.code ? '[' + err.code + '] ' : ''}${err.message || 'Unknown error'}` };
70
+ }
71
+ return { ok: false, error: `HTTP ${status}` };
72
+ }
73
+
74
+ cli({
75
+ site: 'twitter',
76
+ name: 'list-remove',
77
+ description: 'Remove a user from a Twitter/X list you own (toggles via UI; no-op if not currently a member)',
78
+ domain: 'x.com',
79
+ strategy: Strategy.UI,
80
+ browser: true,
81
+ args: [
82
+ { name: 'listId', positional: true, type: 'string', required: true },
83
+ { name: 'username', positional: true, type: 'string', required: true },
84
+ ],
85
+ columns: ['listId', 'username', 'userId', 'status', 'message'],
86
+ func: async (page, kwargs) => {
87
+ const listId = String(kwargs.listId || '').trim();
88
+ const username = String(kwargs.username || '').replace(/^@/, '').trim();
89
+ if (!listId || !/^\d+$/.test(listId)) {
90
+ throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}`);
91
+ }
92
+ if (!username) throw new CommandExecutionError('Username is required');
93
+
94
+ await page.goto('https://x.com');
95
+ await page.wait(3);
96
+ const ct0 = await page.evaluate(`() => {
97
+ return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
98
+ }`);
99
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
100
+
101
+ const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
102
+ const headers = JSON.stringify({
103
+ 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
104
+ 'X-Csrf-Token': ct0,
105
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
106
+ 'X-Twitter-Active-User': 'yes',
107
+ });
108
+
109
+ const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
110
+ const userId = await page.evaluate(`async () => {
111
+ const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
112
+ if (!resp.ok) return null;
113
+ const d = await resp.json();
114
+ return d.data?.user?.result?.rest_id || null;
115
+ }`);
116
+ if (!userId) throw new CommandExecutionError(`Could not resolve user @${username}`);
117
+
118
+ // Resolve listId → name so we can match the dialog row.
119
+ const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
120
+ const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
121
+ const listsData = await page.evaluate(`async () => {
122
+ const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
123
+ if (!r.ok) return { __error: 'HTTP ' + r.status };
124
+ return await r.json();
125
+ }`);
126
+ if (listsData && listsData.__error) {
127
+ throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`);
128
+ }
129
+ const parsedLists = parseListsManagement(listsData, new Set());
130
+ const targetList = parsedLists.find((l) => l.id === listId);
131
+ if (!targetList) {
132
+ throw new CommandExecutionError(`List ${listId} not found among your lists.`);
133
+ }
134
+ const targetName = targetList.name;
135
+
136
+ await page.goto(`https://x.com/${username}`);
137
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
138
+ const uiResult = await page.evaluate(`(async () => {
139
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
140
+ const findOne = (sel, root = document) => root.querySelector(sel);
141
+ const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => {
142
+ const t0 = Date.now();
143
+ while (Date.now() - t0 < timeoutMs) { const v = fn(); if (v) return v; await sleep(intervalMs); }
144
+ return null;
145
+ };
146
+ try {
147
+ if (!window.__opencliListMutations) {
148
+ window.__opencliListMutations = [];
149
+ const origFetch = window.fetch.bind(window);
150
+ window.fetch = async function(...args) {
151
+ const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
152
+ const method = (args[1] && args[1].method) || 'GET';
153
+ let resp;
154
+ try { resp = await origFetch(...args); } catch (err) {
155
+ if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) {
156
+ window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now() });
157
+ }
158
+ throw err;
159
+ }
160
+ if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) {
161
+ window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now() });
162
+ }
163
+ return resp;
164
+ };
165
+ }
166
+ window.__opencliListMutations.length = 0;
167
+
168
+ const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
169
+ if (!caret) return { ok: false, message: 'Could not find user actions (…) button' };
170
+ caret.click();
171
+ await sleep(600);
172
+ const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
173
+ const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
174
+ if (!addToListItem) return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
175
+ addToListItem.click();
176
+ await sleep(1200);
177
+ const dialog = await waitFor(() => findOne('[role="dialog"]'));
178
+ if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
179
+
180
+ const targetName = ${JSON.stringify(targetName)};
181
+ const scrollCandidates = [
182
+ dialog.querySelector('[data-viewportview="true"]'),
183
+ ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
184
+ ].filter(Boolean);
185
+ let scrollEl = scrollCandidates[0] || dialog;
186
+ for (const se of scrollCandidates) {
187
+ if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
188
+ }
189
+ let row = null;
190
+ let lastScrollTop = -1;
191
+ for (let i = 0; i < 12; i++) {
192
+ const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
193
+ row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
194
+ if (row) break;
195
+ const prev = scrollEl.scrollTop;
196
+ scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
197
+ if (scrollEl.scrollTop === prev && scrollEl.scrollTop === lastScrollTop) break;
198
+ lastScrollTop = scrollEl.scrollTop;
199
+ await sleep(500);
200
+ }
201
+ if (!row) {
202
+ const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
203
+ .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
204
+ return { ok: false, message: 'List "' + targetName + '" not found in dialog. Saw: ' + names.join(' | ') };
205
+ }
206
+
207
+ // Determine current membership: row has a filled checkmark (svg inside a specific container) when member.
208
+ // Heuristic: look for an aria-checked attribute, or an svg with specific fill on the row's right side.
209
+ // The listCell itself carries aria-checked. Require a stable reading
210
+ // (same value twice ~500ms apart) to avoid the X dialog's occasional
211
+ // flash of stale state when re-opened shortly after a toggle.
212
+ const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
213
+ const readChecked = () => {
214
+ const v = listCell.getAttribute('aria-checked');
215
+ return v === 'true' || v === 'false' ? v : null;
216
+ };
217
+ await sleep(600);
218
+ let ariaChecked = readChecked();
219
+ for (let i = 0; i < 8; i++) {
220
+ await sleep(500);
221
+ const next = readChecked();
222
+ if (next && next === ariaChecked) break;
223
+ ariaChecked = next || ariaChecked;
224
+ }
225
+ const isMember = ariaChecked === 'true';
226
+ if (!isMember) {
227
+ const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
228
+ if (closeBtn) closeBtn.click();
229
+ return { ok: true, noop: true };
230
+ }
231
+ try { listCell.scrollIntoView({ block: 'center' }); } catch {}
232
+ await sleep(400);
233
+ const rowRect = listCell.getBoundingClientRect();
234
+ const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
235
+ const txt = (b.innerText || '').trim();
236
+ return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
237
+ });
238
+ const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
239
+ return {
240
+ ok: true,
241
+ needsNativeInteraction: true,
242
+ rowClickX: Math.round(rowRect.left + rowRect.width / 2),
243
+ rowClickY: Math.round(rowRect.top + rowRect.height / 2),
244
+ saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
245
+ saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
246
+ mutationsBefore: window.__opencliListMutations.length,
247
+ };
248
+ } catch (e) {
249
+ return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
250
+ }
251
+ })()`);
252
+
253
+ if (!uiResult.ok) {
254
+ throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: ${uiResult.message}`);
255
+ }
256
+
257
+ let verifiedBy = null;
258
+ if (uiResult.needsNativeInteraction) {
259
+ if (typeof page.nativeClick !== 'function') {
260
+ throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick).');
261
+ }
262
+ if (!uiResult.saveClickX) {
263
+ throw new CommandExecutionError('Save button not found in dialog.');
264
+ }
265
+ const memberCountBefore = Number(targetList.members) || 0;
266
+ await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
267
+ await new Promise((r) => setTimeout(r, 800));
268
+ await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
269
+ await new Promise((r) => setTimeout(r, 3500));
270
+ const listsAfter = await page.evaluate(`async () => {
271
+ const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
272
+ if (!r.ok) return { __error: 'HTTP ' + r.status };
273
+ return await r.json();
274
+ }`);
275
+ const parsedAfter = listsAfter && !listsAfter.__error
276
+ ? parseListsManagement(listsAfter, new Set())
277
+ : [];
278
+ const afterList = parsedAfter.find((l) => l.id === listId);
279
+ const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
280
+ if (memberCountAfter < memberCountBefore) {
281
+ verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
282
+ } else {
283
+ throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}).`);
284
+ }
285
+ }
286
+
287
+ return [{
288
+ listId,
289
+ username,
290
+ userId: String(userId),
291
+ status: uiResult.noop ? 'noop' : 'success',
292
+ message: uiResult.noop
293
+ ? `@${username} was not a member of list ${listId}`
294
+ : `Removed @${username} from list ${listId} (verified via ${verifiedBy})`,
295
+ }];
296
+ },
297
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './list-remove.js';
4
+
5
+ describe('twitter list-remove registration', () => {
6
+ it('registers the list-remove command with the expected shape', () => {
7
+ const cmd = getRegistry().get('twitter/list-remove');
8
+ expect(cmd?.func).toBeTypeOf('function');
9
+ expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']);
10
+ const listIdArg = cmd?.args?.find((a) => a.name === 'listId');
11
+ expect(listIdArg).toBeTruthy();
12
+ expect(listIdArg?.required).toBe(true);
13
+ });
14
+ });