@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
package/dist/src/cli.js CHANGED
@@ -31,9 +31,52 @@ import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
31
31
  import { analyzeSite } from './browser/analyze.js';
32
32
  import { daemonStatus, daemonStop } from './commands/daemon.js';
33
33
  import { log } from './logger.js';
34
+ import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
35
+ import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
34
36
  const CLI_FILE = fileURLToPath(import.meta.url);
35
37
  const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
38
+ const DEFAULT_BOUND_WORKSPACE = 'bound:default';
36
39
  const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
40
+ const FOLLOW_POLL_MS = 1_000;
41
+ function parseDurationMs(raw, flagName) {
42
+ if (raw === undefined || raw === null || raw === '')
43
+ return null;
44
+ const str = String(raw).trim();
45
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(str);
46
+ if (!match)
47
+ return { error: `--${flagName} must be a duration like 500ms, 30s, 2m, got "${str}"` };
48
+ const value = Number.parseFloat(match[1]);
49
+ const unit = match[2] ?? 'ms';
50
+ const multiplier = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1;
51
+ return Math.round(value * multiplier);
52
+ }
53
+ function timestampFromRaw(value) {
54
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
55
+ }
56
+ function toIsoTimestamp(timestamp) {
57
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
58
+ return undefined;
59
+ return new Date(timestamp).toISOString();
60
+ }
61
+ function filterByTimeWindow(items, opts, now = Date.now()) {
62
+ const sinceTs = opts.sinceMs != null ? now - opts.sinceMs : undefined;
63
+ const untilTs = opts.untilMs != null ? now - opts.untilMs : undefined;
64
+ return items.filter((item) => {
65
+ const ts = item.timestamp ?? now;
66
+ if (sinceTs !== undefined && ts < sinceTs)
67
+ return false;
68
+ if (untilTs !== undefined && ts > untilTs)
69
+ return false;
70
+ return true;
71
+ });
72
+ }
73
+ export function selectFreshByTimestamp(items, lastSeenTs) {
74
+ const fresh = items.filter((item) => Number(item.timestamp ?? 0) > lastSeenTs);
75
+ const nextSeenTs = fresh.length > 0
76
+ ? Math.max(lastSeenTs, ...fresh.map((item) => Number(item.timestamp ?? 0)).filter(Number.isFinite))
77
+ : lastSeenTs;
78
+ return { fresh, lastSeenTs: nextSeenTs };
79
+ }
37
80
  /**
38
81
  * Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
39
82
  * the JS interceptor's `window.__opencli_net`) into a consistent shape.
@@ -69,13 +112,15 @@ async function captureNetworkItems(page) {
69
112
  body,
70
113
  bodyFullSize: fullSize,
71
114
  bodyTruncated: truncated,
115
+ timestamp: timestampFromRaw(e.timestamp),
72
116
  };
73
117
  });
74
118
  }
75
119
  }
76
120
  const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`);
77
121
  try {
78
- return JSON.parse(raw);
122
+ const parsed = JSON.parse(raw);
123
+ return parsed.map((item) => ({ ...item, timestamp: timestampFromRaw(item.timestamp) }));
79
124
  }
80
125
  catch {
81
126
  if (process.env.OPENCLI_VERBOSE)
@@ -85,9 +130,12 @@ async function captureNetworkItems(page) {
85
130
  }
86
131
  /** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
87
132
  function filterNetworkItems(items) {
88
- return items.filter((r) => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
89
- !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
90
- !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
133
+ return items.filter((r) => {
134
+ const ct = r.ct?.toLowerCase() ?? '';
135
+ return ((ct.includes('json') || ct.includes('xml') || ct.includes('text/plain') || ct.includes('javascript')) &&
136
+ !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
137
+ !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
138
+ });
91
139
  }
92
140
  /** Exit codes by network error code — usage errors vs runtime failures. */
93
141
  const NETWORK_ERROR_EXIT = {
@@ -253,6 +301,9 @@ async function resolveBrowserTargetInSession(page, targetPage, opts) {
253
301
  throw new Error(`Target tab ${candidate} is not part of the current browser session. ` +
254
302
  'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.');
255
303
  }
304
+ function getBrowserScope(workspace, contextId) {
305
+ return contextId ? `${contextId}:${workspace}` : workspace;
306
+ }
256
307
  async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPACE) {
257
308
  const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim();
258
309
  if (!defaultPage)
@@ -260,19 +311,21 @@ async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPAC
260
311
  return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
261
312
  }
262
313
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
263
- async function getBrowserPage(targetPage) {
314
+ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId) {
264
315
  const { BrowserBridge } = await import('./browser/index.js');
265
316
  const bridge = new BrowserBridge();
266
317
  const envTimeout = process.env.OPENCLI_BROWSER_TIMEOUT;
267
318
  const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined;
268
319
  const page = await bridge.connect({
269
320
  timeout: 30,
270
- workspace: DEFAULT_BROWSER_WORKSPACE,
321
+ workspace,
322
+ ...(contextId && { contextId }),
271
323
  ...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
272
324
  });
325
+ const targetScope = getBrowserScope(workspace, contextId);
273
326
  const resolvedTargetPage = targetPage
274
- ? await resolveBrowserTargetInSession(page, targetPage, { scope: DEFAULT_BROWSER_WORKSPACE, source: 'explicit' })
275
- : await resolveStoredBrowserTarget(page, DEFAULT_BROWSER_WORKSPACE);
327
+ ? await resolveBrowserTargetInSession(page, targetPage, { scope: targetScope, source: 'explicit' })
328
+ : await resolveStoredBrowserTarget(page, targetScope);
276
329
  if (resolvedTargetPage) {
277
330
  if (!page.setActivePage) {
278
331
  throw new Error('This browser session does not support explicit tab targeting');
@@ -290,11 +343,38 @@ function getBrowserTargetId(command) {
290
343
  const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
291
344
  return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined;
292
345
  }
346
+ function getCommandOption(command, option) {
347
+ let current = command;
348
+ while (current) {
349
+ const opts = current.opts();
350
+ if (Object.prototype.hasOwnProperty.call(opts, option) && opts[option] !== undefined)
351
+ return opts[option];
352
+ current = current.parent;
353
+ }
354
+ return undefined;
355
+ }
356
+ function getBrowserWorkspace(command) {
357
+ const raw = getCommandOption(command, 'workspace');
358
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : DEFAULT_BROWSER_WORKSPACE;
359
+ }
360
+ function getBrowserContextId(command) {
361
+ const raw = getCommandOption(command, 'profile');
362
+ return resolveProfileContextId(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined);
363
+ }
364
+ function getPageWorkspace(page) {
365
+ const workspace = page.workspace;
366
+ return typeof workspace === 'string' && workspace.trim() ? workspace.trim() : DEFAULT_BROWSER_WORKSPACE;
367
+ }
368
+ function getPageScope(page) {
369
+ const contextId = page.contextId;
370
+ return getBrowserScope(getPageWorkspace(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined);
371
+ }
293
372
  function resolveBrowserTabTarget(targetId, opts) {
294
373
  if (typeof targetId === 'string' && targetId.trim())
295
374
  return targetId.trim();
296
- if (typeof opts?.tab === 'string' && opts.tab.trim())
297
- return opts.tab.trim();
375
+ const tab = opts instanceof Command ? opts.opts().tab : opts?.tab;
376
+ if (typeof tab === 'string' && tab.trim())
377
+ return tab.trim();
298
378
  return undefined;
299
379
  }
300
380
  function parsePositiveIntOption(val, label, fallback) {
@@ -319,17 +399,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
319
399
  .name('opencli')
320
400
  .description('Make any website your CLI. Zero setup. AI-powered.')
321
401
  .version(PKG_VERSION)
402
+ .option('--profile <name>', 'Chrome profile/context alias for Browser Bridge commands')
322
403
  .enablePositionalOptions();
323
404
  // ── Built-in: list ────────────────────────────────────────────────────────
324
405
  program
325
406
  .command('list')
326
407
  .description('List all available CLI commands')
327
408
  .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
328
- .option('--json', 'JSON output (deprecated)')
329
409
  .action((opts) => {
330
410
  const registry = getRegistry();
331
411
  const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b)));
332
- const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
412
+ const fmt = opts.format;
333
413
  const isStructured = fmt === 'json' || fmt === 'yaml';
334
414
  if (fmt !== 'table') {
335
415
  const rows = isStructured
@@ -414,6 +494,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
414
494
  // All commands wrapped in browserAction() for consistent error handling.
415
495
  const browser = program
416
496
  .command('browser')
497
+ .option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
417
498
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
418
499
  /**
419
500
  * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
@@ -466,7 +547,9 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
466
547
  try {
467
548
  const command = args.at(-1) instanceof Command ? args.at(-1) : undefined;
468
549
  const targetPage = getBrowserTargetId(command);
469
- const page = await getBrowserPage(targetPage);
550
+ const workspace = getBrowserWorkspace(command);
551
+ const contextId = getBrowserContextId(command);
552
+ const page = await getBrowserPage(targetPage, workspace, contextId);
470
553
  await fn(page, ...args);
471
554
  }
472
555
  catch (err) {
@@ -475,6 +558,20 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
475
558
  if (err.hint)
476
559
  log.error(`Hint: ${err.hint}`);
477
560
  }
561
+ else if (err instanceof BrowserCommandError) {
562
+ if (err.code) {
563
+ console.log(JSON.stringify({
564
+ error: {
565
+ code: err.code,
566
+ message: err.message,
567
+ ...(err.hint ? { hint: err.hint } : {}),
568
+ },
569
+ }, null, 2));
570
+ }
571
+ log.error(err.message);
572
+ if (err.hint)
573
+ log.error(`Hint: ${err.hint}`);
574
+ }
478
575
  else if (err instanceof TargetError) {
479
576
  // Agent-facing structured envelope on stdout + short human line on stderr.
480
577
  emitTargetError(err);
@@ -495,6 +592,105 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
495
592
  }
496
593
  };
497
594
  }
595
+ browser.command('bind')
596
+ .option('--domain <host>', 'Only bind a current/visible tab whose hostname matches this domain')
597
+ .option('--path-prefix <path>', 'Only bind a current/visible tab whose pathname starts with this prefix')
598
+ .option('--workspace <name>', 'Bound workspace name (must start with bound:)')
599
+ .description('Bind a bound:* workspace to the current Chrome tab/window')
600
+ .action(async (optsOrCommand, maybeCommand) => {
601
+ const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
602
+ const opts = command?.opts() ?? optsOrCommand ?? {};
603
+ const rawWorkspace = getCommandOption(command, 'workspace');
604
+ const workspace = typeof rawWorkspace === 'string' && rawWorkspace.trim()
605
+ ? rawWorkspace.trim()
606
+ : DEFAULT_BOUND_WORKSPACE;
607
+ if (!workspace.startsWith('bound:')) {
608
+ console.log(JSON.stringify({
609
+ error: {
610
+ code: 'invalid_bind_workspace',
611
+ message: `--workspace must start with "bound:", got "${workspace}"`,
612
+ hint: 'Use the default bound:default or pass --workspace bound:<name>.',
613
+ },
614
+ }, null, 2));
615
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
616
+ return;
617
+ }
618
+ try {
619
+ const { BrowserBridge } = await import('./browser/index.js');
620
+ const bridge = new BrowserBridge();
621
+ const contextId = getBrowserContextId(command);
622
+ await bridge.connect({ timeout: 30, workspace, ...(contextId && { contextId }) });
623
+ const data = await bindTab(workspace, {
624
+ ...(contextId && { contextId }),
625
+ ...(typeof opts.domain === 'string' && opts.domain.trim() ? { matchDomain: opts.domain.trim() } : {}),
626
+ ...(typeof opts.pathPrefix === 'string' && opts.pathPrefix.trim() ? { matchPathPrefix: opts.pathPrefix.trim() } : {}),
627
+ });
628
+ saveBrowserTargetState(undefined, getBrowserScope(workspace, contextId));
629
+ console.log(JSON.stringify({ workspace, ...((data && typeof data === 'object') ? data : { data }) }, null, 2));
630
+ }
631
+ catch (err) {
632
+ if (err instanceof BrowserCommandError && err.code) {
633
+ console.log(JSON.stringify({
634
+ error: {
635
+ code: err.code,
636
+ message: err.message,
637
+ ...(err.hint ? { hint: err.hint } : {}),
638
+ },
639
+ }, null, 2));
640
+ }
641
+ log.error(err instanceof Error ? err.message : String(err));
642
+ if (err instanceof BrowserCommandError && err.hint)
643
+ log.error(`Hint: ${err.hint}`);
644
+ process.exitCode = err instanceof BrowserCommandError && err.code === 'invalid_bind_workspace'
645
+ ? EXIT_CODES.USAGE_ERROR
646
+ : EXIT_CODES.GENERIC_ERROR;
647
+ }
648
+ });
649
+ browser.command('unbind')
650
+ .option('--workspace <name>', 'Bound workspace name to detach')
651
+ .description('Detach a bound:* workspace without closing the user tab/window')
652
+ .action(async (optsOrCommand, maybeCommand) => {
653
+ const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
654
+ const rawWorkspace = getCommandOption(command, 'workspace');
655
+ const workspace = typeof rawWorkspace === 'string' && rawWorkspace.trim()
656
+ ? rawWorkspace.trim()
657
+ : DEFAULT_BOUND_WORKSPACE;
658
+ if (!workspace.startsWith('bound:')) {
659
+ console.log(JSON.stringify({
660
+ error: {
661
+ code: 'invalid_bind_workspace',
662
+ message: `--workspace must start with "bound:", got "${workspace}"`,
663
+ hint: 'Use the default bound:default or pass --workspace bound:<name>.',
664
+ },
665
+ }, null, 2));
666
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
667
+ return;
668
+ }
669
+ try {
670
+ const { BrowserBridge } = await import('./browser/index.js');
671
+ const bridge = new BrowserBridge();
672
+ const contextId = getBrowserContextId(command);
673
+ await bridge.connect({ timeout: 30, workspace, ...(contextId && { contextId }) });
674
+ await sendCommand('close-window', { workspace, ...(contextId && { contextId }) });
675
+ saveBrowserTargetState(undefined, getBrowserScope(workspace, contextId));
676
+ console.log(JSON.stringify({ unbound: true, workspace }, null, 2));
677
+ }
678
+ catch (err) {
679
+ if (err instanceof BrowserCommandError && err.code) {
680
+ console.log(JSON.stringify({
681
+ error: {
682
+ code: err.code,
683
+ message: err.message,
684
+ ...(err.hint ? { hint: err.hint } : {}),
685
+ },
686
+ }, null, 2));
687
+ }
688
+ log.error(err instanceof Error ? err.message : String(err));
689
+ if (err instanceof BrowserCommandError && err.hint)
690
+ log.error(`Hint: ${err.hint}`);
691
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
692
+ }
693
+ });
498
694
  const browserTab = browser
499
695
  .command('tab')
500
696
  .description('Tab management — list, create, and close tabs in the automation window');
@@ -526,7 +722,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
526
722
  throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
527
723
  }
528
724
  await page.selectTab(resolvedTarget);
529
- saveBrowserTargetState(resolvedTarget, DEFAULT_BROWSER_WORKSPACE);
725
+ saveBrowserTargetState(resolvedTarget, getPageScope(page));
530
726
  console.log(JSON.stringify({ selected: resolvedTarget }, null, 2));
531
727
  }));
532
728
  addBrowserTabOption(browserTab.command('close')
@@ -541,15 +737,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
541
737
  throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
542
738
  }
543
739
  const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, {
544
- scope: DEFAULT_BROWSER_WORKSPACE,
740
+ scope: getPageScope(page),
545
741
  source: 'explicit',
546
742
  });
547
743
  if (!validatedTarget) {
548
744
  throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`);
549
745
  }
550
746
  await page.closeTab(validatedTarget);
551
- if (loadBrowserTargetState(DEFAULT_BROWSER_WORKSPACE)?.defaultPage === validatedTarget) {
552
- saveBrowserTargetState(undefined, DEFAULT_BROWSER_WORKSPACE);
747
+ const scope = getPageScope(page);
748
+ if (loadBrowserTargetState(scope)?.defaultPage === validatedTarget) {
749
+ saveBrowserTargetState(undefined, scope);
553
750
  }
554
751
  console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
555
752
  }));
@@ -564,12 +761,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
564
761
  * silently dropping the body. Per-entry cap is 1 MiB and the ring is
565
762
  * capped at 200 entries, bounding worst-case in-page memory.
566
763
  */
567
- const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
568
- addBrowserTabOption(browser.command('open').argument('<url>').description('Open URL in automation window'))
569
- .action(browserAction(async (page, url) => {
764
+ const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body,timestamp:Date.now()};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`;
765
+ addBrowserTabOption(browser.command('open').argument('<url>').option('--allow-navigate-bound', 'Allow navigating a bound user tab', false).description('Open URL in automation window'))
766
+ .action(browserAction(async (page, url, opts) => {
570
767
  // Start session-level capture before navigation (catches initial requests)
571
768
  const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
572
- await page.goto(url);
769
+ if (opts.allowNavigateBound === true) {
770
+ await page.goto(url, { allowBoundNavigation: true });
771
+ }
772
+ else {
773
+ await page.goto(url);
774
+ }
573
775
  await page.wait(2);
574
776
  // Fallback: inject JS interceptor when session capture is unavailable
575
777
  if (!hasSessionCapture) {
@@ -583,8 +785,19 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
583
785
  ...(page.getActivePage?.() ? { page: page.getActivePage?.() } : {}),
584
786
  }, null, 2));
585
787
  }));
586
- addBrowserTabOption(browser.command('back').description('Go back in browser history'))
587
- .action(browserAction(async (page) => {
788
+ addBrowserTabOption(browser.command('back').option('--allow-navigate-bound', 'Allow history navigation in a bound user tab', false).description('Go back in browser history'))
789
+ .action(browserAction(async (page, opts) => {
790
+ if (getPageWorkspace(page).startsWith('bound:') && opts.allowNavigateBound !== true) {
791
+ console.log(JSON.stringify({
792
+ error: {
793
+ code: 'bound_navigation_blocked',
794
+ message: `Workspace "${getPageWorkspace(page)}" is bound to a user tab; history navigation is blocked by default.`,
795
+ hint: 'Pass --allow-navigate-bound only if you intentionally want to navigate the bound tab.',
796
+ },
797
+ }, null, 2));
798
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
799
+ return;
800
+ }
588
801
  await page.evaluate('history.back()');
589
802
  await page.wait(2);
590
803
  console.log('Navigated back');
@@ -624,6 +837,69 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
624
837
  console.log(await page.screenshot({ format: 'png' }));
625
838
  }
626
839
  }));
840
+ addBrowserTabOption(browser.command('console'))
841
+ .option('--level <level>', 'Console level: all, error, warning, log, info, debug', 'all')
842
+ .option('--since <duration>', 'Only include messages from the last duration (for example: 30s, 2m)')
843
+ .option('--until <duration>', 'Only include messages older than the duration from now')
844
+ .option('--follow', 'Continuously print new console messages as JSON lines', false)
845
+ .description('Read recent browser console messages')
846
+ .action(browserAction(async (page, opts) => {
847
+ const sinceMs = parseDurationMs(opts.since, 'since');
848
+ const untilMs = parseDurationMs(opts.until, 'until');
849
+ if (sinceMs && typeof sinceMs === 'object') {
850
+ console.log(JSON.stringify({ error: { code: 'invalid_since', message: sinceMs.error } }, null, 2));
851
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
852
+ return;
853
+ }
854
+ if (untilMs && typeof untilMs === 'object') {
855
+ console.log(JSON.stringify({ error: { code: 'invalid_until', message: untilMs.error } }, null, 2));
856
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
857
+ return;
858
+ }
859
+ const normalize = (messages) => messages.map((message) => {
860
+ if (message && typeof message === 'object') {
861
+ const record = message;
862
+ return {
863
+ ...record,
864
+ timestamp: timestampFromRaw(record.timestamp),
865
+ };
866
+ }
867
+ return { type: 'log', text: String(message), timestamp: Date.now() };
868
+ });
869
+ const filter = (messages) => filterByTimeWindow(messages, { sinceMs, untilMs }).filter((message) => {
870
+ if (opts.level === 'all')
871
+ return true;
872
+ const type = String(message.type ?? message.level ?? '').toLowerCase();
873
+ return opts.level === 'error'
874
+ ? type === 'error' || type === 'warning'
875
+ : type === String(opts.level).toLowerCase();
876
+ });
877
+ if (opts.follow) {
878
+ let lastSeenTs = 0;
879
+ while (true) {
880
+ const messages = filter(normalize(await page.consoleMessages('all')));
881
+ const next = selectFreshByTimestamp(messages, lastSeenTs);
882
+ for (const message of next.fresh) {
883
+ console.log(JSON.stringify({
884
+ ...message,
885
+ timestamp: toIsoTimestamp(message.timestamp),
886
+ }));
887
+ }
888
+ lastSeenTs = next.lastSeenTs;
889
+ await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
890
+ }
891
+ }
892
+ const messages = filter(normalize(await page.consoleMessages(opts.level)));
893
+ console.log(JSON.stringify({
894
+ workspace: getPageWorkspace(page),
895
+ captured_at: new Date().toISOString(),
896
+ count: messages.length,
897
+ messages: messages.map((message) => ({
898
+ ...message,
899
+ timestamp: toIsoTimestamp(message.timestamp),
900
+ })),
901
+ }, null, 2));
902
+ }));
627
903
  // ── Analyze (site recon, agent-native) ──
628
904
  //
629
905
  // Mechanizes the `site-recon.md` decision tree into one CLI call. The agent
@@ -1196,14 +1472,28 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1196
1472
  .option('--all', 'Include static resources (js/css/images/telemetry)')
1197
1473
  .option('--raw', 'Emit full bodies for every entry (skip shape preview)')
1198
1474
  .option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
1475
+ .option('--since <duration>', 'Only include entries from the last duration (for example: 30s, 2m)')
1476
+ .option('--until <duration>', 'Only include entries older than the duration from now')
1477
+ .option('--follow', 'Continuously print new matching entries as JSON lines', false)
1478
+ .option('--failed', 'Only include failed HTTP requests (status 0 or >= 400)', false)
1199
1479
  .option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
1200
1480
  .option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
1201
1481
  .description('Capture network requests as shape previews; retrieve full bodies by key')
1202
1482
  .action(browserAction(async (page, opts) => {
1203
1483
  const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
1204
- const workspace = DEFAULT_BROWSER_WORKSPACE;
1484
+ const workspace = getPageWorkspace(page);
1205
1485
  const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
1206
1486
  const hasFilter = typeof opts.filter === 'string';
1487
+ const sinceMs = parseDurationMs(opts.since, 'since');
1488
+ const untilMs = parseDurationMs(opts.until, 'until');
1489
+ if (sinceMs && typeof sinceMs === 'object') {
1490
+ emitNetworkError('invalid_since', sinceMs.error);
1491
+ return;
1492
+ }
1493
+ if (untilMs && typeof untilMs === 'object') {
1494
+ emitNetworkError('invalid_until', untilMs.error);
1495
+ return;
1496
+ }
1207
1497
  // --detail and --filter do different things (one request by key vs. narrow
1208
1498
  // the list by shape), don't compose, and combining them has no sensible
1209
1499
  // semantic. Reject up front with a structured error instead of silently
@@ -1221,6 +1511,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1221
1511
  }
1222
1512
  filterFields = parsed.fields;
1223
1513
  }
1514
+ if (hasDetail && opts.follow) {
1515
+ emitNetworkError('invalid_args', '--follow cannot be used with --detail.');
1516
+ return;
1517
+ }
1224
1518
  // --detail short-circuits: read from cache only, no live capture needed.
1225
1519
  if (hasDetail) {
1226
1520
  const res = loadNetworkCache(workspace, { ttlMs });
@@ -1267,6 +1561,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1267
1561
  status: entry.status,
1268
1562
  ct: entry.ct,
1269
1563
  size: entry.size,
1564
+ ...(typeof entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(entry.timestamp) } : {}),
1270
1565
  shape: inferShape(entry.body),
1271
1566
  body: outputBody,
1272
1567
  };
@@ -1280,6 +1575,38 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1280
1575
  console.log(JSON.stringify(detailEnvelope, null, 2));
1281
1576
  return;
1282
1577
  }
1578
+ if (opts.follow) {
1579
+ if (!await page.startNetworkCapture?.()) {
1580
+ try {
1581
+ await page.evaluate(NETWORK_INTERCEPTOR_JS);
1582
+ }
1583
+ catch { /* non-fatal */ }
1584
+ }
1585
+ while (true) {
1586
+ const rawItems = await captureNetworkItems(page).catch((err) => {
1587
+ emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
1588
+ return [];
1589
+ });
1590
+ let items = opts.all ? rawItems : filterNetworkItems(rawItems);
1591
+ items = filterByTimeWindow(items, { sinceMs, untilMs });
1592
+ if (opts.failed)
1593
+ items = items.filter((item) => item.status === 0 || item.status >= 400);
1594
+ const keyed = assignKeys(items);
1595
+ for (const item of keyed) {
1596
+ console.log(JSON.stringify({
1597
+ key: item.key,
1598
+ timestamp: toIsoTimestamp(item.timestamp),
1599
+ method: item.method,
1600
+ status: item.status,
1601
+ url: item.url,
1602
+ ct: item.ct,
1603
+ size: item.size,
1604
+ ...(item.bodyTruncated ? { body_truncated: true } : {}),
1605
+ }));
1606
+ }
1607
+ await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
1608
+ }
1609
+ }
1283
1610
  // Fresh capture path.
1284
1611
  let rawItems;
1285
1612
  try {
@@ -1289,7 +1616,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1289
1616
  emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
1290
1617
  return;
1291
1618
  }
1292
- const items = opts.all ? rawItems : filterNetworkItems(rawItems);
1619
+ let items = opts.all ? rawItems : filterNetworkItems(rawItems);
1620
+ items = filterByTimeWindow(items, { sinceMs, untilMs });
1621
+ if (opts.failed)
1622
+ items = items.filter((item) => item.status === 0 || item.status >= 400);
1293
1623
  const filteredOut = rawItems.length - items.length;
1294
1624
  const keyed = assignKeys(items);
1295
1625
  const cacheEntries = keyed.map((it) => ({
@@ -1300,6 +1630,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1300
1630
  size: it.size,
1301
1631
  ct: it.ct,
1302
1632
  body: it.body,
1633
+ ...(typeof it.timestamp === 'number' ? { timestamp: it.timestamp } : {}),
1303
1634
  ...(it.bodyTruncated ? { body_truncated: true } : {}),
1304
1635
  ...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
1305
1636
  ? { body_full_size: it.bodyFullSize }
@@ -1342,12 +1673,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1342
1673
  envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
1343
1674
  }
1344
1675
  if (opts.raw) {
1345
- envelope.entries = visible.map((s) => s.entry);
1676
+ envelope.entries = visible.map((s) => ({
1677
+ ...s.entry,
1678
+ ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
1679
+ }));
1346
1680
  }
1347
1681
  else {
1348
1682
  envelope.entries = visible.map((s) => ({
1349
1683
  key: s.entry.key,
1350
1684
  method: s.entry.method,
1685
+ ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
1351
1686
  status: s.entry.status,
1352
1687
  url: s.entry.url,
1353
1688
  ct: s.entry.ct,
@@ -1412,10 +1747,10 @@ cli({
1412
1747
  { name: 'limit', type: 'int', default: 10, help: 'Number of items' },
1413
1748
  ],
1414
1749
  columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
1415
- func: async (page, kwargs) => {
1750
+ func: async (kwargs) => {
1416
1751
  // TODO: implement data fetching
1417
1752
  // Prefer API calls (fetch) over browser automation
1418
- // page is available if browser: true
1753
+ // If you set browser: true, change this to: async (page, kwargs) => { ... }
1419
1754
  return [];
1420
1755
  },
1421
1756
  });
@@ -1884,6 +2219,78 @@ cli({
1884
2219
  ? `✅ Reset "${site}". Now using official baseline.`
1885
2220
  : `✅ Removed custom site "${site}".`));
1886
2221
  });
2222
+ // ── Built-in: browser profile selection ──────────────────────────────────
2223
+ const profileCmd = program.command('profile').description('Manage Browser Bridge Chrome profiles');
2224
+ profileCmd
2225
+ .command('list')
2226
+ .description('List Chrome profiles connected through the Browser Bridge extension')
2227
+ .action(async () => {
2228
+ const status = await fetchDaemonStatus();
2229
+ const config = loadProfileConfig();
2230
+ const profiles = status?.profiles ?? [];
2231
+ if (!status) {
2232
+ console.log(styleText('yellow', 'Daemon is not running. Run opencli doctor after opening Chrome.'));
2233
+ return;
2234
+ }
2235
+ if (profiles.length === 0) {
2236
+ console.log(styleText('yellow', 'No Browser Bridge profiles connected.'));
2237
+ console.log(styleText('dim', 'Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.'));
2238
+ return;
2239
+ }
2240
+ const knownContextIds = new Set(profiles.map((profile) => profile.contextId));
2241
+ console.log(styleText('bold', 'Connected Browser Bridge profiles'));
2242
+ console.log();
2243
+ for (const profile of profiles) {
2244
+ const alias = aliasForContextId(config, profile.contextId);
2245
+ const defaultMark = config.defaultContextId === profile.contextId ? styleText('green', ' default') : '';
2246
+ const aliasText = alias ? ` ${styleText('cyan', alias)}` : '';
2247
+ const version = profile.extensionVersion ? ` v${profile.extensionVersion}` : ' version unknown';
2248
+ console.log(` ${profile.contextId}${aliasText}${defaultMark} — connected${version}`);
2249
+ }
2250
+ const disconnectedAliases = Object.entries(config.aliases)
2251
+ .filter(([, contextId]) => !knownContextIds.has(contextId));
2252
+ if (disconnectedAliases.length > 0 || (config.defaultContextId && !knownContextIds.has(config.defaultContextId))) {
2253
+ console.log();
2254
+ console.log(styleText('dim', 'Disconnected saved profiles:'));
2255
+ const shown = new Set();
2256
+ for (const [alias, contextId] of disconnectedAliases) {
2257
+ shown.add(contextId);
2258
+ console.log(styleText('dim', ` ${contextId} ${alias} — not connected`));
2259
+ }
2260
+ if (config.defaultContextId && !shown.has(config.defaultContextId) && !knownContextIds.has(config.defaultContextId)) {
2261
+ console.log(styleText('dim', ` ${config.defaultContextId} — default, not connected`));
2262
+ }
2263
+ }
2264
+ });
2265
+ profileCmd
2266
+ .command('rename')
2267
+ .description('Assign a local alias to a connected Browser Bridge profile')
2268
+ .argument('<contextId>', 'Profile contextId from opencli profile list')
2269
+ .argument('<alias>', 'Local alias, e.g. work or personal')
2270
+ .action((contextId, alias) => {
2271
+ try {
2272
+ renameProfile(contextId, alias);
2273
+ console.log(`Profile ${contextId} is now aliased as ${styleText('cyan', alias)}.`);
2274
+ }
2275
+ catch (err) {
2276
+ console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
2277
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
2278
+ }
2279
+ });
2280
+ profileCmd
2281
+ .command('use')
2282
+ .description('Set the default Browser Bridge profile for future commands')
2283
+ .argument('<profile>', 'Profile alias or contextId')
2284
+ .action((profile) => {
2285
+ try {
2286
+ const config = setDefaultProfile(profile);
2287
+ console.log(`Default Browser Bridge profile: ${styleText('cyan', config.defaultContextId ?? profile)}`);
2288
+ }
2289
+ catch (err) {
2290
+ console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
2291
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
2292
+ }
2293
+ });
1887
2294
  // ── Built-in: daemon ──────────────────────────────────────────────────────
1888
2295
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
1889
2296
  daemonCmd
@@ -1896,7 +2303,10 @@ cli({
1896
2303
  .action(async () => { await daemonStop(); });
1897
2304
  // ── External CLIs ─────────────────────────────────────────────────────────
1898
2305
  const externalClis = loadExternalClis();
1899
- program
2306
+ const externalCmd = program
2307
+ .command('external')
2308
+ .description('Manage external CLI passthrough commands');
2309
+ externalCmd
1900
2310
  .command('install')
1901
2311
  .description('Install an external CLI')
1902
2312
  .argument('<name>', 'Name of the external CLI')
@@ -1909,7 +2319,7 @@ cli({
1909
2319
  }
1910
2320
  installExternalCli(ext);
1911
2321
  });
1912
- program
2322
+ externalCmd
1913
2323
  .command('register')
1914
2324
  .description('Register an external CLI')
1915
2325
  .argument('<name>', 'Name of the CLI')
@@ -1919,6 +2329,26 @@ cli({
1919
2329
  .action((name, opts) => {
1920
2330
  registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc });
1921
2331
  });
2332
+ externalCmd
2333
+ .command('list')
2334
+ .description('List registered external CLIs')
2335
+ .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
2336
+ .action((opts) => {
2337
+ const rows = loadExternalClis().map((ext) => ({
2338
+ name: ext.name,
2339
+ binary: ext.binary,
2340
+ installed: isBinaryInstalled(ext.binary),
2341
+ description: ext.description ?? '',
2342
+ homepage: ext.homepage ?? '',
2343
+ tags: ext.tags?.join(', ') ?? '',
2344
+ }));
2345
+ renderOutput(rows, {
2346
+ fmt: opts.format,
2347
+ columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
2348
+ title: 'opencli/external/list',
2349
+ source: 'opencli external list',
2350
+ });
2351
+ });
1922
2352
  function passthroughExternal(name, parsedArgs) {
1923
2353
  const args = parsedArgs ?? (() => {
1924
2354
  const idx = process.argv.indexOf(name);
@@ -1965,12 +2395,12 @@ cli({
1965
2395
  registerAllCommands(program, siteGroups);
1966
2396
  // ── Unknown command fallback ──────────────────────────────────────────────
1967
2397
  // Security: do NOT auto-discover and register arbitrary system binaries.
1968
- // Only explicitly registered external CLIs (via `opencli register`) are allowed.
2398
+ // Only explicitly registered external CLIs are allowed.
1969
2399
  program.on('command:*', (operands) => {
1970
2400
  const binary = operands[0];
1971
2401
  console.error(styleText('red', `error: unknown command '${binary}'`));
1972
2402
  if (isBinaryInstalled(binary)) {
1973
- console.error(styleText('dim', ` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
2403
+ console.error(styleText('dim', ` Tip: '${binary}' exists on your PATH. Use 'opencli external register ${binary}' to add it as an external CLI.`));
1974
2404
  }
1975
2405
  program.outputHelp();
1976
2406
  process.exitCode = EXIT_CODES.USAGE_ERROR;