@jackwener/opencli 1.5.8 → 1.6.0

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 (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -137,6 +137,57 @@ describe('xiaohongshu note', () => {
137
137
  await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
138
138
  });
139
139
 
140
+ it('throws a token hint when the note page renders as an empty shell', async () => {
141
+ const page = createPageMock({
142
+ loginWall: false,
143
+ notFound: false,
144
+ title: '',
145
+ desc: '',
146
+ author: '',
147
+ likes: '',
148
+ collects: '',
149
+ comments: '',
150
+ tags: [],
151
+ });
152
+
153
+ try {
154
+ await command!.func!(page, { 'note-id': '69ca3927000000001a020fd5' });
155
+ throw new Error('expected xiaohongshu note to fail on an empty shell page');
156
+ } catch (error) {
157
+ expect(error).toMatchObject({
158
+ code: 'EMPTY_RESULT',
159
+ hint: expect.stringMatching(/xsec_token|full url|search_result/i),
160
+ });
161
+ }
162
+ });
163
+
164
+ it('keeps the empty-shell hint generic when the user already passed a full URL', async () => {
165
+ const page = createPageMock({
166
+ loginWall: false,
167
+ notFound: false,
168
+ title: '',
169
+ desc: '',
170
+ author: '',
171
+ likes: '',
172
+ collects: '',
173
+ comments: '',
174
+ tags: [],
175
+ });
176
+
177
+ try {
178
+ await command!.func!(page, {
179
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
180
+ });
181
+ throw new Error('expected xiaohongshu note to fail on an empty shell page');
182
+ } catch (error) {
183
+ expect(error).toMatchObject({
184
+ code: 'EMPTY_RESULT',
185
+ hint: expect.stringContaining('loaded without visible content'),
186
+ });
187
+ expect((error as { hint?: string }).hint).not.toContain('bare note ID');
188
+ }
189
+ });
190
+
140
191
  it('normalizes placeholder text to 0 for zero-count metrics', async () => {
141
192
  const page = createPageMock({
142
193
  loginWall: false, notFound: false,
@@ -21,6 +21,7 @@ cli({
21
21
  columns: ['field', 'value'],
22
22
  func: async (page, kwargs) => {
23
23
  const raw = String(kwargs['note-id']);
24
+ const isBareNoteId = !/^https?:\/\//.test(raw.trim());
24
25
  const noteId = parseNoteId(raw);
25
26
  const url = buildNoteUrl(raw);
26
27
 
@@ -68,6 +69,23 @@ cli({
68
69
  // XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
69
70
  // normalize to '0' unless the value looks numeric.
70
71
  const numOrZero = (v: string) => /^\d+/.test(v) ? v : '0';
72
+
73
+ // XHS sometimes renders an empty shell page for bare /explore/<id> visits
74
+ // when the request lacks a valid xsec_token. Title + author are always
75
+ // present on a real note, so their absence is the simplest reliable signal.
76
+ const emptyShell = !d.title && !d.author;
77
+ if (emptyShell) {
78
+ if (isBareNoteId) {
79
+ throw new EmptyResultError(
80
+ 'xiaohongshu/note',
81
+ 'Pass the full search_result URL with xsec_token, for example from `opencli xiaohongshu search`, instead of a bare note ID.',
82
+ );
83
+ }
84
+ throw new EmptyResultError(
85
+ 'xiaohongshu/note',
86
+ 'The note page loaded without visible content. Retry with a fresh URL or run with --verbose; if it persists, the page structure may have changed.',
87
+ );
88
+ }
71
89
  const rows = [
72
90
  { field: 'title', value: d.title || '' },
73
91
  { field: 'author', value: d.author || '' },
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { Command } from 'commander';
3
3
  import type { CliCommand } from './registry.js';
4
+ import { EmptyResultError, SelectorError } from './errors.js';
4
5
 
5
6
  const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
6
7
  mockExecuteCommand: vi.fn(),
@@ -153,3 +154,111 @@ describe('commanderAdapter command aliases', () => {
153
154
  expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
154
155
  });
155
156
  });
157
+
158
+ describe('commanderAdapter default formats', () => {
159
+ const cmd: CliCommand = {
160
+ site: 'gemini',
161
+ name: 'ask',
162
+ description: 'Ask Gemini',
163
+ browser: false,
164
+ args: [],
165
+ columns: ['response'],
166
+ defaultFormat: 'plain',
167
+ func: vi.fn(),
168
+ };
169
+
170
+ beforeEach(() => {
171
+ mockExecuteCommand.mockReset();
172
+ mockExecuteCommand.mockResolvedValue([{ response: 'hello' }]);
173
+ mockRenderOutput.mockReset();
174
+ delete process.env.OPENCLI_VERBOSE;
175
+ process.exitCode = undefined;
176
+ });
177
+
178
+ it('uses the command defaultFormat when the user keeps the default table format', async () => {
179
+ const program = new Command();
180
+ const siteCmd = program.command('gemini');
181
+ registerCommandToProgram(siteCmd, cmd);
182
+
183
+ await program.parseAsync(['node', 'opencli', 'gemini', 'ask']);
184
+
185
+ expect(mockRenderOutput).toHaveBeenCalledWith(
186
+ [{ response: 'hello' }],
187
+ expect.objectContaining({ fmt: 'plain' }),
188
+ );
189
+ });
190
+
191
+ it('respects an explicit user format over the command defaultFormat', async () => {
192
+ const program = new Command();
193
+ const siteCmd = program.command('gemini');
194
+ registerCommandToProgram(siteCmd, cmd);
195
+
196
+ await program.parseAsync(['node', 'opencli', 'gemini', 'ask', '--format', 'json']);
197
+
198
+ expect(mockRenderOutput).toHaveBeenCalledWith(
199
+ [{ response: 'hello' }],
200
+ expect.objectContaining({ fmt: 'json' }),
201
+ );
202
+ });
203
+ });
204
+
205
+ describe('commanderAdapter empty result hints', () => {
206
+ const cmd: CliCommand = {
207
+ site: 'xiaohongshu',
208
+ name: 'note',
209
+ description: 'Read one note',
210
+ browser: false,
211
+ args: [
212
+ { name: 'note-id', positional: true, required: true, help: 'Note ID' },
213
+ ],
214
+ func: vi.fn(),
215
+ };
216
+
217
+ beforeEach(() => {
218
+ mockExecuteCommand.mockReset();
219
+ mockRenderOutput.mockReset();
220
+ delete process.env.OPENCLI_VERBOSE;
221
+ process.exitCode = undefined;
222
+ });
223
+
224
+ it('prints the adapter hint instead of the generic outdated-adapter message', async () => {
225
+ const program = new Command();
226
+ const siteCmd = program.command('xiaohongshu');
227
+ registerCommandToProgram(siteCmd, cmd);
228
+
229
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
230
+ mockExecuteCommand.mockRejectedValueOnce(
231
+ new EmptyResultError(
232
+ 'xiaohongshu/note',
233
+ 'Pass the full search_result URL with xsec_token instead of a bare note ID.',
234
+ ),
235
+ );
236
+
237
+ await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
238
+
239
+ const output = errorSpy.mock.calls.flat().join('\n');
240
+ expect(output).toContain('xsec_token');
241
+ expect(output).not.toContain('this adapter may be outdated');
242
+
243
+ errorSpy.mockRestore();
244
+ });
245
+
246
+ it('prints selector-specific hints too', async () => {
247
+ const program = new Command();
248
+ const siteCmd = program.command('xiaohongshu');
249
+ registerCommandToProgram(siteCmd, cmd);
250
+
251
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
252
+ mockExecuteCommand.mockRejectedValueOnce(
253
+ new SelectorError('.note-title', 'The note title selector no longer matches the current page.'),
254
+ );
255
+
256
+ await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
257
+
258
+ const output = errorSpy.mock.calls.flat().join('\n');
259
+ expect(output).toContain('selector no longer matches');
260
+ expect(output).not.toContain('this adapter may be outdated');
261
+
262
+ errorSpy.mockRestore();
263
+ });
264
+ });
@@ -69,7 +69,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
69
69
  }
70
70
  }
71
71
  subCmd
72
- .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
72
+ .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
73
73
  .option('-v, --verbose', 'Debug output', false);
74
74
 
75
75
  subCmd.addHelpText('after', formatRegistryHelpText(cmd));
@@ -95,7 +95,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
95
95
  }
96
96
 
97
97
  const verbose = optionsRecord.verbose === true;
98
- const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
98
+ let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
99
99
  if (verbose) process.env.OPENCLI_VERBOSE = '1';
100
100
  if (cmd.deprecated) {
101
101
  const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
@@ -108,10 +108,14 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
108
108
  return;
109
109
  }
110
110
 
111
+ const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
112
+ if (format === 'table' && resolved.defaultFormat) {
113
+ format = resolved.defaultFormat;
114
+ }
115
+
111
116
  if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
112
117
  console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
113
118
  }
114
- const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
115
119
  renderOutput(result, {
116
120
  fmt: format,
117
121
  columns: resolved.columns,
@@ -221,7 +225,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
221
225
  if (err instanceof SelectorError || err instanceof EmptyResultError) {
222
226
  const icon = ERROR_ICONS[err.code] ?? '⚠️';
223
227
  console.error(chalk.red(`${icon} ${err.message}`));
224
- console.error(chalk.yellow('The page structure may have changed — this adapter may be outdated.'));
228
+ console.error(chalk.yellow(`→ ${err.hint ?? 'The page structure may have changed — this adapter may be outdated.'}`));
225
229
  console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
226
230
  console.error(chalk.dim(` Report: ${ISSUES_URL}`));
227
231
  return;
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
+ const {
4
+ fetchDaemonStatusMock,
5
+ requestDaemonShutdownMock,
6
+ } = vi.hoisted(() => ({
7
+ fetchDaemonStatusMock: vi.fn(),
8
+ requestDaemonShutdownMock: vi.fn(),
9
+ }));
10
+
3
11
  vi.mock('chalk', () => ({
4
12
  default: {
5
13
  green: (s: string) => s,
@@ -10,12 +18,17 @@ vi.mock('chalk', () => ({
10
18
  }));
11
19
 
12
20
  const mockConnect = vi.fn();
13
- vi.mock('../browser/mcp.js', () => ({
21
+ vi.mock('../browser/bridge.js', () => ({
14
22
  BrowserBridge: class {
15
23
  connect = mockConnect;
16
24
  },
17
25
  }));
18
26
 
27
+ vi.mock('../browser/daemon-client.js', () => ({
28
+ fetchDaemonStatus: fetchDaemonStatusMock,
29
+ requestDaemonShutdown: requestDaemonShutdownMock,
30
+ }));
31
+
19
32
  import { daemonStatus, daemonStop, daemonRestart } from './daemon.js';
20
33
 
21
34
  describe('daemon commands', () => {
@@ -25,6 +38,8 @@ describe('daemon commands', () => {
25
38
  beforeEach(() => {
26
39
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
27
40
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
41
+ fetchDaemonStatusMock.mockReset();
42
+ requestDaemonShutdownMock.mockReset();
28
43
  });
29
44
 
30
45
  afterEach(() => {
@@ -34,7 +49,7 @@ describe('daemon commands', () => {
34
49
 
35
50
  describe('daemonStatus', () => {
36
51
  it('shows "not running" when daemon is unreachable', async () => {
37
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
52
+ fetchDaemonStatusMock.mockResolvedValue(null);
38
53
 
39
54
  await daemonStatus();
40
55
 
@@ -42,7 +57,7 @@ describe('daemon commands', () => {
42
57
  });
43
58
 
44
59
  it('shows "not running" when daemon returns non-ok response', async () => {
45
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
60
+ fetchDaemonStatusMock.mockResolvedValue(null);
46
61
 
47
62
  await daemonStatus();
48
63
 
@@ -61,13 +76,7 @@ describe('daemon commands', () => {
61
76
  port: 19825,
62
77
  };
63
78
 
64
- vi.stubGlobal(
65
- 'fetch',
66
- vi.fn().mockResolvedValue({
67
- ok: true,
68
- json: () => Promise.resolve(status),
69
- }),
70
- );
79
+ fetchDaemonStatusMock.mockResolvedValue(status);
71
80
 
72
81
  await daemonStatus();
73
82
 
@@ -91,13 +100,7 @@ describe('daemon commands', () => {
91
100
  port: 19825,
92
101
  };
93
102
 
94
- vi.stubGlobal(
95
- 'fetch',
96
- vi.fn().mockResolvedValue({
97
- ok: true,
98
- json: () => Promise.resolve(status),
99
- }),
100
- );
103
+ fetchDaemonStatusMock.mockResolvedValue(status);
101
104
 
102
105
  await daemonStatus();
103
106
 
@@ -107,7 +110,7 @@ describe('daemon commands', () => {
107
110
 
108
111
  describe('daemonStop', () => {
109
112
  it('reports "not running" when daemon is unreachable', async () => {
110
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
113
+ fetchDaemonStatusMock.mockResolvedValue(null);
111
114
 
112
115
  await daemonStop();
113
116
 
@@ -115,59 +118,36 @@ describe('daemon commands', () => {
115
118
  });
116
119
 
117
120
  it('sends shutdown and reports success', async () => {
118
- const statusResponse = {
121
+ fetchDaemonStatusMock.mockResolvedValue({
119
122
  ok: true,
120
- json: () =>
121
- Promise.resolve({
122
- ok: true,
123
- pid: 12345,
124
- uptime: 100,
125
- extensionConnected: true,
126
- pending: 0,
127
- lastCliRequestTime: Date.now(),
128
- memoryMB: 50,
129
- port: 19825,
130
- }),
131
- };
132
- const shutdownResponse = { ok: true };
133
-
134
- const mockFetch = vi.fn()
135
- .mockResolvedValueOnce(statusResponse)
136
- .mockResolvedValueOnce(shutdownResponse);
137
- vi.stubGlobal('fetch', mockFetch);
123
+ pid: 12345,
124
+ uptime: 100,
125
+ extensionConnected: true,
126
+ pending: 0,
127
+ lastCliRequestTime: Date.now(),
128
+ memoryMB: 50,
129
+ port: 19825,
130
+ });
131
+ requestDaemonShutdownMock.mockResolvedValue(true);
138
132
 
139
133
  await daemonStop();
140
134
 
141
- // Verify shutdown was called with POST
142
- expect(mockFetch).toHaveBeenCalledTimes(2);
143
- const shutdownCall = mockFetch.mock.calls[1];
144
- expect(shutdownCall[0]).toContain('/shutdown');
145
- expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
146
-
135
+ expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
147
136
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
148
137
  });
149
138
 
150
139
  it('reports failure when shutdown request fails', async () => {
151
- const statusResponse = {
140
+ fetchDaemonStatusMock.mockResolvedValue({
152
141
  ok: true,
153
- json: () =>
154
- Promise.resolve({
155
- ok: true,
156
- pid: 12345,
157
- uptime: 100,
158
- extensionConnected: true,
159
- pending: 0,
160
- lastCliRequestTime: Date.now(),
161
- memoryMB: 50,
162
- port: 19825,
163
- }),
164
- };
165
- const shutdownResponse = { ok: false };
166
-
167
- const mockFetch = vi.fn()
168
- .mockResolvedValueOnce(statusResponse)
169
- .mockResolvedValueOnce(shutdownResponse);
170
- vi.stubGlobal('fetch', mockFetch);
142
+ pid: 12345,
143
+ uptime: 100,
144
+ extensionConnected: true,
145
+ pending: 0,
146
+ lastCliRequestTime: Date.now(),
147
+ memoryMB: 50,
148
+ port: 19825,
149
+ });
150
+ requestDaemonShutdownMock.mockResolvedValue(false);
171
151
 
172
152
  await daemonStop();
173
153
 
@@ -188,7 +168,7 @@ describe('daemon commands', () => {
188
168
  };
189
169
 
190
170
  it('starts daemon directly when not running', async () => {
191
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
171
+ fetchDaemonStatusMock.mockResolvedValue(null);
192
172
  mockConnect.mockResolvedValue(undefined);
193
173
 
194
174
  await daemonRestart();
@@ -198,36 +178,22 @@ describe('daemon commands', () => {
198
178
  });
199
179
 
200
180
  it('stops then starts when daemon is running', async () => {
201
- const mockFetch = vi.fn()
202
- // First call: fetchStatus in daemonRestart — daemon is running
203
- .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(statusData) })
204
- // Second call: requestShutdown — success
205
- .mockResolvedValueOnce({ ok: true })
206
- // Subsequent calls: polling fetchStatus until unreachable
207
- .mockRejectedValue(new Error('ECONNREFUSED'));
208
-
209
- vi.stubGlobal('fetch', mockFetch);
181
+ fetchDaemonStatusMock
182
+ .mockResolvedValueOnce(statusData)
183
+ .mockResolvedValueOnce(null);
184
+ requestDaemonShutdownMock.mockResolvedValue(true);
210
185
  mockConnect.mockResolvedValue(undefined);
211
186
 
212
187
  await daemonRestart();
213
188
 
214
- // Verify shutdown was called
215
- const shutdownCall = mockFetch.mock.calls[1];
216
- expect(shutdownCall[0]).toContain('/shutdown');
217
- expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
218
-
189
+ expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
219
190
  expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
220
191
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
221
192
  });
222
193
 
223
194
  it('aborts when shutdown fails', async () => {
224
- const mockFetch = vi.fn()
225
- // fetchStatus — daemon is running
226
- .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(statusData) })
227
- // requestShutdown — failure
228
- .mockResolvedValueOnce({ ok: false });
229
-
230
- vi.stubGlobal('fetch', mockFetch);
195
+ fetchDaemonStatusMock.mockResolvedValue(statusData);
196
+ requestDaemonShutdownMock.mockResolvedValue(false);
231
197
 
232
198
  await daemonRestart();
233
199
 
@@ -6,55 +6,7 @@
6
6
  */
7
7
 
8
8
  import chalk from 'chalk';
9
- import { DEFAULT_DAEMON_PORT } from '../constants.js';
10
-
11
- const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
12
- const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
13
-
14
- interface DaemonStatus {
15
- ok: boolean;
16
- pid: number;
17
- uptime: number;
18
- extensionConnected: boolean;
19
- pending: number;
20
- lastCliRequestTime: number;
21
- memoryMB: number;
22
- port: number;
23
- }
24
-
25
- async function fetchStatus(): Promise<DaemonStatus | null> {
26
- const controller = new AbortController();
27
- const timer = setTimeout(() => controller.abort(), 2000);
28
- try {
29
- const res = await fetch(`${DAEMON_URL}/status`, {
30
- headers: { 'X-OpenCLI': '1' },
31
- signal: controller.signal,
32
- });
33
- if (!res.ok) return null;
34
- return await res.json() as DaemonStatus;
35
- } catch {
36
- return null;
37
- } finally {
38
- clearTimeout(timer);
39
- }
40
- }
41
-
42
- async function requestShutdown(): Promise<boolean> {
43
- const controller = new AbortController();
44
- const timer = setTimeout(() => controller.abort(), 5000);
45
- try {
46
- const res = await fetch(`${DAEMON_URL}/shutdown`, {
47
- method: 'POST',
48
- headers: { 'X-OpenCLI': '1' },
49
- signal: controller.signal,
50
- });
51
- return res.ok;
52
- } catch {
53
- return false;
54
- } finally {
55
- clearTimeout(timer);
56
- }
57
- }
9
+ import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
58
10
 
59
11
  function formatUptime(seconds: number): string {
60
12
  const h = Math.floor(seconds / 3600);
@@ -74,7 +26,7 @@ function formatTimeSince(timestampMs: number): string {
74
26
  }
75
27
 
76
28
  export async function daemonStatus(): Promise<void> {
77
- const status = await fetchStatus();
29
+ const status = await fetchDaemonStatus();
78
30
  if (!status) {
79
31
  console.log(`Daemon: ${chalk.dim('not running')}`);
80
32
  return;
@@ -89,13 +41,13 @@ export async function daemonStatus(): Promise<void> {
89
41
  }
90
42
 
91
43
  export async function daemonStop(): Promise<void> {
92
- const status = await fetchStatus();
44
+ const status = await fetchDaemonStatus();
93
45
  if (!status) {
94
46
  console.log(chalk.dim('Daemon is not running.'));
95
47
  return;
96
48
  }
97
49
 
98
- const ok = await requestShutdown();
50
+ const ok = await requestDaemonShutdown();
99
51
  if (ok) {
100
52
  console.log(chalk.green('Daemon stopped.'));
101
53
  } else {
@@ -105,9 +57,9 @@ export async function daemonStop(): Promise<void> {
105
57
  }
106
58
 
107
59
  export async function daemonRestart(): Promise<void> {
108
- const status = await fetchStatus();
60
+ const status = await fetchDaemonStatus();
109
61
  if (status) {
110
- const ok = await requestShutdown();
62
+ const ok = await requestDaemonShutdown();
111
63
  if (!ok) {
112
64
  console.error(chalk.red('Failed to stop daemon.'));
113
65
  process.exitCode = 1;
@@ -117,12 +69,12 @@ export async function daemonRestart(): Promise<void> {
117
69
  const deadline = Date.now() + 5000;
118
70
  while (Date.now() < deadline) {
119
71
  await new Promise(r => setTimeout(r, 200));
120
- if (!(await fetchStatus())) break;
72
+ if (!(await fetchDaemonStatus())) break;
121
73
  }
122
74
  }
123
75
 
124
76
  // Import BrowserBridge to spawn a new daemon
125
- const { BrowserBridge } = await import('../browser/mcp.js');
77
+ const { BrowserBridge } = await import('../browser/bridge.js');
126
78
  const bridge = new BrowserBridge();
127
79
  try {
128
80
  console.log('Starting daemon...');
package/src/discovery.ts CHANGED
@@ -76,6 +76,28 @@ export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DI
76
76
  `${JSON.stringify({ name: 'opencli-user-runtime', private: true, type: 'module' }, null, 2)}\n`,
77
77
  ),
78
78
  ]);
79
+
80
+ // Create node_modules/@jackwener/opencli symlink so user TS CLIs can import
81
+ // from '@jackwener/opencli/registry' (the package export).
82
+ // This is needed because ~/.opencli/clis/ is outside opencli's node_modules tree.
83
+ const opencliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
84
+ const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener');
85
+ const symlinkPath = path.join(symlinkDir, 'opencli');
86
+ try {
87
+ // Only recreate if symlink is missing or points to wrong target
88
+ let needsUpdate = true;
89
+ try {
90
+ const existing = await fs.promises.readlink(symlinkPath);
91
+ if (existing === opencliRoot) needsUpdate = false;
92
+ } catch { /* doesn't exist */ }
93
+ if (needsUpdate) {
94
+ await fs.promises.mkdir(symlinkDir, { recursive: true });
95
+ try { await fs.promises.unlink(symlinkPath); } catch { /* doesn't exist */ }
96
+ await fs.promises.symlink(opencliRoot, symlinkPath, 'dir');
97
+ }
98
+ } catch {
99
+ // Non-fatal: npm-linked installs or permission issues may prevent this
100
+ }
79
101
  }
80
102
 
81
103
  /**
package/src/doctor.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * opencli doctor — diagnose browser connectivity.
3
3
  *
4
- * Simplified for the daemon-based architecture. No more token management,
5
- * MCP path discovery, or config file scanning.
4
+ * Simplified for the daemon-based architecture.
6
5
  */
7
6
 
8
7
  import chalk from 'chalk';
@@ -26,6 +25,7 @@ export type ConnectivityResult = {
26
25
  durationMs: number;
27
26
  };
28
27
 
28
+
29
29
  export type DoctorReport = {
30
30
  cliVersion?: string;
31
31
  daemonRunning: boolean;
@@ -42,11 +42,11 @@ export type DoctorReport = {
42
42
  export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
43
43
  const start = Date.now();
44
44
  try {
45
- const mcp = new BrowserBridge();
46
- const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
45
+ const bridge = new BrowserBridge();
46
+ const page = await bridge.connect({ timeout: opts?.timeout ?? 8 });
47
47
  // Try a simple eval to verify end-to-end connectivity
48
48
  await page.evaluate('1 + 1');
49
- await mcp.close();
49
+ await bridge.close();
50
50
  return { ok: true, durationMs: Date.now() - start };
51
51
  } catch (err) {
52
52
  return { ok: false, error: getErrorMessage(err), durationMs: Date.now() - start };
@@ -58,9 +58,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
58
58
  let initialStatus = await checkDaemonStatus();
59
59
  if (!initialStatus.running) {
60
60
  try {
61
- const mcp = new BrowserBridge();
62
- await mcp.connect({ timeout: 5 });
63
- await mcp.close();
61
+ const bridge = new BrowserBridge();
62
+ await bridge.connect({ timeout: 5 });
63
+ await bridge.close();
64
64
  } catch {
65
65
  // Auto-start failed; we'll report it below.
66
66
  }
@@ -94,7 +94,6 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
94
94
  if (connectivity && !connectivity.ok) {
95
95
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
96
96
  }
97
-
98
97
  if (status.extensionVersion && opts.cliVersion) {
99
98
  const extMajor = status.extensionVersion.split('.')[0];
100
99
  const cliMajor = opts.cliVersion.split('.')[0];
package/src/explore.ts CHANGED
@@ -135,7 +135,7 @@ export interface ExploreBundle {
135
135
  }
136
136
 
137
137
  /**
138
- * Parse raw network output from Playwright MCP.
138
+ * Parse raw network output from browser page.
139
139
  * Handles text format: [GET] url => [200]
140
140
  */
141
141
  function parseNetworkRequests(raw: unknown): NetworkEntry[] {