@jackwener/opencli 1.5.9 → 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 (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +18 -0
  3. package/SKILL.md +59 -0
  4. package/autoresearch/baseline-browse.txt +1 -0
  5. package/autoresearch/baseline-skill.txt +1 -0
  6. package/autoresearch/browse-tasks.json +688 -0
  7. package/autoresearch/eval-browse.ts +185 -0
  8. package/autoresearch/eval-skill.ts +248 -0
  9. package/autoresearch/run-browse.sh +9 -0
  10. package/autoresearch/run-skill.sh +9 -0
  11. package/dist/browser/daemon-client.d.ts +20 -1
  12. package/dist/browser/daemon-client.js +37 -30
  13. package/dist/browser/daemon-client.test.d.ts +1 -0
  14. package/dist/browser/daemon-client.test.js +77 -0
  15. package/dist/browser/discover.js +8 -19
  16. package/dist/browser/page.d.ts +4 -0
  17. package/dist/browser/page.js +48 -1
  18. package/dist/cli.js +392 -0
  19. package/dist/clis/twitter/article.js +28 -1
  20. package/dist/clis/xiaohongshu/note.js +11 -0
  21. package/dist/clis/xiaohongshu/note.test.js +49 -0
  22. package/dist/commanderAdapter.js +1 -1
  23. package/dist/commanderAdapter.test.js +43 -0
  24. package/dist/commands/daemon.js +7 -46
  25. package/dist/commands/daemon.test.js +44 -69
  26. package/dist/discovery.js +27 -0
  27. package/dist/types.d.ts +8 -0
  28. package/docs/guide/getting-started.md +21 -0
  29. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  30. package/docs/zh/guide/getting-started.md +21 -0
  31. package/extension/package-lock.json +2 -2
  32. package/extension/src/background.ts +51 -4
  33. package/extension/src/cdp.ts +77 -124
  34. package/extension/src/protocol.ts +5 -1
  35. package/package.json +1 -1
  36. package/skills/opencli-explorer/SKILL.md +6 -0
  37. package/skills/opencli-oneshot/SKILL.md +6 -0
  38. package/skills/opencli-operate/SKILL.md +213 -0
  39. package/skills/opencli-usage/SKILL.md +113 -32
  40. package/src/browser/daemon-client.test.ts +103 -0
  41. package/src/browser/daemon-client.ts +53 -30
  42. package/src/browser/discover.ts +8 -17
  43. package/src/browser/page.ts +48 -1
  44. package/src/cli.ts +392 -0
  45. package/src/clis/twitter/article.ts +31 -1
  46. package/src/clis/xiaohongshu/note.test.ts +51 -0
  47. package/src/clis/xiaohongshu/note.ts +18 -0
  48. package/src/commanderAdapter.test.ts +62 -0
  49. package/src/commanderAdapter.ts +1 -1
  50. package/src/commands/daemon.test.ts +49 -83
  51. package/src/commands/daemon.ts +7 -55
  52. package/src/discovery.ts +22 -0
  53. package/src/doctor.ts +1 -1
  54. package/src/types.ts +8 -0
  55. package/extension/dist/background.js +0 -681
@@ -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,
@@ -212,7 +212,7 @@ async function renderError(err, cmdName, verbose) {
212
212
  if (err instanceof SelectorError || err instanceof EmptyResultError) {
213
213
  const icon = ERROR_ICONS[err.code] ?? '⚠️';
214
214
  console.error(chalk.red(`${icon} ${err.message}`));
215
- 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.'}`));
216
216
  console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
217
217
  console.error(chalk.dim(` Report: ${ISSUES_URL}`));
218
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(),
@@ -157,3 +158,45 @@ describe('commanderAdapter default formats', () => {
157
158
  expect(mockRenderOutput).toHaveBeenCalledWith([{ response: 'hello' }], expect.objectContaining({ fmt: 'json' }));
158
159
  });
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,7 +66,7 @@ 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
  }
@@ -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,
@@ -13,6 +17,10 @@ vi.mock('../browser/bridge.js', () => ({
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/types.d.ts CHANGED
@@ -80,4 +80,12 @@ export interface IPage {
80
80
  getCurrentUrl?(): Promise<string | null>;
81
81
  /** Returns the active tab ID, or undefined if not yet resolved. */
82
82
  getActiveTabId?(): number | undefined;
83
+ /** Send a raw CDP command via chrome.debugger passthrough. */
84
+ cdp?(method: string, params?: Record<string, unknown>): Promise<unknown>;
85
+ /** Click at native coordinates via CDP Input.dispatchMouseEvent. */
86
+ nativeClick?(x: number, y: number): Promise<void>;
87
+ /** Type text via CDP Input.insertText. */
88
+ nativeType?(text: string): Promise<void>;
89
+ /** Press a key via CDP Input.dispatchKeyEvent. */
90
+ nativeKeyPress?(key: string, modifiers?: string[]): Promise<void>;
83
91
  }
@@ -48,6 +48,27 @@ opencli bilibili hot -f csv # CSV
48
48
  opencli bilibili hot -v # Verbose: show pipeline debug
49
49
  ```
50
50
 
51
+ ### Tab Completion
52
+
53
+ OpenCLI supports intelligent tab completion to speed up command input:
54
+
55
+ ```bash
56
+ # Add shell completion to your startup config
57
+ echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
58
+ echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
59
+ echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
60
+
61
+ # Restart your shell, then press Tab to complete:
62
+ opencli [Tab] # Complete site names (bilibili, zhihu, twitter...)
63
+ opencli bilibili [Tab] # Complete commands (hot, search, me, download...)
64
+ ```
65
+
66
+ The completion includes:
67
+ - All available sites and adapters
68
+ - Built-in commands (list, explore, validate...)
69
+ - Command aliases
70
+ - Real-time updates as you add new adapters
71
+
51
72
  ## Next Steps
52
73
 
53
74
  - [Installation details](/guide/installation)
@@ -0,0 +1,144 @@
1
+ # Browse Skill Testing Design
2
+
3
+ Two-layer testing framework for `opencli browse` commands and the
4
+ Claude Code skill integration.
5
+
6
+ ## Goal
7
+
8
+ Verify that `opencli browse` works reliably on real websites and that
9
+ Claude Code can use the skill to complete browser tasks end-to-end.
10
+
11
+ ## Architecture
12
+
13
+ ```
14
+ autoresearch/
15
+ ├── browse-tasks.json ← 59 task definitions with browse command sequences
16
+ ├── eval-browse.ts ← Layer 1: deterministic browse command testing
17
+ ├── eval-skill.ts ← Layer 2: Claude Code skill E2E testing
18
+ ├── run-browse.sh ← Launch Layer 1
19
+ ├── run-skill.sh ← Launch Layer 2
20
+ ├── baseline-browse.txt ← Layer 1 best score
21
+ ├── baseline-skill.txt ← Layer 2 best score
22
+ └── results/ ← Per-run results (gitignored)
23
+ ```
24
+
25
+ ## Layer 1: Deterministic Browse Command Testing
26
+
27
+ Tests `opencli browse` commands directly on real websites. No LLM
28
+ involved — pure command reliability testing.
29
+
30
+ ### How It Works
31
+
32
+ Each task defines a sequence of browse commands and a judge for the
33
+ last command's output:
34
+
35
+ ```json
36
+ {
37
+ "name": "hn-top-stories",
38
+ "steps": [
39
+ "opencli browse open https://news.ycombinator.com",
40
+ "opencli browse eval \"JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a=>({title:a.textContent,url:a.href})))\""
41
+ ],
42
+ "judge": { "type": "arrayMinLength", "minLength": 5 }
43
+ }
44
+ ```
45
+
46
+ ### Execution
47
+
48
+ ```bash
49
+ ./autoresearch/run-browse.sh
50
+ ```
51
+
52
+ - Runs all 59 tasks serially
53
+ - Each task: execute steps → judge last step output → pass/fail
54
+ - `opencli browse close` between tasks for clean state
55
+ - Expected: ~2 minutes, $0 cost
56
+
57
+ ### Task Categories
58
+
59
+ | Category | Count | Example |
60
+ |----------|-------|---------|
61
+ | extract | 9 | Open page, eval JS to extract data |
62
+ | list | 10 | Open page, eval JS to extract array |
63
+ | search | 6 | Open, type query, keys Enter, eval results |
64
+ | nav | 7 | Open, click link, eval new page title |
65
+ | scroll | 5 | Open, scroll, eval footer/hidden content |
66
+ | form | 6 | Open, type into fields, eval field values |
67
+ | complex | 6 | Multi-step: open → click → navigate → extract |
68
+ | bench | 10 | Test set (various) |
69
+
70
+ ## Layer 2: Claude Code Skill E2E Testing
71
+
72
+ Spawns Claude Code with the opencli-operate skill to complete tasks
73
+ autonomously using browse commands.
74
+
75
+ ### How It Works
76
+
77
+ ```bash
78
+ claude -p \
79
+ --system-prompt "$(cat skills/opencli-operate/SKILL.md)" \
80
+ --dangerously-skip-permissions \
81
+ --allowedTools "Bash(opencli:*)" \
82
+ --output-format json \
83
+ "用 opencli browse 完成任务:Extract the top 5 stories from Hacker News with title and score. Start URL: https://news.ycombinator.com"
84
+ ```
85
+
86
+ ### Execution
87
+
88
+ ```bash
89
+ ./autoresearch/run-skill.sh
90
+ ```
91
+
92
+ - Runs all 59 tasks serially
93
+ - Each task: spawn Claude Code → it uses browse commands autonomously → judge output
94
+ - Expected: ~20 minutes, ~$5-10
95
+
96
+ ### Judge
97
+
98
+ Both layers use the same judge types:
99
+
100
+ | Type | Description |
101
+ |------|-------------|
102
+ | `contains` | Output contains a substring |
103
+ | `arrayMinLength` | Output is an array with ≥ N items |
104
+ | `arrayFieldsPresent` | Array items have required fields |
105
+ | `nonEmpty` | Output is non-empty |
106
+ | `matchesPattern` | Output matches a regex |
107
+
108
+ ## Output Format
109
+
110
+ ```
111
+ 🔬 Layer 1: Browse Commands — 59 tasks
112
+
113
+ [1/59] extract-title-example... ✓ (0.5s)
114
+ [2/59] hn-top-stories... ✓ (1.2s)
115
+ ...
116
+
117
+ Score: 55/59 (93%)
118
+ Time: 2min
119
+ Cost: $0
120
+
121
+ 🔬 Layer 2: Skill E2E — 59 tasks
122
+
123
+ [1/59] extract-title-example... ✓ (8s, $0.01)
124
+ [2/59] hn-top-stories... ✓ (15s, $0.08)
125
+ ...
126
+
127
+ Score: 52/59 (88%)
128
+ Time: 20min
129
+ Cost: $6.50
130
+ ```
131
+
132
+ ## Constraints
133
+
134
+ - All 59 tasks run on real websites (no mocks)
135
+ - Layer 1: zero LLM cost, ~2 min
136
+ - Layer 2: ~$5-10 LLM cost, ~20 min
137
+ - Results saved to `autoresearch/results/` (gitignored)
138
+ - Baselines tracked in `baseline-browse.txt` and `baseline-skill.txt`
139
+
140
+ ## Success Criteria
141
+
142
+ - Layer 1 ≥ 90% (browse commands work on real sites)
143
+ - Layer 2 ≥ 85% (Claude Code can use skill effectively)
144
+ - Both layers cover all 8 task categories
@@ -32,6 +32,27 @@ opencli bilibili hot -f md # Markdown
32
32
  opencli bilibili hot -f csv # CSV
33
33
  ```
34
34
 
35
+ ## 终端自动补全
36
+
37
+ OpenCLI 支持智能的 Tab 自动补全,加快命令输入:
38
+
39
+ ```bash
40
+ # 把自动补全加入 shell 启动配置
41
+ echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
42
+ echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
43
+ echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
44
+
45
+ # 重启 shell 后,按 Tab 键补全:
46
+ opencli [Tab] # 补全站点名称(bilibili、zhihu、twitter...)
47
+ opencli bilibili [Tab] # 补全命令(hot、search、me、download...)
48
+ ```
49
+
50
+ 补全功能包含:
51
+ - 所有可用的站点和适配器
52
+ - 内置命令(list、explore、validate...)
53
+ - 命令别名
54
+ - 新增适配器时的实时更新
55
+
35
56
  ## 下一步
36
57
 
37
58
  - [安装详情](/zh/guide/installation)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "1.5.4",
9
+ "version": "1.5.5",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",