@jackwener/opencli 1.4.1 → 1.5.1

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 (369) hide show
  1. package/.github/workflows/build-extension.yml +2 -6
  2. package/.github/workflows/ci.yml +21 -1
  3. package/README.md +35 -6
  4. package/README.zh-CN.md +12 -5
  5. package/SKILL.md +2 -0
  6. package/dist/browser/cdp.d.ts +2 -1
  7. package/dist/browser/cdp.js +5 -0
  8. package/dist/browser/discover.d.ts +4 -1
  9. package/dist/browser/discover.js +6 -2
  10. package/dist/browser/errors.d.ts +2 -2
  11. package/dist/browser/errors.js +4 -12
  12. package/dist/browser/mcp.d.ts +2 -1
  13. package/dist/browser/page.d.ts +3 -0
  14. package/dist/browser/page.js +24 -1
  15. package/dist/build-manifest.d.ts +2 -0
  16. package/dist/build-manifest.js +39 -14
  17. package/dist/build-manifest.test.js +21 -0
  18. package/dist/capabilityRouting.d.ts +2 -0
  19. package/dist/capabilityRouting.js +2 -1
  20. package/dist/cli-manifest.json +1567 -108
  21. package/dist/cli.js +68 -6
  22. package/dist/clis/36kr/article.d.ts +1 -0
  23. package/dist/clis/36kr/article.js +62 -0
  24. package/dist/clis/36kr/hot.d.ts +3 -0
  25. package/dist/clis/36kr/hot.js +80 -0
  26. package/dist/clis/36kr/hot.test.d.ts +1 -0
  27. package/dist/clis/36kr/hot.test.js +15 -0
  28. package/dist/clis/36kr/news.d.ts +1 -0
  29. package/dist/clis/36kr/news.js +51 -0
  30. package/dist/clis/36kr/news.test.d.ts +1 -0
  31. package/dist/clis/36kr/news.test.js +85 -0
  32. package/dist/clis/36kr/search.d.ts +1 -0
  33. package/dist/clis/36kr/search.js +72 -0
  34. package/dist/clis/bilibili/comments.d.ts +5 -0
  35. package/dist/clis/bilibili/comments.js +40 -0
  36. package/dist/clis/bilibili/comments.test.d.ts +1 -0
  37. package/dist/clis/bilibili/comments.test.js +82 -0
  38. package/dist/clis/bluesky/feeds.yaml +29 -0
  39. package/dist/clis/bluesky/followers.yaml +33 -0
  40. package/dist/clis/bluesky/following.yaml +33 -0
  41. package/dist/clis/bluesky/profile.yaml +27 -0
  42. package/dist/clis/bluesky/search.yaml +34 -0
  43. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  44. package/dist/clis/bluesky/thread.yaml +32 -0
  45. package/dist/clis/bluesky/trending.yaml +27 -0
  46. package/dist/clis/bluesky/user.yaml +34 -0
  47. package/dist/clis/chatgpt/ask.js +29 -14
  48. package/dist/clis/chatgpt/ax.d.ts +6 -0
  49. package/dist/clis/chatgpt/ax.js +172 -1
  50. package/dist/clis/chatgpt/model.d.ts +1 -0
  51. package/dist/clis/chatgpt/model.js +24 -0
  52. package/dist/clis/chatgpt/send.js +12 -3
  53. package/dist/clis/douban/download.d.ts +1 -0
  54. package/dist/clis/douban/download.js +67 -0
  55. package/dist/clis/douban/download.test.d.ts +1 -0
  56. package/dist/clis/douban/download.test.js +170 -0
  57. package/dist/clis/douban/photos.d.ts +1 -0
  58. package/dist/clis/douban/photos.js +34 -0
  59. package/dist/clis/douban/utils.d.ts +25 -0
  60. package/dist/clis/douban/utils.js +190 -1
  61. package/dist/clis/douban/utils.test.d.ts +1 -0
  62. package/dist/clis/douban/utils.test.js +64 -0
  63. package/dist/clis/imdb/person.d.ts +1 -0
  64. package/dist/clis/imdb/person.js +203 -0
  65. package/dist/clis/imdb/reviews.d.ts +1 -0
  66. package/dist/clis/imdb/reviews.js +88 -0
  67. package/dist/clis/imdb/search.d.ts +1 -0
  68. package/dist/clis/imdb/search.js +161 -0
  69. package/dist/clis/imdb/title.d.ts +1 -0
  70. package/dist/clis/imdb/title.js +93 -0
  71. package/dist/clis/imdb/top.d.ts +1 -0
  72. package/dist/clis/imdb/top.js +53 -0
  73. package/dist/clis/imdb/trending.d.ts +1 -0
  74. package/dist/clis/imdb/trending.js +52 -0
  75. package/dist/clis/imdb/utils.d.ts +46 -0
  76. package/dist/clis/imdb/utils.js +285 -0
  77. package/dist/clis/imdb/utils.test.d.ts +1 -0
  78. package/dist/clis/imdb/utils.test.js +88 -0
  79. package/dist/clis/jd/item.d.ts +4 -0
  80. package/dist/clis/jd/item.js +16 -15
  81. package/dist/clis/jd/item.test.js +16 -1
  82. package/dist/clis/linux-do/categories.yaml +38 -9
  83. package/dist/clis/linux-do/category.d.ts +1 -0
  84. package/dist/clis/linux-do/category.js +36 -0
  85. package/dist/clis/linux-do/feed.d.ts +45 -0
  86. package/dist/clis/linux-do/feed.js +397 -0
  87. package/dist/clis/linux-do/feed.test.d.ts +1 -0
  88. package/dist/clis/linux-do/feed.test.js +118 -0
  89. package/dist/clis/linux-do/hot.d.ts +1 -0
  90. package/dist/clis/linux-do/hot.js +25 -0
  91. package/dist/clis/linux-do/latest.d.ts +1 -0
  92. package/dist/clis/linux-do/latest.js +18 -0
  93. package/dist/clis/linux-do/tags.yaml +41 -0
  94. package/dist/clis/linux-do/topic.yaml +41 -3
  95. package/dist/clis/linux-do/user-posts.yaml +67 -0
  96. package/dist/clis/linux-do/user-topics.yaml +54 -0
  97. package/dist/clis/paperreview/commands.test.d.ts +3 -0
  98. package/dist/clis/paperreview/commands.test.js +243 -0
  99. package/dist/clis/paperreview/feedback.d.ts +1 -0
  100. package/dist/clis/paperreview/feedback.js +52 -0
  101. package/dist/clis/paperreview/review.d.ts +1 -0
  102. package/dist/clis/paperreview/review.js +37 -0
  103. package/dist/clis/paperreview/submit.d.ts +1 -0
  104. package/dist/clis/paperreview/submit.js +85 -0
  105. package/dist/clis/paperreview/utils.d.ts +46 -0
  106. package/dist/clis/paperreview/utils.js +197 -0
  107. package/dist/clis/paperreview/utils.test.d.ts +1 -0
  108. package/dist/clis/paperreview/utils.test.js +49 -0
  109. package/dist/clis/producthunt/browse.d.ts +1 -0
  110. package/dist/clis/producthunt/browse.js +99 -0
  111. package/dist/clis/producthunt/hot.d.ts +1 -0
  112. package/dist/clis/producthunt/hot.js +110 -0
  113. package/dist/clis/producthunt/posts.d.ts +1 -0
  114. package/dist/clis/producthunt/posts.js +28 -0
  115. package/dist/clis/producthunt/today.d.ts +1 -0
  116. package/dist/clis/producthunt/today.js +35 -0
  117. package/dist/clis/producthunt/utils.d.ts +29 -0
  118. package/dist/clis/producthunt/utils.js +99 -0
  119. package/dist/clis/producthunt/utils.test.d.ts +1 -0
  120. package/dist/clis/producthunt/utils.test.js +64 -0
  121. package/dist/clis/twitter/article.js +4 -28
  122. package/dist/clis/twitter/likes.d.ts +24 -0
  123. package/dist/clis/twitter/likes.js +217 -0
  124. package/dist/clis/twitter/likes.test.d.ts +1 -0
  125. package/dist/clis/twitter/likes.test.js +85 -0
  126. package/dist/clis/twitter/profile.js +4 -28
  127. package/dist/clis/twitter/search.js +2 -1
  128. package/dist/clis/twitter/search.test.js +2 -0
  129. package/dist/clis/twitter/shared.d.ts +6 -0
  130. package/dist/clis/twitter/shared.js +35 -0
  131. package/dist/clis/twitter/timeline.js +2 -13
  132. package/dist/clis/twitter/trending.js +29 -61
  133. package/dist/clis/v2ex/hot.yaml +17 -3
  134. package/dist/clis/weixin/download.d.ts +17 -0
  135. package/dist/clis/weixin/download.js +88 -20
  136. package/dist/clis/weread/book.js +2 -2
  137. package/dist/clis/weread/commands.test.d.ts +3 -0
  138. package/dist/clis/weread/commands.test.js +43 -0
  139. package/dist/clis/weread/highlights.js +2 -2
  140. package/dist/clis/weread/notebooks.js +2 -2
  141. package/dist/clis/weread/notes.js +3 -3
  142. package/dist/clis/weread/shelf.js +2 -2
  143. package/dist/clis/weread/utils.d.ts +4 -4
  144. package/dist/clis/weread/utils.js +32 -14
  145. package/dist/clis/weread/utils.test.js +1 -28
  146. package/dist/clis/xiaohongshu/comments.d.ts +5 -0
  147. package/dist/clis/xiaohongshu/comments.js +74 -0
  148. package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
  149. package/dist/clis/xiaohongshu/comments.test.js +79 -0
  150. package/dist/clis/xiaohongshu/publish.js +179 -47
  151. package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
  152. package/dist/clis/xiaohongshu/publish.test.js +131 -0
  153. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  154. package/dist/clis/xiaohongshu/search.js +20 -1
  155. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  156. package/dist/clis/xiaohongshu/search.test.js +32 -1
  157. package/dist/commanderAdapter.d.ts +1 -0
  158. package/dist/commanderAdapter.js +176 -29
  159. package/dist/commanderAdapter.test.d.ts +1 -0
  160. package/dist/commanderAdapter.test.js +62 -0
  161. package/dist/daemon.js +17 -1
  162. package/dist/discovery.js +48 -42
  163. package/dist/doctor.d.ts +2 -2
  164. package/dist/doctor.js +11 -4
  165. package/dist/download/index.js +63 -51
  166. package/dist/download/index.test.js +17 -4
  167. package/dist/engine.test.js +42 -0
  168. package/dist/errors.d.ts +4 -2
  169. package/dist/errors.js +17 -34
  170. package/dist/execution.d.ts +1 -3
  171. package/dist/execution.js +66 -8
  172. package/dist/execution.test.d.ts +1 -0
  173. package/dist/execution.test.js +40 -0
  174. package/dist/external.js +6 -1
  175. package/dist/hooks.js +2 -0
  176. package/dist/main.js +6 -0
  177. package/dist/output.js +5 -1
  178. package/dist/pipeline/executor.js +3 -4
  179. package/dist/plugin-manifest.d.ts +70 -0
  180. package/dist/plugin-manifest.js +160 -0
  181. package/dist/plugin-manifest.test.d.ts +4 -0
  182. package/dist/plugin-manifest.test.js +179 -0
  183. package/dist/plugin-scaffold.d.ts +28 -0
  184. package/dist/plugin-scaffold.js +142 -0
  185. package/dist/plugin-scaffold.test.d.ts +4 -0
  186. package/dist/plugin-scaffold.test.js +83 -0
  187. package/dist/plugin.d.ts +82 -11
  188. package/dist/plugin.js +870 -84
  189. package/dist/plugin.test.js +1032 -17
  190. package/dist/registry.d.ts +4 -0
  191. package/dist/registry.js +2 -0
  192. package/dist/runtime-detect.d.ts +21 -0
  193. package/dist/runtime-detect.js +32 -0
  194. package/dist/runtime-detect.test.d.ts +1 -0
  195. package/dist/runtime-detect.test.js +27 -0
  196. package/dist/runtime.d.ts +1 -0
  197. package/dist/runtime.js +2 -2
  198. package/dist/serialization.d.ts +2 -0
  199. package/dist/serialization.js +6 -0
  200. package/dist/types.d.ts +3 -0
  201. package/dist/update-check.d.ts +22 -0
  202. package/dist/update-check.js +112 -0
  203. package/dist/weixin-download.test.d.ts +1 -0
  204. package/dist/weixin-download.test.js +30 -0
  205. package/dist/weread-private-api-regression.test.d.ts +1 -0
  206. package/dist/weread-private-api-regression.test.js +122 -0
  207. package/dist/yaml-schema.d.ts +3 -0
  208. package/dist/yaml-schema.js +18 -1
  209. package/docs/.vitepress/config.mts +4 -0
  210. package/docs/adapters/browser/36kr.md +47 -0
  211. package/docs/adapters/browser/bluesky.md +53 -0
  212. package/docs/adapters/browser/douban.md +14 -0
  213. package/docs/adapters/browser/imdb.md +47 -0
  214. package/docs/adapters/browser/jd.md +2 -2
  215. package/docs/adapters/browser/linux-do.md +181 -20
  216. package/docs/adapters/browser/paperreview.md +43 -0
  217. package/docs/adapters/browser/producthunt.md +49 -0
  218. package/docs/adapters/desktop/chatgpt.md +5 -0
  219. package/docs/adapters/index.md +6 -2
  220. package/docs/advanced/download.md +4 -0
  221. package/docs/advanced/rate-limiter-plugin.md +99 -0
  222. package/docs/guide/electron-app-cli.md +200 -0
  223. package/docs/guide/getting-started.md +1 -0
  224. package/docs/guide/plugins.md +97 -0
  225. package/docs/zh/guide/electron-app-cli.md +188 -0
  226. package/docs/zh/guide/getting-started.md +1 -0
  227. package/docs/zh/guide/plugins.md +65 -0
  228. package/extension/package.json +1 -0
  229. package/extension/scripts/package-release.mjs +179 -0
  230. package/extension/src/background.ts +2 -0
  231. package/package.json +4 -1
  232. package/scripts/postinstall.js +10 -0
  233. package/src/browser/cdp.ts +8 -1
  234. package/src/browser/discover.ts +8 -3
  235. package/src/browser/errors.ts +13 -14
  236. package/src/browser/mcp.ts +2 -1
  237. package/src/browser/page.ts +24 -1
  238. package/src/build-manifest.test.ts +23 -0
  239. package/src/build-manifest.ts +40 -15
  240. package/src/capabilityRouting.ts +2 -1
  241. package/src/cli.ts +69 -6
  242. package/src/clis/36kr/article.ts +69 -0
  243. package/src/clis/36kr/hot.test.ts +19 -0
  244. package/src/clis/36kr/hot.ts +100 -0
  245. package/src/clis/36kr/news.test.ts +90 -0
  246. package/src/clis/36kr/news.ts +54 -0
  247. package/src/clis/36kr/search.ts +78 -0
  248. package/src/clis/bilibili/comments.test.ts +102 -0
  249. package/src/clis/bilibili/comments.ts +44 -0
  250. package/src/clis/bluesky/feeds.yaml +29 -0
  251. package/src/clis/bluesky/followers.yaml +33 -0
  252. package/src/clis/bluesky/following.yaml +33 -0
  253. package/src/clis/bluesky/profile.yaml +27 -0
  254. package/src/clis/bluesky/search.yaml +34 -0
  255. package/src/clis/bluesky/starter-packs.yaml +34 -0
  256. package/src/clis/bluesky/thread.yaml +32 -0
  257. package/src/clis/bluesky/trending.yaml +27 -0
  258. package/src/clis/bluesky/user.yaml +34 -0
  259. package/src/clis/chatgpt/ask.ts +28 -14
  260. package/src/clis/chatgpt/ax.ts +180 -1
  261. package/src/clis/chatgpt/model.ts +27 -0
  262. package/src/clis/chatgpt/send.ts +16 -6
  263. package/src/clis/douban/download.test.ts +196 -0
  264. package/src/clis/douban/download.ts +78 -0
  265. package/src/clis/douban/photos.ts +36 -0
  266. package/src/clis/douban/utils.test.ts +97 -0
  267. package/src/clis/douban/utils.ts +232 -1
  268. package/src/clis/imdb/person.ts +232 -0
  269. package/src/clis/imdb/reviews.ts +111 -0
  270. package/src/clis/imdb/search.ts +179 -0
  271. package/src/clis/imdb/title.ts +121 -0
  272. package/src/clis/imdb/top.ts +67 -0
  273. package/src/clis/imdb/trending.ts +66 -0
  274. package/src/clis/imdb/utils.test.ts +117 -0
  275. package/src/clis/imdb/utils.ts +305 -0
  276. package/src/clis/jd/item.test.ts +18 -1
  277. package/src/clis/jd/item.ts +18 -15
  278. package/src/clis/linux-do/categories.yaml +38 -9
  279. package/src/clis/linux-do/category.ts +37 -0
  280. package/src/clis/linux-do/feed.test.ts +132 -0
  281. package/src/clis/linux-do/feed.ts +501 -0
  282. package/src/clis/linux-do/hot.ts +26 -0
  283. package/src/clis/linux-do/latest.ts +19 -0
  284. package/src/clis/linux-do/tags.yaml +41 -0
  285. package/src/clis/linux-do/topic.yaml +41 -3
  286. package/src/clis/linux-do/user-posts.yaml +67 -0
  287. package/src/clis/linux-do/user-topics.yaml +54 -0
  288. package/src/clis/paperreview/commands.test.ts +283 -0
  289. package/src/clis/paperreview/feedback.ts +64 -0
  290. package/src/clis/paperreview/review.ts +47 -0
  291. package/src/clis/paperreview/submit.ts +119 -0
  292. package/src/clis/paperreview/utils.test.ts +68 -0
  293. package/src/clis/paperreview/utils.ts +276 -0
  294. package/src/clis/producthunt/browse.ts +109 -0
  295. package/src/clis/producthunt/hot.ts +127 -0
  296. package/src/clis/producthunt/posts.ts +29 -0
  297. package/src/clis/producthunt/today.ts +37 -0
  298. package/src/clis/producthunt/utils.test.ts +72 -0
  299. package/src/clis/producthunt/utils.ts +122 -0
  300. package/src/clis/twitter/article.ts +5 -28
  301. package/src/clis/twitter/likes.test.ts +91 -0
  302. package/src/clis/twitter/likes.ts +256 -0
  303. package/src/clis/twitter/profile.ts +5 -28
  304. package/src/clis/twitter/search.test.ts +2 -0
  305. package/src/clis/twitter/search.ts +3 -1
  306. package/src/clis/twitter/shared.ts +45 -0
  307. package/src/clis/twitter/timeline.ts +2 -13
  308. package/src/clis/twitter/trending.ts +29 -77
  309. package/src/clis/v2ex/hot.yaml +17 -3
  310. package/src/clis/weixin/download.ts +114 -20
  311. package/src/clis/weread/book.ts +2 -2
  312. package/src/clis/weread/commands.test.ts +57 -0
  313. package/src/clis/weread/highlights.ts +2 -2
  314. package/src/clis/weread/notebooks.ts +2 -2
  315. package/src/clis/weread/notes.ts +3 -3
  316. package/src/clis/weread/shelf.ts +2 -2
  317. package/src/clis/weread/utils.test.ts +1 -32
  318. package/src/clis/weread/utils.ts +41 -16
  319. package/src/clis/xiaohongshu/comments.test.ts +96 -0
  320. package/src/clis/xiaohongshu/comments.ts +81 -0
  321. package/src/clis/xiaohongshu/publish.test.ts +151 -0
  322. package/src/clis/xiaohongshu/publish.ts +206 -54
  323. package/src/clis/xiaohongshu/search.test.ts +39 -1
  324. package/src/clis/xiaohongshu/search.ts +19 -1
  325. package/src/commanderAdapter.test.ts +78 -0
  326. package/src/commanderAdapter.ts +188 -24
  327. package/src/daemon.ts +19 -1
  328. package/src/discovery.ts +49 -48
  329. package/src/doctor.ts +15 -5
  330. package/src/download/index.test.ts +14 -4
  331. package/src/download/index.ts +67 -55
  332. package/src/engine.test.ts +38 -0
  333. package/src/errors.ts +26 -63
  334. package/src/execution.test.ts +47 -0
  335. package/src/execution.ts +67 -9
  336. package/src/external.ts +6 -1
  337. package/src/hooks.ts +1 -0
  338. package/src/main.ts +7 -0
  339. package/src/output.ts +3 -1
  340. package/src/pipeline/executor.ts +4 -6
  341. package/src/plugin-manifest.test.ts +223 -0
  342. package/src/plugin-manifest.ts +206 -0
  343. package/src/plugin-scaffold.test.ts +98 -0
  344. package/src/plugin-scaffold.ts +170 -0
  345. package/src/plugin.test.ts +1104 -17
  346. package/src/plugin.ts +1101 -86
  347. package/src/registry.ts +6 -1
  348. package/src/runtime-detect.test.ts +30 -0
  349. package/src/runtime-detect.ts +36 -0
  350. package/src/runtime.ts +3 -3
  351. package/src/serialization.ts +4 -0
  352. package/src/types.ts +3 -0
  353. package/src/update-check.ts +114 -0
  354. package/src/weixin-download.test.ts +64 -0
  355. package/src/weread-private-api-regression.test.ts +150 -0
  356. package/src/yaml-schema.ts +20 -0
  357. package/tests/e2e/browser-auth.test.ts +13 -9
  358. package/tests/e2e/browser-public-extended.test.ts +1 -1
  359. package/tests/e2e/browser-public.test.ts +62 -4
  360. package/tests/e2e/helpers.ts +2 -1
  361. package/tests/e2e/public-commands.test.ts +37 -3
  362. package/tests/smoke/api-health.test.ts +1 -1
  363. package/vitest.config.ts +10 -0
  364. package/dist/clis/linux-do/category.yaml +0 -51
  365. package/dist/clis/linux-do/hot.yaml +0 -50
  366. package/dist/clis/linux-do/latest.yaml +0 -40
  367. package/src/clis/linux-do/category.yaml +0 -51
  368. package/src/clis/linux-do/hot.yaml +0 -50
  369. package/src/clis/linux-do/latest.yaml +0 -40
@@ -7,6 +7,8 @@ import * as path from 'node:path';
7
7
  import * as https from 'node:https';
8
8
  import * as http from 'node:http';
9
9
  import * as os from 'node:os';
10
+ import { Transform } from 'node:stream';
11
+ import { pipeline } from 'node:stream/promises';
10
12
  import { URL } from 'node:url';
11
13
  import { isBinaryInstalled } from '../external.js';
12
14
  import { getErrorMessage } from '../errors.js';
@@ -68,65 +70,75 @@ export async function httpDownload(url, destPath, options = {}, redirectCount =
68
70
  if (cookies) {
69
71
  requestHeaders['Cookie'] = cookies;
70
72
  }
71
- // Ensure directory exists
72
- const dir = path.dirname(destPath);
73
- fs.mkdirSync(dir, { recursive: true });
74
73
  const tempPath = `${destPath}.tmp`;
75
- const file = fs.createWriteStream(tempPath);
76
- const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
77
- // Handle redirects
78
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
79
- file.close();
80
- if (fs.existsSync(tempPath))
81
- fs.unlinkSync(tempPath);
82
- if (redirectCount >= maxRedirects) {
83
- resolve({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
84
- return;
85
- }
86
- const redirectUrl = resolveRedirectUrl(url, response.headers.location);
87
- const originalHost = new URL(url).hostname;
88
- const redirectHost = new URL(redirectUrl).hostname;
89
- // Do not forward cookies when a redirect crosses host boundaries.
90
- const redirectOptions = originalHost === redirectHost
91
- ? options
92
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
93
- httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1).then(resolve);
74
+ let settled = false;
75
+ const finish = (result) => {
76
+ if (settled)
94
77
  return;
78
+ settled = true;
79
+ resolve(result);
80
+ };
81
+ const cleanupTempFile = async () => {
82
+ try {
83
+ await fs.promises.rm(tempPath, { force: true });
95
84
  }
96
- if (response.statusCode !== 200) {
97
- file.close();
98
- if (fs.existsSync(tempPath))
99
- fs.unlinkSync(tempPath);
100
- resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
101
- return;
85
+ catch {
86
+ // Ignore cleanup errors so the original failure is preserved.
102
87
  }
103
- const totalSize = parseInt(response.headers['content-length'] || '0', 10);
104
- let received = 0;
105
- response.on('data', (chunk) => {
106
- received += chunk.length;
107
- if (onProgress)
108
- onProgress(received, totalSize);
109
- });
110
- response.pipe(file);
111
- file.on('finish', () => {
112
- file.close();
113
- // Rename temp file to final destination
114
- fs.renameSync(tempPath, destPath);
115
- resolve({ success: true, size: received });
116
- });
88
+ };
89
+ const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
90
+ void (async () => {
91
+ // Handle redirects before creating any file handles.
92
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
93
+ response.resume();
94
+ if (redirectCount >= maxRedirects) {
95
+ finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
96
+ return;
97
+ }
98
+ const redirectUrl = resolveRedirectUrl(url, response.headers.location);
99
+ const originalHost = new URL(url).hostname;
100
+ const redirectHost = new URL(redirectUrl).hostname;
101
+ const redirectOptions = originalHost === redirectHost
102
+ ? options
103
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
104
+ finish(await httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1));
105
+ return;
106
+ }
107
+ if (response.statusCode !== 200) {
108
+ response.resume();
109
+ finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
110
+ return;
111
+ }
112
+ const totalSize = parseInt(response.headers['content-length'] || '0', 10);
113
+ let received = 0;
114
+ const progressStream = new Transform({
115
+ transform(chunk, _encoding, callback) {
116
+ received += chunk.length;
117
+ if (onProgress)
118
+ onProgress(received, totalSize);
119
+ callback(null, chunk);
120
+ },
121
+ });
122
+ try {
123
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
124
+ await pipeline(response, progressStream, fs.createWriteStream(tempPath));
125
+ await fs.promises.rename(tempPath, destPath);
126
+ finish({ success: true, size: received });
127
+ }
128
+ catch (err) {
129
+ await cleanupTempFile();
130
+ finish({ success: false, size: 0, error: getErrorMessage(err) });
131
+ }
132
+ })();
117
133
  });
118
134
  request.on('error', (err) => {
119
- file.close();
120
- if (fs.existsSync(tempPath))
121
- fs.unlinkSync(tempPath);
122
- resolve({ success: false, size: 0, error: err.message });
135
+ void (async () => {
136
+ await cleanupTempFile();
137
+ finish({ success: false, size: 0, error: err.message });
138
+ })();
123
139
  });
124
140
  request.on('timeout', () => {
125
- request.destroy();
126
- file.close();
127
- if (fs.existsSync(tempPath))
128
- fs.unlinkSync(tempPath);
129
- resolve({ success: false, size: 0, error: 'Timeout' });
141
+ request.destroy(new Error('Timeout'));
130
142
  });
131
143
  });
132
144
  }
@@ -5,11 +5,19 @@ import * as path from 'node:path';
5
5
  import { afterEach, describe, expect, it } from 'vitest';
6
6
  import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
7
7
  const servers = [];
8
+ const tempDirs = [];
8
9
  afterEach(async () => {
9
10
  await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
10
11
  server.close((err) => (err ? reject(err) : resolve()));
11
12
  })));
12
13
  servers.length = 0;
14
+ for (const dir of tempDirs) {
15
+ try {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ catch { /* ignore */ }
19
+ }
20
+ tempDirs.length = 0;
13
21
  });
14
22
  async function startServer(handler, hostname = '127.0.0.1') {
15
23
  const server = http.createServer(handler);
@@ -21,7 +29,9 @@ async function startServer(handler, hostname = '127.0.0.1') {
21
29
  }
22
30
  return `http://${hostname}:${address.port}`;
23
31
  }
24
- describe('download helpers', () => {
32
+ // Windows Defender can briefly lock newly-written .tmp files, causing EPERM.
33
+ // Retry once to handle this flakiness.
34
+ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, () => {
25
35
  it('resolves relative redirects against the original URL', () => {
26
36
  expect(resolveRedirectUrl('https://example.com/a/file', '/cdn/file.bin')).toBe('https://example.com/cdn/file.bin');
27
37
  expect(resolveRedirectUrl('https://example.com/a/file', '../next')).toBe('https://example.com/next');
@@ -38,7 +48,8 @@ describe('download helpers', () => {
38
48
  res.setHeader('Location', '/loop');
39
49
  res.end();
40
50
  });
41
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-download-'));
51
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
52
+ tempDirs.push(tempDir);
42
53
  const destPath = path.join(tempDir, 'file.txt');
43
54
  const result = await httpDownload(`${baseUrl}/loop`, destPath, { maxRedirects: 2 });
44
55
  expect(result).toEqual({
@@ -60,7 +71,8 @@ describe('download helpers', () => {
60
71
  res.setHeader('Location', targetUrl);
61
72
  res.end();
62
73
  });
63
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-download-'));
74
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
75
+ tempDirs.push(tempDir);
64
76
  const destPath = path.join(tempDir, 'redirect.txt');
65
77
  const result = await httpDownload(`${redirectUrl}/start`, destPath, { cookies: 'sid=abc' });
66
78
  expect(result).toEqual({ success: true, size: 2 });
@@ -79,7 +91,8 @@ describe('download helpers', () => {
79
91
  res.setHeader('Location', targetUrl);
80
92
  res.end();
81
93
  });
82
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-download-'));
94
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
95
+ tempDirs.push(tempDir);
83
96
  const destPath = path.join(tempDir, 'redirect-header.txt');
84
97
  const result = await httpDownload(`${redirectUrl}/start`, destPath, {
85
98
  headers: { Cookie: 'sid=header-cookie' },
@@ -75,11 +75,26 @@ cli({
75
75
  describe('discoverPlugins', () => {
76
76
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
77
77
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');
78
+ const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
79
+ const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
80
+ const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
78
81
  afterEach(async () => {
79
82
  try {
80
83
  await fs.promises.rm(testPluginDir, { recursive: true });
81
84
  }
82
85
  catch { }
86
+ try {
87
+ await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true });
88
+ }
89
+ catch { }
90
+ try {
91
+ await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true });
92
+ }
93
+ catch { }
94
+ try {
95
+ await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true });
96
+ }
97
+ catch { }
83
98
  });
84
99
  it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
85
100
  // Create a simple YAML adapter in the plugins directory
@@ -108,6 +123,33 @@ columns: [message]
108
123
  // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
109
124
  await expect(discoverPlugins()).resolves.not.toThrow();
110
125
  });
126
+ it('discovers YAML plugins from symlinked plugin directories', async () => {
127
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
128
+ await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
129
+ await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
130
+ site: __test-plugin-symlink__
131
+ name: hello
132
+ description: Test plugin greeting via symlink
133
+ strategy: public
134
+ browser: false
135
+
136
+ pipeline:
137
+ - evaluate: "() => [{ message: 'hello from symlink plugin' }]"
138
+
139
+ columns: [message]
140
+ `);
141
+ await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
142
+ await discoverPlugins();
143
+ const cmd = getRegistry().get('__test-plugin-symlink__/hello');
144
+ expect(cmd).toBeDefined();
145
+ expect(cmd.description).toBe('Test plugin greeting via symlink');
146
+ });
147
+ it('skips broken plugin symlinks without throwing', async () => {
148
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
149
+ await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
150
+ await expect(discoverPlugins()).resolves.not.toThrow();
151
+ expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
152
+ });
111
153
  });
112
154
  describe('executeCommand', () => {
113
155
  beforeEach(() => {
package/dist/errors.d.ts CHANGED
@@ -12,8 +12,10 @@ export declare class CliError extends Error {
12
12
  readonly hint?: string;
13
13
  constructor(code: string, message: string, hint?: string);
14
14
  }
15
+ export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
15
16
  export declare class BrowserConnectError extends CliError {
16
- constructor(message: string, hint?: string);
17
+ readonly kind: BrowserConnectKind;
18
+ constructor(message: string, hint?: string, kind?: BrowserConnectKind);
17
19
  }
18
20
  export declare class AdapterLoadError extends CliError {
19
21
  constructor(message: string, hint?: string);
@@ -29,7 +31,7 @@ export declare class AuthRequiredError extends CliError {
29
31
  constructor(domain: string, message?: string);
30
32
  }
31
33
  export declare class TimeoutError extends CliError {
32
- constructor(label: string, seconds: number);
34
+ constructor(label: string, seconds: number, hint?: string);
33
35
  }
34
36
  export declare class ArgumentError extends CliError {
35
37
  constructor(message: string, hint?: string);
package/dist/errors.js CHANGED
@@ -12,74 +12,50 @@ export class CliError extends Error {
12
12
  hint;
13
13
  constructor(code, message, hint) {
14
14
  super(message);
15
- this.name = 'CliError';
15
+ this.name = new.target.name;
16
16
  this.code = code;
17
17
  this.hint = hint;
18
18
  }
19
19
  }
20
- // ── Browser / Connection ────────────────────────────────────────────────────
21
20
  export class BrowserConnectError extends CliError {
22
- constructor(message, hint) {
21
+ kind;
22
+ constructor(message, hint, kind = 'unknown') {
23
23
  super('BROWSER_CONNECT', message, hint);
24
- this.name = 'BrowserConnectError';
24
+ this.kind = kind;
25
25
  }
26
26
  }
27
- // ── Adapter loading ─────────────────────────────────────────────────────────
28
27
  export class AdapterLoadError extends CliError {
29
- constructor(message, hint) {
30
- super('ADAPTER_LOAD', message, hint);
31
- this.name = 'AdapterLoadError';
32
- }
28
+ constructor(message, hint) { super('ADAPTER_LOAD', message, hint); }
33
29
  }
34
- // ── Command execution ───────────────────────────────────────────────────────
35
30
  export class CommandExecutionError extends CliError {
36
- constructor(message, hint) {
37
- super('COMMAND_EXEC', message, hint);
38
- this.name = 'CommandExecutionError';
39
- }
31
+ constructor(message, hint) { super('COMMAND_EXEC', message, hint); }
40
32
  }
41
- // ── Configuration ───────────────────────────────────────────────────────────
42
33
  export class ConfigError extends CliError {
43
- constructor(message, hint) {
44
- super('CONFIG', message, hint);
45
- this.name = 'ConfigError';
46
- }
34
+ constructor(message, hint) { super('CONFIG', message, hint); }
47
35
  }
48
- // ── Authentication / Login ──────────────────────────────────────────────────
49
36
  export class AuthRequiredError extends CliError {
50
37
  domain;
51
38
  constructor(domain, message) {
52
39
  super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`);
53
- this.name = 'AuthRequiredError';
54
40
  this.domain = domain;
55
41
  }
56
42
  }
57
- // ── Timeout ─────────────────────────────────────────────────────────────────
58
43
  export class TimeoutError extends CliError {
59
- constructor(label, seconds) {
60
- super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
61
- this.name = 'TimeoutError';
44
+ constructor(label, seconds, hint) {
45
+ super('TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
62
46
  }
63
47
  }
64
- // ── Argument validation ─────────────────────────────────────────────────────
65
48
  export class ArgumentError extends CliError {
66
- constructor(message, hint) {
67
- super('ARGUMENT', message, hint);
68
- this.name = 'ArgumentError';
69
- }
49
+ constructor(message, hint) { super('ARGUMENT', message, hint); }
70
50
  }
71
- // ── Empty result ────────────────────────────────────────────────────────────
72
51
  export class EmptyResultError extends CliError {
73
52
  constructor(command, hint) {
74
53
  super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in');
75
- this.name = 'EmptyResultError';
76
54
  }
77
55
  }
78
- // ── Selector / DOM ──────────────────────────────────────────────────────────
79
56
  export class SelectorError extends CliError {
80
57
  constructor(selector, hint) {
81
58
  super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.');
82
- this.name = 'SelectorError';
83
59
  }
84
60
  }
85
61
  // ── Utilities ───────────────────────────────────────────────────────────
@@ -95,4 +71,11 @@ export const ERROR_ICONS = {
95
71
  ARGUMENT: '❌',
96
72
  EMPTY_RESULT: '📭',
97
73
  SELECTOR: '🔍',
74
+ COMMAND_EXEC: '💥',
75
+ ADAPTER_LOAD: '📦',
76
+ NETWORK: '🌐',
77
+ API_ERROR: '🚫',
78
+ RATE_LIMITED: '⏳',
79
+ PAGE_CHANGED: '🔄',
80
+ CONFIG: '⚙️ ',
98
81
  };
@@ -9,8 +9,6 @@
9
9
  * 5. Lazy-loading of TS modules from manifest
10
10
  * 6. Lifecycle hooks (onBeforeExecute / onAfterExecute)
11
11
  */
12
- import { type CliCommand, type Arg } from './registry.js';
13
- type CommandArgs = Record<string, unknown>;
12
+ import { type CliCommand, type Arg, type CommandArgs } from './registry.js';
14
13
  export declare function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandArgs;
15
14
  export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs, debug?: boolean): Promise<unknown>;
16
- export {};
package/dist/execution.js CHANGED
@@ -12,10 +12,13 @@
12
12
  import { Strategy, getRegistry, fullName } from './registry.js';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import { executePipeline } from './pipeline/index.js';
15
- import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
15
+ import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js';
16
16
  import { shouldUseBrowserSession } from './capabilityRouting.js';
17
17
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
18
18
  import { emitHook } from './hooks.js';
19
+ import { checkDaemonStatus } from './browser/discover.js';
20
+ import { PKG_VERSION } from './version.js';
21
+ import chalk from 'chalk';
19
22
  const _loadedModules = new Set();
20
23
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
21
24
  const result = { ...kwargs };
@@ -107,6 +110,25 @@ function ensureRequiredEnv(cmd) {
107
110
  return;
108
111
  throw new CommandExecutionError(`Command ${fullName(cmd)} requires environment variable ${missing.name}.`, missing.help ?? `Set ${missing.name} before running ${fullName(cmd)}.`);
109
112
  }
113
+ /**
114
+ * Check if the browser is already on the target domain, avoiding redundant navigation.
115
+ * Returns true if current page hostname matches the pre-nav URL hostname.
116
+ */
117
+ async function isAlreadyOnDomain(page, targetUrl) {
118
+ if (!page.getCurrentUrl)
119
+ return false;
120
+ try {
121
+ const currentUrl = await page.getCurrentUrl();
122
+ if (!currentUrl)
123
+ return false;
124
+ const currentHost = new URL(currentUrl).hostname;
125
+ const targetHost = new URL(targetUrl).hostname;
126
+ return currentHost === targetHost;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
110
132
  export async function executeCommand(cmd, rawKwargs, debug = false) {
111
133
  let kwargs;
112
134
  try {
@@ -126,18 +148,43 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
126
148
  let result;
127
149
  try {
128
150
  if (shouldUseBrowserSession(cmd)) {
151
+ // ── Fail-fast: only when daemon is UP but extension is not connected ──
152
+ // If daemon is not running, let browserSession() handle auto-start as usual.
153
+ // We only short-circuit when the daemon confirms the extension is missing —
154
+ // that's a clear setup gap, not a transient startup state.
155
+ // Use a short timeout: localhost responds in <50ms when running.
156
+ // 300ms avoids a full 2s wait on cold-start (daemon not yet running).
157
+ const status = await checkDaemonStatus({ timeout: 300 });
158
+ if (status.running && !status.extensionConnected) {
159
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
160
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
161
+ ' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
162
+ ' Then run: opencli doctor');
163
+ }
164
+ // ── Version mismatch: warn but don't block ──
165
+ if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
166
+ process.stderr.write(chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`));
167
+ }
129
168
  ensureRequiredEnv(cmd);
130
169
  const BrowserFactory = getBrowserFactory();
131
170
  result = await browserSession(BrowserFactory, async (page) => {
132
171
  const preNavUrl = resolvePreNav(cmd);
133
172
  if (preNavUrl) {
134
- try {
135
- await page.goto(preNavUrl);
136
- await page.wait(2);
137
- }
138
- catch (err) {
173
+ const skip = await isAlreadyOnDomain(page, preNavUrl);
174
+ if (skip) {
139
175
  if (debug)
140
- console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
176
+ console.error(`[pre-nav] Already on target domain, skipping navigation`);
177
+ }
178
+ else {
179
+ try {
180
+ // goto() already includes smart DOM-settle detection (waitForDomStable).
181
+ // No additional fixed sleep needed.
182
+ await page.goto(preNavUrl);
183
+ }
184
+ catch (err) {
185
+ if (debug)
186
+ console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
187
+ }
141
188
  }
142
189
  }
143
190
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
@@ -147,7 +194,18 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
147
194
  }, { workspace: `site:${cmd.site}` });
148
195
  }
149
196
  else {
150
- result = await runCommand(cmd, null, kwargs, debug);
197
+ // Non-browser commands: apply timeout only when explicitly configured.
198
+ const timeout = cmd.timeoutSeconds;
199
+ if (timeout !== undefined && timeout > 0) {
200
+ result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
201
+ timeout,
202
+ label: fullName(cmd),
203
+ hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
204
+ });
205
+ }
206
+ else {
207
+ result = await runCommand(cmd, null, kwargs, debug);
208
+ }
151
209
  }
152
210
  }
153
211
  catch (err) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { executeCommand } from './execution.js';
3
+ import { TimeoutError } from './errors.js';
4
+ import { cli, Strategy } from './registry.js';
5
+ import { withTimeoutMs } from './runtime.js';
6
+ describe('executeCommand — non-browser timeout', () => {
7
+ it('applies timeoutSeconds to non-browser commands', async () => {
8
+ const cmd = cli({
9
+ site: 'test-execution',
10
+ name: 'non-browser-timeout',
11
+ description: 'test non-browser timeout',
12
+ browser: false,
13
+ strategy: Strategy.PUBLIC,
14
+ timeoutSeconds: 0.01,
15
+ func: () => new Promise(() => { }),
16
+ });
17
+ // Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
18
+ // the error will be a TimeoutError with the command label, not 'sentinel'.
19
+ const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
20
+ .catch((err) => err);
21
+ expect(error).toBeInstanceOf(TimeoutError);
22
+ expect(error).toMatchObject({
23
+ code: 'TIMEOUT',
24
+ message: 'test-execution/non-browser-timeout timed out after 0.01s',
25
+ });
26
+ });
27
+ it('skips timeout when timeoutSeconds is 0', async () => {
28
+ const cmd = cli({
29
+ site: 'test-execution',
30
+ name: 'non-browser-zero-timeout',
31
+ description: 'test zero timeout bypasses wrapping',
32
+ browser: false,
33
+ strategy: Strategy.PUBLIC,
34
+ timeoutSeconds: 0,
35
+ func: () => new Promise(() => { }),
36
+ });
37
+ // With timeout guard skipped, the sentinel fires instead.
38
+ await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
39
+ });
40
+ });
package/dist/external.js CHANGED
@@ -12,7 +12,10 @@ function getUserRegistryPath() {
12
12
  const home = os.homedir();
13
13
  return path.join(home, '.opencli', 'external-clis.yaml');
14
14
  }
15
+ let _cachedExternalClis = null;
15
16
  export function loadExternalClis() {
17
+ if (_cachedExternalClis)
18
+ return _cachedExternalClis;
16
19
  const configs = new Map();
17
20
  // 1. Load built-in
18
21
  const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
@@ -41,7 +44,8 @@ export function loadExternalClis() {
41
44
  catch (err) {
42
45
  log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
43
46
  }
44
- return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
47
+ _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
48
+ return _cachedExternalClis;
45
49
  }
46
50
  export function isBinaryInstalled(binary) {
47
51
  try {
@@ -200,5 +204,6 @@ export function registerExternalCli(name, opts) {
200
204
  }
201
205
  const dump = yaml.dump(items, { indent: 2, sortKeys: true });
202
206
  fs.writeFileSync(userPath, dump, 'utf8');
207
+ _cachedExternalClis = null; // Invalidate cache so next load reflects the change
203
208
  console.log(chalk.dim(userPath));
204
209
  }
package/dist/hooks.js CHANGED
@@ -15,6 +15,8 @@ const _hooks = globalThis.__opencli_hooks__ ??= new Map();
15
15
  // ── Registration API (used by plugins) ─────────────────────────────────────
16
16
  function addHook(name, fn) {
17
17
  const list = _hooks.get(name) ?? [];
18
+ if (list.includes(fn))
19
+ return;
18
20
  list.push(fn);
19
21
  _hooks.set(name, list);
20
22
  }
package/dist/main.js CHANGED
@@ -19,12 +19,18 @@ import { discoverClis, discoverPlugins } from './discovery.js';
19
19
  import { getCompletions } from './completion.js';
20
20
  import { runCli } from './cli.js';
21
21
  import { emitHook } from './hooks.js';
22
+ import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = path.dirname(__filename);
24
25
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
25
26
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
27
+ // Sequential: plugins must run after built-in discovery so they can override built-in commands.
26
28
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
27
29
  await discoverPlugins();
30
+ // Register exit hook: notice appears after command output (same as npm/gh/yarn)
31
+ registerUpdateNoticeOnExit();
32
+ // Kick off background fetch for next run (non-blocking)
33
+ checkForUpdateBackground();
28
34
  // ── Fast-path: handle --get-completions before commander parses ─────────
29
35
  // Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
30
36
  const getCompIdx = process.argv.indexOf('--get-completions');
package/dist/output.js CHANGED
@@ -5,7 +5,11 @@ import chalk from 'chalk';
5
5
  import Table from 'cli-table3';
6
6
  import yaml from 'js-yaml';
7
7
  function normalizeRows(data) {
8
- return Array.isArray(data) ? data : [data];
8
+ if (Array.isArray(data))
9
+ return data;
10
+ if (data && typeof data === 'object')
11
+ return [data];
12
+ return [{ value: data }];
9
13
  }
10
14
  function resolveColumns(rows, opts) {
11
15
  return opts.columns ?? Object.keys(rows[0] ?? {});
@@ -4,8 +4,7 @@
4
4
  import { getStep } from './registry.js';
5
5
  import { log } from '../logger.js';
6
6
  import { ConfigError } from '../errors.js';
7
- /** Steps that interact with the browser and may fail transiently */
8
- const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot']);
7
+ import { BROWSER_ONLY_STEPS } from '../capabilityRouting.js';
9
8
  export async function executePipeline(page, pipeline, ctx = {}) {
10
9
  const args = ctx.args ?? {};
11
10
  const debug = ctx.debug ?? false;
@@ -33,7 +32,7 @@ export async function executePipeline(page, pipeline, ctx = {}) {
33
32
  }
34
33
  catch (err) {
35
34
  // Attempt cleanup: close automation window on pipeline failure
36
- if (page && typeof page.closeWindow === 'function') {
35
+ if (page?.closeWindow) {
37
36
  try {
38
37
  await page.closeWindow();
39
38
  }
@@ -44,7 +43,7 @@ export async function executePipeline(page, pipeline, ctx = {}) {
44
43
  return data;
45
44
  }
46
45
  async function executeStepWithRetry(handler, page, params, data, args, op, configRetries) {
47
- const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
46
+ const maxRetries = configRetries ?? (BROWSER_ONLY_STEPS.has(op) ? 2 : 0);
48
47
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
48
  try {
50
49
  return await handler(page, params, data, args);