@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
@@ -2,10 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
+ import { BrowserCommandError } from './browser/daemon-client.js';
5
6
  import { TargetError } from './browser/target-errors.js';
6
- const { mockBrowserConnect, mockBrowserClose, browserState, } = vi.hoisted(() => ({
7
+ import { PKG_VERSION } from './version.js';
8
+ const { mockBrowserConnect, mockBrowserClose, mockBindTab, mockSendCommand, mockExecFileSync, browserState, } = vi.hoisted(() => ({
7
9
  mockBrowserConnect: vi.fn(),
8
10
  mockBrowserClose: vi.fn(),
11
+ mockBindTab: vi.fn(),
12
+ mockSendCommand: vi.fn(),
13
+ mockExecFileSync: vi.fn(),
9
14
  browserState: { page: null },
10
15
  }));
11
16
  vi.mock('./browser/index.js', () => {
@@ -17,7 +22,22 @@ vi.mock('./browser/index.js', () => {
17
22
  },
18
23
  };
19
24
  });
20
- import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation } from './cli.js';
25
+ vi.mock('./browser/daemon-client.js', async () => {
26
+ const actual = await vi.importActual('./browser/daemon-client.js');
27
+ return {
28
+ ...actual,
29
+ bindTab: mockBindTab,
30
+ sendCommand: mockSendCommand,
31
+ };
32
+ });
33
+ vi.mock('node:child_process', async () => {
34
+ const actual = await vi.importActual('node:child_process');
35
+ return {
36
+ ...actual,
37
+ execFileSync: mockExecFileSync,
38
+ };
39
+ });
40
+ import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation, selectFreshByTimestamp } from './cli.js';
21
41
  describe('resolveBrowserVerifyInvocation', () => {
22
42
  it('prefers the built entry declared in package metadata', () => {
23
43
  const projectRoot = path.join('repo-root');
@@ -82,6 +102,107 @@ describe('resolveBrowserVerifyInvocation', () => {
82
102
  });
83
103
  });
84
104
  });
105
+ describe('selectFreshByTimestamp', () => {
106
+ it('uses timestamp watermarks so rolled buffers still emit new messages', () => {
107
+ const first = selectFreshByTimestamp([
108
+ { timestamp: 1, text: 'a' },
109
+ { timestamp: 2, text: 'b' },
110
+ ], 0);
111
+ expect(first.fresh.map((item) => item.text)).toEqual(['a', 'b']);
112
+ expect(first.lastSeenTs).toBe(2);
113
+ const rolled = selectFreshByTimestamp([
114
+ { timestamp: 2, text: 'b' },
115
+ { timestamp: 3, text: 'c' },
116
+ ], first.lastSeenTs);
117
+ expect(rolled.fresh.map((item) => item.text)).toEqual(['c']);
118
+ expect(rolled.lastSeenTs).toBe(3);
119
+ });
120
+ });
121
+ describe('browser verify', () => {
122
+ beforeEach(() => {
123
+ process.exitCode = undefined;
124
+ mockExecFileSync.mockReset().mockReturnValue('[]');
125
+ });
126
+ it('passes --trace through to the adapter subprocess', async () => {
127
+ const originalHome = process.env.HOME;
128
+ const originalUserProfile = process.env.USERPROFILE;
129
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-trace-'));
130
+ process.env.HOME = fakeHome;
131
+ process.env.USERPROFILE = fakeHome;
132
+ try {
133
+ const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
134
+ fs.mkdirSync(adapterDir, { recursive: true });
135
+ fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
136
+ const program = createProgram('', '');
137
+ await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']);
138
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
139
+ const [, execArgs] = mockExecFileSync.mock.calls[0];
140
+ expect(execArgs.slice(-6)).toEqual(['hn', 'top', '--trace', 'retain-on-failure', '--format', 'json']);
141
+ }
142
+ finally {
143
+ if (originalHome === undefined)
144
+ delete process.env.HOME;
145
+ else
146
+ process.env.HOME = originalHome;
147
+ if (originalUserProfile === undefined)
148
+ delete process.env.USERPROFILE;
149
+ else
150
+ process.env.USERPROFILE = originalUserProfile;
151
+ fs.rmSync(fakeHome, { recursive: true, force: true });
152
+ }
153
+ });
154
+ });
155
+ describe('profile list', () => {
156
+ const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
157
+ beforeEach(() => {
158
+ process.exitCode = undefined;
159
+ stdoutSpy.mockClear();
160
+ vi.stubGlobal('fetch', vi.fn());
161
+ });
162
+ it('reports stale daemon instead of no profiles when status lacks profile support', async () => {
163
+ vi.mocked(fetch).mockResolvedValue({
164
+ ok: true,
165
+ json: async () => ({
166
+ ok: true,
167
+ pid: 123,
168
+ uptime: 1,
169
+ daemonVersion: '1.7.6',
170
+ extensionConnected: true,
171
+ extensionVersion: '1.0.3',
172
+ pending: 0,
173
+ memoryMB: 20,
174
+ port: 19825,
175
+ }),
176
+ });
177
+ const program = createProgram('', '');
178
+ await program.parseAsync(['node', 'opencli', 'profile', 'list']);
179
+ const output = stdoutSpy.mock.calls.flat().join('\n');
180
+ expect(output).toContain('stale');
181
+ expect(output).toContain('opencli daemon restart');
182
+ expect(output).not.toContain('No Browser Bridge profiles connected');
183
+ });
184
+ it('keeps the empty profile message for current daemon status with no profiles', async () => {
185
+ vi.mocked(fetch).mockResolvedValue({
186
+ ok: true,
187
+ json: async () => ({
188
+ ok: true,
189
+ pid: 123,
190
+ uptime: 1,
191
+ daemonVersion: PKG_VERSION,
192
+ extensionConnected: false,
193
+ profiles: [],
194
+ pending: 0,
195
+ memoryMB: 20,
196
+ port: 19825,
197
+ }),
198
+ });
199
+ const program = createProgram('', '');
200
+ await program.parseAsync(['node', 'opencli', 'profile', 'list']);
201
+ const output = stdoutSpy.mock.calls.flat().join('\n');
202
+ expect(output).toContain('No Browser Bridge profiles connected');
203
+ expect(output).not.toContain('opencli daemon restart');
204
+ });
205
+ });
85
206
  describe('browser tab targeting commands', () => {
86
207
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
87
208
  const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
@@ -95,6 +216,13 @@ describe('browser tab targeting commands', () => {
95
216
  stderrSpy.mockClear();
96
217
  mockBrowserConnect.mockClear();
97
218
  mockBrowserClose.mockReset().mockResolvedValue(undefined);
219
+ mockBindTab.mockReset().mockResolvedValue({
220
+ workspace: 'bound:default',
221
+ page: 'tab-2',
222
+ url: 'https://user.example/inbox',
223
+ title: 'Inbox',
224
+ });
225
+ mockSendCommand.mockReset().mockResolvedValue({ closed: true });
98
226
  browserState.page = {
99
227
  goto: vi.fn().mockResolvedValue(undefined),
100
228
  wait: vi.fn().mockResolvedValue(undefined),
@@ -104,6 +232,7 @@ describe('browser tab targeting commands', () => {
104
232
  startNetworkCapture: vi.fn().mockResolvedValue(true),
105
233
  getCookies: vi.fn().mockResolvedValue([]),
106
234
  evaluate: vi.fn().mockResolvedValue({ ok: true }),
235
+ snapshot: vi.fn().mockResolvedValue('snapshot'),
107
236
  tabs: vi.fn().mockResolvedValue([
108
237
  { index: 0, page: 'tab-1', url: 'https://one.example', title: 'one', active: true },
109
238
  { index: 1, page: 'tab-2', url: 'https://two.example', title: 'two', active: false },
@@ -127,6 +256,62 @@ describe('browser tab targeting commands', () => {
127
256
  throw new Error(`Expected string arg to console.log, got ${typeof last}`);
128
257
  return JSON.parse(last);
129
258
  }
259
+ it('binds the current Chrome tab into a bound workspace', async () => {
260
+ const program = createProgram('', '');
261
+ await program.parseAsync(['node', 'opencli', 'browser', 'bind', '--domain', 'user.example', '--path-prefix', '/inbox']);
262
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
263
+ expect(mockBindTab).toHaveBeenCalledWith('bound:default', {
264
+ matchDomain: 'user.example',
265
+ matchPathPrefix: '/inbox',
266
+ });
267
+ const out = lastJsonLog();
268
+ expect(out.workspace).toBe('bound:default');
269
+ expect(out.url).toBe('https://user.example/inbox');
270
+ });
271
+ it('rejects bind workspaces outside the bound namespace', async () => {
272
+ const program = createProgram('', '');
273
+ await program.parseAsync(['node', 'opencli', 'browser', 'bind', '--workspace', 'browser:default']);
274
+ expect(mockBrowserConnect).not.toHaveBeenCalled();
275
+ expect(mockBindTab).not.toHaveBeenCalled();
276
+ const out = lastJsonLog();
277
+ expect(out.error.code).toBe('invalid_bind_workspace');
278
+ expect(process.exitCode).toBeDefined();
279
+ });
280
+ it('runs browser commands against an explicit bound workspace', async () => {
281
+ const program = createProgram('', '');
282
+ await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', 'state']);
283
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
284
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
285
+ });
286
+ it('blocks history navigation on bound workspaces unless explicitly allowed', async () => {
287
+ browserState.page = {
288
+ ...browserState.page,
289
+ workspace: 'bound:default',
290
+ evaluate: vi.fn(),
291
+ wait: vi.fn(),
292
+ };
293
+ const program = createProgram('', '');
294
+ await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', 'back']);
295
+ expect(browserState.page?.evaluate).not.toHaveBeenCalled();
296
+ const out = lastJsonLog();
297
+ expect(out.error.code).toBe('bound_navigation_blocked');
298
+ });
299
+ it('unbinds a bound workspace through the daemon close-window command', async () => {
300
+ const program = createProgram('', '');
301
+ await program.parseAsync(['node', 'opencli', 'browser', 'unbind', '--workspace', 'bound:default']);
302
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
303
+ expect(mockSendCommand).toHaveBeenCalledWith('close-window', { workspace: 'bound:default' });
304
+ const out = lastJsonLog();
305
+ expect(out).toEqual({ unbound: true, workspace: 'bound:default' });
306
+ });
307
+ it('does not print false success when unbind fails', async () => {
308
+ mockSendCommand.mockRejectedValueOnce(new BrowserCommandError('Workspace "bound:default" is not attached to a tab.', 'bound_session_missing', 'Run bind again, then retry the browser command.'));
309
+ const program = createProgram('', '');
310
+ await program.parseAsync(['node', 'opencli', 'browser', 'unbind', '--workspace', 'bound:default']);
311
+ const out = lastJsonLog();
312
+ expect(out.error.code).toBe('bound_session_missing');
313
+ expect(process.exitCode).toBeDefined();
314
+ });
130
315
  it('binds browser commands to an explicit target tab via --tab', async () => {
131
316
  const program = createProgram('', '');
132
317
  await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-2', 'document.title']);
@@ -446,6 +631,9 @@ describe('browser network command', () => {
446
631
  function getNetworkCachePath(cacheDir) {
447
632
  return path.join(cacheDir, 'browser-network', 'browser_default.json');
448
633
  }
634
+ function getBoundNetworkCachePath(cacheDir) {
635
+ return path.join(cacheDir, 'browser-network', 'bound_default.json');
636
+ }
449
637
  function lastJsonLog() {
450
638
  const calls = consoleLogSpy.mock.calls;
451
639
  if (calls.length === 0)
@@ -473,6 +661,7 @@ describe('browser network command', () => {
473
661
  responseStatus: 200,
474
662
  responseContentType: 'application/json',
475
663
  responsePreview: JSON.stringify({ data: { user: { rest_id: '42' } } }),
664
+ timestamp: Date.now(),
476
665
  },
477
666
  {
478
667
  url: 'https://cdn.example.com/app.js',
@@ -496,6 +685,19 @@ describe('browser network command', () => {
496
685
  expect(out.entries[0]).not.toHaveProperty('body');
497
686
  expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(true);
498
687
  });
688
+ it('uses the selected browser workspace for network cache scope', async () => {
689
+ const cacheDir = String(process.env.OPENCLI_CACHE_DIR);
690
+ browserState.page = {
691
+ ...browserState.page,
692
+ workspace: 'bound:default',
693
+ };
694
+ const program = createProgram('', '');
695
+ await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', 'network']);
696
+ const out = lastJsonLog();
697
+ expect(out.workspace).toBe('bound:default');
698
+ expect(fs.existsSync(getBoundNetworkCachePath(cacheDir))).toBe(true);
699
+ expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(false);
700
+ });
499
701
  it('--all includes static resources that the default filter drops', async () => {
500
702
  const program = createProgram('', '');
501
703
  await program.parseAsync(['node', 'opencli', 'browser', 'network', '--all']);
@@ -504,11 +706,73 @@ describe('browser network command', () => {
504
706
  expect(out.entries.map((e) => e.key)).toContain('UserTweets');
505
707
  expect(out.entries.map((e) => e.key)).toContain('GET cdn.example.com/app.js');
506
708
  });
709
+ it('--failed and --since filter captured entries by status and time window', async () => {
710
+ const now = Date.now();
711
+ browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
712
+ {
713
+ url: 'https://api.example.com/new-fail',
714
+ method: 'GET',
715
+ responseStatus: 500,
716
+ responseContentType: 'application/json',
717
+ responsePreview: JSON.stringify({ error: true }),
718
+ timestamp: now,
719
+ },
720
+ {
721
+ url: 'https://api.example.com/old-fail',
722
+ method: 'GET',
723
+ responseStatus: 500,
724
+ responseContentType: 'application/json',
725
+ responsePreview: JSON.stringify({ error: true }),
726
+ timestamp: now - 180_000,
727
+ },
728
+ {
729
+ url: 'https://api.example.com/new-ok',
730
+ method: 'GET',
731
+ responseStatus: 200,
732
+ responseContentType: 'application/json',
733
+ responsePreview: JSON.stringify({ ok: true }),
734
+ timestamp: now,
735
+ },
736
+ ]);
737
+ const program = createProgram('', '');
738
+ await program.parseAsync(['node', 'opencli', 'browser', 'network', '--since', '120s', '--failed']);
739
+ const out = lastJsonLog();
740
+ expect(out.count).toBe(1);
741
+ expect(out.entries[0].url).toBe('https://api.example.com/new-fail');
742
+ expect(out.entries[0].timestamp).toMatch(/T/);
743
+ });
744
+ it('default output keeps text/javascript API responses while dropping static JS files', async () => {
745
+ browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
746
+ {
747
+ url: 'https://hw.mail.163.com/js6/s?sid=abc&func=mbox:listMessages',
748
+ method: 'POST',
749
+ responseStatus: 200,
750
+ responseContentType: 'text/javascript',
751
+ responsePreview: JSON.stringify({ messages: [{ id: 'm1', subject: 'hello' }] }),
752
+ },
753
+ {
754
+ url: 'https://cdn.example.com/app.js',
755
+ method: 'GET',
756
+ responseStatus: 200,
757
+ responseContentType: 'application/javascript',
758
+ responsePreview: '// js',
759
+ },
760
+ ]);
761
+ const program = createProgram('', '');
762
+ await program.parseAsync(['node', 'opencli', 'browser', 'network']);
763
+ const out = lastJsonLog();
764
+ expect(out.count).toBe(1);
765
+ expect(out.filtered_out).toBe(1);
766
+ expect(out.entries[0].key).toBe('POST hw.mail.163.com/js6/s');
767
+ expect(out.entries[0].ct).toBe('text/javascript');
768
+ expect(out.entries[0].shape['$.messages']).toBe('array(1)');
769
+ });
507
770
  it('--raw emits full bodies inline for every entry', async () => {
508
771
  const program = createProgram('', '');
509
772
  await program.parseAsync(['node', 'opencli', 'browser', 'network', '--raw']);
510
773
  const out = lastJsonLog();
511
774
  expect(out.entries[0].body).toEqual({ data: { user: { rest_id: '42' } } });
775
+ expect(out.entries[0].timestamp).toMatch(/T/);
512
776
  });
513
777
  it('--detail <key> returns the full body for the requested entry', async () => {
514
778
  const program = createProgram('', '');
@@ -519,6 +783,7 @@ describe('browser network command', () => {
519
783
  expect(out.key).toBe('UserTweets');
520
784
  expect(out.body).toEqual({ data: { user: { rest_id: '42' } } });
521
785
  expect(out.shape['$.data.user.rest_id']).toBe('string');
786
+ expect(out.timestamp).toMatch(/T/);
522
787
  });
523
788
  it('--detail reports key_not_found with the list of available keys', async () => {
524
789
  const program = createProgram('', '');
@@ -793,6 +1058,42 @@ describe('browser network command', () => {
793
1058
  });
794
1059
  });
795
1060
  });
1061
+ describe('browser console command', () => {
1062
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
1063
+ beforeEach(() => {
1064
+ process.exitCode = undefined;
1065
+ consoleLogSpy.mockClear();
1066
+ mockBrowserConnect.mockClear();
1067
+ mockBrowserClose.mockReset().mockResolvedValue(undefined);
1068
+ const now = Date.now();
1069
+ browserState.page = {
1070
+ setActivePage: vi.fn(),
1071
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
1072
+ tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
1073
+ consoleMessages: vi.fn().mockResolvedValue([
1074
+ { type: 'error', text: 'boom', timestamp: now },
1075
+ { type: 'log', text: 'ok', timestamp: now },
1076
+ { type: 'warning', text: 'old warning', timestamp: now - 180_000 },
1077
+ ]),
1078
+ };
1079
+ });
1080
+ function lastJsonLog() {
1081
+ const calls = consoleLogSpy.mock.calls;
1082
+ if (calls.length === 0)
1083
+ throw new Error('Expected at least one console.log call');
1084
+ const last = calls[calls.length - 1][0];
1085
+ if (typeof last !== 'string')
1086
+ throw new Error(`Expected string arg to console.log, got ${typeof last}`);
1087
+ return JSON.parse(last);
1088
+ }
1089
+ it('filters console messages by level and time window', async () => {
1090
+ const program = createProgram('', '');
1091
+ await program.parseAsync(['node', 'opencli', 'browser', 'console', '--level', 'error', '--since', '120s']);
1092
+ const out = lastJsonLog();
1093
+ expect(out.count).toBe(1);
1094
+ expect(out.messages[0]).toMatchObject({ type: 'error', text: 'boom' });
1095
+ });
1096
+ });
796
1097
  describe('browser get html command', () => {
797
1098
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
798
1099
  function lastLogArg() {
@@ -16,7 +16,6 @@ import { formatRegistryHelpText } from './serialization.js';
16
16
  import { render as renderOutput } from './output.js';
17
17
  import { executeCommand, prepareCommandArgs } from './execution.js';
18
18
  import { CliError, EXIT_CODES, toEnvelope, } from './errors.js';
19
- import { isDiagnosticEnabled } from './diagnostic.js';
20
19
  /**
21
20
  * Register a single CliCommand as a Commander subcommand.
22
21
  */
@@ -48,6 +47,7 @@ export function registerCommandToProgram(siteCmd, cmd) {
48
47
  }
49
48
  subCmd
50
49
  .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
50
+ .option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
51
51
  .option('-v, --verbose', 'Debug output', false);
52
52
  subCmd.addHelpText('after', formatRegistryHelpText(cmd));
53
53
  subCmd.action(async (...actionArgs) => {
@@ -94,7 +94,12 @@ export function registerCommandToProgram(siteCmd, cmd) {
94
94
  const replacement = cmd.replacedBy ? ` Use ${cmd.replacedBy} instead.` : '';
95
95
  log.warn(`Deprecated: ${message}${replacement}`);
96
96
  }
97
- const result = await executeCommand(cmd, kwargs, verbose, { prepared: true });
97
+ const globals = typeof subCmd.optsWithGlobals === 'function' ? subCmd.optsWithGlobals() : {};
98
+ const result = await executeCommand(cmd, kwargs, verbose, {
99
+ prepared: true,
100
+ ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}),
101
+ ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}),
102
+ });
98
103
  if (result === null || result === undefined) {
99
104
  return;
100
105
  }
@@ -116,7 +121,7 @@ export function registerCommandToProgram(siteCmd, cmd) {
116
121
  });
117
122
  }
118
123
  catch (err) {
119
- renderError(err, fullName(cmd), optionsRecord.verbose === true);
124
+ renderError(err, fullName(cmd), optionsRecord.verbose === true, optionsRecord.trace);
120
125
  process.exitCode = resolveExitCode(err);
121
126
  }
122
127
  });
@@ -128,13 +133,16 @@ function resolveExitCode(err) {
128
133
  return EXIT_CODES.GENERIC_ERROR;
129
134
  }
130
135
  // ── Error rendering ─────────────────────────────────────────────────────────
131
- /** Emit AutoFix hint for repairable adapter errors (skipped if already in diagnostic mode). */
132
- function emitAutoFixHint(envelope, cmdName) {
133
- if (isDiagnosticEnabled())
136
+ /** Emit AutoFix hint for repairable adapter errors (skipped if trace already exported). */
137
+ function emitAutoFixHint(envelope, cmdName, traceMode) {
138
+ if (traceMode === 'on' || traceMode === 'retain-on-failure')
134
139
  return envelope;
135
- return envelope + `# AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context\n# OPENCLI_DIAGNOSTIC=1 ${cmdName}\n`;
140
+ const runnable = cmdName.replace('/', ' ');
141
+ return envelope
142
+ + `# AutoFix: re-run with --trace=retain-on-failure for trace artifact\n`
143
+ + `# opencli ${runnable} --trace retain-on-failure\n`;
136
144
  }
137
- function renderError(err, cmdName, verbose) {
145
+ function renderError(err, cmdName, verbose, traceMode) {
138
146
  const envelope = toEnvelope(err);
139
147
  // In verbose mode, include stack trace for debugging
140
148
  if (verbose && err instanceof Error && err.stack) {
@@ -144,7 +152,7 @@ function renderError(err, cmdName, verbose) {
144
152
  // Append AutoFix hint for repairable errors
145
153
  const code = envelope.error.code;
146
154
  if (code === 'SELECTOR' || code === 'EMPTY_RESULT' || code === 'ADAPTER_LOAD' || code === 'UNKNOWN') {
147
- output = emitAutoFixHint(output, cmdName);
155
+ output = emitAutoFixHint(output, cmdName, traceMode);
148
156
  }
149
157
  process.stderr.write(output);
150
158
  }
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { Command } from 'commander';
3
- import { EmptyResultError, SelectorError } from './errors.js';
3
+ import { attachTraceReceipt, EmptyResultError, selectorError } from './errors.js';
4
4
  const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
5
5
  mockExecuteCommand: vi.fn(),
6
6
  mockRenderOutput: vi.fn(),
@@ -67,6 +67,13 @@ describe('commanderAdapter arg passing', () => {
67
67
  'prepare-only': 'cli',
68
68
  });
69
69
  });
70
+ it('passes explicit trace mode to executeCommand', async () => {
71
+ const program = new Command();
72
+ const siteCmd = program.command('paperreview');
73
+ registerCommandToProgram(siteCmd, cmd);
74
+ await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--trace', 'retain-on-failure']);
75
+ expect(mockExecuteCommand).toHaveBeenCalledWith(expect.objectContaining({ site: 'paperreview', name: 'submit' }), expect.objectContaining({ pdf: './paper.pdf' }), false, { prepared: true, trace: 'retain-on-failure' });
76
+ });
70
77
  it('rejects invalid bool values before calling executeCommand', async () => {
71
78
  const program = new Command();
72
79
  const siteCmd = program.command('paperreview');
@@ -269,6 +276,9 @@ describe('commanderAdapter error envelope output', () => {
269
276
  expect(output).toContain('ok: false');
270
277
  expect(output).toContain('code: EMPTY_RESULT');
271
278
  expect(output).toContain('xsec_token');
279
+ expect(output).toContain('--trace=retain-on-failure');
280
+ expect(output).toContain('opencli xiaohongshu note --trace retain-on-failure');
281
+ expect(output).not.toContain('OPENCLI_DIAGNOSTIC');
272
282
  stderrSpy.mockRestore();
273
283
  });
274
284
  it('outputs YAML error envelope for selector errors', async () => {
@@ -276,12 +286,67 @@ describe('commanderAdapter error envelope output', () => {
276
286
  const siteCmd = program.command('xiaohongshu');
277
287
  registerCommandToProgram(siteCmd, cmd);
278
288
  const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
279
- mockExecuteCommand.mockRejectedValueOnce(new SelectorError('.note-title', 'The note title selector no longer matches the current page.'));
289
+ mockExecuteCommand.mockRejectedValueOnce(selectorError('.note-title', 'The note title selector no longer matches the current page.'));
280
290
  await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
281
291
  const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
282
292
  expect(output).toContain('ok: false');
283
293
  expect(output).toContain('code: SELECTOR');
284
294
  expect(output).toContain('selector no longer matches');
295
+ expect(output).toContain('--trace=retain-on-failure');
296
+ stderrSpy.mockRestore();
297
+ });
298
+ it('does not add an AutoFix rerun hint when trace is already enabled', async () => {
299
+ const program = new Command();
300
+ const siteCmd = program.command('xiaohongshu');
301
+ registerCommandToProgram(siteCmd, cmd);
302
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
303
+ mockExecuteCommand.mockRejectedValueOnce(selectorError('.note-title'));
304
+ await program.parseAsync([
305
+ 'node',
306
+ 'opencli',
307
+ 'xiaohongshu',
308
+ 'note',
309
+ '69ca3927000000001a020fd5',
310
+ '--trace',
311
+ 'retain-on-failure',
312
+ ]);
313
+ const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
314
+ expect(output).toContain('code: SELECTOR');
315
+ expect(output).not.toContain('AutoFix: re-run');
316
+ stderrSpy.mockRestore();
317
+ });
318
+ it('includes trace metadata from the error envelope when execution attached it', async () => {
319
+ const program = new Command();
320
+ const siteCmd = program.command('xiaohongshu');
321
+ registerCommandToProgram(siteCmd, cmd);
322
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
323
+ const err = selectorError('.note-title');
324
+ attachTraceReceipt(err, {
325
+ schemaVersion: 1,
326
+ opencliVersion: '1.7.8',
327
+ traceId: 'trace-1',
328
+ traceDir: '/tmp/opencli/profiles/default/traces/trace-1',
329
+ summaryPath: '/tmp/opencli/profiles/default/traces/trace-1/summary.md',
330
+ receiptPath: '/tmp/opencli/profiles/default/traces/trace-1/receipt.json',
331
+ status: 'failure',
332
+ createdAt: '2026-05-03T00:00:00.000Z',
333
+ error: { code: 'SELECTOR', message: 'Could not find element: .note-title' },
334
+ });
335
+ mockExecuteCommand.mockRejectedValueOnce(err);
336
+ await program.parseAsync([
337
+ 'node',
338
+ 'opencli',
339
+ 'xiaohongshu',
340
+ 'note',
341
+ '69ca3927000000001a020fd5',
342
+ '--trace',
343
+ 'retain-on-failure',
344
+ ]);
345
+ const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
346
+ expect(output).toContain('trace:');
347
+ expect(output).toContain('dir: /tmp/opencli/profiles/default/traces/trace-1');
348
+ expect(output).toContain('summaryPath: /tmp/opencli/profiles/default/traces/trace-1/summary.md');
349
+ expect(output).toContain('receiptPath: /tmp/opencli/profiles/default/traces/trace-1/receipt.json');
285
350
  stderrSpy.mockRestore();
286
351
  });
287
352
  });
@@ -2,6 +2,8 @@
2
2
  * CLI commands for daemon lifecycle:
3
3
  * opencli daemon status — show daemon state
4
4
  * opencli daemon stop — graceful shutdown
5
+ * opencli daemon restart — graceful shutdown, then start a fresh daemon
5
6
  */
6
7
  export declare function daemonStatus(): Promise<void>;
7
8
  export declare function daemonStop(): Promise<void>;
9
+ export declare function daemonRestart(): Promise<void>;
@@ -2,11 +2,15 @@
2
2
  * CLI commands for daemon lifecycle:
3
3
  * opencli daemon status — show daemon state
4
4
  * opencli daemon stop — graceful shutdown
5
+ * opencli daemon restart — graceful shutdown, then start a fresh daemon
5
6
  */
6
7
  import { styleText } from 'node:util';
7
8
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
9
+ import { restartDaemon } from '../browser/daemon-lifecycle.js';
8
10
  import { formatDuration } from '../download/progress.js';
9
11
  import { log } from '../logger.js';
12
+ import { PKG_VERSION } from '../version.js';
13
+ import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
10
14
  export async function daemonStatus() {
11
15
  const status = await fetchDaemonStatus();
12
16
  if (!status) {
@@ -18,9 +22,18 @@ export async function daemonStatus() {
18
22
  : status.extensionVersion
19
23
  ? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
20
24
  : `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
21
- console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
25
+ const daemonVersion = formatDaemonVersion(status);
26
+ const stale = isDaemonStale(status, PKG_VERSION);
27
+ console.log(`Daemon: ${stale ? styleText('yellow', 'stale') : styleText('green', 'running')} (PID ${status.pid})`);
28
+ console.log(`Version: ${daemonVersion}${stale ? styleText('yellow', ` (CLI v${PKG_VERSION}; run: opencli daemon restart)`) : ''}`);
22
29
  console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
23
30
  console.log(`Extension: ${extensionLabel}`);
31
+ if (status.profiles && status.profiles.length > 0) {
32
+ console.log(`Profiles: ${status.profiles.map((profile) => {
33
+ const version = profile.extensionVersion ? ` v${profile.extensionVersion}` : '';
34
+ return `${profile.contextId}${version}`;
35
+ }).join(', ')}`);
36
+ }
24
37
  console.log(`Memory: ${status.memoryMB} MB`);
25
38
  console.log(`Port: ${status.port}`);
26
39
  }
@@ -39,3 +52,31 @@ export async function daemonStop() {
39
52
  process.exitCode = 1;
40
53
  }
41
54
  }
55
+ export async function daemonRestart() {
56
+ const before = await fetchDaemonStatus();
57
+ if (before?.profiles && before.profiles.length > 0) {
58
+ log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser profile(s); the extension should reconnect automatically.`);
59
+ }
60
+ const result = await restartDaemon();
61
+ if (!result.stopped) {
62
+ log.error('Failed to stop daemon before restart.');
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ if (!result.status) {
67
+ log.error('Daemon restart timed out before the new daemon reported status.');
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ const action = result.previousStatus ? 'restarted' : 'started';
72
+ const version = formatDaemonVersion(result.status);
73
+ log.success(`Daemon ${action} on port ${result.status.port} (${version}).`);
74
+ if (result.status.extensionConnected) {
75
+ const profiles = result.status.profiles?.length ?? 0;
76
+ const profileText = profiles > 0 ? `; profiles connected: ${profiles}` : '';
77
+ log.status(`Extension connected${profileText}.`);
78
+ }
79
+ else {
80
+ log.warn('Daemon is running, but the Browser Bridge extension has not connected yet.');
81
+ }
82
+ }