@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
@@ -80,18 +80,22 @@ cli({
80
80
  },
81
81
  };
82
82
 
83
+ // Read csrftoken directly from the cookie store via CDP — zero page.evaluate round-trip
84
+ const cookies = await page.getCookies({ url: 'https://maimai.cn' });
85
+ const csrftokenFromCookie = cookies.find((c) => c.name === 'csrftoken')?.value || '';
86
+
83
87
  // Execute the search API call in browser context
84
- const data = await page.evaluate(async (body) => {
85
- // Get CSRF token from cookie or meta tag
86
- let csrftoken = document.cookie.split('; ')
87
- .find(row => row.startsWith('csrftoken='))
88
- ?.split('=')[1] || '';
88
+ const data = await page.evaluate(`async () => {
89
+ // Prefer cookie-derived csrftoken (hoisted from CDP); fall back to meta tag
90
+ let csrftoken = ${JSON.stringify(csrftokenFromCookie)};
89
91
 
90
92
  if (!csrftoken) {
91
93
  const meta = document.querySelector('meta[name="csrf-token"]');
92
94
  if (meta) csrftoken = meta.getAttribute('content') || '';
93
95
  }
94
96
 
97
+ const body = ${JSON.stringify(requestBody)};
98
+
95
99
  const res = await fetch('https://maimai.cn/api/ent/discover/search?channel=www&data_version=3.0&version=1.0.0', {
96
100
  method: 'POST',
97
101
  headers: {
@@ -117,7 +121,7 @@ cli({
117
121
  }
118
122
 
119
123
  return result;
120
- }, requestBody);
124
+ }`);
121
125
 
122
126
  // Extract talent list from response
123
127
  const talentList = data.data?.list || data.data?.talent_list || data.list || data.talent_list || [];
@@ -17,7 +17,7 @@ cli({
17
17
  await requireNotebooklmSession(page);
18
18
  const state = await getNotebooklmPageState(page);
19
19
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm current', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
20
+ throw new EmptyResultError('opencli notebooklm current', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
21
21
  }
22
22
  const current = await readCurrentNotebooklm(page);
23
23
  if (!current) {
@@ -18,7 +18,7 @@ cli({
18
18
  await requireNotebooklmSession(page);
19
19
  const state = await getNotebooklmPageState(page);
20
20
  if (state.kind !== 'notebook') {
21
- throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
21
+ throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
22
22
  }
23
23
  const rpcRow = await getNotebooklmDetailViaRpc(page).catch(() => null);
24
24
  if (rpcRow)
@@ -17,7 +17,7 @@ cli({
17
17
  await requireNotebooklmSession(page);
18
18
  const state = await getNotebooklmPageState(page);
19
19
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
20
+ throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
21
21
  }
22
22
  const rows = await listNotebooklmHistoryViaRpc(page);
23
23
  return rows;
@@ -18,7 +18,7 @@ cli({
18
18
  await requireNotebooklmSession(page);
19
19
  const state = await getNotebooklmPageState(page);
20
20
  if (state.kind !== 'notebook') {
21
- throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
21
+ throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
22
22
  }
23
23
  const rows = await listNotebooklmNotesFromPage(page);
24
24
  if (rows.length > 0)
@@ -31,7 +31,7 @@ cli({
31
31
  await requireNotebooklmSession(page);
32
32
  const state = await getNotebooklmPageState(page);
33
33
  if (state.kind !== 'notebook') {
34
- throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
34
+ throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
35
35
  }
36
36
  const query = typeof kwargs.note === 'string' ? kwargs.note : String(kwargs.note ?? '');
37
37
  const visible = await readNotebooklmVisibleNoteFromPage(page);
@@ -7,7 +7,7 @@ cli({
7
7
  name: 'open',
8
8
  access: 'read',
9
9
  aliases: ['select'],
10
- description: 'Open one NotebookLM notebook in the automation workspace by id or URL',
10
+ description: 'Open one NotebookLM notebook in the adapter session by id or URL',
11
11
  domain: NOTEBOOKLM_DOMAIN,
12
12
  strategy: Strategy.COOKIE,
13
13
  browser: true,
@@ -28,7 +28,7 @@ cli({
28
28
  await requireNotebooklmSession(page);
29
29
  const state = await getNotebooklmPageState(page);
30
30
  if (state.kind !== 'notebook') {
31
- throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the automation workspace`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.');
31
+ throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the adapter session`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.');
32
32
  }
33
33
  if (state.notebookId !== notebookId) {
34
34
  console.warn(`[notebooklm open] expected notebook "${notebookId}" but page reports "${state.notebookId}"; continuing`);
@@ -38,7 +38,7 @@ describe('notebooklm open', () => {
38
38
  source: 'current-page',
39
39
  });
40
40
  });
41
- it('opens a notebook by id in the automation workspace', async () => {
41
+ it('opens a notebook by id in the adapter session', async () => {
42
42
  const page = {
43
43
  goto: vi.fn(async () => { }),
44
44
  wait: vi.fn(async () => { }),
@@ -24,7 +24,7 @@ cli({
24
24
  await requireNotebooklmSession(page);
25
25
  const state = await getNotebooklmPageState(page);
26
26
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
27
+ throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
28
28
  }
29
29
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
30
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -24,7 +24,7 @@ cli({
24
24
  await requireNotebooklmSession(page);
25
25
  const state = await getNotebooklmPageState(page);
26
26
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
27
+ throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
28
28
  }
29
29
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
30
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -24,7 +24,7 @@ cli({
24
24
  await requireNotebooklmSession(page);
25
25
  const state = await getNotebooklmPageState(page);
26
26
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
27
+ throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
28
28
  }
29
29
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
30
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -17,7 +17,7 @@ cli({
17
17
  await requireNotebooklmSession(page);
18
18
  const state = await getNotebooklmPageState(page);
19
19
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
20
+ throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
21
21
  }
22
22
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
23
23
  if (rpcRows.length > 0)
@@ -17,7 +17,7 @@ cli({
17
17
  await requireNotebooklmSession(page);
18
18
  const state = await getNotebooklmPageState(page);
19
19
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
20
+ throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
21
21
  }
22
22
  const domSummary = await readNotebooklmSummaryFromPage(page);
23
23
  if (domSummary)
@@ -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}`;
package/clis/qwen/ask.js CHANGED
@@ -24,7 +24,7 @@ cli({
24
24
  domain: QIANWEN_DOMAIN,
25
25
  strategy: Strategy.COOKIE,
26
26
  browser: true,
27
- browserSession: { reuse: 'site' },
27
+ siteSession: 'persistent',
28
28
  navigateBefore: false,
29
29
  defaultFormat: 'plain',
30
30
  args: [
@@ -18,7 +18,7 @@ cli({
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
20
  navigateBefore: false,
21
- browserSession: { reuse: 'site' },
21
+ siteSession: 'persistent',
22
22
  args: [
23
23
  { name: 'id', positional: true, required: true, help: 'Session ID (32-char hex) or full https://www.qianwen.com/chat/<id> URL' },
24
24
  { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
@@ -24,7 +24,7 @@ cli({
24
24
  domain: QIANWEN_DOMAIN,
25
25
  strategy: Strategy.COOKIE,
26
26
  browser: true,
27
- browserSession: { reuse: 'site' },
27
+ siteSession: 'persistent',
28
28
  navigateBefore: false,
29
29
  args: [
30
30
  { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show (default 20, max 100)' },
@@ -99,7 +99,7 @@ cli({
99
99
  domain: QIANWEN_DOMAIN,
100
100
  strategy: Strategy.COOKIE,
101
101
  browser: true,
102
- browserSession: { reuse: 'site' },
102
+ siteSession: 'persistent',
103
103
  navigateBefore: false,
104
104
  defaultFormat: 'plain',
105
105
  args: [
package/clis/qwen/new.js CHANGED
@@ -9,7 +9,7 @@ cli({
9
9
  domain: QIANWEN_DOMAIN,
10
10
  strategy: Strategy.COOKIE,
11
11
  browser: true,
12
- browserSession: { reuse: 'site' },
12
+ siteSession: 'persistent',
13
13
  navigateBefore: false,
14
14
  args: [],
15
15
  columns: ['Status'],
package/clis/qwen/read.js CHANGED
@@ -16,7 +16,7 @@ cli({
16
16
  domain: QIANWEN_DOMAIN,
17
17
  strategy: Strategy.COOKIE,
18
18
  browser: true,
19
- browserSession: { reuse: 'site' },
19
+ siteSession: 'persistent',
20
20
  navigateBefore: false,
21
21
  args: [
22
22
  { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
package/clis/qwen/send.js CHANGED
@@ -20,7 +20,7 @@ cli({
20
20
  domain: QIANWEN_DOMAIN,
21
21
  strategy: Strategy.COOKIE,
22
22
  browser: true,
23
- browserSession: { reuse: 'site' },
23
+ siteSession: 'persistent',
24
24
  navigateBefore: false,
25
25
  args: [
26
26
  { name: 'prompt', required: true, positional: true, help: 'Prompt to send to Qianwen' },
@@ -9,7 +9,7 @@ cli({
9
9
  domain: QIANWEN_DOMAIN,
10
10
  strategy: Strategy.COOKIE,
11
11
  browser: true,
12
- browserSession: { reuse: 'site' },
12
+ siteSession: 'persistent',
13
13
  navigateBefore: false,
14
14
  args: [],
15
15
  columns: ['Status', 'Login', 'Model', 'SessionId', 'Url'],
@@ -8,6 +8,7 @@ cli({
8
8
  domain: 'reddit.com',
9
9
  strategy: Strategy.COOKIE,
10
10
  browser: true,
11
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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 persistent site session', () => {
7
+ expect(command?.browser).toBe(true);
8
+ expect(command?.siteSession).toBe('persistent');
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
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
+ siteSession: 'persistent',
10
11
  args: [
11
12
  { name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
12
13
  ],