@jackwener/opencli 1.7.8 → 1.7.10

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 (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Z-Library adapter utilities.
3
+ */
4
+
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
6
+
7
+ const ZLIBRARY_DOMAIN = 'z-library.im';
8
+ const ZLIBRARY_ORIGIN = `https://${ZLIBRARY_DOMAIN}`;
9
+ const ZLIBRARY_ALLOWED_HOSTS = new Set([
10
+ ZLIBRARY_DOMAIN,
11
+ `www.${ZLIBRARY_DOMAIN}`,
12
+ ]);
13
+
14
+ export function normalizeZlibraryBookUrl(input) {
15
+ const raw = String(input || '').trim();
16
+ let url;
17
+ try {
18
+ url = new URL(raw);
19
+ } catch {
20
+ throw new ArgumentError('Z-Library book URL must be a valid http(s) URL', `Example: ${ZLIBRARY_ORIGIN}/book/...`);
21
+ }
22
+ if (!['http:', 'https:'].includes(url.protocol) || !ZLIBRARY_ALLOWED_HOSTS.has(url.hostname)) {
23
+ throw new ArgumentError(
24
+ `Unsupported Z-Library URL host: ${url.hostname}`,
25
+ `Pass a book URL under ${ZLIBRARY_DOMAIN}, for example ${ZLIBRARY_ORIGIN}/book/...`,
26
+ );
27
+ }
28
+ return url.toString();
29
+ }
30
+
31
+ /**
32
+ * Build a Z-Library search URL.
33
+ * Z-Library uses /s/<url-encoded-query> for search.
34
+ */
35
+ export function buildSearchUrl(query) {
36
+ const normalized = String(query || '').trim();
37
+ if (!normalized) {
38
+ throw new ArgumentError('zlibrary search query cannot be empty');
39
+ }
40
+ return `${ZLIBRARY_ORIGIN}/s/${encodeURIComponent(normalized)}`;
41
+ }
42
+
43
+ /**
44
+ * Extract book title from page context.
45
+ * Tries z-bookcard shadow DOM first, then falls back to page title.
46
+ */
47
+ export async function extractBookTitle(page) {
48
+ const title = await page.evaluate(`
49
+ (() => {
50
+ const card = document.querySelector('z-bookcard');
51
+ if (card && card.shadowRoot) {
52
+ const el = card.shadowRoot.querySelector('[class*="title"], h1, a');
53
+ if (el) return el.textContent.trim().split('\\n')[0].trim();
54
+ }
55
+ return document.title.replace(/\\s*[-|].*$/, '').trim();
56
+ })()
57
+ `);
58
+ return String(title || '').trim();
59
+ }
60
+
61
+ /**
62
+ * Extract available download formats from book page.
63
+ * Clicks the three-dot menu to reveal download options.
64
+ * NOTE: Z-Library download links redirect through /dl/<hash> URLs.
65
+ * These require browser cookies and may not produce direct file downloads
66
+ * in OpenCLI's browser automation. For actual file downloading,
67
+ * consider using Playwright's download event handling instead.
68
+ */
69
+ export async function extractFormats(page) {
70
+ // Click three-dot menu if present
71
+ await page.evaluate(`
72
+ (() => {
73
+ const btn = document.querySelector(
74
+ 'button[aria-label*="more" i], [class*="dots" i], [class*="more" i]'
75
+ );
76
+ if (btn) btn.click();
77
+ })()
78
+ `);
79
+ // Wait for menu
80
+ await page.wait({ time: 3 });
81
+
82
+ const formats = await page.evaluate(`
83
+ JSON.stringify((() => {
84
+ const res = { pdf: '', epub: '' };
85
+ document.querySelectorAll('a[href]').forEach(a => {
86
+ const h = a.href || '';
87
+ const t = (a.textContent || '').toUpperCase();
88
+ if (h.includes('/dl/') && t.includes('PDF')) res.pdf = h;
89
+ if (h.includes('/dl/') && t.includes('EPUB')) res.epub = h;
90
+ });
91
+ return res;
92
+ })())
93
+ `);
94
+ return JSON.parse(formats);
95
+ }
96
+
97
+ /**
98
+ * Extract book cards from search results page.
99
+ *
100
+ * Z-Library renders search results as <z-bookcard> custom elements.
101
+ * Each card contains the book title, author, and a link to the book page.
102
+ * The link is inside a shadow DOM that can be queried with card.shadowRoot.
103
+ *
104
+ * This approach was validated on 2026-04-28 against z-library.im.
105
+ */
106
+ export async function extractSearchResults(page, limit) {
107
+ const raw = await page.evaluate(`
108
+ JSON.stringify(
109
+ Array.from(document.querySelectorAll('z-bookcard'))
110
+ .slice(0, ${limit})
111
+ .map((card, index) => {
112
+ const text = card.textContent.trim();
113
+ const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
114
+ const title = lines[0] || '';
115
+ const author = lines[1] || '';
116
+ let url = '';
117
+ try {
118
+ if (card.shadowRoot) {
119
+ const link = card.shadowRoot.querySelector('a');
120
+ if (link) url = link.href || '';
121
+ }
122
+ } catch(e) {}
123
+ return { rank: index + 1, title, author, url };
124
+ })
125
+ .filter(item => item.url && item.title)
126
+ )
127
+ `);
128
+
129
+ try {
130
+ return JSON.parse(raw);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ export { ZLIBRARY_DOMAIN, ZLIBRARY_ORIGIN };
@@ -0,0 +1,11 @@
1
+ import type { InternalCliCommand } from './registry.js';
2
+ /**
3
+ * Resolve the editable source file path for an adapter.
4
+ *
5
+ * Priority:
6
+ * 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
7
+ * 2. cmd._modulePath (set for manifest lazy-loaded JS)
8
+ *
9
+ * Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
10
+ */
11
+ export declare function resolveAdapterSourcePath(cmd: InternalCliCommand): string | undefined;
@@ -0,0 +1,24 @@
1
+ import * as fs from 'node:fs';
2
+ /**
3
+ * Resolve the editable source file path for an adapter.
4
+ *
5
+ * Priority:
6
+ * 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
7
+ * 2. cmd._modulePath (set for manifest lazy-loaded JS)
8
+ *
9
+ * Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
10
+ */
11
+ export function resolveAdapterSourcePath(cmd) {
12
+ const candidates = [];
13
+ if (cmd.source && !cmd.source.startsWith('manifest:')) {
14
+ candidates.push(cmd.source);
15
+ }
16
+ if (cmd._modulePath) {
17
+ candidates.push(cmd._modulePath);
18
+ }
19
+ for (const candidate of candidates) {
20
+ if (fs.existsSync(candidate))
21
+ return candidate;
22
+ }
23
+ return candidates[0];
24
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveAdapterSourcePath } from './adapter-source.js';
3
+ function makeCmd(overrides = {}) {
4
+ return {
5
+ site: 'test-site',
6
+ name: 'test-cmd',
7
+ description: 'test',
8
+ args: [],
9
+ ...overrides,
10
+ };
11
+ }
12
+ describe('resolveAdapterSourcePath', () => {
13
+ it('returns source when it is a real file path (not manifest:)', () => {
14
+ const cmd = makeCmd({ source: '/home/user/.opencli/clis/arxiv/search.js' });
15
+ expect(resolveAdapterSourcePath(cmd)).toBe('/home/user/.opencli/clis/arxiv/search.js');
16
+ });
17
+ it('skips manifest: pseudo-paths and falls back to _modulePath', () => {
18
+ const cmd = makeCmd({ source: 'manifest:arxiv/search', _modulePath: '/pkg/clis/arxiv/search.js' });
19
+ expect(resolveAdapterSourcePath(cmd)).toBe('/pkg/clis/arxiv/search.js');
20
+ });
21
+ it('returns undefined when only manifest: pseudo-path and no _modulePath', () => {
22
+ const cmd = makeCmd({ source: 'manifest:test/cmd' });
23
+ expect(resolveAdapterSourcePath(cmd)).toBeUndefined();
24
+ });
25
+ it('returns _modulePath when it is the only path available', () => {
26
+ const cmd = makeCmd({ _modulePath: '/project/clis/site/cmd.js' });
27
+ expect(resolveAdapterSourcePath(cmd)).toBe('/project/clis/site/cmd.js');
28
+ });
29
+ });
@@ -8,7 +8,7 @@
8
8
  * Subclasses implement the transport-specific methods: goto, evaluate,
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
- import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
11
+ import type { BrowserCookie, FetchJsonOptions, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
12
12
  import { type ResolveOptions, type TargetMatchLevel } from './target-resolver.js';
13
13
  export interface ResolveSuccess {
14
14
  matches_n: number;
@@ -26,6 +26,7 @@ export declare abstract class BasePage implements IPage {
26
26
  abstract goto(url: string, options?: {
27
27
  waitUntil?: 'load' | 'none';
28
28
  settleMs?: number;
29
+ allowBoundNavigation?: boolean;
29
30
  }): Promise<void>;
30
31
  abstract evaluate(js: string): Promise<unknown>;
31
32
  /**
@@ -37,6 +38,7 @@ export declare abstract class BasePage implements IPage {
37
38
  * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
38
39
  */
39
40
  evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown>;
41
+ fetchJson(url: string, opts?: FetchJsonOptions): Promise<unknown>;
40
42
  abstract getCookies(opts?: {
41
43
  domain?: string;
42
44
  url?: string;
@@ -12,6 +12,8 @@ import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
12
  import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
13
  import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
14
14
  import { TargetError } from './target-errors.js';
15
+ import { CliError } from '../errors.js';
16
+ import { formatSnapshot } from '../snapshotFormatter.js';
15
17
  /**
16
18
  * Execute `resolveTargetJs` once, throw structured `TargetError` on failure.
17
19
  * Single helper so click/typeText/scrollTo share one resolution pathway,
@@ -30,7 +32,10 @@ async function runResolve(page, ref, opts = {}) {
30
32
  }
31
33
  return { matches_n: resolution.matches_n, match_level: resolution.match_level };
32
34
  }
33
- import { formatSnapshot } from '../snapshotFormatter.js';
35
+ function previewText(text) {
36
+ const preview = (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
37
+ return preview ? `Response preview: ${preview}` : undefined;
38
+ }
34
39
  export class BasePage {
35
40
  _lastUrl = null;
36
41
  /** Cached previous snapshot hashes for incremental diff marking */
@@ -54,6 +59,76 @@ export class BasePage {
54
59
  .join('\n');
55
60
  return this.evaluate(`${declarations}\n${js}`);
56
61
  }
62
+ async fetchJson(url, opts = {}) {
63
+ const request = {
64
+ url,
65
+ method: opts.method ?? 'GET',
66
+ headers: opts.headers ?? {},
67
+ body: opts.body,
68
+ hasBody: opts.body !== undefined,
69
+ timeoutMs: opts.timeoutMs ?? 15_000,
70
+ };
71
+ const result = await this.evaluateWithArgs(`
72
+ (async () => {
73
+ const ctrl = new AbortController();
74
+ const timer = setTimeout(() => ctrl.abort(), request.timeoutMs);
75
+ try {
76
+ const headers = { Accept: 'application/json', ...request.headers };
77
+ const init = {
78
+ method: request.method,
79
+ credentials: 'include',
80
+ headers,
81
+ signal: ctrl.signal,
82
+ };
83
+ if (request.hasBody) {
84
+ if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) {
85
+ headers['Content-Type'] = 'application/json';
86
+ }
87
+ init.body = JSON.stringify(request.body);
88
+ }
89
+ const resp = await fetch(request.url, init);
90
+ const text = await resp.text();
91
+ return {
92
+ ok: resp.ok,
93
+ status: resp.status,
94
+ statusText: resp.statusText,
95
+ url: resp.url,
96
+ contentType: resp.headers.get('content-type') || '',
97
+ text,
98
+ };
99
+ } catch (error) {
100
+ return {
101
+ ok: false,
102
+ status: 0,
103
+ statusText: '',
104
+ url: request.url,
105
+ contentType: '',
106
+ text: '',
107
+ error: error instanceof Error ? error.message : String(error),
108
+ };
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ })()
113
+ `, { request });
114
+ const targetUrl = result.url || url;
115
+ if (result.error) {
116
+ throw new CliError('FETCH_ERROR', `Browser fetch failed for ${targetUrl}: ${result.error}`, 'Check that the page is reachable and the current browser profile has access.');
117
+ }
118
+ if (!result.ok) {
119
+ throw new CliError('FETCH_ERROR', `HTTP ${result.status ?? 0}${result.statusText ? ` ${result.statusText}` : ''} from ${targetUrl}`, previewText(result.text));
120
+ }
121
+ const text = result.text ?? '';
122
+ if (!text.trim())
123
+ return null;
124
+ try {
125
+ return JSON.parse(text);
126
+ }
127
+ catch {
128
+ const contentType = result.contentType ? ` (${result.contentType})` : '';
129
+ throw new CliError('FETCH_ERROR', `Expected JSON from ${targetUrl}${contentType}`, previewText(text));
130
+ }
131
+ }
57
132
  // ── Shared DOM helper implementations ──
58
133
  async click(ref, opts = {}) {
59
134
  // Phase 1: Resolve target with fingerprint verification
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CliError } from '../errors.js';
3
+ import { BasePage } from './base-page.js';
4
+ class TestPage extends BasePage {
5
+ result;
6
+ args;
7
+ async goto() { }
8
+ async evaluate() { return null; }
9
+ async evaluateWithArgs(_js, args) {
10
+ this.args = args;
11
+ return this.result;
12
+ }
13
+ async getCookies() { return []; }
14
+ async screenshot() { return ''; }
15
+ async tabs() { return []; }
16
+ async selectTab() { }
17
+ }
18
+ describe('BasePage.fetchJson', () => {
19
+ it('passes a narrow browser-context JSON request and parses the response in Node', async () => {
20
+ const page = new TestPage();
21
+ page.result = {
22
+ ok: true,
23
+ status: 200,
24
+ url: 'https://api.example.com/items',
25
+ contentType: 'application/json',
26
+ text: '{"items":[1]}',
27
+ };
28
+ await expect(page.fetchJson('https://api.example.com/items', {
29
+ method: 'POST',
30
+ headers: { 'X-Test': '1' },
31
+ body: { q: 'opencli' },
32
+ timeoutMs: 1234,
33
+ })).resolves.toEqual({ items: [1] });
34
+ expect(page.args).toEqual({
35
+ request: {
36
+ url: 'https://api.example.com/items',
37
+ method: 'POST',
38
+ headers: { 'X-Test': '1' },
39
+ body: { q: 'opencli' },
40
+ hasBody: true,
41
+ timeoutMs: 1234,
42
+ },
43
+ });
44
+ });
45
+ it('throws a CliError for non-JSON responses', async () => {
46
+ const page = new TestPage();
47
+ page.result = {
48
+ ok: true,
49
+ status: 200,
50
+ url: 'https://api.example.com/items',
51
+ contentType: 'text/html',
52
+ text: '<html>blocked</html>',
53
+ };
54
+ const err = await page.fetchJson('https://api.example.com/items').catch((error) => error);
55
+ expect(err).toBeInstanceOf(CliError);
56
+ expect(err.code).toBe('FETCH_ERROR');
57
+ expect(err.message).toContain('Expected JSON');
58
+ expect(err.hint).toContain('blocked');
59
+ });
60
+ it('throws a CliError for browser fetch transport errors', async () => {
61
+ const page = new TestPage();
62
+ page.result = {
63
+ ok: false,
64
+ status: 0,
65
+ url: 'https://api.example.com/items',
66
+ text: '',
67
+ error: 'The operation was aborted.',
68
+ };
69
+ await expect(page.fetchJson('https://api.example.com/items')).rejects.toMatchObject({
70
+ code: 'FETCH_ERROR',
71
+ message: expect.stringContaining('The operation was aborted.'),
72
+ });
73
+ });
74
+ });
@@ -16,11 +16,10 @@ export declare class BrowserBridge implements IBrowserFactory {
16
16
  timeout?: number;
17
17
  workspace?: string;
18
18
  idleTimeout?: number;
19
+ contextId?: string;
19
20
  }): Promise<IPage>;
20
21
  close(): Promise<void>;
21
22
  private _ensureDaemon;
22
- /** Poll until daemon is fully stopped (port released). */
23
- private _waitForDaemonStop;
24
23
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
25
24
  private _pollUntilReady;
26
25
  }
@@ -1,15 +1,13 @@
1
1
  /**
2
2
  * Browser session manager — auto-spawns daemon and provides IPage.
3
3
  */
4
- import { spawn } from 'node:child_process';
5
- import { fileURLToPath } from 'node:url';
6
- import * as path from 'node:path';
7
- import * as fs from 'node:fs';
8
4
  import { Page } from './page.js';
9
5
  import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
6
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
7
  import { BrowserConnectError } from '../errors.js';
12
8
  import { PKG_VERSION } from '../version.js';
9
+ import { resolveProfileContextId } from './profile.js';
10
+ import { resolveDaemonLaunchSpec, spawnDaemonProcess, waitForDaemonStop } from './daemon-lifecycle.js';
13
11
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
14
12
  /**
15
13
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -32,8 +30,9 @@ export class BrowserBridge {
32
30
  throw new Error('Session is closed');
33
31
  this._state = 'connecting';
34
32
  try {
35
- await this._ensureDaemon(opts.timeout);
36
- this._page = new Page(opts.workspace, opts.idleTimeout);
33
+ const contextId = opts.contextId ?? resolveProfileContextId();
34
+ await this._ensureDaemon(opts.timeout, contextId);
35
+ this._page = new Page(opts.workspace, opts.idleTimeout, contextId);
37
36
  this._state = 'connected';
38
37
  return this._page;
39
38
  }
@@ -51,13 +50,21 @@ export class BrowserBridge {
51
50
  this._page = null;
52
51
  this._state = 'closed';
53
52
  }
54
- async _ensureDaemon(timeoutSeconds) {
53
+ async _ensureDaemon(timeoutSeconds, contextId) {
55
54
  const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
56
55
  const timeoutMs = effectiveSeconds * 1000;
57
- const health = await getDaemonHealth();
56
+ const health = await getDaemonHealth({ contextId });
58
57
  // Fast path: everything ready
59
58
  if (health.state === 'ready')
60
59
  return;
60
+ if (health.state === 'profile-required') {
61
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
62
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
63
+ }
64
+ if (health.state === 'profile-disconnected') {
65
+ const label = contextId ?? health.status.contextId ?? 'unknown';
66
+ throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
67
+ }
61
68
  // Daemon running but no extension
62
69
  if (health.state === 'no-extension') {
63
70
  // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
@@ -72,7 +79,7 @@ export class BrowserBridge {
72
79
  process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
73
80
  }
74
81
  const shutdownAccepted = await requestDaemonShutdown();
75
- const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
82
+ const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
76
83
  if (!portReleased) {
77
84
  // Stale daemon replacement failed — don't blindly spawn on an occupied port
78
85
  throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
@@ -86,8 +93,17 @@ export class BrowserBridge {
86
93
  process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
87
94
  process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
88
95
  }
89
- if (await this._pollUntilReady(timeoutMs))
96
+ if (await this._pollUntilReady(timeoutMs, contextId))
90
97
  return;
98
+ const finalHealth = await getDaemonHealth({ contextId });
99
+ if (finalHealth.state === 'profile-required') {
100
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
101
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
102
+ }
103
+ if (finalHealth.state === 'profile-disconnected') {
104
+ const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
105
+ throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
106
+ }
91
107
  throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
92
108
  'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
93
109
  'If not installed:\n' +
@@ -96,28 +112,22 @@ export class BrowserBridge {
96
112
  }
97
113
  }
98
114
  // No daemon — spawn one
99
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
100
- const parentDir = path.resolve(__dirname, '..');
101
- const daemonTs = path.join(parentDir, 'daemon.ts');
102
- const daemonJs = path.join(parentDir, 'daemon.js');
103
- const isTs = fs.existsSync(daemonTs);
104
- const daemonPath = isTs ? daemonTs : daemonJs;
105
115
  if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
106
116
  process.stderr.write('⏳ Starting daemon...\n');
107
117
  }
108
- const spawnArgs = isTs
109
- ? [process.execPath, '--import', 'tsx/esm', daemonPath]
110
- : [process.execPath, daemonPath];
111
- this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
112
- detached: true,
113
- stdio: 'ignore',
114
- env: { ...process.env },
115
- });
116
- this._daemonProc.unref();
118
+ this._daemonProc = spawnDaemonProcess();
117
119
  // Wait for daemon + extension
118
- if (await this._pollUntilReady(timeoutMs))
120
+ if (await this._pollUntilReady(timeoutMs, contextId))
119
121
  return;
120
- const finalHealth = await getDaemonHealth();
122
+ const finalHealth = await getDaemonHealth({ contextId });
123
+ if (finalHealth.state === 'profile-required') {
124
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
125
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
126
+ }
127
+ if (finalHealth.state === 'profile-disconnected') {
128
+ const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
129
+ throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
130
+ }
121
131
  if (finalHealth.state === 'no-extension') {
122
132
  throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
123
133
  'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
@@ -125,25 +135,14 @@ export class BrowserBridge {
125
135
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
126
136
  ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
127
137
  }
128
- throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
129
- }
130
- /** Poll until daemon is fully stopped (port released). */
131
- async _waitForDaemonStop(timeoutMs) {
132
- const deadline = Date.now() + timeoutMs;
133
- while (Date.now() < deadline) {
134
- await new Promise(resolve => setTimeout(resolve, 200));
135
- const h = await getDaemonHealth();
136
- if (h.state === 'stopped')
137
- return true;
138
- }
139
- return false;
138
+ throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${resolveDaemonLaunchSpec().scriptPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
140
139
  }
141
140
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
142
- async _pollUntilReady(timeoutMs) {
141
+ async _pollUntilReady(timeoutMs, contextId) {
143
142
  const deadline = Date.now() + timeoutMs;
144
143
  while (Date.now() < deadline) {
145
144
  await new Promise(resolve => setTimeout(resolve, 200));
146
- const h = await getDaemonHealth();
145
+ const h = await getDaemonHealth({ contextId });
147
146
  if (h.state === 'ready')
148
147
  return true;
149
148
  }
@@ -25,6 +25,7 @@ export declare class CDPBridge implements IBrowserFactory {
25
25
  timeout?: number;
26
26
  workspace?: string;
27
27
  cdpEndpoint?: string;
28
+ contextId?: string;
28
29
  }): Promise<IPage>;
29
30
  close(): Promise<void>;
30
31
  send(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
@@ -229,7 +229,7 @@ class CDPPage extends BasePage {
229
229
  const idx = this._networkEntries.push({
230
230
  url: p.request.url,
231
231
  method: p.request.method,
232
- timestamp: p.timestamp,
232
+ timestamp: Date.now(),
233
233
  }) - 1;
234
234
  this._pendingRequests.set(p.requestId, idx);
235
235
  }
@@ -290,7 +290,7 @@ class CDPPage extends BasePage {
290
290
  this.bridge.on('Runtime.consoleAPICalled', (params) => {
291
291
  const p = params;
292
292
  const text = (p.args || []).map(a => a.value !== undefined ? String(a.value) : (a.description || '')).join(' ');
293
- this._consoleMessages.push({ type: p.type, text, timestamp: p.timestamp });
293
+ this._consoleMessages.push({ type: p.type, text, timestamp: Date.now() });
294
294
  if (this._consoleMessages.length > 500)
295
295
  this._consoleMessages.shift();
296
296
  });
@@ -298,7 +298,7 @@ class CDPPage extends BasePage {
298
298
  this.bridge.on('Runtime.exceptionThrown', (params) => {
299
299
  const p = params;
300
300
  const desc = p.exceptionDetails?.exception?.description || p.exceptionDetails?.text || 'Unknown exception';
301
- this._consoleMessages.push({ type: 'error', text: desc, timestamp: p.timestamp });
301
+ this._consoleMessages.push({ type: 'error', text: desc, timestamp: Date.now() });
302
302
  if (this._consoleMessages.length > 500)
303
303
  this._consoleMessages.shift();
304
304
  });