@jackwener/opencli 1.6.0 → 1.6.2

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 (390) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +27 -45
  4. package/README.zh-CN.md +32 -34
  5. package/autoresearch/browse-tasks.json +18 -20
  6. package/autoresearch/commands/debug.ts +163 -0
  7. package/autoresearch/commands/fix.ts +145 -0
  8. package/autoresearch/commands/plan.ts +88 -0
  9. package/autoresearch/commands/run.ts +138 -0
  10. package/autoresearch/config.ts +82 -0
  11. package/autoresearch/engine.ts +359 -0
  12. package/autoresearch/eval-all.ts +127 -0
  13. package/autoresearch/eval-browse.ts +1 -1
  14. package/autoresearch/eval-publish.ts +238 -0
  15. package/autoresearch/eval-save.ts +249 -0
  16. package/autoresearch/eval-skill.ts +14 -8
  17. package/autoresearch/eval-v2ex.ts +220 -0
  18. package/autoresearch/eval-zhihu.ts +230 -0
  19. package/autoresearch/logger.ts +69 -0
  20. package/autoresearch/presets/combined-reliability.ts +27 -0
  21. package/autoresearch/presets/index.ts +23 -0
  22. package/autoresearch/presets/operate-reliability.ts +24 -0
  23. package/autoresearch/presets/save-reliability.ts +26 -0
  24. package/autoresearch/presets/skill-quality.ts +20 -0
  25. package/autoresearch/presets/v2ex-reliability.ts +24 -0
  26. package/autoresearch/presets/zhihu-reliability.ts +25 -0
  27. package/autoresearch/publish-tasks.json +345 -0
  28. package/autoresearch/run-save.sh +11 -0
  29. package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
  30. package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
  31. package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
  32. package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
  33. package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
  34. package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
  35. package/autoresearch/save-tasks.json +281 -0
  36. package/autoresearch/v2ex-tasks.json +899 -0
  37. package/autoresearch/zhihu-tasks.json +848 -0
  38. package/bun.lock +615 -0
  39. package/dist/browser/base-page.d.ts +4 -2
  40. package/dist/browser/base-page.js +37 -4
  41. package/dist/browser/bridge.js +10 -8
  42. package/dist/browser/cdp.js +2 -6
  43. package/dist/browser/daemon-client.d.ts +11 -1
  44. package/dist/browser/daemon-client.js +3 -0
  45. package/dist/browser/dom-helpers.d.ts +4 -2
  46. package/dist/browser/dom-helpers.js +42 -31
  47. package/dist/browser/dom-snapshot.js +23 -1
  48. package/dist/browser/page.d.ts +7 -2
  49. package/dist/browser/page.js +112 -30
  50. package/dist/browser.test.js +1 -1
  51. package/dist/build-manifest.d.ts +1 -0
  52. package/dist/build-manifest.js +1 -0
  53. package/dist/cli-manifest.json +1133 -182
  54. package/dist/cli.d.ts +2 -0
  55. package/dist/cli.js +48 -7
  56. package/dist/cli.test.d.ts +1 -0
  57. package/dist/cli.test.js +88 -0
  58. package/dist/clis/1688/item.d.ts +70 -0
  59. package/dist/clis/1688/item.js +187 -0
  60. package/dist/clis/1688/item.test.d.ts +1 -0
  61. package/dist/clis/1688/item.test.js +67 -0
  62. package/dist/clis/1688/search.d.ts +56 -0
  63. package/dist/clis/1688/search.js +309 -0
  64. package/dist/clis/1688/search.test.d.ts +1 -0
  65. package/dist/clis/1688/search.test.js +75 -0
  66. package/dist/clis/1688/shared.d.ts +112 -0
  67. package/dist/clis/1688/shared.js +514 -0
  68. package/dist/clis/1688/shared.test.d.ts +1 -0
  69. package/dist/clis/1688/shared.test.js +57 -0
  70. package/dist/clis/1688/store.d.ts +45 -0
  71. package/dist/clis/1688/store.js +226 -0
  72. package/dist/clis/1688/store.test.d.ts +1 -0
  73. package/dist/clis/1688/store.test.js +62 -0
  74. package/dist/clis/amazon/bestsellers.d.ts +0 -20
  75. package/dist/clis/amazon/bestsellers.js +6 -129
  76. package/dist/clis/amazon/bestsellers.test.js +12 -3
  77. package/dist/clis/amazon/movers-shakers.d.ts +1 -0
  78. package/dist/clis/amazon/movers-shakers.js +7 -0
  79. package/dist/clis/amazon/new-releases.d.ts +1 -0
  80. package/dist/clis/amazon/new-releases.js +7 -0
  81. package/dist/clis/amazon/rankings.d.ts +59 -0
  82. package/dist/clis/amazon/rankings.js +226 -0
  83. package/dist/clis/amazon/rankings.test.d.ts +1 -0
  84. package/dist/clis/amazon/rankings.test.js +41 -0
  85. package/dist/clis/amazon/shared.d.ts +11 -0
  86. package/dist/clis/amazon/shared.js +121 -11
  87. package/dist/clis/amazon/shared.test.js +11 -0
  88. package/dist/clis/bilibili/comments.js +2 -2
  89. package/dist/clis/bilibili/comments.test.js +3 -2
  90. package/dist/clis/bilibili/download.js +2 -1
  91. package/dist/clis/bilibili/subtitle.js +4 -3
  92. package/dist/clis/bilibili/subtitle.test.js +2 -1
  93. package/dist/clis/bilibili/utils.d.ts +5 -0
  94. package/dist/clis/bilibili/utils.js +30 -0
  95. package/dist/clis/bilibili/utils.test.d.ts +1 -0
  96. package/dist/clis/bilibili/utils.test.js +17 -0
  97. package/dist/clis/douban/marks.js +1 -1
  98. package/dist/clis/douban/subject.yaml +50 -19
  99. package/dist/clis/doubao/utils.js +32 -12
  100. package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
  101. package/dist/clis/douyin/_shared/transcode.test.js +0 -2
  102. package/dist/clis/douyin/draft.test.js +0 -2
  103. package/dist/clis/facebook/search.test.js +0 -2
  104. package/dist/clis/gemini/ask.js +9 -3
  105. package/dist/clis/gemini/ask.test.d.ts +1 -0
  106. package/dist/clis/gemini/ask.test.js +100 -0
  107. package/dist/clis/gemini/reply-state.test.d.ts +1 -0
  108. package/dist/clis/gemini/reply-state.test.js +641 -0
  109. package/dist/clis/gemini/utils.d.ts +44 -1
  110. package/dist/clis/gemini/utils.js +528 -61
  111. package/dist/clis/gemini/utils.test.js +149 -2
  112. package/dist/clis/hupu/detail.d.ts +1 -0
  113. package/dist/clis/hupu/detail.js +72 -0
  114. package/dist/clis/hupu/hot.yaml +43 -0
  115. package/dist/clis/hupu/like.d.ts +1 -0
  116. package/dist/clis/hupu/like.js +75 -0
  117. package/dist/clis/hupu/reply.d.ts +1 -0
  118. package/dist/clis/hupu/reply.js +71 -0
  119. package/dist/clis/hupu/search.d.ts +1 -0
  120. package/dist/clis/hupu/search.js +59 -0
  121. package/dist/clis/hupu/unlike.d.ts +1 -0
  122. package/dist/clis/hupu/unlike.js +75 -0
  123. package/dist/clis/hupu/utils.d.ts +20 -0
  124. package/dist/clis/hupu/utils.js +319 -0
  125. package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
  126. package/dist/clis/instagram/_shared/private-publish.js +1030 -0
  127. package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
  128. package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
  129. package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
  130. package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
  131. package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
  132. package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
  133. package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
  134. package/dist/clis/instagram/_shared/runtime-info.js +81 -0
  135. package/dist/clis/instagram/note.d.ts +1 -0
  136. package/dist/clis/instagram/note.js +222 -0
  137. package/dist/clis/instagram/note.test.d.ts +1 -0
  138. package/dist/clis/instagram/note.test.js +81 -0
  139. package/dist/clis/instagram/post.d.ts +4 -0
  140. package/dist/clis/instagram/post.js +1496 -0
  141. package/dist/clis/instagram/post.test.d.ts +1 -0
  142. package/dist/clis/instagram/post.test.js +1647 -0
  143. package/dist/clis/instagram/reel.d.ts +1 -0
  144. package/dist/clis/instagram/reel.js +826 -0
  145. package/dist/clis/instagram/reel.test.d.ts +1 -0
  146. package/dist/clis/instagram/reel.test.js +167 -0
  147. package/dist/clis/instagram/story.d.ts +1 -0
  148. package/dist/clis/instagram/story.js +115 -0
  149. package/dist/clis/instagram/story.test.d.ts +1 -0
  150. package/dist/clis/instagram/story.test.js +167 -0
  151. package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
  152. package/dist/clis/sinafinance/stock-rank.js +65 -0
  153. package/dist/clis/substack/utils.test.js +0 -2
  154. package/dist/clis/twitter/post.js +72 -45
  155. package/dist/clis/twitter/post.test.d.ts +1 -0
  156. package/dist/clis/twitter/post.test.js +116 -0
  157. package/dist/clis/twitter/reply.d.ts +12 -0
  158. package/dist/clis/twitter/reply.js +257 -35
  159. package/dist/clis/twitter/reply.test.d.ts +1 -0
  160. package/dist/clis/twitter/reply.test.js +151 -0
  161. package/dist/clis/twitter/search.js +67 -5
  162. package/dist/clis/twitter/search.test.js +83 -5
  163. package/dist/clis/xianyu/chat.d.ts +7 -0
  164. package/dist/clis/xianyu/chat.js +146 -0
  165. package/dist/clis/xianyu/chat.test.d.ts +1 -0
  166. package/dist/clis/xianyu/chat.test.js +15 -0
  167. package/dist/clis/xianyu/item.d.ts +7 -0
  168. package/dist/clis/xianyu/item.js +152 -0
  169. package/dist/clis/xianyu/item.test.d.ts +1 -0
  170. package/dist/clis/xianyu/item.test.js +56 -0
  171. package/dist/clis/xianyu/search.d.ts +10 -0
  172. package/dist/clis/xianyu/search.js +134 -0
  173. package/dist/clis/xianyu/search.test.d.ts +1 -0
  174. package/dist/clis/xianyu/search.test.js +17 -0
  175. package/dist/clis/xianyu/utils.d.ts +1 -0
  176. package/dist/clis/xianyu/utils.js +8 -0
  177. package/dist/clis/xiaoe/catalog.yaml +129 -0
  178. package/dist/clis/xiaoe/content.yaml +43 -0
  179. package/dist/clis/xiaoe/courses.yaml +73 -0
  180. package/dist/clis/xiaoe/detail.yaml +39 -0
  181. package/dist/clis/xiaoe/play-url.yaml +124 -0
  182. package/dist/clis/xiaohongshu/comments.test.js +0 -2
  183. package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
  184. package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
  185. package/dist/clis/xiaohongshu/download.test.js +0 -2
  186. package/dist/clis/xiaohongshu/note.test.js +0 -2
  187. package/dist/clis/xiaohongshu/publish.test.js +0 -2
  188. package/dist/clis/xiaohongshu/search.js +29 -20
  189. package/dist/clis/xiaohongshu/search.test.js +56 -48
  190. package/dist/clis/yuanbao/ask.d.ts +21 -0
  191. package/dist/clis/yuanbao/ask.js +427 -0
  192. package/dist/clis/yuanbao/ask.test.d.ts +1 -0
  193. package/dist/clis/yuanbao/ask.test.js +124 -0
  194. package/dist/clis/yuanbao/new.d.ts +1 -0
  195. package/dist/clis/yuanbao/new.js +70 -0
  196. package/dist/clis/yuanbao/new.test.d.ts +1 -0
  197. package/dist/clis/yuanbao/new.test.js +30 -0
  198. package/dist/clis/yuanbao/shared.d.ts +13 -0
  199. package/dist/clis/yuanbao/shared.js +49 -0
  200. package/dist/clis/zhihu/question.js +30 -19
  201. package/dist/clis/zhihu/question.test.js +34 -16
  202. package/dist/commanderAdapter.js +8 -4
  203. package/dist/commanderAdapter.test.js +42 -0
  204. package/dist/completion.js +3 -1
  205. package/dist/completion.test.d.ts +1 -0
  206. package/dist/completion.test.js +23 -0
  207. package/dist/doctor.js +1 -1
  208. package/dist/electron-apps.d.ts +2 -0
  209. package/dist/electron-apps.js +7 -1
  210. package/dist/errors.js +1 -1
  211. package/dist/execution.js +25 -35
  212. package/dist/explore.js +1 -1
  213. package/dist/launcher.d.ts +4 -0
  214. package/dist/launcher.js +64 -8
  215. package/dist/launcher.test.js +88 -7
  216. package/dist/output.d.ts +2 -0
  217. package/dist/output.js +10 -1
  218. package/dist/output.test.d.ts +0 -3
  219. package/dist/output.test.js +59 -92
  220. package/dist/pipeline/executor.test.js +0 -2
  221. package/dist/pipeline/steps/download.test.js +0 -2
  222. package/dist/registry.d.ts +2 -0
  223. package/dist/serialization.d.ts +1 -0
  224. package/dist/serialization.js +1 -0
  225. package/dist/types.d.ts +9 -2
  226. package/docs/.vitepress/config.mts +4 -0
  227. package/docs/adapters/browser/1688.md +52 -0
  228. package/docs/adapters/browser/36kr.md +2 -1
  229. package/docs/adapters/browser/doubao.md +5 -1
  230. package/docs/adapters/browser/hupu.md +53 -0
  231. package/docs/adapters/browser/sinafinance.md +32 -2
  232. package/docs/adapters/browser/weibo.md +6 -1
  233. package/docs/adapters/browser/wikipedia.md +2 -0
  234. package/docs/adapters/browser/xianyu.md +42 -0
  235. package/docs/adapters/browser/xiaoe.md +44 -0
  236. package/docs/adapters/browser/yuanbao.md +64 -0
  237. package/docs/adapters/index.md +14 -5
  238. package/docs/comparison.md +1 -1
  239. package/docs/developer/ai-workflow.md +2 -2
  240. package/docs/developer/contributing.md +1 -1
  241. package/docs/developer/testing.md +2 -0
  242. package/docs/guide/plugins.md +1 -0
  243. package/docs/guide/troubleshooting.md +11 -0
  244. package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
  245. package/docs/zh/guide/plugins.md +1 -0
  246. package/extension/dist/background.js +1127 -0
  247. package/extension/src/background.test.ts +39 -0
  248. package/extension/src/background.ts +223 -34
  249. package/extension/src/cdp.ts +194 -4
  250. package/extension/src/protocol.ts +22 -1
  251. package/package.json +3 -2
  252. package/scripts/postinstall.js +1 -1
  253. package/skills/opencli-explorer/SKILL.md +1 -1
  254. package/skills/opencli-oneshot/SKILL.md +2 -2
  255. package/skills/opencli-operate/SKILL.md +120 -27
  256. package/skills/opencli-usage/SKILL.md +31 -20
  257. package/skills/opencli-usage/browser.md +114 -16
  258. package/skills/opencli-usage/public-api.md +32 -3
  259. package/skills/smart-search/SKILL.md +156 -0
  260. package/skills/smart-search/references/sources-ai.md +74 -0
  261. package/skills/smart-search/references/sources-info.md +43 -0
  262. package/skills/smart-search/references/sources-media.md +50 -0
  263. package/skills/smart-search/references/sources-other.md +42 -0
  264. package/skills/smart-search/references/sources-shopping.md +31 -0
  265. package/skills/smart-search/references/sources-social.md +51 -0
  266. package/skills/smart-search/references/sources-tech.md +42 -0
  267. package/skills/smart-search/references/sources-travel.md +20 -0
  268. package/src/browser/base-page.ts +41 -6
  269. package/src/browser/bridge.ts +11 -8
  270. package/src/browser/cdp.ts +1 -8
  271. package/src/browser/daemon-client.ts +11 -1
  272. package/src/browser/dom-helpers.ts +43 -31
  273. package/src/browser/dom-snapshot.ts +23 -1
  274. package/src/browser/page.ts +115 -31
  275. package/src/browser.test.ts +1 -1
  276. package/src/build-manifest.ts +2 -0
  277. package/src/cli.test.ts +133 -0
  278. package/src/cli.ts +73 -11
  279. package/src/clis/1688/item.test.ts +69 -0
  280. package/src/clis/1688/item.ts +282 -0
  281. package/src/clis/1688/search.test.ts +81 -0
  282. package/src/clis/1688/search.ts +402 -0
  283. package/src/clis/1688/shared.test.ts +75 -0
  284. package/src/clis/1688/shared.ts +623 -0
  285. package/src/clis/1688/store.test.ts +69 -0
  286. package/src/clis/1688/store.ts +300 -0
  287. package/src/clis/amazon/bestsellers.test.ts +12 -3
  288. package/src/clis/amazon/bestsellers.ts +6 -178
  289. package/src/clis/amazon/movers-shakers.ts +8 -0
  290. package/src/clis/amazon/new-releases.ts +8 -0
  291. package/src/clis/amazon/rankings.test.ts +47 -0
  292. package/src/clis/amazon/rankings.ts +312 -0
  293. package/src/clis/amazon/shared.test.ts +16 -0
  294. package/src/clis/amazon/shared.ts +134 -12
  295. package/src/clis/bilibili/comments.test.ts +4 -3
  296. package/src/clis/bilibili/comments.ts +2 -2
  297. package/src/clis/bilibili/download.ts +2 -1
  298. package/src/clis/bilibili/subtitle.test.ts +2 -1
  299. package/src/clis/bilibili/subtitle.ts +4 -3
  300. package/src/clis/bilibili/utils.test.ts +21 -0
  301. package/src/clis/bilibili/utils.ts +27 -0
  302. package/src/clis/douban/marks.ts +1 -1
  303. package/src/clis/douban/subject.yaml +50 -19
  304. package/src/clis/doubao/utils.ts +32 -12
  305. package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
  306. package/src/clis/douyin/_shared/transcode.test.ts +0 -2
  307. package/src/clis/douyin/draft.test.ts +0 -2
  308. package/src/clis/facebook/search.test.ts +0 -2
  309. package/src/clis/gemini/ask.test.ts +116 -0
  310. package/src/clis/gemini/ask.ts +10 -3
  311. package/src/clis/gemini/reply-state.test.ts +708 -0
  312. package/src/clis/gemini/utils.test.ts +184 -2
  313. package/src/clis/gemini/utils.ts +588 -60
  314. package/src/clis/hupu/detail.ts +126 -0
  315. package/src/clis/hupu/hot.yaml +43 -0
  316. package/src/clis/hupu/like.ts +76 -0
  317. package/src/clis/hupu/reply.ts +76 -0
  318. package/src/clis/hupu/search.ts +95 -0
  319. package/src/clis/hupu/unlike.ts +76 -0
  320. package/src/clis/hupu/utils.ts +381 -0
  321. package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
  322. package/src/clis/instagram/_shared/private-publish.ts +1303 -0
  323. package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
  324. package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
  325. package/src/clis/instagram/_shared/runtime-info.ts +91 -0
  326. package/src/clis/instagram/note.test.ts +96 -0
  327. package/src/clis/instagram/note.ts +254 -0
  328. package/src/clis/instagram/post.test.ts +1716 -0
  329. package/src/clis/instagram/post.ts +1620 -0
  330. package/src/clis/instagram/reel.test.ts +191 -0
  331. package/src/clis/instagram/reel.ts +886 -0
  332. package/src/clis/instagram/story.test.ts +191 -0
  333. package/src/clis/instagram/story.ts +151 -0
  334. package/src/clis/sinafinance/stock-rank.ts +68 -0
  335. package/src/clis/substack/utils.test.ts +0 -2
  336. package/src/clis/twitter/post.test.ts +157 -0
  337. package/src/clis/twitter/post.ts +82 -48
  338. package/src/clis/twitter/reply.test.ts +177 -0
  339. package/src/clis/twitter/reply.ts +285 -39
  340. package/src/clis/twitter/search.test.ts +88 -5
  341. package/src/clis/twitter/search.ts +68 -5
  342. package/src/clis/xianyu/chat.test.ts +20 -0
  343. package/src/clis/xianyu/chat.ts +175 -0
  344. package/src/clis/xianyu/item.test.ts +67 -0
  345. package/src/clis/xianyu/item.ts +172 -0
  346. package/src/clis/xianyu/search.test.ts +22 -0
  347. package/src/clis/xianyu/search.ts +151 -0
  348. package/src/clis/xianyu/utils.ts +9 -0
  349. package/src/clis/xiaoe/catalog.yaml +129 -0
  350. package/src/clis/xiaoe/content.yaml +43 -0
  351. package/src/clis/xiaoe/courses.yaml +73 -0
  352. package/src/clis/xiaoe/detail.yaml +39 -0
  353. package/src/clis/xiaoe/play-url.yaml +124 -0
  354. package/src/clis/xiaohongshu/comments.test.ts +0 -2
  355. package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
  356. package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
  357. package/src/clis/xiaohongshu/download.test.ts +0 -2
  358. package/src/clis/xiaohongshu/note.test.ts +0 -2
  359. package/src/clis/xiaohongshu/publish.test.ts +0 -2
  360. package/src/clis/xiaohongshu/search.test.ts +59 -48
  361. package/src/clis/xiaohongshu/search.ts +31 -21
  362. package/src/clis/yuanbao/ask.test.ts +156 -0
  363. package/src/clis/yuanbao/ask.ts +522 -0
  364. package/src/clis/yuanbao/new.test.ts +36 -0
  365. package/src/clis/yuanbao/new.ts +81 -0
  366. package/src/clis/yuanbao/shared.ts +57 -0
  367. package/src/clis/zhihu/question.test.ts +42 -17
  368. package/src/clis/zhihu/question.ts +31 -26
  369. package/src/commanderAdapter.test.ts +51 -0
  370. package/src/commanderAdapter.ts +8 -4
  371. package/src/completion.test.ts +30 -0
  372. package/src/completion.ts +3 -1
  373. package/src/doctor.ts +1 -1
  374. package/src/electron-apps.ts +9 -1
  375. package/src/errors.ts +1 -1
  376. package/src/execution.ts +26 -30
  377. package/src/explore.ts +1 -1
  378. package/src/launcher.test.ts +121 -7
  379. package/src/launcher.ts +87 -9
  380. package/src/output.test.ts +50 -90
  381. package/src/output.ts +10 -1
  382. package/src/pipeline/executor.test.ts +0 -2
  383. package/src/pipeline/steps/download.test.ts +0 -2
  384. package/src/registry.ts +2 -0
  385. package/src/serialization.ts +2 -0
  386. package/src/types.ts +9 -2
  387. package/tests/e2e/browser-auth.test.ts +9 -0
  388. package/CLI-EXPLORER.md +0 -724
  389. package/CLI-ONESHOT.md +0 -216
  390. package/SKILL.md +0 -59
@@ -1,7 +1,33 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
1
3
  import { cli, Strategy } from '../../registry.js';
2
4
  import { CommandExecutionError } from '../../errors.js';
3
5
  import type { IPage } from '../../types.js';
4
6
 
7
+ const MAX_IMAGES = 4;
8
+ const UPLOAD_POLL_MS = 500;
9
+ const UPLOAD_TIMEOUT_MS = 30_000;
10
+ const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
11
+
12
+ function validateImagePaths(raw: string): string[] {
13
+ const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
14
+ if (paths.length > MAX_IMAGES) {
15
+ throw new CommandExecutionError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
16
+ }
17
+ return paths.map(p => {
18
+ const absPath = path.resolve(p);
19
+ const ext = path.extname(absPath).toLowerCase();
20
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
21
+ throw new CommandExecutionError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
22
+ }
23
+ const stat = fs.statSync(absPath, { throwIfNoEntry: false } as any);
24
+ if (!stat || !stat.isFile()) {
25
+ throw new CommandExecutionError(`Not a valid file: ${absPath}`);
26
+ }
27
+ return absPath;
28
+ });
29
+ }
30
+
5
31
  cli({
6
32
  site: 'twitter',
7
33
  name: 'post',
@@ -11,66 +37,74 @@ cli({
11
37
  browser: true,
12
38
  args: [
13
39
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
40
+ { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
14
41
  ],
15
42
  columns: ['status', 'message', 'text'],
16
43
  func: async (page: IPage | null, kwargs: any) => {
17
44
  if (!page) throw new CommandExecutionError('Browser session required for twitter post');
18
45
 
19
- // 1. Navigate directly to the compose tweet modal
46
+ // Validate images upfront before any browser interaction
47
+ const absPaths = kwargs.images ? validateImagePaths(String(kwargs.images)) : [];
48
+
49
+ // 1. Navigate to compose modal
20
50
  await page.goto('https://x.com/compose/tweet');
21
- await page.wait(3); // Wait for the modal and React app to hydrate
51
+ await page.wait(3);
22
52
 
23
- // 2. Automate typing and clicking
24
- const result = await page.evaluate(`(async () => {
53
+ // 2. Type the text via clipboard paste (handles newlines in Draft.js)
54
+ const typeResult = await page.evaluate(`(async () => {
25
55
  try {
26
- // Find the active text area
27
56
  const box = document.querySelector('[data-testid="tweetTextarea_0"]');
28
- if (box) {
29
- box.focus();
30
- // Simulate a paste event to properly handle newlines in Draft.js/React
31
- const textToInsert = ${JSON.stringify(kwargs.text)};
32
- const dataTransfer = new DataTransfer();
33
- dataTransfer.setData('text/plain', textToInsert);
34
- box.dispatchEvent(new ClipboardEvent('paste', {
35
- clipboardData: dataTransfer,
36
- bubbles: true,
37
- cancelable: true
38
- }));
39
- } else {
40
- return { ok: false, message: 'Could not find the tweet composer text area.' };
41
- }
42
-
43
- // Wait a brief moment for the button state to update
44
- await new Promise(r => setTimeout(r, 1000));
45
-
46
- // Click the post button
47
- const btn = document.querySelector('[data-testid="tweetButton"]');
48
- if (btn && !btn.disabled) {
49
- btn.click();
50
- return { ok: true, message: 'Tweet posted successfully.' };
51
- } else {
52
- // Sometimes it's rendered inline depending on the viewport
53
- const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
54
- if (inlineBtn && !inlineBtn.disabled) {
55
- inlineBtn.click();
56
- return { ok: true, message: 'Tweet posted successfully.' };
57
- }
58
- return { ok: false, message: 'Tweet button is disabled or not found.' };
59
- }
60
- } catch (e) {
61
- return { ok: false, message: e.toString() };
62
- }
57
+ if (!box) return { ok: false, message: 'Could not find the tweet composer text area.' };
58
+ box.focus();
59
+ const dt = new DataTransfer();
60
+ dt.setData('text/plain', ${JSON.stringify(kwargs.text)});
61
+ box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
62
+ return { ok: true };
63
+ } catch (e) { return { ok: false, message: String(e) }; }
63
64
  })()`);
64
65
 
65
- // 3. Wait a few seconds for the network request to finish sending
66
- if (result.ok) {
67
- await page.wait(3);
66
+ if (!typeResult.ok) {
67
+ return [{ status: 'failed', message: typeResult.message, text: kwargs.text }];
68
+ }
69
+
70
+ // 3. Attach images if provided
71
+ if (absPaths.length > 0) {
72
+ if (!page.setFileInput) {
73
+ throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
74
+ }
75
+ await page.setFileInput(absPaths, 'input[data-testid="fileInput"]');
76
+
77
+ // Poll until attachments render and tweet button is enabled
78
+ const pollIterations = Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS);
79
+ const uploaded = await page.evaluate(`(async () => {
80
+ for (let i = 0; i < ${JSON.stringify(pollIterations)}; i++) {
81
+ await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)}));
82
+ const container = document.querySelector('[data-testid="attachments"]');
83
+ if (!container) continue;
84
+ if (container.querySelectorAll('[role="group"]').length !== ${JSON.stringify(absPaths.length)}) continue;
85
+ const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
86
+ if (btn && !btn.disabled) return true;
87
+ }
88
+ return false;
89
+ })()`);
90
+
91
+ if (!uploaded) {
92
+ return [{ status: 'failed', message: `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text: kwargs.text }];
93
+ }
68
94
  }
69
95
 
70
- return [{
71
- status: result.ok ? 'success' : 'failed',
72
- message: result.message,
73
- text: kwargs.text
74
- }];
96
+ // 4. Click the post button
97
+ await page.wait(1);
98
+ const result = await page.evaluate(`(async () => {
99
+ try {
100
+ const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
101
+ if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; }
102
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
103
+ } catch (e) { return { ok: false, message: String(e) }; }
104
+ })()`);
105
+
106
+ if (result.ok) await page.wait(3);
107
+
108
+ return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text }];
75
109
  }
76
110
  });
@@ -0,0 +1,177 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ import { getRegistry } from '../../registry.js';
8
+ import type { IPage } from '../../types.js';
9
+ import { __test__ } from './reply.js';
10
+
11
+ function createPageMock(evaluateResults: any[], overrides: Partial<IPage> = {}): IPage {
12
+ const evaluate = vi.fn();
13
+ for (const result of evaluateResults) {
14
+ evaluate.mockResolvedValueOnce(result);
15
+ }
16
+
17
+ return {
18
+ goto: vi.fn().mockResolvedValue(undefined),
19
+ evaluate,
20
+ snapshot: vi.fn().mockResolvedValue(undefined),
21
+ click: vi.fn().mockResolvedValue(undefined),
22
+ typeText: vi.fn().mockResolvedValue(undefined),
23
+ pressKey: vi.fn().mockResolvedValue(undefined),
24
+ scrollTo: vi.fn().mockResolvedValue(undefined),
25
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
26
+ wait: vi.fn().mockResolvedValue(undefined),
27
+ tabs: vi.fn().mockResolvedValue([]),
28
+ selectTab: vi.fn().mockResolvedValue(undefined),
29
+ networkRequests: vi.fn().mockResolvedValue([]),
30
+ consoleMessages: vi.fn().mockResolvedValue([]),
31
+ scroll: vi.fn().mockResolvedValue(undefined),
32
+ autoScroll: vi.fn().mockResolvedValue(undefined),
33
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
34
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
35
+ getCookies: vi.fn().mockResolvedValue([]),
36
+ screenshot: vi.fn().mockResolvedValue(''),
37
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe('twitter reply command', () => {
43
+ it('keeps the text-only reply flow working', async () => {
44
+ const cmd = getRegistry().get('twitter/reply');
45
+ expect(cmd?.func).toBeTypeOf('function');
46
+
47
+ const page = createPageMock([
48
+ { ok: true, message: 'Reply posted successfully.' },
49
+ ]);
50
+
51
+ const result = await cmd!.func!(page, {
52
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
53
+ text: 'text-only reply',
54
+ });
55
+
56
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/_kop6/status/2040254679301718161?s=20');
57
+ expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="primaryColumn"]' });
58
+ expect(result).toEqual([
59
+ {
60
+ status: 'success',
61
+ message: 'Reply posted successfully.',
62
+ text: 'text-only reply',
63
+ },
64
+ ]);
65
+ });
66
+
67
+ it('uploads a local image through the dedicated reply composer when --image is provided', async () => {
68
+ const cmd = getRegistry().get('twitter/reply');
69
+ expect(cmd?.func).toBeTypeOf('function');
70
+
71
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
72
+ const imagePath = path.join(tempDir, 'qr.png');
73
+ fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
74
+
75
+ const setFileInput = vi.fn().mockResolvedValue(undefined);
76
+ const page = createPageMock([
77
+ { ok: true, previewCount: 1 },
78
+ { ok: true, message: 'Reply posted successfully.' },
79
+ ], {
80
+ setFileInput,
81
+ });
82
+
83
+ const result = await cmd!.func!(page, {
84
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
85
+ text: 'reply with image',
86
+ image: imagePath,
87
+ });
88
+
89
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161');
90
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]' });
91
+ expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
92
+ expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
93
+ expect(result).toEqual([
94
+ {
95
+ status: 'success',
96
+ message: 'Reply posted successfully.',
97
+ text: 'reply with image',
98
+ image: imagePath,
99
+ },
100
+ ]);
101
+ });
102
+
103
+ it('downloads a remote image before uploading when --image-url is provided', async () => {
104
+ const cmd = getRegistry().get('twitter/reply');
105
+ expect(cmd?.func).toBeTypeOf('function');
106
+
107
+ const fetchMock = vi.fn().mockResolvedValue({
108
+ ok: true,
109
+ headers: {
110
+ get: vi.fn().mockReturnValue('image/png'),
111
+ },
112
+ arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer),
113
+ });
114
+ vi.stubGlobal('fetch', fetchMock);
115
+
116
+ const setFileInput = vi.fn().mockResolvedValue(undefined);
117
+ const page = createPageMock([
118
+ { ok: true, previewCount: 1 },
119
+ { ok: true, message: 'Reply posted successfully.' },
120
+ ], {
121
+ setFileInput,
122
+ });
123
+
124
+ const result = await cmd!.func!(page, {
125
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
126
+ text: 'reply with remote image',
127
+ 'image-url': 'https://example.com/qr',
128
+ });
129
+
130
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/qr');
131
+ expect(setFileInput).toHaveBeenCalledTimes(1);
132
+ const uploadedPath = setFileInput.mock.calls[0][0][0];
133
+ expect(uploadedPath).toMatch(/opencli-twitter-reply-.*\/image\.png$/);
134
+ expect(fs.existsSync(uploadedPath)).toBe(false);
135
+ expect(result).toEqual([
136
+ {
137
+ status: 'success',
138
+ message: 'Reply posted successfully.',
139
+ text: 'reply with remote image',
140
+ 'image-url': 'https://example.com/qr',
141
+ },
142
+ ]);
143
+
144
+ vi.unstubAllGlobals();
145
+ });
146
+
147
+ it('rejects invalid image paths early', async () => {
148
+ await expect(() => __test__.resolveImagePath('/tmp/does-not-exist.png'))
149
+ .toThrow('Image file not found');
150
+ });
151
+
152
+ it('rejects using --image and --image-url together', async () => {
153
+ const cmd = getRegistry().get('twitter/reply');
154
+ expect(cmd?.func).toBeTypeOf('function');
155
+
156
+ const page = createPageMock([]);
157
+
158
+ await expect(cmd!.func!(page, {
159
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
160
+ text: 'nope',
161
+ image: '/tmp/a.png',
162
+ 'image-url': 'https://example.com/a.png',
163
+ })).rejects.toThrow('Use either --image or --image-url, not both.');
164
+ });
165
+
166
+ it('extracts tweet ids from both user and i/status URLs', () => {
167
+ expect(__test__.extractTweetId('https://x.com/_kop6/status/2040254679301718161?s=20')).toBe('2040254679301718161');
168
+ expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
169
+ expect(__test__.buildReplyComposerUrl('https://x.com/i/status/2040318731105313143'))
170
+ .toBe('https://x.com/compose/post?in_reply_to=2040318731105313143');
171
+ });
172
+
173
+ it('prefers content-type when resolving remote image extensions', () => {
174
+ expect(__test__.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp');
175
+ expect(__test__.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg');
176
+ });
177
+ });
@@ -1,63 +1,309 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+
1
5
  import { CommandExecutionError } from '../../errors.js';
2
6
  import { cli, Strategy } from '../../registry.js';
3
7
  import type { IPage } from '../../types.js';
4
8
 
9
+ const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
10
+ const SUPPORTED_IMAGE_EXTENSIONS = new Set([
11
+ '.jpg',
12
+ '.jpeg',
13
+ '.png',
14
+ '.gif',
15
+ '.webp',
16
+ ]);
17
+ const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
18
+ const CONTENT_TYPE_TO_EXTENSION: Record<string, string> = {
19
+ 'image/jpeg': '.jpg',
20
+ 'image/jpg': '.jpg',
21
+ 'image/png': '.png',
22
+ 'image/gif': '.gif',
23
+ 'image/webp': '.webp',
24
+ };
25
+
26
+ function resolveImagePath(imagePath: string): string {
27
+ const absPath = path.resolve(imagePath);
28
+ if (!fs.existsSync(absPath)) {
29
+ throw new Error(`Image file not found: ${absPath}`);
30
+ }
31
+
32
+ const ext = path.extname(absPath).toLowerCase();
33
+ if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
34
+ throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
35
+ }
36
+
37
+ const stat = fs.statSync(absPath);
38
+ if (stat.size > MAX_IMAGE_SIZE_BYTES) {
39
+ throw new Error(
40
+ `Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`
41
+ );
42
+ }
43
+
44
+ return absPath;
45
+ }
46
+
47
+ function extractTweetId(url: string): string {
48
+ let pathname = '';
49
+ try {
50
+ pathname = new URL(url).pathname;
51
+ } catch {
52
+ throw new Error(`Invalid tweet URL: ${url}`);
53
+ }
54
+
55
+ const match = pathname.match(/\/status\/(\d+)/);
56
+ if (!match?.[1]) {
57
+ throw new Error(`Could not extract tweet ID from URL: ${url}`);
58
+ }
59
+
60
+ return match[1];
61
+ }
62
+
63
+ function buildReplyComposerUrl(url: string): string {
64
+ return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
65
+ }
66
+
67
+ function resolveImageExtension(url: string, contentType: string | null): string {
68
+ const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
69
+ if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
70
+ return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
71
+ }
72
+
73
+ try {
74
+ const pathname = new URL(url).pathname;
75
+ const ext = path.extname(pathname).toLowerCase();
76
+ if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) return ext;
77
+ } catch {
78
+ // Fall through to the final error below.
79
+ }
80
+
81
+ throw new Error(
82
+ `Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
83
+ 'Supported: jpg, jpeg, png, gif, webp'
84
+ );
85
+ }
86
+
87
+ async function downloadRemoteImage(imageUrl: string): Promise<string> {
88
+ let parsed: URL;
89
+ try {
90
+ parsed = new URL(imageUrl);
91
+ } catch {
92
+ throw new Error(`Invalid image URL: ${imageUrl}`);
93
+ }
94
+
95
+ if (!/^https?:$/.test(parsed.protocol)) {
96
+ throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
97
+ }
98
+
99
+ const response = await fetch(imageUrl);
100
+ if (!response.ok) {
101
+ throw new Error(`Image download failed: HTTP ${response.status}`);
102
+ }
103
+
104
+ const contentLength = Number(response.headers.get('content-length') || '0');
105
+ if (contentLength > MAX_IMAGE_SIZE_BYTES) {
106
+ throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
107
+ }
108
+
109
+ const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
110
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
111
+ const tmpPath = path.join(tmpDir, `image${ext}`);
112
+ const buffer = Buffer.from(await response.arrayBuffer());
113
+ if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
114
+ fs.rmSync(tmpDir, { recursive: true, force: true });
115
+ throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
116
+ }
117
+ fs.writeFileSync(tmpPath, buffer);
118
+ return tmpPath;
119
+ }
120
+
121
+ async function attachReplyImage(page: IPage, absImagePath: string): Promise<void> {
122
+ let uploaded = false;
123
+ if (page.setFileInput) {
124
+ try {
125
+ await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
126
+ uploaded = true;
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
130
+ throw new Error(`Image upload failed: ${msg}`);
131
+ }
132
+ // setFileInput not supported by extension — fall through to base64 fallback
133
+ }
134
+ }
135
+
136
+ if (!uploaded) {
137
+ const ext = path.extname(absImagePath).toLowerCase();
138
+ const mimeType = ext === '.png'
139
+ ? 'image/png'
140
+ : ext === '.gif'
141
+ ? 'image/gif'
142
+ : ext === '.webp'
143
+ ? 'image/webp'
144
+ : 'image/jpeg';
145
+ const base64 = fs.readFileSync(absImagePath).toString('base64');
146
+ if (base64.length > 500_000) {
147
+ console.warn(
148
+ `[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
149
+ 'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
150
+ 'or compress the image before attaching.'
151
+ );
152
+ }
153
+ const upload = await page.evaluate(`
154
+ (() => {
155
+ const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
156
+ if (!input) return { ok: false, error: 'No file input found on page' };
157
+
158
+ const binary = atob(${JSON.stringify(base64)});
159
+ const bytes = new Uint8Array(binary.length);
160
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
161
+
162
+ const dt = new DataTransfer();
163
+ const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
164
+ dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
165
+
166
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false });
167
+ input.dispatchEvent(new Event('change', { bubbles: true }));
168
+ input.dispatchEvent(new Event('input', { bubbles: true }));
169
+ return { ok: true };
170
+ })()
171
+ `);
172
+
173
+ if (!upload?.ok) {
174
+ throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
175
+ }
176
+ }
177
+
178
+ await page.wait(2);
179
+ const uploadState = await page.evaluate(`
180
+ (() => {
181
+ const previewCount = document.querySelectorAll(
182
+ '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
183
+ ).length;
184
+ const hasMedia = previewCount > 0
185
+ || !!document.querySelector('[data-testid="attachments"]')
186
+ || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
187
+ /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
188
+ );
189
+ return { ok: hasMedia, previewCount };
190
+ })()
191
+ `);
192
+
193
+ if (!uploadState?.ok) {
194
+ throw new Error('Image upload failed: preview did not appear.');
195
+ }
196
+ }
197
+
198
+ async function submitReply(page: IPage, text: string): Promise<{ ok: boolean; message: string }> {
199
+ return page.evaluate(`(async () => {
200
+ try {
201
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
202
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
203
+ const box = boxes.find(visible) || boxes[0];
204
+ if (!box) {
205
+ return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
206
+ }
207
+
208
+ box.focus();
209
+ const textToInsert = ${JSON.stringify(text)};
210
+ // execCommand('insertText') is more reliable with Twitter's Draft.js editor
211
+ if (!document.execCommand('insertText', false, textToInsert)) {
212
+ // Fallback to paste event if execCommand fails
213
+ const dataTransfer = new DataTransfer();
214
+ dataTransfer.setData('text/plain', textToInsert);
215
+ box.dispatchEvent(new ClipboardEvent('paste', {
216
+ clipboardData: dataTransfer,
217
+ bubbles: true,
218
+ cancelable: true
219
+ }));
220
+ }
221
+
222
+ await new Promise(r => setTimeout(r, 1000));
223
+
224
+ const buttons = Array.from(
225
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
226
+ );
227
+ const btn = buttons.find((el) => visible(el) && !el.disabled);
228
+ if (!btn) {
229
+ return { ok: false, message: 'Reply button is disabled or not found.' };
230
+ }
231
+
232
+ btn.click();
233
+ return { ok: true, message: 'Reply posted successfully.' };
234
+ } catch (e) {
235
+ return { ok: false, message: e.toString() };
236
+ }
237
+ })()`);
238
+ }
239
+
5
240
  cli({
6
241
  site: 'twitter',
7
242
  name: 'reply',
8
- description: 'Reply to a specific tweet',
243
+ description: 'Reply to a specific tweet, optionally with a local or remote image',
9
244
  domain: 'x.com',
10
245
  strategy: Strategy.UI, // Uses the UI directly to input and click post
11
246
  browser: true,
12
247
  args: [
13
248
  { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' },
14
249
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' },
250
+ { name: 'image', help: 'Optional local image path to attach to the reply' },
251
+ { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
15
252
  ],
16
253
  columns: ['status', 'message', 'text'],
17
254
  func: async (page: IPage | null, kwargs: any) => {
18
255
  if (!page) throw new CommandExecutionError('Browser session required for twitter reply');
256
+ if (kwargs.image && kwargs['image-url']) {
257
+ throw new Error('Use either --image or --image-url, not both.');
258
+ }
259
+
260
+ let localImagePath: string | undefined;
261
+ let cleanupDir: string | undefined;
262
+ try {
263
+ if (kwargs.image) {
264
+ localImagePath = resolveImagePath(kwargs.image);
265
+ } else if (kwargs['image-url']) {
266
+ localImagePath = await downloadRemoteImage(kwargs['image-url']);
267
+ cleanupDir = path.dirname(localImagePath);
268
+ }
269
+
270
+ // Dedicated composer is more reliable for image replies because the media
271
+ // toolbar and file input are consistently present there.
272
+ if (localImagePath) {
273
+ await page.goto(buildReplyComposerUrl(kwargs.url));
274
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
275
+ await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
276
+ await attachReplyImage(page, localImagePath);
277
+ } else {
278
+ await page.goto(kwargs.url);
279
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
280
+ }
281
+
282
+ const result = await submitReply(page, kwargs.text);
19
283
 
20
- // 1. Navigate to the tweet page
21
- await page.goto(kwargs.url);
22
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
-
24
- // 2. Automate typing the reply and clicking reply
25
- const result = await page.evaluate(`(async () => {
26
- try {
27
- // Find the reply text area on the tweet page.
28
- // The placeholder is usually "Post your reply"
29
- const box = document.querySelector('[data-testid="tweetTextarea_0"]');
30
- if (box) {
31
- box.focus();
32
- document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
33
- } else {
34
- return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
35
- }
36
-
37
- // Wait for React state to register the input and enable the button
38
- await new Promise(r => setTimeout(r, 1000));
39
-
40
- // Find the Reply button. It usually shares the same test id tweetButtonInline in this context
41
- const btn = document.querySelector('[data-testid="tweetButtonInline"]');
42
- if (btn && !btn.disabled) {
43
- btn.click();
44
- return { ok: true, message: 'Reply posted successfully.' };
45
- } else {
46
- return { ok: false, message: 'Reply button is disabled or not found.' };
47
- }
48
- } catch (e) {
49
- return { ok: false, message: e.toString() };
50
- }
51
- })()`);
52
-
53
- if (result.ok) {
284
+ if (result.ok) {
54
285
  await page.wait(3); // Wait for network submission to complete
55
- }
286
+ }
56
287
 
57
- return [{
288
+ return [{
58
289
  status: result.ok ? 'success' : 'failed',
59
290
  message: result.message,
60
- text: kwargs.text
61
- }];
291
+ text: kwargs.text,
292
+ ...(kwargs.image ? { image: kwargs.image } : {}),
293
+ ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
294
+ }];
295
+ } finally {
296
+ if (cleanupDir) {
297
+ fs.rmSync(cleanupDir, { recursive: true, force: true });
298
+ }
299
+ }
62
300
  }
63
301
  });
302
+
303
+ export const __test__ = {
304
+ buildReplyComposerUrl,
305
+ downloadRemoteImage,
306
+ extractTweetId,
307
+ resolveImageExtension,
308
+ resolveImagePath,
309
+ };