@jackwener/opencli 1.7.2 → 1.7.4

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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Unified target resolver for browser actions.
3
+ *
4
+ * Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
5
+ * principled resolution pipeline:
6
+ *
7
+ * 1. Input classification: numeric → ref path, CSS-like → CSS path
8
+ * 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
9
+ * 3. CSS path: querySelectorAll + uniqueness check
10
+ * 4. Structured errors: stale_ref / ambiguous / not_found
11
+ *
12
+ * All JS is generated as strings for page.evaluate() — runs in the browser.
13
+ */
14
+ /**
15
+ * Generate JS that resolves a target to a single DOM element.
16
+ *
17
+ * Returns a JS expression that evaluates to:
18
+ * { ok: true, el: Element } — success (el is assigned to `__resolved`)
19
+ * { ok: false, code, message, hint, candidates } — structured error
20
+ *
21
+ * The resolved element is stored in `__resolved` for the caller to use.
22
+ */
23
+ export function resolveTargetJs(ref) {
24
+ const safeRef = JSON.stringify(ref);
25
+ return `
26
+ (() => {
27
+ const ref = ${safeRef};
28
+ const identity = window.__opencli_ref_identity || {};
29
+
30
+ // ── Classify input ──
31
+ const isNumeric = /^\\d+$/.test(ref);
32
+ const isCssLike = !isNumeric && /^[a-zA-Z#.\\[]/.test(ref);
33
+
34
+ if (isNumeric) {
35
+ // ── Ref path ──
36
+ let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
37
+ if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
38
+
39
+ if (!el) {
40
+ return {
41
+ ok: false,
42
+ code: 'not_found',
43
+ message: 'ref=' + ref + ' not found in DOM',
44
+ hint: 'The element may have been removed. Re-run \`opencli browser state\` to get a fresh snapshot.',
45
+ };
46
+ }
47
+
48
+ // ── Fingerprint verification (identity vector) ──
49
+ const fp = identity[ref];
50
+ if (fp) {
51
+ const tag = el.tagName.toLowerCase();
52
+ const text = (el.textContent || '').trim().slice(0, 30);
53
+ const role = el.getAttribute('role') || '';
54
+ const ariaLabel = el.getAttribute('aria-label') || '';
55
+ const id = el.id || '';
56
+ const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || '';
57
+
58
+ // Hard fail: tag must always match
59
+ const tagMatch = fp.tag === tag;
60
+
61
+ // Soft signals: each non-empty stored field that mismatches counts against
62
+ var mismatches = 0;
63
+ var checks = 0;
64
+ if (fp.id) { checks++; if (fp.id !== id) mismatches++; }
65
+ if (fp.testId) { checks++; if (fp.testId !== testId) mismatches++; }
66
+ if (fp.ariaLabel) { checks++; if (fp.ariaLabel !== ariaLabel) mismatches++; }
67
+ if (fp.role) { checks++; if (fp.role !== role) mismatches++; }
68
+ if (fp.text) {
69
+ checks++;
70
+ // Text: allow prefix match (page text can grow), but empty current text never matches
71
+ if (!text || (!text.startsWith(fp.text) && !fp.text.startsWith(text))) mismatches++;
72
+ }
73
+
74
+ // Stale if tag changed, or if any uniquely identifying field (id/testId) changed,
75
+ // or if majority of soft signals mismatch
76
+ var isStale = !tagMatch;
77
+ if (!isStale && checks > 0) {
78
+ // id and testId are strong identifiers — any mismatch on these is decisive
79
+ if (fp.id && fp.id !== id) isStale = true;
80
+ else if (fp.testId && fp.testId !== testId) isStale = true;
81
+ // For remaining signals, stale if more than half mismatch
82
+ else if (mismatches > checks / 2) isStale = true;
83
+ }
84
+
85
+ if (isStale) {
86
+ return {
87
+ ok: false,
88
+ code: 'stale_ref',
89
+ message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '')
90
+ + ' but now points to <' + tag + '>' + (text ? '"' + text.slice(0, 30) + '"' : ''),
91
+ hint: 'The page has changed since the last snapshot. Re-run \`opencli browser state\` to refresh.',
92
+ };
93
+ }
94
+ }
95
+
96
+ window.__resolved = el;
97
+ return { ok: true };
98
+ }
99
+
100
+ if (isCssLike) {
101
+ // ── CSS selector path ──
102
+ let matches;
103
+ try {
104
+ matches = document.querySelectorAll(ref);
105
+ } catch (e) {
106
+ return {
107
+ ok: false,
108
+ code: 'not_found',
109
+ message: 'Invalid CSS selector: ' + ref,
110
+ hint: 'Check the selector syntax. Use ref numbers from snapshot for reliable targeting.',
111
+ };
112
+ }
113
+
114
+ if (matches.length === 0) {
115
+ return {
116
+ ok: false,
117
+ code: 'not_found',
118
+ message: 'CSS selector "' + ref + '" matched 0 elements',
119
+ hint: 'The element may not exist or may be hidden. Re-run \`opencli browser state\` to check.',
120
+ };
121
+ }
122
+
123
+ if (matches.length > 1) {
124
+ const candidates = [];
125
+ const limit = Math.min(matches.length, 5);
126
+ for (let i = 0; i < limit; i++) {
127
+ const m = matches[i];
128
+ const tag = m.tagName.toLowerCase();
129
+ const text = (m.textContent || '').trim().slice(0, 40);
130
+ const id = m.id ? '#' + m.id : '';
131
+ candidates.push('<' + tag + id + '>' + (text ? ' "' + text + '"' : ''));
132
+ }
133
+ return {
134
+ ok: false,
135
+ code: 'ambiguous',
136
+ message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements',
137
+ hint: 'Use a more specific selector, or use ref numbers from \`opencli browser state\` snapshot.',
138
+ candidates: candidates,
139
+ };
140
+ }
141
+
142
+ window.__resolved = matches[0];
143
+ return { ok: true };
144
+ }
145
+
146
+ // ── Unrecognized input ──
147
+ return {
148
+ ok: false,
149
+ code: 'not_found',
150
+ message: 'Cannot parse target: ' + ref,
151
+ hint: 'Use a numeric ref from snapshot (e.g. "12") or a CSS selector (e.g. "#submit").',
152
+ };
153
+ })()
154
+ `;
155
+ }
156
+ /**
157
+ * Generate JS for click that uses the unified resolver.
158
+ * Assumes resolveTargetJs has been called and __resolved is set.
159
+ */
160
+ export function clickResolvedJs() {
161
+ return `
162
+ (() => {
163
+ const el = window.__resolved;
164
+ if (!el) throw new Error('No resolved element');
165
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
166
+ const rect = el.getBoundingClientRect();
167
+ const x = Math.round(rect.left + rect.width / 2);
168
+ const y = Math.round(rect.top + rect.height / 2);
169
+ try {
170
+ el.click();
171
+ return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) };
172
+ } catch (e) {
173
+ return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message };
174
+ }
175
+ })()
176
+ `;
177
+ }
178
+ /**
179
+ * Generate JS for type that uses the unified resolver.
180
+ */
181
+ export function typeResolvedJs(text) {
182
+ const safeText = JSON.stringify(text);
183
+ return `
184
+ (() => {
185
+ const el = window.__resolved;
186
+ if (!el) throw new Error('No resolved element');
187
+ el.focus();
188
+ if (el.isContentEditable) {
189
+ const sel = window.getSelection();
190
+ const range = document.createRange();
191
+ range.selectNodeContents(el);
192
+ sel.removeAllRanges();
193
+ sel.addRange(range);
194
+ document.execCommand('delete', false);
195
+ document.execCommand('insertText', false, ${safeText});
196
+ el.dispatchEvent(new Event('input', { bubbles: true }));
197
+ } else {
198
+ const proto = el instanceof HTMLTextAreaElement
199
+ ? HTMLTextAreaElement.prototype
200
+ : HTMLInputElement.prototype;
201
+ const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
202
+ if (nativeSetter) {
203
+ nativeSetter.call(el, ${safeText});
204
+ } else {
205
+ el.value = ${safeText};
206
+ }
207
+ el.dispatchEvent(new Event('input', { bubbles: true }));
208
+ el.dispatchEvent(new Event('change', { bubbles: true }));
209
+ }
210
+ return 'typed';
211
+ })()
212
+ `;
213
+ }
214
+ /**
215
+ * Generate JS for scrollTo that uses the unified resolver.
216
+ * Assumes resolveTargetJs has been called and __resolved is set.
217
+ */
218
+ export function scrollResolvedJs() {
219
+ return `
220
+ (() => {
221
+ const el = window.__resolved;
222
+ if (!el) throw new Error('No resolved element');
223
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
224
+ return { scrolled: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80) };
225
+ })()
226
+ `;
227
+ }
228
+ /**
229
+ * Generate JS to get text content of resolved element.
230
+ */
231
+ export function getTextResolvedJs() {
232
+ return `
233
+ (() => {
234
+ const el = window.__resolved;
235
+ if (!el) throw new Error('No resolved element');
236
+ return el.textContent?.trim() ?? null;
237
+ })()
238
+ `;
239
+ }
240
+ /**
241
+ * Generate JS to get value of resolved input/textarea element.
242
+ */
243
+ export function getValueResolvedJs() {
244
+ return `
245
+ (() => {
246
+ const el = window.__resolved;
247
+ if (!el) throw new Error('No resolved element');
248
+ return el.value ?? null;
249
+ })()
250
+ `;
251
+ }
252
+ /**
253
+ * Generate JS to get all attributes of resolved element.
254
+ */
255
+ export function getAttributesResolvedJs() {
256
+ return `
257
+ (() => {
258
+ const el = window.__resolved;
259
+ if (!el) throw new Error('No resolved element');
260
+ return JSON.stringify(Object.fromEntries([...el.attributes].map(a => [a.name, a.value])));
261
+ })()
262
+ `;
263
+ }
264
+ /**
265
+ * Generate JS to select an option on a resolved <select> element.
266
+ */
267
+ export function selectResolvedJs(option) {
268
+ const safeOption = JSON.stringify(option);
269
+ return `
270
+ (() => {
271
+ const el = window.__resolved;
272
+ if (!el) throw new Error('No resolved element');
273
+ if (el.tagName !== 'SELECT') return { error: 'Not a <select>' };
274
+ const match = Array.from(el.options).find(o => o.text.trim() === ${safeOption} || o.value === ${safeOption});
275
+ if (!match) return { error: 'Option not found', available: Array.from(el.options).map(o => o.text.trim()) };
276
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
277
+ if (setter) setter.call(el, match.value); else el.value = match.value;
278
+ el.dispatchEvent(new Event('input', { bubbles: true }));
279
+ el.dispatchEvent(new Event('change', { bubbles: true }));
280
+ return { selected: match.text };
281
+ })()
282
+ `;
283
+ }
284
+ /**
285
+ * Generate JS to check if resolved element is an autocomplete/combobox field.
286
+ */
287
+ export function isAutocompleteResolvedJs() {
288
+ return `
289
+ (() => {
290
+ const el = window.__resolved;
291
+ if (!el) return false;
292
+ const role = el.getAttribute('role');
293
+ const ac = el.getAttribute('aria-autocomplete');
294
+ const list = el.getAttribute('list');
295
+ return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
296
+ })()
297
+ `;
298
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveTargetJs } from './target-resolver.js';
3
+ /**
4
+ * Tests for the target resolver JS generator.
5
+ *
6
+ * Since resolveTargetJs() produces JS strings for browser evaluate(),
7
+ * we test the generated JS by running it in a simulated DOM-like context
8
+ * and verifying the structure of the output.
9
+ */
10
+ describe('resolveTargetJs', () => {
11
+ it('generates JS that returns structured resolution for numeric ref', () => {
12
+ const js = resolveTargetJs('12');
13
+ expect(js).toContain('data-opencli-ref');
14
+ expect(js).toContain('__opencli_ref_identity');
15
+ expect(js).toContain('"12"');
16
+ });
17
+ it('generates JS that handles CSS selector input', () => {
18
+ const js = resolveTargetJs('#submit-btn');
19
+ expect(js).toContain('querySelectorAll');
20
+ expect(js).toContain('"#submit-btn"');
21
+ });
22
+ it('generates JS with stale_ref detection for numeric refs', () => {
23
+ const js = resolveTargetJs('5');
24
+ expect(js).toContain('stale_ref');
25
+ expect(js).toContain('__opencli_ref_identity');
26
+ });
27
+ it('generates JS with ambiguity detection for CSS selectors', () => {
28
+ const js = resolveTargetJs('.btn');
29
+ expect(js).toContain('ambiguous');
30
+ expect(js).toContain('candidates');
31
+ });
32
+ it('generates JS that rejects unrecognized input', () => {
33
+ const js = resolveTargetJs('???');
34
+ expect(js).toContain('not_found');
35
+ expect(js).toContain('Cannot parse target');
36
+ });
37
+ it('escapes ref value safely', () => {
38
+ const js = resolveTargetJs('"; alert(1); "');
39
+ // JSON.stringify should handle escaping
40
+ expect(js).not.toContain('alert(1); "');
41
+ expect(js).toContain('\\"');
42
+ });
43
+ });
@@ -112,13 +112,15 @@ describe('BrowserBridge state', () => {
112
112
  bridge._state = 'closing';
113
113
  await expect(bridge.connect()).rejects.toThrow('Session is closing');
114
114
  });
115
- it('fails fast when daemon is running but extension is disconnected', async () => {
115
+ it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
116
+ const { PKG_VERSION } = await import('./version.js');
116
117
  vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
117
118
  state: 'no-extension',
118
119
  status: {
119
120
  ok: true,
120
121
  pid: 1,
121
122
  uptime: 0,
123
+ daemonVersion: PKG_VERSION,
122
124
  extensionConnected: false,
123
125
  pending: 0,
124
126
  memoryMB: 0,
@@ -128,6 +130,41 @@ describe('BrowserBridge state', () => {
128
130
  const bridge = new BrowserBridge();
129
131
  await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
130
132
  });
133
+ it('attempts stale daemon replacement when daemonVersion is missing', async () => {
134
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
135
+ state: 'no-extension',
136
+ status: {
137
+ ok: true,
138
+ pid: 1,
139
+ uptime: 0,
140
+ extensionConnected: false,
141
+ pending: 0,
142
+ memoryMB: 0,
143
+ port: 0,
144
+ },
145
+ });
146
+ vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
147
+ const bridge = new BrowserBridge();
148
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
149
+ });
150
+ it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
151
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
152
+ state: 'no-extension',
153
+ status: {
154
+ ok: true,
155
+ pid: 1,
156
+ uptime: 0,
157
+ daemonVersion: '0.0.1',
158
+ extensionConnected: false,
159
+ pending: 0,
160
+ memoryMB: 0,
161
+ port: 0,
162
+ },
163
+ });
164
+ vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
165
+ const bridge = new BrowserBridge();
166
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
167
+ });
131
168
  });
132
169
  describe('stealth anti-detection', () => {
133
170
  it('generates non-empty JS string', () => {
package/dist/src/cli.js CHANGED
@@ -18,8 +18,10 @@ import { PKG_VERSION } from './version.js';
18
18
  import { printCompletionScript } from './completion.js';
19
19
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
20
20
  import { registerAllCommands } from './commanderAdapter.js';
21
- import { EXIT_CODES, getErrorMessage } from './errors.js';
22
- import { daemonStop } from './commands/daemon.js';
21
+ import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
22
+ import { TargetError } from './browser/target-errors.js';
23
+ import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
24
+ import { daemonStatus, daemonStop } from './commands/daemon.js';
23
25
  import { log } from './logger.js';
24
26
  const CLI_FILE = fileURLToPath(import.meta.url);
25
27
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
@@ -249,6 +251,13 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
249
251
  const browser = program
250
252
  .command('browser')
251
253
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
254
+ /** Resolve a ref/CSS target via the unified resolver, throwing TargetError on failure. */
255
+ async function resolveRef(page, ref) {
256
+ const resolution = await page.evaluate(resolveTargetJs(ref));
257
+ if (!resolution.ok) {
258
+ throw new TargetError(resolution);
259
+ }
260
+ }
252
261
  /** Wrap browser actions with error handling and optional --json output */
253
262
  function browserAction(fn) {
254
263
  return async (...args) => {
@@ -257,15 +266,28 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
257
266
  await fn(page, ...args);
258
267
  }
259
268
  catch (err) {
260
- const msg = getErrorMessage(err);
261
- if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
262
- log.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
269
+ if (err instanceof BrowserConnectError) {
270
+ log.error(err.message);
271
+ if (err.hint)
272
+ log.error(`Hint: ${err.hint}`);
263
273
  }
264
- else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
265
- log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
274
+ else if (err instanceof TargetError) {
275
+ log.error(`[${err.code}] ${err.message}`);
276
+ if (err.hint)
277
+ log.error(`Hint: ${err.hint}`);
278
+ if (err.candidates?.length) {
279
+ log.error('Candidates:');
280
+ err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
281
+ }
266
282
  }
267
283
  else {
268
- log.error(msg);
284
+ const msg = getErrorMessage(err);
285
+ if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
286
+ log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
287
+ }
288
+ else {
289
+ log.error(msg);
290
+ }
269
291
  }
270
292
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
271
293
  }
@@ -277,7 +299,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
277
299
  browser.command('open').argument('<url>').description('Open URL in automation window')
278
300
  .action(browserAction(async (page, url) => {
279
301
  // Start session-level capture before navigation (catches initial requests)
280
- const hasSessionCapture = await page.startNetworkCapture?.().then(() => true).catch(() => false);
302
+ const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
281
303
  await page.goto(url);
282
304
  await page.wait(2);
283
305
  // Fallback: inject JS interceptor when session capture is unavailable
@@ -337,12 +359,14 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
337
359
  }));
338
360
  get.command('text').argument('<index>', 'Element index').description('Element text content')
339
361
  .action(browserAction(async (page, index) => {
340
- const text = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.textContent?.trim())(${JSON.stringify(String(index))})`);
362
+ await resolveRef(page, String(index));
363
+ const text = await page.evaluate(getTextResolvedJs());
341
364
  console.log(text ?? '(empty)');
342
365
  }));
343
366
  get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
344
367
  .action(browserAction(async (page, index) => {
345
- const val = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.value)(${JSON.stringify(String(index))})`);
368
+ await resolveRef(page, String(index));
369
+ const val = await page.evaluate(getValueResolvedJs());
346
370
  console.log(val ?? '(empty)');
347
371
  }));
348
372
  get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
@@ -353,7 +377,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
353
377
  }));
354
378
  get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
355
379
  .action(browserAction(async (page, index) => {
356
- const attrs = await page.evaluate(`((idx) => JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="' + idx + '"]')?.attributes].map(a=>[a.name,a.value]))))(${JSON.stringify(String(index))})`);
380
+ await resolveRef(page, String(index));
381
+ const attrs = await page.evaluate(getAttributesResolvedJs());
357
382
  console.log(attrs ?? '{}');
358
383
  }));
359
384
  // ── Interact ──
@@ -369,16 +394,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
369
394
  await page.wait(0.3);
370
395
  await page.typeText(index, text);
371
396
  // Detect autocomplete/combobox fields and wait for dropdown suggestions
372
- const isAutocomplete = await page.evaluate(`
373
- (() => {
374
- const el = document.querySelector('[data-opencli-ref="${index}"]');
375
- if (!el) return false;
376
- const role = el.getAttribute('role');
377
- const ac = el.getAttribute('aria-autocomplete');
378
- const list = el.getAttribute('list');
379
- return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
380
- })()
381
- `);
397
+ // __resolved is already set by typeText's resolver call
398
+ const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
382
399
  if (isAutocomplete) {
383
400
  await page.wait(0.4);
384
401
  console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
@@ -390,19 +407,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
390
407
  browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
391
408
  .description('Select dropdown option')
392
409
  .action(browserAction(async (page, index, option) => {
393
- const result = await page.evaluate(`
394
- (function() {
395
- var sel = document.querySelector('[data-opencli-ref="${index}"]');
396
- if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
397
- var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
398
- if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
399
- var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
400
- if (setter) setter.call(sel, match.value); else sel.value = match.value;
401
- sel.dispatchEvent(new Event('input', {bubbles:true}));
402
- sel.dispatchEvent(new Event('change', {bubbles:true}));
403
- return { selected: match.text };
404
- })()
405
- `);
410
+ await resolveRef(page, String(index));
411
+ const result = await page.evaluate(selectResolvedJs(option));
406
412
  if (result?.error) {
407
413
  console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
408
414
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
@@ -1001,6 +1007,10 @@ cli({
1001
1007
  });
1002
1008
  // ── Built-in: daemon ──────────────────────────────────────────────────────
1003
1009
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
1010
+ daemonCmd
1011
+ .command('status')
1012
+ .description('Show daemon status')
1013
+ .action(async () => { await daemonStatus(); });
1004
1014
  daemonCmd
1005
1015
  .command('stop')
1006
1016
  .description('Stop the daemon')
@@ -1,5 +1,7 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ export declare function daemonStatus(): Promise<void>;
5
7
  export declare function daemonStop(): Promise<void>;
@@ -1,9 +1,29 @@
1
1
  /**
2
- * CLI command for daemon lifecycle:
3
- * opencli daemon stopgraceful shutdown
2
+ * CLI commands for daemon lifecycle:
3
+ * opencli daemon statusshow daemon state
4
+ * opencli daemon stop — graceful shutdown
4
5
  */
6
+ import { styleText } from 'node:util';
5
7
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
8
+ import { formatDuration } from '../download/progress.js';
6
9
  import { log } from '../logger.js';
10
+ export async function daemonStatus() {
11
+ const status = await fetchDaemonStatus();
12
+ if (!status) {
13
+ console.log(`Daemon: ${styleText('dim', 'not running')}`);
14
+ return;
15
+ }
16
+ const extensionLabel = !status.extensionConnected
17
+ ? styleText('yellow', 'disconnected')
18
+ : status.extensionVersion
19
+ ? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
20
+ : `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
21
+ console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
22
+ console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
23
+ console.log(`Extension: ${extensionLabel}`);
24
+ console.log(`Memory: ${status.memoryMB} MB`);
25
+ console.log(`Port: ${status.port}`);
26
+ }
7
27
  export async function daemonStop() {
8
28
  const status = await fetchDaemonStatus();
9
29
  if (!status) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
3
3
  fetchDaemonStatusMock: vi.fn(),
4
4
  requestDaemonShutdownMock: vi.fn(),
@@ -7,7 +7,70 @@ vi.mock('../browser/daemon-client.js', () => ({
7
7
  fetchDaemonStatus: fetchDaemonStatusMock,
8
8
  requestDaemonShutdown: requestDaemonShutdownMock,
9
9
  }));
10
- import { daemonStop } from './daemon.js';
10
+ import { daemonStatus, daemonStop } from './daemon.js';
11
+ describe('daemonStatus', () => {
12
+ let stdoutSpy;
13
+ beforeEach(() => {
14
+ stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
15
+ fetchDaemonStatusMock.mockReset();
16
+ requestDaemonShutdownMock.mockReset();
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ it('reports "not running" when daemon is unreachable', async () => {
22
+ fetchDaemonStatusMock.mockResolvedValue(null);
23
+ await daemonStatus();
24
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
25
+ });
26
+ it('shows daemon info when running', async () => {
27
+ fetchDaemonStatusMock.mockResolvedValue({
28
+ ok: true,
29
+ pid: 12345,
30
+ uptime: 3661,
31
+ extensionConnected: true,
32
+ extensionVersion: '1.6.8',
33
+ pending: 0,
34
+ memoryMB: 64,
35
+ port: 19825,
36
+ });
37
+ await daemonStatus();
38
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
39
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
40
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
41
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
42
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
43
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
44
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
45
+ });
46
+ it('shows disconnected when extension is not connected', async () => {
47
+ fetchDaemonStatusMock.mockResolvedValue({
48
+ ok: true,
49
+ pid: 99,
50
+ uptime: 120,
51
+ extensionConnected: false,
52
+ pending: 0,
53
+ memoryMB: 32,
54
+ port: 19825,
55
+ });
56
+ await daemonStatus();
57
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
58
+ });
59
+ it('shows version unknown when the connected extension does not report one', async () => {
60
+ fetchDaemonStatusMock.mockResolvedValue({
61
+ ok: true,
62
+ pid: 99,
63
+ uptime: 120,
64
+ extensionConnected: true,
65
+ extensionVersion: undefined,
66
+ pending: 0,
67
+ memoryMB: 32,
68
+ port: 19825,
69
+ });
70
+ await daemonStatus();
71
+ expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('version unknown'));
72
+ });
73
+ });
11
74
  describe('daemonStop', () => {
12
75
  let stderrSpy;
13
76
  beforeEach(() => {