@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
@@ -8,6 +8,8 @@ import * as path from 'node:path';
8
8
  import * as https from 'node:https';
9
9
  import * as http from 'node:http';
10
10
  import * as os from 'node:os';
11
+ import { Transform } from 'node:stream';
12
+ import { pipeline } from 'node:stream/promises';
11
13
  import { URL } from 'node:url';
12
14
  import type { ProgressBar } from './progress.js';
13
15
  import { isBinaryInstalled } from '../external.js';
@@ -99,74 +101,84 @@ export async function httpDownload(
99
101
  requestHeaders['Cookie'] = cookies;
100
102
  }
101
103
 
102
- // Ensure directory exists
103
- const dir = path.dirname(destPath);
104
- fs.mkdirSync(dir, { recursive: true });
105
-
106
104
  const tempPath = `${destPath}.tmp`;
107
- const file = fs.createWriteStream(tempPath);
105
+ let settled = false;
108
106
 
109
- const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
110
- // Handle redirects
111
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
112
- file.close();
113
- if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
114
- if (redirectCount >= maxRedirects) {
115
- resolve({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
116
- return;
117
- }
118
- const redirectUrl = resolveRedirectUrl(url, response.headers.location);
119
- const originalHost = new URL(url).hostname;
120
- const redirectHost = new URL(redirectUrl).hostname;
121
- // Do not forward cookies when a redirect crosses host boundaries.
122
- const redirectOptions = originalHost === redirectHost
123
- ? options
124
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
125
- httpDownload(
126
- redirectUrl,
127
- destPath,
128
- redirectOptions,
129
- redirectCount + 1,
130
- ).then(resolve);
131
- return;
132
- }
107
+ const finish = (result: { success: boolean; size: number; error?: string }) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ resolve(result);
111
+ };
133
112
 
134
- if (response.statusCode !== 200) {
135
- file.close();
136
- if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
137
- resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
138
- return;
113
+ const cleanupTempFile = async () => {
114
+ try {
115
+ await fs.promises.rm(tempPath, { force: true });
116
+ } catch {
117
+ // Ignore cleanup errors so the original failure is preserved.
139
118
  }
119
+ };
140
120
 
141
- const totalSize = parseInt(response.headers['content-length'] || '0', 10);
142
- let received = 0;
143
-
144
- response.on('data', (chunk: Buffer) => {
145
- received += chunk.length;
146
- if (onProgress) onProgress(received, totalSize);
147
- });
121
+ const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
122
+ void (async () => {
123
+ // Handle redirects before creating any file handles.
124
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
125
+ response.resume();
126
+ if (redirectCount >= maxRedirects) {
127
+ finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
128
+ return;
129
+ }
130
+ const redirectUrl = resolveRedirectUrl(url, response.headers.location);
131
+ const originalHost = new URL(url).hostname;
132
+ const redirectHost = new URL(redirectUrl).hostname;
133
+ const redirectOptions = originalHost === redirectHost
134
+ ? options
135
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
136
+ finish(await httpDownload(
137
+ redirectUrl,
138
+ destPath,
139
+ redirectOptions,
140
+ redirectCount + 1,
141
+ ));
142
+ return;
143
+ }
148
144
 
149
- response.pipe(file);
145
+ if (response.statusCode !== 200) {
146
+ response.resume();
147
+ finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
148
+ return;
149
+ }
150
150
 
151
- file.on('finish', () => {
152
- file.close();
153
- // Rename temp file to final destination
154
- fs.renameSync(tempPath, destPath);
155
- resolve({ success: true, size: received });
156
- });
151
+ const totalSize = parseInt(response.headers['content-length'] || '0', 10);
152
+ let received = 0;
153
+ const progressStream = new Transform({
154
+ transform(chunk, _encoding, callback) {
155
+ received += chunk.length;
156
+ if (onProgress) onProgress(received, totalSize);
157
+ callback(null, chunk);
158
+ },
159
+ });
160
+
161
+ try {
162
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
163
+ await pipeline(response, progressStream, fs.createWriteStream(tempPath));
164
+ await fs.promises.rename(tempPath, destPath);
165
+ finish({ success: true, size: received });
166
+ } catch (err) {
167
+ await cleanupTempFile();
168
+ finish({ success: false, size: 0, error: getErrorMessage(err) });
169
+ }
170
+ })();
157
171
  });
158
172
 
159
173
  request.on('error', (err) => {
160
- file.close();
161
- if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
162
- resolve({ success: false, size: 0, error: err.message });
174
+ void (async () => {
175
+ await cleanupTempFile();
176
+ finish({ success: false, size: 0, error: err.message });
177
+ })();
163
178
  });
164
179
 
165
180
  request.on('timeout', () => {
166
- request.destroy();
167
- file.close();
168
- if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
169
- resolve({ success: false, size: 0, error: 'Timeout' });
181
+ request.destroy(new Error('Timeout'));
170
182
  });
171
183
  });
172
184
  }
@@ -83,9 +83,15 @@ cli({
83
83
  describe('discoverPlugins', () => {
84
84
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
85
85
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');
86
+ const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
87
+ const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
88
+ const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
86
89
 
87
90
  afterEach(async () => {
88
91
  try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {}
92
+ try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {}
93
+ try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {}
94
+ try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {}
89
95
  });
90
96
 
91
97
  it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
@@ -118,6 +124,38 @@ columns: [message]
118
124
  // discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
119
125
  await expect(discoverPlugins()).resolves.not.toThrow();
120
126
  });
127
+
128
+ it('discovers YAML plugins from symlinked plugin directories', async () => {
129
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
130
+ await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
131
+ await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
132
+ site: __test-plugin-symlink__
133
+ name: hello
134
+ description: Test plugin greeting via symlink
135
+ strategy: public
136
+ browser: false
137
+
138
+ pipeline:
139
+ - evaluate: "() => [{ message: 'hello from symlink plugin' }]"
140
+
141
+ columns: [message]
142
+ `);
143
+ await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
144
+
145
+ await discoverPlugins();
146
+
147
+ const cmd = getRegistry().get('__test-plugin-symlink__/hello');
148
+ expect(cmd).toBeDefined();
149
+ expect(cmd!.description).toBe('Test plugin greeting via symlink');
150
+ });
151
+
152
+ it('skips broken plugin symlinks without throwing', async () => {
153
+ await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
154
+ await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
155
+
156
+ await expect(discoverPlugins()).resolves.not.toThrow();
157
+ expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
158
+ });
121
159
  });
122
160
 
123
161
  describe('executeCommand', () => {
package/src/errors.ts CHANGED
@@ -14,109 +14,65 @@ export class CliError extends Error {
14
14
 
15
15
  constructor(code: string, message: string, hint?: string) {
16
16
  super(message);
17
- this.name = 'CliError';
17
+ this.name = new.target.name;
18
18
  this.code = code;
19
19
  this.hint = hint;
20
20
  }
21
21
  }
22
22
 
23
- // ── Browser / Connection ────────────────────────────────────────────────────
23
+ export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
24
24
 
25
25
  export class BrowserConnectError extends CliError {
26
- constructor(message: string, hint?: string) {
26
+ readonly kind: BrowserConnectKind;
27
+ constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') {
27
28
  super('BROWSER_CONNECT', message, hint);
28
- this.name = 'BrowserConnectError';
29
+ this.kind = kind;
29
30
  }
30
31
  }
31
32
 
32
- // ── Adapter loading ─────────────────────────────────────────────────────────
33
-
34
33
  export class AdapterLoadError extends CliError {
35
- constructor(message: string, hint?: string) {
36
- super('ADAPTER_LOAD', message, hint);
37
- this.name = 'AdapterLoadError';
38
- }
34
+ constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); }
39
35
  }
40
36
 
41
- // ── Command execution ───────────────────────────────────────────────────────
42
-
43
37
  export class CommandExecutionError extends CliError {
44
- constructor(message: string, hint?: string) {
45
- super('COMMAND_EXEC', message, hint);
46
- this.name = 'CommandExecutionError';
47
- }
38
+ constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); }
48
39
  }
49
40
 
50
- // ── Configuration ───────────────────────────────────────────────────────────
51
-
52
41
  export class ConfigError extends CliError {
53
- constructor(message: string, hint?: string) {
54
- super('CONFIG', message, hint);
55
- this.name = 'ConfigError';
56
- }
42
+ constructor(message: string, hint?: string) { super('CONFIG', message, hint); }
57
43
  }
58
44
 
59
- // ── Authentication / Login ──────────────────────────────────────────────────
60
-
61
45
  export class AuthRequiredError extends CliError {
62
46
  readonly domain: string;
63
-
64
47
  constructor(domain: string, message?: string) {
65
- super(
66
- 'AUTH_REQUIRED',
67
- message ?? `Not logged in to ${domain}`,
68
- `Please open Chrome and log in to https://${domain}`,
69
- );
70
- this.name = 'AuthRequiredError';
48
+ super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`);
71
49
  this.domain = domain;
72
50
  }
73
51
  }
74
52
 
75
- // ── Timeout ─────────────────────────────────────────────────────────────────
76
-
77
53
  export class TimeoutError extends CliError {
78
- constructor(label: string, seconds: number) {
54
+ constructor(label: string, seconds: number, hint?: string) {
79
55
  super(
80
56
  'TIMEOUT',
81
57
  `${label} timed out after ${seconds}s`,
82
- 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
58
+ hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
83
59
  );
84
- this.name = 'TimeoutError';
85
60
  }
86
61
  }
87
62
 
88
- // ── Argument validation ─────────────────────────────────────────────────────
89
-
90
63
  export class ArgumentError extends CliError {
91
- constructor(message: string, hint?: string) {
92
- super('ARGUMENT', message, hint);
93
- this.name = 'ArgumentError';
94
- }
64
+ constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); }
95
65
  }
96
66
 
97
- // ── Empty result ────────────────────────────────────────────────────────────
98
-
99
67
  export class EmptyResultError extends CliError {
100
68
  constructor(command: string, hint?: string) {
101
- super(
102
- 'EMPTY_RESULT',
103
- `${command} returned no data`,
104
- hint ?? 'The page structure may have changed, or you may need to log in',
105
- );
106
- this.name = 'EmptyResultError';
69
+ super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in');
107
70
  }
108
71
  }
109
72
 
110
- // ── Selector / DOM ──────────────────────────────────────────────────────────
111
-
112
73
  export class SelectorError extends CliError {
113
74
  constructor(selector: string, hint?: string) {
114
- super(
115
- 'SELECTOR',
116
- `Could not find element: ${selector}`,
117
- hint ?? 'The page UI may have changed. Please report this issue.',
118
- );
119
- this.name = 'SelectorError';
75
+ super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.');
120
76
  }
121
77
  }
122
78
 
@@ -129,10 +85,17 @@ export function getErrorMessage(error: unknown): string {
129
85
 
130
86
  /** Error code → emoji mapping for CLI output rendering. */
131
87
  export const ERROR_ICONS: Record<string, string> = {
132
- AUTH_REQUIRED: '🔒',
88
+ AUTH_REQUIRED: '🔒',
133
89
  BROWSER_CONNECT: '🔌',
134
- TIMEOUT: '⏱ ',
135
- ARGUMENT: '❌',
136
- EMPTY_RESULT: '📭',
137
- SELECTOR: '🔍',
90
+ TIMEOUT: '⏱ ',
91
+ ARGUMENT: '❌',
92
+ EMPTY_RESULT: '📭',
93
+ SELECTOR: '🔍',
94
+ COMMAND_EXEC: '💥',
95
+ ADAPTER_LOAD: '📦',
96
+ NETWORK: '🌐',
97
+ API_ERROR: '🚫',
98
+ RATE_LIMITED: '⏳',
99
+ PAGE_CHANGED: '🔄',
100
+ CONFIG: '⚙️ ',
138
101
  };
@@ -0,0 +1,47 @@
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
+
7
+ describe('executeCommand — non-browser timeout', () => {
8
+ it('applies timeoutSeconds to non-browser commands', async () => {
9
+ const cmd = cli({
10
+ site: 'test-execution',
11
+ name: 'non-browser-timeout',
12
+ description: 'test non-browser timeout',
13
+ browser: false,
14
+ strategy: Strategy.PUBLIC,
15
+ timeoutSeconds: 0.01,
16
+ func: () => new Promise(() => {}),
17
+ });
18
+
19
+ // Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
20
+ // the error will be a TimeoutError with the command label, not 'sentinel'.
21
+ const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
22
+ .catch((err) => err);
23
+
24
+ expect(error).toBeInstanceOf(TimeoutError);
25
+ expect(error).toMatchObject({
26
+ code: 'TIMEOUT',
27
+ message: 'test-execution/non-browser-timeout timed out after 0.01s',
28
+ });
29
+ });
30
+
31
+ it('skips timeout when timeoutSeconds is 0', async () => {
32
+ const cmd = cli({
33
+ site: 'test-execution',
34
+ name: 'non-browser-zero-timeout',
35
+ description: 'test zero timeout bypasses wrapping',
36
+ browser: false,
37
+ strategy: Strategy.PUBLIC,
38
+ timeoutSeconds: 0,
39
+ func: () => new Promise(() => {}),
40
+ });
41
+
42
+ // With timeout guard skipped, the sentinel fires instead.
43
+ await expect(
44
+ withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'),
45
+ ).rejects.toThrow('sentinel timeout');
46
+ });
47
+ });
package/src/execution.ts CHANGED
@@ -10,17 +10,19 @@
10
10
  * 6. Lifecycle hooks (onBeforeExecute / onAfterExecute)
11
11
  */
12
12
 
13
- import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegistry, fullName } from './registry.js';
13
+ import { type CliCommand, type InternalCliCommand, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js';
14
14
  import type { IPage } from './types.js';
15
15
  import { pathToFileURL } from 'node:url';
16
16
  import { executePipeline } from './pipeline/index.js';
17
- import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
17
+ import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js';
18
18
  import { shouldUseBrowserSession } from './capabilityRouting.js';
19
19
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
20
20
  import { emitHook, type HookContext } from './hooks.js';
21
+ import { checkDaemonStatus } from './browser/discover.js';
22
+ import { PKG_VERSION } from './version.js';
23
+ import chalk from 'chalk';
21
24
 
22
25
  const _loadedModules = new Set<string>();
23
- type CommandArgs = Record<string, unknown>;
24
26
 
25
27
  export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandArgs {
26
28
  const result: CommandArgs = { ...kwargs };
@@ -128,6 +130,23 @@ function ensureRequiredEnv(cmd: CliCommand): void {
128
130
  );
129
131
  }
130
132
 
133
+ /**
134
+ * Check if the browser is already on the target domain, avoiding redundant navigation.
135
+ * Returns true if current page hostname matches the pre-nav URL hostname.
136
+ */
137
+ async function isAlreadyOnDomain(page: IPage, targetUrl: string): Promise<boolean> {
138
+ if (!page.getCurrentUrl) return false;
139
+ try {
140
+ const currentUrl = await page.getCurrentUrl();
141
+ if (!currentUrl) return false;
142
+ const currentHost = new URL(currentUrl).hostname;
143
+ const targetHost = new URL(targetUrl).hostname;
144
+ return currentHost === targetHost;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
131
150
  export async function executeCommand(
132
151
  cmd: CliCommand,
133
152
  rawKwargs: CommandArgs,
@@ -151,16 +170,45 @@ export async function executeCommand(
151
170
  let result: unknown;
152
171
  try {
153
172
  if (shouldUseBrowserSession(cmd)) {
173
+ // ── Fail-fast: only when daemon is UP but extension is not connected ──
174
+ // If daemon is not running, let browserSession() handle auto-start as usual.
175
+ // We only short-circuit when the daemon confirms the extension is missing —
176
+ // that's a clear setup gap, not a transient startup state.
177
+ // Use a short timeout: localhost responds in <50ms when running.
178
+ // 300ms avoids a full 2s wait on cold-start (daemon not yet running).
179
+ const status = await checkDaemonStatus({ timeout: 300 });
180
+ if (status.running && !status.extensionConnected) {
181
+ throw new BrowserConnectError(
182
+ 'Browser Bridge extension not connected',
183
+ 'Install the Browser Bridge:\n' +
184
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
185
+ ' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
186
+ ' Then run: opencli doctor',
187
+ );
188
+ }
189
+ // ── Version mismatch: warn but don't block ──
190
+ if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
191
+ process.stderr.write(
192
+ chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`)
193
+ );
194
+ }
195
+
154
196
  ensureRequiredEnv(cmd);
155
197
  const BrowserFactory = getBrowserFactory();
156
198
  result = await browserSession(BrowserFactory, async (page) => {
157
199
  const preNavUrl = resolvePreNav(cmd);
158
200
  if (preNavUrl) {
159
- try {
160
- await page.goto(preNavUrl);
161
- await page.wait(2);
162
- } catch (err) {
163
- if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
201
+ const skip = await isAlreadyOnDomain(page, preNavUrl);
202
+ if (skip) {
203
+ if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
204
+ } else {
205
+ try {
206
+ // goto() already includes smart DOM-settle detection (waitForDomStable).
207
+ // No additional fixed sleep needed.
208
+ await page.goto(preNavUrl);
209
+ } catch (err) {
210
+ if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
211
+ }
164
212
  }
165
213
  }
166
214
  return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
@@ -169,7 +217,17 @@ export async function executeCommand(
169
217
  });
170
218
  }, { workspace: `site:${cmd.site}` });
171
219
  } else {
172
- result = await runCommand(cmd, null, kwargs, debug);
220
+ // Non-browser commands: apply timeout only when explicitly configured.
221
+ const timeout = cmd.timeoutSeconds;
222
+ if (timeout !== undefined && timeout > 0) {
223
+ result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
224
+ timeout,
225
+ label: fullName(cmd),
226
+ hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
227
+ });
228
+ } else {
229
+ result = await runCommand(cmd, null, kwargs, debug);
230
+ }
173
231
  }
174
232
  } catch (err) {
175
233
  hookCtx.error = err;
package/src/external.ts CHANGED
@@ -31,7 +31,10 @@ function getUserRegistryPath(): string {
31
31
  return path.join(home, '.opencli', 'external-clis.yaml');
32
32
  }
33
33
 
34
+ let _cachedExternalClis: ExternalCliConfig[] | null = null;
35
+
34
36
  export function loadExternalClis(): ExternalCliConfig[] {
37
+ if (_cachedExternalClis) return _cachedExternalClis;
35
38
  const configs = new Map<string, ExternalCliConfig>();
36
39
 
37
40
  // 1. Load built-in
@@ -60,7 +63,8 @@ export function loadExternalClis(): ExternalCliConfig[] {
60
63
  log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
61
64
  }
62
65
 
63
- return Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
66
+ _cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
67
+ return _cachedExternalClis;
64
68
  }
65
69
 
66
70
  export function isBinaryInstalled(binary: string): boolean {
@@ -237,5 +241,6 @@ export function registerExternalCli(name: string, opts?: RegisterOptions): void
237
241
 
238
242
  const dump = yaml.dump(items, { indent: 2, sortKeys: true });
239
243
  fs.writeFileSync(userPath, dump, 'utf8');
244
+ _cachedExternalClis = null; // Invalidate cache so next load reflects the change
240
245
  console.log(chalk.dim(userPath));
241
246
  }
package/src/hooks.ts CHANGED
@@ -44,6 +44,7 @@ const _hooks: Map<HookName, HookFn[]> =
44
44
 
45
45
  function addHook(name: HookName, fn: HookFn): void {
46
46
  const list = _hooks.get(name) ?? [];
47
+ if (list.includes(fn)) return;
47
48
  list.push(fn);
48
49
  _hooks.set(name, list);
49
50
  }
package/src/main.ts CHANGED
@@ -20,15 +20,22 @@ import { discoverClis, discoverPlugins } from './discovery.js';
20
20
  import { getCompletions } from './completion.js';
21
21
  import { runCli } from './cli.js';
22
22
  import { emitHook } from './hooks.js';
23
+ import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
23
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const __dirname = path.dirname(__filename);
26
27
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
27
28
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
28
29
 
30
+ // Sequential: plugins must run after built-in discovery so they can override built-in commands.
29
31
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
30
32
  await discoverPlugins();
31
33
 
34
+ // Register exit hook: notice appears after command output (same as npm/gh/yarn)
35
+ registerUpdateNoticeOnExit();
36
+ // Kick off background fetch for next run (non-blocking)
37
+ checkForUpdateBackground();
38
+
32
39
  // ── Fast-path: handle --get-completions before commander parses ─────────
33
40
  // Usage: opencli --get-completions --cursor <N> [word1 word2 ...]
34
41
  const getCompIdx = process.argv.indexOf('--get-completions');
package/src/output.ts CHANGED
@@ -16,7 +16,9 @@ export interface RenderOptions {
16
16
  }
17
17
 
18
18
  function normalizeRows(data: unknown): Record<string, unknown>[] {
19
- return Array.isArray(data) ? data : [data as Record<string, unknown>];
19
+ if (Array.isArray(data)) return data;
20
+ if (data && typeof data === 'object') return [data as Record<string, unknown>];
21
+ return [{ value: data }];
20
22
  }
21
23
 
22
24
  function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): string[] {
@@ -7,6 +7,7 @@ import type { IPage } from '../types.js';
7
7
  import { getStep, type StepHandler } from './registry.js';
8
8
  import { log } from '../logger.js';
9
9
  import { ConfigError } from '../errors.js';
10
+ import { BROWSER_ONLY_STEPS } from '../capabilityRouting.js';
10
11
 
11
12
  export interface PipelineContext {
12
13
  args?: Record<string, unknown>;
@@ -15,9 +16,6 @@ export interface PipelineContext {
15
16
  stepRetries?: number;
16
17
  }
17
18
 
18
- /** Steps that interact with the browser and may fail transiently */
19
- const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot']);
20
-
21
19
  export async function executePipeline(
22
20
  page: IPage | null,
23
21
  pipeline: unknown[],
@@ -50,8 +48,8 @@ export async function executePipeline(
50
48
  }
51
49
  } catch (err) {
52
50
  // Attempt cleanup: close automation window on pipeline failure
53
- if (page && typeof (page as unknown as Record<string, unknown>).closeWindow === 'function') {
54
- try { await (page as unknown as { closeWindow: () => Promise<void> }).closeWindow(); } catch { /* ignore */ }
51
+ if (page?.closeWindow) {
52
+ try { await page.closeWindow(); } catch { /* ignore */ }
55
53
  }
56
54
  throw err;
57
55
  }
@@ -67,7 +65,7 @@ async function executeStepWithRetry(
67
65
  op: string,
68
66
  configRetries?: number,
69
67
  ): Promise<unknown> {
70
- const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
68
+ const maxRetries = configRetries ?? (BROWSER_ONLY_STEPS.has(op) ? 2 : 0);
71
69
 
72
70
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
71
  try {