@jackwener/opencli 1.7.7 → 1.7.9

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 (280) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +782 -55
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/amazon/discussion.js +37 -6
  6. package/clis/amazon/discussion.test.js +147 -32
  7. package/clis/apple-podcasts/commands.test.js +4 -4
  8. package/clis/apple-podcasts/episodes.js +1 -1
  9. package/clis/apple-podcasts/search.js +1 -1
  10. package/clis/apple-podcasts/top.js +1 -1
  11. package/clis/arxiv/paper.js +1 -1
  12. package/clis/arxiv/search.js +1 -1
  13. package/clis/band/mentions.js +3 -3
  14. package/clis/bbc/news.js +1 -1
  15. package/clis/bilibili/subtitle.js +2 -2
  16. package/clis/bloomberg/businessweek.js +1 -1
  17. package/clis/bloomberg/economics.js +1 -1
  18. package/clis/bloomberg/industries.js +1 -1
  19. package/clis/bloomberg/main.js +1 -1
  20. package/clis/bloomberg/markets.js +1 -1
  21. package/clis/bloomberg/opinions.js +1 -1
  22. package/clis/bloomberg/politics.js +1 -1
  23. package/clis/bloomberg/tech.js +1 -1
  24. package/clis/boss/search.js +49 -8
  25. package/clis/boss/search.test.js +78 -0
  26. package/clis/boss/send.js +3 -3
  27. package/clis/chatgpt/image.js +37 -8
  28. package/clis/chatgpt/image.test.js +92 -0
  29. package/clis/chatgpt/utils.js +39 -6
  30. package/clis/chatgpt/utils.test.js +63 -0
  31. package/clis/chatgpt-app/ask.js +4 -20
  32. package/clis/chatgpt-app/ax.js +135 -2
  33. package/clis/chatgpt-app/ax.test.js +35 -0
  34. package/clis/chatgpt-app/model.js +1 -1
  35. package/clis/chatgpt-app/new.js +1 -1
  36. package/clis/chatgpt-app/read.js +1 -1
  37. package/clis/chatgpt-app/send.js +3 -22
  38. package/clis/chatgpt-app/status.js +1 -1
  39. package/clis/chatwise/ask.js +2 -2
  40. package/clis/chatwise/model.js +2 -2
  41. package/clis/chatwise/send.js +2 -2
  42. package/clis/claude/ask.js +128 -0
  43. package/clis/claude/ask.test.js +338 -0
  44. package/clis/claude/commands.test.js +118 -0
  45. package/clis/claude/detail.js +29 -0
  46. package/clis/claude/history.js +31 -0
  47. package/clis/claude/new.js +21 -0
  48. package/clis/claude/read.js +24 -0
  49. package/clis/claude/send.js +41 -0
  50. package/clis/claude/status.js +24 -0
  51. package/clis/claude/utils.js +440 -0
  52. package/clis/claude/utils.test.js +148 -0
  53. package/clis/codex/ask.js +2 -2
  54. package/clis/codex/send.js +2 -2
  55. package/clis/ctrip/search.js +1 -1
  56. package/clis/ctrip/search.test.js +4 -4
  57. package/clis/cursor/ask.js +2 -2
  58. package/clis/cursor/composer.js +2 -2
  59. package/clis/cursor/send.js +2 -2
  60. package/clis/deepseek/ask.js +49 -10
  61. package/clis/deepseek/ask.test.js +150 -3
  62. package/clis/deepseek/utils.js +60 -22
  63. package/clis/deepseek/utils.test.js +124 -5
  64. package/clis/doubao/utils.js +53 -11
  65. package/clis/doubao/utils.test.js +22 -2
  66. package/clis/eastmoney/announcement.js +1 -1
  67. package/clis/eastmoney/convertible.js +1 -1
  68. package/clis/eastmoney/etf.js +1 -1
  69. package/clis/eastmoney/holders.js +1 -1
  70. package/clis/eastmoney/index-board.js +1 -1
  71. package/clis/eastmoney/kline.js +1 -1
  72. package/clis/eastmoney/kuaixun.js +1 -1
  73. package/clis/eastmoney/longhu.js +1 -1
  74. package/clis/eastmoney/money-flow.js +1 -1
  75. package/clis/eastmoney/northbound.js +1 -1
  76. package/clis/eastmoney/quote.js +1 -1
  77. package/clis/eastmoney/rank.js +1 -1
  78. package/clis/eastmoney/sectors.js +1 -1
  79. package/clis/facebook/marketplace-inbox.js +83 -0
  80. package/clis/facebook/marketplace-listings.js +83 -0
  81. package/clis/facebook/marketplace.test.js +91 -0
  82. package/clis/google/news.js +1 -1
  83. package/clis/google/suggest.js +1 -1
  84. package/clis/google/trends.js +1 -1
  85. package/clis/google-scholar/cite.js +74 -0
  86. package/clis/google-scholar/cite.test.js +47 -0
  87. package/clis/google-scholar/profile.js +92 -0
  88. package/clis/google-scholar/profile.test.js +49 -0
  89. package/clis/google-scholar/search.js +1 -1
  90. package/clis/google-scholar/search.test.js +15 -0
  91. package/clis/hf/top.js +1 -1
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/powerchina/search.js +250 -0
  115. package/clis/powerchina/search.test.js +67 -0
  116. package/clis/producthunt/posts.js +1 -1
  117. package/clis/producthunt/today.js +1 -1
  118. package/clis/sinablog/search.js +1 -1
  119. package/clis/sinafinance/news.js +1 -1
  120. package/clis/sinafinance/stock.js +6 -3
  121. package/clis/sinafinance/stock.test.js +59 -0
  122. package/clis/spotify/spotify.js +6 -6
  123. package/clis/substack/search.js +1 -1
  124. package/clis/toutiao/articles.js +80 -0
  125. package/clis/toutiao/articles.test.js +30 -0
  126. package/clis/twitter/followers.js +2 -2
  127. package/clis/twitter/following.js +224 -73
  128. package/clis/twitter/following.test.js +277 -0
  129. package/clis/twitter/post.js +184 -47
  130. package/clis/twitter/post.test.js +114 -34
  131. package/clis/uiverse/_shared.js +63 -4
  132. package/clis/uiverse/_shared.test.js +7 -0
  133. package/clis/uiverse/code.js +1 -0
  134. package/clis/uiverse/navigation.test.js +12 -0
  135. package/clis/uiverse/preview.js +1 -0
  136. package/clis/web/read.js +319 -81
  137. package/clis/web/read.test.js +221 -5
  138. package/clis/weibo/favorites.js +169 -0
  139. package/clis/weibo/favorites.test.js +114 -0
  140. package/clis/weibo/publish.js +282 -0
  141. package/clis/weibo/publish.test.js +183 -0
  142. package/clis/weixin/create-draft.js +225 -0
  143. package/clis/weixin/drafts.js +65 -0
  144. package/clis/weixin/drafts.test.js +65 -0
  145. package/clis/weread/ranking.js +1 -1
  146. package/clis/weread/search-regression.test.js +8 -8
  147. package/clis/weread/search.js +1 -1
  148. package/clis/wikipedia/random.js +1 -1
  149. package/clis/wikipedia/search.js +1 -1
  150. package/clis/wikipedia/summary.js +1 -1
  151. package/clis/wikipedia/trending.js +1 -1
  152. package/clis/xianyu/chat.js +3 -3
  153. package/clis/xianyu/item.js +2 -2
  154. package/clis/xianyu/item.test.js +3 -3
  155. package/clis/xiaohongshu/search.js +17 -2
  156. package/clis/xiaohongshu/search.test.js +37 -1
  157. package/clis/xiaoyuzhou/download.js +1 -1
  158. package/clis/xiaoyuzhou/download.test.js +3 -3
  159. package/clis/xiaoyuzhou/episode.js +1 -1
  160. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  161. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  162. package/clis/xiaoyuzhou/podcast.js +1 -1
  163. package/clis/xiaoyuzhou/transcript.js +1 -1
  164. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  165. package/clis/yollomi/models.js +1 -1
  166. package/clis/youtube/channel.js +24 -1
  167. package/clis/youtube/channel.test.js +59 -0
  168. package/clis/zhihu/answer.js +21 -162
  169. package/clis/zhihu/answer.test.js +26 -53
  170. package/clis/zhihu/collection.js +197 -0
  171. package/clis/zhihu/collection.test.js +290 -0
  172. package/clis/zhihu/collections.js +127 -0
  173. package/clis/zhihu/collections.test.js +182 -0
  174. package/clis/zhihu/comment.js +24 -305
  175. package/clis/zhihu/comment.test.js +31 -35
  176. package/clis/zhihu/favorite.js +44 -182
  177. package/clis/zhihu/favorite.test.js +30 -167
  178. package/clis/zhihu/follow.js +25 -56
  179. package/clis/zhihu/follow.test.js +20 -23
  180. package/clis/zhihu/like.js +22 -67
  181. package/clis/zhihu/like.test.js +19 -42
  182. package/clis/zhihu/search.js +3 -2
  183. package/clis/zhihu/write-shared.js +8 -1
  184. package/clis/zhihu/write-shared.test.js +1 -0
  185. package/clis/zlibrary/commands.test.js +75 -0
  186. package/clis/zlibrary/info.js +47 -0
  187. package/clis/zlibrary/search.js +46 -0
  188. package/clis/zlibrary/utils.js +136 -0
  189. package/dist/src/adapter-source.d.ts +11 -0
  190. package/dist/src/adapter-source.js +24 -0
  191. package/dist/src/adapter-source.test.js +29 -0
  192. package/dist/src/browser/base-page.d.ts +3 -1
  193. package/dist/src/browser/base-page.js +76 -1
  194. package/dist/src/browser/base-page.test.d.ts +1 -0
  195. package/dist/src/browser/base-page.test.js +74 -0
  196. package/dist/src/browser/bridge.d.ts +1 -0
  197. package/dist/src/browser/bridge.js +36 -9
  198. package/dist/src/browser/cdp.d.ts +1 -0
  199. package/dist/src/browser/cdp.js +3 -3
  200. package/dist/src/browser/daemon-client.d.ts +38 -4
  201. package/dist/src/browser/daemon-client.js +24 -7
  202. package/dist/src/browser/daemon-client.test.js +49 -0
  203. package/dist/src/browser/errors.js +3 -0
  204. package/dist/src/browser/errors.test.js +3 -0
  205. package/dist/src/browser/network-cache.d.ts +1 -0
  206. package/dist/src/browser/page.d.ts +3 -1
  207. package/dist/src/browser/page.js +10 -2
  208. package/dist/src/browser/profile.d.ts +14 -0
  209. package/dist/src/browser/profile.js +85 -0
  210. package/dist/src/build-manifest.d.ts +2 -0
  211. package/dist/src/build-manifest.js +13 -3
  212. package/dist/src/build-manifest.test.js +20 -2
  213. package/dist/src/cli.d.ts +6 -0
  214. package/dist/src/cli.js +462 -32
  215. package/dist/src/cli.test.js +209 -2
  216. package/dist/src/commanderAdapter.js +29 -9
  217. package/dist/src/commanderAdapter.test.js +78 -2
  218. package/dist/src/commands/daemon.js +6 -0
  219. package/dist/src/completion-shared.js +1 -2
  220. package/dist/src/completion.test.js +3 -2
  221. package/dist/src/daemon.js +125 -41
  222. package/dist/src/doctor.d.ts +4 -6
  223. package/dist/src/doctor.js +80 -22
  224. package/dist/src/doctor.test.js +82 -0
  225. package/dist/src/engine.test.js +6 -5
  226. package/dist/src/errors.d.ts +14 -8
  227. package/dist/src/errors.js +36 -30
  228. package/dist/src/errors.test.js +5 -5
  229. package/dist/src/execution.d.ts +4 -0
  230. package/dist/src/execution.js +173 -25
  231. package/dist/src/execution.test.js +171 -1
  232. package/dist/src/main.js +10 -0
  233. package/dist/src/observation/artifact.d.ts +16 -0
  234. package/dist/src/observation/artifact.js +260 -0
  235. package/dist/src/observation/artifact.test.d.ts +1 -0
  236. package/dist/src/observation/artifact.test.js +121 -0
  237. package/dist/src/observation/events.d.ts +89 -0
  238. package/dist/src/observation/events.js +1 -0
  239. package/dist/src/observation/index.d.ts +7 -0
  240. package/dist/src/observation/index.js +7 -0
  241. package/dist/src/observation/manager.d.ts +9 -0
  242. package/dist/src/observation/manager.js +27 -0
  243. package/dist/src/observation/manager.test.d.ts +1 -0
  244. package/dist/src/observation/manager.test.js +13 -0
  245. package/dist/src/observation/redaction.d.ts +11 -0
  246. package/dist/src/observation/redaction.js +81 -0
  247. package/dist/src/observation/redaction.test.d.ts +1 -0
  248. package/dist/src/observation/redaction.test.js +32 -0
  249. package/dist/src/observation/retention.d.ts +32 -0
  250. package/dist/src/observation/retention.js +160 -0
  251. package/dist/src/observation/retention.test.d.ts +1 -0
  252. package/dist/src/observation/retention.test.js +118 -0
  253. package/dist/src/observation/ring-buffer.d.ts +22 -0
  254. package/dist/src/observation/ring-buffer.js +45 -0
  255. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  256. package/dist/src/observation/ring-buffer.test.js +22 -0
  257. package/dist/src/observation/session.d.ts +25 -0
  258. package/dist/src/observation/session.js +50 -0
  259. package/dist/src/pipeline/executor.test.js +1 -0
  260. package/dist/src/pipeline/steps/download.test.js +1 -0
  261. package/dist/src/pipeline/steps/fetch.js +1 -21
  262. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  263. package/dist/src/plugin-scaffold.js +1 -1
  264. package/dist/src/plugin-scaffold.test.js +1 -1
  265. package/dist/src/registry.d.ts +40 -9
  266. package/dist/src/registry.js +3 -1
  267. package/dist/src/runtime-detect.d.ts +10 -0
  268. package/dist/src/runtime-detect.js +19 -0
  269. package/dist/src/runtime-detect.test.js +12 -1
  270. package/dist/src/runtime.d.ts +2 -0
  271. package/dist/src/runtime.js +1 -0
  272. package/dist/src/types.d.ts +22 -0
  273. package/dist/src/update-check.d.ts +31 -1
  274. package/dist/src/update-check.js +62 -16
  275. package/dist/src/update-check.test.js +86 -1
  276. package/package.json +1 -1
  277. package/dist/src/diagnostic.d.ts +0 -63
  278. package/dist/src/diagnostic.js +0 -292
  279. package/dist/src/diagnostic.test.js +0 -302
  280. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { SelectorError } from '@jackwener/opencli/errors';
2
+ import { selectorError } from '@jackwener/opencli/errors';
3
3
  export const composerCommand = cli({
4
4
  site: 'cursor',
5
5
  name: 'composer',
@@ -27,7 +27,7 @@ export const composerCommand = cli({
27
27
  return true;
28
28
  })(${JSON.stringify(textToInsert)})`);
29
29
  if (!typed) {
30
- throw new SelectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
30
+ throw selectorError('Cursor Composer input element', 'Could not find Cursor Composer input element after pressing Cmd+I.');
31
31
  }
32
32
  await page.wait(0.5);
33
33
  await page.pressKey('Enter');
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { SelectorError } from '@jackwener/opencli/errors';
2
+ import { selectorError } from '@jackwener/opencli/errors';
3
3
  export const sendCommand = cli({
4
4
  site: 'cursor',
5
5
  name: 'send',
@@ -24,7 +24,7 @@ export const sendCommand = cli({
24
24
  return true;
25
25
  })(${JSON.stringify(textToInsert)})`);
26
26
  if (!injected) {
27
- throw new SelectorError('Cursor Composer input element');
27
+ throw selectorError('Cursor Composer input element');
28
28
  }
29
29
  // Submit the command. In Cursor, Enter usually submits the chat.
30
30
  await page.wait(0.5);
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
3
3
  import {
4
4
  DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
5
5
  sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
@@ -18,7 +18,7 @@ export const askCommand = cli({
18
18
  { name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
19
19
  { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
20
20
  { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
21
- { name: 'model', default: 'instant', choices: ['instant', 'expert'], help: 'Model to use: instant or expert' },
21
+ { name: 'model', default: 'instant', choices: ['instant', 'expert', 'vision'], help: 'Model to use: instant, expert, or vision' },
22
22
  { name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
23
23
  { name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
24
24
  { name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
@@ -35,26 +35,65 @@ export const askCommand = cli({
35
35
  await page.goto(DEEPSEEK_URL);
36
36
  await page.wait(3);
37
37
  } else {
38
- await ensureOnDeepSeek(page);
38
+ const navigated = await ensureOnDeepSeek(page);
39
+ if (navigated) {
40
+ // Workspace was recycled; try to resume the most recent
41
+ // conversation instead of starting a new one.
42
+ await page.evaluate(`(() => {
43
+ var link = document.querySelector('a[href*="/a/chat/s/"]');
44
+ if (link) link.click();
45
+ })()`);
46
+ await page.wait(2);
47
+ }
39
48
  }
40
49
 
41
50
  await page.wait(2);
42
51
 
52
+ // Model selector is only available on the new-chat page, not inside
53
+ // an existing conversation. Skip it when we resumed a prior thread.
54
+ const currentUrl = await page.evaluate('window.location.href') || '';
55
+ const inConversation = currentUrl.includes('/a/chat/s/');
56
+ const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
57
+
43
58
  const wantModel = kwargs.model || 'instant';
44
- const modelResult = await withRetry(() => selectModel(page, wantModel));
45
- if (!modelResult?.ok) {
46
- throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
59
+ if (inConversation && modelExplicit) {
60
+ throw new CliError(
61
+ 'ARGUMENT',
62
+ `Cannot switch to ${wantModel} model inside an existing conversation.`,
63
+ 'Re-run with --new to start a fresh chat before selecting a model.',
64
+ EXIT_CODES.USAGE_ERROR,
65
+ );
66
+ }
67
+
68
+ if (!inConversation) {
69
+ const modelResult = await withRetry(() => selectModel(page, wantModel));
70
+ if (!modelResult?.ok) {
71
+ throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
72
+ }
73
+ if (modelResult?.toggled) await page.wait(0.5);
47
74
  }
48
- if (modelResult?.toggled) await page.wait(0.5);
49
75
 
50
76
  const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
51
77
  if (!thinkResult?.ok && wantThink) {
52
78
  throw new CommandExecutionError('Could not enable DeepThink');
53
79
  }
54
80
 
55
- const searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
56
- if (!searchResult?.ok && wantSearch) {
57
- throw new CommandExecutionError('Could not enable Search');
81
+ if (wantModel === 'vision' && wantSearch) {
82
+ throw new CliError(
83
+ 'ARGUMENT',
84
+ 'DeepSeek vision mode does not support --search.',
85
+ 'Run without --search, or use --model instant/expert for web search.',
86
+ EXIT_CODES.USAGE_ERROR,
87
+ );
88
+ }
89
+
90
+ // Vision mode does not have the search toggle.
91
+ let searchResult;
92
+ if (wantModel !== 'vision') {
93
+ searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
94
+ if (!searchResult?.ok && wantSearch) {
95
+ throw new CommandExecutionError('Could not enable Search');
96
+ }
58
97
  }
59
98
 
60
99
  if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
3
3
 
4
4
  const {
5
5
  mockEnsureOnDeepSeek,
@@ -43,11 +43,13 @@ describe('deepseek ask --file', () => {
43
43
  const page = {
44
44
  wait: vi.fn().mockResolvedValue(undefined),
45
45
  goto: vi.fn().mockResolvedValue(undefined),
46
+ evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
46
47
  };
47
48
 
48
49
  beforeEach(() => {
49
50
  vi.clearAllMocks();
50
- mockEnsureOnDeepSeek.mockResolvedValue(undefined);
51
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
52
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
51
53
  mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
52
54
  mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
53
55
  mockSendWithFile.mockResolvedValue({ ok: true });
@@ -90,11 +92,13 @@ describe('deepseek ask --think', () => {
90
92
  const page = {
91
93
  wait: vi.fn().mockResolvedValue(undefined),
92
94
  goto: vi.fn().mockResolvedValue(undefined),
95
+ evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
93
96
  };
94
97
 
95
98
  beforeEach(() => {
96
99
  vi.clearAllMocks();
97
- mockEnsureOnDeepSeek.mockResolvedValue(undefined);
100
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
101
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
98
102
  mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
99
103
  mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
100
104
  mockSendMessage.mockResolvedValue({ ok: true });
@@ -163,3 +167,146 @@ describe('deepseek ask --think', () => {
163
167
  expect(Object.keys(rows[0])).toEqual(['response']);
164
168
  });
165
169
  });
170
+
171
+ describe('deepseek ask conversation resume', () => {
172
+ const page = {
173
+ wait: vi.fn().mockResolvedValue(undefined),
174
+ goto: vi.fn().mockResolvedValue(undefined),
175
+ evaluate: vi.fn(),
176
+ };
177
+
178
+ beforeEach(() => {
179
+ vi.clearAllMocks();
180
+ mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
181
+ mockSendMessage.mockResolvedValue({ ok: true });
182
+ mockGetBubbleCount.mockResolvedValue(2);
183
+ mockWaitForResponse.mockResolvedValue('follow-up reply');
184
+ });
185
+
186
+ it('resumes the most recent conversation and skips model selection', async () => {
187
+ mockEnsureOnDeepSeek.mockResolvedValue(true);
188
+ // first evaluate: sidebar resume click (returns undefined)
189
+ page.evaluate.mockResolvedValueOnce(undefined);
190
+ // second evaluate: URL check (now inside a conversation)
191
+ page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
192
+
193
+ const rows = await askCommand.func(page, {
194
+ prompt: 'follow up',
195
+ timeout: 120,
196
+ new: false,
197
+ model: 'instant',
198
+ think: false,
199
+ search: false,
200
+ });
201
+
202
+ expect(rows).toEqual([{ response: 'follow-up reply' }]);
203
+ expect(mockSelectModel).not.toHaveBeenCalled();
204
+ expect(mockSendMessage).toHaveBeenCalled();
205
+ });
206
+
207
+ it('skips model selection when already inside an existing conversation', async () => {
208
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
209
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
210
+
211
+ const rows = await askCommand.func(page, {
212
+ prompt: 'continue',
213
+ timeout: 120,
214
+ new: false,
215
+ model: 'expert',
216
+ think: false,
217
+ search: false,
218
+ });
219
+
220
+ expect(rows).toEqual([{ response: 'follow-up reply' }]);
221
+ expect(mockSelectModel).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
225
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
226
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
227
+
228
+ await expect(askCommand.func(page, {
229
+ prompt: 'continue',
230
+ timeout: 120,
231
+ new: false,
232
+ model: 'expert',
233
+ think: false,
234
+ search: false,
235
+ __opencliOptionSources: { model: 'cli' },
236
+ })).rejects.toMatchObject(new CliError(
237
+ 'ARGUMENT',
238
+ 'Cannot switch to expert model inside an existing conversation.',
239
+ 'Re-run with --new to start a fresh chat before selecting a model.',
240
+ EXIT_CODES.USAGE_ERROR,
241
+ ));
242
+
243
+ expect(mockSelectModel).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it('still selects model when no conversation to resume', async () => {
247
+ mockEnsureOnDeepSeek.mockResolvedValue(true);
248
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
249
+ // first evaluate: sidebar resume click (no link found)
250
+ page.evaluate.mockResolvedValueOnce(undefined);
251
+ // second evaluate: URL check (still on root page)
252
+ page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
253
+
254
+ const rows = await askCommand.func(page, {
255
+ prompt: 'hello',
256
+ timeout: 120,
257
+ new: false,
258
+ model: 'instant',
259
+ think: false,
260
+ search: false,
261
+ });
262
+
263
+ expect(rows).toEqual([{ response: 'follow-up reply' }]);
264
+ expect(mockSelectModel).toHaveBeenCalled();
265
+ });
266
+
267
+ it('skips search toggle in vision mode when search is not requested', async () => {
268
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
269
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
270
+ mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
271
+ mockSendMessage.mockResolvedValue({ ok: true });
272
+ mockGetBubbleCount.mockResolvedValue(0);
273
+ mockWaitForResponse.mockResolvedValue('vision reply');
274
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
275
+
276
+ const rows = await askCommand.func(page, {
277
+ prompt: 'describe',
278
+ timeout: 120,
279
+ new: false,
280
+ model: 'vision',
281
+ think: false,
282
+ search: false,
283
+ });
284
+
285
+ expect(rows).toEqual([{ response: 'vision reply' }]);
286
+ expect(mockSetFeature).toHaveBeenCalledTimes(1);
287
+ expect(mockSetFeature).toHaveBeenCalledWith(expect.anything(), 'DeepThink', false);
288
+ });
289
+
290
+ it('fails fast instead of silently ignoring --search in vision mode', async () => {
291
+ mockEnsureOnDeepSeek.mockResolvedValue(false);
292
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
293
+ page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
294
+
295
+ await expect(askCommand.func(page, {
296
+ prompt: 'describe',
297
+ timeout: 120,
298
+ new: false,
299
+ model: 'vision',
300
+ think: false,
301
+ search: true,
302
+ })).rejects.toMatchObject(new CliError(
303
+ 'ARGUMENT',
304
+ 'DeepSeek vision mode does not support --search.',
305
+ 'Run without --search, or use --model instant/expert for web search.',
306
+ EXIT_CODES.USAGE_ERROR,
307
+ ));
308
+
309
+ expect(mockSendMessage).not.toHaveBeenCalled();
310
+ expect(mockSendWithFile).not.toHaveBeenCalled();
311
+ });
312
+ });
@@ -15,10 +15,10 @@ export async function isOnDeepSeek(page) {
15
15
  }
16
16
 
17
17
  export async function ensureOnDeepSeek(page) {
18
- if (!(await isOnDeepSeek(page))) {
19
- await page.goto(DEEPSEEK_URL);
20
- await page.wait(3);
21
- }
18
+ if (await isOnDeepSeek(page)) return false;
19
+ await page.goto(DEEPSEEK_URL);
20
+ await page.wait(3);
21
+ return true;
22
22
  }
23
23
 
24
24
  export async function getPageState(page) {
@@ -40,9 +40,10 @@ export async function selectModel(page, modelName) {
40
40
  return page.evaluate(`(() => {
41
41
  var radios = document.querySelectorAll('div[role="radio"]');
42
42
  if (radios.length === 0) return { ok: false };
43
- var isFirst = '${modelName}'.toLowerCase() === 'instant';
44
- if (!isFirst && radios.length < 2) return { ok: false };
45
- var target = isFirst ? radios[0] : radios[radios.length - 1];
43
+ var name = '${modelName}'.toLowerCase();
44
+ var index = name === 'instant' ? 0 : name === 'expert' ? 1 : name === 'vision' ? 2 : -1;
45
+ if (index < 0 || index >= radios.length) return { ok: false };
46
+ var target = radios[index];
46
47
  var alreadySelected = target.getAttribute('aria-checked') === 'true';
47
48
  if (!alreadySelected) target.click();
48
49
  return { ok: true, toggled: !alreadySelected };
@@ -74,14 +75,18 @@ export async function sendMessage(page, prompt) {
74
75
  document.execCommand('insertText', false, ${promptJson});
75
76
  await new Promise(r => setTimeout(r, 800));
76
77
 
77
- const btns = document.querySelectorAll('div[role="button"]');
78
- for (const btn of btns) {
79
- if (btn.getAttribute('aria-disabled') === 'false') {
80
- const svgs = btn.querySelectorAll('svg');
81
- if (svgs.length > 0 && btn.closest('div')?.querySelector('textarea')) {
82
- btn.click();
83
- return { ok: true };
84
- }
78
+ // Find the send button: last non-toggle button in the textarea's container
79
+ var container = box.parentElement;
80
+ while (container && !container.querySelector('div[role="button"]')) {
81
+ container = container.parentElement;
82
+ }
83
+ if (container) {
84
+ var btns = container.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
85
+ var sendBtn = btns[btns.length - 1];
86
+ if (sendBtn && sendBtn.getAttribute('aria-disabled') === 'false'
87
+ && sendBtn.querySelectorAll('svg').length > 0) {
88
+ sendBtn.click();
89
+ return { ok: true };
85
90
  }
86
91
  }
87
92
 
@@ -252,8 +257,7 @@ export async function getConversationList(page) {
252
257
  const items = [];
253
258
  const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
254
259
  links.forEach((link, i) => {
255
- const titleEl = link.querySelector('div');
256
- const title = titleEl ? titleEl.textContent.trim() : '';
260
+ const title = (link.innerText || '').trim().split('\\n')[0].trim();
257
261
  const href = link.getAttribute('href') || '';
258
262
  const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
259
263
  items.push({
@@ -274,9 +278,18 @@ async function waitForFilePreview(page, fileName) {
274
278
  for (let attempt = 0; attempt < 8; attempt++) {
275
279
  await page.wait(2);
276
280
  const ready = await page.evaluate(`(() => {
277
- const name = ${JSON.stringify(fileName)};
278
- return Array.from(document.querySelectorAll('div'))
279
- .some((el) => el.children.length === 0 && (el.textContent || '').trim() === name);
281
+ var name = ${JSON.stringify(fileName)};
282
+ var hasFileName = Array.from(document.querySelectorAll('div'))
283
+ .some(function(el) { return el.children.length === 0 && (el.textContent || '').trim() === name; });
284
+ if (hasFileName) return true;
285
+ // Vision mode shows an image thumbnail, not filename text. Require
286
+ // a preview-like node here; send-button readiness is checked later.
287
+ var box = document.querySelector('${TEXTAREA_SELECTOR}');
288
+ if (!box) return false;
289
+ var c = box.parentElement;
290
+ while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
291
+ if (!c) return false;
292
+ return !!c.querySelector('img[src], canvas, video, [style*="background-image"], [class*="preview"], [class*="upload"]');
280
293
  })()`);
281
294
  if (ready) return true;
282
295
  }
@@ -315,7 +328,7 @@ export async function sendWithFile(page, filePath, prompt) {
315
328
  uploaded = true;
316
329
  } catch (err) {
317
330
  const msg = String(err?.message || err);
318
- if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
331
+ if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
319
332
  throw err;
320
333
  }
321
334
  }
@@ -342,7 +355,8 @@ export async function sendWithFile(page, filePath, prompt) {
342
355
  }
343
356
 
344
357
  inp.files = dt.files;
345
- inp[propsKey].onChange({ target: { files: dt.files } });
358
+ // Use inp.files, not dt.files; assignment transfers ownership
359
+ inp[propsKey].onChange({ target: { files: inp.files } });
346
360
  return { ok: true };
347
361
  })()`);
348
362
  if (fallbackResult && !fallbackResult.ok) return fallbackResult;
@@ -351,6 +365,30 @@ export async function sendWithFile(page, filePath, prompt) {
351
365
  const ready = await waitForFilePreview(page, fileName);
352
366
  if (!ready) return { ok: false, reason: 'file preview did not appear' };
353
367
 
368
+ // File preview appears immediately but send button stays disabled until
369
+ // the server upload finishes. Wait for it.
370
+ let sendEnabled = false;
371
+ for (let tick = 0; tick < 15; tick++) {
372
+ const enabled = await page.evaluate(`(() => {
373
+ var box = document.querySelector('${TEXTAREA_SELECTOR}');
374
+ if (!box) return false;
375
+ var c = box.parentElement;
376
+ while (c && !c.querySelector('div[role="button"]')) c = c.parentElement;
377
+ if (!c) return false;
378
+ var btns = c.querySelectorAll('div[role="button"]:not(.ds-toggle-button)');
379
+ var last = btns[btns.length - 1];
380
+ return !!(last && last.getAttribute('aria-disabled') === 'false');
381
+ })()`);
382
+ if (enabled) {
383
+ sendEnabled = true;
384
+ break;
385
+ }
386
+ await page.wait(1);
387
+ }
388
+ if (!sendEnabled) {
389
+ return { ok: false, reason: 'send button did not enable after upload' };
390
+ }
391
+
354
392
  return sendMessage(page, prompt);
355
393
  }
356
394
 
@@ -107,15 +107,36 @@ describe('deepseek sendWithFile', () => {
107
107
  setFileInput: vi.fn().mockResolvedValue(undefined),
108
108
  wait: vi.fn().mockResolvedValue(undefined),
109
109
  evaluate: vi.fn()
110
- .mockResolvedValueOnce(undefined)
111
- .mockResolvedValueOnce(true)
112
- .mockResolvedValueOnce({ ok: true }),
110
+ .mockResolvedValueOnce(undefined) // sidebar collapse
111
+ .mockResolvedValueOnce(true) // waitForFilePreview
112
+ .mockResolvedValueOnce(true) // send button enabled check
113
+ .mockResolvedValueOnce({ ok: true }), // sendMessage
113
114
  };
114
115
 
115
116
  const result = await sendWithFile(page, filePath, 'summarize this');
116
117
 
117
- expect(result).toEqual({ ok: true });
118
- expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
118
+ expect(result).toEqual({ ok: true }); expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
119
+ });
120
+
121
+ it('fails closed when upload preview appears but send button never enables', async () => {
122
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
123
+ tempDirs.push(dir);
124
+ const filePath = path.join(dir, 'report.txt');
125
+ fs.writeFileSync(filePath, 'hello');
126
+
127
+ const page = {
128
+ setFileInput: vi.fn().mockResolvedValue(undefined),
129
+ wait: vi.fn().mockResolvedValue(undefined),
130
+ evaluate: vi.fn()
131
+ .mockResolvedValueOnce(undefined) // sidebar collapse
132
+ .mockResolvedValueOnce(true) // waitForFilePreview
133
+ .mockResolvedValue(false), // send button never enables
134
+ };
135
+
136
+ const result = await sendWithFile(page, filePath, 'summarize this');
137
+
138
+ expect(result).toEqual({ ok: false, reason: 'send button did not enable after upload' });
139
+ expect(page.evaluate).toHaveBeenCalledTimes(17);
119
140
  });
120
141
  });
121
142
 
@@ -142,4 +163,102 @@ describe('deepseek selectModel', () => {
142
163
  expect(result).toEqual({ ok: false });
143
164
  expect(instantRadio.click).not.toHaveBeenCalled();
144
165
  });
166
+
167
+ it('selects the correct radio for each model', async () => {
168
+ const radios = [0, 1, 2].map(() => ({
169
+ getAttribute: vi.fn(() => 'false'),
170
+ click: vi.fn(),
171
+ }));
172
+ global.document = {
173
+ querySelectorAll: vi.fn(() => radios),
174
+ };
175
+ const page = {
176
+ evaluate: vi.fn(async (script) => eval(script)),
177
+ };
178
+
179
+ await selectModel(page, 'instant');
180
+ expect(radios[0].click).toHaveBeenCalled();
181
+ expect(radios[1].click).not.toHaveBeenCalled();
182
+ expect(radios[2].click).not.toHaveBeenCalled();
183
+
184
+ radios.forEach(r => r.click.mockClear());
185
+ await selectModel(page, 'expert');
186
+ expect(radios[1].click).toHaveBeenCalled();
187
+
188
+ radios.forEach(r => r.click.mockClear());
189
+ await selectModel(page, 'vision');
190
+ expect(radios[2].click).toHaveBeenCalled();
191
+ });
192
+
193
+ it('rejects unknown model names', async () => {
194
+ const radios = [0, 1, 2].map(() => ({
195
+ getAttribute: vi.fn(() => 'false'),
196
+ click: vi.fn(),
197
+ }));
198
+ global.document = {
199
+ querySelectorAll: vi.fn(() => radios),
200
+ };
201
+ const page = {
202
+ evaluate: vi.fn(async (script) => eval(script)),
203
+ };
204
+
205
+ const result = await selectModel(page, 'turbo');
206
+ expect(result).toEqual({ ok: false });
207
+ });
208
+ });
209
+
210
+ describe('deepseek sendWithFile Not allowed fallback', () => {
211
+ const tempDirs = [];
212
+
213
+ afterEach(() => {
214
+ vi.restoreAllMocks();
215
+ while (tempDirs.length) {
216
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
217
+ }
218
+ });
219
+
220
+ it('falls back to DataTransfer when setFileInput throws Not allowed', async () => {
221
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
222
+ tempDirs.push(dir);
223
+ const filePath = path.join(dir, 'image.png');
224
+ fs.writeFileSync(filePath, 'fake-png');
225
+
226
+ const page = {
227
+ setFileInput: vi.fn().mockRejectedValue(new Error('Not allowed')),
228
+ wait: vi.fn().mockResolvedValue(undefined),
229
+ evaluate: vi.fn()
230
+ .mockResolvedValueOnce(undefined) // sidebar collapse
231
+ .mockResolvedValueOnce({ ok: true }) // DataTransfer fallback
232
+ .mockResolvedValueOnce(true) // waitForFilePreview
233
+ .mockResolvedValueOnce(true) // send button enabled
234
+ .mockResolvedValueOnce({ ok: true }),// sendMessage
235
+ };
236
+
237
+ const result = await sendWithFile(page, filePath, 'describe');
238
+
239
+ expect(page.setFileInput).toHaveBeenCalled();
240
+ expect(page.evaluate).toHaveBeenCalledTimes(5);
241
+ expect(result).toEqual({ ok: true });
242
+ });
243
+
244
+ it('does not treat send-button enablement alone as image upload proof', async () => {
245
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-deepseek-'));
246
+ tempDirs.push(dir);
247
+ const filePath = path.join(dir, 'image.png');
248
+ fs.writeFileSync(filePath, 'fake-png');
249
+
250
+ const page = {
251
+ setFileInput: vi.fn().mockResolvedValue(undefined),
252
+ wait: vi.fn().mockResolvedValue(undefined),
253
+ evaluate: vi.fn()
254
+ .mockResolvedValueOnce(undefined) // sidebar collapse
255
+ .mockResolvedValue(false), // no filename / thumbnail preview
256
+ };
257
+
258
+ const result = await sendWithFile(page, filePath, 'describe');
259
+
260
+ expect(result).toEqual({ ok: false, reason: 'file preview did not appear' });
261
+ expect(page.evaluate.mock.calls[1][0]).toContain('img[src], canvas, video');
262
+ expect(page.evaluate.mock.calls[1][0]).not.toContain("aria-disabled') === 'false'");
263
+ });
145
264
  });