@jackwener/opencli 1.7.8 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
package/dist/src/cli.js CHANGED
@@ -29,11 +29,55 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
29
29
  import { buildHtmlTreeJs } from './browser/html-tree.js';
30
30
  import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
31
31
  import { analyzeSite } from './browser/analyze.js';
32
- import { daemonStatus, daemonStop } from './commands/daemon.js';
32
+ import { daemonRestart, 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';
36
+ import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js';
34
37
  const CLI_FILE = fileURLToPath(import.meta.url);
35
38
  const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
39
+ const DEFAULT_BOUND_WORKSPACE = 'bound:default';
36
40
  const BROWSER_TAB_OPTION_DESCRIPTION = 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"';
41
+ const FOLLOW_POLL_MS = 1_000;
42
+ function parseDurationMs(raw, flagName) {
43
+ if (raw === undefined || raw === null || raw === '')
44
+ return null;
45
+ const str = String(raw).trim();
46
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(str);
47
+ if (!match)
48
+ return { error: `--${flagName} must be a duration like 500ms, 30s, 2m, got "${str}"` };
49
+ const value = Number.parseFloat(match[1]);
50
+ const unit = match[2] ?? 'ms';
51
+ const multiplier = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1;
52
+ return Math.round(value * multiplier);
53
+ }
54
+ function timestampFromRaw(value) {
55
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
56
+ }
57
+ function toIsoTimestamp(timestamp) {
58
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
59
+ return undefined;
60
+ return new Date(timestamp).toISOString();
61
+ }
62
+ function filterByTimeWindow(items, opts, now = Date.now()) {
63
+ const sinceTs = opts.sinceMs != null ? now - opts.sinceMs : undefined;
64
+ const untilTs = opts.untilMs != null ? now - opts.untilMs : undefined;
65
+ return items.filter((item) => {
66
+ const ts = item.timestamp ?? now;
67
+ if (sinceTs !== undefined && ts < sinceTs)
68
+ return false;
69
+ if (untilTs !== undefined && ts > untilTs)
70
+ return false;
71
+ return true;
72
+ });
73
+ }
74
+ export function selectFreshByTimestamp(items, lastSeenTs) {
75
+ const fresh = items.filter((item) => Number(item.timestamp ?? 0) > lastSeenTs);
76
+ const nextSeenTs = fresh.length > 0
77
+ ? Math.max(lastSeenTs, ...fresh.map((item) => Number(item.timestamp ?? 0)).filter(Number.isFinite))
78
+ : lastSeenTs;
79
+ return { fresh, lastSeenTs: nextSeenTs };
80
+ }
37
81
  /**
38
82
  * Normalize raw capture entries (from daemon/CDP `readNetworkCapture` or
39
83
  * the JS interceptor's `window.__opencli_net`) into a consistent shape.
@@ -69,13 +113,15 @@ async function captureNetworkItems(page) {
69
113
  body,
70
114
  bodyFullSize: fullSize,
71
115
  bodyTruncated: truncated,
116
+ timestamp: timestampFromRaw(e.timestamp),
72
117
  };
73
118
  });
74
119
  }
75
120
  }
76
121
  const raw = await page.evaluate(`(function(){ var out = window.__opencli_net || []; window.__opencli_net = []; return JSON.stringify(out); })()`);
77
122
  try {
78
- return JSON.parse(raw);
123
+ const parsed = JSON.parse(raw);
124
+ return parsed.map((item) => ({ ...item, timestamp: timestampFromRaw(item.timestamp) }));
79
125
  }
80
126
  catch {
81
127
  if (process.env.OPENCLI_VERBOSE)
@@ -85,9 +131,12 @@ async function captureNetworkItems(page) {
85
131
  }
86
132
  /** Drop static-resource / telemetry noise so agents see only API-shaped traffic. */
87
133
  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));
134
+ return items.filter((r) => {
135
+ const ct = r.ct?.toLowerCase() ?? '';
136
+ return ((ct.includes('json') || ct.includes('xml') || ct.includes('text/plain') || ct.includes('javascript')) &&
137
+ !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
138
+ !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
139
+ });
91
140
  }
92
141
  /** Exit codes by network error code — usage errors vs runtime failures. */
93
142
  const NETWORK_ERROR_EXIT = {
@@ -253,6 +302,9 @@ async function resolveBrowserTargetInSession(page, targetPage, opts) {
253
302
  throw new Error(`Target tab ${candidate} is not part of the current browser session. ` +
254
303
  'The Browser Bridge workspace may have restarted; re-run "opencli browser tab list" and choose a current target.');
255
304
  }
305
+ function getBrowserScope(workspace, contextId) {
306
+ return contextId ? `${contextId}:${workspace}` : workspace;
307
+ }
256
308
  async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPACE) {
257
309
  const defaultPage = loadBrowserTargetState(scope)?.defaultPage?.trim();
258
310
  if (!defaultPage)
@@ -260,19 +312,21 @@ async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPAC
260
312
  return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
261
313
  }
262
314
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
263
- async function getBrowserPage(targetPage) {
315
+ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId) {
264
316
  const { BrowserBridge } = await import('./browser/index.js');
265
317
  const bridge = new BrowserBridge();
266
318
  const envTimeout = process.env.OPENCLI_BROWSER_TIMEOUT;
267
319
  const idleTimeout = envTimeout ? parseInt(envTimeout, 10) : undefined;
268
320
  const page = await bridge.connect({
269
321
  timeout: 30,
270
- workspace: DEFAULT_BROWSER_WORKSPACE,
322
+ workspace,
323
+ ...(contextId && { contextId }),
271
324
  ...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
272
325
  });
326
+ const targetScope = getBrowserScope(workspace, contextId);
273
327
  const resolvedTargetPage = targetPage
274
- ? await resolveBrowserTargetInSession(page, targetPage, { scope: DEFAULT_BROWSER_WORKSPACE, source: 'explicit' })
275
- : await resolveStoredBrowserTarget(page, DEFAULT_BROWSER_WORKSPACE);
328
+ ? await resolveBrowserTargetInSession(page, targetPage, { scope: targetScope, source: 'explicit' })
329
+ : await resolveStoredBrowserTarget(page, targetScope);
276
330
  if (resolvedTargetPage) {
277
331
  if (!page.setActivePage) {
278
332
  throw new Error('This browser session does not support explicit tab targeting');
@@ -290,11 +344,38 @@ function getBrowserTargetId(command) {
290
344
  const opts = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
291
345
  return typeof opts.tab === 'string' && opts.tab.trim() ? opts.tab.trim() : undefined;
292
346
  }
347
+ function getCommandOption(command, option) {
348
+ let current = command;
349
+ while (current) {
350
+ const opts = current.opts();
351
+ if (Object.prototype.hasOwnProperty.call(opts, option) && opts[option] !== undefined)
352
+ return opts[option];
353
+ current = current.parent;
354
+ }
355
+ return undefined;
356
+ }
357
+ function getBrowserWorkspace(command) {
358
+ const raw = getCommandOption(command, 'workspace');
359
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : DEFAULT_BROWSER_WORKSPACE;
360
+ }
361
+ function getBrowserContextId(command) {
362
+ const raw = getCommandOption(command, 'profile');
363
+ return resolveProfileContextId(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined);
364
+ }
365
+ function getPageWorkspace(page) {
366
+ const workspace = page.workspace;
367
+ return typeof workspace === 'string' && workspace.trim() ? workspace.trim() : DEFAULT_BROWSER_WORKSPACE;
368
+ }
369
+ function getPageScope(page) {
370
+ const contextId = page.contextId;
371
+ return getBrowserScope(getPageWorkspace(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined);
372
+ }
293
373
  function resolveBrowserTabTarget(targetId, opts) {
294
374
  if (typeof targetId === 'string' && targetId.trim())
295
375
  return targetId.trim();
296
- if (typeof opts?.tab === 'string' && opts.tab.trim())
297
- return opts.tab.trim();
376
+ const tab = opts instanceof Command ? opts.opts().tab : opts?.tab;
377
+ if (typeof tab === 'string' && tab.trim())
378
+ return tab.trim();
298
379
  return undefined;
299
380
  }
300
381
  function parsePositiveIntOption(val, label, fallback) {
@@ -319,17 +400,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
319
400
  .name('opencli')
320
401
  .description('Make any website your CLI. Zero setup. AI-powered.')
321
402
  .version(PKG_VERSION)
403
+ .option('--profile <name>', 'Chrome profile/context alias for Browser Bridge commands')
322
404
  .enablePositionalOptions();
323
405
  // ── Built-in: list ────────────────────────────────────────────────────────
324
406
  program
325
407
  .command('list')
326
408
  .description('List all available CLI commands')
327
409
  .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
328
- .option('--json', 'JSON output (deprecated)')
329
410
  .action((opts) => {
330
411
  const registry = getRegistry();
331
412
  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;
413
+ const fmt = opts.format;
333
414
  const isStructured = fmt === 'json' || fmt === 'yaml';
334
415
  if (fmt !== 'table') {
335
416
  const rows = isStructured
@@ -414,6 +495,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
414
495
  // All commands wrapped in browserAction() for consistent error handling.
415
496
  const browser = program
416
497
  .command('browser')
498
+ .option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
417
499
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
418
500
  /**
419
501
  * Resolve a `<target>` (numeric ref or CSS selector) via the unified resolver.
@@ -466,7 +548,9 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
466
548
  try {
467
549
  const command = args.at(-1) instanceof Command ? args.at(-1) : undefined;
468
550
  const targetPage = getBrowserTargetId(command);
469
- const page = await getBrowserPage(targetPage);
551
+ const workspace = getBrowserWorkspace(command);
552
+ const contextId = getBrowserContextId(command);
553
+ const page = await getBrowserPage(targetPage, workspace, contextId);
470
554
  await fn(page, ...args);
471
555
  }
472
556
  catch (err) {
@@ -475,6 +559,20 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
475
559
  if (err.hint)
476
560
  log.error(`Hint: ${err.hint}`);
477
561
  }
562
+ else if (err instanceof BrowserCommandError) {
563
+ if (err.code) {
564
+ console.log(JSON.stringify({
565
+ error: {
566
+ code: err.code,
567
+ message: err.message,
568
+ ...(err.hint ? { hint: err.hint } : {}),
569
+ },
570
+ }, null, 2));
571
+ }
572
+ log.error(err.message);
573
+ if (err.hint)
574
+ log.error(`Hint: ${err.hint}`);
575
+ }
478
576
  else if (err instanceof TargetError) {
479
577
  // Agent-facing structured envelope on stdout + short human line on stderr.
480
578
  emitTargetError(err);
@@ -495,6 +593,105 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
495
593
  }
496
594
  };
497
595
  }
596
+ browser.command('bind')
597
+ .option('--domain <host>', 'Only bind a current/visible tab whose hostname matches this domain')
598
+ .option('--path-prefix <path>', 'Only bind a current/visible tab whose pathname starts with this prefix')
599
+ .option('--workspace <name>', 'Bound workspace name (must start with bound:)')
600
+ .description('Bind a bound:* workspace to the current Chrome tab/window')
601
+ .action(async (optsOrCommand, maybeCommand) => {
602
+ const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
603
+ const opts = command?.opts() ?? optsOrCommand ?? {};
604
+ const rawWorkspace = getCommandOption(command, 'workspace');
605
+ const workspace = typeof rawWorkspace === 'string' && rawWorkspace.trim()
606
+ ? rawWorkspace.trim()
607
+ : DEFAULT_BOUND_WORKSPACE;
608
+ if (!workspace.startsWith('bound:')) {
609
+ console.log(JSON.stringify({
610
+ error: {
611
+ code: 'invalid_bind_workspace',
612
+ message: `--workspace must start with "bound:", got "${workspace}"`,
613
+ hint: 'Use the default bound:default or pass --workspace bound:<name>.',
614
+ },
615
+ }, null, 2));
616
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
617
+ return;
618
+ }
619
+ try {
620
+ const { BrowserBridge } = await import('./browser/index.js');
621
+ const bridge = new BrowserBridge();
622
+ const contextId = getBrowserContextId(command);
623
+ await bridge.connect({ timeout: 30, workspace, ...(contextId && { contextId }) });
624
+ const data = await bindTab(workspace, {
625
+ ...(contextId && { contextId }),
626
+ ...(typeof opts.domain === 'string' && opts.domain.trim() ? { matchDomain: opts.domain.trim() } : {}),
627
+ ...(typeof opts.pathPrefix === 'string' && opts.pathPrefix.trim() ? { matchPathPrefix: opts.pathPrefix.trim() } : {}),
628
+ });
629
+ saveBrowserTargetState(undefined, getBrowserScope(workspace, contextId));
630
+ console.log(JSON.stringify({ workspace, ...((data && typeof data === 'object') ? data : { data }) }, null, 2));
631
+ }
632
+ catch (err) {
633
+ if (err instanceof BrowserCommandError && err.code) {
634
+ console.log(JSON.stringify({
635
+ error: {
636
+ code: err.code,
637
+ message: err.message,
638
+ ...(err.hint ? { hint: err.hint } : {}),
639
+ },
640
+ }, null, 2));
641
+ }
642
+ log.error(err instanceof Error ? err.message : String(err));
643
+ if (err instanceof BrowserCommandError && err.hint)
644
+ log.error(`Hint: ${err.hint}`);
645
+ process.exitCode = err instanceof BrowserCommandError && err.code === 'invalid_bind_workspace'
646
+ ? EXIT_CODES.USAGE_ERROR
647
+ : EXIT_CODES.GENERIC_ERROR;
648
+ }
649
+ });
650
+ browser.command('unbind')
651
+ .option('--workspace <name>', 'Bound workspace name to detach')
652
+ .description('Detach a bound:* workspace without closing the user tab/window')
653
+ .action(async (optsOrCommand, maybeCommand) => {
654
+ const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
655
+ const rawWorkspace = getCommandOption(command, 'workspace');
656
+ const workspace = typeof rawWorkspace === 'string' && rawWorkspace.trim()
657
+ ? rawWorkspace.trim()
658
+ : DEFAULT_BOUND_WORKSPACE;
659
+ if (!workspace.startsWith('bound:')) {
660
+ console.log(JSON.stringify({
661
+ error: {
662
+ code: 'invalid_bind_workspace',
663
+ message: `--workspace must start with "bound:", got "${workspace}"`,
664
+ hint: 'Use the default bound:default or pass --workspace bound:<name>.',
665
+ },
666
+ }, null, 2));
667
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
668
+ return;
669
+ }
670
+ try {
671
+ const { BrowserBridge } = await import('./browser/index.js');
672
+ const bridge = new BrowserBridge();
673
+ const contextId = getBrowserContextId(command);
674
+ await bridge.connect({ timeout: 30, workspace, ...(contextId && { contextId }) });
675
+ await sendCommand('close-window', { workspace, ...(contextId && { contextId }) });
676
+ saveBrowserTargetState(undefined, getBrowserScope(workspace, contextId));
677
+ console.log(JSON.stringify({ unbound: true, workspace }, null, 2));
678
+ }
679
+ catch (err) {
680
+ if (err instanceof BrowserCommandError && err.code) {
681
+ console.log(JSON.stringify({
682
+ error: {
683
+ code: err.code,
684
+ message: err.message,
685
+ ...(err.hint ? { hint: err.hint } : {}),
686
+ },
687
+ }, null, 2));
688
+ }
689
+ log.error(err instanceof Error ? err.message : String(err));
690
+ if (err instanceof BrowserCommandError && err.hint)
691
+ log.error(`Hint: ${err.hint}`);
692
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
693
+ }
694
+ });
498
695
  const browserTab = browser
499
696
  .command('tab')
500
697
  .description('Tab management — list, create, and close tabs in the automation window');
@@ -526,7 +723,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
526
723
  throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
527
724
  }
528
725
  await page.selectTab(resolvedTarget);
529
- saveBrowserTargetState(resolvedTarget, DEFAULT_BROWSER_WORKSPACE);
726
+ saveBrowserTargetState(resolvedTarget, getPageScope(page));
530
727
  console.log(JSON.stringify({ selected: resolvedTarget }, null, 2));
531
728
  }));
532
729
  addBrowserTabOption(browserTab.command('close')
@@ -541,15 +738,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
541
738
  throw new Error('Target tab required. Pass it as an argument or --tab <targetId>.');
542
739
  }
543
740
  const validatedTarget = await resolveBrowserTargetInSession(page, resolvedTarget, {
544
- scope: DEFAULT_BROWSER_WORKSPACE,
741
+ scope: getPageScope(page),
545
742
  source: 'explicit',
546
743
  });
547
744
  if (!validatedTarget) {
548
745
  throw new Error(`Target tab ${resolvedTarget} is not part of the current browser session.`);
549
746
  }
550
747
  await page.closeTab(validatedTarget);
551
- if (loadBrowserTargetState(DEFAULT_BROWSER_WORKSPACE)?.defaultPage === validatedTarget) {
552
- saveBrowserTargetState(undefined, DEFAULT_BROWSER_WORKSPACE);
748
+ const scope = getPageScope(page);
749
+ if (loadBrowserTargetState(scope)?.defaultPage === validatedTarget) {
750
+ saveBrowserTargetState(undefined, scope);
553
751
  }
554
752
  console.log(JSON.stringify({ closed: validatedTarget }, null, 2));
555
753
  }));
@@ -564,12 +762,17 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
564
762
  * silently dropping the body. Per-entry cap is 1 MiB and the ring is
565
763
  * capped at 200 entries, bounding worst-case in-page memory.
566
764
  */
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) => {
765
+ 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)}})()`;
766
+ addBrowserTabOption(browser.command('open').argument('<url>').option('--allow-navigate-bound', 'Allow navigating a bound user tab', false).description('Open URL in automation window'))
767
+ .action(browserAction(async (page, url, opts) => {
570
768
  // Start session-level capture before navigation (catches initial requests)
571
769
  const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
572
- await page.goto(url);
770
+ if (opts.allowNavigateBound === true) {
771
+ await page.goto(url, { allowBoundNavigation: true });
772
+ }
773
+ else {
774
+ await page.goto(url);
775
+ }
573
776
  await page.wait(2);
574
777
  // Fallback: inject JS interceptor when session capture is unavailable
575
778
  if (!hasSessionCapture) {
@@ -583,8 +786,19 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
583
786
  ...(page.getActivePage?.() ? { page: page.getActivePage?.() } : {}),
584
787
  }, null, 2));
585
788
  }));
586
- addBrowserTabOption(browser.command('back').description('Go back in browser history'))
587
- .action(browserAction(async (page) => {
789
+ addBrowserTabOption(browser.command('back').option('--allow-navigate-bound', 'Allow history navigation in a bound user tab', false).description('Go back in browser history'))
790
+ .action(browserAction(async (page, opts) => {
791
+ if (getPageWorkspace(page).startsWith('bound:') && opts.allowNavigateBound !== true) {
792
+ console.log(JSON.stringify({
793
+ error: {
794
+ code: 'bound_navigation_blocked',
795
+ message: `Workspace "${getPageWorkspace(page)}" is bound to a user tab; history navigation is blocked by default.`,
796
+ hint: 'Pass --allow-navigate-bound only if you intentionally want to navigate the bound tab.',
797
+ },
798
+ }, null, 2));
799
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
800
+ return;
801
+ }
588
802
  await page.evaluate('history.back()');
589
803
  await page.wait(2);
590
804
  console.log('Navigated back');
@@ -624,6 +838,69 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
624
838
  console.log(await page.screenshot({ format: 'png' }));
625
839
  }
626
840
  }));
841
+ addBrowserTabOption(browser.command('console'))
842
+ .option('--level <level>', 'Console level: all, error, warning, log, info, debug', 'all')
843
+ .option('--since <duration>', 'Only include messages from the last duration (for example: 30s, 2m)')
844
+ .option('--until <duration>', 'Only include messages older than the duration from now')
845
+ .option('--follow', 'Continuously print new console messages as JSON lines', false)
846
+ .description('Read recent browser console messages')
847
+ .action(browserAction(async (page, opts) => {
848
+ const sinceMs = parseDurationMs(opts.since, 'since');
849
+ const untilMs = parseDurationMs(opts.until, 'until');
850
+ if (sinceMs && typeof sinceMs === 'object') {
851
+ console.log(JSON.stringify({ error: { code: 'invalid_since', message: sinceMs.error } }, null, 2));
852
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
853
+ return;
854
+ }
855
+ if (untilMs && typeof untilMs === 'object') {
856
+ console.log(JSON.stringify({ error: { code: 'invalid_until', message: untilMs.error } }, null, 2));
857
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
858
+ return;
859
+ }
860
+ const normalize = (messages) => messages.map((message) => {
861
+ if (message && typeof message === 'object') {
862
+ const record = message;
863
+ return {
864
+ ...record,
865
+ timestamp: timestampFromRaw(record.timestamp),
866
+ };
867
+ }
868
+ return { type: 'log', text: String(message), timestamp: Date.now() };
869
+ });
870
+ const filter = (messages) => filterByTimeWindow(messages, { sinceMs, untilMs }).filter((message) => {
871
+ if (opts.level === 'all')
872
+ return true;
873
+ const type = String(message.type ?? message.level ?? '').toLowerCase();
874
+ return opts.level === 'error'
875
+ ? type === 'error' || type === 'warning'
876
+ : type === String(opts.level).toLowerCase();
877
+ });
878
+ if (opts.follow) {
879
+ let lastSeenTs = 0;
880
+ while (true) {
881
+ const messages = filter(normalize(await page.consoleMessages('all')));
882
+ const next = selectFreshByTimestamp(messages, lastSeenTs);
883
+ for (const message of next.fresh) {
884
+ console.log(JSON.stringify({
885
+ ...message,
886
+ timestamp: toIsoTimestamp(message.timestamp),
887
+ }));
888
+ }
889
+ lastSeenTs = next.lastSeenTs;
890
+ await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
891
+ }
892
+ }
893
+ const messages = filter(normalize(await page.consoleMessages(opts.level)));
894
+ console.log(JSON.stringify({
895
+ workspace: getPageWorkspace(page),
896
+ captured_at: new Date().toISOString(),
897
+ count: messages.length,
898
+ messages: messages.map((message) => ({
899
+ ...message,
900
+ timestamp: toIsoTimestamp(message.timestamp),
901
+ })),
902
+ }, null, 2));
903
+ }));
627
904
  // ── Analyze (site recon, agent-native) ──
628
905
  //
629
906
  // Mechanizes the `site-recon.md` decision tree into one CLI call. The agent
@@ -1196,14 +1473,28 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1196
1473
  .option('--all', 'Include static resources (js/css/images/telemetry)')
1197
1474
  .option('--raw', 'Emit full bodies for every entry (skip shape preview)')
1198
1475
  .option('--filter <fields>', 'Comma-separated field names; keep only entries whose body shape has ALL names as path segments')
1476
+ .option('--since <duration>', 'Only include entries from the last duration (for example: 30s, 2m)')
1477
+ .option('--until <duration>', 'Only include entries older than the duration from now')
1478
+ .option('--follow', 'Continuously print new matching entries as JSON lines', false)
1479
+ .option('--failed', 'Only include failed HTTP requests (status 0 or >= 400)', false)
1199
1480
  .option('--max-body <chars>', 'With --detail: cap the emitted body at N chars (0 = unlimited, default)', '0')
1200
1481
  .option('--ttl <ms>', 'Cache TTL in ms for --detail lookups', String(DEFAULT_TTL_MS))
1201
1482
  .description('Capture network requests as shape previews; retrieve full bodies by key')
1202
1483
  .action(browserAction(async (page, opts) => {
1203
1484
  const ttlMs = parsePositiveIntOption(opts.ttl, 'ttl', DEFAULT_TTL_MS);
1204
- const workspace = DEFAULT_BROWSER_WORKSPACE;
1485
+ const workspace = getPageWorkspace(page);
1205
1486
  const hasDetail = typeof opts.detail === 'string' && opts.detail.length > 0;
1206
1487
  const hasFilter = typeof opts.filter === 'string';
1488
+ const sinceMs = parseDurationMs(opts.since, 'since');
1489
+ const untilMs = parseDurationMs(opts.until, 'until');
1490
+ if (sinceMs && typeof sinceMs === 'object') {
1491
+ emitNetworkError('invalid_since', sinceMs.error);
1492
+ return;
1493
+ }
1494
+ if (untilMs && typeof untilMs === 'object') {
1495
+ emitNetworkError('invalid_until', untilMs.error);
1496
+ return;
1497
+ }
1207
1498
  // --detail and --filter do different things (one request by key vs. narrow
1208
1499
  // the list by shape), don't compose, and combining them has no sensible
1209
1500
  // semantic. Reject up front with a structured error instead of silently
@@ -1221,6 +1512,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1221
1512
  }
1222
1513
  filterFields = parsed.fields;
1223
1514
  }
1515
+ if (hasDetail && opts.follow) {
1516
+ emitNetworkError('invalid_args', '--follow cannot be used with --detail.');
1517
+ return;
1518
+ }
1224
1519
  // --detail short-circuits: read from cache only, no live capture needed.
1225
1520
  if (hasDetail) {
1226
1521
  const res = loadNetworkCache(workspace, { ttlMs });
@@ -1267,6 +1562,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1267
1562
  status: entry.status,
1268
1563
  ct: entry.ct,
1269
1564
  size: entry.size,
1565
+ ...(typeof entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(entry.timestamp) } : {}),
1270
1566
  shape: inferShape(entry.body),
1271
1567
  body: outputBody,
1272
1568
  };
@@ -1280,6 +1576,38 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1280
1576
  console.log(JSON.stringify(detailEnvelope, null, 2));
1281
1577
  return;
1282
1578
  }
1579
+ if (opts.follow) {
1580
+ if (!await page.startNetworkCapture?.()) {
1581
+ try {
1582
+ await page.evaluate(NETWORK_INTERCEPTOR_JS);
1583
+ }
1584
+ catch { /* non-fatal */ }
1585
+ }
1586
+ while (true) {
1587
+ const rawItems = await captureNetworkItems(page).catch((err) => {
1588
+ emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
1589
+ return [];
1590
+ });
1591
+ let items = opts.all ? rawItems : filterNetworkItems(rawItems);
1592
+ items = filterByTimeWindow(items, { sinceMs, untilMs });
1593
+ if (opts.failed)
1594
+ items = items.filter((item) => item.status === 0 || item.status >= 400);
1595
+ const keyed = assignKeys(items);
1596
+ for (const item of keyed) {
1597
+ console.log(JSON.stringify({
1598
+ key: item.key,
1599
+ timestamp: toIsoTimestamp(item.timestamp),
1600
+ method: item.method,
1601
+ status: item.status,
1602
+ url: item.url,
1603
+ ct: item.ct,
1604
+ size: item.size,
1605
+ ...(item.bodyTruncated ? { body_truncated: true } : {}),
1606
+ }));
1607
+ }
1608
+ await new Promise((resolve) => setTimeout(resolve, FOLLOW_POLL_MS));
1609
+ }
1610
+ }
1283
1611
  // Fresh capture path.
1284
1612
  let rawItems;
1285
1613
  try {
@@ -1289,7 +1617,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1289
1617
  emitNetworkError('capture_failed', `Could not read network capture: ${err.message}`);
1290
1618
  return;
1291
1619
  }
1292
- const items = opts.all ? rawItems : filterNetworkItems(rawItems);
1620
+ let items = opts.all ? rawItems : filterNetworkItems(rawItems);
1621
+ items = filterByTimeWindow(items, { sinceMs, untilMs });
1622
+ if (opts.failed)
1623
+ items = items.filter((item) => item.status === 0 || item.status >= 400);
1293
1624
  const filteredOut = rawItems.length - items.length;
1294
1625
  const keyed = assignKeys(items);
1295
1626
  const cacheEntries = keyed.map((it) => ({
@@ -1300,6 +1631,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1300
1631
  size: it.size,
1301
1632
  ct: it.ct,
1302
1633
  body: it.body,
1634
+ ...(typeof it.timestamp === 'number' ? { timestamp: it.timestamp } : {}),
1303
1635
  ...(it.bodyTruncated ? { body_truncated: true } : {}),
1304
1636
  ...(it.bodyTruncated && typeof it.bodyFullSize === 'number'
1305
1637
  ? { body_full_size: it.bodyFullSize }
@@ -1342,12 +1674,16 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
1342
1674
  envelope.body_truncated_hint = 'Some bodies exceeded the capture limit; their `shape` reflects only the captured prefix.';
1343
1675
  }
1344
1676
  if (opts.raw) {
1345
- envelope.entries = visible.map((s) => s.entry);
1677
+ envelope.entries = visible.map((s) => ({
1678
+ ...s.entry,
1679
+ ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
1680
+ }));
1346
1681
  }
1347
1682
  else {
1348
1683
  envelope.entries = visible.map((s) => ({
1349
1684
  key: s.entry.key,
1350
1685
  method: s.entry.method,
1686
+ ...(typeof s.entry.timestamp === 'number' ? { timestamp: toIsoTimestamp(s.entry.timestamp) } : {}),
1351
1687
  status: s.entry.status,
1352
1688
  url: s.entry.url,
1353
1689
  ct: s.entry.ct,
@@ -1412,10 +1748,10 @@ cli({
1412
1748
  { name: 'limit', type: 'int', default: 10, help: 'Number of items' },
1413
1749
  ],
1414
1750
  columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
1415
- func: async (page, kwargs) => {
1751
+ func: async (kwargs) => {
1416
1752
  // TODO: implement data fetching
1417
1753
  // Prefer API calls (fetch) over browser automation
1418
- // page is available if browser: true
1754
+ // If you set browser: true, change this to: async (page, kwargs) => { ... }
1419
1755
  return [];
1420
1756
  },
1421
1757
  });
@@ -1437,6 +1773,7 @@ cli({
1437
1773
  .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
1438
1774
  .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
1439
1775
  .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
1776
+ .option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
1440
1777
  .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
1441
1778
  .action(async (name, opts = {}) => {
1442
1779
  try {
@@ -1474,10 +1811,11 @@ cli({
1474
1811
  const cliArgs = expandFixtureArgs(fixtureArgs);
1475
1812
  if (cliArgs.length === 0 && hasLimitArg)
1476
1813
  cliArgs.push('--limit', '3');
1477
- const argDisplay = cliArgs.join(' ');
1814
+ const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
1815
+ const argDisplay = [...cliArgs, ...traceArgs].join(' ');
1478
1816
  const invocation = resolveBrowserVerifyInvocation();
1479
1817
  // Always request JSON so we can validate structurally.
1480
- const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json'];
1818
+ const execArgs = [...invocation.args, site, command, ...cliArgs, ...traceArgs, '--format', 'json'];
1481
1819
  let rawJson;
1482
1820
  try {
1483
1821
  rawJson = execFileSync(invocation.binary, execArgs, {
@@ -1884,6 +2222,83 @@ cli({
1884
2222
  ? `✅ Reset "${site}". Now using official baseline.`
1885
2223
  : `✅ Removed custom site "${site}".`));
1886
2224
  });
2225
+ // ── Built-in: browser profile selection ──────────────────────────────────
2226
+ const profileCmd = program.command('profile').description('Manage Browser Bridge Chrome profiles');
2227
+ profileCmd
2228
+ .command('list')
2229
+ .description('List Chrome profiles connected through the Browser Bridge extension')
2230
+ .action(async () => {
2231
+ const status = await fetchDaemonStatus();
2232
+ const config = loadProfileConfig();
2233
+ const profiles = status?.profiles ?? [];
2234
+ if (!status) {
2235
+ console.log(styleText('yellow', 'Daemon is not running. Run opencli doctor after opening Chrome.'));
2236
+ return;
2237
+ }
2238
+ if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) {
2239
+ console.log(styleText('yellow', `Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`));
2240
+ console.log(styleText('dim', 'Run: opencli daemon restart'));
2241
+ return;
2242
+ }
2243
+ if (profiles.length === 0) {
2244
+ console.log(styleText('yellow', 'No Browser Bridge profiles connected.'));
2245
+ console.log(styleText('dim', 'Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.'));
2246
+ return;
2247
+ }
2248
+ const knownContextIds = new Set(profiles.map((profile) => profile.contextId));
2249
+ console.log(styleText('bold', 'Connected Browser Bridge profiles'));
2250
+ console.log();
2251
+ for (const profile of profiles) {
2252
+ const alias = aliasForContextId(config, profile.contextId);
2253
+ const defaultMark = config.defaultContextId === profile.contextId ? styleText('green', ' default') : '';
2254
+ const aliasText = alias ? ` ${styleText('cyan', alias)}` : '';
2255
+ const version = profile.extensionVersion ? ` v${profile.extensionVersion}` : ' version unknown';
2256
+ console.log(` ${profile.contextId}${aliasText}${defaultMark} — connected${version}`);
2257
+ }
2258
+ const disconnectedAliases = Object.entries(config.aliases)
2259
+ .filter(([, contextId]) => !knownContextIds.has(contextId));
2260
+ if (disconnectedAliases.length > 0 || (config.defaultContextId && !knownContextIds.has(config.defaultContextId))) {
2261
+ console.log();
2262
+ console.log(styleText('dim', 'Disconnected saved profiles:'));
2263
+ const shown = new Set();
2264
+ for (const [alias, contextId] of disconnectedAliases) {
2265
+ shown.add(contextId);
2266
+ console.log(styleText('dim', ` ${contextId} ${alias} — not connected`));
2267
+ }
2268
+ if (config.defaultContextId && !shown.has(config.defaultContextId) && !knownContextIds.has(config.defaultContextId)) {
2269
+ console.log(styleText('dim', ` ${config.defaultContextId} — default, not connected`));
2270
+ }
2271
+ }
2272
+ });
2273
+ profileCmd
2274
+ .command('rename')
2275
+ .description('Assign a local alias to a connected Browser Bridge profile')
2276
+ .argument('<contextId>', 'Profile contextId from opencli profile list')
2277
+ .argument('<alias>', 'Local alias, e.g. work or personal')
2278
+ .action((contextId, alias) => {
2279
+ try {
2280
+ renameProfile(contextId, alias);
2281
+ console.log(`Profile ${contextId} is now aliased as ${styleText('cyan', alias)}.`);
2282
+ }
2283
+ catch (err) {
2284
+ console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
2285
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
2286
+ }
2287
+ });
2288
+ profileCmd
2289
+ .command('use')
2290
+ .description('Set the default Browser Bridge profile for future commands')
2291
+ .argument('<profile>', 'Profile alias or contextId')
2292
+ .action((profile) => {
2293
+ try {
2294
+ const config = setDefaultProfile(profile);
2295
+ console.log(`Default Browser Bridge profile: ${styleText('cyan', config.defaultContextId ?? profile)}`);
2296
+ }
2297
+ catch (err) {
2298
+ console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
2299
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
2300
+ }
2301
+ });
1887
2302
  // ── Built-in: daemon ──────────────────────────────────────────────────────
1888
2303
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
1889
2304
  daemonCmd
@@ -1894,9 +2309,16 @@ cli({
1894
2309
  .command('stop')
1895
2310
  .description('Stop the daemon')
1896
2311
  .action(async () => { await daemonStop(); });
2312
+ daemonCmd
2313
+ .command('restart')
2314
+ .description('Restart the daemon')
2315
+ .action(async () => { await daemonRestart(); });
1897
2316
  // ── External CLIs ─────────────────────────────────────────────────────────
1898
2317
  const externalClis = loadExternalClis();
1899
- program
2318
+ const externalCmd = program
2319
+ .command('external')
2320
+ .description('Manage external CLI passthrough commands');
2321
+ externalCmd
1900
2322
  .command('install')
1901
2323
  .description('Install an external CLI')
1902
2324
  .argument('<name>', 'Name of the external CLI')
@@ -1909,7 +2331,7 @@ cli({
1909
2331
  }
1910
2332
  installExternalCli(ext);
1911
2333
  });
1912
- program
2334
+ externalCmd
1913
2335
  .command('register')
1914
2336
  .description('Register an external CLI')
1915
2337
  .argument('<name>', 'Name of the CLI')
@@ -1919,6 +2341,26 @@ cli({
1919
2341
  .action((name, opts) => {
1920
2342
  registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc });
1921
2343
  });
2344
+ externalCmd
2345
+ .command('list')
2346
+ .description('List registered external CLIs')
2347
+ .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
2348
+ .action((opts) => {
2349
+ const rows = loadExternalClis().map((ext) => ({
2350
+ name: ext.name,
2351
+ binary: ext.binary,
2352
+ installed: isBinaryInstalled(ext.binary),
2353
+ description: ext.description ?? '',
2354
+ homepage: ext.homepage ?? '',
2355
+ tags: ext.tags?.join(', ') ?? '',
2356
+ }));
2357
+ renderOutput(rows, {
2358
+ fmt: opts.format,
2359
+ columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
2360
+ title: 'opencli/external/list',
2361
+ source: 'opencli external list',
2362
+ });
2363
+ });
1922
2364
  function passthroughExternal(name, parsedArgs) {
1923
2365
  const args = parsedArgs ?? (() => {
1924
2366
  const idx = process.argv.indexOf(name);
@@ -1965,12 +2407,12 @@ cli({
1965
2407
  registerAllCommands(program, siteGroups);
1966
2408
  // ── Unknown command fallback ──────────────────────────────────────────────
1967
2409
  // Security: do NOT auto-discover and register arbitrary system binaries.
1968
- // Only explicitly registered external CLIs (via `opencli register`) are allowed.
2410
+ // Only explicitly registered external CLIs are allowed.
1969
2411
  program.on('command:*', (operands) => {
1970
2412
  const binary = operands[0];
1971
2413
  console.error(styleText('red', `error: unknown command '${binary}'`));
1972
2414
  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.`));
2415
+ console.error(styleText('dim', ` Tip: '${binary}' exists on your PATH. Use 'opencli external register ${binary}' to add it as an external CLI.`));
1974
2416
  }
1975
2417
  program.outputHelp();
1976
2418
  process.exitCode = EXIT_CODES.USAGE_ERROR;