@jackwener/opencli 1.7.15 → 1.7.16

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 (94) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +161 -31
  4. package/clis/chatgpt/ask.js +2 -1
  5. package/clis/chatgpt/detail.js +6 -1
  6. package/clis/chatgpt/read.js +2 -1
  7. package/clis/chatgpt/send.js +2 -1
  8. package/clis/chatgpt/utils.js +54 -12
  9. package/clis/chatgpt/utils.test.js +36 -1
  10. package/clis/claude/ask.js +22 -7
  11. package/clis/claude/detail.js +9 -2
  12. package/clis/claude/new.js +8 -2
  13. package/clis/claude/read.js +2 -1
  14. package/clis/claude/send.js +8 -3
  15. package/clis/claude/utils.js +27 -4
  16. package/clis/deepseek/ask.js +21 -8
  17. package/clis/deepseek/detail.js +9 -1
  18. package/clis/deepseek/new.js +13 -2
  19. package/clis/deepseek/read.js +2 -1
  20. package/clis/deepseek/utils.js +8 -1
  21. package/clis/linkedin/search.js +8 -11
  22. package/clis/maimai/search-talents.js +10 -6
  23. package/clis/openreview/author.js +58 -0
  24. package/clis/openreview/openreview.test.js +83 -1
  25. package/clis/openreview/utils.js +14 -0
  26. package/clis/reddit/comment.js +1 -0
  27. package/clis/reddit/frontpage.js +1 -0
  28. package/clis/reddit/popular.js +1 -0
  29. package/clis/reddit/read.js +2 -0
  30. package/clis/reddit/read.test.js +4 -0
  31. package/clis/reddit/save.js +1 -0
  32. package/clis/reddit/saved.js +1 -0
  33. package/clis/reddit/search.js +1 -0
  34. package/clis/reddit/subreddit.js +1 -0
  35. package/clis/reddit/subscribe.js +1 -0
  36. package/clis/reddit/upvote.js +1 -0
  37. package/clis/reddit/upvoted.js +1 -0
  38. package/clis/reddit/user-comments.js +1 -0
  39. package/clis/reddit/user-posts.js +1 -0
  40. package/clis/reddit/user.js +1 -0
  41. package/clis/twitter/article.js +7 -4
  42. package/clis/twitter/bookmark-folder.js +3 -5
  43. package/clis/twitter/bookmark-folder.test.js +5 -2
  44. package/clis/twitter/bookmark-folders.js +3 -5
  45. package/clis/twitter/bookmark-folders.test.js +3 -1
  46. package/clis/twitter/bookmarks.js +3 -5
  47. package/clis/twitter/download.js +1 -0
  48. package/clis/twitter/followers.js +1 -0
  49. package/clis/twitter/following.js +3 -6
  50. package/clis/twitter/following.test.js +2 -1
  51. package/clis/twitter/likes.js +3 -5
  52. package/clis/twitter/list-add.js +4 -3
  53. package/clis/twitter/list-add.test.js +23 -1
  54. package/clis/twitter/list-remove.js +4 -3
  55. package/clis/twitter/list-remove.test.js +23 -1
  56. package/clis/twitter/list-tweets.js +3 -5
  57. package/clis/twitter/lists.js +3 -5
  58. package/clis/twitter/notifications.js +1 -0
  59. package/clis/twitter/profile.js +7 -4
  60. package/clis/twitter/search.js +1 -0
  61. package/clis/twitter/thread.js +5 -7
  62. package/clis/twitter/timeline.js +5 -7
  63. package/clis/twitter/trending.js +4 -4
  64. package/clis/twitter/tweets.js +3 -6
  65. package/clis/youtube/like.js +6 -2
  66. package/clis/youtube/subscribe.js +6 -2
  67. package/clis/youtube/unlike.js +6 -2
  68. package/clis/youtube/unsubscribe.js +6 -2
  69. package/clis/youtube/utils.js +19 -13
  70. package/clis/youtube/utils.test.js +17 -1
  71. package/dist/src/browser/bridge.d.ts +1 -0
  72. package/dist/src/browser/bridge.js +1 -1
  73. package/dist/src/browser/cdp.d.ts +1 -0
  74. package/dist/src/browser/daemon-client.d.ts +2 -2
  75. package/dist/src/browser/daemon-client.js +6 -3
  76. package/dist/src/browser/daemon-client.test.js +10 -0
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +5 -1
  79. package/dist/src/cli.js +70 -2
  80. package/dist/src/cli.test.js +139 -7
  81. package/dist/src/commanderAdapter.js +7 -0
  82. package/dist/src/doctor.js +2 -2
  83. package/dist/src/doctor.test.js +4 -4
  84. package/dist/src/execution.d.ts +2 -0
  85. package/dist/src/execution.js +31 -6
  86. package/dist/src/execution.test.js +43 -16
  87. package/dist/src/external-clis.yaml +24 -0
  88. package/dist/src/help.d.ts +1 -0
  89. package/dist/src/help.js +29 -0
  90. package/dist/src/main.js +4 -14
  91. package/dist/src/runtime.d.ts +3 -0
  92. package/dist/src/runtime.js +1 -0
  93. package/dist/src/types.d.ts +1 -1
  94. package/package.json +1 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * OpenReview submissions by author profile id (newest first).
3
+ *
4
+ * Pairs with `openreview paper <id>` and `openreview reviews <id>` for the
5
+ * full read-side workflow: list every submission an author put on
6
+ * OpenReview, then drill into a specific paper or its review thread.
7
+ *
8
+ * Uses the public v2 endpoint `/notes?content.authorids=~<profile-id>`,
9
+ * which returns the same note shape as `paper`, sorted by `cdate:desc`.
10
+ */
11
+ import { cli, Strategy } from '@jackwener/opencli/registry';
12
+ import { EmptyResultError } from '@jackwener/opencli/errors';
13
+ import {
14
+ noteToRow,
15
+ openreviewFetch,
16
+ requireBoundedInt,
17
+ requireProfileId,
18
+ } from './utils.js';
19
+
20
+ cli({
21
+ site: 'openreview',
22
+ name: 'author',
23
+ access: 'read',
24
+ description: 'List OpenReview submissions by an author profile id (newest first)',
25
+ domain: 'openreview.net',
26
+ strategy: Strategy.PUBLIC,
27
+ browser: false,
28
+ args: [
29
+ { name: 'profile', positional: true, required: true, help: 'OpenReview profile id (e.g. "~Yoshua_Bengio1"). Find it on the author profile URL on openreview.net.' },
30
+ { name: 'limit', type: 'int', default: 50, help: 'Max submissions (1-1000)' },
31
+ ],
32
+ columns: ['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url'],
33
+ func: async (args) => {
34
+ const profile = requireProfileId(args.profile);
35
+ const limit = requireBoundedInt(args.limit, 50, 1000);
36
+ const path = `/notes?content.authorids=${encodeURIComponent(profile)}&limit=${limit}&sort=cdate:desc`;
37
+ const json = await openreviewFetch(path, `openreview author ${profile}`);
38
+ const notes = Array.isArray(json?.notes) ? json.notes : [];
39
+ if (!notes.length) {
40
+ throw new EmptyResultError(
41
+ 'openreview author',
42
+ `No OpenReview submissions found for profile "${profile}". Confirm the id format (~First_LastN) and that the profile has public submissions.`,
43
+ );
44
+ }
45
+ return notes.slice(0, limit).map((note, i) => {
46
+ const row = noteToRow(note);
47
+ return {
48
+ rank: i + 1,
49
+ id: row.id,
50
+ title: row.title,
51
+ authors: row.authors,
52
+ venue: row.venue,
53
+ pdate: row.pdate,
54
+ url: row.url,
55
+ };
56
+ });
57
+ },
58
+ });
@@ -8,11 +8,13 @@ import {
8
8
  requireBoundedInt,
9
9
  requireForumId,
10
10
  requireNonNegativeInt,
11
+ requireProfileId,
11
12
  } from './utils.js';
12
13
  import './search.js';
13
14
  import './venue.js';
14
15
  import './paper.js';
15
16
  import './reviews.js';
17
+ import './author.js';
16
18
 
17
19
  const SAMPLE_NOTE = {
18
20
  id: 'abc123XYZ_',
@@ -37,21 +39,24 @@ afterEach(() => {
37
39
  });
38
40
 
39
41
  describe('openreview adapter', () => {
40
- it('registers all four commands with the expected columns', () => {
42
+ it('registers all five commands with the expected columns', () => {
41
43
  const search = getRegistry().get('openreview/search');
42
44
  const venue = getRegistry().get('openreview/venue');
43
45
  const paper = getRegistry().get('openreview/paper');
44
46
  const reviews = getRegistry().get('openreview/reviews');
47
+ const author = getRegistry().get('openreview/author');
45
48
 
46
49
  expect(search).toBeDefined();
47
50
  expect(venue).toBeDefined();
48
51
  expect(paper).toBeDefined();
49
52
  expect(reviews).toBeDefined();
53
+ expect(author).toBeDefined();
50
54
 
51
55
  expect(search.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
52
56
  expect(venue.columns).toEqual(['rank', 'id', 'title', 'authors', 'keywords', 'primary_area', 'pdate', 'pdf', 'url']);
53
57
  expect(paper.columns).toEqual(['id', 'title', 'authors', 'keywords', 'venue', 'venueid', 'primary_area', 'abstract', 'pdate', 'pdf', 'url']);
54
58
  expect(reviews.columns).toEqual(['type', 'author', 'rating', 'confidence', 'text']);
59
+ expect(author.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
55
60
  });
56
61
 
57
62
  it('noteToRow extracts every wrapped v2 field, joins lists, and builds absolute URLs', () => {
@@ -109,6 +114,30 @@ describe('openreview adapter', () => {
109
114
  expect(() => requireForumId('short')).toThrow('not a valid forum id');
110
115
  });
111
116
 
117
+ it('requireProfileId accepts canonical profile ids and rejects malformed input', () => {
118
+ expect(requireProfileId('~Yoshua_Bengio1')).toBe('~Yoshua_Bengio1');
119
+ expect(requireProfileId('~Bo_Liu17')).toBe('~Bo_Liu17');
120
+ expect(requireProfileId('~Geoffrey_Everest_Hinton1')).toBe('~Geoffrey_Everest_Hinton1');
121
+ expect(requireProfileId('~Anne-Christin_Hauschild1')).toBe('~Anne-Christin_Hauschild1');
122
+ expect(requireProfileId('~S.Aruna_Deepthi1')).toBe('~S.Aruna_Deepthi1');
123
+ expect(requireProfileId('~Andrzej_Czyżewski1')).toBe('~Andrzej_Czyżewski1');
124
+ expect(requireProfileId('~August_Bøgh_Rønberg1')).toBe('~August_Bøgh_Rønberg1');
125
+ expect(requireProfileId('~Wagner_Meira_Jr.1')).toBe('~Wagner_Meira_Jr.1');
126
+ expect(() => requireProfileId('')).toThrow('required');
127
+ expect(() => requireProfileId(' ')).toThrow('required');
128
+ // Missing leading tilde.
129
+ expect(() => requireProfileId('Bo_Liu17')).toThrow('not a valid profile id');
130
+ // Missing trailing disambiguator number.
131
+ expect(() => requireProfileId('~Bo_Liu')).toThrow('not a valid profile id');
132
+ // Spaces / non-letter characters break the underscore-joined name.
133
+ expect(() => requireProfileId('~Bo Liu1')).toThrow('not a valid profile id');
134
+ // dblp-style PID must not silently fall through.
135
+ expect(() => requireProfileId('56/953')).toThrow('not a valid profile id');
136
+ expect(() => requireProfileId('~Bo_Liu1?evil=1')).toThrow('not a valid profile id');
137
+ expect(() => requireProfileId('~Bo/Liu1')).toThrow('not a valid profile id');
138
+ expect(() => requireProfileId('~123')).toThrow('not a valid profile id');
139
+ });
140
+
112
141
  it('formatDate handles ms-since-epoch and rejects invalid input', () => {
113
142
  expect(formatDate(1727524853394)).toBe('2024-09-28');
114
143
  expect(formatDate(0)).toBe('');
@@ -342,4 +371,57 @@ describe('openreview adapter', () => {
342
371
  expect(rows[1].text.length).toBe(500);
343
372
  expect(rows[1].text.endsWith('...')).toBe(true);
344
373
  });
374
+
375
+ it('author rejects invalid profile ids before calling the network', async () => {
376
+ const fetchMock = vi.fn();
377
+ vi.stubGlobal('fetch', fetchMock);
378
+ const author = getRegistry().get('openreview/author');
379
+ await expect(author.func({ profile: 'Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
380
+ await expect(author.func({ profile: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
381
+ expect(fetchMock).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it('author throws EmptyResult when the profile has no submissions', async () => {
385
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [] }), { status: 200 })));
386
+ const author = getRegistry().get('openreview/author');
387
+ await expect(author.func({ profile: '~No_Submissions1', limit: 5 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' });
388
+ });
389
+
390
+ it('author wraps non-200 responses as CommandExecutionError', async () => {
391
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
392
+ const author = getRegistry().get('openreview/author');
393
+ await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
394
+ });
395
+
396
+ it('author wraps fetch network errors as CommandExecutionError', async () => {
397
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNRESET')));
398
+ const author = getRegistry().get('openreview/author');
399
+ await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({
400
+ code: 'COMMAND_EXEC',
401
+ message: expect.stringContaining('Network failure'),
402
+ });
403
+ });
404
+
405
+ it('author hits /notes?content.authorids and returns rank-ordered rows', async () => {
406
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [SAMPLE_NOTE, SAMPLE_NOTE] }), { status: 200 }));
407
+ vi.stubGlobal('fetch', fetchMock);
408
+ const author = getRegistry().get('openreview/author');
409
+ const rows = await author.func({ profile: '~Bo_Liu17', limit: 50 });
410
+ expect(rows).toHaveLength(2);
411
+ expect(rows[0]).toEqual({
412
+ rank: 1,
413
+ id: 'abc123XYZ_',
414
+ title: 'Test Paper Title with spaces',
415
+ authors: 'Alice Smith, Bob Jones',
416
+ venue: 'ICLR 2024 oral',
417
+ pdate: '2024-09-28',
418
+ url: 'https://openreview.net/forum?id=abc123XYZ_',
419
+ });
420
+ expect(rows[1].rank).toBe(2);
421
+ // Confirm the request shape: canonical authorids filter + cdate sort.
422
+ const url = fetchMock.mock.calls[0][0];
423
+ expect(url).toContain('content.authorids=');
424
+ expect(url).toContain(encodeURIComponent('~Bo_Liu17'));
425
+ expect(url).toContain('sort=cdate:desc');
426
+ });
345
427
  });
@@ -55,6 +55,20 @@ export function requireForumId(value, label = 'id') {
55
55
  return id;
56
56
  }
57
57
 
58
+ /** OpenReview profile ids are `~...N` slugs and may include dots, hyphens, and Unicode letters. */
59
+ const PROFILE_ID_PATTERN = /^~(?=.*\p{L})[\p{L}\p{M}0-9._-]+\d+$/u;
60
+
61
+ export function requireProfileId(value, label = 'profile') {
62
+ const id = String(value ?? '').trim();
63
+ if (!id) {
64
+ throw new ArgumentError(`openreview ${label} is required`);
65
+ }
66
+ if (!PROFILE_ID_PATTERN.test(id)) {
67
+ throw new ArgumentError(`openreview ${label} "${value}" is not a valid profile id (expected "~First_Last1" or similar; find it on the author's openreview.net profile URL)`);
68
+ }
69
+ return id;
70
+ }
71
+
58
72
  /** Wrap fetch + json with typed errors so failures never look like empty results. */
59
73
  export async function openreviewFetch(path, label) {
60
74
  const url = `${OPENREVIEW_API}${path}`;
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
13
14
  { name: 'text', type: 'string', required: true, positional: true, help: 'Comment text' },
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'limit', type: 'int', default: 15 },
12
13
  ],
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'limit', type: 'int', default: 20 },
12
13
  ],
@@ -15,6 +15,8 @@ cli({
15
15
  description: 'Read a Reddit post and its comments',
16
16
  domain: 'reddit.com',
17
17
  strategy: Strategy.COOKIE,
18
+ browser: true,
19
+ browserSession: { reuse: 'site' },
18
20
  args: [
19
21
  { name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
20
22
  { name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
@@ -3,6 +3,10 @@ import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './read.js';
4
4
  describe('reddit read adapter', () => {
5
5
  const command = getRegistry().get('reddit/read');
6
+ it('opts into the Reddit site browser session lease', () => {
7
+ expect(command?.browser).toBe(true);
8
+ expect(command?.browserSession).toEqual({ reuse: 'site' });
9
+ });
6
10
  it('returns threaded rows from the browser-evaluated payload', async () => {
7
11
  const page = {
8
12
  goto: vi.fn().mockResolvedValue(undefined),
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
13
14
  { name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' },
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'limit', type: 'int', default: 15 },
13
14
  ],
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'query', type: 'string', required: true, positional: true, help: 'Reddit search query' },
12
13
  {
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix; e.g. `python`)' },
12
13
  {
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'subreddit', type: 'string', required: true, positional: true, help: 'Subreddit name (e.g. python)' },
13
14
  { name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
13
14
  { name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' },
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ browserSession: { reuse: 'site' },
11
12
  args: [
12
13
  { name: 'limit', type: 'int', default: 15 },
13
14
  ],
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
12
13
  { name: 'limit', type: 'int', default: 15 },
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
12
13
  { name: 'limit', type: 'int', default: 15 },
@@ -7,6 +7,7 @@ cli({
7
7
  domain: 'reddit.com',
8
8
  strategy: Strategy.COOKIE,
9
9
  browser: true,
10
+ browserSession: { reuse: 'site' },
10
11
  args: [
11
12
  { name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
12
13
  ],
@@ -11,6 +11,7 @@ cli({
11
11
  domain: 'x.com',
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
14
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
+ browserSession: { reuse: 'site' },
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
  });