@jackwener/opencli 1.7.4 → 1.7.6

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 (181) hide show
  1. package/README.md +76 -51
  2. package/README.zh-CN.md +78 -62
  3. package/cli-manifest.json +4558 -2979
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/video.js +61 -0
  8. package/clis/bilibili/video.test.js +81 -0
  9. package/clis/deepseek/ask.js +94 -0
  10. package/clis/deepseek/ask.test.js +73 -0
  11. package/clis/deepseek/history.js +25 -0
  12. package/clis/deepseek/new.js +20 -0
  13. package/clis/deepseek/read.js +22 -0
  14. package/clis/deepseek/status.js +24 -0
  15. package/clis/deepseek/utils.js +291 -0
  16. package/clis/deepseek/utils.test.js +37 -0
  17. package/clis/eastmoney/_secid.js +78 -0
  18. package/clis/eastmoney/announcement.js +52 -0
  19. package/clis/eastmoney/convertible.js +73 -0
  20. package/clis/eastmoney/etf.js +65 -0
  21. package/clis/eastmoney/holders.js +78 -0
  22. package/clis/eastmoney/index-board.js +96 -0
  23. package/clis/eastmoney/kline.js +87 -0
  24. package/clis/eastmoney/kuaixun.js +54 -0
  25. package/clis/eastmoney/longhu.js +67 -0
  26. package/clis/eastmoney/money-flow.js +78 -0
  27. package/clis/eastmoney/northbound.js +57 -0
  28. package/clis/eastmoney/quote.js +107 -0
  29. package/clis/eastmoney/rank.js +94 -0
  30. package/clis/eastmoney/sectors.js +76 -0
  31. package/clis/google-scholar/search.js +58 -0
  32. package/clis/google-scholar/search.test.js +23 -0
  33. package/clis/gov-law/commands.test.js +39 -0
  34. package/clis/gov-law/recent.js +22 -0
  35. package/clis/gov-law/search.js +41 -0
  36. package/clis/gov-law/shared.js +51 -0
  37. package/clis/gov-policy/commands.test.js +27 -0
  38. package/clis/gov-policy/recent.js +47 -0
  39. package/clis/gov-policy/search.js +48 -0
  40. package/clis/jianyu/search.js +139 -3
  41. package/clis/jianyu/search.test.js +25 -0
  42. package/clis/jianyu/shared/procurement-detail.js +15 -0
  43. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  44. package/clis/nowcoder/companies.js +23 -0
  45. package/clis/nowcoder/creators.js +27 -0
  46. package/clis/nowcoder/detail.js +61 -0
  47. package/clis/nowcoder/experience.js +36 -0
  48. package/clis/nowcoder/hot.js +24 -0
  49. package/clis/nowcoder/jobs.js +21 -0
  50. package/clis/nowcoder/notifications.js +29 -0
  51. package/clis/nowcoder/papers.js +40 -0
  52. package/clis/nowcoder/practice.js +37 -0
  53. package/clis/nowcoder/recommend.js +30 -0
  54. package/clis/nowcoder/referral.js +39 -0
  55. package/clis/nowcoder/salary.js +40 -0
  56. package/clis/nowcoder/search.js +49 -0
  57. package/clis/nowcoder/suggest.js +33 -0
  58. package/clis/nowcoder/topics.js +27 -0
  59. package/clis/nowcoder/trending.js +25 -0
  60. package/clis/twitter/list-add.js +337 -0
  61. package/clis/twitter/list-add.test.js +15 -0
  62. package/clis/twitter/list-remove.js +297 -0
  63. package/clis/twitter/list-remove.test.js +14 -0
  64. package/clis/twitter/list-tweets.js +185 -0
  65. package/clis/twitter/list-tweets.test.js +108 -0
  66. package/clis/twitter/lists.js +134 -47
  67. package/clis/twitter/lists.test.js +105 -38
  68. package/clis/twitter/shared.js +7 -2
  69. package/clis/twitter/tweets.js +218 -0
  70. package/clis/twitter/tweets.test.js +125 -0
  71. package/clis/wanfang/search.js +66 -0
  72. package/clis/wanfang/search.test.js +23 -0
  73. package/clis/web/read.js +1 -1
  74. package/clis/weixin/download.js +3 -2
  75. package/clis/xiaohongshu/publish.js +149 -28
  76. package/clis/xiaohongshu/publish.test.js +319 -6
  77. package/clis/xiaoyuzhou/download.js +8 -4
  78. package/clis/xiaoyuzhou/download.test.js +23 -13
  79. package/clis/xiaoyuzhou/episode.js +9 -4
  80. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  81. package/clis/xiaoyuzhou/podcast.js +9 -4
  82. package/clis/xiaoyuzhou/utils.js +0 -40
  83. package/clis/xiaoyuzhou/utils.test.js +15 -75
  84. package/clis/youtube/channel.js +35 -0
  85. package/clis/zsxq/dynamics.js +1 -1
  86. package/clis/zsxq/utils.js +6 -3
  87. package/clis/zsxq/utils.test.js +31 -0
  88. package/dist/src/browser/base-page.d.ts +14 -4
  89. package/dist/src/browser/base-page.js +35 -25
  90. package/dist/src/browser/bridge.d.ts +1 -0
  91. package/dist/src/browser/bridge.js +1 -1
  92. package/dist/src/browser/cdp.d.ts +1 -0
  93. package/dist/src/browser/cdp.js +13 -4
  94. package/dist/src/browser/compound.d.ts +59 -0
  95. package/dist/src/browser/compound.js +112 -0
  96. package/dist/src/browser/compound.test.js +175 -0
  97. package/dist/src/browser/daemon-client.d.ts +6 -4
  98. package/dist/src/browser/daemon-client.js +6 -1
  99. package/dist/src/browser/daemon-client.test.js +40 -1
  100. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  101. package/dist/src/browser/dom-snapshot.js +83 -5
  102. package/dist/src/browser/dom-snapshot.test.js +65 -0
  103. package/dist/src/browser/extract.d.ts +69 -0
  104. package/dist/src/browser/extract.js +132 -0
  105. package/dist/src/browser/extract.test.js +129 -0
  106. package/dist/src/browser/find.d.ts +76 -0
  107. package/dist/src/browser/find.js +179 -0
  108. package/dist/src/browser/find.test.js +120 -0
  109. package/dist/src/browser/html-tree.d.ts +75 -0
  110. package/dist/src/browser/html-tree.js +112 -0
  111. package/dist/src/browser/html-tree.test.d.ts +1 -0
  112. package/dist/src/browser/html-tree.test.js +181 -0
  113. package/dist/src/browser/network-cache.d.ts +48 -0
  114. package/dist/src/browser/network-cache.js +66 -0
  115. package/dist/src/browser/network-cache.test.d.ts +1 -0
  116. package/dist/src/browser/network-cache.test.js +58 -0
  117. package/dist/src/browser/network-key.d.ts +22 -0
  118. package/dist/src/browser/network-key.js +66 -0
  119. package/dist/src/browser/network-key.test.d.ts +1 -0
  120. package/dist/src/browser/network-key.test.js +49 -0
  121. package/dist/src/browser/page.d.ts +14 -4
  122. package/dist/src/browser/page.js +48 -7
  123. package/dist/src/browser/page.test.js +97 -0
  124. package/dist/src/browser/shape-filter.d.ts +52 -0
  125. package/dist/src/browser/shape-filter.js +101 -0
  126. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  127. package/dist/src/browser/shape-filter.test.js +101 -0
  128. package/dist/src/browser/shape.d.ts +23 -0
  129. package/dist/src/browser/shape.js +95 -0
  130. package/dist/src/browser/shape.test.d.ts +1 -0
  131. package/dist/src/browser/shape.test.js +82 -0
  132. package/dist/src/browser/target-errors.d.ts +14 -1
  133. package/dist/src/browser/target-errors.js +13 -0
  134. package/dist/src/browser/target-errors.test.js +39 -6
  135. package/dist/src/browser/target-resolver.d.ts +57 -10
  136. package/dist/src/browser/target-resolver.js +195 -75
  137. package/dist/src/browser/target-resolver.test.js +80 -5
  138. package/dist/src/cli.js +849 -267
  139. package/dist/src/cli.test.js +961 -90
  140. package/dist/src/commanderAdapter.d.ts +0 -1
  141. package/dist/src/commanderAdapter.js +2 -16
  142. package/dist/src/commanderAdapter.test.js +1 -1
  143. package/dist/src/completion-shared.js +2 -5
  144. package/dist/src/daemon.js +8 -0
  145. package/dist/src/download/article-download.d.ts +1 -0
  146. package/dist/src/download/article-download.js +3 -0
  147. package/dist/src/download/article-download.test.d.ts +1 -0
  148. package/dist/src/download/article-download.test.js +39 -0
  149. package/dist/src/execution.js +7 -2
  150. package/dist/src/execution.test.js +54 -0
  151. package/dist/src/main.js +16 -0
  152. package/dist/src/plugin.d.ts +1 -8
  153. package/dist/src/plugin.js +1 -27
  154. package/dist/src/plugin.test.js +1 -59
  155. package/dist/src/registry.d.ts +1 -0
  156. package/dist/src/registry.js +3 -2
  157. package/dist/src/registry.test.js +22 -0
  158. package/dist/src/types.d.ts +32 -8
  159. package/package.json +1 -1
  160. package/clis/twitter/lists-parser.js +0 -77
  161. package/clis/twitter/lists.d.ts +0 -5
  162. package/dist/src/cascade.d.ts +0 -46
  163. package/dist/src/cascade.js +0 -135
  164. package/dist/src/explore.d.ts +0 -99
  165. package/dist/src/explore.js +0 -402
  166. package/dist/src/generate-verified.d.ts +0 -105
  167. package/dist/src/generate-verified.js +0 -696
  168. package/dist/src/generate-verified.test.js +0 -925
  169. package/dist/src/generate.d.ts +0 -46
  170. package/dist/src/generate.js +0 -117
  171. package/dist/src/record.d.ts +0 -96
  172. package/dist/src/record.js +0 -657
  173. package/dist/src/record.test.js +0 -293
  174. package/dist/src/skill-generate.d.ts +0 -30
  175. package/dist/src/skill-generate.js +0 -75
  176. package/dist/src/skill-generate.test.js +0 -173
  177. package/dist/src/synthesize.d.ts +0 -97
  178. package/dist/src/synthesize.js +0 -208
  179. /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
  180. /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
  181. /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Stable keys for network capture entries.
3
+ *
4
+ * Agents reference entries by key (e.g. `UserTweets`, `GET api.x.com/1.1/home`)
5
+ * instead of array index, so the mapping survives new captures.
6
+ *
7
+ * Rules:
8
+ * GraphQL (URL contains `/graphql/`): key = operationName derived from URL path
9
+ * (the segment after a 22-char query id, or the last segment)
10
+ * Everything else: key = `METHOD host+pathname`
11
+ *
12
+ * On collision assignKeys suffixes duplicates as `base#2`, `base#3`, ... —
13
+ * the first occurrence stays bare (there is no `#1`).
14
+ */
15
+ export interface KeyableRequest {
16
+ url: string;
17
+ method: string;
18
+ }
19
+ export declare function deriveKey(req: KeyableRequest): string;
20
+ export declare function assignKeys<T extends KeyableRequest>(requests: T[]): Array<T & {
21
+ key: string;
22
+ }>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Stable keys for network capture entries.
3
+ *
4
+ * Agents reference entries by key (e.g. `UserTweets`, `GET api.x.com/1.1/home`)
5
+ * instead of array index, so the mapping survives new captures.
6
+ *
7
+ * Rules:
8
+ * GraphQL (URL contains `/graphql/`): key = operationName derived from URL path
9
+ * (the segment after a 22-char query id, or the last segment)
10
+ * Everything else: key = `METHOD host+pathname`
11
+ *
12
+ * On collision assignKeys suffixes duplicates as `base#2`, `base#3`, ... —
13
+ * the first occurrence stays bare (there is no `#1`).
14
+ */
15
+ export function deriveKey(req) {
16
+ const parsed = safeParseUrl(req.url);
17
+ if (!parsed)
18
+ return `${req.method.toUpperCase()} ${truncate(req.url, 120)}`;
19
+ const path = parsed.pathname;
20
+ if (path.includes('/graphql/')) {
21
+ const op = graphqlOperationName(path);
22
+ if (op)
23
+ return op;
24
+ }
25
+ return `${req.method.toUpperCase()} ${parsed.host}${path}`;
26
+ }
27
+ export function assignKeys(requests) {
28
+ const counts = new Map();
29
+ const out = [];
30
+ for (const req of requests) {
31
+ const base = deriveKey(req);
32
+ const n = counts.get(base) ?? 0;
33
+ counts.set(base, n + 1);
34
+ const key = n === 0 ? base : `${base}#${n + 1}`;
35
+ out.push({ ...req, key });
36
+ }
37
+ return out;
38
+ }
39
+ function graphqlOperationName(pathname) {
40
+ // Patterns we've seen in the wild:
41
+ // /i/api/graphql/<queryId>/UserTweets
42
+ // /graphql/<queryId>/SomeOp
43
+ // /graphql/SomeOp (rare, no id)
44
+ const segments = pathname.split('/').filter(Boolean);
45
+ const idx = segments.indexOf('graphql');
46
+ if (idx < 0)
47
+ return null;
48
+ const tail = segments.slice(idx + 1);
49
+ if (tail.length === 0)
50
+ return null;
51
+ if (tail.length === 1)
52
+ return tail[0];
53
+ // tail[0] is usually a query id; the operation name is the next segment.
54
+ return tail[1] || tail[0];
55
+ }
56
+ function safeParseUrl(url) {
57
+ try {
58
+ return new URL(url);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function truncate(s, max) {
65
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assignKeys, deriveKey } from './network-key.js';
3
+ describe('deriveKey', () => {
4
+ it('extracts operationName from Twitter-style graphql URLs', () => {
5
+ expect(deriveKey({
6
+ method: 'GET',
7
+ url: 'https://x.com/i/api/graphql/6fWQaBPK51aGyC_VC7t9GQ/UserTweets?variables=...',
8
+ })).toBe('UserTweets');
9
+ });
10
+ it('handles graphql URLs without a query id', () => {
11
+ expect(deriveKey({
12
+ method: 'POST',
13
+ url: 'https://example.com/graphql/MyOp?vars=1',
14
+ })).toBe('MyOp');
15
+ });
16
+ it('uses METHOD host+pathname for REST calls', () => {
17
+ expect(deriveKey({
18
+ method: 'get',
19
+ url: 'https://api.example.com/v1/users?page=1',
20
+ })).toBe('GET api.example.com/v1/users');
21
+ });
22
+ it('falls back to truncated raw url when URL parsing fails', () => {
23
+ const key = deriveKey({ method: 'GET', url: 'not-a-valid-url' });
24
+ expect(key.startsWith('GET ')).toBe(true);
25
+ expect(key).toContain('not-a-valid-url');
26
+ });
27
+ });
28
+ describe('assignKeys', () => {
29
+ it('disambiguates collisions with #N suffixes', () => {
30
+ const out = assignKeys([
31
+ { url: 'https://x.com/i/api/graphql/a/UserTweets', method: 'GET' },
32
+ { url: 'https://x.com/i/api/graphql/b/UserTweets', method: 'GET' },
33
+ { url: 'https://api.example.com/v1/u', method: 'GET' },
34
+ { url: 'https://api.example.com/v1/u', method: 'GET' },
35
+ { url: 'https://api.example.com/v1/u', method: 'GET' },
36
+ ]);
37
+ expect(out.map(o => o.key)).toEqual([
38
+ 'UserTweets',
39
+ 'UserTweets#2',
40
+ 'GET api.example.com/v1/u',
41
+ 'GET api.example.com/v1/u#2',
42
+ 'GET api.example.com/v1/u#3',
43
+ ]);
44
+ });
45
+ it('preserves extra fields on each request', () => {
46
+ const out = assignKeys([{ url: 'https://a.com/x', method: 'GET', status: 200 }]);
47
+ expect(out[0]).toMatchObject({ status: 200, key: 'GET a.com/x' });
48
+ });
49
+ });
@@ -15,7 +15,8 @@ import { BasePage } from './base-page.js';
15
15
  */
16
16
  export declare class Page extends BasePage {
17
17
  private readonly workspace;
18
- constructor(workspace?: string);
18
+ private readonly _idleTimeout;
19
+ constructor(workspace?: string, idleTimeout?: number);
19
20
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
20
21
  private _page;
21
22
  private _networkCaptureUnsupported;
@@ -30,8 +31,8 @@ export declare class Page extends BasePage {
30
31
  }): Promise<void>;
31
32
  /** Get the active page identity (targetId) */
32
33
  getActivePage(): string | undefined;
33
- /** @deprecated Use getActivePage() instead */
34
- getActiveTabId(): number | undefined;
34
+ /** Bind this Page instance to a specific page identity (targetId). */
35
+ setActivePage(page?: string): void;
35
36
  private _markUnsupportedNetworkCapture;
36
37
  evaluate(js: string): Promise<unknown>;
37
38
  getCookies(opts?: {
@@ -41,7 +42,9 @@ export declare class Page extends BasePage {
41
42
  /** Close the automation window in the extension */
42
43
  closeWindow(): Promise<void>;
43
44
  tabs(): Promise<unknown[]>;
44
- selectTab(index: number): Promise<void>;
45
+ newTab(url?: string): Promise<string | undefined>;
46
+ closeTab(target?: number | string): Promise<void>;
47
+ selectTab(target: number | string): Promise<void>;
45
48
  /**
46
49
  * Capture a screenshot via CDP Page.captureScreenshot.
47
50
  */
@@ -55,6 +58,13 @@ export declare class Page extends BasePage {
55
58
  */
56
59
  setFileInput(files: string[], selector?: string): Promise<void>;
57
60
  insertText(text: string): Promise<void>;
61
+ frames(): Promise<Array<{
62
+ index: number;
63
+ frameId: string;
64
+ url: string;
65
+ name: string;
66
+ }>>;
67
+ evaluateInFrame(js: string, frameIndex: number): Promise<unknown>;
58
68
  cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
59
69
  /** CDP native click fallback — called when JS el.click() fails */
60
70
  protected tryNativeClick(x: number, y: number): Promise<boolean>;
@@ -27,9 +27,11 @@ function isUnsupportedNetworkCaptureError(err) {
27
27
  */
28
28
  export class Page extends BasePage {
29
29
  workspace;
30
- constructor(workspace = 'default') {
30
+ _idleTimeout;
31
+ constructor(workspace = 'default', idleTimeout) {
31
32
  super();
32
33
  this.workspace = workspace;
34
+ this._idleTimeout = idleTimeout;
33
35
  }
34
36
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
35
37
  _page;
@@ -37,13 +39,14 @@ export class Page extends BasePage {
37
39
  _networkCaptureWarned = false;
38
40
  /** Helper: spread workspace into command params */
39
41
  _wsOpt() {
40
- return { workspace: this.workspace };
42
+ return { workspace: this.workspace, ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }) };
41
43
  }
42
44
  /** Helper: spread workspace + page identity into command params */
43
45
  _cmdOpts() {
44
46
  return {
45
47
  workspace: this.workspace,
46
48
  ...(this._page !== undefined && { page: this._page }),
49
+ ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
47
50
  };
48
51
  }
49
52
  async goto(url, options) {
@@ -102,9 +105,10 @@ export class Page extends BasePage {
102
105
  getActivePage() {
103
106
  return this._page;
104
107
  }
105
- /** @deprecated Use getActivePage() instead */
106
- getActiveTabId() {
107
- return undefined;
108
+ /** Bind this Page instance to a specific page identity (targetId). */
109
+ setActivePage(page) {
110
+ this._page = page;
111
+ this._lastUrl = null;
108
112
  }
109
113
  _markUnsupportedNetworkCapture() {
110
114
  this._networkCaptureUnsupported = true;
@@ -150,10 +154,39 @@ export class Page extends BasePage {
150
154
  const result = await sendCommand('tabs', { op: 'list', ...this._wsOpt() });
151
155
  return Array.isArray(result) ? result : [];
152
156
  }
153
- async selectTab(index) {
154
- const result = await sendCommandFull('tabs', { op: 'select', index, ...this._wsOpt() });
157
+ async newTab(url) {
158
+ const result = await sendCommandFull('tabs', {
159
+ op: 'new',
160
+ ...(url !== undefined && { url }),
161
+ ...this._wsOpt(),
162
+ });
163
+ this._lastUrl = null;
164
+ return result.page;
165
+ }
166
+ async closeTab(target) {
167
+ const params = { op: 'close', ...this._wsOpt() };
168
+ if (typeof target === 'number')
169
+ params.index = target;
170
+ else if (typeof target === 'string')
171
+ params.page = target;
172
+ else if (this._page !== undefined)
173
+ params.page = this._page;
174
+ const result = await sendCommand('tabs', params);
175
+ const closedPage = typeof result?.closed === 'string' ? result.closed : undefined;
176
+ if ((closedPage && closedPage === this._page) || (!closedPage && (target === undefined || target === this._page))) {
177
+ this._page = undefined;
178
+ this._lastUrl = null;
179
+ }
180
+ }
181
+ async selectTab(target) {
182
+ const result = await sendCommandFull('tabs', {
183
+ op: 'select',
184
+ ...(typeof target === 'number' ? { index: target } : { page: target }),
185
+ ...this._wsOpt(),
186
+ });
155
187
  if (result.page)
156
188
  this._page = result.page;
189
+ this._lastUrl = null;
157
190
  }
158
191
  /**
159
192
  * Capture a screenshot via CDP Page.captureScreenshot.
@@ -227,6 +260,14 @@ export class Page extends BasePage {
227
260
  throw new Error('insertText returned no inserted flag — command may not be supported by the extension');
228
261
  }
229
262
  }
263
+ async frames() {
264
+ const result = await sendCommand('frames', { ...this._cmdOpts() });
265
+ return Array.isArray(result) ? result : [];
266
+ }
267
+ async evaluateInFrame(js, frameIndex) {
268
+ const code = wrapForEval(js);
269
+ return sendCommand('exec', { code, frameIndex, ...this._cmdOpts() });
270
+ }
230
271
  async cdp(method, params = {}) {
231
272
  return sendCommand('cdp', {
232
273
  cdpMethod: method,
@@ -102,3 +102,100 @@ describe('Page network capture compatibility', () => {
102
102
  expect(warnMock).toHaveBeenCalledTimes(1);
103
103
  });
104
104
  });
105
+ describe('Page active target tracking', () => {
106
+ beforeEach(() => {
107
+ sendCommandMock.mockReset();
108
+ sendCommandFullMock.mockReset();
109
+ warnMock.mockReset();
110
+ });
111
+ it('tracks only one active page identity at a time', async () => {
112
+ sendCommandFullMock
113
+ .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
114
+ .mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
115
+ sendCommandMock.mockResolvedValue('ok');
116
+ const page = new Page('browser:default');
117
+ await page.goto('https://first.example', { waitUntil: 'none' });
118
+ expect(page.getActivePage()).toBe('page-1');
119
+ await page.selectTab(1);
120
+ expect(page.getActivePage()).toBe('page-2');
121
+ await page.evaluate('1 + 1');
122
+ expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
123
+ workspace: 'browser:default',
124
+ page: 'page-2',
125
+ }));
126
+ });
127
+ it('allows the caller to bind a specific active page identity explicitly', async () => {
128
+ sendCommandMock.mockResolvedValue('bound');
129
+ const page = new Page('browser:default');
130
+ page.setActivePage?.('page-explicit');
131
+ await page.evaluate('1 + 1');
132
+ expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
133
+ workspace: 'browser:default',
134
+ page: 'page-explicit',
135
+ }));
136
+ });
137
+ it('creates a new tab without changing the current active page binding', async () => {
138
+ sendCommandFullMock
139
+ .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
140
+ .mockResolvedValueOnce({
141
+ data: { url: 'https://second.example' },
142
+ page: 'page-2',
143
+ });
144
+ sendCommandMock.mockResolvedValue('ok');
145
+ const page = new Page('browser:default');
146
+ await page.goto('https://first.example', { waitUntil: 'none' });
147
+ const created = await page.newTab?.('https://second.example');
148
+ expect(created).toBe('page-2');
149
+ expect(page.getActivePage()).toBe('page-1');
150
+ await page.evaluate('1 + 1');
151
+ expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
152
+ workspace: 'browser:default',
153
+ page: 'page-1',
154
+ }));
155
+ });
156
+ it('allows the caller to adopt a new tab explicitly after creation', async () => {
157
+ sendCommandFullMock.mockResolvedValueOnce({
158
+ data: { url: 'https://second.example' },
159
+ page: 'page-2',
160
+ });
161
+ const page = new Page('browser:default');
162
+ const created = await page.newTab?.('https://second.example');
163
+ expect(created).toBe('page-2');
164
+ expect(page.getActivePage()).toBeUndefined();
165
+ page.setActivePage?.(created);
166
+ expect(page.getActivePage()).toBe('page-2');
167
+ expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
168
+ op: 'new',
169
+ url: 'https://second.example',
170
+ workspace: 'browser:default',
171
+ }));
172
+ });
173
+ it('closes a tab by explicit page identity', async () => {
174
+ sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' });
175
+ const page = new Page('browser:default');
176
+ await page.closeTab?.('page-2');
177
+ expect(sendCommandMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
178
+ op: 'close',
179
+ workspace: 'browser:default',
180
+ page: 'page-2',
181
+ }));
182
+ });
183
+ it('clears the active page binding when closing the selected tab by numeric index', async () => {
184
+ sendCommandFullMock.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
185
+ sendCommandMock
186
+ .mockResolvedValueOnce({ closed: 'page-2' })
187
+ .mockResolvedValueOnce('ok');
188
+ const page = new Page('browser:default');
189
+ await page.selectTab(1);
190
+ expect(page.getActivePage()).toBe('page-2');
191
+ await page.closeTab?.(1);
192
+ expect(page.getActivePage()).toBeUndefined();
193
+ await page.evaluate('1 + 1');
194
+ const evalCall = sendCommandMock.mock.calls.at(-1);
195
+ expect(evalCall?.[0]).toBe('exec');
196
+ expect(evalCall?.[1]).toEqual(expect.objectContaining({
197
+ workspace: 'browser:default',
198
+ }));
199
+ expect(evalCall?.[1]).not.toHaveProperty('page');
200
+ });
201
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shape-based field filter for `browser network --filter <fields>`.
3
+ *
4
+ * Agents know what fields a target request's body should contain
5
+ * (e.g. "author, text, likes") but not which of the captured requests
6
+ * carries that body. This module lets the network command filter
7
+ * entries down to those whose inferred shape exposes every requested
8
+ * field name as some path segment.
9
+ *
10
+ * Matching is "any-segment" (not last-segment-only): a field matches
11
+ * if it equals any segment name of any path in the shape map. This
12
+ * keeps nested-container fields (e.g. `legacy`, `author` used as an
13
+ * object key with further nesting) findable.
14
+ */
15
+ import type { Shape } from './shape.js';
16
+ export interface ParsedFilter {
17
+ /** Deduped, order-preserving, trimmed non-empty field names. */
18
+ fields: string[];
19
+ }
20
+ export interface FilterParseError {
21
+ /** `invalid_filter` structured error reason for agents. */
22
+ reason: string;
23
+ }
24
+ /**
25
+ * Parse `--filter` argument value. Splits on `,`, trims, drops empties,
26
+ * and dedupes (first-seen wins). Returns `FilterParseError` when the
27
+ * result is empty after cleaning — which means the caller passed only
28
+ * whitespace, commas, or an empty string.
29
+ */
30
+ export declare function parseFilter(raw: string): ParsedFilter | FilterParseError;
31
+ /**
32
+ * Extract named segments from a shape path. Drops the leading `$`,
33
+ * strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
34
+ * keys back to their raw string.
35
+ *
36
+ * Examples:
37
+ * `$` → []
38
+ * `$.data.items[0].author` → ['data','items','author']
39
+ * `$.data.user["nick name"]` → ['data','user','nick name']
40
+ * `$.rows[0][1]` → ['rows']
41
+ */
42
+ export declare function extractSegments(path: string): string[];
43
+ /**
44
+ * Collect the set of segment names used anywhere in a shape map.
45
+ * The returned set is what we test field membership against.
46
+ */
47
+ export declare function collectShapeSegments(shape: Shape): Set<string>;
48
+ /**
49
+ * True iff every field in `fields` equals some segment name in `shape`.
50
+ * AND semantics: all fields must be present.
51
+ */
52
+ export declare function shapeMatchesFilter(shape: Shape, fields: string[]): boolean;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Parse `--filter` argument value. Splits on `,`, trims, drops empties,
3
+ * and dedupes (first-seen wins). Returns `FilterParseError` when the
4
+ * result is empty after cleaning — which means the caller passed only
5
+ * whitespace, commas, or an empty string.
6
+ */
7
+ export function parseFilter(raw) {
8
+ if (typeof raw !== 'string') {
9
+ return { reason: `--filter value must be a non-empty comma-separated field list` };
10
+ }
11
+ const parts = raw.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
12
+ if (parts.length === 0) {
13
+ return { reason: `--filter value must be a non-empty comma-separated field list (got "${raw}")` };
14
+ }
15
+ const seen = new Set();
16
+ const fields = [];
17
+ for (const p of parts) {
18
+ if (!seen.has(p)) {
19
+ seen.add(p);
20
+ fields.push(p);
21
+ }
22
+ }
23
+ return { fields };
24
+ }
25
+ /**
26
+ * Extract named segments from a shape path. Drops the leading `$`,
27
+ * strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
28
+ * keys back to their raw string.
29
+ *
30
+ * Examples:
31
+ * `$` → []
32
+ * `$.data.items[0].author` → ['data','items','author']
33
+ * `$.data.user["nick name"]` → ['data','user','nick name']
34
+ * `$.rows[0][1]` → ['rows']
35
+ */
36
+ export function extractSegments(path) {
37
+ if (!path || path === '$')
38
+ return [];
39
+ const out = [];
40
+ // Start past the leading `$`; if path doesn't start with `$` treat
41
+ // it as a raw segment list (keeps us robust to unexpected input).
42
+ let i = path.startsWith('$') ? 1 : 0;
43
+ while (i < path.length) {
44
+ const c = path[i];
45
+ if (c === '.') {
46
+ i++;
47
+ continue;
48
+ }
49
+ if (c === '[') {
50
+ // Either `[N]` (numeric) or `["key"]` (quoted key). Handle both.
51
+ const end = path.indexOf(']', i);
52
+ if (end === -1)
53
+ break;
54
+ const inner = path.slice(i + 1, end);
55
+ i = end + 1;
56
+ if (inner.length >= 2 && inner.startsWith('"') && inner.endsWith('"')) {
57
+ try {
58
+ out.push(JSON.parse(inner));
59
+ }
60
+ catch {
61
+ out.push(inner.slice(1, -1));
62
+ }
63
+ }
64
+ // numeric index: drop
65
+ continue;
66
+ }
67
+ // Bare identifier: read up to next `.` or `[`
68
+ let j = i;
69
+ while (j < path.length && path[j] !== '.' && path[j] !== '[')
70
+ j++;
71
+ out.push(path.slice(i, j));
72
+ i = j;
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Collect the set of segment names used anywhere in a shape map.
78
+ * The returned set is what we test field membership against.
79
+ */
80
+ export function collectShapeSegments(shape) {
81
+ const acc = new Set();
82
+ for (const p of Object.keys(shape)) {
83
+ for (const seg of extractSegments(p))
84
+ acc.add(seg);
85
+ }
86
+ return acc;
87
+ }
88
+ /**
89
+ * True iff every field in `fields` equals some segment name in `shape`.
90
+ * AND semantics: all fields must be present.
91
+ */
92
+ export function shapeMatchesFilter(shape, fields) {
93
+ if (fields.length === 0)
94
+ return true;
95
+ const segs = collectShapeSegments(shape);
96
+ for (const f of fields) {
97
+ if (!segs.has(f))
98
+ return false;
99
+ }
100
+ return true;
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectShapeSegments, extractSegments, parseFilter, shapeMatchesFilter, } from './shape-filter.js';
3
+ describe('parseFilter', () => {
4
+ it('splits comma-separated fields, trims, and drops empty tokens', () => {
5
+ const r = parseFilter('author, text , likes');
6
+ expect(r).toEqual({ fields: ['author', 'text', 'likes'] });
7
+ });
8
+ it('dedupes while preserving first-seen order', () => {
9
+ const r = parseFilter('a,b,a,c,b');
10
+ expect(r).toEqual({ fields: ['a', 'b', 'c'] });
11
+ });
12
+ it('rejects empty string as invalid_filter', () => {
13
+ const r = parseFilter('');
14
+ expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
15
+ });
16
+ it('rejects whitespace-only as invalid_filter', () => {
17
+ const r = parseFilter(' ');
18
+ expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
19
+ });
20
+ it('rejects commas-only as invalid_filter', () => {
21
+ const r = parseFilter(',,,');
22
+ expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
23
+ });
24
+ it('accepts a single field', () => {
25
+ expect(parseFilter('author')).toEqual({ fields: ['author'] });
26
+ });
27
+ });
28
+ describe('extractSegments', () => {
29
+ it('returns empty for root', () => {
30
+ expect(extractSegments('$')).toEqual([]);
31
+ });
32
+ it('splits dotted path and drops $', () => {
33
+ expect(extractSegments('$.data.user.name')).toEqual(['data', 'user', 'name']);
34
+ });
35
+ it('drops numeric array indices', () => {
36
+ expect(extractSegments('$.items[0].author')).toEqual(['items', 'author']);
37
+ expect(extractSegments('$.rows[0][12]')).toEqual(['rows']);
38
+ });
39
+ it('unwraps bracket-quoted keys', () => {
40
+ expect(extractSegments('$.data["weird key"]')).toEqual(['data', 'weird key']);
41
+ });
42
+ it('handles bracket-quoted keys at root', () => {
43
+ expect(extractSegments('$["123bad"]')).toEqual(['123bad']);
44
+ });
45
+ it('mixes bracket keys and dot segments', () => {
46
+ expect(extractSegments('$.data.user["nick name"].age'))
47
+ .toEqual(['data', 'user', 'nick name', 'age']);
48
+ });
49
+ });
50
+ describe('collectShapeSegments', () => {
51
+ it('collects every segment name from every path in a shape', () => {
52
+ const shape = {
53
+ '$': 'object',
54
+ '$.data': 'object',
55
+ '$.data.items': 'array(3)',
56
+ '$.data.items[0]': 'object',
57
+ '$.data.items[0].author': 'string',
58
+ '$.data.items[0].text': 'string',
59
+ };
60
+ const segs = collectShapeSegments(shape);
61
+ expect(segs.has('data')).toBe(true);
62
+ expect(segs.has('items')).toBe(true);
63
+ expect(segs.has('author')).toBe(true);
64
+ expect(segs.has('text')).toBe(true);
65
+ expect(segs.has('$')).toBe(false);
66
+ expect(segs.has('[0]')).toBe(false);
67
+ });
68
+ it('returns an empty set for an empty shape', () => {
69
+ expect(collectShapeSegments({}).size).toBe(0);
70
+ });
71
+ });
72
+ describe('shapeMatchesFilter', () => {
73
+ const shape = {
74
+ '$': 'object',
75
+ '$.data': 'object',
76
+ '$.data.items': 'array(1)',
77
+ '$.data.items[0].author': 'string',
78
+ '$.data.items[0].text': 'string',
79
+ '$.data.items[0].likes': 'number',
80
+ };
81
+ it('returns true when every field matches some path segment (AND)', () => {
82
+ expect(shapeMatchesFilter(shape, ['author', 'text', 'likes'])).toBe(true);
83
+ });
84
+ it('matches nested container names, not just leaves (any-segment rule)', () => {
85
+ // `data` and `items` are container segments, not leaves; still must match.
86
+ expect(shapeMatchesFilter(shape, ['data', 'items'])).toBe(true);
87
+ });
88
+ it('returns false when any field is missing', () => {
89
+ expect(shapeMatchesFilter(shape, ['author', 'missing'])).toBe(false);
90
+ });
91
+ it('is case-sensitive', () => {
92
+ expect(shapeMatchesFilter(shape, ['Author'])).toBe(false);
93
+ expect(shapeMatchesFilter(shape, ['author'])).toBe(true);
94
+ });
95
+ it('empty filter list vacuously matches', () => {
96
+ expect(shapeMatchesFilter(shape, [])).toBe(true);
97
+ });
98
+ it('rejects requests whose shape has no body (empty shape)', () => {
99
+ expect(shapeMatchesFilter({}, ['author'])).toBe(false);
100
+ });
101
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * JSON shape inference for browser network response previews.
3
+ *
4
+ * Produces a flat path → type descriptor map so agents can understand
5
+ * response structure without paying the token cost of the full body.
6
+ *
7
+ * Descriptors:
8
+ * string | number | boolean | null primitives
9
+ * string(len=N) strings longer than sampleStringLen
10
+ * array(0) | array(N) array at depth cap or summarized
11
+ * object | object(empty) objects at depth cap or summarized
12
+ * (truncated) output size budget exceeded
13
+ */
14
+ export interface InferShapeOptions {
15
+ /** Max path depth to descend into (default 6) */
16
+ maxDepth?: number;
17
+ /** Byte budget for the serialized output; truncates when exceeded (default 2048) */
18
+ maxBytes?: number;
19
+ /** Strings longer than this get summarized as `string(len=N)` (default 80) */
20
+ sampleStringLen?: number;
21
+ }
22
+ export type Shape = Record<string, string>;
23
+ export declare function inferShape(value: unknown, opts?: InferShapeOptions): Shape;