@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
@@ -112,6 +112,55 @@ describe('xiaohongshu note', () => {
112
112
  const page = createPageMock({ loginWall: false, notFound: true });
113
113
  await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
114
114
  });
115
+ it('throws a token hint when the note page renders as an empty shell', async () => {
116
+ const page = createPageMock({
117
+ loginWall: false,
118
+ notFound: false,
119
+ title: '',
120
+ desc: '',
121
+ author: '',
122
+ likes: '',
123
+ collects: '',
124
+ comments: '',
125
+ tags: [],
126
+ });
127
+ try {
128
+ await command.func(page, { 'note-id': '69ca3927000000001a020fd5' });
129
+ throw new Error('expected xiaohongshu note to fail on an empty shell page');
130
+ }
131
+ catch (error) {
132
+ expect(error).toMatchObject({
133
+ code: 'EMPTY_RESULT',
134
+ hint: expect.stringMatching(/xsec_token|full url|search_result/i),
135
+ });
136
+ }
137
+ });
138
+ it('keeps the empty-shell hint generic when the user already passed a full URL', async () => {
139
+ const page = createPageMock({
140
+ loginWall: false,
141
+ notFound: false,
142
+ title: '',
143
+ desc: '',
144
+ author: '',
145
+ likes: '',
146
+ collects: '',
147
+ comments: '',
148
+ tags: [],
149
+ });
150
+ try {
151
+ await command.func(page, {
152
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
153
+ });
154
+ throw new Error('expected xiaohongshu note to fail on an empty shell page');
155
+ }
156
+ catch (error) {
157
+ expect(error).toMatchObject({
158
+ code: 'EMPTY_RESULT',
159
+ hint: expect.stringContaining('loaded without visible content'),
160
+ });
161
+ expect(error.hint).not.toContain('bare note ID');
162
+ }
163
+ });
115
164
  it('normalizes placeholder text to 0 for zero-count metrics', async () => {
116
165
  const page = createPageMock({
117
166
  loginWall: false, notFound: false,
@@ -59,7 +59,7 @@ export function registerCommandToProgram(siteCmd, cmd) {
59
59
  }
60
60
  }
61
61
  subCmd
62
- .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
62
+ .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
63
63
  .option('-v, --verbose', 'Debug output', false);
64
64
  subCmd.addHelpText('after', formatRegistryHelpText(cmd));
65
65
  subCmd.action(async (...actionArgs) => {
@@ -84,7 +84,7 @@ export function registerCommandToProgram(siteCmd, cmd) {
84
84
  kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
85
85
  }
86
86
  const verbose = optionsRecord.verbose === true;
87
- const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
87
+ let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
88
88
  if (verbose)
89
89
  process.env.OPENCLI_VERBOSE = '1';
90
90
  if (cmd.deprecated) {
@@ -96,10 +96,13 @@ export function registerCommandToProgram(siteCmd, cmd) {
96
96
  if (result === null || result === undefined) {
97
97
  return;
98
98
  }
99
+ const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
100
+ if (format === 'table' && resolved.defaultFormat) {
101
+ format = resolved.defaultFormat;
102
+ }
99
103
  if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
100
104
  console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
101
105
  }
102
- const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
103
106
  renderOutput(result, {
104
107
  fmt: format,
105
108
  columns: resolved.columns,
@@ -209,7 +212,7 @@ async function renderError(err, cmdName, verbose) {
209
212
  if (err instanceof SelectorError || err instanceof EmptyResultError) {
210
213
  const icon = ERROR_ICONS[err.code] ?? '⚠️';
211
214
  console.error(chalk.red(`${icon} ${err.message}`));
212
- console.error(chalk.yellow('The page structure may have changed — this adapter may be outdated.'));
215
+ console.error(chalk.yellow(`→ ${err.hint ?? 'The page structure may have changed — this adapter may be outdated.'}`));
213
216
  console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
214
217
  console.error(chalk.dim(` Report: ${ISSUES_URL}`));
215
218
  return;
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { Command } from 'commander';
3
+ import { EmptyResultError, SelectorError } from './errors.js';
3
4
  const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
4
5
  mockExecuteCommand: vi.fn(),
5
6
  mockRenderOutput: vi.fn(),
@@ -124,3 +125,78 @@ describe('commanderAdapter command aliases', () => {
124
125
  expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
125
126
  });
126
127
  });
128
+ describe('commanderAdapter default formats', () => {
129
+ const cmd = {
130
+ site: 'gemini',
131
+ name: 'ask',
132
+ description: 'Ask Gemini',
133
+ browser: false,
134
+ args: [],
135
+ columns: ['response'],
136
+ defaultFormat: 'plain',
137
+ func: vi.fn(),
138
+ };
139
+ beforeEach(() => {
140
+ mockExecuteCommand.mockReset();
141
+ mockExecuteCommand.mockResolvedValue([{ response: 'hello' }]);
142
+ mockRenderOutput.mockReset();
143
+ delete process.env.OPENCLI_VERBOSE;
144
+ process.exitCode = undefined;
145
+ });
146
+ it('uses the command defaultFormat when the user keeps the default table format', async () => {
147
+ const program = new Command();
148
+ const siteCmd = program.command('gemini');
149
+ registerCommandToProgram(siteCmd, cmd);
150
+ await program.parseAsync(['node', 'opencli', 'gemini', 'ask']);
151
+ expect(mockRenderOutput).toHaveBeenCalledWith([{ response: 'hello' }], expect.objectContaining({ fmt: 'plain' }));
152
+ });
153
+ it('respects an explicit user format over the command defaultFormat', async () => {
154
+ const program = new Command();
155
+ const siteCmd = program.command('gemini');
156
+ registerCommandToProgram(siteCmd, cmd);
157
+ await program.parseAsync(['node', 'opencli', 'gemini', 'ask', '--format', 'json']);
158
+ expect(mockRenderOutput).toHaveBeenCalledWith([{ response: 'hello' }], expect.objectContaining({ fmt: 'json' }));
159
+ });
160
+ });
161
+ describe('commanderAdapter empty result hints', () => {
162
+ const cmd = {
163
+ site: 'xiaohongshu',
164
+ name: 'note',
165
+ description: 'Read one note',
166
+ browser: false,
167
+ args: [
168
+ { name: 'note-id', positional: true, required: true, help: 'Note ID' },
169
+ ],
170
+ func: vi.fn(),
171
+ };
172
+ beforeEach(() => {
173
+ mockExecuteCommand.mockReset();
174
+ mockRenderOutput.mockReset();
175
+ delete process.env.OPENCLI_VERBOSE;
176
+ process.exitCode = undefined;
177
+ });
178
+ it('prints the adapter hint instead of the generic outdated-adapter message', async () => {
179
+ const program = new Command();
180
+ const siteCmd = program.command('xiaohongshu');
181
+ registerCommandToProgram(siteCmd, cmd);
182
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
183
+ mockExecuteCommand.mockRejectedValueOnce(new EmptyResultError('xiaohongshu/note', 'Pass the full search_result URL with xsec_token instead of a bare note ID.'));
184
+ await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
185
+ const output = errorSpy.mock.calls.flat().join('\n');
186
+ expect(output).toContain('xsec_token');
187
+ expect(output).not.toContain('this adapter may be outdated');
188
+ errorSpy.mockRestore();
189
+ });
190
+ it('prints selector-specific hints too', async () => {
191
+ const program = new Command();
192
+ const siteCmd = program.command('xiaohongshu');
193
+ registerCommandToProgram(siteCmd, cmd);
194
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
195
+ mockExecuteCommand.mockRejectedValueOnce(new SelectorError('.note-title', 'The note title selector no longer matches the current page.'));
196
+ await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
197
+ const output = errorSpy.mock.calls.flat().join('\n');
198
+ expect(output).toContain('selector no longer matches');
199
+ expect(output).not.toContain('this adapter may be outdated');
200
+ errorSpy.mockRestore();
201
+ });
202
+ });
@@ -5,46 +5,7 @@
5
5
  * opencli daemon restart — stop + respawn
6
6
  */
7
7
  import chalk from 'chalk';
8
- import { DEFAULT_DAEMON_PORT } from '../constants.js';
9
- const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
10
- const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
11
- async function fetchStatus() {
12
- const controller = new AbortController();
13
- const timer = setTimeout(() => controller.abort(), 2000);
14
- try {
15
- const res = await fetch(`${DAEMON_URL}/status`, {
16
- headers: { 'X-OpenCLI': '1' },
17
- signal: controller.signal,
18
- });
19
- if (!res.ok)
20
- return null;
21
- return await res.json();
22
- }
23
- catch {
24
- return null;
25
- }
26
- finally {
27
- clearTimeout(timer);
28
- }
29
- }
30
- async function requestShutdown() {
31
- const controller = new AbortController();
32
- const timer = setTimeout(() => controller.abort(), 5000);
33
- try {
34
- const res = await fetch(`${DAEMON_URL}/shutdown`, {
35
- method: 'POST',
36
- headers: { 'X-OpenCLI': '1' },
37
- signal: controller.signal,
38
- });
39
- return res.ok;
40
- }
41
- catch {
42
- return false;
43
- }
44
- finally {
45
- clearTimeout(timer);
46
- }
47
- }
8
+ import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
48
9
  function formatUptime(seconds) {
49
10
  const h = Math.floor(seconds / 3600);
50
11
  const m = Math.floor((seconds % 3600) / 60);
@@ -65,7 +26,7 @@ function formatTimeSince(timestampMs) {
65
26
  return `${h}h ${m % 60}m ago`;
66
27
  }
67
28
  export async function daemonStatus() {
68
- const status = await fetchStatus();
29
+ const status = await fetchDaemonStatus();
69
30
  if (!status) {
70
31
  console.log(`Daemon: ${chalk.dim('not running')}`);
71
32
  return;
@@ -78,12 +39,12 @@ export async function daemonStatus() {
78
39
  console.log(`Port: ${status.port}`);
79
40
  }
80
41
  export async function daemonStop() {
81
- const status = await fetchStatus();
42
+ const status = await fetchDaemonStatus();
82
43
  if (!status) {
83
44
  console.log(chalk.dim('Daemon is not running.'));
84
45
  return;
85
46
  }
86
- const ok = await requestShutdown();
47
+ const ok = await requestDaemonShutdown();
87
48
  if (ok) {
88
49
  console.log(chalk.green('Daemon stopped.'));
89
50
  }
@@ -93,9 +54,9 @@ export async function daemonStop() {
93
54
  }
94
55
  }
95
56
  export async function daemonRestart() {
96
- const status = await fetchStatus();
57
+ const status = await fetchDaemonStatus();
97
58
  if (status) {
98
- const ok = await requestShutdown();
59
+ const ok = await requestDaemonShutdown();
99
60
  if (!ok) {
100
61
  console.error(chalk.red('Failed to stop daemon.'));
101
62
  process.exitCode = 1;
@@ -105,12 +66,12 @@ export async function daemonRestart() {
105
66
  const deadline = Date.now() + 5000;
106
67
  while (Date.now() < deadline) {
107
68
  await new Promise(r => setTimeout(r, 200));
108
- if (!(await fetchStatus()))
69
+ if (!(await fetchDaemonStatus()))
109
70
  break;
110
71
  }
111
72
  }
112
73
  // Import BrowserBridge to spawn a new daemon
113
- const { BrowserBridge } = await import('../browser/mcp.js');
74
+ const { BrowserBridge } = await import('../browser/bridge.js');
114
75
  const bridge = new BrowserBridge();
115
76
  try {
116
77
  console.log('Starting daemon...');
@@ -1,4 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
3
+ fetchDaemonStatusMock: vi.fn(),
4
+ requestDaemonShutdownMock: vi.fn(),
5
+ }));
2
6
  vi.mock('chalk', () => ({
3
7
  default: {
4
8
  green: (s) => s,
@@ -8,11 +12,15 @@ vi.mock('chalk', () => ({
8
12
  },
9
13
  }));
10
14
  const mockConnect = vi.fn();
11
- vi.mock('../browser/mcp.js', () => ({
15
+ vi.mock('../browser/bridge.js', () => ({
12
16
  BrowserBridge: class {
13
17
  connect = mockConnect;
14
18
  },
15
19
  }));
20
+ vi.mock('../browser/daemon-client.js', () => ({
21
+ fetchDaemonStatus: fetchDaemonStatusMock,
22
+ requestDaemonShutdown: requestDaemonShutdownMock,
23
+ }));
16
24
  import { daemonStatus, daemonStop, daemonRestart } from './daemon.js';
17
25
  describe('daemon commands', () => {
18
26
  let logSpy;
@@ -20,6 +28,8 @@ describe('daemon commands', () => {
20
28
  beforeEach(() => {
21
29
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
22
30
  errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
31
+ fetchDaemonStatusMock.mockReset();
32
+ requestDaemonShutdownMock.mockReset();
23
33
  });
24
34
  afterEach(() => {
25
35
  vi.restoreAllMocks();
@@ -27,12 +37,12 @@ describe('daemon commands', () => {
27
37
  });
28
38
  describe('daemonStatus', () => {
29
39
  it('shows "not running" when daemon is unreachable', async () => {
30
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
40
+ fetchDaemonStatusMock.mockResolvedValue(null);
31
41
  await daemonStatus();
32
42
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
33
43
  });
34
44
  it('shows "not running" when daemon returns non-ok response', async () => {
35
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
45
+ fetchDaemonStatusMock.mockResolvedValue(null);
36
46
  await daemonStatus();
37
47
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
38
48
  });
@@ -47,10 +57,7 @@ describe('daemon commands', () => {
47
57
  memoryMB: 64,
48
58
  port: 19825,
49
59
  };
50
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
51
- ok: true,
52
- json: () => Promise.resolve(status),
53
- }));
60
+ fetchDaemonStatusMock.mockResolvedValue(status);
54
61
  await daemonStatus();
55
62
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
56
63
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
@@ -70,66 +77,45 @@ describe('daemon commands', () => {
70
77
  memoryMB: 32,
71
78
  port: 19825,
72
79
  };
73
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
74
- ok: true,
75
- json: () => Promise.resolve(status),
76
- }));
80
+ fetchDaemonStatusMock.mockResolvedValue(status);
77
81
  await daemonStatus();
78
82
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
79
83
  });
80
84
  });
81
85
  describe('daemonStop', () => {
82
86
  it('reports "not running" when daemon is unreachable', async () => {
83
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
87
+ fetchDaemonStatusMock.mockResolvedValue(null);
84
88
  await daemonStop();
85
89
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
86
90
  });
87
91
  it('sends shutdown and reports success', async () => {
88
- const statusResponse = {
92
+ fetchDaemonStatusMock.mockResolvedValue({
89
93
  ok: true,
90
- json: () => Promise.resolve({
91
- ok: true,
92
- pid: 12345,
93
- uptime: 100,
94
- extensionConnected: true,
95
- pending: 0,
96
- lastCliRequestTime: Date.now(),
97
- memoryMB: 50,
98
- port: 19825,
99
- }),
100
- };
101
- const shutdownResponse = { ok: true };
102
- const mockFetch = vi.fn()
103
- .mockResolvedValueOnce(statusResponse)
104
- .mockResolvedValueOnce(shutdownResponse);
105
- vi.stubGlobal('fetch', mockFetch);
94
+ pid: 12345,
95
+ uptime: 100,
96
+ extensionConnected: true,
97
+ pending: 0,
98
+ lastCliRequestTime: Date.now(),
99
+ memoryMB: 50,
100
+ port: 19825,
101
+ });
102
+ requestDaemonShutdownMock.mockResolvedValue(true);
106
103
  await daemonStop();
107
- // Verify shutdown was called with POST
108
- expect(mockFetch).toHaveBeenCalledTimes(2);
109
- const shutdownCall = mockFetch.mock.calls[1];
110
- expect(shutdownCall[0]).toContain('/shutdown');
111
- expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
104
+ expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
112
105
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
113
106
  });
114
107
  it('reports failure when shutdown request fails', async () => {
115
- const statusResponse = {
108
+ fetchDaemonStatusMock.mockResolvedValue({
116
109
  ok: true,
117
- json: () => Promise.resolve({
118
- ok: true,
119
- pid: 12345,
120
- uptime: 100,
121
- extensionConnected: true,
122
- pending: 0,
123
- lastCliRequestTime: Date.now(),
124
- memoryMB: 50,
125
- port: 19825,
126
- }),
127
- };
128
- const shutdownResponse = { ok: false };
129
- const mockFetch = vi.fn()
130
- .mockResolvedValueOnce(statusResponse)
131
- .mockResolvedValueOnce(shutdownResponse);
132
- vi.stubGlobal('fetch', mockFetch);
110
+ pid: 12345,
111
+ uptime: 100,
112
+ extensionConnected: true,
113
+ pending: 0,
114
+ lastCliRequestTime: Date.now(),
115
+ memoryMB: 50,
116
+ port: 19825,
117
+ });
118
+ requestDaemonShutdownMock.mockResolvedValue(false);
133
119
  await daemonStop();
134
120
  expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
135
121
  });
@@ -146,37 +132,26 @@ describe('daemon commands', () => {
146
132
  port: 19825,
147
133
  };
148
134
  it('starts daemon directly when not running', async () => {
149
- vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
135
+ fetchDaemonStatusMock.mockResolvedValue(null);
150
136
  mockConnect.mockResolvedValue(undefined);
151
137
  await daemonRestart();
152
138
  expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
153
139
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
154
140
  });
155
141
  it('stops then starts when daemon is running', async () => {
156
- const mockFetch = vi.fn()
157
- // First call: fetchStatus in daemonRestart — daemon is running
158
- .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(statusData) })
159
- // Second call: requestShutdown — success
160
- .mockResolvedValueOnce({ ok: true })
161
- // Subsequent calls: polling fetchStatus until unreachable
162
- .mockRejectedValue(new Error('ECONNREFUSED'));
163
- vi.stubGlobal('fetch', mockFetch);
142
+ fetchDaemonStatusMock
143
+ .mockResolvedValueOnce(statusData)
144
+ .mockResolvedValueOnce(null);
145
+ requestDaemonShutdownMock.mockResolvedValue(true);
164
146
  mockConnect.mockResolvedValue(undefined);
165
147
  await daemonRestart();
166
- // Verify shutdown was called
167
- const shutdownCall = mockFetch.mock.calls[1];
168
- expect(shutdownCall[0]).toContain('/shutdown');
169
- expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
148
+ expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
170
149
  expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
171
150
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
172
151
  });
173
152
  it('aborts when shutdown fails', async () => {
174
- const mockFetch = vi.fn()
175
- // fetchStatus — daemon is running
176
- .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(statusData) })
177
- // requestShutdown — failure
178
- .mockResolvedValueOnce({ ok: false });
179
- vi.stubGlobal('fetch', mockFetch);
153
+ fetchDaemonStatusMock.mockResolvedValue(statusData);
154
+ requestDaemonShutdownMock.mockResolvedValue(false);
180
155
  await daemonRestart();
181
156
  expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
182
157
  expect(mockConnect).not.toHaveBeenCalled();
package/dist/discovery.js CHANGED
@@ -66,6 +66,33 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
66
66
  writeCompatShimIfNeeded(path.join(baseDir, 'errors.js'), `export * from '${errorsUrl}';\n`),
67
67
  writeCompatShimIfNeeded(path.join(baseDir, 'package.json'), `${JSON.stringify({ name: 'opencli-user-runtime', private: true, type: 'module' }, null, 2)}\n`),
68
68
  ]);
69
+ // Create node_modules/@jackwener/opencli symlink so user TS CLIs can import
70
+ // from '@jackwener/opencli/registry' (the package export).
71
+ // This is needed because ~/.opencli/clis/ is outside opencli's node_modules tree.
72
+ const opencliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
73
+ const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener');
74
+ const symlinkPath = path.join(symlinkDir, 'opencli');
75
+ try {
76
+ // Only recreate if symlink is missing or points to wrong target
77
+ let needsUpdate = true;
78
+ try {
79
+ const existing = await fs.promises.readlink(symlinkPath);
80
+ if (existing === opencliRoot)
81
+ needsUpdate = false;
82
+ }
83
+ catch { /* doesn't exist */ }
84
+ if (needsUpdate) {
85
+ await fs.promises.mkdir(symlinkDir, { recursive: true });
86
+ try {
87
+ await fs.promises.unlink(symlinkPath);
88
+ }
89
+ catch { /* doesn't exist */ }
90
+ await fs.promises.symlink(opencliRoot, symlinkPath, 'dir');
91
+ }
92
+ }
93
+ catch {
94
+ // Non-fatal: npm-linked installs or permission issues may prevent this
95
+ }
69
96
  }
70
97
  /**
71
98
  * Discover and register CLI commands.
package/dist/doctor.d.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
  export type DoctorOptions = {
8
7
  yes?: boolean;
package/dist/doctor.js 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
  import chalk from 'chalk';
8
7
  import { DEFAULT_DAEMON_PORT } from './constants.js';
@@ -17,11 +16,11 @@ import { getRuntimeLabel } from './runtime-detect.js';
17
16
  export async function checkConnectivity(opts) {
18
17
  const start = Date.now();
19
18
  try {
20
- const mcp = new BrowserBridge();
21
- const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
19
+ const bridge = new BrowserBridge();
20
+ const page = await bridge.connect({ timeout: opts?.timeout ?? 8 });
22
21
  // Try a simple eval to verify end-to-end connectivity
23
22
  await page.evaluate('1 + 1');
24
- await mcp.close();
23
+ await bridge.close();
25
24
  return { ok: true, durationMs: Date.now() - start };
26
25
  }
27
26
  catch (err) {
@@ -33,9 +32,9 @@ export async function runBrowserDoctor(opts = {}) {
33
32
  let initialStatus = await checkDaemonStatus();
34
33
  if (!initialStatus.running) {
35
34
  try {
36
- const mcp = new BrowserBridge();
37
- await mcp.connect({ timeout: 5 });
38
- await mcp.close();
35
+ const bridge = new BrowserBridge();
36
+ await bridge.connect({ timeout: 5 });
37
+ await bridge.close();
39
38
  }
40
39
  catch {
41
40
  // Auto-start failed; we'll report it below.
package/dist/explore.js CHANGED
@@ -46,7 +46,7 @@ export function slugify(value) {
46
46
  return value.trim().toLowerCase().replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
47
47
  }
48
48
  /**
49
- * Parse raw network output from Playwright MCP.
49
+ * Parse raw network output from browser page.
50
50
  * Handles text format: [GET] url => [200]
51
51
  */
52
52
  function parseNetworkRequests(raw) {
package/dist/output.js CHANGED
@@ -24,6 +24,9 @@ export function render(data, opts = {}) {
24
24
  case 'json':
25
25
  renderJson(data);
26
26
  break;
27
+ case 'plain':
28
+ renderPlain(data, opts);
29
+ break;
27
30
  case 'md':
28
31
  case 'markdown':
29
32
  renderMarkdown(data, opts);
@@ -77,6 +80,31 @@ function renderTable(data, opts) {
77
80
  function renderJson(data) {
78
81
  console.log(JSON.stringify(data, null, 2));
79
82
  }
83
+ function renderPlain(data, opts) {
84
+ const rows = normalizeRows(data);
85
+ if (!rows.length)
86
+ return;
87
+ // Single-row single-field shortcuts for chat-style commands.
88
+ if (rows.length === 1) {
89
+ const row = rows[0];
90
+ const entries = Object.entries(row);
91
+ if (entries.length === 1) {
92
+ const [key, value] = entries[0];
93
+ if (key === 'response' || key === 'content' || key === 'text' || key === 'value') {
94
+ console.log(String(value ?? ''));
95
+ return;
96
+ }
97
+ }
98
+ }
99
+ rows.forEach((row, index) => {
100
+ const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== '');
101
+ entries.forEach(([key, value]) => {
102
+ console.log(`${key}: ${value}`);
103
+ });
104
+ if (index < rows.length - 1)
105
+ console.log('');
106
+ });
107
+ }
80
108
  function renderMarkdown(data, opts) {
81
109
  const rows = normalizeRows(data);
82
110
  if (!rows.length)
@@ -77,4 +77,19 @@ describe('render', () => {
77
77
  const calls = log.mock.calls.map(c => c[0]);
78
78
  expect(calls[1]).toBe('test,');
79
79
  });
80
+ it('renders single-field rows in plain mode as the bare value', () => {
81
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
82
+ render([{ response: 'Gemini says hi' }], { fmt: 'plain' });
83
+ expect(log).toHaveBeenCalledWith('Gemini says hi');
84
+ });
85
+ it('renders multi-field rows in plain mode as key-value lines', () => {
86
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
87
+ render([{ status: 'ok', file: '~/tmp/a.png', link: 'https://example.com' }], { fmt: 'plain' });
88
+ const calls = log.mock.calls.map(c => c[0]);
89
+ expect(calls).toEqual([
90
+ 'status: ok',
91
+ 'file: ~/tmp/a.png',
92
+ 'link: https://example.com',
93
+ ]);
94
+ });
80
95
  });
@@ -5,6 +5,7 @@ import { getStep } from './registry.js';
5
5
  import { log } from '../logger.js';
6
6
  import { ConfigError } from '../errors.js';
7
7
  import { BROWSER_ONLY_STEPS } from '../capabilityRouting.js';
8
+ import { isTransientBrowserError } from '../browser/errors.js';
8
9
  export async function executePipeline(page, pipeline, ctx = {}) {
9
10
  const args = ctx.args ?? {};
10
11
  const debug = ctx.debug ?? false;
@@ -52,13 +53,7 @@ async function executeStepWithRetry(handler, page, params, data, args, op, confi
52
53
  if (attempt >= maxRetries)
53
54
  throw err;
54
55
  // Only retry on transient browser errors
55
- const msg = err instanceof Error ? err.message : '';
56
- const isTransient = msg.includes('Extension disconnected')
57
- || msg.includes('attach failed')
58
- || msg.includes('no longer exists')
59
- || msg.includes('CDP connection')
60
- || msg.includes('Daemon command failed');
61
- if (!isTransient)
56
+ if (!isTransientBrowserError(err))
62
57
  throw err;
63
58
  // Brief delay before retry
64
59
  await new Promise(resolve => setTimeout(resolve, 1000));