@jackwener/opencli 1.7.12 → 1.7.13

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 (407) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +12194 -6843
  4. package/clis/1point3acres/digest.js +35 -0
  5. package/clis/1point3acres/forum.js +51 -0
  6. package/clis/1point3acres/forums.js +44 -0
  7. package/clis/1point3acres/hot.js +35 -0
  8. package/clis/1point3acres/latest.js +35 -0
  9. package/clis/1point3acres/notifications.js +64 -0
  10. package/clis/1point3acres/search.js +71 -0
  11. package/clis/1point3acres/thread.js +117 -0
  12. package/clis/1point3acres/user.js +77 -0
  13. package/clis/1point3acres/utils.js +247 -0
  14. package/clis/_shared/desktop-commands.js +4 -0
  15. package/clis/aibase/news.js +110 -0
  16. package/clis/aibase/news.test.js +59 -0
  17. package/clis/amazon/discussion.test.js +1 -28
  18. package/clis/antigravity/watch.js +3 -2
  19. package/clis/arxiv/author.js +44 -0
  20. package/clis/baidu-scholar/search.js +0 -1
  21. package/clis/bbc/topic.js +57 -0
  22. package/clis/bbc/utils.js +79 -0
  23. package/clis/chaoxing/assignments.js +1 -1
  24. package/clis/chaoxing/exams.js +1 -1
  25. package/clis/chatgpt/ask.js +57 -0
  26. package/clis/chatgpt/commands.test.js +45 -0
  27. package/clis/chatgpt/detail.js +46 -0
  28. package/clis/chatgpt/history.js +39 -0
  29. package/clis/chatgpt/image.js +12 -11
  30. package/clis/chatgpt/image.test.js +23 -0
  31. package/clis/chatgpt/new.js +25 -0
  32. package/clis/chatgpt/read.js +43 -0
  33. package/clis/chatgpt/send.js +46 -0
  34. package/clis/chatgpt/status.js +29 -0
  35. package/clis/chatgpt/utils.js +294 -4
  36. package/clis/chatgpt/utils.test.js +13 -0
  37. package/clis/chatgpt-app/ask.js +6 -3
  38. package/clis/chatwise/ask.js +16 -43
  39. package/clis/chatwise/composer.test.js +186 -0
  40. package/clis/chatwise/send.js +2 -24
  41. package/clis/chatwise/utils.js +143 -0
  42. package/clis/claude/ask.js +1 -1
  43. package/clis/claude/detail.js +1 -0
  44. package/clis/claude/history.js +1 -0
  45. package/clis/claude/new.js +1 -0
  46. package/clis/claude/read.js +1 -0
  47. package/clis/claude/send.js +1 -0
  48. package/clis/claude/status.js +1 -0
  49. package/clis/codex/ask.js +15 -9
  50. package/clis/codex/history.js +16 -33
  51. package/clis/codex/projects.js +28 -0
  52. package/clis/codex/read.js +10 -4
  53. package/clis/codex/send.js +10 -3
  54. package/clis/codex/sidebar.js +356 -0
  55. package/clis/codex/sidebar.test.js +329 -0
  56. package/clis/coingecko/categories.js +75 -0
  57. package/clis/coingecko/coin.js +107 -0
  58. package/clis/coingecko/coingecko.test.js +109 -0
  59. package/clis/coingecko/derivatives.js +84 -0
  60. package/clis/coingecko/exchanges.js +74 -0
  61. package/clis/coingecko/global.js +71 -0
  62. package/clis/coingecko/top.js +64 -0
  63. package/clis/coingecko/trending.js +55 -0
  64. package/clis/coupang/add-to-cart.js +21 -13
  65. package/clis/coupang/coupang.test.js +159 -0
  66. package/clis/coupang/product.js +257 -0
  67. package/clis/coupang/search.js +38 -16
  68. package/clis/coupang/utils.js +55 -1
  69. package/clis/crates/crate.js +62 -0
  70. package/clis/crates/search.js +44 -0
  71. package/clis/crates/utils.js +72 -0
  72. package/clis/ctrip/ctrip.test.js +234 -0
  73. package/clis/ctrip/hotel-suggest.js +45 -0
  74. package/clis/ctrip/search.js +22 -68
  75. package/clis/ctrip/utils.js +175 -0
  76. package/clis/cursor/ask.js +6 -3
  77. package/clis/dblp/author.js +133 -0
  78. package/clis/dblp/venue.js +64 -0
  79. package/clis/deepseek/ask.js +12 -7
  80. package/clis/deepseek/ask.test.js +13 -13
  81. package/clis/deepseek/detail.js +38 -0
  82. package/clis/deepseek/detail.test.js +81 -0
  83. package/clis/deepseek/history.js +1 -0
  84. package/clis/deepseek/new.js +1 -0
  85. package/clis/deepseek/read.js +1 -0
  86. package/clis/deepseek/send.js +140 -0
  87. package/clis/deepseek/send.test.js +107 -0
  88. package/clis/deepseek/status.js +1 -0
  89. package/clis/deepseek/utils.js +66 -0
  90. package/clis/deepseek/utils.test.js +107 -1
  91. package/clis/defillama/defillama.test.js +99 -0
  92. package/clis/defillama/protocol.js +84 -0
  93. package/clis/defillama/protocols.js +55 -0
  94. package/clis/defillama/utils.js +99 -0
  95. package/clis/devto/latest.js +74 -0
  96. package/clis/dockerhub/image.js +52 -0
  97. package/clis/dockerhub/search.js +47 -0
  98. package/clis/dockerhub/utils.js +100 -0
  99. package/clis/doubao/ask.js +7 -3
  100. package/clis/doubao/detail.js +1 -0
  101. package/clis/doubao/history.js +1 -0
  102. package/clis/doubao/meeting-summary.js +1 -0
  103. package/clis/doubao/meeting-transcript.js +1 -0
  104. package/clis/doubao/new.js +1 -0
  105. package/clis/doubao/read.js +1 -0
  106. package/clis/doubao/send.js +1 -0
  107. package/clis/doubao/status.js +1 -0
  108. package/clis/douyin/draft.test.js +1 -30
  109. package/clis/endoflife/endoflife.test.js +51 -0
  110. package/clis/endoflife/product.js +55 -0
  111. package/clis/endoflife/utils.js +89 -0
  112. package/clis/facebook/__fixtures__/notifications-page.html +13 -0
  113. package/clis/facebook/notifications.js +326 -30
  114. package/clis/facebook/notifications.test.js +458 -0
  115. package/clis/flathub/app.js +71 -0
  116. package/clis/flathub/flathub.test.js +90 -0
  117. package/clis/flathub/search.js +80 -0
  118. package/clis/flathub/utils.js +114 -0
  119. package/clis/gemini/ask.js +7 -3
  120. package/clis/gemini/ask.test.js +2 -2
  121. package/clis/gemini/deep-research-result.js +6 -2
  122. package/clis/gemini/deep-research-result.test.js +15 -14
  123. package/clis/gemini/deep-research.js +8 -4
  124. package/clis/gemini/deep-research.test.js +15 -18
  125. package/clis/gemini/image.js +7 -2
  126. package/clis/gemini/new.js +1 -0
  127. package/clis/gemini/utils.js +0 -4
  128. package/clis/google-scholar/cite.js +0 -1
  129. package/clis/google-scholar/profile.js +0 -1
  130. package/clis/google-scholar/search.js +0 -1
  131. package/clis/goproxy/goproxy.test.js +103 -0
  132. package/clis/goproxy/module.js +47 -0
  133. package/clis/goproxy/utils.js +165 -0
  134. package/clis/goproxy/versions.js +59 -0
  135. package/clis/gov-law/recent.js +0 -1
  136. package/clis/gov-law/search.js +0 -1
  137. package/clis/gov-policy/__fixtures__/recent.html +16 -0
  138. package/clis/gov-policy/__fixtures__/search.html +41 -0
  139. package/clis/gov-policy/gov-policy.test.js +224 -0
  140. package/clis/gov-policy/recent.js +66 -24
  141. package/clis/gov-policy/search.js +65 -23
  142. package/clis/gov-policy/utils.js +54 -0
  143. package/clis/grok/ask.js +49 -265
  144. package/clis/grok/ask.test.js +21 -46
  145. package/clis/grok/detail.js +60 -0
  146. package/clis/grok/history.js +48 -0
  147. package/clis/grok/{image.ts → image.js} +56 -70
  148. package/clis/grok/image.test.ts +20 -0
  149. package/clis/grok/new.js +20 -0
  150. package/clis/grok/read.js +39 -0
  151. package/clis/grok/send.js +50 -0
  152. package/clis/grok/status.js +41 -0
  153. package/clis/grok/utils.js +326 -0
  154. package/clis/grok/utils.test.js +103 -0
  155. package/clis/hf/datasets.js +88 -0
  156. package/clis/hf/hf.test.js +16 -0
  157. package/clis/hf/models.js +91 -0
  158. package/clis/hf/paper.js +79 -0
  159. package/clis/hf/spaces.js +101 -0
  160. package/clis/hf/top.js +1 -0
  161. package/clis/homebrew/cask.js +39 -0
  162. package/clis/homebrew/formula.js +41 -0
  163. package/clis/homebrew/popular.js +54 -0
  164. package/clis/homebrew/utils.js +100 -0
  165. package/clis/hupu/__fixtures__/hot-home.html +64 -0
  166. package/clis/hupu/detail.js +0 -1
  167. package/clis/hupu/hot.js +156 -35
  168. package/clis/hupu/hot.test.js +224 -0
  169. package/clis/hupu/search.js +0 -1
  170. package/clis/instagram/note.js +1 -1
  171. package/clis/instagram/note.test.js +1 -29
  172. package/clis/instagram/post.js +1 -1
  173. package/clis/instagram/post.test.js +1 -1
  174. package/clis/instagram/reel.js +1 -1
  175. package/clis/instagram/story.js +1 -1
  176. package/clis/instagram/story.test.js +1 -34
  177. package/clis/jd/commands.test.js +1 -24
  178. package/clis/lichess/lichess.test.js +85 -0
  179. package/clis/lichess/top.js +46 -0
  180. package/clis/lichess/user.js +91 -0
  181. package/clis/lichess/utils.js +97 -0
  182. package/clis/linkedin/search.js +107 -10
  183. package/clis/linkedin/search.test.js +222 -0
  184. package/clis/linux-do/feed.js +2 -5
  185. package/clis/linux-do/feed.test.js +35 -0
  186. package/clis/lobsters/domain.js +92 -0
  187. package/clis/maven/artifact.js +49 -0
  188. package/clis/maven/search.js +51 -0
  189. package/clis/maven/utils.js +110 -0
  190. package/clis/mdn/search.js +97 -0
  191. package/clis/medium/tag.js +135 -0
  192. package/clis/npm/downloads.js +59 -0
  193. package/clis/npm/package.js +70 -0
  194. package/clis/npm/search.js +49 -0
  195. package/clis/npm/utils.js +76 -0
  196. package/clis/nuget/nuget.test.js +111 -0
  197. package/clis/nuget/package.js +101 -0
  198. package/clis/nuget/search.js +69 -0
  199. package/clis/nuget/utils.js +87 -0
  200. package/clis/nvd/cve.js +121 -0
  201. package/clis/oeis/oeis.test.js +88 -0
  202. package/clis/oeis/search.js +63 -0
  203. package/clis/oeis/sequence.js +71 -0
  204. package/clis/oeis/utils.js +88 -0
  205. package/clis/openalex/search.js +69 -0
  206. package/clis/openalex/utils.js +160 -0
  207. package/clis/openalex/work.js +65 -0
  208. package/clis/openfda/drug-label.js +74 -0
  209. package/clis/openfda/food-recall.js +65 -0
  210. package/clis/openfda/openfda.test.js +114 -0
  211. package/clis/openfda/utils.js +67 -0
  212. package/clis/osv/osv.test.js +97 -0
  213. package/clis/osv/query.js +72 -0
  214. package/clis/osv/utils.js +169 -0
  215. package/clis/osv/vulnerability.js +54 -0
  216. package/clis/packagist/package.js +49 -0
  217. package/clis/packagist/search.js +43 -0
  218. package/clis/packagist/utils.js +113 -0
  219. package/clis/paperreview/feedback.js +1 -1
  220. package/clis/paperreview/review.js +1 -1
  221. package/clis/paperreview/submit.js +1 -1
  222. package/clis/pixiv/download.test.js +1 -1
  223. package/clis/pixiv/illusts.test.js +1 -1
  224. package/clis/pixiv/search.test.js +1 -1
  225. package/clis/pubmed/article.js +50 -0
  226. package/clis/pubmed/author.js +64 -0
  227. package/clis/pubmed/citations.js +36 -0
  228. package/clis/pubmed/pubmed.test.js +276 -0
  229. package/clis/pubmed/related.js +45 -0
  230. package/clis/pubmed/search.js +75 -0
  231. package/clis/pubmed/utils.js +309 -0
  232. package/clis/pypi/downloads.js +66 -0
  233. package/clis/pypi/package.js +79 -0
  234. package/clis/pypi/utils.js +55 -0
  235. package/clis/quark/mv.js +1 -1
  236. package/clis/quark/save.js +1 -1
  237. package/clis/qwen/ask.js +85 -0
  238. package/clis/qwen/detail.js +62 -0
  239. package/clis/qwen/history.js +61 -0
  240. package/clis/qwen/image.js +179 -0
  241. package/clis/qwen/new.js +23 -0
  242. package/clis/qwen/read.js +41 -0
  243. package/clis/qwen/send.js +55 -0
  244. package/clis/qwen/status.js +37 -0
  245. package/clis/qwen/utils.js +409 -0
  246. package/clis/qwen/utils.test.js +45 -0
  247. package/clis/rest-countries/country.js +65 -0
  248. package/clis/rest-countries/region.js +64 -0
  249. package/clis/rest-countries/rest-countries.test.js +83 -0
  250. package/clis/rest-countries/utils.js +126 -0
  251. package/clis/reuters/article-detail.js +53 -0
  252. package/clis/reuters/reuters.test.js +299 -0
  253. package/clis/reuters/search.js +45 -34
  254. package/clis/reuters/utils.js +159 -0
  255. package/clis/rfc/rfc.js +52 -0
  256. package/clis/rfc/rfc.test.js +74 -0
  257. package/clis/rfc/utils.js +72 -0
  258. package/clis/rubygems/gem.js +42 -0
  259. package/clis/rubygems/search.js +47 -0
  260. package/clis/rubygems/utils.js +86 -0
  261. package/clis/stackoverflow/related.js +66 -0
  262. package/clis/stackoverflow/stackoverflow.test.js +58 -0
  263. package/clis/stackoverflow/tag.js +60 -0
  264. package/clis/stackoverflow/user.js +50 -0
  265. package/clis/stackoverflow/utils.js +118 -0
  266. package/clis/steam/app.js +67 -0
  267. package/clis/steam/search.js +58 -0
  268. package/clis/steam/steam.test.js +46 -0
  269. package/clis/steam/utils.js +107 -0
  270. package/clis/taobao/commands.test.js +1 -24
  271. package/clis/test-utils.js +61 -0
  272. package/clis/tieba/hot.js +0 -1
  273. package/clis/tiktok/comment.js +128 -41
  274. package/clis/tiktok/creator-videos.js +270 -0
  275. package/clis/tiktok/creator-videos.test.js +113 -0
  276. package/clis/tiktok/explore.js +137 -29
  277. package/clis/tiktok/follow.js +115 -33
  278. package/clis/tiktok/following.js +157 -36
  279. package/clis/tiktok/friends.js +139 -37
  280. package/clis/tiktok/live.js +137 -41
  281. package/clis/tiktok/notifications.js +141 -38
  282. package/clis/tiktok/refactor.test.js +389 -0
  283. package/clis/tiktok/unfollow.js +124 -38
  284. package/clis/tiktok/user.js +203 -29
  285. package/clis/tiktok/utils.js +505 -0
  286. package/clis/tiktok/write-refactor.test.js +370 -0
  287. package/clis/toutiao/articles.js +36 -62
  288. package/clis/toutiao/hot.js +63 -0
  289. package/clis/toutiao/toutiao.test.js +378 -0
  290. package/clis/toutiao/utils.js +161 -0
  291. package/clis/tvmaze/search.js +61 -0
  292. package/clis/tvmaze/show.js +60 -0
  293. package/clis/tvmaze/tvmaze.test.js +93 -0
  294. package/clis/tvmaze/utils.js +110 -0
  295. package/clis/twitter/accept.js +1 -1
  296. package/clis/twitter/followers.js +134 -69
  297. package/clis/twitter/reply-dm.js +1 -1
  298. package/clis/twitter/reply.test.js +1 -29
  299. package/clis/uisdc/news.js +105 -0
  300. package/clis/uisdc/news.test.js +66 -0
  301. package/clis/wanfang/search.js +0 -1
  302. package/clis/web/read.js +47 -17
  303. package/clis/web/read.test.js +101 -1
  304. package/clis/weixin/create-draft.js +1 -1
  305. package/clis/weixin/drafts.js +1 -1
  306. package/clis/weixin/drafts.test.js +5 -1
  307. package/clis/weixin/search.js +157 -0
  308. package/clis/weixin/search.test.js +227 -0
  309. package/clis/wikidata/entity.js +60 -0
  310. package/clis/wikidata/search.js +50 -0
  311. package/clis/wikidata/utils.js +117 -0
  312. package/clis/wikidata/wikidata.test.js +83 -0
  313. package/clis/wikipedia/page.js +95 -0
  314. package/clis/wttr/current.js +63 -0
  315. package/clis/wttr/forecast.js +71 -0
  316. package/clis/wttr/utils.js +50 -0
  317. package/clis/wttr/wttr.test.js +84 -0
  318. package/clis/xianyu/chat.js +16 -4
  319. package/clis/xianyu/chat.test.js +64 -0
  320. package/clis/xianyu/publish.js +485 -0
  321. package/clis/xianyu/publish.test.js +220 -0
  322. package/clis/xiaoe/catalog.js +105 -40
  323. package/clis/xiaoe/content.js +164 -29
  324. package/clis/xiaoe/courses.js +86 -29
  325. package/clis/xiaoe/xiaoe.test.js +486 -0
  326. package/clis/xiaohongshu/creator-notes-summary.js +1 -1
  327. package/clis/xiaohongshu/publish.js +16 -3
  328. package/clis/xiaohongshu/publish.test.js +46 -1
  329. package/clis/youtube/transcript.js +13 -19
  330. package/clis/youtube/transcript.test.js +17 -0
  331. package/clis/yuanbao/ask.js +17 -66
  332. package/clis/yuanbao/ask.test.js +5 -5
  333. package/clis/yuanbao/detail.js +65 -0
  334. package/clis/yuanbao/history.js +51 -0
  335. package/clis/yuanbao/new.js +1 -0
  336. package/clis/yuanbao/read.js +38 -0
  337. package/clis/yuanbao/send.js +57 -0
  338. package/clis/yuanbao/shared.js +297 -5
  339. package/clis/yuanbao/shared.test.js +80 -0
  340. package/clis/yuanbao/status.js +44 -0
  341. package/clis/zlibrary/commands.test.js +1 -11
  342. package/dist/src/browser/base-page.d.ts +9 -0
  343. package/dist/src/browser/base-page.js +44 -1
  344. package/dist/src/browser/base-page.test.js +66 -0
  345. package/dist/src/browser/cdp.d.ts +1 -0
  346. package/dist/src/browser/cdp.js +51 -9
  347. package/dist/src/browser/daemon-client.d.ts +4 -0
  348. package/dist/src/browser/errors.js +1 -1
  349. package/dist/src/browser/page.d.ts +1 -1
  350. package/dist/src/browser/page.js +3 -1
  351. package/dist/src/browser/page.test.js +29 -0
  352. package/dist/src/browser/target-errors.d.ts +2 -1
  353. package/dist/src/browser/target-errors.js +1 -0
  354. package/dist/src/browser/target-resolver.d.ts +25 -0
  355. package/dist/src/browser/target-resolver.js +43 -0
  356. package/dist/src/build-manifest.js +9 -4
  357. package/dist/src/build-manifest.test.js +2 -8
  358. package/dist/src/capabilityRouting.d.ts +16 -1
  359. package/dist/src/capabilityRouting.js +24 -1
  360. package/dist/src/capabilityRouting.test.js +19 -1
  361. package/dist/src/cli.js +76 -11
  362. package/dist/src/cli.test.js +150 -0
  363. package/dist/src/commanderAdapter.js +0 -5
  364. package/dist/src/commanderAdapter.test.js +0 -1
  365. package/dist/src/discovery.js +2 -5
  366. package/dist/src/errors.js +1 -1
  367. package/dist/src/execution.d.ts +1 -1
  368. package/dist/src/execution.js +111 -27
  369. package/dist/src/execution.test.js +326 -17
  370. package/dist/src/help.d.ts +23 -2
  371. package/dist/src/help.js +41 -19
  372. package/dist/src/help.test.d.ts +1 -0
  373. package/dist/src/help.test.js +54 -0
  374. package/dist/src/main.js +14 -1
  375. package/dist/src/manifest-types.d.ts +5 -3
  376. package/dist/src/pipeline/executor.js +1 -1
  377. package/dist/src/pipeline/executor.test.js +8 -0
  378. package/dist/src/pipeline/registry.d.ts +9 -0
  379. package/dist/src/pipeline/registry.js +13 -1
  380. package/dist/src/pipeline/steps/browser.d.ts +1 -0
  381. package/dist/src/pipeline/steps/browser.js +10 -0
  382. package/dist/src/pipeline/steps/download.test.js +1 -0
  383. package/dist/src/registry-api.d.ts +1 -1
  384. package/dist/src/registry.d.ts +12 -11
  385. package/dist/src/registry.js +16 -6
  386. package/dist/src/registry.test.js +2 -2
  387. package/dist/src/runtime.d.ts +2 -1
  388. package/dist/src/runtime.js +1 -1
  389. package/dist/src/serialization.d.ts +2 -2
  390. package/dist/src/serialization.js +4 -6
  391. package/dist/src/serialization.test.js +17 -0
  392. package/dist/src/types.d.ts +17 -0
  393. package/dist/src/validate.js +15 -11
  394. package/dist/src/validate.test.d.ts +9 -0
  395. package/dist/src/validate.test.js +90 -0
  396. package/package.json +1 -1
  397. package/scripts/fetch-adapters.js +1 -1
  398. package/scripts/typed-error-lint-baseline.json +5 -77
  399. package/clis/ctrip/search.test.js +0 -64
  400. package/clis/gov-policy/commands.test.js +0 -27
  401. package/clis/linux-do/category.js +0 -37
  402. package/clis/linux-do/hot.js +0 -26
  403. package/clis/linux-do/latest.js +0 -19
  404. package/clis/pixiv/test-utils.js +0 -23
  405. package/clis/toutiao/articles.test.js +0 -30
  406. package/dist/src/analysis.d.ts +0 -40
  407. package/dist/src/analysis.js +0 -172
@@ -0,0 +1,140 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import {
4
+ DEEPSEEK_DOMAIN,
5
+ MESSAGE_SELECTOR,
6
+ TEXTAREA_SELECTOR,
7
+ parseDeepSeekConversationId,
8
+ } from './utils.js';
9
+
10
+ export const sendCommand = cli({
11
+ site: 'deepseek',
12
+ name: 'send',
13
+ access: 'write',
14
+ description: 'Send a prompt to a specific DeepSeek conversation by ID, without waiting for a response',
15
+ domain: DEEPSEEK_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ browser: true,
18
+ browserSession: { reuse: 'site' },
19
+ navigateBefore: false,
20
+ args: [
21
+ { name: 'id', required: true, positional: true, help: 'Conversation ID (UUID) or full /a/chat/s/<id> URL' },
22
+ { name: 'prompt', required: true, positional: true, help: 'Prompt to send' },
23
+ { name: 'timeout', type: 'int', required: false, default: 60, help: 'Max seconds for the overall command (default: 60)' },
24
+ ],
25
+ columns: ['Status', 'InjectedText'],
26
+ func: async (page, kwargs) => {
27
+ const id = parseDeepSeekConversationId(kwargs.id);
28
+ const prompt = kwargs.prompt;
29
+
30
+ // Navigate directly to the target conversation. The framework runs
31
+ // each browser command in an ephemeral per-command workspace (tab),
32
+ // so there is no shared "current conversation" between commands; the
33
+ // ID must be explicit. Skipping this navigation lands on the deepseek
34
+ // root, where the click silently no-ops because React has not bound
35
+ // the textarea on a freshly-opened tab.
36
+ await page.goto(`https://chat.deepseek.com/a/chat/s/${id}`);
37
+ await waitForTextareaReady(page);
38
+
39
+ // Focus the textarea via DOM, then drive input through CDP
40
+ // `Input.insertText`. This mirrors the doubao adapter (#1278) and is
41
+ // the only reliable path for the React-controlled DeepSeek composer
42
+ // on a freshly-opened tab: `execCommand('insertText')` plus a
43
+ // synthesised input event leaves the controlled state desynced and
44
+ // the resulting send click silently no-ops on the server side.
45
+ const focusOk = await page.evaluate(`(() => {
46
+ const box = document.querySelector('${TEXTAREA_SELECTOR}');
47
+ if (!box) return false;
48
+ box.focus();
49
+ return document.activeElement === box;
50
+ })()`);
51
+ if (!focusOk) {
52
+ throw new CommandExecutionError('Could not focus DeepSeek textarea before native input');
53
+ }
54
+ if (typeof page.nativeType !== 'function') {
55
+ throw new CommandExecutionError(
56
+ 'Native CDP input is not available on this page object',
57
+ 'deepseek send relies on Input.insertText; ensure the daemon and extension are up to date.',
58
+ );
59
+ }
60
+ await page.nativeType(prompt);
61
+ await page.wait(0.6);
62
+
63
+ // Submit + wait inside the same eval until the user bubble has
64
+ // rendered AND remains stable past a 3s settle window. Returning
65
+ // earlier lets the framework close the per-command workspace
66
+ // mid-flight, aborting the in-flight chat-completion request before
67
+ // the server has acknowledged it; the optimistic bubble then never
68
+ // persists.
69
+ const promptJson = JSON.stringify(prompt);
70
+ const result = await page.evaluate(`(async () => {
71
+ const TEXTAREA = '${TEXTAREA_SELECTOR}';
72
+ const MESSAGE = '${MESSAGE_SELECTOR}';
73
+ const expected = ${promptJson};
74
+ const isUser = (m) => m.className.split(/\\s+/).length > 2;
75
+ const box = document.querySelector(TEXTAREA);
76
+ if (!box) return { ok: false, reason: 'textarea not found' };
77
+ if ((box.value || '').trim() !== expected.trim()) {
78
+ return { ok: false, reason: 'native input did not populate textarea (got ' + JSON.stringify(box.value) + ')' };
79
+ }
80
+ let container = box.parentElement;
81
+ while (container && !container.querySelector('div[role="button"]')) {
82
+ container = container.parentElement;
83
+ }
84
+ const btns = container ? container.querySelectorAll('div[role="button"]:not(.ds-toggle-button)') : [];
85
+ const sendBtn = btns[btns.length - 1];
86
+ if (!sendBtn || sendBtn.querySelectorAll('svg').length === 0) {
87
+ return { ok: false, reason: 'send button not found' };
88
+ }
89
+ if (sendBtn.getAttribute('aria-disabled') !== 'false') {
90
+ return { ok: false, reason: 'send button stayed disabled after native input' };
91
+ }
92
+ sendBtn.click();
93
+ // Verify the prompt rendered as a user-class bubble that includes
94
+ // the expected text. Counting bubbles is unreliable under DeepSeek
95
+ // virtualization (the visible message list is windowed); a text
96
+ // match on any user bubble is the authoritative signal.
97
+ const matchesPrompt = (m) => isUser(m) && (m.innerText || '').trim().includes(expected);
98
+ const findUserBubble = () => Array.from(document.querySelectorAll(MESSAGE)).reverse().find(matchesPrompt);
99
+ let appeared = false;
100
+ for (let i = 0; i < 20 && !appeared; i++) {
101
+ await new Promise(r => setTimeout(r, 500));
102
+ if (findUserBubble()) appeared = true;
103
+ }
104
+ if (!appeared) return { ok: false, reason: 'prompt never rendered as a user bubble within 10s' };
105
+ // Settle: must still be present after 3s; an optimistic render
106
+ // that gets rolled back fails this check.
107
+ for (let i = 0; i < 3; i++) {
108
+ await new Promise(r => setTimeout(r, 1000));
109
+ if (!findUserBubble()) {
110
+ return { ok: false, reason: 'prompt rendered then disappeared during the 3s settle window' };
111
+ }
112
+ }
113
+ return { ok: true };
114
+ })()`).catch((err) => {
115
+ const msg = String(err?.message || err);
116
+ // SPA navigation after click can collapse the eval context; the
117
+ // resulting "Promise was collected" only fires after the bubble
118
+ // settle has already passed, so treat it as a successful submit.
119
+ if (msg.includes('Promise was collected')) return { ok: true };
120
+ throw err;
121
+ });
122
+
123
+ if (!result?.ok) {
124
+ throw new CommandExecutionError(result?.reason || 'Failed to send message');
125
+ }
126
+ return [{ Status: 'Success', InjectedText: prompt }];
127
+ },
128
+ });
129
+
130
+ /** Poll for the deepseek textarea to mount before driving any input. */
131
+ async function waitForTextareaReady(page) {
132
+ const probe = `(() => !!document.querySelector('${TEXTAREA_SELECTOR}'))()`;
133
+ for (let attempt = 0; attempt < 10; attempt++) {
134
+ if (await page.evaluate(probe)) return;
135
+ await page.wait(1);
136
+ }
137
+ throw new CommandExecutionError(
138
+ 'DeepSeek textarea did not mount within 10s; the conversation page may not have loaded',
139
+ );
140
+ }
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+
5
+ import './send.js';
6
+
7
+ describe('deepseek send', () => {
8
+ const command = getRegistry().get('deepseek/send');
9
+ const id = '749e6bbd-6a45-4440-beaa-ae5238bf06d8';
10
+
11
+ function createPage(overrides = {}) {
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn(),
16
+ nativeType: vi.fn().mockResolvedValue(undefined),
17
+ screenshot: vi.fn().mockResolvedValue(''),
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('registers as a cookie-browser write command with id + prompt positional args', () => {
27
+ expect(command).toBeDefined();
28
+ expect(command.browser).toBe(true);
29
+ expect(command.strategy).toBe('cookie');
30
+ expect(command.access).toBe('write');
31
+ expect(command.columns).toEqual(['Status', 'InjectedText']);
32
+ expect(command.args.map((a) => a.name)).toEqual(['id', 'prompt', 'timeout']);
33
+ expect(command.args.find((a) => a.name === 'timeout')).toMatchObject({ type: 'int', default: 60 });
34
+ });
35
+
36
+ it('rejects malformed conversation IDs before any browser navigation', async () => {
37
+ const page = createPage();
38
+ await expect(command.func(page, { id: 'not-a-uuid', prompt: 'hi' })).rejects.toThrow(ArgumentError);
39
+ expect(page.goto).not.toHaveBeenCalled();
40
+ expect(page.nativeType).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('navigates to the conversation, drives input through nativeType, and verifies the bubble', async () => {
44
+ const page = createPage();
45
+ page.evaluate
46
+ .mockResolvedValueOnce(true) // waitForTextareaReady probe
47
+ .mockResolvedValueOnce(true) // focus check
48
+ .mockResolvedValueOnce({ ok: true }); // submit + verify IIFE
49
+
50
+ const rows = await command.func(page, { id, prompt: 'hello' });
51
+
52
+ expect(rows).toEqual([{ Status: 'Success', InjectedText: 'hello' }]);
53
+ expect(page.goto).toHaveBeenCalledWith(`https://chat.deepseek.com/a/chat/s/${id}`);
54
+ expect(page.nativeType).toHaveBeenCalledWith('hello');
55
+ });
56
+
57
+ it('throws when the textarea never mounts within the readiness timeout', async () => {
58
+ const page = createPage();
59
+ page.evaluate.mockResolvedValue(false); // probe always fails
60
+
61
+ await expect(command.func(page, { id, prompt: 'hi' })).rejects.toThrow(CommandExecutionError);
62
+ expect(page.nativeType).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('throws when nativeType is not exposed on the page object', async () => {
66
+ const page = createPage({ nativeType: undefined });
67
+ page.evaluate
68
+ .mockResolvedValueOnce(true) // waitForTextareaReady
69
+ .mockResolvedValueOnce(true); // focus
70
+
71
+ await expect(command.func(page, { id, prompt: 'hi' })).rejects.toThrow(CommandExecutionError);
72
+ });
73
+
74
+ it('throws when the focus probe reports the textarea did not take focus', async () => {
75
+ const page = createPage();
76
+ page.evaluate
77
+ .mockResolvedValueOnce(true) // waitForTextareaReady
78
+ .mockResolvedValueOnce(false); // focus failed
79
+
80
+ await expect(command.func(page, { id, prompt: 'hi' })).rejects.toThrow(CommandExecutionError);
81
+ expect(page.nativeType).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('translates the IIFE failure reason into a CommandExecutionError', async () => {
85
+ const page = createPage();
86
+ page.evaluate
87
+ .mockResolvedValueOnce(true)
88
+ .mockResolvedValueOnce(true)
89
+ .mockResolvedValueOnce({ ok: false, reason: 'send button stayed disabled after native input' });
90
+
91
+ await expect(command.func(page, { id, prompt: 'hi' })).rejects.toThrow(
92
+ new CommandExecutionError('send button stayed disabled after native input'),
93
+ );
94
+ });
95
+
96
+ it('treats "Promise was collected" from the IIFE as a successful submit', async () => {
97
+ const page = createPage();
98
+ page.evaluate
99
+ .mockResolvedValueOnce(true)
100
+ .mockResolvedValueOnce(true)
101
+ .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}'));
102
+
103
+ const rows = await command.func(page, { id, prompt: 'hi' });
104
+
105
+ expect(rows).toEqual([{ Status: 'Success', InjectedText: 'hi' }]);
106
+ });
107
+ });
@@ -9,6 +9,7 @@ export const statusCommand = cli({
9
9
  domain: DEEPSEEK_DOMAIN,
10
10
  strategy: Strategy.COOKIE,
11
11
  browser: true,
12
+ browserSession: { reuse: 'site' },
12
13
  navigateBefore: false,
13
14
  args: [],
14
15
  columns: ['Status', 'Login', 'Url'],
@@ -1,7 +1,33 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+
1
3
  export const DEEPSEEK_DOMAIN = 'chat.deepseek.com';
2
4
  export const DEEPSEEK_URL = 'https://chat.deepseek.com/';
3
5
  export const TEXTAREA_SELECTOR = 'textarea[placeholder*="DeepSeek"]';
4
6
  export const MESSAGE_SELECTOR = '.ds-message';
7
+ const CONVERSATION_ID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
8
+
9
+ /**
10
+ * Normalize a DeepSeek conversation ID. Accepts a bare UUID or any URL that
11
+ * embeds one (`/a/chat/s/<id>` or full chat URL).
12
+ *
13
+ * Throws ArgumentError when the input does not contain a UUID-shaped id, so
14
+ * `detail` fails before any browser navigation happens.
15
+ */
16
+ export function parseDeepSeekConversationId(input) {
17
+ const raw = String(input ?? '').trim();
18
+ if (!raw) {
19
+ throw new ArgumentError('id', 'must be a non-empty conversation ID or URL');
20
+ }
21
+ const urlMatch = raw.match(/\/a\/chat\/s\/([a-f0-9-]+)/i);
22
+ const candidate = urlMatch ? urlMatch[1] : raw;
23
+ if (!CONVERSATION_ID_RE.test(candidate)) {
24
+ throw new ArgumentError(
25
+ 'id',
26
+ `not a valid DeepSeek conversation ID (got "${input}"); expected a UUID like "749e6bbd-6a45-4440-beaa-ae5238bf06d8" or a full /a/chat/s/<id> URL`,
27
+ );
28
+ }
29
+ return candidate.toLowerCase();
30
+ }
5
31
 
6
32
  export async function isOnDeepSeek(page) {
7
33
  const url = await page.evaluate('window.location.href').catch(() => '');
@@ -274,6 +300,46 @@ export async function getConversationList(page) {
274
300
  return [];
275
301
  }
276
302
 
303
+ /**
304
+ * Pick the URL of the most recent non-pinned conversation, or the first overall
305
+ * if every visible conversation is pinned.
306
+ *
307
+ * Used by `ask` when the workspace was recycled and we need to resume an
308
+ * existing thread instead of opening a new chat. Polls the sidebar for up to
309
+ * 10s and returns null if no conversation links surface in time, so callers
310
+ * can fail fast instead of silently navigating to a fresh page.
311
+ *
312
+ * Pinned detection is text-based on the section header ("置顶" / "Pinned"),
313
+ * because DeepSeek's CSS-module class names are randomized per build.
314
+ */
315
+ export async function pickResumeUrl(page) {
316
+ await page.evaluate(`(() => {
317
+ if (document.querySelectorAll('a[href*="/a/chat/s/"]').length === 0) {
318
+ const btn = document.querySelector('div[tabindex="0"][role="button"]');
319
+ if (btn) btn.click();
320
+ }
321
+ })()`);
322
+ for (let attempt = 0; attempt < 5; attempt++) {
323
+ await page.wait(2);
324
+ const url = await page.evaluate(`(() => {
325
+ const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
326
+ if (links.length === 0) return null;
327
+ const PINNED_HEADER = /^\\s*(置\\s*顶|Pinned)\\s*$/i;
328
+ const isPinned = (link) => {
329
+ const section = link.parentElement;
330
+ const header = section && section.firstElementChild;
331
+ if (!header || header === link) return false;
332
+ return PINNED_HEADER.test((header.innerText || header.textContent || '').trim());
333
+ };
334
+ const target = Array.from(links).find((l) => !isPinned(l)) || links[0];
335
+ const href = target.getAttribute('href') || '';
336
+ return href ? 'https://chat.deepseek.com' + href : null;
337
+ })()`);
338
+ if (url) return url;
339
+ }
340
+ return null;
341
+ }
342
+
277
343
  async function waitForFilePreview(page, fileName) {
278
344
  for (let attempt = 0; attempt < 8; attempt++) {
279
345
  await page.wait(2);
@@ -2,7 +2,46 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { selectModel, sendWithFile, parseThinkingResponse } from './utils.js';
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
6
+ import {
7
+ selectModel,
8
+ sendWithFile,
9
+ parseThinkingResponse,
10
+ parseDeepSeekConversationId,
11
+ pickResumeUrl,
12
+ } from './utils.js';
13
+
14
+ describe('deepseek parseDeepSeekConversationId', () => {
15
+ const id = '749e6bbd-6a45-4440-beaa-ae5238bf06d8';
16
+
17
+ it('returns a bare UUID unchanged', () => {
18
+ expect(parseDeepSeekConversationId(id)).toBe(id);
19
+ });
20
+
21
+ it('lowercases an upper-case UUID', () => {
22
+ expect(parseDeepSeekConversationId(id.toUpperCase())).toBe(id);
23
+ });
24
+
25
+ it('extracts the UUID from a full /a/chat/s/<id> URL', () => {
26
+ expect(parseDeepSeekConversationId(`https://chat.deepseek.com/a/chat/s/${id}`)).toBe(id);
27
+ expect(parseDeepSeekConversationId(`https://chat.deepseek.com/a/chat/s/${id}?from=share`)).toBe(id);
28
+ expect(parseDeepSeekConversationId(`/a/chat/s/${id}`)).toBe(id);
29
+ });
30
+
31
+ it('throws ArgumentError on empty input', () => {
32
+ expect(() => parseDeepSeekConversationId('')).toThrow(ArgumentError);
33
+ expect(() => parseDeepSeekConversationId(null)).toThrow(ArgumentError);
34
+ expect(() => parseDeepSeekConversationId(undefined)).toThrow(ArgumentError);
35
+ expect(() => parseDeepSeekConversationId(' ')).toThrow(ArgumentError);
36
+ });
37
+
38
+ it('throws ArgumentError on non-UUID input', () => {
39
+ expect(() => parseDeepSeekConversationId('not-an-id')).toThrow(ArgumentError);
40
+ expect(() => parseDeepSeekConversationId('123')).toThrow(ArgumentError);
41
+ // URL with the wrong path shape must not silently fall through to "use raw input".
42
+ expect(() => parseDeepSeekConversationId('https://chat.deepseek.com/somewhere/else')).toThrow(ArgumentError);
43
+ });
44
+ });
6
45
 
7
46
  describe('deepseek parseThinkingResponse', () => {
8
47
  it('returns plain response when no thinking header is present', () => {
@@ -262,3 +301,70 @@ describe('deepseek sendWithFile Not allowed fallback', () => {
262
301
  expect(page.evaluate.mock.calls[1][0]).not.toContain("aria-disabled') === 'false'");
263
302
  });
264
303
  });
304
+
305
+ describe('deepseek pickResumeUrl', () => {
306
+ function createPage() {
307
+ return {
308
+ wait: vi.fn().mockResolvedValue(undefined),
309
+ evaluate: vi.fn(),
310
+ };
311
+ }
312
+
313
+ it('returns the URL on the first attempt when the sidebar is already populated', async () => {
314
+ const page = createPage();
315
+ // First evaluate is the sidebar-expand no-op; subsequent ones look up the resume URL.
316
+ page.evaluate
317
+ .mockResolvedValueOnce(undefined)
318
+ .mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/recent-id');
319
+
320
+ const url = await pickResumeUrl(page);
321
+
322
+ expect(url).toBe('https://chat.deepseek.com/a/chat/s/recent-id');
323
+ // Sidebar expand + one resume lookup. No retry needed.
324
+ expect(page.evaluate).toHaveBeenCalledTimes(2);
325
+ expect(page.wait).toHaveBeenCalledTimes(1);
326
+ });
327
+
328
+ it('polls until the sidebar finishes loading and returns the late URL', async () => {
329
+ const page = createPage();
330
+ page.evaluate
331
+ .mockResolvedValueOnce(undefined) // sidebar-expand
332
+ .mockResolvedValueOnce(null) // attempt 1: nothing
333
+ .mockResolvedValueOnce(null) // attempt 2: nothing
334
+ .mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/late-id'); // attempt 3: ready
335
+
336
+ const url = await pickResumeUrl(page);
337
+
338
+ expect(url).toBe('https://chat.deepseek.com/a/chat/s/late-id');
339
+ expect(page.evaluate).toHaveBeenCalledTimes(4);
340
+ expect(page.wait).toHaveBeenCalledTimes(3);
341
+ });
342
+
343
+ it('returns null after exhausting all retries instead of falling through silently', async () => {
344
+ const page = createPage();
345
+ // Sidebar expand + 5 polling attempts, each returning null.
346
+ page.evaluate.mockResolvedValue(null);
347
+
348
+ const url = await pickResumeUrl(page);
349
+
350
+ expect(url).toBeNull();
351
+ expect(page.evaluate).toHaveBeenCalledTimes(6);
352
+ expect(page.wait).toHaveBeenCalledTimes(5);
353
+ });
354
+
355
+ it('embeds the pinned-section text matcher inside the resume lookup', async () => {
356
+ const page = createPage();
357
+ page.evaluate.mockResolvedValue(null);
358
+
359
+ await pickResumeUrl(page);
360
+
361
+ const lookupSrc = page.evaluate.mock.calls
362
+ .map(([js]) => js)
363
+ .find((js) => js.includes('firstElementChild'));
364
+ expect(lookupSrc, 'expected a resume-lookup evaluate call').toBeDefined();
365
+ // Pinned detection must be text-based on the section header (CSS-module class names are randomized per build).
366
+ expect(lookupSrc).toContain('置');
367
+ expect(lookupSrc).toContain('Pinned');
368
+ expect(lookupSrc).toContain('firstElementChild');
369
+ });
370
+ });
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './protocols.js';
5
+ import './protocol.js';
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllGlobals();
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe('defillama protocols adapter', () => {
13
+ const cmd = getRegistry().get('defillama/protocols');
14
+
15
+ it('rejects bad limit before fetching', async () => {
16
+ const fetchMock = vi.fn();
17
+ vi.stubGlobal('fetch', fetchMock);
18
+
19
+ await expect(cmd.func({ limit: 0 })).rejects.toThrow(ArgumentError);
20
+ await expect(cmd.func({ limit: 1000 })).rejects.toThrow(ArgumentError);
21
+ expect(fetchMock).not.toHaveBeenCalled();
22
+ });
23
+
24
+ it('maps HTTP 429 to CommandExecutionError', async () => {
25
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
26
+ await expect(cmd.func({ limit: 5 })).rejects.toThrow(CommandExecutionError);
27
+ });
28
+
29
+ it('throws EmptyResultError when API returns no protocols', async () => {
30
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })));
31
+ await expect(cmd.func({ limit: 5 })).rejects.toThrow(EmptyResultError);
32
+ });
33
+
34
+ it('returns rows whose slug round-trips into defillama protocol <slug>', async () => {
35
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([
36
+ { name: 'Aave V3', slug: 'aave-v3', tvl: 1000, mcap: 500, category: 'Lending', chains: ['Ethereum', 'Polygon'], change_1d: 1, change_7d: 2, listedAt: 1668170565 },
37
+ { name: 'Lido', slug: 'lido', tvl: 800, mcap: 200, category: 'Liquid Staking', chains: ['Ethereum'], change_1d: 0.5, change_7d: 1, listedAt: 1640000000 },
38
+ ]), { status: 200 })));
39
+
40
+ const rows = await cmd.func({ limit: 10 });
41
+ expect(rows).toHaveLength(2);
42
+ expect(rows[0]).toMatchObject({
43
+ rank: 1, slug: 'aave-v3', name: 'Aave V3', category: 'Lending', tvl: 1000,
44
+ chains: 'Ethereum, Polygon', url: 'https://defillama.com/protocol/aave-v3',
45
+ });
46
+ // slug is the round-trip key into defillama protocol
47
+ expect(rows[0].slug).toMatch(/^[a-z0-9][a-z0-9._-]*$/);
48
+ });
49
+ });
50
+
51
+ describe('defillama protocol adapter', () => {
52
+ const cmd = getRegistry().get('defillama/protocol');
53
+
54
+ it('rejects empty / malformed slug before fetching', async () => {
55
+ const fetchMock = vi.fn();
56
+ vi.stubGlobal('fetch', fetchMock);
57
+
58
+ await expect(cmd.func({ slug: '' })).rejects.toThrow(ArgumentError);
59
+ await expect(cmd.func({ slug: 'BAD SLUG' })).rejects.toThrow(ArgumentError);
60
+ expect(fetchMock).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('maps HTTP 400 "Protocol not found" to EmptyResultError', async () => {
64
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Protocol not found', { status: 400 })));
65
+ await expect(cmd.func({ slug: 'no-such-thing' })).rejects.toThrow(EmptyResultError);
66
+ });
67
+
68
+ it('parent protocols aggregate chains from children in /protocols', async () => {
69
+ const detail = {
70
+ id: 'parent#aave',
71
+ name: 'Aave',
72
+ isParentProtocol: true,
73
+ chains: [],
74
+ tvl: [{ date: 1700000000, totalLiquidityUSD: 12345 }],
75
+ mcap: 1e9,
76
+ twitter: 'aave',
77
+ github: ['aave'],
78
+ description: 'lending',
79
+ url: 'https://aave.com',
80
+ };
81
+ const list = [
82
+ { name: 'Aave V3', slug: 'aave-v3', parentProtocol: 'parent#aave', chains: ['Ethereum', 'Polygon'], category: 'Lending' },
83
+ { name: 'Aave V2', slug: 'aave-v2', parentProtocol: 'parent#aave', chains: ['Ethereum'], category: 'Lending' },
84
+ { name: 'Other', slug: 'other', chains: ['Solana'] },
85
+ ];
86
+ const fetchMock = vi.fn()
87
+ .mockImplementationOnce(() => Promise.resolve(new Response(JSON.stringify(detail), { status: 200 })))
88
+ .mockImplementationOnce(() => Promise.resolve(new Response(JSON.stringify(list), { status: 200 })));
89
+ vi.stubGlobal('fetch', fetchMock);
90
+
91
+ const rows = await cmd.func({ slug: 'aave' });
92
+ expect(rows).toHaveLength(1);
93
+ expect(rows[0]).toMatchObject({
94
+ slug: 'aave', name: 'Aave', isParent: true, tvl: 12345, tvlAt: '2023-11-14',
95
+ });
96
+ // chains should include Ethereum + Polygon (children) but not Solana (unrelated)
97
+ expect(rows[0].chains.split(', ').sort()).toEqual(['Ethereum', 'Polygon']);
98
+ });
99
+ });
@@ -0,0 +1,84 @@
1
+ // defillama protocol — single DeFi protocol details by slug.
2
+ //
3
+ // Hits `https://api.llama.fi/protocol/<slug>`. The endpoint returns a rich
4
+ // object that includes a `tvl` time-series array; we project the latest entry
5
+ // as the current TVL plus identifying metadata (category from /protocols since
6
+ // the per-protocol endpoint omits it).
7
+ import { cli, Strategy } from '@jackwener/opencli/registry';
8
+ import { EmptyResultError } from '@jackwener/opencli/errors';
9
+ import { LLAMA_BASE, llamaFetch, requireSlug, unixToDate } from './utils.js';
10
+
11
+ cli({
12
+ site: 'defillama',
13
+ name: 'protocol',
14
+ access: 'read',
15
+ description: 'Single DefiLlama protocol details (current TVL, mcap, chains, twitter, github, description)',
16
+ domain: 'defillama.com',
17
+ strategy: Strategy.PUBLIC,
18
+ browser: false,
19
+ args: [
20
+ { name: 'slug', positional: true, type: 'string', required: true, help: 'DefiLlama protocol slug (e.g. "aave", "lido")' },
21
+ ],
22
+ columns: [
23
+ 'slug', 'name', 'category', 'isParent', 'tvl', 'tvlAt', 'mcap',
24
+ 'chains', 'twitter', 'github', 'audits', 'listedAt',
25
+ 'description', 'website', 'url',
26
+ ],
27
+ func: async (args) => {
28
+ const slug = requireSlug(args.slug, 'slug');
29
+ // Per-protocol detail endpoint
30
+ const detail = await llamaFetch(`${LLAMA_BASE}/protocol/${encodeURIComponent(slug)}`, `defillama protocol ${slug}`);
31
+ if (!detail || !detail.name) {
32
+ throw new EmptyResultError('defillama protocol', `DefiLlama returned no metadata for "${slug}".`);
33
+ }
34
+ // Latest TVL from the time-series array (per-protocol endpoint omits a scalar tvl).
35
+ const tvlSeries = Array.isArray(detail.tvl) ? detail.tvl : [];
36
+ const lastPoint = tvlSeries.length ? tvlSeries[tvlSeries.length - 1] : null;
37
+ const tvl = lastPoint && Number.isFinite(Number(lastPoint.totalLiquidityUSD))
38
+ ? Number(lastPoint.totalLiquidityUSD)
39
+ : null;
40
+ const tvlAt = lastPoint ? unixToDate(lastPoint.date) : null;
41
+ // /protocols carries category + chains. Parent protocols (isParentProtocol=true)
42
+ // do NOT appear in /protocols themselves — only their children do — so we union
43
+ // child chains and leave category empty for parents.
44
+ const isParent = detail.isParentProtocol === true;
45
+ let category = '';
46
+ const chainsSet = new Set(Array.isArray(detail.chains) ? detail.chains : []);
47
+ const list = await llamaFetch(`${LLAMA_BASE}/protocols`, `defillama protocol ${slug} list lookup`);
48
+ if (Array.isArray(list)) {
49
+ if (!isParent) {
50
+ const match = list.find((p) => p && p.slug === slug);
51
+ if (match && typeof match.category === 'string') category = match.category;
52
+ if (match && Array.isArray(match.chains)) {
53
+ for (const c of match.chains) chainsSet.add(c);
54
+ }
55
+ }
56
+ else {
57
+ // Aggregate chains across child protocols of this parent.
58
+ for (const p of list) {
59
+ if (p && p.parentProtocol === detail.id && Array.isArray(p.chains)) {
60
+ for (const c of p.chains) chainsSet.add(c);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ const githubArr = Array.isArray(detail.github) ? detail.github : [];
66
+ return [{
67
+ slug,
68
+ name: String(detail.name).trim(),
69
+ category,
70
+ isParent,
71
+ tvl,
72
+ tvlAt,
73
+ mcap: detail.mcap == null ? null : Number(detail.mcap),
74
+ chains: [...chainsSet].join(', '),
75
+ twitter: String(detail.twitter ?? '').trim(),
76
+ github: githubArr.join(', '),
77
+ audits: String(detail.audits ?? '').trim(),
78
+ listedAt: unixToDate(detail.listedAt),
79
+ description: String(detail.description ?? '').trim(),
80
+ website: String(detail.url ?? '').trim(),
81
+ url: `https://defillama.com/protocol/${slug}`,
82
+ }];
83
+ },
84
+ });