@jackwener/opencli 1.7.4 → 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 (126) hide show
  1. package/README.md +71 -49
  2. package/README.zh-CN.md +73 -60
  3. package/cli-manifest.json +3261 -1758
  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/deepseek/ask.js +74 -0
  8. package/clis/deepseek/history.js +25 -0
  9. package/clis/deepseek/new.js +20 -0
  10. package/clis/deepseek/read.js +22 -0
  11. package/clis/deepseek/status.js +24 -0
  12. package/clis/deepseek/utils.js +208 -0
  13. package/clis/eastmoney/_secid.js +78 -0
  14. package/clis/eastmoney/announcement.js +52 -0
  15. package/clis/eastmoney/convertible.js +73 -0
  16. package/clis/eastmoney/etf.js +65 -0
  17. package/clis/eastmoney/holders.js +78 -0
  18. package/clis/eastmoney/index-board.js +96 -0
  19. package/clis/eastmoney/kline.js +87 -0
  20. package/clis/eastmoney/kuaixun.js +54 -0
  21. package/clis/eastmoney/longhu.js +67 -0
  22. package/clis/eastmoney/money-flow.js +78 -0
  23. package/clis/eastmoney/northbound.js +57 -0
  24. package/clis/eastmoney/quote.js +107 -0
  25. package/clis/eastmoney/rank.js +94 -0
  26. package/clis/eastmoney/sectors.js +76 -0
  27. package/clis/google-scholar/search.js +58 -0
  28. package/clis/google-scholar/search.test.js +23 -0
  29. package/clis/gov-law/commands.test.js +39 -0
  30. package/clis/gov-law/recent.js +22 -0
  31. package/clis/gov-law/search.js +41 -0
  32. package/clis/gov-law/shared.js +51 -0
  33. package/clis/gov-policy/commands.test.js +27 -0
  34. package/clis/gov-policy/recent.js +47 -0
  35. package/clis/gov-policy/search.js +48 -0
  36. package/clis/nowcoder/companies.js +23 -0
  37. package/clis/nowcoder/creators.js +27 -0
  38. package/clis/nowcoder/detail.js +61 -0
  39. package/clis/nowcoder/experience.js +36 -0
  40. package/clis/nowcoder/hot.js +24 -0
  41. package/clis/nowcoder/jobs.js +21 -0
  42. package/clis/nowcoder/notifications.js +29 -0
  43. package/clis/nowcoder/papers.js +40 -0
  44. package/clis/nowcoder/practice.js +37 -0
  45. package/clis/nowcoder/recommend.js +30 -0
  46. package/clis/nowcoder/referral.js +39 -0
  47. package/clis/nowcoder/salary.js +40 -0
  48. package/clis/nowcoder/search.js +49 -0
  49. package/clis/nowcoder/suggest.js +33 -0
  50. package/clis/nowcoder/topics.js +27 -0
  51. package/clis/nowcoder/trending.js +25 -0
  52. package/clis/twitter/list-add.js +337 -0
  53. package/clis/twitter/list-add.test.js +15 -0
  54. package/clis/twitter/list-remove.js +297 -0
  55. package/clis/twitter/list-remove.test.js +14 -0
  56. package/clis/twitter/list-tweets.js +185 -0
  57. package/clis/twitter/list-tweets.test.js +108 -0
  58. package/clis/twitter/lists.js +134 -47
  59. package/clis/twitter/lists.test.js +105 -38
  60. package/clis/wanfang/search.js +66 -0
  61. package/clis/wanfang/search.test.js +23 -0
  62. package/clis/web/read.js +1 -1
  63. package/clis/weixin/download.js +3 -2
  64. package/clis/xiaohongshu/publish.js +149 -28
  65. package/clis/xiaohongshu/publish.test.js +319 -6
  66. package/clis/xiaoyuzhou/download.js +8 -4
  67. package/clis/xiaoyuzhou/download.test.js +23 -13
  68. package/clis/xiaoyuzhou/episode.js +9 -4
  69. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  70. package/clis/xiaoyuzhou/podcast.js +9 -4
  71. package/clis/xiaoyuzhou/utils.js +0 -40
  72. package/clis/xiaoyuzhou/utils.test.js +15 -75
  73. package/clis/zsxq/dynamics.js +1 -1
  74. package/clis/zsxq/utils.js +6 -3
  75. package/clis/zsxq/utils.test.js +31 -0
  76. package/dist/src/browser/base-page.d.ts +1 -1
  77. package/dist/src/browser/bridge.d.ts +1 -0
  78. package/dist/src/browser/bridge.js +1 -1
  79. package/dist/src/browser/cdp.js +1 -1
  80. package/dist/src/browser/daemon-client.d.ts +6 -4
  81. package/dist/src/browser/daemon-client.js +6 -1
  82. package/dist/src/browser/daemon-client.test.js +40 -1
  83. package/dist/src/browser/dom-snapshot.js +7 -2
  84. package/dist/src/browser/page.d.ts +14 -4
  85. package/dist/src/browser/page.js +48 -7
  86. package/dist/src/browser/page.test.js +97 -0
  87. package/dist/src/cli.js +227 -150
  88. package/dist/src/cli.test.js +167 -90
  89. package/dist/src/commanderAdapter.d.ts +0 -1
  90. package/dist/src/commanderAdapter.js +2 -16
  91. package/dist/src/commanderAdapter.test.js +1 -1
  92. package/dist/src/completion-shared.js +2 -5
  93. package/dist/src/daemon.js +8 -0
  94. package/dist/src/download/article-download.d.ts +1 -0
  95. package/dist/src/download/article-download.js +3 -0
  96. package/dist/src/download/article-download.test.js +39 -0
  97. package/dist/src/plugin.d.ts +1 -8
  98. package/dist/src/plugin.js +1 -27
  99. package/dist/src/plugin.test.js +1 -59
  100. package/dist/src/registry.d.ts +1 -0
  101. package/dist/src/registry.js +3 -2
  102. package/dist/src/registry.test.js +22 -0
  103. package/dist/src/types.d.ts +14 -5
  104. package/package.json +1 -1
  105. package/clis/twitter/lists-parser.js +0 -77
  106. package/clis/twitter/lists.d.ts +0 -5
  107. package/dist/src/cascade.d.ts +0 -46
  108. package/dist/src/cascade.js +0 -135
  109. package/dist/src/explore.d.ts +0 -99
  110. package/dist/src/explore.js +0 -402
  111. package/dist/src/generate-verified.d.ts +0 -105
  112. package/dist/src/generate-verified.js +0 -696
  113. package/dist/src/generate-verified.test.js +0 -925
  114. package/dist/src/generate.d.ts +0 -46
  115. package/dist/src/generate.js +0 -117
  116. package/dist/src/record.d.ts +0 -96
  117. package/dist/src/record.js +0 -657
  118. package/dist/src/record.test.d.ts +0 -1
  119. package/dist/src/record.test.js +0 -293
  120. package/dist/src/skill-generate.d.ts +0 -30
  121. package/dist/src/skill-generate.js +0 -75
  122. package/dist/src/skill-generate.test.d.ts +0 -1
  123. package/dist/src/skill-generate.test.js +0 -173
  124. package/dist/src/synthesize.d.ts +0 -97
  125. package/dist/src/synthesize.js +0 -208
  126. /package/dist/src/{generate-verified.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
  });
@@ -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',
@@ -154,6 +154,14 @@ async function handleRequest(req, res) {
154
154
  const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0
155
155
  ? body.timeout * 1000
156
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
+ }
157
165
  const result = await new Promise((resolve, reject) => {
158
166
  const timer = setTimeout(() => {
159
167
  pending.delete(body.id);
@@ -44,6 +44,7 @@ export interface ArticleDownloadResult {
44
44
  publish_time: string;
45
45
  status: string;
46
46
  size: string;
47
+ saved: string;
47
48
  }
48
49
  /**
49
50
  * Download an article to Markdown with optional image localization.
@@ -129,6 +129,7 @@ export async function downloadArticle(data, options) {
129
129
  publish_time: '-',
130
130
  status: 'failed — no title',
131
131
  size: '-',
132
+ saved: '-',
132
133
  }];
133
134
  }
134
135
  if (!data.contentHtml) {
@@ -138,6 +139,7 @@ export async function downloadArticle(data, options) {
138
139
  publish_time: data.publishTime || '-',
139
140
  status: 'failed — no content',
140
141
  size: '-',
142
+ saved: '-',
141
143
  }];
142
144
  }
143
145
  // Convert HTML to Markdown
@@ -174,5 +176,6 @@ export async function downloadArticle(data, options) {
174
176
  publish_time: data.publishTime || '-',
175
177
  status: 'success',
176
178
  size: formatBytes(size),
179
+ saved: filePath,
177
180
  }];
178
181
  }
@@ -0,0 +1,39 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { downloadArticle } from './article-download.js';
6
+ const tempDirs = [];
7
+ afterEach(() => {
8
+ for (const dir of tempDirs) {
9
+ try {
10
+ fs.rmSync(dir, { recursive: true, force: true });
11
+ }
12
+ catch {
13
+ // Ignore cleanup errors in tests.
14
+ }
15
+ }
16
+ tempDirs.length = 0;
17
+ });
18
+ describe('downloadArticle', () => {
19
+ it('returns the saved markdown file path on success', async () => {
20
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-'));
21
+ tempDirs.push(tempDir);
22
+ const result = await downloadArticle({
23
+ title: 'Test Article',
24
+ author: 'Author',
25
+ publishTime: '2026-04-20 12:00:00',
26
+ sourceUrl: 'https://example.com/article',
27
+ contentHtml: '<p>Hello world</p>',
28
+ }, {
29
+ output: tempDir,
30
+ downloadImages: false,
31
+ });
32
+ expect(result).toHaveLength(1);
33
+ expect(result[0].status).toBe('success');
34
+ expect(result[0].saved).toMatch(new RegExp(`^${tempDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
35
+ expect(path.extname(result[0].saved)).toBe('.md');
36
+ expect(fs.existsSync(result[0].saved)).toBe(true);
37
+ expect(fs.readFileSync(result[0].saved, 'utf8')).toContain('Hello world');
38
+ });
39
+ });
@@ -60,13 +60,6 @@ declare function resolveStoredPluginSource(lockEntry: LockEntry | undefined, plu
60
60
  */
61
61
  type MoveDirFsOps = Pick<typeof fs, 'renameSync' | 'cpSync' | 'rmSync'>;
62
62
  declare function moveDir(src: string, dest: string, fsOps?: MoveDirFsOps): void;
63
- type PromoteDirFsOps = MoveDirFsOps & Pick<typeof fs, 'existsSync' | 'mkdirSync'>;
64
- /**
65
- * Promote a prepared staging directory into its final location.
66
- * The final path is only exposed after the directory has been fully prepared.
67
- */
68
- declare function promoteDir(stagingDir: string, dest: string, fsOps?: PromoteDirFsOps): void;
69
- declare function replaceDir(stagingDir: string, dest: string, fsOps?: PromoteDirFsOps): void;
70
63
  export interface ValidationResult {
71
64
  valid: boolean;
72
65
  errors: string[];
@@ -149,4 +142,4 @@ declare function parseSource(source: string): ParsedSource | null;
149
142
  */
150
143
  export declare function resolveEsbuildBin(): string | null;
151
144
  declare function resolveHostOpencliRoot(startFile?: string): string;
152
- export { resolveHostOpencliRoot as _resolveHostOpencliRoot, resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, promoteDir as _promoteDir, replaceDir as _replaceDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };
145
+ export { resolveHostOpencliRoot as _resolveHostOpencliRoot, resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };
@@ -159,32 +159,6 @@ function createSiblingTempPath(dest, kind) {
159
159
  const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
160
160
  return path.join(path.dirname(dest), `.${path.basename(dest)}.${kind}-${suffix}`);
161
161
  }
162
- /**
163
- * Promote a prepared staging directory into its final location.
164
- * The final path is only exposed after the directory has been fully prepared.
165
- */
166
- function promoteDir(stagingDir, dest, fsOps = fs) {
167
- if (fsOps.existsSync(dest)) {
168
- throw new PluginError(`Destination already exists: ${dest}`);
169
- }
170
- fsOps.mkdirSync(path.dirname(dest), { recursive: true });
171
- const tempDest = createSiblingTempPath(dest, 'tmp');
172
- try {
173
- moveDir(stagingDir, tempDest, fsOps);
174
- fsOps.renameSync(tempDest, dest);
175
- }
176
- catch (err) {
177
- try {
178
- fsOps.rmSync(tempDest, { recursive: true, force: true });
179
- }
180
- catch { }
181
- throw err;
182
- }
183
- }
184
- function replaceDir(stagingDir, dest, fsOps = fs) {
185
- const replacement = beginReplaceDir(stagingDir, dest, fsOps);
186
- replacement.finalize();
187
- }
188
162
  function cloneRepoToTemp(cloneUrl) {
189
163
  const tmpCloneDir = path.join(os.tmpdir(), `opencli-clone-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
190
164
  try {
@@ -1268,4 +1242,4 @@ function transpilePluginTs(pluginDir) {
1268
1242
  log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
1269
1243
  }
1270
1244
  }
1271
- export { resolveHostOpencliRoot as _resolveHostOpencliRoot, resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, promoteDir as _promoteDir, replaceDir as _replaceDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };
1245
+ export { resolveHostOpencliRoot as _resolveHostOpencliRoot, resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };
@@ -12,7 +12,7 @@ const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
12
12
  mockExecFileSync: vi.fn(),
13
13
  mockExecSync: vi.fn(),
14
14
  }));
15
- const { _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, _promoteDir, _replaceDir, installPlugin, listPlugins, _readLockFile, _readLockFileWithWriter, _resolveEsbuildBin, _resolveHostOpencliRoot, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _writeLockFileWithFs, _isSymlinkSync, _getMonoreposDir, getLockFilePath, _installLocalPlugin, _isLocalPluginSource, _moveDir, _resolvePluginSource, _resolveStoredPluginSource, _toStoredPluginSource, _toLocalPluginSource, } = pluginModule;
15
+ const { _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, installPlugin, listPlugins, _readLockFile, _readLockFileWithWriter, _resolveEsbuildBin, _resolveHostOpencliRoot, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _writeLockFileWithFs, _isSymlinkSync, _getMonoreposDir, getLockFilePath, _installLocalPlugin, _isLocalPluginSource, _moveDir, _resolvePluginSource, _resolveStoredPluginSource, _toStoredPluginSource, _toLocalPluginSource, } = pluginModule;
16
16
  describe('parseSource', () => {
17
17
  it('parses github:user/repo format', () => {
18
18
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
@@ -924,64 +924,6 @@ describe('moveDir', () => {
924
924
  expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
925
925
  });
926
926
  });
927
- describe('promoteDir', () => {
928
- it('cleans up temporary publish dir when final rename fails', () => {
929
- const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
930
- const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
931
- const publishErr = new Error('publish failed');
932
- const existsSync = vi.fn(() => false);
933
- const mkdirSync = vi.fn(() => undefined);
934
- const cpSync = vi.fn(() => undefined);
935
- const rmSync = vi.fn(() => undefined);
936
- const renameSync = vi.fn((src, _target) => {
937
- if (String(src) === staging)
938
- return;
939
- throw publishErr;
940
- });
941
- expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
942
- const tempDest = renameSync.mock.calls[0][1];
943
- expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
944
- expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
945
- expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
946
- });
947
- });
948
- describe('replaceDir', () => {
949
- it('rolls back the original destination when swap fails', () => {
950
- const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
951
- const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
952
- const publishErr = new Error('swap failed');
953
- const existingPaths = new Set([dest]);
954
- const existsSync = vi.fn((p) => existingPaths.has(String(p)));
955
- const mkdirSync = vi.fn(() => undefined);
956
- const cpSync = vi.fn(() => undefined);
957
- const rmSync = vi.fn(() => undefined);
958
- const renameSync = vi.fn((src, target) => {
959
- if (String(src) === staging) {
960
- existingPaths.add(String(target));
961
- return;
962
- }
963
- if (String(src) === dest) {
964
- existingPaths.delete(dest);
965
- existingPaths.add(String(target));
966
- return;
967
- }
968
- if (String(target) === dest)
969
- throw publishErr;
970
- if (existingPaths.has(String(src))) {
971
- existingPaths.delete(String(src));
972
- existingPaths.add(String(target));
973
- }
974
- });
975
- expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
976
- const tempDest = renameSync.mock.calls[0][1];
977
- const backupDest = renameSync.mock.calls[1][1];
978
- expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
979
- expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
980
- expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
981
- expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
982
- expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
983
- });
984
- });
985
927
  describe('installPlugin transactional staging', () => {
986
928
  const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
987
929
  const standaloneName = '__test-transactional-standalone__';
@@ -4,6 +4,7 @@
4
4
  import type { IPage } from './types.js';
5
5
  export declare enum Strategy {
6
6
  PUBLIC = "public",
7
+ LOCAL = "local",
7
8
  COOKIE = "cookie",
8
9
  HEADER = "header",
9
10
  INTERCEPT = "intercept",
@@ -4,6 +4,7 @@
4
4
  export var Strategy;
5
5
  (function (Strategy) {
6
6
  Strategy["PUBLIC"] = "public";
7
+ Strategy["LOCAL"] = "local";
7
8
  Strategy["COOKIE"] = "cookie";
8
9
  Strategy["HEADER"] = "header";
9
10
  Strategy["INTERCEPT"] = "intercept";
@@ -58,13 +59,13 @@ export function strategyLabel(cmd) {
58
59
  */
59
60
  function normalizeCommand(cmd) {
60
61
  const strategy = cmd.strategy ?? (cmd.browser === false ? Strategy.PUBLIC : Strategy.COOKIE);
61
- const browser = cmd.browser ?? (strategy !== Strategy.PUBLIC);
62
+ const browser = cmd.browser ?? (strategy !== Strategy.PUBLIC && strategy !== Strategy.LOCAL);
62
63
  let navigateBefore = cmd.navigateBefore;
63
64
  if (navigateBefore === undefined) {
64
65
  if ((strategy === Strategy.COOKIE || strategy === Strategy.HEADER) && cmd.domain) {
65
66
  navigateBefore = `https://${cmd.domain}`;
66
67
  }
67
- else if (strategy !== Strategy.PUBLIC) {
68
+ else if (strategy !== Strategy.PUBLIC && strategy !== Strategy.LOCAL) {
68
69
  // Non-PUBLIC without domain: needs authenticated browser context
69
70
  // but no specific pre-navigation URL. `true` signals this to
70
71
  // shouldUseBrowserSession without triggering resolvePreNav.
@@ -43,6 +43,17 @@ describe('cli() registration', () => {
43
43
  });
44
44
  expect(cmd.strategy).toBe(Strategy.PUBLIC);
45
45
  });
46
+ it('preserves LOCAL strategy on registration', () => {
47
+ const cmd = cli({
48
+ site: 'test-registry',
49
+ name: 'local-strategy',
50
+ description: 'reads local credentials',
51
+ strategy: Strategy.LOCAL,
52
+ browser: false,
53
+ });
54
+ expect(cmd.strategy).toBe(Strategy.LOCAL);
55
+ expect(cmd.browser).toBe(false);
56
+ });
46
57
  it('overwrites existing command on re-registration', () => {
47
58
  cli({ site: 'test-registry', name: 'overwrite', description: 'v1' });
48
59
  cli({ site: 'test-registry', name: 'overwrite', description: 'v2' });
@@ -148,6 +159,17 @@ describe('normalizeCommand (via registerCommand)', () => {
148
159
  expect(cmd.browser).toBe(false);
149
160
  expect(cmd.navigateBefore).toBeUndefined();
150
161
  });
162
+ it('LOCAL → browser false, navigateBefore undefined', () => {
163
+ registerCommand({
164
+ site: 'test-norm', name: 'local', description: '', args: [],
165
+ strategy: Strategy.LOCAL,
166
+ });
167
+ const cmd = getRegistry().get('test-norm/local');
168
+ expect(cmd.strategy).toBe(Strategy.LOCAL);
169
+ expect(strategyLabel(cmd)).toBe('local');
170
+ expect(cmd.browser).toBe(false);
171
+ expect(cmd.navigateBefore).toBeUndefined();
172
+ });
151
173
  it('explicit navigateBefore: false overrides COOKIE + domain', () => {
152
174
  registerCommand({
153
175
  site: 'test-norm', name: 'cookie-override', description: '', args: [],