@jackwener/opencli 1.7.15 → 1.7.17

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 (172) hide show
  1. package/README.md +15 -13
  2. package/README.zh-CN.md +15 -12
  3. package/cli-manifest.json +165 -209
  4. package/clis/chatgpt/ask.js +3 -2
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +7 -2
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +3 -2
  12. package/clis/chatgpt/send.js +3 -2
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +259 -25
  15. package/clis/chatgpt/utils.test.js +166 -2
  16. package/clis/claude/ask.js +23 -8
  17. package/clis/claude/detail.js +10 -3
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +9 -3
  20. package/clis/claude/read.js +3 -2
  21. package/clis/claude/send.js +9 -4
  22. package/clis/claude/status.js +1 -1
  23. package/clis/claude/utils.js +27 -4
  24. package/clis/deepseek/ask.js +22 -9
  25. package/clis/deepseek/detail.js +10 -2
  26. package/clis/deepseek/history.js +1 -1
  27. package/clis/deepseek/new.js +14 -3
  28. package/clis/deepseek/read.js +3 -2
  29. package/clis/deepseek/send.js +1 -1
  30. package/clis/deepseek/status.js +1 -1
  31. package/clis/deepseek/utils.js +8 -1
  32. package/clis/doubao/ask.js +1 -1
  33. package/clis/doubao/detail.js +1 -1
  34. package/clis/doubao/history.js +1 -1
  35. package/clis/doubao/meeting-summary.js +1 -1
  36. package/clis/doubao/meeting-transcript.js +1 -1
  37. package/clis/doubao/new.js +1 -1
  38. package/clis/doubao/read.js +1 -1
  39. package/clis/doubao/send.js +1 -1
  40. package/clis/doubao/status.js +1 -1
  41. package/clis/gemini/ask.js +1 -1
  42. package/clis/gemini/deep-research-result.js +1 -1
  43. package/clis/gemini/deep-research.js +1 -1
  44. package/clis/gemini/image.js +1 -1
  45. package/clis/gemini/new.js +1 -1
  46. package/clis/grok/ask.js +1 -1
  47. package/clis/grok/detail.js +1 -1
  48. package/clis/grok/history.js +1 -1
  49. package/clis/grok/image.js +1 -1
  50. package/clis/grok/new.js +1 -1
  51. package/clis/grok/read.js +1 -1
  52. package/clis/grok/send.js +1 -1
  53. package/clis/grok/status.js +1 -1
  54. package/clis/linkedin/search.js +8 -11
  55. package/clis/maimai/search-talents.js +10 -6
  56. package/clis/notebooklm/current.js +1 -1
  57. package/clis/notebooklm/get.js +1 -1
  58. package/clis/notebooklm/history.js +1 -1
  59. package/clis/notebooklm/note-list.js +1 -1
  60. package/clis/notebooklm/notes-get.js +1 -1
  61. package/clis/notebooklm/open.js +2 -2
  62. package/clis/notebooklm/open.test.js +1 -1
  63. package/clis/notebooklm/source-fulltext.js +1 -1
  64. package/clis/notebooklm/source-get.js +1 -1
  65. package/clis/notebooklm/source-guide.js +1 -1
  66. package/clis/notebooklm/source-list.js +1 -1
  67. package/clis/notebooklm/summary.js +1 -1
  68. package/clis/openreview/author.js +58 -0
  69. package/clis/openreview/openreview.test.js +83 -1
  70. package/clis/openreview/utils.js +14 -0
  71. package/clis/qwen/ask.js +1 -1
  72. package/clis/qwen/detail.js +1 -1
  73. package/clis/qwen/history.js +1 -1
  74. package/clis/qwen/image.js +1 -1
  75. package/clis/qwen/new.js +1 -1
  76. package/clis/qwen/read.js +1 -1
  77. package/clis/qwen/send.js +1 -1
  78. package/clis/qwen/status.js +1 -1
  79. package/clis/reddit/comment.js +1 -0
  80. package/clis/reddit/frontpage.js +1 -0
  81. package/clis/reddit/popular.js +1 -0
  82. package/clis/reddit/read.js +2 -0
  83. package/clis/reddit/read.test.js +4 -0
  84. package/clis/reddit/save.js +1 -0
  85. package/clis/reddit/saved.js +1 -0
  86. package/clis/reddit/search.js +1 -0
  87. package/clis/reddit/subreddit.js +1 -0
  88. package/clis/reddit/subscribe.js +1 -0
  89. package/clis/reddit/upvote.js +1 -0
  90. package/clis/reddit/upvoted.js +1 -0
  91. package/clis/reddit/user-comments.js +1 -0
  92. package/clis/reddit/user-posts.js +1 -0
  93. package/clis/reddit/user.js +1 -0
  94. package/clis/twitter/article.js +7 -4
  95. package/clis/twitter/bookmark-folder.js +3 -5
  96. package/clis/twitter/bookmark-folder.test.js +5 -2
  97. package/clis/twitter/bookmark-folders.js +3 -5
  98. package/clis/twitter/bookmark-folders.test.js +3 -1
  99. package/clis/twitter/bookmarks.js +3 -5
  100. package/clis/twitter/download.js +1 -0
  101. package/clis/twitter/followers.js +1 -0
  102. package/clis/twitter/following.js +3 -6
  103. package/clis/twitter/following.test.js +2 -1
  104. package/clis/twitter/likes.js +3 -5
  105. package/clis/twitter/list-add.js +4 -3
  106. package/clis/twitter/list-add.test.js +23 -1
  107. package/clis/twitter/list-remove.js +4 -3
  108. package/clis/twitter/list-remove.test.js +23 -1
  109. package/clis/twitter/list-tweets.js +3 -5
  110. package/clis/twitter/lists.js +3 -5
  111. package/clis/twitter/notifications.js +1 -0
  112. package/clis/twitter/profile.js +7 -4
  113. package/clis/twitter/search.js +1 -0
  114. package/clis/twitter/thread.js +5 -7
  115. package/clis/twitter/timeline.js +5 -7
  116. package/clis/twitter/trending.js +4 -4
  117. package/clis/twitter/tweets.js +3 -6
  118. package/clis/youtube/like.js +6 -2
  119. package/clis/youtube/subscribe.js +6 -2
  120. package/clis/youtube/unlike.js +6 -2
  121. package/clis/youtube/unsubscribe.js +6 -2
  122. package/clis/youtube/utils.js +19 -13
  123. package/clis/youtube/utils.test.js +17 -1
  124. package/clis/yuanbao/ask.js +1 -1
  125. package/clis/yuanbao/detail.js +1 -1
  126. package/clis/yuanbao/history.js +1 -1
  127. package/clis/yuanbao/new.js +1 -1
  128. package/clis/yuanbao/read.js +1 -1
  129. package/clis/yuanbao/send.js +1 -1
  130. package/clis/yuanbao/status.js +1 -1
  131. package/dist/src/browser/bridge.d.ts +4 -1
  132. package/dist/src/browser/bridge.js +3 -1
  133. package/dist/src/browser/cdp.d.ts +4 -1
  134. package/dist/src/browser/daemon-client.d.ts +9 -16
  135. package/dist/src/browser/daemon-client.js +8 -9
  136. package/dist/src/browser/daemon-client.test.js +10 -0
  137. package/dist/src/browser/network-cache.d.ts +5 -5
  138. package/dist/src/browser/network-cache.js +8 -8
  139. package/dist/src/browser/network-cache.test.js +4 -4
  140. package/dist/src/browser/page.d.ts +9 -7
  141. package/dist/src/browser/page.js +27 -16
  142. package/dist/src/browser/page.test.js +60 -30
  143. package/dist/src/build-manifest.js +1 -1
  144. package/dist/src/cli.js +91 -125
  145. package/dist/src/cli.test.js +293 -180
  146. package/dist/src/commanderAdapter.js +9 -0
  147. package/dist/src/discovery.js +1 -1
  148. package/dist/src/doctor.d.ts +0 -4
  149. package/dist/src/doctor.js +8 -72
  150. package/dist/src/doctor.test.js +26 -97
  151. package/dist/src/execution.d.ts +3 -0
  152. package/dist/src/execution.js +47 -23
  153. package/dist/src/execution.test.js +68 -45
  154. package/dist/src/external-clis.yaml +24 -0
  155. package/dist/src/help.d.ts +1 -0
  156. package/dist/src/help.js +36 -1
  157. package/dist/src/main.js +0 -29
  158. package/dist/src/manifest-types.d.ts +2 -4
  159. package/dist/src/observation/artifact.js +1 -1
  160. package/dist/src/observation/artifact.test.js +3 -3
  161. package/dist/src/observation/events.d.ts +1 -1
  162. package/dist/src/observation/manager.js +1 -1
  163. package/dist/src/observation/manager.test.js +3 -3
  164. package/dist/src/registry-api.d.ts +1 -1
  165. package/dist/src/registry.d.ts +3 -12
  166. package/dist/src/registry.js +6 -10
  167. package/dist/src/runtime.d.ts +10 -2
  168. package/dist/src/runtime.js +4 -1
  169. package/dist/src/serialization.d.ts +1 -1
  170. package/dist/src/serialization.js +1 -1
  171. package/dist/src/types.d.ts +0 -15
  172. package/package.json +1 -1
@@ -11,6 +11,7 @@ cli({
11
11
  domain: 'x.com',
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
14
+ siteSession: 'persistent',
14
15
  args: [
15
16
  { name: 'tweet-id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
16
17
  ],
@@ -51,12 +52,16 @@ cli({
51
52
  // Navigate to the tweet page for cookie context
52
53
  await page.goto(`https://x.com/i/status/${tweetId}`);
53
54
  await page.wait(3);
55
+ // Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
56
+ const cookies = await page.getCookies({ url: 'https://x.com' });
57
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
58
+ if (!ct0)
59
+ throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
54
60
  const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
55
61
  const result = await page.evaluate(`
56
62
  async () => {
57
63
  const tweetId = "${tweetId}";
58
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
59
- if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
64
+ const ct0 = ${JSON.stringify(ct0)};
60
65
 
61
66
  const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
62
67
  const headers = {
@@ -156,8 +161,6 @@ cli({
156
161
  }
157
162
  `);
158
163
  if (result?.error) {
159
- if (String(result.error).includes('No ct0 cookie'))
160
- throw new AuthRequiredError('x.com', result.error);
161
164
  throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
162
165
  }
163
166
  return result || [];
@@ -122,6 +122,7 @@ cli({
122
122
  domain: 'x.com',
123
123
  strategy: Strategy.COOKIE,
124
124
  browser: true,
125
+ siteSession: 'persistent',
125
126
  args: [
126
127
  { name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
127
128
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
@@ -140,11 +141,8 @@ cli({
140
141
  throw new ArgumentError(`Invalid --limit: ${JSON.stringify(kwargs.limit)}. Expected a positive integer.`);
141
142
  }
142
143
 
143
- await page.goto('https://x.com');
144
- await page.wait(3);
145
- const ct0 = await page.evaluate(`() => {
146
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
147
- }`);
144
+ const cookies = await page.getCookies({ url: 'https://x.com' });
145
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
148
146
  if (!ct0)
149
147
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
150
148
 
@@ -309,11 +309,13 @@ describe('twitter bookmark-folder command (registry)', () => {
309
309
  const page = {
310
310
  goto: vi.fn().mockResolvedValue(undefined),
311
311
  wait: vi.fn().mockResolvedValue(undefined),
312
+ getCookies: vi.fn().mockResolvedValue([]),
312
313
  evaluate: vi.fn().mockResolvedValue(null),
313
314
  };
314
315
  await expect(command.func(page, { 'folder-id': '12345', limit: 5 }))
315
316
  .rejects
316
317
  .toThrow(/Not logged into x.com/);
318
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
317
319
  });
318
320
 
319
321
  it('accepts an opaque safe folder-id and sends it in the GraphQL variables', async () => {
@@ -321,14 +323,15 @@ describe('twitter bookmark-folder command (registry)', () => {
321
323
  const page = {
322
324
  goto: vi.fn().mockResolvedValue(undefined),
323
325
  wait: vi.fn().mockResolvedValue(undefined),
326
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'ct0-token' }]),
324
327
  evaluate: vi.fn()
325
- .mockResolvedValueOnce('ct0-token')
326
328
  .mockResolvedValueOnce('queryX')
327
329
  .mockResolvedValueOnce({ data: { bookmark_timeline_v2: { timeline: { instructions: [] } } } }),
328
330
  };
329
331
  const result = await command.func(page, { 'folder-id': 'folder_AbC-123', limit: 5 });
330
332
  expect(result).toEqual([]);
331
- const fetchScript = page.evaluate.mock.calls[2][0];
333
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
334
+ const fetchScript = page.evaluate.mock.calls[1][0];
332
335
  expect(decodeURIComponent(fetchScript)).toContain('"bookmark_collection_id":"folder_AbC-123"');
333
336
  });
334
337
  });
@@ -77,14 +77,12 @@ cli({
77
77
  domain: 'x.com',
78
78
  strategy: Strategy.COOKIE,
79
79
  browser: true,
80
+ siteSession: 'persistent',
80
81
  args: [],
81
82
  columns: ['id', 'name', 'items', 'created_at'],
82
83
  func: async (page) => {
83
- await page.goto('https://x.com');
84
- await page.wait(3);
85
- const ct0 = await page.evaluate(`() => {
86
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
87
- }`);
84
+ const cookies = await page.getCookies({ url: 'https://x.com' });
85
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
88
86
  if (!ct0)
89
87
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
90
88
 
@@ -143,8 +143,10 @@ describe('twitter bookmark-folders command (registry)', () => {
143
143
  const page = {
144
144
  goto: vi.fn().mockResolvedValue(undefined),
145
145
  wait: vi.fn().mockResolvedValue(undefined),
146
- evaluate: vi.fn().mockResolvedValue(null), // null cookie → AuthRequired
146
+ getCookies: vi.fn().mockResolvedValue([]), // no ct0 cookie → AuthRequired
147
+ evaluate: vi.fn().mockResolvedValue(null),
147
148
  };
148
149
  await expect(command.func(page, {})).rejects.toThrow(/Not logged into x.com/);
150
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
149
151
  });
150
152
  });
@@ -105,6 +105,7 @@ cli({
105
105
  domain: 'x.com',
106
106
  strategy: Strategy.COOKIE,
107
107
  browser: true,
108
+ siteSession: 'persistent',
108
109
  args: [
109
110
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
110
111
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
@@ -112,11 +113,8 @@ cli({
112
113
  columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
113
114
  func: async (page, kwargs) => {
114
115
  const limit = kwargs.limit || 20;
115
- await page.goto('https://x.com');
116
- await page.wait(3);
117
- const ct0 = await page.evaluate(`() => {
118
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
119
- }`);
116
+ const cookies = await page.getCookies({ url: 'https://x.com' });
117
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
120
118
  if (!ct0)
121
119
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
122
120
  const queryId = await page.evaluate(`async () => {
@@ -15,6 +15,7 @@ cli({
15
15
  description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
16
16
  domain: 'x.com',
17
17
  strategy: Strategy.COOKIE,
18
+ siteSession: 'persistent',
18
19
  args: [
19
20
  { name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
20
21
  { name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
@@ -83,6 +83,7 @@ cli({
83
83
  domain: 'x.com',
84
84
  strategy: Strategy.UI,
85
85
  browser: true,
86
+ siteSession: 'persistent',
86
87
  args: [
87
88
  {
88
89
  name: 'user',
@@ -139,6 +139,7 @@ cli({
139
139
  domain: 'x.com',
140
140
  strategy: Strategy.COOKIE,
141
141
  browser: true,
142
+ siteSession: 'persistent',
142
143
  args: [
143
144
  {
144
145
  name: 'user',
@@ -157,12 +158,8 @@ cli({
157
158
  }
158
159
  let targetUser = normalizeScreenName(kwargs.user);
159
160
 
160
- await page.goto('https://x.com');
161
- await page.wait(3);
162
-
163
- const ct0 = await page.evaluate(`() => {
164
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
165
- }`);
161
+ const cookies = await page.getCookies({ url: 'https://x.com' });
162
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
166
163
  if (!ct0)
167
164
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
168
165
 
@@ -205,8 +205,8 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
205
205
  const page = {
206
206
  goto: vi.fn().mockResolvedValue(undefined),
207
207
  wait: vi.fn().mockResolvedValue(undefined),
208
+ getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
208
209
  evaluate: vi.fn(async (script) => {
209
- if (script.includes('document.cookie')) return ct0;
210
210
  if (script.includes('operationName')) return null;
211
211
  if (script.includes('/UserByScreenName')) return userLookup;
212
212
  if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
@@ -228,6 +228,7 @@ describe('twitter following command', () => {
228
228
  const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
229
229
 
230
230
  expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
231
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
231
232
  const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
232
233
  expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
233
234
  expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
@@ -142,6 +142,7 @@ cli({
142
142
  domain: 'x.com',
143
143
  strategy: Strategy.COOKIE,
144
144
  browser: true,
145
+ siteSession: 'persistent',
145
146
  args: [
146
147
  { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
147
148
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
@@ -151,11 +152,8 @@ cli({
151
152
  func: async (page, kwargs) => {
152
153
  const limit = kwargs.limit || 20;
153
154
  let username = (kwargs.username || '').replace(/^@/, '');
154
- await page.goto('https://x.com');
155
- await page.wait(3);
156
- const ct0 = await page.evaluate(`() => {
157
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
158
- }`);
155
+ const cookies = await page.getCookies({ url: 'https://x.com' });
156
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
159
157
  if (!ct0)
160
158
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
161
159
  // If no username provided, detect the logged-in user
@@ -84,11 +84,12 @@ cli({
84
84
  if (!username) {
85
85
  throw new CommandExecutionError('Username is required');
86
86
  }
87
+ // Strategy.UI does not get a domain URL pre-nav from the framework.
88
+ // This page context is load-bearing for pre-target GraphQL calls below.
87
89
  await page.goto('https://x.com');
88
90
  await page.wait(3);
89
- const ct0 = await page.evaluate(`() => {
90
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
91
- }`);
91
+ const cookies = await page.getCookies({ url: 'https://x.com' });
92
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
92
93
  if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
93
94
 
94
95
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './list-add.js';
4
4
 
@@ -12,4 +12,26 @@ describe('twitter list-add registration', () => {
12
12
  expect(listIdArg?.required).toBe(true);
13
13
  expect(listIdArg?.positional).toBe(true);
14
14
  });
15
+
16
+ it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
17
+ const cmd = getRegistry().get('twitter/list-add');
18
+ const page = {
19
+ goto: vi.fn().mockResolvedValue(undefined),
20
+ wait: vi.fn().mockResolvedValue(undefined),
21
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
22
+ evaluate: vi.fn()
23
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
24
+ .mockResolvedValueOnce('user-1')
25
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
26
+ .mockResolvedValueOnce({}),
27
+ };
28
+
29
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
30
+ .rejects
31
+ .toThrow(/List 123 not found/);
32
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
33
+ expect(page.goto).toHaveBeenCalledTimes(1);
34
+ expect(page.wait).toHaveBeenCalledWith(3);
35
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
36
+ });
15
37
  });
@@ -92,11 +92,12 @@ cli({
92
92
  }
93
93
  if (!username) throw new CommandExecutionError('Username is required');
94
94
 
95
+ // Strategy.UI does not get a domain URL pre-nav from the framework.
96
+ // This page context is load-bearing for pre-target GraphQL calls below.
95
97
  await page.goto('https://x.com');
96
98
  await page.wait(3);
97
- const ct0 = await page.evaluate(`() => {
98
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
99
- }`);
99
+ const cookies = await page.getCookies({ url: 'https://x.com' });
100
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
100
101
  if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
101
102
 
102
103
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './list-remove.js';
4
4
 
@@ -11,4 +11,26 @@ describe('twitter list-remove registration', () => {
11
11
  expect(listIdArg).toBeTruthy();
12
12
  expect(listIdArg?.required).toBe(true);
13
13
  });
14
+
15
+ it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
16
+ const cmd = getRegistry().get('twitter/list-remove');
17
+ const page = {
18
+ goto: vi.fn().mockResolvedValue(undefined),
19
+ wait: vi.fn().mockResolvedValue(undefined),
20
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
21
+ evaluate: vi.fn()
22
+ .mockResolvedValueOnce(null) // UserByScreenName queryId fallback
23
+ .mockResolvedValueOnce('user-1')
24
+ .mockResolvedValueOnce(null) // ListsManagement queryId fallback
25
+ .mockResolvedValueOnce({}),
26
+ };
27
+
28
+ await expect(cmd.func(page, { listId: '123', username: 'alice' }))
29
+ .rejects
30
+ .toThrow(/List 123 not found/);
31
+ expect(page.goto).toHaveBeenCalledWith('https://x.com');
32
+ expect(page.goto).toHaveBeenCalledTimes(1);
33
+ expect(page.wait).toHaveBeenCalledWith(3);
34
+ expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
35
+ });
14
36
  });
@@ -112,6 +112,7 @@ cli({
112
112
  domain: 'x.com',
113
113
  strategy: Strategy.COOKIE,
114
114
  browser: true,
115
+ siteSession: 'persistent',
115
116
  args: [
116
117
  { name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
117
118
  { name: 'limit', type: 'int', default: 50 },
@@ -124,11 +125,8 @@ cli({
124
125
  throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`);
125
126
  }
126
127
  const limit = kwargs.limit || 50;
127
- await page.goto('https://x.com');
128
- await page.wait(3);
129
- const ct0 = await page.evaluate(`() => {
130
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
131
- }`);
128
+ const cookies = await page.getCookies({ url: 'https://x.com' });
129
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
132
130
  if (!ct0)
133
131
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
134
132
  const queryId = await page.evaluate(`async () => {
@@ -92,17 +92,15 @@ export const command = cli({
92
92
  domain: 'x.com',
93
93
  strategy: Strategy.COOKIE,
94
94
  browser: true,
95
+ siteSession: 'persistent',
95
96
  args: [
96
97
  { name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
97
98
  ],
98
99
  columns: ['id', 'name', 'members', 'followers', 'mode'],
99
100
  func: async (page, kwargs) => {
100
101
  const limit = kwargs.limit || 50;
101
- await page.goto('https://x.com');
102
- await page.wait(3);
103
- const ct0 = await page.evaluate(`() => {
104
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
105
- }`);
102
+ const cookies = await page.getCookies({ url: 'https://x.com' });
103
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
106
104
  if (!ct0)
107
105
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
108
106
  const queryId = await page.evaluate(`async () => {
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.INTERCEPT,
10
10
  browser: true,
11
+ siteSession: 'persistent',
11
12
  args: [
12
13
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
13
14
  ],
@@ -11,6 +11,7 @@ cli({
11
11
  domain: 'x.com',
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
14
+ siteSession: 'persistent',
14
15
  args: [
15
16
  { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
16
17
  ],
@@ -32,12 +33,16 @@ cli({
32
33
  // Navigate directly to the user's profile page (gives us cookie context)
33
34
  await page.goto(`https://x.com/${username}`);
34
35
  await page.wait(3);
36
+ // Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
37
+ const cookies = await page.getCookies({ url: 'https://x.com' });
38
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
39
+ if (!ct0)
40
+ throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
35
41
  const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
36
42
  const result = await page.evaluate(`
37
43
  async () => {
38
44
  const screenName = "${username}";
39
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
40
- if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
45
+ const ct0 = ${JSON.stringify(ct0)};
41
46
 
42
47
  const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
43
48
  const headers = {
@@ -96,8 +101,6 @@ cli({
96
101
  }
97
102
  `);
98
103
  if (result?.error) {
99
- if (String(result.error).includes('No ct0 cookie'))
100
- throw new AuthRequiredError('x.com', result.error);
101
104
  throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
102
105
  }
103
106
  return result || [];
@@ -228,6 +228,7 @@ cli({
228
228
  domain: 'x.com',
229
229
  strategy: Strategy.INTERCEPT, // Use intercept strategy
230
230
  browser: true,
231
+ siteSession: 'persistent',
231
232
  args: [
232
233
  { name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
233
234
  { name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
@@ -100,6 +100,7 @@ cli({
100
100
  domain: 'x.com',
101
101
  strategy: Strategy.COOKIE,
102
102
  browser: true,
103
+ siteSession: 'persistent',
103
104
  args: [
104
105
  { name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
105
106
  { name: 'limit', type: 'int', default: 50 },
@@ -111,13 +112,10 @@ cli({
111
112
  const urlMatch = tweetId.match(/\/status\/(\d+)/);
112
113
  if (urlMatch)
113
114
  tweetId = urlMatch[1];
114
- // Navigate to x.com for cookie context
115
- await page.goto('https://x.com');
116
- await page.wait(3);
117
- // Extract CSRF token the only thing we need from the browser
118
- const ct0 = await page.evaluate(`() => {
119
- return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
120
- }`);
115
+ // Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
116
+ // Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
117
+ const cookies = await page.getCookies({ url: 'https://x.com' });
118
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
121
119
  if (!ct0)
122
120
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
123
121
  // Build auth headers in TypeScript
@@ -141,6 +141,7 @@ cli({
141
141
  domain: 'x.com',
142
142
  strategy: Strategy.COOKIE,
143
143
  browser: true,
144
+ siteSession: 'persistent',
144
145
  args: [
145
146
  {
146
147
  name: 'type',
@@ -156,13 +157,10 @@ cli({
156
157
  const limit = kwargs.limit || 20;
157
158
  const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
158
159
  const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[timelineType];
159
- // Navigate to x.com for cookie context
160
- await page.goto('https://x.com');
161
- await page.wait(3);
162
- // Extract CSRF token
163
- const ct0 = await page.evaluate(`() => {
164
- return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
165
- }`);
160
+ // Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
161
+ // Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
162
+ const cookies = await page.getCookies({ url: 'https://x.com' });
163
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
166
164
  if (!ct0)
167
165
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
168
166
  // Dynamically resolve queryId for the selected endpoint
@@ -17,6 +17,7 @@ cli({
17
17
  domain: 'x.com',
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
+ siteSession: 'persistent',
20
21
  args: [
21
22
  { name: 'limit', type: 'int', default: 20, help: 'Number of trends to show' },
22
23
  ],
@@ -26,10 +27,9 @@ cli({
26
27
  // Navigate to trending page
27
28
  await page.goto('https://x.com/explore/tabs/trending');
28
29
  await page.wait(3);
29
- // Verify login via CSRF cookie
30
- const ct0 = await page.evaluate(`(() => {
31
- return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
32
- })()`);
30
+ // Verify login via CSRF cookie (read directly from cookie store via CDP)
31
+ const cookies = await page.getCookies({ url: 'https://x.com' });
32
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
33
33
  if (!ct0)
34
34
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
35
35
  await page.wait(2);
@@ -149,6 +149,7 @@ cli({
149
149
  domain: 'x.com',
150
150
  strategy: Strategy.COOKIE,
151
151
  browser: true,
152
+ siteSession: 'persistent',
152
153
  args: [
153
154
  { name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
154
155
  { name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
@@ -160,12 +161,8 @@ cli({
160
161
  const username = String(kwargs.username || '').replace(/^@/, '').trim();
161
162
  if (!username) throw new CommandExecutionError('username is required');
162
163
 
163
- await page.goto('https://x.com');
164
- await page.wait(3);
165
-
166
- const ct0 = await page.evaluate(`() => {
167
- return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
168
- }`);
164
+ const cookies = await page.getCookies({ url: 'https://x.com' });
165
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
169
166
  if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
170
167
 
171
168
  const userTweetsQueryId = await resolveTwitterQueryId(page, 'UserTweets', USER_TWEETS_QUERY_ID);
@@ -2,7 +2,7 @@
2
2
  * YouTube like — like a video via InnerTube like API (requires SAPISIDHASH auth).
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
- import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
5
+ import { parseVideoId, prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN } from './utils.js';
6
6
  import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
7
 
8
8
  cli({
@@ -19,6 +19,10 @@ cli({
19
19
  func: async (page, kwargs) => {
20
20
  const videoId = parseVideoId(String(kwargs.url));
21
21
  await prepareYoutubeApiPage(page);
22
+ // Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
23
+ const sapisid = await readYoutubeSapisid(page);
24
+ if (!sapisid)
25
+ throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
22
26
  const result = await page.evaluate(`
23
27
  (async () => {
24
28
  ${SAPISID_HASH_FN}
@@ -28,7 +32,7 @@ cli({
28
32
  const context = cfg.INNERTUBE_CONTEXT;
29
33
  if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
30
34
 
31
- const authHash = await getSapisidHash('https://www.youtube.com');
35
+ const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
32
36
  if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
33
37
 
34
38
  const resp = await fetch('/youtubei/v1/like/like?key=' + apiKey + '&prettyPrint=false', {
@@ -2,7 +2,7 @@
2
2
  * YouTube subscribe — subscribe to a channel via InnerTube subscription API.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
- import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
5
+ import { prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
6
6
  import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
7
 
8
8
  cli({
@@ -19,6 +19,10 @@ cli({
19
19
  func: async (page, kwargs) => {
20
20
  const channelInput = String(kwargs.channel);
21
21
  await prepareYoutubeApiPage(page);
22
+ // Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
23
+ const sapisid = await readYoutubeSapisid(page);
24
+ if (!sapisid)
25
+ throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
22
26
  const result = await page.evaluate(`
23
27
  (async () => {
24
28
  ${SAPISID_HASH_FN}
@@ -28,7 +32,7 @@ cli({
28
32
  const context = cfg.INNERTUBE_CONTEXT;
29
33
  if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
30
34
 
31
- const authHash = await getSapisidHash('https://www.youtube.com');
35
+ const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
32
36
  if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
33
37
 
34
38
  ${RESOLVE_CHANNEL_HANDLE_FN}
@@ -2,7 +2,7 @@
2
2
  * YouTube unlike — remove like from a video via InnerTube like API.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
- import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
5
+ import { parseVideoId, prepareYoutubeApiPage, readYoutubeSapisid, SAPISID_HASH_FN } from './utils.js';
6
6
  import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
7
 
8
8
  cli({
@@ -19,6 +19,10 @@ cli({
19
19
  func: async (page, kwargs) => {
20
20
  const videoId = parseVideoId(String(kwargs.url));
21
21
  await prepareYoutubeApiPage(page);
22
+ // Read SAPISID directly from the cookie store via CDP — zero document.cookie round-trip
23
+ const sapisid = await readYoutubeSapisid(page);
24
+ if (!sapisid)
25
+ throw new AuthRequiredError('www.youtube.com', 'Not logged in (SAPISID cookie missing)');
22
26
  const result = await page.evaluate(`
23
27
  (async () => {
24
28
  ${SAPISID_HASH_FN}
@@ -28,7 +32,7 @@ cli({
28
32
  const context = cfg.INNERTUBE_CONTEXT;
29
33
  if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
30
34
 
31
- const authHash = await getSapisidHash('https://www.youtube.com');
35
+ const authHash = await getSapisidHash(${JSON.stringify(sapisid)}, 'https://www.youtube.com');
32
36
  if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
33
37
 
34
38
  const resp = await fetch('/youtubei/v1/like/removelike?key=' + apiKey + '&prettyPrint=false', {