@jackwener/opencli 1.5.0 → 1.5.2

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 (108) hide show
  1. package/dist/browser/cdp.js +5 -0
  2. package/dist/browser/discover.js +11 -7
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/page.d.ts +4 -0
  6. package/dist/browser/page.js +52 -3
  7. package/dist/browser.test.js +5 -0
  8. package/dist/cli-manifest.json +460 -1
  9. package/dist/cli.js +34 -3
  10. package/dist/clis/apple-podcasts/commands.test.js +26 -3
  11. package/dist/clis/apple-podcasts/top.js +4 -1
  12. package/dist/clis/bluesky/feeds.yaml +29 -0
  13. package/dist/clis/bluesky/followers.yaml +33 -0
  14. package/dist/clis/bluesky/following.yaml +33 -0
  15. package/dist/clis/bluesky/profile.yaml +27 -0
  16. package/dist/clis/bluesky/search.yaml +34 -0
  17. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  18. package/dist/clis/bluesky/thread.yaml +32 -0
  19. package/dist/clis/bluesky/trending.yaml +27 -0
  20. package/dist/clis/bluesky/user.yaml +34 -0
  21. package/dist/clis/twitter/trending.js +29 -61
  22. package/dist/clis/weread/shelf.js +132 -9
  23. package/dist/clis/weread/utils.js +5 -1
  24. package/dist/clis/xiaohongshu/publish.js +78 -42
  25. package/dist/clis/xiaohongshu/publish.test.js +20 -8
  26. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  27. package/dist/clis/xiaohongshu/search.js +20 -1
  28. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  29. package/dist/clis/xiaohongshu/search.test.js +32 -1
  30. package/dist/daemon.js +1 -0
  31. package/dist/discovery.js +40 -28
  32. package/dist/doctor.d.ts +1 -2
  33. package/dist/doctor.js +9 -5
  34. package/dist/engine.test.js +42 -0
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/errors.js +2 -2
  37. package/dist/execution.js +45 -13
  38. package/dist/execution.test.d.ts +1 -0
  39. package/dist/execution.test.js +40 -0
  40. package/dist/extension-manifest-regression.test.d.ts +1 -0
  41. package/dist/extension-manifest-regression.test.js +12 -0
  42. package/dist/external.js +6 -1
  43. package/dist/main.js +1 -0
  44. package/dist/plugin-scaffold.d.ts +28 -0
  45. package/dist/plugin-scaffold.js +142 -0
  46. package/dist/plugin-scaffold.test.d.ts +4 -0
  47. package/dist/plugin-scaffold.test.js +83 -0
  48. package/dist/plugin.d.ts +55 -17
  49. package/dist/plugin.js +706 -154
  50. package/dist/plugin.test.js +836 -38
  51. package/dist/runtime.d.ts +1 -0
  52. package/dist/runtime.js +1 -1
  53. package/dist/types.d.ts +2 -0
  54. package/dist/weread-private-api-regression.test.js +185 -0
  55. package/docs/adapters/browser/bluesky.md +53 -0
  56. package/docs/guide/plugins.md +10 -0
  57. package/extension/dist/background.js +4 -2
  58. package/extension/manifest.json +4 -1
  59. package/extension/package-lock.json +2 -2
  60. package/extension/package.json +1 -1
  61. package/extension/src/background.ts +2 -1
  62. package/package.json +1 -1
  63. package/src/browser/cdp.ts +6 -0
  64. package/src/browser/discover.ts +10 -7
  65. package/src/browser/index.ts +2 -0
  66. package/src/browser/page.ts +49 -3
  67. package/src/browser.test.ts +6 -0
  68. package/src/cli.ts +34 -3
  69. package/src/clis/apple-podcasts/commands.test.ts +30 -2
  70. package/src/clis/apple-podcasts/top.ts +4 -1
  71. package/src/clis/bluesky/feeds.yaml +29 -0
  72. package/src/clis/bluesky/followers.yaml +33 -0
  73. package/src/clis/bluesky/following.yaml +33 -0
  74. package/src/clis/bluesky/profile.yaml +27 -0
  75. package/src/clis/bluesky/search.yaml +34 -0
  76. package/src/clis/bluesky/starter-packs.yaml +34 -0
  77. package/src/clis/bluesky/thread.yaml +32 -0
  78. package/src/clis/bluesky/trending.yaml +27 -0
  79. package/src/clis/bluesky/user.yaml +34 -0
  80. package/src/clis/twitter/trending.ts +29 -77
  81. package/src/clis/weread/shelf.ts +169 -9
  82. package/src/clis/weread/utils.ts +6 -1
  83. package/src/clis/xiaohongshu/publish.test.ts +22 -8
  84. package/src/clis/xiaohongshu/publish.ts +93 -52
  85. package/src/clis/xiaohongshu/search.test.ts +39 -1
  86. package/src/clis/xiaohongshu/search.ts +19 -1
  87. package/src/daemon.ts +1 -0
  88. package/src/discovery.ts +41 -33
  89. package/src/doctor.ts +11 -8
  90. package/src/engine.test.ts +38 -0
  91. package/src/errors.ts +6 -2
  92. package/src/execution.test.ts +47 -0
  93. package/src/execution.ts +39 -15
  94. package/src/extension-manifest-regression.test.ts +17 -0
  95. package/src/external.ts +6 -1
  96. package/src/main.ts +1 -0
  97. package/src/plugin-scaffold.test.ts +98 -0
  98. package/src/plugin-scaffold.ts +170 -0
  99. package/src/plugin.test.ts +881 -38
  100. package/src/plugin.ts +871 -158
  101. package/src/runtime.ts +2 -2
  102. package/src/types.ts +2 -0
  103. package/src/weread-private-api-regression.test.ts +207 -0
  104. package/tests/e2e/browser-public.test.ts +1 -1
  105. package/tests/e2e/output-formats.test.ts +10 -14
  106. package/tests/e2e/plugin-management.test.ts +4 -1
  107. package/tests/e2e/public-commands.test.ts +12 -1
  108. package/vitest.config.ts +1 -15
package/dist/runtime.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare const DEFAULT_BROWSER_EXPLORE_TIMEOUT: number;
13
13
  export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
14
14
  timeout: number;
15
15
  label?: string;
16
+ hint?: string;
16
17
  }): Promise<T>;
17
18
  /**
18
19
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
package/dist/runtime.js CHANGED
@@ -26,7 +26,7 @@ export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_
26
26
  */
27
27
  export async function runWithTimeout(promise, opts) {
28
28
  const label = opts.label ?? 'Operation';
29
- return withTimeoutMs(promise, opts.timeout * 1000, () => new TimeoutError(label, opts.timeout));
29
+ return withTimeoutMs(promise, opts.timeout * 1000, () => new TimeoutError(label, opts.timeout, opts.hint));
30
30
  }
31
31
  /**
32
32
  * Timeout with milliseconds unit. Used for low-level internal timeouts.
package/dist/types.d.ts CHANGED
@@ -69,4 +69,6 @@ export interface IPage {
69
69
  getInterceptedRequests(): Promise<any[]>;
70
70
  screenshot(options?: ScreenshotOptions): Promise<string>;
71
71
  closeWindow?(): Promise<void>;
72
+ /** Returns the current page URL, or null if unavailable. */
73
+ getCurrentUrl?(): Promise<string | null>;
72
74
  }
@@ -44,6 +44,21 @@ describe('weread private API regression', () => {
44
44
  }));
45
45
  await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
46
46
  });
47
+ it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
48
+ const mockPage = {
49
+ getCookies: vi.fn().mockResolvedValue([]),
50
+ evaluate: vi.fn(),
51
+ };
52
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
+ ok: true,
54
+ status: 200,
55
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
56
+ }));
57
+ await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
58
+ code: 'AUTH_REQUIRED',
59
+ message: 'Not logged in to WeRead',
60
+ });
61
+ });
47
62
  it('maps non-auth API errors to API_ERROR', async () => {
48
63
  const mockPage = {
49
64
  getCookies: vi.fn().mockResolvedValue([]),
@@ -119,4 +134,174 @@ describe('weread private API regression', () => {
119
134
  },
120
135
  ]);
121
136
  });
137
+ it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
138
+ const command = getRegistry().get('weread/shelf');
139
+ expect(command?.func).toBeTypeOf('function');
140
+ const mockPage = {
141
+ getCookies: vi.fn()
142
+ .mockResolvedValueOnce([
143
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
144
+ ])
145
+ .mockResolvedValueOnce([
146
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
147
+ ]),
148
+ goto: vi.fn().mockResolvedValue(undefined),
149
+ evaluate: vi.fn().mockImplementation(async (source) => {
150
+ expect(source).toContain('shelf:rawBooks:vid-current');
151
+ expect(source).toContain('shelf:shelfIndexes:vid-current');
152
+ return {
153
+ cacheFound: true,
154
+ rawBooks: [
155
+ {
156
+ bookId: '40055543',
157
+ title: '置身事内:中国政府与经济发展',
158
+ author: '兰小欢',
159
+ },
160
+ {
161
+ bookId: '29196155',
162
+ title: '文明、现代化、价值投资与中国',
163
+ author: '李录',
164
+ },
165
+ ],
166
+ shelfIndexes: [
167
+ { bookId: '29196155', idx: 0, role: 'book' },
168
+ { bookId: '40055543', idx: 1, role: 'book' },
169
+ ],
170
+ lastChapters: {
171
+ '29196155': 40,
172
+ '40055543': 60,
173
+ },
174
+ };
175
+ }),
176
+ };
177
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
178
+ ok: false,
179
+ status: 401,
180
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
181
+ }));
182
+ const result = await command.func(mockPage, { limit: 1 });
183
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
184
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
185
+ expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
186
+ expect(result).toEqual([
187
+ {
188
+ title: '文明、现代化、价值投资与中国',
189
+ author: '李录',
190
+ progress: '-',
191
+ bookId: '29196155',
192
+ },
193
+ ]);
194
+ });
195
+ it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
196
+ const command = getRegistry().get('weread/shelf');
197
+ expect(command?.func).toBeTypeOf('function');
198
+ const mockPage = {
199
+ getCookies: vi.fn()
200
+ .mockResolvedValueOnce([
201
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
202
+ ])
203
+ .mockResolvedValueOnce([
204
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
205
+ ]),
206
+ goto: vi.fn().mockResolvedValue(undefined),
207
+ evaluate: vi.fn().mockResolvedValue({
208
+ cacheFound: false,
209
+ rawBooks: [],
210
+ shelfIndexes: [],
211
+ lastChapters: {},
212
+ }),
213
+ };
214
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
215
+ ok: false,
216
+ status: 401,
217
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
218
+ }));
219
+ await expect(command.func(mockPage, { limit: 20 })).rejects.toMatchObject({
220
+ code: 'AUTH_REQUIRED',
221
+ message: 'Not logged in to WeRead',
222
+ });
223
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
224
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
225
+ });
226
+ it('returns an empty list when the current session cache is confirmed but empty', async () => {
227
+ const command = getRegistry().get('weread/shelf');
228
+ expect(command?.func).toBeTypeOf('function');
229
+ const mockPage = {
230
+ getCookies: vi.fn()
231
+ .mockResolvedValueOnce([
232
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
233
+ ])
234
+ .mockResolvedValueOnce([
235
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
236
+ ]),
237
+ goto: vi.fn().mockResolvedValue(undefined),
238
+ evaluate: vi.fn().mockResolvedValue({
239
+ cacheFound: true,
240
+ rawBooks: [],
241
+ shelfIndexes: [],
242
+ lastChapters: {},
243
+ }),
244
+ };
245
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
246
+ ok: false,
247
+ status: 401,
248
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
249
+ }));
250
+ const result = await command.func(mockPage, { limit: 20 });
251
+ expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
252
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
253
+ expect(result).toEqual([]);
254
+ });
255
+ it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
256
+ const command = getRegistry().get('weread/shelf');
257
+ expect(command?.func).toBeTypeOf('function');
258
+ const mockPage = {
259
+ getCookies: vi.fn()
260
+ .mockResolvedValueOnce([
261
+ { name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
262
+ ])
263
+ .mockResolvedValueOnce([
264
+ { name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
265
+ ]),
266
+ goto: vi.fn().mockResolvedValue(undefined),
267
+ evaluate: vi.fn().mockResolvedValue({
268
+ cacheFound: true,
269
+ rawBooks: [
270
+ {
271
+ bookId: '40055543',
272
+ title: '置身事内:中国政府与经济发展',
273
+ author: '兰小欢',
274
+ },
275
+ {
276
+ bookId: '29196155',
277
+ title: '文明、现代化、价值投资与中国',
278
+ author: '李录',
279
+ },
280
+ ],
281
+ shelfIndexes: [],
282
+ }),
283
+ };
284
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
285
+ ok: false,
286
+ status: 401,
287
+ json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
288
+ }));
289
+ const result = await command.func(mockPage, { limit: 2 });
290
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
291
+ expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
292
+ expect(result).toEqual([
293
+ {
294
+ title: '置身事内:中国政府与经济发展',
295
+ author: '兰小欢',
296
+ progress: '-',
297
+ bookId: '40055543',
298
+ },
299
+ {
300
+ title: '文明、现代化、价值投资与中国',
301
+ author: '李录',
302
+ progress: '-',
303
+ bookId: '29196155',
304
+ },
305
+ ]);
306
+ });
122
307
  });
@@ -0,0 +1,53 @@
1
+ # Bluesky
2
+
3
+ **Mode**: 🌐 Public · **Domain**: `bsky.app`
4
+
5
+ ## Commands
6
+
7
+ | Command | Description |
8
+ |---------|-------------|
9
+ | `opencli bluesky profile` | User profile info |
10
+ | `opencli bluesky user` | Recent posts from a user |
11
+ | `opencli bluesky trending` | Trending topics |
12
+ | `opencli bluesky search` | Search users |
13
+ | `opencli bluesky feeds` | Popular feed generators |
14
+ | `opencli bluesky followers` | User's followers |
15
+ | `opencli bluesky following` | Accounts a user follows |
16
+ | `opencli bluesky thread` | Post thread with replies |
17
+ | `opencli bluesky starter-packs` | User's starter packs |
18
+
19
+ ## Usage Examples
20
+
21
+ ```bash
22
+ # User profile
23
+ opencli bluesky profile --handle bsky.app
24
+
25
+ # Recent posts
26
+ opencli bluesky user --handle bsky.app --limit 10
27
+
28
+ # Trending topics
29
+ opencli bluesky trending --limit 10
30
+
31
+ # Search users
32
+ opencli bluesky search --query "AI" --limit 10
33
+
34
+ # Popular feeds
35
+ opencli bluesky feeds --limit 10
36
+
37
+ # Followers / following
38
+ opencli bluesky followers --handle bsky.app --limit 10
39
+ opencli bluesky following --handle bsky.app
40
+
41
+ # Post thread with replies
42
+ opencli bluesky thread --uri "at://did:.../app.bsky.feed.post/..."
43
+
44
+ # Starter packs
45
+ opencli bluesky starter-packs --handle bsky.app
46
+
47
+ # JSON output
48
+ opencli bluesky profile --handle bsky.app -f json
49
+ ```
50
+
51
+ ## Prerequisites
52
+
53
+ None — all commands use the public Bluesky AT Protocol API, no browser or login required.
@@ -31,9 +31,19 @@ Plugins live in `~/.opencli/plugins/<name>/`. Each subdirectory is scanned at st
31
31
  ### Supported Source Formats
32
32
 
33
33
  ```bash
34
+ # GitHub shorthand
34
35
  opencli plugin install github:user/repo
35
36
  opencli plugin install github:user/repo/subplugin # install specific sub-plugin from monorepo
36
37
  opencli plugin install https://github.com/user/repo
38
+
39
+ # Any git-cloneable URL
40
+ opencli plugin install https://gitlab.example.com/team/repo.git
41
+ opencli plugin install ssh://git@gitlab.example.com/team/repo.git
42
+ opencli plugin install git@gitlab.example.com:team/repo.git
43
+
44
+ # Local plugin (for development)
45
+ opencli plugin install file:///path/to/plugin
46
+ opencli plugin install /path/to/plugin
37
47
  ```
38
48
 
39
49
  The repo name prefix `opencli-plugin-` is automatically stripped for the local directory name. For example, `opencli-plugin-hot-digest` becomes `hot-digest`.
@@ -164,6 +164,7 @@ function connect() {
164
164
  clearTimeout(reconnectTimer);
165
165
  reconnectTimer = null;
166
166
  }
167
+ ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
167
168
  };
168
169
  ws.onmessage = async (event) => {
169
170
  try {
@@ -195,7 +196,7 @@ function scheduleReconnect() {
195
196
  }, delay);
196
197
  }
197
198
  const automationSessions = /* @__PURE__ */ new Map();
198
- const WINDOW_IDLE_TIMEOUT = 12e4;
199
+ const WINDOW_IDLE_TIMEOUT = 3e4;
199
200
  function getWorkspaceKey(workspace) {
200
201
  return workspace?.trim() || "default";
201
202
  }
@@ -230,7 +231,8 @@ async function getAutomationWindow(workspace) {
230
231
  focused: false,
231
232
  width: 1280,
232
233
  height: 900,
233
- type: "normal"
234
+ type: "normal",
235
+ state: "minimized"
234
236
  });
235
237
  const session = {
236
238
  windowId: win.id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCLI",
4
- "version": "1.4.1",
4
+ "version": "1.5.2",
5
5
  "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -10,6 +10,9 @@
10
10
  "activeTab",
11
11
  "alarms"
12
12
  ],
13
+ "host_permissions": [
14
+ "<all_urls>"
15
+ ],
13
16
  "background": {
14
17
  "service_worker": "dist/background.js",
15
18
  "type": "module"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "0.2.0",
3
+ "version": "1.5.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "0.2.0",
9
+ "version": "1.5.2",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.4.1",
3
+ "version": "1.5.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -106,7 +106,7 @@ type AutomationSession = {
106
106
  };
107
107
 
108
108
  const automationSessions = new Map<string, AutomationSession>();
109
- const WINDOW_IDLE_TIMEOUT = 120000; // 120slonger to survive slow pipelines
109
+ const WINDOW_IDLE_TIMEOUT = 30000; // 30squick cleanup after command finishes
110
110
 
111
111
  function getWorkspaceKey(workspace?: string): string {
112
112
  return workspace?.trim() || 'default';
@@ -152,6 +152,7 @@ async function getAutomationWindow(workspace: string): Promise<number> {
152
152
  width: 1280,
153
153
  height: 900,
154
154
  type: 'normal',
155
+ state: 'minimized',
155
156
  });
156
157
  const session: AutomationSession = {
157
158
  windowId: win.id!,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -173,6 +173,7 @@ export class CDPBridge implements IBrowserFactory {
173
173
 
174
174
  class CDPPage implements IPage {
175
175
  private _pageEnabled = false;
176
+ private _lastUrl: string | null = null;
176
177
  constructor(private bridge: CDPBridge) {}
177
178
 
178
179
  async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
@@ -183,6 +184,7 @@ class CDPPage implements IPage {
183
184
  const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => {});
184
185
  await this.bridge.send('Page.navigate', { url });
185
186
  await loadPromise;
187
+ this._lastUrl = url;
186
188
  if (options?.waitUntil !== 'none') {
187
189
  const maxMs = options?.settleMs ?? 1000;
188
190
  await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
@@ -307,6 +309,10 @@ class CDPPage implements IPage {
307
309
  return [];
308
310
  }
309
311
 
312
+ async getCurrentUrl(): Promise<string | null> {
313
+ return this._lastUrl;
314
+ }
315
+
310
316
  async installInterceptor(pattern: string): Promise<void> {
311
317
  const { generateInterceptorJs } = await import('../interceptor.js');
312
318
  await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
@@ -22,13 +22,16 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
22
22
  const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
23
23
  const controller = new AbortController();
24
24
  const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
25
- const res = await fetch(`http://127.0.0.1:${port}/status`, {
26
- headers: { 'X-OpenCLI': '1' },
27
- signal: controller.signal,
28
- });
29
- const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
30
- clearTimeout(timer);
31
- return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
25
+ try {
26
+ const res = await fetch(`http://127.0.0.1:${port}/status`, {
27
+ headers: { 'X-OpenCLI': '1' },
28
+ signal: controller.signal,
29
+ });
30
+ const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
31
+ return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
32
+ } finally {
33
+ clearTimeout(timer);
34
+ }
32
35
  } catch {
33
36
  return { running: false, extensionConnected: false };
34
37
  }
@@ -15,6 +15,7 @@ export type { DomSnapshotOptions } from './dom-snapshot.js';
15
15
 
16
16
  import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
17
17
  import { __test__ as cdpTest } from './cdp.js';
18
+ import { isRetryableSettleError } from './page.js';
18
19
  import { withTimeoutMs } from '../runtime.js';
19
20
 
20
21
  export const __test__ = {
@@ -24,4 +25,5 @@ export const __test__ = {
24
25
  withTimeoutMs,
25
26
  selectCDPTarget: cdpTest.selectCDPTarget,
26
27
  scoreCDPTarget: cdpTest.scoreCDPTarget,
28
+ isRetryableSettleError,
27
29
  };
@@ -28,6 +28,12 @@ import {
28
28
  waitForDomStableJs,
29
29
  } from './dom-helpers.js';
30
30
 
31
+ export function isRetryableSettleError(err: unknown): boolean {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ return message.includes('Inspected target navigated or closed')
34
+ || (message.includes('-32000') && message.toLowerCase().includes('target'));
35
+ }
36
+
31
37
  /**
32
38
  * Page — implements IPage by talking to the daemon via HTTP.
33
39
  */
@@ -36,6 +42,8 @@ export class Page implements IPage {
36
42
 
37
43
  /** Active tab ID, set after navigate and used in all subsequent commands */
38
44
  private _tabId: number | undefined;
45
+ /** Last navigated URL, tracked in-memory to avoid extra round-trips */
46
+ private _lastUrl: string | null = null;
39
47
 
40
48
  /** Helper: spread workspace into command params */
41
49
  private _wsOpt(): { workspace: string } {
@@ -55,10 +63,11 @@ export class Page implements IPage {
55
63
  url,
56
64
  ...this._cmdOpts(),
57
65
  }) as { tabId?: number };
58
- // Remember the tabId for subsequent exec calls
66
+ // Remember the tabId and URL for subsequent calls
59
67
  if (result?.tabId) {
60
68
  this._tabId = result.tabId;
61
69
  }
70
+ this._lastUrl = url;
62
71
  // Inject stealth anti-detection patches (guard flag prevents double-injection).
63
72
  try {
64
73
  await sendCommand('exec', {
@@ -72,13 +81,34 @@ export class Page implements IPage {
72
81
  // settleMs is now a timeout cap (default 1000ms), not a fixed wait.
73
82
  if (options?.waitUntil !== 'none') {
74
83
  const maxMs = options?.settleMs ?? 1000;
75
- await sendCommand('exec', {
84
+ const settleOpts = {
76
85
  code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
77
86
  ...this._cmdOpts(),
78
- });
87
+ };
88
+ try {
89
+ await sendCommand('exec', settleOpts);
90
+ } catch (err) {
91
+ if (!isRetryableSettleError(err)) throw err;
92
+ // SPA client-side redirects can invalidate the CDP target after
93
+ // chrome.tabs reports 'complete'. Wait briefly for the new document
94
+ // to load, then retry the settle probe once.
95
+ try {
96
+ await new Promise((r) => setTimeout(r, 200));
97
+ await sendCommand('exec', settleOpts);
98
+ } catch (retryErr) {
99
+ if (!isRetryableSettleError(retryErr)) throw retryErr;
100
+ // Retry also failed — give up silently. Settle is best-effort
101
+ // after successful navigation; the next real command will surface
102
+ // any persistent target error immediately.
103
+ }
104
+ }
79
105
  }
80
106
  }
81
107
 
108
+ async getCurrentUrl(): Promise<string | null> {
109
+ return this._lastUrl;
110
+ }
111
+
82
112
  /** Close the automation window in the extension */
83
113
  async closeWindow(): Promise<void> {
84
114
  try {
@@ -183,6 +213,22 @@ export class Page implements IPage {
183
213
 
184
214
  async wait(options: number | WaitOptions): Promise<void> {
185
215
  if (typeof options === 'number') {
216
+ if (options >= 1) {
217
+ // For waits >= 1s, use DOM-stable check: return early when the page
218
+ // stops mutating, with the original wait time as the hard cap.
219
+ // This turns e.g. `page.wait(5)` from a fixed 5s sleep into
220
+ // "wait until DOM is stable, max 5s" — often completing in <1s.
221
+ try {
222
+ const maxMs = options * 1000;
223
+ await sendCommand('exec', {
224
+ code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
225
+ ...this._cmdOpts(),
226
+ });
227
+ return;
228
+ } catch {
229
+ // Fallback: fixed sleep (e.g. if page has no DOM yet)
230
+ }
231
+ }
186
232
  await new Promise(resolve => setTimeout(resolve, options * 1000));
187
233
  return;
188
234
  }
@@ -45,6 +45,12 @@ describe('browser helpers', () => {
45
45
  await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
46
46
  });
47
47
 
48
+ it('retries settle only for target-invalidated errors', () => {
49
+ expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
50
+ expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
51
+ expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
52
+ });
53
+
48
54
  it('prefers the real Electron app target over DevTools and blank pages', () => {
49
55
  const target = __test__.selectCDPTarget([
50
56
  {
package/src/cli.ts CHANGED
@@ -76,9 +76,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
76
76
  for (const [site, cmds] of sites) {
77
77
  console.log(chalk.bold.cyan(` ${site}`));
78
78
  for (const cmd of cmds) {
79
- const tag = strategyLabel(cmd) === 'public'
79
+ const label = strategyLabel(cmd);
80
+ const tag = label === 'public'
80
81
  ? chalk.green('[public]')
81
- : chalk.yellow(`[${strategyLabel(cmd)}]`);
82
+ : chalk.yellow(`[${label}]`);
82
83
  console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
83
84
  }
84
85
  console.log();
@@ -252,7 +253,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
252
253
 
253
254
  pluginCmd
254
255
  .command('install')
255
- .description('Install a plugin from GitHub')
256
+ .description('Install a plugin from a git repository')
256
257
  .argument('<source>', 'Plugin source (e.g. github:user/repo)')
257
258
  .action(async (source: string) => {
258
259
  const { installPlugin } = await import('./plugin.js');
@@ -411,6 +412,36 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
411
412
  console.log();
412
413
  });
413
414
 
415
+ pluginCmd
416
+ .command('create')
417
+ .description('Create a new plugin scaffold')
418
+ .argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
419
+ .option('-d, --dir <path>', 'Output directory (default: ./<name>)')
420
+ .option('--description <text>', 'Plugin description')
421
+ .action(async (name: string, opts: { dir?: string; description?: string }) => {
422
+ const { createPluginScaffold } = await import('./plugin-scaffold.js');
423
+ try {
424
+ const result = createPluginScaffold(name, {
425
+ dir: opts.dir,
426
+ description: opts.description,
427
+ });
428
+ console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
429
+ console.log();
430
+ console.log(chalk.bold(' Files created:'));
431
+ for (const f of result.files) {
432
+ console.log(` ${chalk.cyan(f)}`);
433
+ }
434
+ console.log();
435
+ console.log(chalk.dim(' Next steps:'));
436
+ console.log(chalk.dim(` cd ${result.dir}`));
437
+ console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
438
+ console.log(chalk.dim(` opencli ${name} hello`));
439
+ } catch (err) {
440
+ console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
441
+ process.exitCode = 1;
442
+ }
443
+ });
444
+
414
445
  // ── External CLIs ─────────────────────────────────────────────────────────
415
446
 
416
447
  const externalClis = loadExternalClis();