@jackwener/opencli 1.7.7 → 1.7.9

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 (280) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +782 -55
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/amazon/discussion.js +37 -6
  6. package/clis/amazon/discussion.test.js +147 -32
  7. package/clis/apple-podcasts/commands.test.js +4 -4
  8. package/clis/apple-podcasts/episodes.js +1 -1
  9. package/clis/apple-podcasts/search.js +1 -1
  10. package/clis/apple-podcasts/top.js +1 -1
  11. package/clis/arxiv/paper.js +1 -1
  12. package/clis/arxiv/search.js +1 -1
  13. package/clis/band/mentions.js +3 -3
  14. package/clis/bbc/news.js +1 -1
  15. package/clis/bilibili/subtitle.js +2 -2
  16. package/clis/bloomberg/businessweek.js +1 -1
  17. package/clis/bloomberg/economics.js +1 -1
  18. package/clis/bloomberg/industries.js +1 -1
  19. package/clis/bloomberg/main.js +1 -1
  20. package/clis/bloomberg/markets.js +1 -1
  21. package/clis/bloomberg/opinions.js +1 -1
  22. package/clis/bloomberg/politics.js +1 -1
  23. package/clis/bloomberg/tech.js +1 -1
  24. package/clis/boss/search.js +49 -8
  25. package/clis/boss/search.test.js +78 -0
  26. package/clis/boss/send.js +3 -3
  27. package/clis/chatgpt/image.js +37 -8
  28. package/clis/chatgpt/image.test.js +92 -0
  29. package/clis/chatgpt/utils.js +39 -6
  30. package/clis/chatgpt/utils.test.js +63 -0
  31. package/clis/chatgpt-app/ask.js +4 -20
  32. package/clis/chatgpt-app/ax.js +135 -2
  33. package/clis/chatgpt-app/ax.test.js +35 -0
  34. package/clis/chatgpt-app/model.js +1 -1
  35. package/clis/chatgpt-app/new.js +1 -1
  36. package/clis/chatgpt-app/read.js +1 -1
  37. package/clis/chatgpt-app/send.js +3 -22
  38. package/clis/chatgpt-app/status.js +1 -1
  39. package/clis/chatwise/ask.js +2 -2
  40. package/clis/chatwise/model.js +2 -2
  41. package/clis/chatwise/send.js +2 -2
  42. package/clis/claude/ask.js +128 -0
  43. package/clis/claude/ask.test.js +338 -0
  44. package/clis/claude/commands.test.js +118 -0
  45. package/clis/claude/detail.js +29 -0
  46. package/clis/claude/history.js +31 -0
  47. package/clis/claude/new.js +21 -0
  48. package/clis/claude/read.js +24 -0
  49. package/clis/claude/send.js +41 -0
  50. package/clis/claude/status.js +24 -0
  51. package/clis/claude/utils.js +440 -0
  52. package/clis/claude/utils.test.js +148 -0
  53. package/clis/codex/ask.js +2 -2
  54. package/clis/codex/send.js +2 -2
  55. package/clis/ctrip/search.js +1 -1
  56. package/clis/ctrip/search.test.js +4 -4
  57. package/clis/cursor/ask.js +2 -2
  58. package/clis/cursor/composer.js +2 -2
  59. package/clis/cursor/send.js +2 -2
  60. package/clis/deepseek/ask.js +49 -10
  61. package/clis/deepseek/ask.test.js +150 -3
  62. package/clis/deepseek/utils.js +60 -22
  63. package/clis/deepseek/utils.test.js +124 -5
  64. package/clis/doubao/utils.js +53 -11
  65. package/clis/doubao/utils.test.js +22 -2
  66. package/clis/eastmoney/announcement.js +1 -1
  67. package/clis/eastmoney/convertible.js +1 -1
  68. package/clis/eastmoney/etf.js +1 -1
  69. package/clis/eastmoney/holders.js +1 -1
  70. package/clis/eastmoney/index-board.js +1 -1
  71. package/clis/eastmoney/kline.js +1 -1
  72. package/clis/eastmoney/kuaixun.js +1 -1
  73. package/clis/eastmoney/longhu.js +1 -1
  74. package/clis/eastmoney/money-flow.js +1 -1
  75. package/clis/eastmoney/northbound.js +1 -1
  76. package/clis/eastmoney/quote.js +1 -1
  77. package/clis/eastmoney/rank.js +1 -1
  78. package/clis/eastmoney/sectors.js +1 -1
  79. package/clis/facebook/marketplace-inbox.js +83 -0
  80. package/clis/facebook/marketplace-listings.js +83 -0
  81. package/clis/facebook/marketplace.test.js +91 -0
  82. package/clis/google/news.js +1 -1
  83. package/clis/google/suggest.js +1 -1
  84. package/clis/google/trends.js +1 -1
  85. package/clis/google-scholar/cite.js +74 -0
  86. package/clis/google-scholar/cite.test.js +47 -0
  87. package/clis/google-scholar/profile.js +92 -0
  88. package/clis/google-scholar/profile.test.js +49 -0
  89. package/clis/google-scholar/search.js +1 -1
  90. package/clis/google-scholar/search.test.js +15 -0
  91. package/clis/hf/top.js +1 -1
  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/powerchina/search.js +250 -0
  115. package/clis/powerchina/search.test.js +67 -0
  116. package/clis/producthunt/posts.js +1 -1
  117. package/clis/producthunt/today.js +1 -1
  118. package/clis/sinablog/search.js +1 -1
  119. package/clis/sinafinance/news.js +1 -1
  120. package/clis/sinafinance/stock.js +6 -3
  121. package/clis/sinafinance/stock.test.js +59 -0
  122. package/clis/spotify/spotify.js +6 -6
  123. package/clis/substack/search.js +1 -1
  124. package/clis/toutiao/articles.js +80 -0
  125. package/clis/toutiao/articles.test.js +30 -0
  126. package/clis/twitter/followers.js +2 -2
  127. package/clis/twitter/following.js +224 -73
  128. package/clis/twitter/following.test.js +277 -0
  129. package/clis/twitter/post.js +184 -47
  130. package/clis/twitter/post.test.js +114 -34
  131. package/clis/uiverse/_shared.js +63 -4
  132. package/clis/uiverse/_shared.test.js +7 -0
  133. package/clis/uiverse/code.js +1 -0
  134. package/clis/uiverse/navigation.test.js +12 -0
  135. package/clis/uiverse/preview.js +1 -0
  136. package/clis/web/read.js +319 -81
  137. package/clis/web/read.test.js +221 -5
  138. package/clis/weibo/favorites.js +169 -0
  139. package/clis/weibo/favorites.test.js +114 -0
  140. package/clis/weibo/publish.js +282 -0
  141. package/clis/weibo/publish.test.js +183 -0
  142. package/clis/weixin/create-draft.js +225 -0
  143. package/clis/weixin/drafts.js +65 -0
  144. package/clis/weixin/drafts.test.js +65 -0
  145. package/clis/weread/ranking.js +1 -1
  146. package/clis/weread/search-regression.test.js +8 -8
  147. package/clis/weread/search.js +1 -1
  148. package/clis/wikipedia/random.js +1 -1
  149. package/clis/wikipedia/search.js +1 -1
  150. package/clis/wikipedia/summary.js +1 -1
  151. package/clis/wikipedia/trending.js +1 -1
  152. package/clis/xianyu/chat.js +3 -3
  153. package/clis/xianyu/item.js +2 -2
  154. package/clis/xianyu/item.test.js +3 -3
  155. package/clis/xiaohongshu/search.js +17 -2
  156. package/clis/xiaohongshu/search.test.js +37 -1
  157. package/clis/xiaoyuzhou/download.js +1 -1
  158. package/clis/xiaoyuzhou/download.test.js +3 -3
  159. package/clis/xiaoyuzhou/episode.js +1 -1
  160. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  161. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  162. package/clis/xiaoyuzhou/podcast.js +1 -1
  163. package/clis/xiaoyuzhou/transcript.js +1 -1
  164. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  165. package/clis/yollomi/models.js +1 -1
  166. package/clis/youtube/channel.js +24 -1
  167. package/clis/youtube/channel.test.js +59 -0
  168. package/clis/zhihu/answer.js +21 -162
  169. package/clis/zhihu/answer.test.js +26 -53
  170. package/clis/zhihu/collection.js +197 -0
  171. package/clis/zhihu/collection.test.js +290 -0
  172. package/clis/zhihu/collections.js +127 -0
  173. package/clis/zhihu/collections.test.js +182 -0
  174. package/clis/zhihu/comment.js +24 -305
  175. package/clis/zhihu/comment.test.js +31 -35
  176. package/clis/zhihu/favorite.js +44 -182
  177. package/clis/zhihu/favorite.test.js +30 -167
  178. package/clis/zhihu/follow.js +25 -56
  179. package/clis/zhihu/follow.test.js +20 -23
  180. package/clis/zhihu/like.js +22 -67
  181. package/clis/zhihu/like.test.js +19 -42
  182. package/clis/zhihu/search.js +3 -2
  183. package/clis/zhihu/write-shared.js +8 -1
  184. package/clis/zhihu/write-shared.test.js +1 -0
  185. package/clis/zlibrary/commands.test.js +75 -0
  186. package/clis/zlibrary/info.js +47 -0
  187. package/clis/zlibrary/search.js +46 -0
  188. package/clis/zlibrary/utils.js +136 -0
  189. package/dist/src/adapter-source.d.ts +11 -0
  190. package/dist/src/adapter-source.js +24 -0
  191. package/dist/src/adapter-source.test.js +29 -0
  192. package/dist/src/browser/base-page.d.ts +3 -1
  193. package/dist/src/browser/base-page.js +76 -1
  194. package/dist/src/browser/base-page.test.d.ts +1 -0
  195. package/dist/src/browser/base-page.test.js +74 -0
  196. package/dist/src/browser/bridge.d.ts +1 -0
  197. package/dist/src/browser/bridge.js +36 -9
  198. package/dist/src/browser/cdp.d.ts +1 -0
  199. package/dist/src/browser/cdp.js +3 -3
  200. package/dist/src/browser/daemon-client.d.ts +38 -4
  201. package/dist/src/browser/daemon-client.js +24 -7
  202. package/dist/src/browser/daemon-client.test.js +49 -0
  203. package/dist/src/browser/errors.js +3 -0
  204. package/dist/src/browser/errors.test.js +3 -0
  205. package/dist/src/browser/network-cache.d.ts +1 -0
  206. package/dist/src/browser/page.d.ts +3 -1
  207. package/dist/src/browser/page.js +10 -2
  208. package/dist/src/browser/profile.d.ts +14 -0
  209. package/dist/src/browser/profile.js +85 -0
  210. package/dist/src/build-manifest.d.ts +2 -0
  211. package/dist/src/build-manifest.js +13 -3
  212. package/dist/src/build-manifest.test.js +20 -2
  213. package/dist/src/cli.d.ts +6 -0
  214. package/dist/src/cli.js +462 -32
  215. package/dist/src/cli.test.js +209 -2
  216. package/dist/src/commanderAdapter.js +29 -9
  217. package/dist/src/commanderAdapter.test.js +78 -2
  218. package/dist/src/commands/daemon.js +6 -0
  219. package/dist/src/completion-shared.js +1 -2
  220. package/dist/src/completion.test.js +3 -2
  221. package/dist/src/daemon.js +125 -41
  222. package/dist/src/doctor.d.ts +4 -6
  223. package/dist/src/doctor.js +80 -22
  224. package/dist/src/doctor.test.js +82 -0
  225. package/dist/src/engine.test.js +6 -5
  226. package/dist/src/errors.d.ts +14 -8
  227. package/dist/src/errors.js +36 -30
  228. package/dist/src/errors.test.js +5 -5
  229. package/dist/src/execution.d.ts +4 -0
  230. package/dist/src/execution.js +173 -25
  231. package/dist/src/execution.test.js +171 -1
  232. package/dist/src/main.js +10 -0
  233. package/dist/src/observation/artifact.d.ts +16 -0
  234. package/dist/src/observation/artifact.js +260 -0
  235. package/dist/src/observation/artifact.test.d.ts +1 -0
  236. package/dist/src/observation/artifact.test.js +121 -0
  237. package/dist/src/observation/events.d.ts +89 -0
  238. package/dist/src/observation/events.js +1 -0
  239. package/dist/src/observation/index.d.ts +7 -0
  240. package/dist/src/observation/index.js +7 -0
  241. package/dist/src/observation/manager.d.ts +9 -0
  242. package/dist/src/observation/manager.js +27 -0
  243. package/dist/src/observation/manager.test.d.ts +1 -0
  244. package/dist/src/observation/manager.test.js +13 -0
  245. package/dist/src/observation/redaction.d.ts +11 -0
  246. package/dist/src/observation/redaction.js +81 -0
  247. package/dist/src/observation/redaction.test.d.ts +1 -0
  248. package/dist/src/observation/redaction.test.js +32 -0
  249. package/dist/src/observation/retention.d.ts +32 -0
  250. package/dist/src/observation/retention.js +160 -0
  251. package/dist/src/observation/retention.test.d.ts +1 -0
  252. package/dist/src/observation/retention.test.js +118 -0
  253. package/dist/src/observation/ring-buffer.d.ts +22 -0
  254. package/dist/src/observation/ring-buffer.js +45 -0
  255. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  256. package/dist/src/observation/ring-buffer.test.js +22 -0
  257. package/dist/src/observation/session.d.ts +25 -0
  258. package/dist/src/observation/session.js +50 -0
  259. package/dist/src/pipeline/executor.test.js +1 -0
  260. package/dist/src/pipeline/steps/download.test.js +1 -0
  261. package/dist/src/pipeline/steps/fetch.js +1 -21
  262. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  263. package/dist/src/plugin-scaffold.js +1 -1
  264. package/dist/src/plugin-scaffold.test.js +1 -1
  265. package/dist/src/registry.d.ts +40 -9
  266. package/dist/src/registry.js +3 -1
  267. package/dist/src/runtime-detect.d.ts +10 -0
  268. package/dist/src/runtime-detect.js +19 -0
  269. package/dist/src/runtime-detect.test.js +12 -1
  270. package/dist/src/runtime.d.ts +2 -0
  271. package/dist/src/runtime.js +1 -0
  272. package/dist/src/types.d.ts +22 -0
  273. package/dist/src/update-check.d.ts +31 -1
  274. package/dist/src/update-check.js +62 -16
  275. package/dist/src/update-check.test.js +86 -1
  276. package/package.json +1 -1
  277. package/dist/src/diagnostic.d.ts +0 -63
  278. package/dist/src/diagnostic.js +0 -292
  279. package/dist/src/diagnostic.test.js +0 -302
  280. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -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,6 +16,7 @@ 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;
@@ -10,6 +10,7 @@ import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
11
  import { BrowserConnectError } from '../errors.js';
12
12
  import { PKG_VERSION } from '../version.js';
13
+ import { resolveProfileContextId } from './profile.js';
13
14
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
14
15
  /**
15
16
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -32,8 +33,9 @@ export class BrowserBridge {
32
33
  throw new Error('Session is closed');
33
34
  this._state = 'connecting';
34
35
  try {
35
- await this._ensureDaemon(opts.timeout);
36
- this._page = new Page(opts.workspace, opts.idleTimeout);
36
+ const contextId = opts.contextId ?? resolveProfileContextId();
37
+ await this._ensureDaemon(opts.timeout, contextId);
38
+ this._page = new Page(opts.workspace, opts.idleTimeout, contextId);
37
39
  this._state = 'connected';
38
40
  return this._page;
39
41
  }
@@ -51,13 +53,21 @@ export class BrowserBridge {
51
53
  this._page = null;
52
54
  this._state = 'closed';
53
55
  }
54
- async _ensureDaemon(timeoutSeconds) {
56
+ async _ensureDaemon(timeoutSeconds, contextId) {
55
57
  const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
56
58
  const timeoutMs = effectiveSeconds * 1000;
57
- const health = await getDaemonHealth();
59
+ const health = await getDaemonHealth({ contextId });
58
60
  // Fast path: everything ready
59
61
  if (health.state === 'ready')
60
62
  return;
63
+ if (health.state === 'profile-required') {
64
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
65
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
66
+ }
67
+ if (health.state === 'profile-disconnected') {
68
+ const label = contextId ?? health.status.contextId ?? 'unknown';
69
+ 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');
70
+ }
61
71
  // Daemon running but no extension
62
72
  if (health.state === 'no-extension') {
63
73
  // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
@@ -86,8 +96,17 @@ export class BrowserBridge {
86
96
  process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
87
97
  process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
88
98
  }
89
- if (await this._pollUntilReady(timeoutMs))
99
+ if (await this._pollUntilReady(timeoutMs, contextId))
90
100
  return;
101
+ const finalHealth = await getDaemonHealth({ contextId });
102
+ if (finalHealth.state === 'profile-required') {
103
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
104
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
105
+ }
106
+ if (finalHealth.state === 'profile-disconnected') {
107
+ const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
108
+ 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');
109
+ }
91
110
  throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
92
111
  'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
93
112
  'If not installed:\n' +
@@ -115,9 +134,17 @@ export class BrowserBridge {
115
134
  });
116
135
  this._daemonProc.unref();
117
136
  // Wait for daemon + extension
118
- if (await this._pollUntilReady(timeoutMs))
137
+ if (await this._pollUntilReady(timeoutMs, contextId))
119
138
  return;
120
- const finalHealth = await getDaemonHealth();
139
+ const finalHealth = await getDaemonHealth({ contextId });
140
+ if (finalHealth.state === 'profile-required') {
141
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
142
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
143
+ }
144
+ if (finalHealth.state === 'profile-disconnected') {
145
+ const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
146
+ 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');
147
+ }
121
148
  if (finalHealth.state === 'no-extension') {
122
149
  throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
123
150
  'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
@@ -139,11 +166,11 @@ export class BrowserBridge {
139
166
  return false;
140
167
  }
141
168
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
142
- async _pollUntilReady(timeoutMs) {
169
+ async _pollUntilReady(timeoutMs, contextId) {
143
170
  const deadline = Date.now() + timeoutMs;
144
171
  while (Date.now() < deadline) {
145
172
  await new Promise(resolve => setTimeout(resolve, 200));
146
- const h = await getDaemonHealth();
173
+ const h = await getDaemonHealth({ contextId });
147
174
  if (h.state === 'ready')
148
175
  return true;
149
176
  }
@@ -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
  });
@@ -6,7 +6,7 @@
6
6
  import type { BrowserSessionInfo } from '../types.js';
7
7
  export interface DaemonCommand {
8
8
  id: string;
9
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
9
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
10
10
  /** Target page identity (targetId). Cross-layer contract with the extension. */
11
11
  page?: string;
12
12
  code?: string;
@@ -30,21 +30,32 @@ export interface DaemonCommand {
30
30
  pattern?: string;
31
31
  cdpMethod?: string;
32
32
  cdpParams?: Record<string, unknown>;
33
- /** When true, automation windows are created in the foreground */
33
+ /** When true, the owned automation container is created in the foreground */
34
34
  windowFocused?: boolean;
35
35
  /** Custom idle timeout in seconds for this workspace session. Overrides the default. */
36
36
  idleTimeout?: number;
37
+ /** Explicitly allow navigation inside a borrowed bound tab. */
38
+ allowBoundNavigation?: boolean;
37
39
  /** Frame index for cross-frame operations (0-based, from 'frames' action) */
38
40
  frameIndex?: number;
41
+ /** Browser profile/context to route the command to. */
42
+ contextId?: string;
39
43
  }
40
44
  export interface DaemonResult {
41
45
  id: string;
42
46
  ok: boolean;
43
47
  data?: unknown;
44
48
  error?: string;
49
+ errorCode?: string;
50
+ errorHint?: string;
45
51
  /** Page identity (targetId) — present on page-scoped command responses */
46
52
  page?: string;
47
53
  }
54
+ export declare class BrowserCommandError extends Error {
55
+ readonly code?: string | undefined;
56
+ readonly hint?: string | undefined;
57
+ constructor(message: string, code?: string | undefined, hint?: string | undefined);
58
+ }
48
59
  export interface DaemonStatus {
49
60
  ok: boolean;
50
61
  pid: number;
@@ -53,12 +64,25 @@ export interface DaemonStatus {
53
64
  extensionConnected: boolean;
54
65
  extensionVersion?: string;
55
66
  extensionCompatRange?: string;
67
+ contextId?: string;
68
+ profileRequired?: boolean;
69
+ profileDisconnected?: boolean;
70
+ profiles?: BrowserProfileStatus[];
56
71
  pending: number;
57
72
  memoryMB: number;
58
73
  port: number;
59
74
  }
75
+ export interface BrowserProfileStatus {
76
+ contextId: string;
77
+ extensionConnected: boolean;
78
+ extensionVersion?: string;
79
+ extensionCompatRange?: string;
80
+ pending: number;
81
+ lastSeenAt?: number;
82
+ }
60
83
  export declare function fetchDaemonStatus(opts?: {
61
84
  timeout?: number;
85
+ contextId?: string;
62
86
  }): Promise<DaemonStatus | null>;
63
87
  export type DaemonHealth = {
64
88
  state: 'stopped';
@@ -66,6 +90,12 @@ export type DaemonHealth = {
66
90
  } | {
67
91
  state: 'no-extension';
68
92
  status: DaemonStatus;
93
+ } | {
94
+ state: 'profile-required';
95
+ status: DaemonStatus;
96
+ } | {
97
+ state: 'profile-disconnected';
98
+ status: DaemonStatus;
69
99
  } | {
70
100
  state: 'ready';
71
101
  status: DaemonStatus;
@@ -76,6 +106,7 @@ export type DaemonHealth = {
76
106
  */
77
107
  export declare function getDaemonHealth(opts?: {
78
108
  timeout?: number;
109
+ contextId?: string;
79
110
  }): Promise<DaemonHealth>;
80
111
  export declare function requestDaemonShutdown(opts?: {
81
112
  timeout?: number;
@@ -92,8 +123,11 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
92
123
  data: unknown;
93
124
  page?: string;
94
125
  }>;
95
- export declare function listSessions(): Promise<BrowserSessionInfo[]>;
96
- export declare function bindCurrentTab(workspace: string, opts?: {
126
+ export declare function listSessions(opts?: {
127
+ contextId?: string;
128
+ }): Promise<BrowserSessionInfo[]>;
129
+ export declare function bindTab(workspace: string, opts?: {
97
130
  matchDomain?: string;
98
131
  matchPathPrefix?: string;
132
+ contextId?: string;
99
133
  }): Promise<unknown>;
@@ -6,6 +6,7 @@
6
6
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
7
7
  import { sleep } from '../utils.js';
8
8
  import { classifyBrowserError } from './errors.js';
9
+ import { resolveProfileContextId } from './profile.js';
9
10
  const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
10
11
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
11
12
  const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
@@ -13,6 +14,16 @@ let _idCounter = 0;
13
14
  function generateId() {
14
15
  return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`;
15
16
  }
17
+ export class BrowserCommandError extends Error {
18
+ code;
19
+ hint;
20
+ constructor(message, code, hint) {
21
+ super(message);
22
+ this.code = code;
23
+ this.hint = hint;
24
+ this.name = 'BrowserCommandError';
25
+ }
26
+ }
16
27
  async function requestDaemon(pathname, init) {
17
28
  const { timeout = 2000, headers, ...rest } = init ?? {};
18
29
  const controller = new AbortController();
@@ -30,7 +41,8 @@ async function requestDaemon(pathname, init) {
30
41
  }
31
42
  export async function fetchDaemonStatus(opts) {
32
43
  try {
33
- const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 });
44
+ const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : '';
45
+ const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 });
34
46
  if (!res.ok)
35
47
  return null;
36
48
  return await res.json();
@@ -47,6 +59,10 @@ export async function getDaemonHealth(opts) {
47
59
  const status = await fetchDaemonStatus(opts);
48
60
  if (!status)
49
61
  return { state: 'stopped', status: null };
62
+ if (status.profileRequired)
63
+ return { state: 'profile-required', status };
64
+ if (status.profileDisconnected)
65
+ return { state: 'profile-disconnected', status };
50
66
  if (!status.extensionConnected)
51
67
  return { state: 'no-extension', status };
52
68
  return { state: 'ready', status };
@@ -75,7 +91,8 @@ async function sendCommandRaw(action, params) {
75
91
  const id = generateId();
76
92
  const wf = process.env.OPENCLI_WINDOW_FOCUSED;
77
93
  const windowFocused = (wf === '1' || wf === 'true') ? true : undefined;
78
- const command = { id, action, ...params, ...(windowFocused && { windowFocused }) };
94
+ const contextId = params.contextId ?? resolveProfileContextId();
95
+ const command = { id, action, ...params, ...(contextId && { contextId }), ...(windowFocused && { windowFocused }) };
79
96
  try {
80
97
  const res = await requestDaemon('/command', {
81
98
  method: 'POST',
@@ -95,7 +112,7 @@ async function sendCommandRaw(action, params) {
95
112
  await sleep(advice.delayMs);
96
113
  continue;
97
114
  }
98
- throw new Error(result.error ?? 'Daemon command failed');
115
+ throw new BrowserCommandError(result.error ?? 'Daemon command failed', result.errorCode, result.errorHint);
99
116
  }
100
117
  return result;
101
118
  }
@@ -126,10 +143,10 @@ export async function sendCommandFull(action, params = {}) {
126
143
  const result = await sendCommandRaw(action, params);
127
144
  return { data: result.data, page: result.page };
128
145
  }
129
- export async function listSessions() {
130
- const result = await sendCommand('sessions');
146
+ export async function listSessions(opts) {
147
+ const result = await sendCommand('sessions', { ...(opts?.contextId && { contextId: opts.contextId }) });
131
148
  return Array.isArray(result) ? result : [];
132
149
  }
133
- export async function bindCurrentTab(workspace, opts = {}) {
134
- return sendCommand('bind-current', { workspace, ...opts });
150
+ export async function bindTab(workspace, opts = {}) {
151
+ return sendCommand('bind', { workspace, ...opts });
135
152
  }
@@ -6,6 +6,7 @@ describe('daemon-client', () => {
6
6
  });
7
7
  afterEach(() => {
8
8
  vi.restoreAllMocks();
9
+ vi.unstubAllEnvs();
9
10
  });
10
11
  it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => {
11
12
  const status = {
@@ -78,6 +79,43 @@ describe('daemon-client', () => {
78
79
  });
79
80
  await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
80
81
  });
82
+ it('getDaemonHealth returns profile-required when multiple profiles are connected without a selection', async () => {
83
+ const status = {
84
+ ok: true,
85
+ pid: 123,
86
+ uptime: 10,
87
+ extensionConnected: false,
88
+ profileRequired: true,
89
+ profiles: [
90
+ { contextId: 'work', extensionConnected: true, pending: 0 },
91
+ { contextId: 'personal', extensionConnected: true, pending: 0 },
92
+ ],
93
+ pending: 0,
94
+ memoryMB: 32,
95
+ port: 19825,
96
+ };
97
+ vi.mocked(fetch).mockResolvedValue({
98
+ ok: true,
99
+ json: () => Promise.resolve(status),
100
+ });
101
+ await expect(getDaemonHealth()).resolves.toEqual({ state: 'profile-required', status });
102
+ });
103
+ it('fetchDaemonStatus includes contextId in the status query', async () => {
104
+ vi.mocked(fetch).mockResolvedValue({
105
+ ok: true,
106
+ json: () => Promise.resolve({
107
+ ok: true,
108
+ pid: 1,
109
+ uptime: 0,
110
+ extensionConnected: true,
111
+ pending: 0,
112
+ memoryMB: 1,
113
+ port: 19825,
114
+ }),
115
+ });
116
+ await fetchDaemonStatus({ contextId: 'work' });
117
+ expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/);
118
+ });
81
119
  it('sendCommand includes the current pid in generated command ids', async () => {
82
120
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
83
121
  vi.mocked(fetch).mockResolvedValue({
@@ -95,6 +133,17 @@ describe('daemon-client', () => {
95
133
  expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
96
134
  expect(ids[0]).not.toBe(ids[1]);
97
135
  });
136
+ it('sendCommand forwards OPENCLI_PROFILE as command contextId', async () => {
137
+ vi.stubEnv('OPENCLI_PROFILE', 'work');
138
+ vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
139
+ vi.mocked(fetch).mockResolvedValue({
140
+ status: 200,
141
+ json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
142
+ });
143
+ await sendCommand('exec', { code: '1 + 1' });
144
+ const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
145
+ expect(body.contextId).toBe('work');
146
+ });
98
147
  it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => {
99
148
  vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123);
100
149
  const fetchMock = vi.mocked(fetch);
@@ -15,7 +15,10 @@ const EXTENSION_TRANSIENT_PATTERNS = [
15
15
  'Extension disconnected',
16
16
  'Extension not connected',
17
17
  'attach failed',
18
+ 'Detached while handling command',
19
+ 'Debugger is not attached to the tab',
18
20
  'no longer exists',
21
+ 'No tab with id',
19
22
  'CDP connection',
20
23
  'Daemon command failed',
21
24
  'No window with id',
@@ -6,7 +6,10 @@ describe('classifyBrowserError', () => {
6
6
  'Extension disconnected',
7
7
  'Extension not connected',
8
8
  'attach failed',
9
+ 'Detached while handling command',
10
+ 'Debugger is not attached to the tab: 123',
9
11
  'no longer exists',
12
+ 'No tab with id: 456',
10
13
  'CDP connection reset',
11
14
  'Daemon command failed',
12
15
  'No window with id: 123',
@@ -25,6 +25,7 @@ export interface CachedNetworkEntry {
25
25
  */
26
26
  body_truncated?: boolean;
27
27
  body_full_size?: number;
28
+ timestamp?: number;
28
29
  }
29
30
  export interface NetworkCacheFile {
30
31
  version: 1;