@jackwener/opencli 1.7.9 → 1.7.11

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 (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/cli-manifest.json +60 -1
  4. package/clis/instagram/collection-create.js +57 -0
  5. package/clis/instagram/collection-delete.js +91 -0
  6. package/clis/instagram/saved.js +21 -7
  7. package/dist/src/adapter-shadow.d.ts +11 -0
  8. package/dist/src/adapter-shadow.js +72 -0
  9. package/dist/src/adapter-shadow.test.d.ts +1 -0
  10. package/dist/src/adapter-shadow.test.js +49 -0
  11. package/dist/src/browser/base-page.d.ts +6 -2
  12. package/dist/src/browser/base-page.js +88 -6
  13. package/dist/src/browser/base-page.test.js +61 -1
  14. package/dist/src/browser/bridge.d.ts +0 -2
  15. package/dist/src/browser/bridge.js +4 -32
  16. package/dist/src/browser/cdp.js +48 -0
  17. package/dist/src/browser/cdp.test.js +23 -0
  18. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  19. package/dist/src/browser/daemon-lifecycle.js +67 -0
  20. package/dist/src/browser/daemon-version.d.ts +4 -0
  21. package/dist/src/browser/daemon-version.js +12 -0
  22. package/dist/src/browser/dom-helpers.d.ts +1 -1
  23. package/dist/src/browser/dom-helpers.js +15 -3
  24. package/dist/src/browser/page.js +1 -1
  25. package/dist/src/browser/target-resolver.d.ts +8 -0
  26. package/dist/src/browser/target-resolver.js +75 -0
  27. package/dist/src/browser/verify-fixture.d.ts +1 -0
  28. package/dist/src/browser/verify-fixture.js +18 -0
  29. package/dist/src/browser/verify-fixture.test.js +16 -1
  30. package/dist/src/build-manifest.d.ts +68 -33
  31. package/dist/src/build-manifest.js +175 -29
  32. package/dist/src/build-manifest.test.js +75 -1
  33. package/dist/src/cli.js +25 -10
  34. package/dist/src/cli.test.js +153 -1
  35. package/dist/src/commands/daemon.d.ts +2 -0
  36. package/dist/src/commands/daemon.js +36 -1
  37. package/dist/src/commands/daemon.test.js +103 -2
  38. package/dist/src/doctor.d.ts +3 -0
  39. package/dist/src/doctor.js +27 -20
  40. package/dist/src/doctor.test.js +71 -1
  41. package/dist/src/manifest-types.d.ts +39 -0
  42. package/dist/src/manifest-types.js +9 -0
  43. package/package.json +2 -2
package/README.md CHANGED
@@ -97,7 +97,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](.
97
97
  |------|------------------|
98
98
  | Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` |
99
99
  | Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
100
- | Modify an official adapter locally | `opencli adapter eject/status/reset` |
100
+ | Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
101
101
  | Publish or install third-party commands | `opencli plugin install github:user/repo` |
102
102
  | Wrap an existing local binary | `opencli external register <name>` |
103
103
 
@@ -174,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil
174
174
  2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback.
175
175
  3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
176
176
  4. Decode response fields and design output columns.
177
- 5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
177
+ 5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
178
178
  6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
179
179
 
180
180
  ### CLI Hub and desktop adapters
@@ -407,7 +407,7 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski
407
407
  - Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming).
408
408
  - Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
409
409
  - Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
410
- - Decode response fields, design columns, scaffold with `opencli browser init`.
410
+ - Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
411
411
  - Verify with `opencli browser verify <site>/<name>` before shipping.
412
412
 
413
413
  For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
package/README.zh-CN.md CHANGED
@@ -81,7 +81,7 @@ opencli bilibili hot --limit 5
81
81
  |------|----------|
82
82
  | 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
83
83
  | 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
84
- | 本地修改官方 adapter | `opencli adapter eject/status/reset` |
84
+ | 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
85
85
  | 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
86
86
  | 包装已有本机 binary | `opencli external register <name>` |
87
87
 
@@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
158
158
  2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
159
159
  3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
160
160
  4. 字段解码 + 设计输出列
161
- 5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
161
+ 5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
162
162
  6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
163
163
 
164
164
  ### CLI 枢纽与桌面端适配器
@@ -505,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载
505
505
  - 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
506
506
  - 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
507
507
  - 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
508
- - 字段解码、设计 columns、`opencli browser init` 生成骨架
508
+ - 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
509
509
  - 交付前用 `opencli browser verify <site>/<name>` 验证
510
510
 
511
511
  在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
package/cli-manifest.json CHANGED
@@ -8692,6 +8692,59 @@
8692
8692
  "modulePath": "imdb/trending.js",
8693
8693
  "sourceFile": "imdb/trending.js"
8694
8694
  },
8695
+ {
8696
+ "site": "instagram",
8697
+ "name": "collection-create",
8698
+ "description": "Create a new Instagram saved-posts collection (folder)",
8699
+ "domain": "www.instagram.com",
8700
+ "strategy": "cookie",
8701
+ "browser": true,
8702
+ "args": [
8703
+ {
8704
+ "name": "name",
8705
+ "type": "str",
8706
+ "required": true,
8707
+ "positional": true,
8708
+ "help": "Name of the collection to create"
8709
+ }
8710
+ ],
8711
+ "columns": [
8712
+ "status",
8713
+ "collectionId",
8714
+ "collectionName",
8715
+ "mediaCount"
8716
+ ],
8717
+ "type": "js",
8718
+ "modulePath": "instagram/collection-create.js",
8719
+ "sourceFile": "instagram/collection-create.js",
8720
+ "navigateBefore": "https://www.instagram.com"
8721
+ },
8722
+ {
8723
+ "site": "instagram",
8724
+ "name": "collection-delete",
8725
+ "description": "Delete an Instagram saved-posts collection (folder) by name or id",
8726
+ "domain": "www.instagram.com",
8727
+ "strategy": "cookie",
8728
+ "browser": true,
8729
+ "args": [
8730
+ {
8731
+ "name": "target",
8732
+ "type": "str",
8733
+ "required": true,
8734
+ "positional": true,
8735
+ "help": "Collection name (case-insensitive) or numeric collection_id"
8736
+ }
8737
+ ],
8738
+ "columns": [
8739
+ "status",
8740
+ "collectionId",
8741
+ "collectionName"
8742
+ ],
8743
+ "type": "js",
8744
+ "modulePath": "instagram/collection-delete.js",
8745
+ "sourceFile": "instagram/collection-delete.js",
8746
+ "navigateBefore": "https://www.instagram.com"
8747
+ },
8695
8748
  {
8696
8749
  "site": "instagram",
8697
8750
  "name": "comment",
@@ -9078,7 +9131,7 @@
9078
9131
  {
9079
9132
  "site": "instagram",
9080
9133
  "name": "saved",
9081
- "description": "Get your saved Instagram posts",
9134
+ "description": "Get your saved Instagram posts (optionally from a specific collection)",
9082
9135
  "domain": "www.instagram.com",
9083
9136
  "strategy": "cookie",
9084
9137
  "browser": true,
@@ -9089,6 +9142,12 @@
9089
9142
  "default": 20,
9090
9143
  "required": false,
9091
9144
  "help": "Number of saved posts"
9145
+ },
9146
+ {
9147
+ "name": "collection",
9148
+ "type": "str",
9149
+ "required": false,
9150
+ "help": "Collection name (case-insensitive). Omit for the default \"All posts\" feed."
9092
9151
  }
9093
9152
  ],
9094
9153
  "columns": [
@@ -0,0 +1,57 @@
1
+ import { cli } from '@jackwener/opencli/registry';
2
+ cli({
3
+ site: 'instagram',
4
+ name: 'collection-create',
5
+ description: 'Create a new Instagram saved-posts collection (folder)',
6
+ domain: 'www.instagram.com',
7
+ args: [
8
+ {
9
+ name: 'name',
10
+ required: true,
11
+ positional: true,
12
+ help: 'Name of the collection to create',
13
+ },
14
+ ],
15
+ columns: ['status', 'collectionId', 'collectionName', 'mediaCount'],
16
+ pipeline: [
17
+ { navigate: 'https://www.instagram.com' },
18
+ { evaluate: `(async () => {
19
+ const name = \${{ args.name | json }};
20
+ if (!name || !String(name).trim()) {
21
+ throw new Error('Collection name cannot be empty');
22
+ }
23
+ const trimmed = String(name).trim();
24
+ const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
25
+ if (!csrf) {
26
+ throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
27
+ }
28
+ const fd = new FormData();
29
+ fd.append('name', trimmed);
30
+ fd.append('module_name', 'collection_create');
31
+ const res = await fetch('https://www.instagram.com/api/v1/collections/create/', {
32
+ method: 'POST',
33
+ credentials: 'include',
34
+ headers: {
35
+ 'X-IG-App-ID': '936619743392459',
36
+ 'X-CSRFToken': csrf,
37
+ },
38
+ body: fd,
39
+ });
40
+ if (!res.ok) {
41
+ const body = await res.text().catch(() => '');
42
+ throw new Error('Failed to create collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
43
+ }
44
+ const d = await res.json();
45
+ if (d?.status && d.status !== 'ok') {
46
+ throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
47
+ }
48
+ return [{
49
+ status: 'Created',
50
+ collectionId: String(d?.collection_id ?? ''),
51
+ collectionName: String(d?.collection_name ?? trimmed),
52
+ mediaCount: d?.collection_media_count ?? 0,
53
+ }];
54
+ })()
55
+ ` },
56
+ ],
57
+ });
@@ -0,0 +1,91 @@
1
+ import { cli } from '@jackwener/opencli/registry';
2
+ cli({
3
+ site: 'instagram',
4
+ name: 'collection-delete',
5
+ description: 'Delete an Instagram saved-posts collection (folder) by name or id',
6
+ domain: 'www.instagram.com',
7
+ args: [
8
+ {
9
+ name: 'target',
10
+ required: true,
11
+ positional: true,
12
+ help: 'Collection name (case-insensitive) or numeric collection_id',
13
+ },
14
+ ],
15
+ columns: ['status', 'collectionId', 'collectionName'],
16
+ pipeline: [
17
+ { navigate: 'https://www.instagram.com' },
18
+ { evaluate: `(async () => {
19
+ const target = \${{ args.target | json }};
20
+ if (!target || !String(target).trim()) {
21
+ throw new Error('Collection target (name or id) cannot be empty');
22
+ }
23
+ const raw = String(target).trim();
24
+ const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
25
+ if (!csrf) {
26
+ throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
27
+ }
28
+ const headers = { 'X-IG-App-ID': '936619743392459' };
29
+
30
+ // Resolve name -> id via /collections/list/. Always go through this path so we can
31
+ // surface an explicit error on duplicate names or unknown names instead of relying
32
+ // on a 404.
33
+ const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%5D', {
34
+ credentials: 'include',
35
+ headers,
36
+ });
37
+ if (!listRes.ok) {
38
+ throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
39
+ }
40
+ const listData = await listRes.json();
41
+ const collections = listData?.items || [];
42
+ const isNumericId = /^\\d{6,}$/.test(raw);
43
+ let id = '';
44
+ let resolvedName = '';
45
+ if (isNumericId) {
46
+ const hit = collections.find((c) => String(c?.collection_id) === raw);
47
+ if (!hit) {
48
+ throw new Error('Collection id not found in your account: ' + raw);
49
+ }
50
+ id = String(hit.collection_id);
51
+ resolvedName = String(hit.collection_name || '');
52
+ } else {
53
+ const wanted = raw.toLowerCase();
54
+ const matches = collections.filter((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
55
+ if (matches.length === 0) {
56
+ const names = collections.map((c) => c?.collection_name).filter(Boolean);
57
+ throw new Error('Collection not found: ' + raw + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
58
+ }
59
+ if (matches.length > 1) {
60
+ const ids = matches.map((c) => c.collection_id).join(', ');
61
+ throw new Error('Multiple collections share the name "' + raw + '" (ids: ' + ids + '). Pass the numeric collection_id explicitly to disambiguate.');
62
+ }
63
+ id = String(matches[0].collection_id);
64
+ resolvedName = String(matches[0].collection_name || raw);
65
+ }
66
+
67
+ const fd = new FormData();
68
+ fd.append('module_name', 'collection_settings');
69
+ const res = await fetch('https://www.instagram.com/api/v1/collections/' + encodeURIComponent(id) + '/delete/', {
70
+ method: 'POST',
71
+ credentials: 'include',
72
+ headers: { ...headers, 'X-CSRFToken': csrf },
73
+ body: fd,
74
+ });
75
+ if (!res.ok) {
76
+ const body = await res.text().catch(() => '');
77
+ throw new Error('Failed to delete collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
78
+ }
79
+ const d = await res.json().catch(() => ({}));
80
+ if (d?.status && d.status !== 'ok') {
81
+ throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
82
+ }
83
+ return [{
84
+ status: 'Deleted',
85
+ collectionId: id,
86
+ collectionName: resolvedName,
87
+ }];
88
+ })()
89
+ ` },
90
+ ],
91
+ });
@@ -2,23 +2,37 @@ import { cli } from '@jackwener/opencli/registry';
2
2
  cli({
3
3
  site: 'instagram',
4
4
  name: 'saved',
5
- description: 'Get your saved Instagram posts',
5
+ description: 'Get your saved Instagram posts (optionally from a specific collection)',
6
6
  domain: 'www.instagram.com',
7
7
  args: [
8
8
  { name: 'limit', type: 'int', default: 20, help: 'Number of saved posts' },
9
+ { name: 'collection', help: 'Collection name (case-insensitive). Omit for the default "All posts" feed.' },
9
10
  ],
10
11
  columns: ['index', 'user', 'caption', 'likes', 'comments', 'type'],
11
12
  pipeline: [
12
13
  { navigate: 'https://www.instagram.com' },
13
14
  { evaluate: `(async () => {
14
15
  const limit = \${{ args.limit }};
15
- const res = await fetch(
16
- 'https://www.instagram.com/api/v1/feed/saved/posts/',
17
- {
18
- credentials: 'include',
19
- headers: { 'X-IG-App-ID': '936619743392459' }
16
+ const collectionArg = \${{ args.collection | json }};
17
+ const headers = { 'X-IG-App-ID': '936619743392459' };
18
+ const opts = { credentials: 'include', headers };
19
+
20
+ let endpoint = 'https://www.instagram.com/api/v1/feed/saved/posts/';
21
+ if (collectionArg && String(collectionArg).trim()) {
22
+ const wanted = String(collectionArg).trim().toLowerCase();
23
+ const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%2C%22ALL_MEDIA_AUTO_COLLECTION%22%5D', opts);
24
+ if (!listRes.ok) throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
25
+ const listData = await listRes.json();
26
+ const collections = listData?.items || [];
27
+ const match = collections.find((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
28
+ if (!match) {
29
+ const names = collections.map((c) => c?.collection_name).filter(Boolean);
30
+ throw new Error('Collection not found: ' + collectionArg + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
20
31
  }
21
- );
32
+ endpoint = 'https://www.instagram.com/api/v1/feed/collection/' + encodeURIComponent(match.collection_id) + '/posts/';
33
+ }
34
+
35
+ const res = await fetch(endpoint, opts);
22
36
  if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
23
37
  const data = await res.json();
24
38
  return (data?.items || []).slice(0, limit).map((item, i) => {
@@ -0,0 +1,11 @@
1
+ export type AdapterShadow = {
2
+ name: string;
3
+ userPath: string;
4
+ builtinPath: string;
5
+ };
6
+ export type AdapterShadowOptions = {
7
+ userClisDir?: string;
8
+ builtinClisDir?: string;
9
+ };
10
+ export declare function findShadowedUserAdapters(opts?: AdapterShadowOptions): AdapterShadow[];
11
+ export declare function formatAdapterShadowIssue(shadows: AdapterShadow[]): string;
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { findPackageRoot, getCliManifestPath } from './package-paths.js';
6
+ function defaultBuiltinClisDir() {
7
+ return path.join(findPackageRoot(fileURLToPath(import.meta.url)), 'clis');
8
+ }
9
+ function safeReaddir(dir) {
10
+ try {
11
+ return fs.readdirSync(dir, { withFileTypes: true });
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ }
17
+ function loadBuiltinCommandFiles(builtinClisDir) {
18
+ try {
19
+ const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8');
20
+ const entries = JSON.parse(raw);
21
+ const files = new Set();
22
+ for (const entry of entries) {
23
+ const rel = entry.sourceFile ?? entry.modulePath;
24
+ if (rel)
25
+ files.add(path.resolve(builtinClisDir, rel));
26
+ }
27
+ return files;
28
+ }
29
+ catch {
30
+ return new Set();
31
+ }
32
+ }
33
+ export function findShadowedUserAdapters(opts = {}) {
34
+ const userClisDir = opts.userClisDir ?? path.join(os.homedir(), '.opencli', 'clis');
35
+ const builtinClisDir = opts.builtinClisDir ?? defaultBuiltinClisDir();
36
+ const builtinCommandFiles = loadBuiltinCommandFiles(builtinClisDir);
37
+ const shadows = [];
38
+ for (const siteEntry of safeReaddir(userClisDir)) {
39
+ if (!siteEntry.isDirectory())
40
+ continue;
41
+ const site = siteEntry.name;
42
+ const userSiteDir = path.join(userClisDir, site);
43
+ const builtinSiteDir = path.join(builtinClisDir, site);
44
+ for (const commandEntry of safeReaddir(userSiteDir)) {
45
+ if (!commandEntry.isFile() || !commandEntry.name.endsWith('.js'))
46
+ continue;
47
+ const userPath = path.join(userSiteDir, commandEntry.name);
48
+ const builtinPath = path.join(builtinSiteDir, commandEntry.name);
49
+ const builtinResolved = path.resolve(builtinPath);
50
+ if (!builtinCommandFiles.has(builtinResolved))
51
+ continue;
52
+ shadows.push({
53
+ name: `${site}/${commandEntry.name.replace(/\.js$/, '')}`,
54
+ userPath,
55
+ builtinPath,
56
+ });
57
+ }
58
+ }
59
+ return shadows.sort((a, b) => a.name.localeCompare(b.name));
60
+ }
61
+ export function formatAdapterShadowIssue(shadows) {
62
+ const visible = shadows.slice(0, 10);
63
+ const lines = ['Local adapter overrides shadow packaged adapters:'];
64
+ for (const shadow of visible) {
65
+ lines.push(` ${shadow.name}: ${shadow.userPath} overrides ${shadow.builtinPath}`);
66
+ }
67
+ if (shadows.length > visible.length) {
68
+ lines.push(` ... and ${shadows.length - visible.length} more`);
69
+ }
70
+ lines.push('Remove the local ~/.opencli/clis copy, or run opencli adapter reset <site>, when you want packaged updates.');
71
+ return lines.join('\n');
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
6
+ describe('adapter shadow detection', () => {
7
+ it('reports user adapters that shadow packaged manifest commands', () => {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-adapter-shadow-'));
9
+ try {
10
+ const userClisDir = path.join(root, 'user-clis');
11
+ const builtinRoot = path.join(root, 'pkg');
12
+ const builtinClisDir = path.join(builtinRoot, 'clis');
13
+ fs.mkdirSync(path.join(userClisDir, 'instagram'), { recursive: true });
14
+ fs.mkdirSync(path.join(userClisDir, 'twitter'), { recursive: true });
15
+ fs.mkdirSync(path.join(builtinClisDir, 'instagram'), { recursive: true });
16
+ fs.mkdirSync(path.join(builtinClisDir, 'twitter'), { recursive: true });
17
+ fs.writeFileSync(path.join(userClisDir, 'instagram', 'saved.js'), '', 'utf-8');
18
+ fs.writeFileSync(path.join(userClisDir, 'instagram', 'utils.js'), '', 'utf-8');
19
+ fs.writeFileSync(path.join(userClisDir, 'twitter', 'search.js'), '', 'utf-8');
20
+ fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'saved.js'), '', 'utf-8');
21
+ fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'utils.js'), '', 'utf-8');
22
+ fs.writeFileSync(path.join(builtinClisDir, 'twitter', 'search.js'), '', 'utf-8');
23
+ fs.writeFileSync(path.join(builtinRoot, 'cli-manifest.json'), `${JSON.stringify([
24
+ { site: 'instagram', name: 'saved', sourceFile: 'instagram/saved.js' },
25
+ ])}\n`, 'utf-8');
26
+ expect(findShadowedUserAdapters({ userClisDir, builtinClisDir })).toEqual([
27
+ {
28
+ name: 'instagram/saved',
29
+ userPath: path.join(userClisDir, 'instagram', 'saved.js'),
30
+ builtinPath: path.join(builtinClisDir, 'instagram', 'saved.js'),
31
+ },
32
+ ]);
33
+ }
34
+ finally {
35
+ fs.rmSync(root, { recursive: true, force: true });
36
+ }
37
+ });
38
+ it('formats a concise doctor issue', () => {
39
+ const issue = formatAdapterShadowIssue([
40
+ {
41
+ name: 'instagram/saved',
42
+ userPath: '/home/me/.opencli/clis/instagram/saved.js',
43
+ builtinPath: '/pkg/clis/instagram/saved.js',
44
+ },
45
+ ]);
46
+ expect(issue).toContain('instagram/saved');
47
+ expect(issue).toContain('opencli adapter reset <site>');
48
+ });
49
+ });
@@ -47,8 +47,12 @@ export declare abstract class BasePage implements IPage {
47
47
  abstract tabs(): Promise<unknown[]>;
48
48
  abstract selectTab(target: number | string): Promise<void>;
49
49
  click(ref: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
50
- /** Override in subclasses with CDP native click support */
51
- protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
50
+ /** Uses native CDP click support when the concrete page exposes it. */
51
+ protected tryNativeClick(x: number, y: number): Promise<boolean>;
52
+ /** Uses native CDP text insertion when the concrete page exposes it. */
53
+ protected tryNativeType(text: string): Promise<boolean>;
54
+ /** Uses native CDP key events when the concrete page exposes them. */
55
+ protected tryNativeKeyPress(key: string, modifiers: string[]): Promise<boolean>;
52
56
  typeText(ref: string, text: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
53
57
  pressKey(key: string): Promise<void>;
54
58
  scrollTo(ref: string, opts?: ResolveOptions): Promise<unknown>;
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
12
  import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
- import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, prepareNativeTypeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
14
14
  import { TargetError } from './target-errors.js';
15
15
  import { CliError } from '../errors.js';
16
16
  import { formatSnapshot } from '../snapshotFormatter.js';
@@ -36,6 +36,27 @@ function previewText(text) {
36
36
  const preview = (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
37
37
  return preview ? `Response preview: ${preview}` : undefined;
38
38
  }
39
+ function parseKeyChord(rawKey) {
40
+ const parts = rawKey.split('+').map(part => part.trim()).filter(Boolean);
41
+ if (parts.length <= 1)
42
+ return { key: rawKey, modifiers: [] };
43
+ const modifiers = [];
44
+ for (const token of parts.slice(0, -1)) {
45
+ const normalized = token.toLowerCase();
46
+ if (normalized === 'ctrl' || normalized === 'control')
47
+ modifiers.push('Ctrl');
48
+ else if (normalized === 'cmd' || normalized === 'command' || normalized === 'meta')
49
+ modifiers.push('Meta');
50
+ else if (normalized === 'option' || normalized === 'alt')
51
+ modifiers.push('Alt');
52
+ else if (normalized === 'shift')
53
+ modifiers.push('Shift');
54
+ else
55
+ return { key: rawKey, modifiers: [] };
56
+ }
57
+ const key = parts.at(-1);
58
+ return key ? { key, modifiers } : { key: rawKey, modifiers: [] };
59
+ }
39
60
  export class BasePage {
40
61
  _lastUrl = null;
41
62
  /** Cached previous snapshot hashes for incremental diff marking */
@@ -147,17 +168,78 @@ export class BasePage {
147
168
  }
148
169
  throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
149
170
  }
150
- /** Override in subclasses with CDP native click support */
151
- async tryNativeClick(_x, _y) {
152
- return false;
171
+ /** Uses native CDP click support when the concrete page exposes it. */
172
+ async tryNativeClick(x, y) {
173
+ const nativeClick = this.nativeClick;
174
+ if (typeof nativeClick !== 'function')
175
+ return false;
176
+ try {
177
+ await nativeClick.call(this, x, y);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /** Uses native CDP text insertion when the concrete page exposes it. */
185
+ async tryNativeType(text) {
186
+ const nativeType = this.nativeType;
187
+ if (typeof nativeType === 'function') {
188
+ try {
189
+ await nativeType.call(this, text);
190
+ return true;
191
+ }
192
+ catch {
193
+ // Fall through to the older dedicated insertText primitive if present.
194
+ }
195
+ }
196
+ const insertText = this.insertText;
197
+ if (typeof insertText !== 'function')
198
+ return false;
199
+ try {
200
+ await insertText.call(this, text);
201
+ return true;
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ /** Uses native CDP key events when the concrete page exposes them. */
208
+ async tryNativeKeyPress(key, modifiers) {
209
+ const nativeKeyPress = this.nativeKeyPress;
210
+ if (typeof nativeKeyPress !== 'function')
211
+ return false;
212
+ try {
213
+ await nativeKeyPress.call(this, key, modifiers);
214
+ return true;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
153
219
  }
154
220
  async typeText(ref, text, opts = {}) {
155
221
  const resolved = await runResolve(this, ref, opts);
156
- await this.evaluate(typeResolvedJs(text));
222
+ let typed = false;
223
+ if (typeof this.nativeType === 'function' || typeof this.insertText === 'function') {
224
+ try {
225
+ const preparation = await this.evaluate(prepareNativeTypeResolvedJs());
226
+ typed = preparation?.ok === true && await this.tryNativeType(text);
227
+ }
228
+ catch {
229
+ // Native input is a reliability upgrade, not the only path. Preserve
230
+ // the existing DOM setter fallback if preparation fails.
231
+ }
232
+ }
233
+ if (!typed) {
234
+ await this.evaluate(typeResolvedJs(text));
235
+ }
157
236
  return resolved;
158
237
  }
159
238
  async pressKey(key) {
160
- await this.evaluate(pressKeyJs(key));
239
+ const parsed = parseKeyChord(key);
240
+ if (!await this.tryNativeKeyPress(parsed.key, parsed.modifiers)) {
241
+ await this.evaluate(pressKeyJs(parsed.key, parsed.modifiers));
242
+ }
161
243
  }
162
244
  async scrollTo(ref, opts = {}) {
163
245
  const resolved = await runResolve(this, ref, opts);