@jackwener/opencli 1.7.15 → 1.7.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/README.zh-CN.md +15 -12
- package/cli-manifest.json +165 -209
- package/clis/chatgpt/ask.js +3 -2
- package/clis/chatgpt/commands.test.js +2 -2
- package/clis/chatgpt/detail.js +7 -2
- package/clis/chatgpt/history.js +1 -1
- package/clis/chatgpt/image.js +38 -4
- package/clis/chatgpt/image.test.js +68 -1
- package/clis/chatgpt/new.js +1 -1
- package/clis/chatgpt/read.js +3 -2
- package/clis/chatgpt/send.js +3 -2
- package/clis/chatgpt/status.js +1 -1
- package/clis/chatgpt/utils.js +259 -25
- package/clis/chatgpt/utils.test.js +166 -2
- package/clis/claude/ask.js +23 -8
- package/clis/claude/detail.js +10 -3
- package/clis/claude/history.js +1 -1
- package/clis/claude/new.js +9 -3
- package/clis/claude/read.js +3 -2
- package/clis/claude/send.js +9 -4
- package/clis/claude/status.js +1 -1
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +22 -9
- package/clis/deepseek/detail.js +10 -2
- package/clis/deepseek/history.js +1 -1
- package/clis/deepseek/new.js +14 -3
- package/clis/deepseek/read.js +3 -2
- package/clis/deepseek/send.js +1 -1
- package/clis/deepseek/status.js +1 -1
- package/clis/deepseek/utils.js +8 -1
- package/clis/doubao/ask.js +1 -1
- package/clis/doubao/detail.js +1 -1
- package/clis/doubao/history.js +1 -1
- package/clis/doubao/meeting-summary.js +1 -1
- package/clis/doubao/meeting-transcript.js +1 -1
- package/clis/doubao/new.js +1 -1
- package/clis/doubao/read.js +1 -1
- package/clis/doubao/send.js +1 -1
- package/clis/doubao/status.js +1 -1
- package/clis/gemini/ask.js +1 -1
- package/clis/gemini/deep-research-result.js +1 -1
- package/clis/gemini/deep-research.js +1 -1
- package/clis/gemini/image.js +1 -1
- package/clis/gemini/new.js +1 -1
- package/clis/grok/ask.js +1 -1
- package/clis/grok/detail.js +1 -1
- package/clis/grok/history.js +1 -1
- package/clis/grok/image.js +1 -1
- package/clis/grok/new.js +1 -1
- package/clis/grok/read.js +1 -1
- package/clis/grok/send.js +1 -1
- package/clis/grok/status.js +1 -1
- package/clis/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/notebooklm/current.js +1 -1
- package/clis/notebooklm/get.js +1 -1
- package/clis/notebooklm/history.js +1 -1
- package/clis/notebooklm/note-list.js +1 -1
- package/clis/notebooklm/notes-get.js +1 -1
- package/clis/notebooklm/open.js +2 -2
- package/clis/notebooklm/open.test.js +1 -1
- package/clis/notebooklm/source-fulltext.js +1 -1
- package/clis/notebooklm/source-get.js +1 -1
- package/clis/notebooklm/source-guide.js +1 -1
- package/clis/notebooklm/source-list.js +1 -1
- package/clis/notebooklm/summary.js +1 -1
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/qwen/ask.js +1 -1
- package/clis/qwen/detail.js +1 -1
- package/clis/qwen/history.js +1 -1
- package/clis/qwen/image.js +1 -1
- package/clis/qwen/new.js +1 -1
- package/clis/qwen/read.js +1 -1
- package/clis/qwen/send.js +1 -1
- package/clis/qwen/status.js +1 -1
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +1 -0
- package/clis/reddit/subreddit.js +1 -0
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +1 -0
- package/clis/reddit/user-posts.js +1 -0
- package/clis/reddit/user.js +1 -0
- package/clis/twitter/article.js +7 -4
- package/clis/twitter/bookmark-folder.js +3 -5
- package/clis/twitter/bookmark-folder.test.js +5 -2
- package/clis/twitter/bookmark-folders.js +3 -5
- package/clis/twitter/bookmark-folders.test.js +3 -1
- package/clis/twitter/bookmarks.js +3 -5
- package/clis/twitter/download.js +1 -0
- package/clis/twitter/followers.js +1 -0
- package/clis/twitter/following.js +3 -6
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/likes.js +3 -5
- package/clis/twitter/list-add.js +4 -3
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +4 -3
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +3 -5
- package/clis/twitter/lists.js +3 -5
- package/clis/twitter/notifications.js +1 -0
- package/clis/twitter/profile.js +7 -4
- package/clis/twitter/search.js +1 -0
- package/clis/twitter/thread.js +5 -7
- package/clis/twitter/timeline.js +5 -7
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +3 -6
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- package/clis/yuanbao/ask.js +1 -1
- package/clis/yuanbao/detail.js +1 -1
- package/clis/yuanbao/history.js +1 -1
- package/clis/yuanbao/new.js +1 -1
- package/clis/yuanbao/read.js +1 -1
- package/clis/yuanbao/send.js +1 -1
- package/clis/yuanbao/status.js +1 -1
- package/dist/src/browser/bridge.d.ts +4 -1
- package/dist/src/browser/bridge.js +3 -1
- package/dist/src/browser/cdp.d.ts +4 -1
- package/dist/src/browser/daemon-client.d.ts +9 -16
- package/dist/src/browser/daemon-client.js +8 -9
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/network-cache.d.ts +5 -5
- package/dist/src/browser/network-cache.js +8 -8
- package/dist/src/browser/network-cache.test.js +4 -4
- package/dist/src/browser/page.d.ts +9 -7
- package/dist/src/browser/page.js +27 -16
- package/dist/src/browser/page.test.js +60 -30
- package/dist/src/build-manifest.js +1 -1
- package/dist/src/cli.js +91 -125
- package/dist/src/cli.test.js +293 -180
- package/dist/src/commanderAdapter.js +9 -0
- package/dist/src/discovery.js +1 -1
- package/dist/src/doctor.d.ts +0 -4
- package/dist/src/doctor.js +8 -72
- package/dist/src/doctor.test.js +26 -97
- package/dist/src/execution.d.ts +3 -0
- package/dist/src/execution.js +47 -23
- package/dist/src/execution.test.js +68 -45
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +1 -0
- package/dist/src/help.js +36 -1
- package/dist/src/main.js +0 -29
- package/dist/src/manifest-types.d.ts +2 -4
- package/dist/src/observation/artifact.js +1 -1
- package/dist/src/observation/artifact.test.js +3 -3
- package/dist/src/observation/events.d.ts +1 -1
- package/dist/src/observation/manager.js +1 -1
- package/dist/src/observation/manager.test.js +3 -3
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +3 -12
- package/dist/src/registry.js +6 -10
- package/dist/src/runtime.d.ts +10 -2
- package/dist/src/runtime.js +4 -1
- package/dist/src/serialization.d.ts +1 -1
- package/dist/src/serialization.js +1 -1
- package/dist/src/types.d.ts +0 -15
- package/package.json +1 -1
package/clis/chatgpt/utils.js
CHANGED
|
@@ -12,15 +12,26 @@ export const CHATGPT_URL = 'https://chatgpt.com';
|
|
|
12
12
|
// Selectors
|
|
13
13
|
const COMPOSER_SELECTORS = [
|
|
14
14
|
'[aria-label="Chat with ChatGPT"]',
|
|
15
|
+
'[aria-label="与 ChatGPT 聊天"]',
|
|
15
16
|
'[placeholder="Ask anything"]',
|
|
17
|
+
'[placeholder="有问题,尽管问"]',
|
|
16
18
|
'#prompt-textarea',
|
|
17
19
|
'[data-testid="prompt-textarea"]',
|
|
18
20
|
'[contenteditable="true"][role="textbox"]',
|
|
19
21
|
];
|
|
22
|
+
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
|
|
23
|
+
const SEND_BUTTON_FALLBACK_SELECTORS = [
|
|
24
|
+
'#composer-submit-button:not([disabled])',
|
|
25
|
+
];
|
|
20
26
|
const SEND_BUTTON_LABELS = [
|
|
21
27
|
'Send prompt',
|
|
22
28
|
'Send message',
|
|
23
29
|
'Send',
|
|
30
|
+
'发送提示',
|
|
31
|
+
];
|
|
32
|
+
const CLOSE_SIDEBAR_LABELS = [
|
|
33
|
+
'Close sidebar',
|
|
34
|
+
'关闭边栏',
|
|
24
35
|
];
|
|
25
36
|
|
|
26
37
|
function isSameChatGPTConversation(currentUrl, expectedUrl) {
|
|
@@ -119,16 +130,34 @@ export async function isOnChatGPT(page) {
|
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
// Comma-joined CSS selector list passed to page.wait({ selector }) so the
|
|
134
|
+
// wait succeeds as soon as any composer flavour mounts (querySelectorAll
|
|
135
|
+
// matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS;
|
|
136
|
+
// we only need to know "the composer is ready", not which variant rendered.
|
|
137
|
+
const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]';
|
|
138
|
+
const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
|
|
139
|
+
// Selector used by detail.js to wait for at least one rendered message bubble
|
|
140
|
+
// after navigating to /c/<id>; mirrors the markup queried by getVisibleMessages.
|
|
141
|
+
export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]';
|
|
142
|
+
|
|
122
143
|
export async function ensureOnChatGPT(page) {
|
|
123
144
|
if (await isOnChatGPT(page)) return false;
|
|
124
145
|
await page.goto(CHATGPT_URL, { settleMs: 2000 });
|
|
125
|
-
|
|
146
|
+
try {
|
|
147
|
+
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
|
|
148
|
+
} catch {
|
|
149
|
+
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
|
|
150
|
+
}
|
|
126
151
|
return true;
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
export async function startNewChat(page) {
|
|
130
155
|
await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 });
|
|
131
|
-
|
|
156
|
+
try {
|
|
157
|
+
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
|
|
158
|
+
} catch {
|
|
159
|
+
// Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error.
|
|
160
|
+
}
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
export async function getPageState(page) {
|
|
@@ -177,6 +206,40 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is
|
|
|
177
206
|
return state;
|
|
178
207
|
}
|
|
179
208
|
|
|
209
|
+
export async function clearChatGPTDraft(page) {
|
|
210
|
+
await page.evaluate(`
|
|
211
|
+
(() => {
|
|
212
|
+
const removeLabels = [/^remove file/i, /^移除文件/];
|
|
213
|
+
for (let pass = 0; pass < 10; pass += 1) {
|
|
214
|
+
const button = Array.from(document.querySelectorAll('button')).find((node) => {
|
|
215
|
+
const label = node.getAttribute('aria-label') || '';
|
|
216
|
+
return removeLabels.some((pattern) => pattern.test(label));
|
|
217
|
+
});
|
|
218
|
+
if (!button) break;
|
|
219
|
+
button.click();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const selectors = ${JSON.stringify(COMPOSER_SELECTORS)};
|
|
223
|
+
for (const selector of selectors) {
|
|
224
|
+
for (const node of document.querySelectorAll(selector)) {
|
|
225
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
226
|
+
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
|
|
227
|
+
node.value = '';
|
|
228
|
+
} else if (node.isContentEditable) {
|
|
229
|
+
node.textContent = '';
|
|
230
|
+
node.innerHTML = '<p><br></p>';
|
|
231
|
+
} else {
|
|
232
|
+
node.textContent = '';
|
|
233
|
+
}
|
|
234
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
235
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
})()
|
|
239
|
+
`);
|
|
240
|
+
await page.wait(0.5);
|
|
241
|
+
}
|
|
242
|
+
|
|
180
243
|
/**
|
|
181
244
|
* Send a message to the ChatGPT composer and submit it.
|
|
182
245
|
* Returns true if the message was sent successfully.
|
|
@@ -185,22 +248,32 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
185
248
|
// Close sidebar if open (it can cover the chat composer)
|
|
186
249
|
await page.evaluate(`
|
|
187
250
|
(() => {
|
|
188
|
-
const
|
|
251
|
+
const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)};
|
|
252
|
+
const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || ''));
|
|
189
253
|
if (closeBtn) closeBtn.click();
|
|
190
254
|
})()
|
|
191
255
|
`);
|
|
192
|
-
|
|
256
|
+
// The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next
|
|
257
|
+
// page.evaluate roundtrip flushes the close-sidebar React update and
|
|
258
|
+
// findComposer() retries inside a single CDP call, so no fixed sleep is
|
|
259
|
+
// needed before reading the composer.
|
|
193
260
|
|
|
194
|
-
// Wait for composer to be ready and use Playwright's type()
|
|
195
|
-
await page.wait(1.5);
|
|
196
|
-
|
|
197
261
|
const typeResult = await page.evaluate(`
|
|
198
262
|
(() => {
|
|
199
263
|
${buildComposerLocatorScript()}
|
|
200
264
|
const composer = findComposer();
|
|
201
265
|
if (!composer) return false;
|
|
202
266
|
composer.focus();
|
|
203
|
-
composer
|
|
267
|
+
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
|
|
268
|
+
composer.value = '';
|
|
269
|
+
} else if (composer.isContentEditable) {
|
|
270
|
+
composer.textContent = '';
|
|
271
|
+
composer.innerHTML = '<p><br></p>';
|
|
272
|
+
} else {
|
|
273
|
+
composer.textContent = '';
|
|
274
|
+
}
|
|
275
|
+
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
276
|
+
composer.dispatchEvent(new Event('change', { bubbles: true }));
|
|
204
277
|
return true;
|
|
205
278
|
})()
|
|
206
279
|
`);
|
|
@@ -228,27 +301,37 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
228
301
|
`);
|
|
229
302
|
}
|
|
230
303
|
|
|
231
|
-
|
|
232
|
-
|
|
304
|
+
let sent = null;
|
|
305
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
306
|
+
await page.wait(0.5);
|
|
307
|
+
sent = await page.evaluate(`
|
|
308
|
+
(() => {
|
|
309
|
+
const isUsable = (button) => button
|
|
310
|
+
&& !button.disabled
|
|
311
|
+
&& button.getAttribute('aria-disabled') !== 'true';
|
|
312
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
|
313
|
+
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
|
314
|
+
const btns = Array.from(document.querySelectorAll('button'));
|
|
315
|
+
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
316
|
+
const sendBtn = isUsable(primary)
|
|
317
|
+
? primary
|
|
318
|
+
: btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
|
|
319
|
+
return { sendBtnFound: !!sendBtn };
|
|
320
|
+
})()
|
|
321
|
+
`);
|
|
322
|
+
if (sent?.sendBtnFound) break;
|
|
323
|
+
}
|
|
233
324
|
|
|
234
|
-
|
|
235
|
-
const sent = await page.evaluate(`
|
|
236
|
-
(() => {
|
|
237
|
-
const btns = Array.from(document.querySelectorAll('button'));
|
|
238
|
-
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
239
|
-
const sendBtn = btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
240
|
-
return { sendBtnFound: !!sendBtn };
|
|
241
|
-
})()
|
|
242
|
-
`);
|
|
243
|
-
|
|
244
|
-
if (!sent || !sent.sendBtnFound) {
|
|
325
|
+
if (!sent?.sendBtnFound) {
|
|
245
326
|
return false;
|
|
246
327
|
}
|
|
247
328
|
|
|
248
329
|
await page.evaluate(`
|
|
249
330
|
(() => {
|
|
331
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
|
332
|
+
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
|
250
333
|
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
251
|
-
const sendBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
334
|
+
const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
252
335
|
if (sendBtn) sendBtn.click();
|
|
253
336
|
})()
|
|
254
337
|
`);
|
|
@@ -361,8 +444,9 @@ export async function waitForChatGPTResponse(page, baselineCount, prompt, timeou
|
|
|
361
444
|
}
|
|
362
445
|
|
|
363
446
|
export async function getConversationList(page) {
|
|
447
|
+
// ensureOnChatGPT already waits for the composer selector after navigation,
|
|
448
|
+
// so the previous standalone 2 s settle is redundant.
|
|
364
449
|
await ensureOnChatGPT(page);
|
|
365
|
-
await page.wait(2);
|
|
366
450
|
|
|
367
451
|
const openSidebar = await page.evaluate(`(() => {
|
|
368
452
|
const button = Array.from(document.querySelectorAll('button'))
|
|
@@ -373,12 +457,22 @@ export async function getConversationList(page) {
|
|
|
373
457
|
}
|
|
374
458
|
return false;
|
|
375
459
|
})()`);
|
|
376
|
-
if (openSidebar)
|
|
460
|
+
if (openSidebar) {
|
|
461
|
+
try {
|
|
462
|
+
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
|
|
463
|
+
} catch {
|
|
464
|
+
// Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto.
|
|
465
|
+
}
|
|
466
|
+
}
|
|
377
467
|
|
|
378
468
|
let items = await extractConversationLinks(page);
|
|
379
469
|
if (!items.length) {
|
|
380
470
|
await page.goto(CHATGPT_URL, { settleMs: 2000 });
|
|
381
|
-
|
|
471
|
+
try {
|
|
472
|
+
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 });
|
|
473
|
+
} catch {
|
|
474
|
+
// No conversation links visible after fallback goto; extractConversationLinks returns empty.
|
|
475
|
+
}
|
|
382
476
|
items = await extractConversationLinks(page);
|
|
383
477
|
}
|
|
384
478
|
|
|
@@ -422,6 +516,142 @@ async function extractConversationLinks(page) {
|
|
|
422
516
|
: [];
|
|
423
517
|
}
|
|
424
518
|
|
|
519
|
+
function imageMimeFromPath(filePath) {
|
|
520
|
+
const lower = String(filePath || '').toLowerCase();
|
|
521
|
+
if (lower.endsWith('.png')) return 'image/png';
|
|
522
|
+
if (lower.endsWith('.webp')) return 'image/webp';
|
|
523
|
+
if (lower.endsWith('.gif')) return 'image/gif';
|
|
524
|
+
if (lower.endsWith('.heic')) return 'image/heic';
|
|
525
|
+
if (lower.endsWith('.heif')) return 'image/heif';
|
|
526
|
+
return 'image/jpeg';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function prepareChatGPTImagePaths(imagePaths) {
|
|
530
|
+
const fs = await import('node:fs');
|
|
531
|
+
const path = await import('node:path');
|
|
532
|
+
const absPaths = imagePaths.map(filePath => path.default.resolve(filePath));
|
|
533
|
+
const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
|
|
534
|
+
|
|
535
|
+
for (const absPath of absPaths) {
|
|
536
|
+
if (!fs.default.existsSync(absPath)) {
|
|
537
|
+
return { ok: false, reason: `Image not found: ${absPath}` };
|
|
538
|
+
}
|
|
539
|
+
const stat = fs.default.statSync(absPath);
|
|
540
|
+
if (!stat.isFile()) {
|
|
541
|
+
return { ok: false, reason: `Not a file: ${absPath}` };
|
|
542
|
+
}
|
|
543
|
+
if (stat.size > 25 * 1024 * 1024) {
|
|
544
|
+
return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` };
|
|
545
|
+
}
|
|
546
|
+
const ext = path.default.extname(absPath).toLowerCase();
|
|
547
|
+
if (!allowedExts.has(ext)) {
|
|
548
|
+
return { ok: false, reason: `Unsupported image type: ${absPath}` };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { ok: true, paths: absPaths };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function waitForChatGPTUploadPreview(page, fileNames) {
|
|
556
|
+
const namesJson = JSON.stringify(fileNames);
|
|
557
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
558
|
+
await page.wait(1);
|
|
559
|
+
const ready = await page.evaluate(`
|
|
560
|
+
(() => {
|
|
561
|
+
const names = ${namesJson};
|
|
562
|
+
const text = document.body ? (document.body.innerText || '') : '';
|
|
563
|
+
const matchedNames = names.filter(name => text.includes(name)).length;
|
|
564
|
+
if (matchedNames >= names.length) return true;
|
|
565
|
+
|
|
566
|
+
const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea');
|
|
567
|
+
let root = composer;
|
|
568
|
+
for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement;
|
|
569
|
+
const scope = root || document.body;
|
|
570
|
+
if (!scope) return false;
|
|
571
|
+
|
|
572
|
+
const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
|
|
573
|
+
return previewNodes.length >= names.length;
|
|
574
|
+
})()
|
|
575
|
+
`);
|
|
576
|
+
if (ready) return true;
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function uploadChatGPTImages(page, imagePaths) {
|
|
582
|
+
const fs = await import('node:fs');
|
|
583
|
+
const path = await import('node:path');
|
|
584
|
+
const prepared = await prepareChatGPTImagePaths(imagePaths);
|
|
585
|
+
if (!prepared.ok) return prepared;
|
|
586
|
+
const absPaths = prepared.paths;
|
|
587
|
+
|
|
588
|
+
const fileNames = absPaths.map(filePath => path.default.basename(filePath));
|
|
589
|
+
|
|
590
|
+
let uploaded = false;
|
|
591
|
+
if (page.setFileInput) {
|
|
592
|
+
try {
|
|
593
|
+
await page.setFileInput(absPaths, 'input[type="file"]');
|
|
594
|
+
uploaded = true;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
const msg = String(err?.message || err);
|
|
597
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!uploaded) {
|
|
604
|
+
const files = absPaths.map(absPath => ({
|
|
605
|
+
name: path.default.basename(absPath),
|
|
606
|
+
mime: imageMimeFromPath(absPath),
|
|
607
|
+
base64: fs.default.readFileSync(absPath).toString('base64'),
|
|
608
|
+
}));
|
|
609
|
+
const fallbackResult = await page.evaluate(`
|
|
610
|
+
(() => {
|
|
611
|
+
const files = ${JSON.stringify(files)};
|
|
612
|
+
const input = document.querySelector('input[type="file"]');
|
|
613
|
+
if (!(input instanceof HTMLInputElement)) {
|
|
614
|
+
return { ok: false, reason: 'file input not found' };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const dt = new DataTransfer();
|
|
618
|
+
for (const item of files) {
|
|
619
|
+
const binary = atob(item.base64);
|
|
620
|
+
const bytes = new Uint8Array(binary.length);
|
|
621
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
622
|
+
dt.items.add(new File([bytes], item.name, { type: item.mime }));
|
|
623
|
+
}
|
|
624
|
+
input.files = dt.files;
|
|
625
|
+
|
|
626
|
+
const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
|
|
627
|
+
if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
|
|
628
|
+
const nativeEvent = new Event('change', { bubbles: true });
|
|
629
|
+
input[propsKey].onChange({
|
|
630
|
+
target: input,
|
|
631
|
+
currentTarget: input,
|
|
632
|
+
nativeEvent,
|
|
633
|
+
preventDefault() {},
|
|
634
|
+
stopPropagation() {},
|
|
635
|
+
isDefaultPrevented() { return false; },
|
|
636
|
+
isPropagationStopped() { return false; },
|
|
637
|
+
persist() {},
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
641
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
642
|
+
}
|
|
643
|
+
return { ok: true };
|
|
644
|
+
})()
|
|
645
|
+
`);
|
|
646
|
+
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const ready = await waitForChatGPTUploadPreview(page, fileNames);
|
|
650
|
+
if (!ready) return { ok: false, reason: 'image upload preview did not appear' };
|
|
651
|
+
|
|
652
|
+
return { ok: true, files: absPaths };
|
|
653
|
+
}
|
|
654
|
+
|
|
425
655
|
/**
|
|
426
656
|
* Check if ChatGPT is still generating a response.
|
|
427
657
|
*/
|
|
@@ -532,9 +762,13 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
|
|
|
532
762
|
|
|
533
763
|
export const __test__ = {
|
|
534
764
|
COMPOSER_SELECTORS,
|
|
765
|
+
SEND_BUTTON_SELECTOR,
|
|
766
|
+
SEND_BUTTON_FALLBACK_SELECTORS,
|
|
535
767
|
SEND_BUTTON_LABELS,
|
|
768
|
+
CLOSE_SIDEBAR_LABELS,
|
|
536
769
|
isSameChatGPTConversation,
|
|
537
770
|
parseChatGPTConversationId,
|
|
771
|
+
imageMimeFromPath,
|
|
538
772
|
};
|
|
539
773
|
|
|
540
774
|
/**
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { __test__, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js';
|
|
6
|
+
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
while (tempDirs.length) {
|
|
12
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
3
15
|
|
|
4
16
|
function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
|
|
5
17
|
let generatingIndex = 0;
|
|
@@ -74,3 +86,155 @@ describe('chatgpt conversation id parsing', () => {
|
|
|
74
86
|
expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/);
|
|
75
87
|
});
|
|
76
88
|
});
|
|
89
|
+
|
|
90
|
+
describe('chatgpt send selectors', () => {
|
|
91
|
+
it('keeps locale-independent send-button selector before aria-label fallbacks', async () => {
|
|
92
|
+
const page = {
|
|
93
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
nativeType: vi.fn().mockResolvedValue(undefined),
|
|
95
|
+
evaluate: vi.fn((script) => {
|
|
96
|
+
if (script.includes('findComposer')) return Promise.resolve(true);
|
|
97
|
+
if (script.includes('sendBtnFound')) {
|
|
98
|
+
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
99
|
+
return Promise.resolve({ sendBtnFound: true });
|
|
100
|
+
}
|
|
101
|
+
if (script.includes('if (sendBtn) sendBtn.click')) {
|
|
102
|
+
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
103
|
+
}
|
|
104
|
+
return Promise.resolve(undefined);
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('uses the composer submit fallback consistently for readiness and click', async () => {
|
|
112
|
+
const page = {
|
|
113
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
114
|
+
nativeType: vi.fn().mockResolvedValue(undefined),
|
|
115
|
+
evaluate: vi.fn((script) => {
|
|
116
|
+
if (script.includes('findComposer')) return Promise.resolve(true);
|
|
117
|
+
if (script.includes('sendBtnFound')) {
|
|
118
|
+
expect(script).toContain('#composer-submit-button:not([disabled])');
|
|
119
|
+
return Promise.resolve({ sendBtnFound: true });
|
|
120
|
+
}
|
|
121
|
+
if (script.includes('if (sendBtn) sendBtn.click')) {
|
|
122
|
+
expect(script).toContain('#composer-submit-button:not([disabled])');
|
|
123
|
+
}
|
|
124
|
+
return Promise.resolve(undefined);
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps zh-CN aria and placeholder fallbacks without replacing English selectors', () => {
|
|
132
|
+
expect(__test__.COMPOSER_SELECTORS).toEqual(expect.arrayContaining([
|
|
133
|
+
'[aria-label="Chat with ChatGPT"]',
|
|
134
|
+
'[aria-label="与 ChatGPT 聊天"]',
|
|
135
|
+
'[placeholder="Ask anything"]',
|
|
136
|
+
'[placeholder="有问题,尽管问"]',
|
|
137
|
+
'[data-testid="prompt-textarea"]',
|
|
138
|
+
]));
|
|
139
|
+
expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])');
|
|
140
|
+
expect(__test__.SEND_BUTTON_FALLBACK_SELECTORS).toContain('#composer-submit-button:not([disabled])');
|
|
141
|
+
expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送提示']));
|
|
142
|
+
expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏']));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('chatgpt image upload helper', () => {
|
|
147
|
+
it('validates local images without a browser page', async () => {
|
|
148
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
149
|
+
tempDirs.push(dir);
|
|
150
|
+
const filePath = path.join(dir, 'cat.png');
|
|
151
|
+
fs.writeFileSync(filePath, 'fake-png');
|
|
152
|
+
|
|
153
|
+
await expect(prepareChatGPTImagePaths([filePath])).resolves.toEqual({ ok: true, paths: [filePath] });
|
|
154
|
+
await expect(prepareChatGPTImagePaths([path.join(dir, 'missing.png')])).resolves.toMatchObject({
|
|
155
|
+
ok: false,
|
|
156
|
+
reason: expect.stringContaining('Image not found'),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('prefers Browser Bridge file input upload and waits for a preview', async () => {
|
|
161
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
162
|
+
tempDirs.push(dir);
|
|
163
|
+
const filePath = path.join(dir, 'cat.png');
|
|
164
|
+
fs.writeFileSync(filePath, 'fake-png');
|
|
165
|
+
|
|
166
|
+
const page = {
|
|
167
|
+
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
168
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
169
|
+
evaluate: vi.fn().mockResolvedValue(true),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const result = await uploadChatGPTImages(page, [filePath]);
|
|
173
|
+
|
|
174
|
+
expect(result).toEqual({ ok: true, files: [filePath] });
|
|
175
|
+
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects missing files before touching the page', async () => {
|
|
179
|
+
const page = {
|
|
180
|
+
setFileInput: vi.fn(),
|
|
181
|
+
wait: vi.fn(),
|
|
182
|
+
evaluate: vi.fn(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = await uploadChatGPTImages(page, ['/no/such/cat.png']);
|
|
186
|
+
|
|
187
|
+
expect(result.ok).toBe(false);
|
|
188
|
+
expect(result.reason).toContain('Image not found');
|
|
189
|
+
expect(page.setFileInput).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects non-image extensions', async () => {
|
|
193
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
194
|
+
tempDirs.push(dir);
|
|
195
|
+
const filePath = path.join(dir, 'report.pdf');
|
|
196
|
+
fs.writeFileSync(filePath, 'fake');
|
|
197
|
+
|
|
198
|
+
const page = {
|
|
199
|
+
setFileInput: vi.fn(),
|
|
200
|
+
wait: vi.fn(),
|
|
201
|
+
evaluate: vi.fn(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const result = await uploadChatGPTImages(page, [filePath]);
|
|
205
|
+
|
|
206
|
+
expect(result.ok).toBe(false);
|
|
207
|
+
expect(result.reason).toContain('Unsupported image type');
|
|
208
|
+
expect(page.setFileInput).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('passes a React-compatible change event in fallback upload', async () => {
|
|
212
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
213
|
+
tempDirs.push(dir);
|
|
214
|
+
const filePath = path.join(dir, 'cat.png');
|
|
215
|
+
fs.writeFileSync(filePath, 'fake-png');
|
|
216
|
+
|
|
217
|
+
const page = {
|
|
218
|
+
setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
|
|
219
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
220
|
+
evaluate: vi.fn((script) => {
|
|
221
|
+
return Promise.resolve({ ok: true });
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const result = await uploadChatGPTImages(page, [filePath]);
|
|
226
|
+
|
|
227
|
+
expect(result).toEqual({ ok: true, files: [filePath] });
|
|
228
|
+
const fallbackScript = page.evaluate.mock.calls
|
|
229
|
+
.map(([script]) => String(script))
|
|
230
|
+
.find(script => script.includes('new DataTransfer()'));
|
|
231
|
+
expect(fallbackScript).toContain('preventDefault()');
|
|
232
|
+
expect(fallbackScript).toContain('stopPropagation()');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('exposes image MIME inference for fallback upload', () => {
|
|
236
|
+
expect(__test__.imageMimeFromPath('/tmp/a.png')).toBe('image/png');
|
|
237
|
+
expect(__test__.imageMimeFromPath('/tmp/a.webp')).toBe('image/webp');
|
|
238
|
+
expect(__test__.imageMimeFromPath('/tmp/a.jpg')).toBe('image/jpeg');
|
|
239
|
+
});
|
|
240
|
+
});
|
package/clis/claude/ask.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
|
-
CLAUDE_DOMAIN, CLAUDE_URL,
|
|
4
|
+
CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, MESSAGE_SELECTOR,
|
|
5
|
+
ensureOnClaude, selectModel, setAdaptiveThinking,
|
|
5
6
|
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
6
7
|
ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt,
|
|
7
8
|
} from './utils.js';
|
|
@@ -14,7 +15,7 @@ export const askCommand = cli({
|
|
|
14
15
|
domain: CLAUDE_DOMAIN,
|
|
15
16
|
strategy: Strategy.COOKIE,
|
|
16
17
|
browser: true,
|
|
17
|
-
|
|
18
|
+
siteSession: 'persistent',
|
|
18
19
|
navigateBefore: false,
|
|
19
20
|
args: [
|
|
20
21
|
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
|
@@ -38,7 +39,11 @@ export const askCommand = cli({
|
|
|
38
39
|
|
|
39
40
|
if (parseBoolFlag(kwargs.new)) {
|
|
40
41
|
await page.goto(CLAUDE_URL);
|
|
41
|
-
|
|
42
|
+
try {
|
|
43
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 });
|
|
44
|
+
} catch {
|
|
45
|
+
// Composer didn't mount; ensureClaudeComposer below surfaces a typed error.
|
|
46
|
+
}
|
|
42
47
|
} else {
|
|
43
48
|
const navigated = await ensureOnClaude(page);
|
|
44
49
|
if (navigated) {
|
|
@@ -48,11 +53,18 @@ export const askCommand = cli({
|
|
|
48
53
|
var link = document.querySelector('a[href*="/chat/"]');
|
|
49
54
|
if (link) link.click();
|
|
50
55
|
})()`);
|
|
51
|
-
|
|
56
|
+
// Wait for the resumed conversation to render messages, or
|
|
57
|
+
// fall through if the link click had no effect (no recents).
|
|
58
|
+
try {
|
|
59
|
+
await page.wait({ selector: MESSAGE_SELECTOR, timeout: 5 });
|
|
60
|
+
} catch {
|
|
61
|
+
// No prior conversation; ensureClaudeComposer still requires composer below.
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
// ensureClaudeComposer reads composer presence directly via getPageState,
|
|
67
|
+
// so the previous standalone 2 s settle is redundant.
|
|
56
68
|
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
|
|
57
69
|
|
|
58
70
|
// Model selector is only available on the new-chat page, not inside
|
|
@@ -80,14 +92,16 @@ export const askCommand = cli({
|
|
|
80
92
|
}
|
|
81
93
|
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
82
94
|
}
|
|
83
|
-
|
|
95
|
+
// Post-toggle settle dropped — the next CDP eval (setAdaptiveThinking) gives
|
|
96
|
+
// React enough time to flush aria-checked updates between rountrips.
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink));
|
|
87
100
|
if (!thinkResult?.ok && wantThink) {
|
|
88
101
|
throw new CommandExecutionError('Could not enable Adaptive thinking');
|
|
89
102
|
}
|
|
90
|
-
|
|
103
|
+
// Post-toggle settle dropped — the next CDP eval (sendMessage / sendWithFile)
|
|
104
|
+
// gives React enough time to flush aria-checked updates.
|
|
91
105
|
|
|
92
106
|
if (kwargs.file) {
|
|
93
107
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
@@ -100,7 +114,8 @@ export const askCommand = cli({
|
|
|
100
114
|
// SPA navigates after send; "Promise was collected" means send succeeded
|
|
101
115
|
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
102
116
|
}
|
|
103
|
-
|
|
117
|
+
// Pre-waitForResponse settle dropped — waitForResponse's first 3 s polling
|
|
118
|
+
// tick covers the same window without an unconditional sleep.
|
|
104
119
|
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
|
105
120
|
if (!result) {
|
|
106
121
|
throw new EmptyResultError(
|
package/clis/claude/detail.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
|
3
|
+
import { CLAUDE_DOMAIN, MESSAGE_SELECTOR, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
|
4
4
|
|
|
5
5
|
export const detailCommand = cli({
|
|
6
6
|
site: 'claude',
|
|
@@ -10,7 +10,7 @@ export const detailCommand = cli({
|
|
|
10
10
|
domain: CLAUDE_DOMAIN,
|
|
11
11
|
strategy: Strategy.COOKIE,
|
|
12
12
|
browser: true,
|
|
13
|
-
|
|
13
|
+
siteSession: 'persistent',
|
|
14
14
|
navigateBefore: false,
|
|
15
15
|
args: [
|
|
16
16
|
{ name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
|
|
@@ -21,7 +21,14 @@ export const detailCommand = cli({
|
|
|
21
21
|
const id = requireConversationId(kwargs.id);
|
|
22
22
|
|
|
23
23
|
await page.goto(`https://claude.ai/chat/${id}`);
|
|
24
|
-
|
|
24
|
+
// Wait for the first assistant message bubble to render instead of a
|
|
25
|
+
// fixed 4 s sleep. Swallow the timeout so empty conversations and
|
|
26
|
+
// login redirects fall through to ensureClaudeLogin / EmptyResultError.
|
|
27
|
+
try {
|
|
28
|
+
await page.wait({ selector: MESSAGE_SELECTOR, timeout: 10 });
|
|
29
|
+
} catch {
|
|
30
|
+
// Empty conversation, missing access, or login redirect — handled below.
|
|
31
|
+
}
|
|
25
32
|
await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
|
|
26
33
|
|
|
27
34
|
const messages = await getVisibleMessages(page);
|