@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -1,97 +1,22 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
2
4
  import * as path from 'node:path';
3
- const { mockExploreUrl, mockRenderExploreSummary, mockGenerateVerifiedFromUrl, mockRenderGenerateVerifiedSummary, mockRecordSession, mockRenderRecordSummary, mockCascadeProbe, mockRenderCascadeResult, mockGetBrowserFactory, mockBrowserSession, } = vi.hoisted(() => ({
4
- mockExploreUrl: vi.fn(),
5
- mockRenderExploreSummary: vi.fn(),
6
- mockGenerateVerifiedFromUrl: vi.fn(),
7
- mockRenderGenerateVerifiedSummary: vi.fn(),
8
- mockRecordSession: vi.fn(),
9
- mockRenderRecordSummary: vi.fn(),
10
- mockCascadeProbe: vi.fn(),
11
- mockRenderCascadeResult: vi.fn(),
12
- mockGetBrowserFactory: vi.fn(() => ({ name: 'BrowserFactory' })),
13
- mockBrowserSession: vi.fn(),
5
+ const { mockBrowserConnect, mockBrowserClose, browserState, } = vi.hoisted(() => ({
6
+ mockBrowserConnect: vi.fn(),
7
+ mockBrowserClose: vi.fn(),
8
+ browserState: { page: null },
14
9
  }));
15
- vi.mock('./explore.js', () => ({
16
- exploreUrl: mockExploreUrl,
17
- renderExploreSummary: mockRenderExploreSummary,
18
- }));
19
- vi.mock('./generate-verified.js', () => ({
20
- generateVerifiedFromUrl: mockGenerateVerifiedFromUrl,
21
- renderGenerateVerifiedSummary: mockRenderGenerateVerifiedSummary,
22
- }));
23
- vi.mock('./record.js', () => ({
24
- recordSession: mockRecordSession,
25
- renderRecordSummary: mockRenderRecordSummary,
26
- }));
27
- vi.mock('./cascade.js', () => ({
28
- cascadeProbe: mockCascadeProbe,
29
- renderCascadeResult: mockRenderCascadeResult,
30
- }));
31
- vi.mock('./runtime.js', () => ({
32
- getBrowserFactory: mockGetBrowserFactory,
33
- browserSession: mockBrowserSession,
34
- }));
35
- import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js';
36
- describe('built-in browser commands verbose wiring', () => {
37
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
38
- beforeEach(() => {
39
- delete process.env.OPENCLI_VERBOSE;
40
- process.exitCode = undefined;
41
- mockExploreUrl.mockReset().mockResolvedValue({ ok: true });
42
- mockRenderExploreSummary.mockReset().mockReturnValue('explore-summary');
43
- mockGenerateVerifiedFromUrl.mockReset().mockResolvedValue({ status: 'success' });
44
- mockRenderGenerateVerifiedSummary.mockReset().mockReturnValue('generate-summary');
45
- mockRecordSession.mockReset().mockResolvedValue({ candidateCount: 1 });
46
- mockRenderRecordSummary.mockReset().mockReturnValue('record-summary');
47
- mockCascadeProbe.mockReset().mockResolvedValue({ ok: true });
48
- mockRenderCascadeResult.mockReset().mockReturnValue('cascade-summary');
49
- mockGetBrowserFactory.mockClear();
50
- mockBrowserSession.mockReset().mockImplementation(async (_factory, fn) => {
51
- const page = {
52
- goto: vi.fn(),
53
- wait: vi.fn(),
54
- };
55
- return fn(page);
56
- });
57
- });
58
- it('enables OPENCLI_VERBOSE for explore via the real CLI command', async () => {
59
- const program = createProgram('', '');
60
- await program.parseAsync(['node', 'opencli', 'explore', 'https://example.com', '-v']);
61
- expect(process.env.OPENCLI_VERBOSE).toBe('1');
62
- expect(mockExploreUrl).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ workspace: 'explore:example.com' }));
63
- });
64
- it('enables OPENCLI_VERBOSE for generate via the real CLI command', async () => {
65
- const program = createProgram('', '');
66
- await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com', '-v']);
67
- expect(process.env.OPENCLI_VERBOSE).toBe('1');
68
- expect(mockGenerateVerifiedFromUrl).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com', workspace: 'generate:example.com', noRegister: false }));
69
- });
70
- it('passes --no-register through the real CLI command', async () => {
71
- const program = createProgram('', '');
72
- await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com', '--no-register']);
73
- expect(mockGenerateVerifiedFromUrl).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com', workspace: 'generate:example.com', noRegister: true }));
74
- });
75
- it('enables OPENCLI_VERBOSE for record via the real CLI command', async () => {
76
- const program = createProgram('', '');
77
- await program.parseAsync(['node', 'opencli', 'record', 'https://example.com', '-v']);
78
- expect(process.env.OPENCLI_VERBOSE).toBe('1');
79
- expect(mockRecordSession).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.com' }));
80
- });
81
- it('enables OPENCLI_VERBOSE for cascade via the real CLI command', async () => {
82
- const program = createProgram('', '');
83
- await program.parseAsync(['node', 'opencli', 'cascade', 'https://example.com', '-v']);
84
- expect(process.env.OPENCLI_VERBOSE).toBe('1');
85
- expect(mockBrowserSession).toHaveBeenCalled();
86
- expect(mockCascadeProbe).toHaveBeenCalledWith(expect.any(Object), 'https://example.com');
87
- });
88
- it('leaves OPENCLI_VERBOSE unset when verbose is omitted', async () => {
89
- const program = createProgram('', '');
90
- await program.parseAsync(['node', 'opencli', 'explore', 'https://example.com']);
91
- expect(process.env.OPENCLI_VERBOSE).toBeUndefined();
92
- });
93
- consoleLogSpy.mockClear();
10
+ vi.mock('./browser/index.js', () => {
11
+ mockBrowserConnect.mockImplementation(async () => browserState.page);
12
+ return {
13
+ BrowserBridge: class {
14
+ connect = mockBrowserConnect;
15
+ close = mockBrowserClose;
16
+ },
17
+ };
94
18
  });
19
+ import { createProgram, findPackageRoot, resolveBrowserVerifyInvocation } from './cli.js';
95
20
  describe('resolveBrowserVerifyInvocation', () => {
96
21
  it('prefers the built entry declared in package metadata', () => {
97
22
  const projectRoot = path.join('repo-root');
@@ -156,6 +81,158 @@ describe('resolveBrowserVerifyInvocation', () => {
156
81
  });
157
82
  });
158
83
  });
84
+ describe('browser tab targeting commands', () => {
85
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
86
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
87
+ function getBrowserStateFile(cacheDir) {
88
+ return path.join(cacheDir, 'browser-state', 'browser_default.json');
89
+ }
90
+ beforeEach(() => {
91
+ process.exitCode = undefined;
92
+ process.env.OPENCLI_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tab-state-'));
93
+ consoleLogSpy.mockClear();
94
+ stderrSpy.mockClear();
95
+ mockBrowserConnect.mockClear();
96
+ mockBrowserClose.mockReset().mockResolvedValue(undefined);
97
+ browserState.page = {
98
+ goto: vi.fn().mockResolvedValue(undefined),
99
+ wait: vi.fn().mockResolvedValue(undefined),
100
+ setActivePage: vi.fn(),
101
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
102
+ getCurrentUrl: vi.fn().mockResolvedValue('https://one.example'),
103
+ startNetworkCapture: vi.fn().mockResolvedValue(true),
104
+ evaluate: vi.fn().mockResolvedValue({ ok: true }),
105
+ tabs: vi.fn().mockResolvedValue([
106
+ { index: 0, page: 'tab-1', url: 'https://one.example', title: 'one', active: true },
107
+ { index: 1, page: 'tab-2', url: 'https://two.example', title: 'two', active: false },
108
+ ]),
109
+ selectTab: vi.fn().mockResolvedValue(undefined),
110
+ newTab: vi.fn().mockResolvedValue('tab-3'),
111
+ closeTab: vi.fn().mockResolvedValue(undefined),
112
+ frames: vi.fn().mockResolvedValue([
113
+ { index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' },
114
+ ]),
115
+ evaluateInFrame: vi.fn().mockResolvedValue('inside frame'),
116
+ readNetworkCapture: vi.fn().mockResolvedValue([]),
117
+ };
118
+ });
119
+ it('binds browser commands to an explicit target tab via --tab', async () => {
120
+ const program = createProgram('', '');
121
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-2', 'document.title']);
122
+ expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2');
123
+ expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
124
+ });
125
+ it('rejects an explicit --tab target that is no longer in the current session', async () => {
126
+ browserState.page = {
127
+ setActivePage: vi.fn(),
128
+ getActivePage: vi.fn(),
129
+ tabs: vi.fn().mockResolvedValue([]),
130
+ evaluate: vi.fn(),
131
+ };
132
+ const program = createProgram('', '');
133
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--tab', 'tab-stale', 'document.title']);
134
+ expect(process.exitCode).toBeDefined();
135
+ expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
136
+ expect(browserState.page?.evaluate).not.toHaveBeenCalled();
137
+ expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session');
138
+ });
139
+ it('lists tabs with target IDs via browser tab list', async () => {
140
+ const program = createProgram('', '');
141
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'list']);
142
+ expect(browserState.page?.tabs).toHaveBeenCalledTimes(1);
143
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"');
144
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-2"');
145
+ });
146
+ it('creates a new tab and prints its target ID', async () => {
147
+ const program = createProgram('', '');
148
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']);
149
+ expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example');
150
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-3"');
151
+ });
152
+ it('prints the resolved target ID when browser open creates or navigates a tab', async () => {
153
+ const program = createProgram('', '');
154
+ await program.parseAsync(['node', 'opencli', 'browser', 'open', 'https://example.com']);
155
+ expect(browserState.page?.goto).toHaveBeenCalledWith('https://example.com');
156
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://one.example"');
157
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"');
158
+ });
159
+ it('lists cross-origin frames via browser frames', async () => {
160
+ const program = createProgram('', '');
161
+ await program.parseAsync(['node', 'opencli', 'browser', 'frames']);
162
+ expect(browserState.page?.frames).toHaveBeenCalledTimes(1);
163
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"frameId": "frame-1"');
164
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://x.example/embed"');
165
+ });
166
+ it('routes browser eval --frame through frame-targeted evaluation', async () => {
167
+ const program = createProgram('', '');
168
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', '--frame', '0', 'document.title']);
169
+ expect(browserState.page?.evaluateInFrame).toHaveBeenCalledWith('document.title', 0);
170
+ expect(browserState.page?.evaluate).not.toHaveBeenCalled();
171
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('inside frame');
172
+ });
173
+ it('does not promote a newly created tab to the persisted default target', async () => {
174
+ const program = createProgram('', '');
175
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'new', 'https://three.example']);
176
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']);
177
+ expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example');
178
+ expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
179
+ expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
180
+ });
181
+ it('persists an explicitly selected tab as the default target for later untargeted commands', async () => {
182
+ const program = createProgram('', '');
183
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']);
184
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']);
185
+ expect(browserState.page?.selectTab).toHaveBeenCalledWith('tab-2');
186
+ expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2');
187
+ expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
188
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"selected": "tab-2"');
189
+ });
190
+ it('clears a saved default target when it is no longer present in the current session', async () => {
191
+ const cacheDir = String(process.env.OPENCLI_CACHE_DIR);
192
+ const program = createProgram('', '');
193
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']);
194
+ expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(true);
195
+ browserState.page = {
196
+ setActivePage: vi.fn(),
197
+ getActivePage: vi.fn(),
198
+ tabs: vi.fn().mockResolvedValue([]),
199
+ evaluate: vi.fn().mockResolvedValue({ ok: true }),
200
+ readNetworkCapture: vi.fn().mockResolvedValue([]),
201
+ };
202
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']);
203
+ expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
204
+ expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
205
+ expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(false);
206
+ });
207
+ it('clears the persisted default target when that tab is closed', async () => {
208
+ const program = createProgram('', '');
209
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'select', 'tab-2']);
210
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']);
211
+ vi.mocked(browserState.page?.setActivePage).mockClear();
212
+ vi.mocked(browserState.page?.evaluate).mockClear();
213
+ await program.parseAsync(['node', 'opencli', 'browser', 'eval', 'document.title']);
214
+ expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2');
215
+ expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
216
+ expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
217
+ });
218
+ it('closes a tab by target ID', async () => {
219
+ const program = createProgram('', '');
220
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-2']);
221
+ expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2');
222
+ expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"closed": "tab-2"');
223
+ });
224
+ it('rejects closing a stale tab target ID that is no longer in the current session', async () => {
225
+ browserState.page = {
226
+ tabs: vi.fn().mockResolvedValue([]),
227
+ closeTab: vi.fn(),
228
+ };
229
+ const program = createProgram('', '');
230
+ await program.parseAsync(['node', 'opencli', 'browser', 'tab', 'close', 'tab-stale']);
231
+ expect(process.exitCode).toBeDefined();
232
+ expect(browserState.page?.closeTab).not.toHaveBeenCalled();
233
+ expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session');
234
+ });
235
+ });
159
236
  describe('findPackageRoot', () => {
160
237
  it('walks up from dist/src to the package root', () => {
161
238
  const packageRoot = path.join('repo-root');
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { Command } from 'commander';
13
13
  import { type CliCommand } from './registry.js';
14
- export declare function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown;
15
14
  /**
16
15
  * Register a single CliCommand as a Commander subcommand.
17
16
  */
@@ -15,22 +15,8 @@ import { fullName, getRegistry } from './registry.js';
15
15
  import { formatRegistryHelpText } from './serialization.js';
16
16
  import { render as renderOutput } from './output.js';
17
17
  import { executeCommand, prepareCommandArgs } from './execution.js';
18
- import { CliError, EXIT_CODES, ArgumentError, toEnvelope, } from './errors.js';
18
+ import { CliError, EXIT_CODES, toEnvelope, } from './errors.js';
19
19
  import { isDiagnosticEnabled } from './diagnostic.js';
20
- export function normalizeArgValue(argType, value, name) {
21
- if (argType !== 'bool' && argType !== 'boolean')
22
- return value;
23
- if (typeof value === 'boolean')
24
- return value;
25
- if (value == null || value === '')
26
- return false;
27
- const normalized = String(value).trim().toLowerCase();
28
- if (normalized === 'true')
29
- return true;
30
- if (normalized === 'false')
31
- return false;
32
- throw new ArgumentError(`"${name}" must be either "true" or "false".`);
33
- }
34
20
  /**
35
21
  * Register a single CliCommand as a Commander subcommand.
36
22
  */
@@ -83,7 +69,7 @@ export function registerCommandToProgram(siteCmd, cmd) {
83
69
  const camelName = arg.name.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
84
70
  const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
85
71
  if (v !== undefined)
86
- rawKwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
72
+ rawKwargs[arg.name] = v;
87
73
  }
88
74
  const kwargs = prepareCommandArgs(cmd, rawKwargs);
89
75
  const verbose = optionsRecord.verbose === true;
@@ -61,7 +61,7 @@ describe('commanderAdapter arg passing', () => {
61
61
  const siteCmd = program.command('paperreview');
62
62
  registerCommandToProgram(siteCmd, cmd);
63
63
  await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'maybe']);
64
- // normalizeArgValue validates bools eagerly; executeCommand should not be reached
64
+ // prepareCommandArgs validates bools before dispatch; executeCommand should not be reached
65
65
  expect(mockExecuteCommand).not.toHaveBeenCalled();
66
66
  });
67
67
  });
@@ -1,5 +1,7 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ export declare function daemonStatus(): Promise<void>;
5
7
  export declare function daemonStop(): Promise<void>;
@@ -1,9 +1,29 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ import { styleText } from 'node:util';
5
7
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
8
+ import { formatDuration } from '../download/progress.js';
6
9
  import { log } from '../logger.js';
10
+ export async function daemonStatus() {
11
+ const status = await fetchDaemonStatus();
12
+ if (!status) {
13
+ console.log(`Daemon: ${styleText('dim', 'not running')}`);
14
+ return;
15
+ }
16
+ const extensionLabel = !status.extensionConnected
17
+ ? styleText('yellow', 'disconnected')
18
+ : status.extensionVersion
19
+ ? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
20
+ : `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
21
+ console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
22
+ console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
23
+ console.log(`Extension: ${extensionLabel}`);
24
+ console.log(`Memory: ${status.memoryMB} MB`);
25
+ console.log(`Port: ${status.port}`);
26
+ }
7
27
  export async function daemonStop() {
8
28
  const status = await fetchDaemonStatus();
9
29
  if (!status) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
3
3
  fetchDaemonStatusMock: vi.fn(),
4
4
  requestDaemonShutdownMock: vi.fn(),
@@ -7,7 +7,70 @@ vi.mock('../browser/daemon-client.js', () => ({
7
7
  fetchDaemonStatus: fetchDaemonStatusMock,
8
8
  requestDaemonShutdown: requestDaemonShutdownMock,
9
9
  }));
10
- import { daemonStop } from './daemon.js';
10
+ import { daemonStatus, daemonStop } from './daemon.js';
11
+ describe('daemonStatus', () => {
12
+ let stdoutSpy;
13
+ beforeEach(() => {
14
+ stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
15
+ fetchDaemonStatusMock.mockReset();
16
+ requestDaemonShutdownMock.mockReset();
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ it('reports "not running" when daemon is unreachable', async () => {
22
+ fetchDaemonStatusMock.mockResolvedValue(null);
23
+ await daemonStatus();
24
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
25
+ });
26
+ it('shows daemon info when running', async () => {
27
+ fetchDaemonStatusMock.mockResolvedValue({
28
+ ok: true,
29
+ pid: 12345,
30
+ uptime: 3661,
31
+ extensionConnected: true,
32
+ extensionVersion: '1.6.8',
33
+ pending: 0,
34
+ memoryMB: 64,
35
+ port: 19825,
36
+ });
37
+ await daemonStatus();
38
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
39
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
40
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
41
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
42
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
43
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
44
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
45
+ });
46
+ it('shows disconnected when extension is not connected', async () => {
47
+ fetchDaemonStatusMock.mockResolvedValue({
48
+ ok: true,
49
+ pid: 99,
50
+ uptime: 120,
51
+ extensionConnected: false,
52
+ pending: 0,
53
+ memoryMB: 32,
54
+ port: 19825,
55
+ });
56
+ await daemonStatus();
57
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
58
+ });
59
+ it('shows version unknown when the connected extension does not report one', async () => {
60
+ fetchDaemonStatusMock.mockResolvedValue({
61
+ ok: true,
62
+ pid: 99,
63
+ uptime: 120,
64
+ extensionConnected: true,
65
+ extensionVersion: undefined,
66
+ pending: 0,
67
+ memoryMB: 32,
68
+ port: 19825,
69
+ });
70
+ await daemonStatus();
71
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('version unknown'));
72
+ });
73
+ });
11
74
  describe('daemonStop', () => {
12
75
  let stderrSpy;
13
76
  beforeEach(() => {
@@ -11,11 +11,8 @@ export const BUILTIN_COMMANDS = [
11
11
  'list',
12
12
  'validate',
13
13
  'verify',
14
- 'explore',
15
- 'probe', // alias for explore
16
- 'synthesize',
17
- 'generate',
18
- 'cascade',
14
+ 'browser',
15
+ 'tab',
19
16
  'doctor',
20
17
  'plugin',
21
18
  'install',
@@ -23,6 +23,7 @@ import { WebSocketServer, WebSocket } from 'ws';
23
23
  import { DEFAULT_DAEMON_PORT } from './constants.js';
24
24
  import { EXIT_CODES } from './errors.js';
25
25
  import { log } from './logger.js';
26
+ import { PKG_VERSION } from './version.js';
26
27
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
27
28
  // ─── State ───────────────────────────────────────────────────────────
28
29
  let extensionWs = null;
@@ -110,6 +111,7 @@ async function handleRequest(req, res) {
110
111
  ok: true,
111
112
  pid: process.pid,
112
113
  uptime,
114
+ daemonVersion: PKG_VERSION,
113
115
  extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
114
116
  extensionVersion,
115
117
  extensionCompatRange,
@@ -152,6 +154,14 @@ async function handleRequest(req, res) {
152
154
  const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
153
155
  ? body.timeout * 1000
154
156
  : 120000;
157
+ if (pending.has(body.id)) {
158
+ jsonResponse(res, 409, {
159
+ id: body.id,
160
+ ok: false,
161
+ error: 'Duplicate command id already pending; retry',
162
+ });
163
+ return;
164
+ }
155
165
  const result = await new Promise((resolve, reject) => {
156
166
  const timer = setTimeout(() => {
157
167
  pending.delete(body.id);
@@ -18,6 +18,7 @@ export type DoctorReport = {
18
18
  cliVersion?: string;
19
19
  daemonRunning: boolean;
20
20
  daemonFlaky?: boolean;
21
+ daemonVersion?: string;
21
22
  extensionConnected: boolean;
22
23
  extensionFlaky?: boolean;
23
24
  extensionVersion?: string;
@@ -87,6 +87,7 @@ export async function runBrowserDoctor(opts = {}) {
87
87
  const sessions = opts.sessions && health.state === 'ready'
88
88
  ? await listSessions()
89
89
  : undefined;
90
+ const extensionVersion = health.status?.extensionVersion;
90
91
  const issues = [];
91
92
  if (daemonFlaky) {
92
93
  issues.push('Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
@@ -100,16 +101,33 @@ export async function runBrowserDoctor(opts = {}) {
100
101
  'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
101
102
  }
102
103
  else if (daemonRunning && !extensionConnected) {
103
- issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
104
- 'Please install the opencli Browser Bridge extension:\n' +
105
- ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
106
- ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
107
- ' 3. Click "Load unpacked" select the extension folder');
104
+ const daemonVersion = health.status?.daemonVersion;
105
+ const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
106
+ if (isStale) {
107
+ const reason = daemonVersion
108
+ ? `daemon v${daemonVersion} CLI v${opts.cliVersion}`
109
+ : `daemon predates version reporting, CLI is v${opts.cliVersion}`;
110
+ issues.push(`Stale daemon detected: ${reason}.\n` +
111
+ 'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
112
+ ' Quick fix: opencli daemon stop && opencli doctor');
113
+ }
114
+ else {
115
+ issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
116
+ 'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
117
+ 'If the extension is not installed:\n' +
118
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
119
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
120
+ ' 3. Click "Load unpacked" → select the extension folder');
121
+ }
122
+ }
123
+ if (extensionConnected && !extensionVersion) {
124
+ issues.push('Extension is connected but did not report a version.\n' +
125
+ ' This usually means an outdated Browser Bridge extension.\n' +
126
+ ' Reload or reinstall the extension from: https://github.com/jackwener/opencli/releases');
108
127
  }
109
128
  if (connectivity && !connectivity.ok) {
110
129
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
111
130
  }
112
- const extensionVersion = health.status?.extensionVersion;
113
131
  const extensionCompatRange = health.status?.extensionCompatRange;
114
132
  if (extensionVersion && opts.cliVersion && extensionCompatRange) {
115
133
  if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
@@ -137,6 +155,7 @@ export async function runBrowserDoctor(opts = {}) {
137
155
  cliVersion: opts.cliVersion,
138
156
  daemonRunning,
139
157
  daemonFlaky,
158
+ daemonVersion: health.status?.daemonVersion,
140
159
  extensionConnected,
141
160
  extensionFlaky,
142
161
  extensionVersion,
@@ -154,16 +173,20 @@ export function renderBrowserDoctorReport(report) {
154
173
  : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
155
174
  const daemonLabel = report.daemonFlaky
156
175
  ? 'unstable (running during live check, then stopped)'
157
- : report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running';
176
+ : report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
158
177
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
159
178
  // Extension status
160
- const extIcon = report.extensionFlaky
179
+ const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
161
180
  ? styleText('yellow', '[WARN]')
162
181
  : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
163
182
  const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
164
183
  ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
165
184
  : '';
166
- const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint : '';
185
+ const extVersion = !report.extensionConnected
186
+ ? ''
187
+ : report.extensionVersion
188
+ ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint
189
+ : styleText('dim', ' (version unknown)');
167
190
  const extLabel = report.extensionFlaky
168
191
  ? 'unstable (connected during live check, then disconnected)'
169
192
  : report.extensionConnected ? 'connected' : 'not connected';