@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.
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +52 -3
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +460 -1
- package/dist/cli.js +34 -3
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/daemon.js +1 -0
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +9 -5
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -13
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +49 -3
- package/src/browser.test.ts +6 -0
- package/src/cli.ts +34 -3
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/daemon.ts +1 -0
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +11 -8
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -15
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- 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.
|
package/docs/guide/plugins.md
CHANGED
|
@@ -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 =
|
|
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,
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCLI",
|
|
4
|
-
"version": "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": "
|
|
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": "
|
|
9
|
+
"version": "1.5.2",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@types/chrome": "^0.0.287",
|
|
12
12
|
"typescript": "^5.7.0",
|
package/extension/package.json
CHANGED
|
@@ -106,7 +106,7 @@ type AutomationSession = {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
const automationSessions = new Map<string, AutomationSession>();
|
|
109
|
-
const WINDOW_IDLE_TIMEOUT =
|
|
109
|
+
const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick 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
package/src/browser/cdp.ts
CHANGED
|
@@ -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), {
|
package/src/browser/discover.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
}
|
package/src/browser/index.ts
CHANGED
|
@@ -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
|
};
|
package/src/browser/page.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/browser.test.ts
CHANGED
|
@@ -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
|
|
79
|
+
const label = strategyLabel(cmd);
|
|
80
|
+
const tag = label === 'public'
|
|
80
81
|
? chalk.green('[public]')
|
|
81
|
-
: chalk.yellow(`[${
|
|
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
|
|
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();
|