@jackwener/opencli 1.5.8 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
package/src/record.ts CHANGED
@@ -35,6 +35,14 @@ export interface RecordedRequest {
35
35
  url: string;
36
36
  method: string;
37
37
  status: number | null;
38
+ /** Request content type captured at record time, if available. */
39
+ requestContentType: string | null;
40
+ /** Response content type captured at record time, if available. */
41
+ responseContentType: string | null;
42
+ /** Parsed JSON request body for replayable write requests. */
43
+ requestBody: unknown;
44
+ /** Parsed JSON response body captured from the network call. */
45
+ responseBody: unknown;
38
46
  contentType: string;
39
47
  body: unknown;
40
48
  capturedAt: number;
@@ -49,13 +57,109 @@ export interface RecordResult {
49
57
  candidates: Array<{ name: string; path: string; strategy: string }>;
50
58
  }
51
59
 
60
+ type RecordedCandidateKind = 'read' | 'write';
61
+
62
+ export interface RecordedCandidate {
63
+ kind: RecordedCandidateKind;
64
+ req: RecordedRequest;
65
+ score: number;
66
+ arrayResult: ReturnType<typeof findArrayPath> | null;
67
+ }
68
+
69
+ interface GeneratedRecordedCandidate {
70
+ kind: RecordedCandidateKind;
71
+ name: string;
72
+ strategy: string;
73
+ yaml: unknown;
74
+ }
75
+
76
+ /** Keep the stronger candidate when multiple recordings share one bucket. */
77
+ function preferRecordedCandidate(current: RecordedCandidate, next: RecordedCandidate): RecordedCandidate {
78
+ if (next.score > current.score) return next;
79
+ if (next.score < current.score) return current;
80
+ return next;
81
+ }
82
+
83
+ /** Apply shared endpoint score tweaks. */
84
+ function applyCommonEndpointScoreAdjustments(req: RecordedRequest, score: number): number {
85
+ let adjusted = score;
86
+ if (req.url.includes('/api/')) adjusted += 3;
87
+ if (req.url.match(/\/(track|log|analytics|beacon|pixel|stats|metric)/i)) adjusted -= 10;
88
+ if (req.url.match(/\/(ping|heartbeat|keep.?alive)/i)) adjusted -= 10;
89
+ return adjusted;
90
+ }
91
+
92
+ /** Build a candidate-level dedupe key. */
93
+ function getRecordedCandidateKey(candidate: RecordedCandidate): string {
94
+ return `${candidate.kind} ${getRecordedRequestKey(candidate.req)}`;
95
+ }
96
+
97
+ /** Build a request dedupe key from method and URL pattern. */
98
+ function getRecordedRequestKey(req: RecordedRequest): string {
99
+ return `${req.method.toUpperCase()} ${urlToPattern(req.url)}`;
100
+ }
101
+
102
+ /** Deduplicate recorded requests by method and URL pattern. */
103
+ function dedupeRecordedRequests(requests: RecordedRequest[]): RecordedRequest[] {
104
+ const deduped = new Map<string, RecordedRequest>();
105
+ for (const req of requests) {
106
+ deduped.set(getRecordedRequestKey(req), req);
107
+ }
108
+ return [...deduped.values()];
109
+ }
110
+
111
+ /** Check whether a content type should be treated as JSON. */
112
+ function isJsonContentType(contentType: string | null | undefined): boolean {
113
+ const normalized = contentType?.toLowerCase() ?? '';
114
+ return normalized.includes('application/json') || normalized.includes('+json');
115
+ }
116
+
117
+ /** Parse a captured request body only when the request advertises JSON. */
118
+ function parseJsonBodyText(contentType: string | null | undefined, raw: string | null | undefined): unknown {
119
+ if (!isJsonContentType(contentType)) return null;
120
+ if (!raw || !raw.trim()) return null;
121
+ try {
122
+ return JSON.parse(raw);
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Build one normalized recorded entry from captured request and response values. */
129
+ export function createRecordedEntry(input: {
130
+ url: string;
131
+ method: string;
132
+ requestContentType?: string | null;
133
+ requestBodyText?: string | null;
134
+ responseBody: unknown;
135
+ responseContentType?: string | null;
136
+ status?: number | null;
137
+ capturedAt?: number;
138
+ }): RecordedRequest {
139
+ const requestBody = parseJsonBodyText(input.requestContentType ?? null, input.requestBodyText ?? null);
140
+ const responseContentType = input.responseContentType ?? 'application/json';
141
+ return {
142
+ url: input.url,
143
+ method: input.method.toUpperCase(),
144
+ status: input.status ?? null,
145
+ requestContentType: input.requestContentType ?? null,
146
+ responseContentType,
147
+ requestBody,
148
+ responseBody: input.responseBody,
149
+ // Keep legacy fields in sync until the analyzer/template path is migrated.
150
+ contentType: responseContentType,
151
+ body: input.responseBody,
152
+ capturedAt: input.capturedAt ?? Date.now(),
153
+ };
154
+ }
155
+
52
156
  // ── Interceptor JS ─────────────────────────────────────────────────────────
53
157
 
54
158
  /**
55
159
  * Generates a full-capture interceptor that stores {url, method, status, body}
56
160
  * for every JSON response. No URL pattern filter — captures everything.
57
161
  */
58
- function generateFullCaptureInterceptorJs(): string {
162
+ export function generateFullCaptureInterceptorJs(): string {
59
163
  return `
60
164
  (() => {
61
165
  // Restore original fetch/XHR if previously patched, then re-patch (idempotent injection)
@@ -63,24 +167,47 @@ function generateFullCaptureInterceptorJs(): string {
63
167
  if (window.__opencli_orig_fetch) window.fetch = window.__opencli_orig_fetch;
64
168
  if (window.__opencli_orig_xhr_open) XMLHttpRequest.prototype.open = window.__opencli_orig_xhr_open;
65
169
  if (window.__opencli_orig_xhr_send) XMLHttpRequest.prototype.send = window.__opencli_orig_xhr_send;
170
+ if (window.__opencli_orig_xhr_set_request_header) XMLHttpRequest.prototype.setRequestHeader = window.__opencli_orig_xhr_set_request_header;
66
171
  window.__opencli_record_patched = false;
67
172
  }
68
173
  // Preserve existing capture buffer across re-injections
69
174
  window.__opencli_record = window.__opencli_record || [];
70
175
 
71
- const _push = (url, method, body) => {
176
+ const _tryParseJson = (contentType, raw) => {
72
177
  try {
73
- // Only capture JSON-like responses
74
- if (typeof body !== 'object' || body === null) return;
75
- // Skip tiny/trivial responses (tracking pixels, empty acks)
76
- const keys = Object.keys(body);
77
- if (keys.length < 2) return;
178
+ const normalized = String(contentType || '').toLowerCase();
179
+ if (!normalized.includes('application/json') && !normalized.includes('+json')) return null;
180
+ if (typeof raw !== 'string' || !raw.trim()) return null;
181
+ return JSON.parse(raw);
182
+ } catch {
183
+ return null;
184
+ }
185
+ };
186
+
187
+ const _push = (entry) => {
188
+ try {
189
+ const responseBody = entry.responseBody;
190
+ if (typeof responseBody !== 'object' || responseBody === null) return;
191
+ const isReplayableWrite = ['POST', 'PUT', 'PATCH'].includes(String(entry.method).toUpperCase())
192
+ && (() => {
193
+ const normalized = String(entry.requestContentType || '').toLowerCase();
194
+ return normalized.includes('application/json') || normalized.includes('+json');
195
+ })()
196
+ && entry.requestBody
197
+ && typeof entry.requestBody === 'object';
198
+ const keys = Object.keys(responseBody);
199
+ if (keys.length < 2 && !isReplayableWrite) return;
78
200
  window.__opencli_record.push({
79
- url: String(url),
80
- method: String(method).toUpperCase(),
201
+ url: String(entry.url),
202
+ method: String(entry.method).toUpperCase(),
81
203
  status: null,
82
- body,
83
- ts: Date.now(),
204
+ requestContentType: entry.requestContentType || null,
205
+ responseContentType: entry.responseContentType || 'application/json',
206
+ requestBody: entry.requestBody || null,
207
+ responseBody,
208
+ contentType: entry.responseContentType || 'application/json',
209
+ body: responseBody,
210
+ capturedAt: Date.now(),
84
211
  });
85
212
  } catch {}
86
213
  };
@@ -89,14 +216,53 @@ function generateFullCaptureInterceptorJs(): string {
89
216
  window.__opencli_orig_fetch = window.fetch;
90
217
  window.fetch = async function(...args) {
91
218
  const req = args[0];
219
+ const init = args[1] || {};
92
220
  const reqUrl = typeof req === 'string' ? req : (req instanceof Request ? req.url : String(req));
93
- const method = (args[1]?.method || (req instanceof Request ? req.method : 'GET') || 'GET');
221
+ const method = (init?.method || (req instanceof Request ? req.method : 'GET') || 'GET');
222
+ const requestContentType = (() => {
223
+ if (init?.headers) {
224
+ try {
225
+ const headers = new Headers(init.headers);
226
+ const value = headers.get('content-type');
227
+ if (value) return value;
228
+ } catch {}
229
+ }
230
+ if (req instanceof Request) {
231
+ return req.headers.get('content-type');
232
+ }
233
+ return null;
234
+ })();
235
+ const requestBodyText = (() => {
236
+ if (typeof init?.body === 'string') return init.body;
237
+ return null;
238
+ })();
239
+ const shouldReadRequestBodyFromRequest = req instanceof Request
240
+ && !requestBodyText
241
+ && ['POST', 'PUT', 'PATCH'].includes(String(method).toUpperCase())
242
+ && (() => {
243
+ const normalized = String(requestContentType || '').toLowerCase();
244
+ return normalized.includes('application/json') || normalized.includes('+json');
245
+ })();
246
+ let requestBodyTextFromRequest = null;
247
+ if (shouldReadRequestBodyFromRequest) {
248
+ try {
249
+ requestBodyTextFromRequest = await req.clone().text();
250
+ } catch {}
251
+ }
252
+ const requestBody = _tryParseJson(requestContentType, requestBodyText || requestBodyTextFromRequest);
94
253
  const res = await window.__opencli_orig_fetch.apply(this, args);
95
254
  const ct = res.headers.get('content-type') || '';
96
255
  if (ct.includes('json')) {
97
256
  try {
98
- const body = await res.clone().json();
99
- _push(reqUrl, method, body);
257
+ const responseBody = await res.clone().json();
258
+ _push({
259
+ url: reqUrl,
260
+ method,
261
+ requestContentType,
262
+ requestBody,
263
+ responseContentType: ct,
264
+ responseBody,
265
+ });
100
266
  } catch {}
101
267
  }
102
268
  return res;
@@ -106,20 +272,38 @@ function generateFullCaptureInterceptorJs(): string {
106
272
  const _XHR = XMLHttpRequest.prototype;
107
273
  window.__opencli_orig_xhr_open = _XHR.open;
108
274
  window.__opencli_orig_xhr_send = _XHR.send;
275
+ window.__opencli_orig_xhr_set_request_header = _XHR.setRequestHeader;
109
276
  _XHR.open = function(method, url) {
110
277
  this.__rec_url = String(url);
111
278
  this.__rec_method = String(method);
279
+ this.__rec_request_content_type = null;
112
280
  this.__rec_listener_added = false; // reset per open() call
113
281
  return window.__opencli_orig_xhr_open.apply(this, arguments);
114
282
  };
283
+ _XHR.setRequestHeader = function(name, value) {
284
+ if (String(name).toLowerCase() === 'content-type') {
285
+ this.__rec_request_content_type = String(value);
286
+ }
287
+ return window.__opencli_orig_xhr_set_request_header.apply(this, arguments);
288
+ };
115
289
  _XHR.send = function() {
290
+ const requestBody = _tryParseJson(this.__rec_request_content_type, typeof arguments[0] === 'string' ? arguments[0] : null);
116
291
  // Guard: only add one listener per XHR instance to prevent duplicate captures
117
292
  if (!this.__rec_listener_added) {
118
293
  this.__rec_listener_added = true;
119
294
  this.addEventListener('load', function() {
120
295
  const ct = this.getResponseHeader?.('content-type') || '';
121
296
  if (ct.includes('json')) {
122
- try { _push(this.__rec_url, this.__rec_method || 'GET', JSON.parse(this.responseText)); } catch {}
297
+ try {
298
+ _push({
299
+ url: this.__rec_url,
300
+ method: this.__rec_method || 'GET',
301
+ requestContentType: this.__rec_request_content_type,
302
+ requestBody,
303
+ responseContentType: ct,
304
+ responseBody: JSON.parse(this.responseText),
305
+ });
306
+ } catch {}
123
307
  }
124
308
  });
125
309
  }
@@ -159,11 +343,42 @@ function scoreRequest(req: RecordedRequest, arrayResult: ReturnType<typeof findA
159
343
  }
160
344
  }
161
345
  }
162
- if (req.url.includes('/api/')) s += 3;
163
- // Penalize likely tracking / analytics endpoints
164
- if (req.url.match(/\/(track|log|analytics|beacon|pixel|stats|metric)/i)) s -= 10;
165
- if (req.url.match(/\/(ping|heartbeat|keep.?alive)/i)) s -= 10;
166
- return s;
346
+ return applyCommonEndpointScoreAdjustments(req, s);
347
+ }
348
+
349
+ /** Check whether one recorded request is safe to treat as a write candidate. */
350
+ function isWriteCandidate(req: RecordedRequest): boolean {
351
+ return ['POST', 'PUT', 'PATCH'].includes(req.method)
352
+ && isJsonContentType(req.requestContentType)
353
+ && !!req.requestBody
354
+ && typeof req.requestBody === 'object'
355
+ && !Array.isArray(req.requestBody)
356
+ && !!req.responseBody
357
+ && typeof req.responseBody === 'object'
358
+ && !Array.isArray(req.responseBody);
359
+ }
360
+
361
+ /** Score replayable write requests while keeping tracking and heartbeat traffic suppressed. */
362
+ function scoreWriteRequest(req: RecordedRequest): number {
363
+ return applyCommonEndpointScoreAdjustments(req, 6);
364
+ }
365
+
366
+ /** Analyze recorded requests into read and write candidates. */
367
+ export function analyzeRecordedRequests(requests: RecordedRequest[]): { candidates: RecordedCandidate[] } {
368
+ const candidates: RecordedCandidate[] = [];
369
+ for (const req of requests) {
370
+ const arrayResult = findArrayPath(req.responseBody);
371
+ if (isWriteCandidate(req)) {
372
+ const score = scoreWriteRequest(req);
373
+ if (score > 0) candidates.push({ kind: 'write', req, score, arrayResult: null });
374
+ continue;
375
+ }
376
+ if (arrayResult) {
377
+ const score = scoreRequest(req, arrayResult);
378
+ if (score > 0) candidates.push({ kind: 'read', req, score, arrayResult });
379
+ }
380
+ }
381
+ return { candidates };
167
382
  }
168
383
 
169
384
  // ── YAML generation ────────────────────────────────────────────────────────
@@ -214,15 +429,20 @@ function buildRecordedYaml(
214
429
  const u = new URL(req.url);
215
430
  if (hasSearch) {
216
431
  for (const p of SEARCH_PARAMS) {
217
- if (u.searchParams.has(p)) { u.searchParams.set(p, '{{args.keyword}}'); break; }
432
+ if (u.searchParams.has(p)) { u.searchParams.set(p, '${{ args.keyword }}'); break; }
218
433
  }
219
434
  }
220
435
  if (hasPage) {
221
436
  for (const p of PAGINATION_PARAMS) {
222
- if (u.searchParams.has(p)) { u.searchParams.set(p, '{{args.page | default(1)}}'); break; }
437
+ if (u.searchParams.has(p)) { u.searchParams.set(p, '${{ args.page | default(1) }}'); break; }
223
438
  }
224
439
  }
225
440
  fetchUrl = u.toString();
441
+ fetchUrl = fetchUrl
442
+ .replaceAll(encodeURIComponent('${{ args.keyword }}'), '${{ args.keyword }}')
443
+ .replaceAll('%24%7B%7B+args.keyword+%7D%7D', '${{ args.keyword }}')
444
+ .replaceAll(encodeURIComponent('${{ args.page | default(1) }}'), '${{ args.page | default(1) }}');
445
+ fetchUrl = fetchUrl.replaceAll('%24%7B%7B+args.page+%7C+default%281%29+%7D%7D', '${{ args.page | default(1) }}');
226
446
  } catch {}
227
447
 
228
448
  // When itemPath is empty, the array IS the response root; otherwise chain with ?.
@@ -271,6 +491,87 @@ function buildRecordedYaml(
271
491
  };
272
492
  }
273
493
 
494
+ /** Build a minimal YAML candidate for replayable JSON write requests. */
495
+ export function buildWriteRecordedYaml(
496
+ site: string,
497
+ pageUrl: string,
498
+ req: RecordedRequest,
499
+ capName: string,
500
+ ): { name: string; yaml: unknown } {
501
+ const responseColumns = req.responseBody && typeof req.responseBody === 'object' && !Array.isArray(req.responseBody)
502
+ ? Object.keys(req.responseBody as Record<string, unknown>).slice(0, 6)
503
+ : ['ok'];
504
+
505
+ const evaluateScript = [
506
+ '(async () => {',
507
+ ` const res = await fetch(${JSON.stringify(req.url)}, {`,
508
+ ` method: ${JSON.stringify(req.method)},`,
509
+ ` credentials: 'include',`,
510
+ ` headers: { 'content-type': ${JSON.stringify(req.requestContentType ?? 'application/json')} },`,
511
+ ` body: JSON.stringify(${JSON.stringify(req.requestBody)}),`,
512
+ ' });',
513
+ ' return await res.json();',
514
+ '})()',
515
+ ].join('\n');
516
+
517
+ return {
518
+ name: capName,
519
+ yaml: {
520
+ site,
521
+ name: capName,
522
+ description: `${site} ${capName} (recorded write)`,
523
+ domain: (() => { try { return new URL(pageUrl).hostname; } catch { return ''; } })(),
524
+ strategy: 'cookie',
525
+ browser: true,
526
+ args: {},
527
+ pipeline: [
528
+ { navigate: pageUrl },
529
+ { evaluate: evaluateScript },
530
+ ],
531
+ columns: responseColumns.length ? responseColumns : ['ok'],
532
+ },
533
+ };
534
+ }
535
+
536
+ /** Turn recorded requests into YAML-ready read and write candidates. */
537
+ export function generateRecordedCandidates(
538
+ site: string,
539
+ pageUrl: string,
540
+ requests: RecordedRequest[],
541
+ ): GeneratedRecordedCandidate[] {
542
+ const analysis = analyzeRecordedRequests(dedupeRecordedRequests(requests));
543
+ const deduped = new Map<string, RecordedCandidate>();
544
+ for (const candidate of analysis.candidates) {
545
+ const key = getRecordedCandidateKey(candidate);
546
+ const current = deduped.get(key);
547
+ deduped.set(key, current ? preferRecordedCandidate(current, candidate) : candidate);
548
+ }
549
+
550
+ const selected = [...deduped.values()]
551
+ .filter((candidate) => candidate.kind === 'read' ? candidate.score >= 8 : candidate.score >= 6)
552
+ .sort((a, b) => b.score - a.score)
553
+ .slice(0, 5);
554
+
555
+ const usedNames = new Set<string>();
556
+ return selected.map((candidate) => {
557
+ let capName = inferCapabilityName(candidate.req.url);
558
+ if (usedNames.has(capName)) capName = `${capName}_${usedNames.size + 1}`;
559
+ usedNames.add(capName);
560
+
561
+ const authIndicators = detectAuthFromContent(candidate.req.url, candidate.req.responseBody);
562
+ const strategy = candidate.kind === 'write' ? 'cookie' : inferStrategy(authIndicators);
563
+ const yamlCandidate = candidate.kind === 'write'
564
+ ? buildWriteRecordedYaml(site, pageUrl, candidate.req, capName)
565
+ : buildRecordedYaml(site, pageUrl, candidate.req, capName, candidate.arrayResult!, authIndicators);
566
+ return {
567
+ kind: candidate.kind,
568
+ name: yamlCandidate.name,
569
+ strategy,
570
+ yaml: yamlCandidate.yaml,
571
+ };
572
+ });
573
+ }
574
+
274
575
  // ── Main record function ───────────────────────────────────────────────────
275
576
 
276
577
  export interface RecordOptions {
@@ -441,32 +742,9 @@ function analyzeAndWrite(
441
742
  return { site, url: pageUrl, requests: [], outDir: targetDir, candidateCount: 0, candidates: [] };
442
743
  }
443
744
 
444
- // Deduplicate by pattern
445
- const seen = new Map<string, RecordedRequest>();
446
- for (const req of requests) {
447
- const pattern = urlToPattern(req.url);
448
- if (!seen.has(pattern)) seen.set(pattern, req);
449
- }
450
-
451
- // Score and rank unique requests
452
- type ScoredEntry = {
453
- req: RecordedRequest;
454
- pattern: string;
455
- arrayResult: ReturnType<typeof findArrayPath>;
456
- authIndicators: string[];
457
- score: number;
458
- };
459
-
460
- const scored: ScoredEntry[] = [];
461
- for (const [pattern, req] of seen) {
462
- const arrayResult = findArrayPath(req.body);
463
- const authIndicators = detectAuthFromContent(req.url, req.body);
464
- const score = scoreRequest(req, arrayResult);
465
- if (score > 0) {
466
- scored.push({ req, pattern, arrayResult, authIndicators, score });
467
- }
468
- }
469
- scored.sort((a, b) => b.score - a.score);
745
+ // Score and rank deduplicated requests for console output and candidate generation.
746
+ const analysisRequests = dedupeRecordedRequests(requests);
747
+ const analysis = analyzeRecordedRequests(analysisRequests);
470
748
 
471
749
  // Save raw captured data
472
750
  fs.writeFileSync(
@@ -480,35 +758,36 @@ function analyzeAndWrite(
480
758
 
481
759
  console.log(chalk.bold('\n Captured endpoints (scored):\n'));
482
760
 
483
- for (const entry of scored.slice(0, 8)) {
761
+ for (const entry of analysis.candidates.sort((a, b) => b.score - a.score).slice(0, 8)) {
484
762
  const itemCount = entry.arrayResult?.items.length ?? 0;
485
- const strategy = inferStrategy(entry.authIndicators);
763
+ const strategy = entry.kind === 'write'
764
+ ? 'cookie'
765
+ : inferStrategy(detectAuthFromContent(entry.req.url, entry.req.responseBody));
486
766
  const marker = entry.score >= 15 ? chalk.green('★') : entry.score >= 8 ? chalk.yellow('◆') : chalk.dim('·');
487
767
  console.log(
488
- ` ${marker} ${chalk.white(entry.pattern)}` +
768
+ ` ${marker} ${chalk.white(urlToPattern(entry.req.url))}` +
489
769
  chalk.dim(` [${strategy}]`) +
490
- (itemCount ? chalk.cyan(` ${itemCount} items`) : ''),
770
+ (entry.kind === 'write'
771
+ ? chalk.magenta(' ← write')
772
+ : itemCount ? chalk.cyan(` ← ${itemCount} items`) : ''),
491
773
  );
492
774
  }
493
775
 
494
776
  console.log();
495
777
 
496
- const topCandidates = scored.filter(e => e.arrayResult && e.score >= 8).slice(0, 5);
778
+ const topCandidates = generateRecordedCandidates(site, pageUrl, analysisRequests);
497
779
  const candidatesDir = path.join(targetDir, 'candidates');
498
780
  fs.mkdirSync(candidatesDir, { recursive: true });
499
781
 
500
782
  for (const entry of topCandidates) {
501
- let capName = inferCapabilityName(entry.req.url);
502
- if (usedNames.has(capName)) capName = `${capName}_${usedNames.size + 1}`;
503
- usedNames.add(capName);
783
+ if (usedNames.has(entry.name)) continue;
784
+ usedNames.add(entry.name);
504
785
 
505
- const strategy = inferStrategy(entry.authIndicators);
506
- const candidate = buildRecordedYaml(site, pageUrl, entry.req, capName, entry.arrayResult!, entry.authIndicators);
507
- const filePath = path.join(candidatesDir, `${capName}.yaml`);
508
- fs.writeFileSync(filePath, yaml.dump(candidate.yaml, { sortKeys: false, lineWidth: 120 }));
509
- candidates.push({ name: capName, path: filePath, strategy });
786
+ const filePath = path.join(candidatesDir, `${entry.name}.yaml`);
787
+ fs.writeFileSync(filePath, yaml.dump(entry.yaml, { sortKeys: false, lineWidth: 120 }));
788
+ candidates.push({ name: entry.name, path: filePath, strategy: entry.strategy });
510
789
 
511
- console.log(chalk.green(` ✓ Generated: ${chalk.bold(capName)}.yaml [${strategy}]`));
790
+ console.log(chalk.green(` ✓ Generated: ${chalk.bold(entry.name)}.yaml [${entry.strategy}]`));
512
791
  console.log(chalk.dim(` → ${filePath}`));
513
792
  }
514
793
 
@@ -75,6 +75,18 @@ describe('cli() registration', () => {
75
75
  expect(registry.get('test-registry/compat')).toBe(cmd);
76
76
  expect(registry.get('test-registry/legacy-name')).toBe(cmd);
77
77
  });
78
+
79
+ it('preserves defaultFormat on the registered command', () => {
80
+ const cmd = cli({
81
+ site: 'test-registry',
82
+ name: 'plain-default',
83
+ description: 'prefers plain output',
84
+ defaultFormat: 'plain',
85
+ });
86
+
87
+ expect(cmd.defaultFormat).toBe('plain');
88
+ expect(getRegistry().get('test-registry/plain-default')?.defaultFormat).toBe('plain');
89
+ });
78
90
  });
79
91
 
80
92
  describe('fullName', () => {
package/src/registry.ts CHANGED
@@ -62,6 +62,8 @@ export interface CliCommand {
62
62
  * - `string`: navigate to this specific URL instead of the domain root
63
63
  */
64
64
  navigateBefore?: boolean | string;
65
+ /** Override the default CLI output format when the user does not pass -f/--format. */
66
+ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv';
65
67
  }
66
68
 
67
69
  /** Internal extension for lazy-loaded TS modules (not exposed in public API) */
@@ -105,6 +107,7 @@ export function cli(opts: CliOptions): CliCommand {
105
107
  deprecated: opts.deprecated,
106
108
  replacedBy: opts.replacedBy,
107
109
  navigateBefore: opts.navigateBefore,
110
+ defaultFormat: opts.defaultFormat,
108
111
  };
109
112
 
110
113
  registerCommand(cmd);
package/src/runtime.ts CHANGED
@@ -72,15 +72,15 @@ export async function browserSession<T>(
72
72
  fn: (page: IPage) => Promise<T>,
73
73
  opts: { workspace?: string; cdpEndpoint?: string } = {},
74
74
  ): Promise<T> {
75
- const mcp = new BrowserFactory();
75
+ const browser = new BrowserFactory();
76
76
  try {
77
- const page = await mcp.connect({
77
+ const page = await browser.connect({
78
78
  timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT,
79
79
  workspace: opts.workspace,
80
80
  cdpEndpoint: opts.cdpEndpoint,
81
81
  });
82
82
  return await fn(page);
83
83
  } finally {
84
- await mcp.close().catch(() => {});
84
+ await browser.close().catch(() => {});
85
85
  }
86
86
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for snapshotFormatter.ts: Playwright MCP snapshot tree filtering.
2
+ * Tests for snapshotFormatter.ts: snapshot tree filtering.
3
3
  *
4
4
  * Uses sanitized excerpts from real websites (GitHub, Bilibili, Twitter)
5
5
  * to validate noise filtering, annotation stripping, and output quality.
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
9
9
  import { formatSnapshot } from './snapshotFormatter.js';
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
- // Fixtures: sanitized excerpts from real Playwright MCP snapshots
12
+ // Fixtures: sanitized excerpts from real aria snapshots
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
15
  /** GitHub dashboard navigation bar (generic-heavy, refs, /url: lines) */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Aria snapshot formatter: parses Playwright MCP snapshot text into clean format.
2
+ * Aria snapshot formatter: parses snapshot text into clean format.
3
3
  *
4
4
  * 4-pass pipeline:
5
5
  * 1. Parse & filter: strip annotations, metadata, noise, ads, boilerplate subtrees
@@ -62,10 +62,10 @@ const BOILERPLATE_LABELS = [
62
62
  /**
63
63
  * Parse role and text from a trimmed snapshot line.
64
64
  * Handles quoted labels and trailing text after colon correctly,
65
- * including lines wrapped in single quotes by Playwright.
65
+ * including lines wrapped in single quotes.
66
66
  */
67
67
  function parseLine(trimmed: string): { role: string; text: string; hasText: boolean; trailingText: string } {
68
- // Unwrap outer single quotes if present (Playwright wraps lines with special chars)
68
+ // Unwrap outer single quotes if present (snapshot wraps lines with special chars)
69
69
  let line = trimmed;
70
70
  if (line.startsWith("'") && line.endsWith("':")) {
71
71
  line = line.slice(1, -2) + ':';
@@ -107,7 +107,7 @@ function parseLine(trimmed: string): { role: string; text: string; hasText: bool
107
107
 
108
108
  /**
109
109
  * Strip ALL bracket annotations from a content line, preserving quoted strings.
110
- * Handles both double-quoted and outer single-quoted lines from Playwright.
110
+ * Handles both double-quoted and outer single-quoted lines.
111
111
  */
112
112
  function stripAnnotations(content: string): string {
113
113
  // Unwrap outer single quotes first
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Page interface: type-safe abstraction over Playwright MCP browser page.
2
+ * Page interface: type-safe abstraction over browser page.
3
3
  *
4
4
  * All pipeline steps and CLI adapters should use this interface
5
5
  * instead of `any` for browser interactions.
@@ -75,4 +75,14 @@ export interface IPage {
75
75
  closeWindow?(): Promise<void>;
76
76
  /** Returns the current page URL, or null if unavailable. */
77
77
  getCurrentUrl?(): Promise<string | null>;
78
+ /** Returns the active tab ID, or undefined if not yet resolved. */
79
+ getActiveTabId?(): number | undefined;
80
+ /** Send a raw CDP command via chrome.debugger passthrough. */
81
+ cdp?(method: string, params?: Record<string, unknown>): Promise<unknown>;
82
+ /** Click at native coordinates via CDP Input.dispatchMouseEvent. */
83
+ nativeClick?(x: number, y: number): Promise<void>;
84
+ /** Type text via CDP Input.insertText. */
85
+ nativeType?(text: string): Promise<void>;
86
+ /** Press a key via CDP Input.dispatchKeyEvent. */
87
+ nativeKeyPress?(key: string, modifiers?: string[]): Promise<void>;
78
88
  }