@jackwener/opencli 0.9.6 → 1.0.0

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 (307) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +83 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +42 -0
  4. package/.github/ISSUE_TEMPLATE/new_site_adapter.yml +57 -0
  5. package/.github/dependabot.yml +27 -0
  6. package/.github/pull_request_template.md +24 -0
  7. package/.github/workflows/ci.yml +14 -8
  8. package/.github/workflows/e2e-headed.yml +6 -2
  9. package/.github/workflows/pkg-pr-new.yml +2 -2
  10. package/.github/workflows/release-please.yml +25 -0
  11. package/.github/workflows/release.yml +2 -2
  12. package/.github/workflows/security.yml +36 -0
  13. package/CDP.md +1 -1
  14. package/CDP.zh-CN.md +1 -1
  15. package/CLI-ELECTRON.md +89 -36
  16. package/CLI-EXPLORER.md +4 -4
  17. package/CONTRIBUTING.md +167 -0
  18. package/README.md +113 -89
  19. package/README.zh-CN.md +114 -91
  20. package/SKILL.md +10 -8
  21. package/TESTING.md +7 -7
  22. package/dist/browser/daemon-client.d.ts +37 -0
  23. package/dist/browser/daemon-client.js +82 -0
  24. package/dist/browser/discover.d.ts +11 -34
  25. package/dist/browser/discover.js +15 -190
  26. package/dist/browser/errors.d.ts +6 -20
  27. package/dist/browser/errors.js +24 -63
  28. package/dist/browser/index.d.ts +2 -11
  29. package/dist/browser/index.js +5 -11
  30. package/dist/browser/mcp.d.ts +9 -18
  31. package/dist/browser/mcp.js +70 -284
  32. package/dist/browser/page.d.ts +28 -6
  33. package/dist/browser/page.js +210 -85
  34. package/dist/browser.test.js +4 -202
  35. package/dist/build-manifest.d.ts +26 -0
  36. package/dist/build-manifest.js +132 -60
  37. package/dist/build-manifest.test.d.ts +1 -0
  38. package/dist/build-manifest.test.js +26 -0
  39. package/dist/cli-manifest.json +1582 -29
  40. package/dist/clis/bilibili/download.d.ts +10 -0
  41. package/dist/clis/bilibili/download.js +135 -0
  42. package/dist/clis/chatwise/ask.d.ts +1 -0
  43. package/dist/clis/chatwise/ask.js +76 -0
  44. package/dist/clis/chatwise/export.d.ts +1 -0
  45. package/dist/clis/chatwise/export.js +46 -0
  46. package/dist/clis/chatwise/history.d.ts +1 -0
  47. package/dist/clis/chatwise/history.js +43 -0
  48. package/dist/clis/chatwise/model.d.ts +1 -0
  49. package/dist/clis/chatwise/model.js +81 -0
  50. package/dist/clis/chatwise/new.d.ts +1 -0
  51. package/dist/clis/chatwise/new.js +18 -0
  52. package/dist/clis/chatwise/read.d.ts +1 -0
  53. package/dist/clis/chatwise/read.js +39 -0
  54. package/dist/clis/chatwise/screenshot.d.ts +1 -0
  55. package/dist/clis/chatwise/screenshot.js +27 -0
  56. package/dist/clis/chatwise/send.d.ts +1 -0
  57. package/dist/clis/chatwise/send.js +45 -0
  58. package/dist/clis/chatwise/status.d.ts +1 -0
  59. package/dist/clis/chatwise/status.js +22 -0
  60. package/dist/clis/discord-app/channels.d.ts +1 -0
  61. package/dist/clis/discord-app/channels.js +45 -0
  62. package/dist/clis/discord-app/members.d.ts +1 -0
  63. package/dist/clis/discord-app/members.js +38 -0
  64. package/dist/clis/discord-app/read.d.ts +1 -0
  65. package/dist/clis/discord-app/read.js +45 -0
  66. package/dist/clis/discord-app/search.d.ts +1 -0
  67. package/dist/clis/discord-app/search.js +56 -0
  68. package/dist/clis/discord-app/send.d.ts +1 -0
  69. package/dist/clis/discord-app/send.js +27 -0
  70. package/dist/clis/discord-app/servers.d.ts +1 -0
  71. package/dist/clis/discord-app/servers.js +36 -0
  72. package/dist/clis/discord-app/status.d.ts +1 -0
  73. package/dist/clis/discord-app/status.js +16 -0
  74. package/dist/clis/feishu/new.d.ts +1 -0
  75. package/dist/clis/feishu/new.js +27 -0
  76. package/dist/clis/feishu/read.d.ts +1 -0
  77. package/dist/clis/feishu/read.js +40 -0
  78. package/dist/clis/feishu/search.d.ts +1 -0
  79. package/dist/clis/feishu/search.js +30 -0
  80. package/dist/clis/feishu/send.d.ts +1 -0
  81. package/dist/clis/feishu/send.js +39 -0
  82. package/dist/clis/feishu/status.d.ts +1 -0
  83. package/dist/clis/feishu/status.js +28 -0
  84. package/dist/clis/grok/ask.d.ts +1 -0
  85. package/dist/clis/grok/ask.js +82 -0
  86. package/dist/clis/grok/debug.d.ts +1 -0
  87. package/dist/clis/grok/debug.js +45 -0
  88. package/dist/clis/jimeng/generate.yaml +84 -0
  89. package/dist/clis/jimeng/history.yaml +47 -0
  90. package/dist/clis/linux-do/categories.yaml +41 -0
  91. package/dist/clis/linux-do/category.yaml +49 -0
  92. package/dist/clis/linux-do/hot.yaml +50 -0
  93. package/dist/clis/linux-do/latest.yaml +40 -0
  94. package/dist/clis/linux-do/search.yaml +45 -0
  95. package/dist/clis/linux-do/topic.yaml +38 -0
  96. package/dist/clis/neteasemusic/like.d.ts +1 -0
  97. package/dist/clis/neteasemusic/like.js +25 -0
  98. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  99. package/dist/clis/neteasemusic/lyrics.js +47 -0
  100. package/dist/clis/neteasemusic/next.d.ts +1 -0
  101. package/dist/clis/neteasemusic/next.js +26 -0
  102. package/dist/clis/neteasemusic/play.d.ts +1 -0
  103. package/dist/clis/neteasemusic/play.js +26 -0
  104. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  105. package/dist/clis/neteasemusic/playing.js +59 -0
  106. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  107. package/dist/clis/neteasemusic/playlist.js +46 -0
  108. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  109. package/dist/clis/neteasemusic/prev.js +25 -0
  110. package/dist/clis/neteasemusic/search.d.ts +1 -0
  111. package/dist/clis/neteasemusic/search.js +52 -0
  112. package/dist/clis/neteasemusic/status.d.ts +1 -0
  113. package/dist/clis/neteasemusic/status.js +16 -0
  114. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  115. package/dist/clis/neteasemusic/volume.js +54 -0
  116. package/dist/clis/notion/export.d.ts +1 -0
  117. package/dist/clis/notion/export.js +31 -0
  118. package/dist/clis/notion/favorites.d.ts +1 -0
  119. package/dist/clis/notion/favorites.js +84 -0
  120. package/dist/clis/notion/new.d.ts +1 -0
  121. package/dist/clis/notion/new.js +34 -0
  122. package/dist/clis/notion/read.d.ts +1 -0
  123. package/dist/clis/notion/read.js +30 -0
  124. package/dist/clis/notion/search.d.ts +1 -0
  125. package/dist/clis/notion/search.js +46 -0
  126. package/dist/clis/notion/sidebar.d.ts +1 -0
  127. package/dist/clis/notion/sidebar.js +41 -0
  128. package/dist/clis/notion/status.d.ts +1 -0
  129. package/dist/clis/notion/status.js +16 -0
  130. package/dist/clis/notion/write.d.ts +1 -0
  131. package/dist/clis/notion/write.js +40 -0
  132. package/dist/clis/twitter/download.d.ts +8 -0
  133. package/dist/clis/twitter/download.js +204 -0
  134. package/dist/clis/wechat/chats.d.ts +1 -0
  135. package/dist/clis/wechat/chats.js +28 -0
  136. package/dist/clis/wechat/contacts.d.ts +1 -0
  137. package/dist/clis/wechat/contacts.js +28 -0
  138. package/dist/clis/wechat/read.d.ts +1 -0
  139. package/dist/clis/wechat/read.js +58 -0
  140. package/dist/clis/wechat/search.d.ts +1 -0
  141. package/dist/clis/wechat/search.js +31 -0
  142. package/dist/clis/wechat/send.d.ts +1 -0
  143. package/dist/clis/wechat/send.js +42 -0
  144. package/dist/clis/wechat/status.d.ts +1 -0
  145. package/dist/clis/wechat/status.js +29 -0
  146. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +10 -0
  147. package/dist/clis/xiaohongshu/creator-note-detail.js +88 -0
  148. package/dist/clis/xiaohongshu/creator-notes.d.ts +11 -0
  149. package/dist/clis/xiaohongshu/creator-notes.js +109 -0
  150. package/dist/clis/xiaohongshu/creator-profile.d.ts +10 -0
  151. package/dist/clis/xiaohongshu/creator-profile.js +54 -0
  152. package/dist/clis/xiaohongshu/creator-stats.d.ts +10 -0
  153. package/dist/clis/xiaohongshu/creator-stats.js +74 -0
  154. package/dist/clis/xiaohongshu/download.d.ts +7 -0
  155. package/dist/clis/xiaohongshu/download.js +155 -0
  156. package/dist/clis/xiaohongshu/search.js +1 -1
  157. package/dist/clis/xiaohongshu/user-helpers.d.ts +15 -0
  158. package/dist/clis/xiaohongshu/user-helpers.js +67 -0
  159. package/dist/clis/xiaohongshu/user-helpers.test.d.ts +1 -0
  160. package/dist/clis/xiaohongshu/user-helpers.test.js +81 -0
  161. package/dist/clis/xiaohongshu/user.js +46 -29
  162. package/dist/clis/zhihu/download.d.ts +11 -0
  163. package/dist/clis/zhihu/download.js +186 -0
  164. package/dist/clis/zhihu/download.test.d.ts +1 -0
  165. package/dist/clis/zhihu/download.test.js +10 -0
  166. package/dist/daemon.d.ts +13 -0
  167. package/dist/daemon.js +187 -0
  168. package/dist/doctor.d.ts +27 -61
  169. package/dist/doctor.js +70 -601
  170. package/dist/doctor.test.js +30 -170
  171. package/dist/download/index.d.ts +79 -0
  172. package/dist/download/index.js +325 -0
  173. package/dist/download/progress.d.ts +36 -0
  174. package/dist/download/progress.js +111 -0
  175. package/dist/engine.test.js +15 -0
  176. package/dist/main.js +22 -28
  177. package/dist/pipeline/executor.test.js +1 -0
  178. package/dist/pipeline/registry.js +2 -0
  179. package/dist/pipeline/steps/browser.js +2 -2
  180. package/dist/pipeline/steps/download.d.ts +34 -0
  181. package/dist/pipeline/steps/download.js +251 -0
  182. package/dist/pipeline/steps/intercept.js +1 -2
  183. package/dist/pipeline/template.js +28 -0
  184. package/dist/setup.d.ts +6 -0
  185. package/dist/setup.js +46 -160
  186. package/dist/types.d.ts +6 -0
  187. package/extension/icons/icon-128.png +0 -0
  188. package/extension/icons/icon-16.png +0 -0
  189. package/extension/icons/icon-32.png +0 -0
  190. package/extension/icons/icon-48.png +0 -0
  191. package/extension/manifest.json +31 -0
  192. package/extension/package.json +16 -0
  193. package/extension/src/background.ts +293 -0
  194. package/extension/src/cdp.ts +125 -0
  195. package/extension/src/protocol.ts +57 -0
  196. package/extension/store-assets/screenshot-1280x800.png +0 -0
  197. package/extension/tsconfig.json +15 -0
  198. package/extension/vite.config.ts +18 -0
  199. package/package.json +8 -7
  200. package/scripts/test-site.mjs +70 -0
  201. package/src/browser/daemon-client.ts +113 -0
  202. package/src/browser/discover.ts +18 -216
  203. package/src/browser/errors.ts +30 -100
  204. package/src/browser/index.ts +6 -12
  205. package/src/browser/mcp.ts +78 -278
  206. package/src/browser/page.ts +222 -88
  207. package/src/browser.test.ts +3 -210
  208. package/src/build-manifest.test.ts +28 -0
  209. package/src/build-manifest.ts +147 -57
  210. package/src/clis/bilibili/download.ts +161 -0
  211. package/src/clis/chatgpt/README.md +1 -1
  212. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  213. package/src/clis/chatwise/README.md +38 -0
  214. package/src/clis/chatwise/README.zh-CN.md +38 -0
  215. package/src/clis/chatwise/ask.ts +87 -0
  216. package/src/clis/chatwise/export.ts +51 -0
  217. package/src/clis/chatwise/history.ts +47 -0
  218. package/src/clis/chatwise/model.ts +87 -0
  219. package/src/clis/chatwise/new.ts +21 -0
  220. package/src/clis/chatwise/read.ts +42 -0
  221. package/src/clis/chatwise/screenshot.ts +33 -0
  222. package/src/clis/chatwise/send.ts +50 -0
  223. package/src/clis/chatwise/status.ts +25 -0
  224. package/src/clis/discord-app/README.md +28 -0
  225. package/src/clis/discord-app/README.zh-CN.md +28 -0
  226. package/src/clis/discord-app/channels.ts +48 -0
  227. package/src/clis/discord-app/members.ts +41 -0
  228. package/src/clis/discord-app/read.ts +49 -0
  229. package/src/clis/discord-app/search.ts +64 -0
  230. package/src/clis/discord-app/send.ts +32 -0
  231. package/src/clis/discord-app/servers.ts +39 -0
  232. package/src/clis/discord-app/status.ts +18 -0
  233. package/src/clis/feishu/README.md +20 -0
  234. package/src/clis/feishu/README.zh-CN.md +20 -0
  235. package/src/clis/feishu/new.ts +32 -0
  236. package/src/clis/feishu/read.ts +48 -0
  237. package/src/clis/feishu/search.ts +35 -0
  238. package/src/clis/feishu/send.ts +46 -0
  239. package/src/clis/feishu/status.ts +34 -0
  240. package/src/clis/grok/ask.ts +90 -0
  241. package/src/clis/grok/debug.ts +49 -0
  242. package/src/clis/jimeng/generate.yaml +84 -0
  243. package/src/clis/jimeng/history.yaml +47 -0
  244. package/src/clis/linux-do/categories.yaml +41 -0
  245. package/src/clis/linux-do/category.yaml +49 -0
  246. package/src/clis/linux-do/hot.yaml +50 -0
  247. package/src/clis/linux-do/latest.yaml +40 -0
  248. package/src/clis/linux-do/search.yaml +45 -0
  249. package/src/clis/linux-do/topic.yaml +38 -0
  250. package/src/clis/neteasemusic/README.md +31 -0
  251. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  252. package/src/clis/neteasemusic/like.ts +28 -0
  253. package/src/clis/neteasemusic/lyrics.ts +53 -0
  254. package/src/clis/neteasemusic/next.ts +30 -0
  255. package/src/clis/neteasemusic/play.ts +30 -0
  256. package/src/clis/neteasemusic/playing.ts +62 -0
  257. package/src/clis/neteasemusic/playlist.ts +51 -0
  258. package/src/clis/neteasemusic/prev.ts +29 -0
  259. package/src/clis/neteasemusic/search.ts +58 -0
  260. package/src/clis/neteasemusic/status.ts +18 -0
  261. package/src/clis/neteasemusic/volume.ts +61 -0
  262. package/src/clis/notion/README.md +29 -0
  263. package/src/clis/notion/README.zh-CN.md +29 -0
  264. package/src/clis/notion/export.ts +36 -0
  265. package/src/clis/notion/favorites.ts +87 -0
  266. package/src/clis/notion/new.ts +39 -0
  267. package/src/clis/notion/read.ts +33 -0
  268. package/src/clis/notion/search.ts +54 -0
  269. package/src/clis/notion/sidebar.ts +44 -0
  270. package/src/clis/notion/status.ts +18 -0
  271. package/src/clis/notion/write.ts +45 -0
  272. package/src/clis/twitter/download.ts +227 -0
  273. package/src/clis/wechat/README.md +28 -0
  274. package/src/clis/wechat/README.zh-CN.md +28 -0
  275. package/src/clis/wechat/chats.ts +33 -0
  276. package/src/clis/wechat/contacts.ts +33 -0
  277. package/src/clis/wechat/read.ts +72 -0
  278. package/src/clis/wechat/search.ts +36 -0
  279. package/src/clis/wechat/send.ts +49 -0
  280. package/src/clis/wechat/status.ts +35 -0
  281. package/src/clis/xiaohongshu/creator-note-detail.ts +95 -0
  282. package/src/clis/xiaohongshu/creator-notes.ts +116 -0
  283. package/src/clis/xiaohongshu/creator-profile.ts +60 -0
  284. package/src/clis/xiaohongshu/creator-stats.ts +81 -0
  285. package/src/clis/xiaohongshu/download.ts +173 -0
  286. package/src/clis/xiaohongshu/search.ts +1 -1
  287. package/src/clis/xiaohongshu/user-helpers.test.ts +106 -0
  288. package/src/clis/xiaohongshu/user-helpers.ts +85 -0
  289. package/src/clis/xiaohongshu/user.ts +52 -32
  290. package/src/clis/zhihu/download.test.ts +12 -0
  291. package/src/clis/zhihu/download.ts +223 -0
  292. package/src/daemon.ts +217 -0
  293. package/src/doctor.test.ts +32 -193
  294. package/src/doctor.ts +74 -668
  295. package/src/download/index.ts +395 -0
  296. package/src/download/progress.ts +125 -0
  297. package/src/engine.test.ts +17 -0
  298. package/src/main.ts +18 -26
  299. package/src/pipeline/executor.test.ts +1 -0
  300. package/src/pipeline/registry.ts +2 -0
  301. package/src/pipeline/steps/browser.ts +2 -2
  302. package/src/pipeline/steps/download.ts +310 -0
  303. package/src/pipeline/steps/intercept.ts +1 -2
  304. package/src/pipeline/template.ts +26 -0
  305. package/src/setup.ts +47 -183
  306. package/src/types.ts +1 -0
  307. package/tests/e2e/browser-auth.test.ts +25 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Download utilities: HTTP downloads, yt-dlp wrapper, format conversion.
3
+ */
4
+
5
+ import { spawn, execSync } from 'node:child_process';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as https from 'node:https';
9
+ import * as http from 'node:http';
10
+ import * as os from 'node:os';
11
+ import { URL } from 'node:url';
12
+ import type { ProgressBar } from './progress.js';
13
+
14
+ export interface DownloadOptions {
15
+ cookies?: string;
16
+ headers?: Record<string, string>;
17
+ timeout?: number;
18
+ onProgress?: (received: number, total: number) => void;
19
+ }
20
+
21
+ export interface YtdlpOptions {
22
+ cookies?: string;
23
+ cookiesFile?: string;
24
+ format?: string;
25
+ extraArgs?: string[];
26
+ onProgress?: (percent: number) => void;
27
+ }
28
+
29
+ /**
30
+ * Check if yt-dlp is available in PATH.
31
+ */
32
+ export function checkYtdlp(): boolean {
33
+ try {
34
+ execSync('yt-dlp --version', { encoding: 'utf-8', stdio: 'pipe' });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if ffmpeg is available in PATH.
43
+ */
44
+ export function checkFfmpeg(): boolean {
45
+ try {
46
+ execSync('ffmpeg -version', { encoding: 'utf-8', stdio: 'pipe' });
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Detect content type from URL and optional headers.
55
+ */
56
+ export function detectContentType(url: string, contentType?: string): 'image' | 'video' | 'document' | 'binary' {
57
+ // Check content-type header first
58
+ if (contentType) {
59
+ if (contentType.startsWith('image/')) return 'image';
60
+ if (contentType.startsWith('video/')) return 'video';
61
+ if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) return 'document';
62
+ }
63
+
64
+ // Detect from URL
65
+ const urlLower = url.toLowerCase();
66
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
67
+
68
+ // Image extensions
69
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif'].includes(ext)) {
70
+ return 'image';
71
+ }
72
+
73
+ // Video extensions
74
+ if (['.mp4', '.webm', '.avi', '.mov', '.mkv', '.flv', '.m3u8', '.ts'].includes(ext)) {
75
+ return 'video';
76
+ }
77
+
78
+ // Video platforms (need yt-dlp)
79
+ if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be') ||
80
+ urlLower.includes('bilibili.com') || urlLower.includes('twitter.com') ||
81
+ urlLower.includes('x.com') || urlLower.includes('tiktok.com') ||
82
+ urlLower.includes('vimeo.com') || urlLower.includes('twitch.tv')) {
83
+ return 'video';
84
+ }
85
+
86
+ // Document extensions
87
+ if (['.html', '.htm', '.json', '.xml', '.txt', '.md', '.markdown'].includes(ext)) {
88
+ return 'document';
89
+ }
90
+
91
+ return 'binary';
92
+ }
93
+
94
+ /**
95
+ * Check if URL requires yt-dlp for download.
96
+ */
97
+ export function requiresYtdlp(url: string): boolean {
98
+ const urlLower = url.toLowerCase();
99
+ return (
100
+ urlLower.includes('youtube.com') ||
101
+ urlLower.includes('youtu.be') ||
102
+ urlLower.includes('bilibili.com/video') ||
103
+ urlLower.includes('twitter.com') ||
104
+ urlLower.includes('x.com') ||
105
+ urlLower.includes('tiktok.com') ||
106
+ urlLower.includes('vimeo.com') ||
107
+ urlLower.includes('twitch.tv')
108
+ );
109
+ }
110
+
111
+ /**
112
+ * HTTP download with progress callback.
113
+ */
114
+ export async function httpDownload(
115
+ url: string,
116
+ destPath: string,
117
+ options: DownloadOptions = {},
118
+ ): Promise<{ success: boolean; size: number; error?: string }> {
119
+ const { cookies, headers = {}, timeout = 30000, onProgress } = options;
120
+
121
+ return new Promise((resolve) => {
122
+ const parsedUrl = new URL(url);
123
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
124
+
125
+ const requestHeaders: Record<string, string> = {
126
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
127
+ ...headers,
128
+ };
129
+
130
+ if (cookies) {
131
+ requestHeaders['Cookie'] = cookies;
132
+ }
133
+
134
+ // Ensure directory exists
135
+ const dir = path.dirname(destPath);
136
+ fs.mkdirSync(dir, { recursive: true });
137
+
138
+ const tempPath = `${destPath}.tmp`;
139
+ const file = fs.createWriteStream(tempPath);
140
+
141
+ const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
142
+ // Handle redirects
143
+ if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
144
+ file.close();
145
+ fs.unlinkSync(tempPath);
146
+ httpDownload(response.headers.location, destPath, options).then(resolve);
147
+ return;
148
+ }
149
+
150
+ if (response.statusCode !== 200) {
151
+ file.close();
152
+ fs.unlinkSync(tempPath);
153
+ resolve({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
154
+ return;
155
+ }
156
+
157
+ const totalSize = parseInt(response.headers['content-length'] || '0', 10);
158
+ let received = 0;
159
+
160
+ response.on('data', (chunk: Buffer) => {
161
+ received += chunk.length;
162
+ if (onProgress) onProgress(received, totalSize);
163
+ });
164
+
165
+ response.pipe(file);
166
+
167
+ file.on('finish', () => {
168
+ file.close();
169
+ // Rename temp file to final destination
170
+ fs.renameSync(tempPath, destPath);
171
+ resolve({ success: true, size: received });
172
+ });
173
+ });
174
+
175
+ request.on('error', (err) => {
176
+ file.close();
177
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
178
+ resolve({ success: false, size: 0, error: err.message });
179
+ });
180
+
181
+ request.on('timeout', () => {
182
+ request.destroy();
183
+ file.close();
184
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
185
+ resolve({ success: false, size: 0, error: 'Timeout' });
186
+ });
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Export cookies to Netscape format for yt-dlp.
192
+ */
193
+ export function exportCookiesToNetscape(
194
+ cookies: Array<{ name: string; value: string; domain: string; path?: string; secure?: boolean; httpOnly?: boolean }>,
195
+ filePath: string,
196
+ ): void {
197
+ const lines = [
198
+ '# Netscape HTTP Cookie File',
199
+ '# https://curl.se/docs/http-cookies.html',
200
+ '# This is a generated file! Do not edit.',
201
+ '',
202
+ ];
203
+
204
+ for (const cookie of cookies) {
205
+ const domain = cookie.domain.startsWith('.') ? cookie.domain : `.${cookie.domain}`;
206
+ const includeSubdomains = 'TRUE';
207
+ const cookiePath = cookie.path || '/';
208
+ const secure = cookie.secure ? 'TRUE' : 'FALSE';
209
+ const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
210
+ lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${cookie.name}\t${cookie.value}`);
211
+ }
212
+
213
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
214
+ fs.writeFileSync(filePath, lines.join('\n'));
215
+ }
216
+
217
+ /**
218
+ * Download video using yt-dlp.
219
+ */
220
+ export async function ytdlpDownload(
221
+ url: string,
222
+ destPath: string,
223
+ options: YtdlpOptions = {},
224
+ ): Promise<{ success: boolean; size: number; error?: string }> {
225
+ const { cookiesFile, format = 'best', extraArgs = [], onProgress } = options;
226
+
227
+ if (!checkYtdlp()) {
228
+ return { success: false, size: 0, error: 'yt-dlp not installed. Install with: pip install yt-dlp' };
229
+ }
230
+
231
+ return new Promise((resolve) => {
232
+ const dir = path.dirname(destPath);
233
+ fs.mkdirSync(dir, { recursive: true });
234
+
235
+ // Build yt-dlp arguments
236
+ const args = [
237
+ url,
238
+ '-o', destPath,
239
+ '-f', format,
240
+ '--no-playlist',
241
+ '--progress',
242
+ ];
243
+
244
+ if (cookiesFile && fs.existsSync(cookiesFile)) {
245
+ args.push('--cookies', cookiesFile);
246
+ } else {
247
+ // Try to use browser cookies
248
+ args.push('--cookies-from-browser', 'chrome');
249
+ }
250
+
251
+ args.push(...extraArgs);
252
+
253
+ const proc = spawn('yt-dlp', args, {
254
+ stdio: ['ignore', 'pipe', 'pipe'],
255
+ });
256
+
257
+ let lastPercent = 0;
258
+ let errorOutput = '';
259
+
260
+ proc.stderr.on('data', (data: Buffer) => {
261
+ const line = data.toString();
262
+ errorOutput += line;
263
+
264
+ // Parse progress from yt-dlp output
265
+ const match = line.match(/(\d+\.?\d*)%/);
266
+ if (match && onProgress) {
267
+ const percent = parseFloat(match[1]);
268
+ if (percent > lastPercent) {
269
+ lastPercent = percent;
270
+ onProgress(percent);
271
+ }
272
+ }
273
+ });
274
+
275
+ proc.stdout.on('data', (data: Buffer) => {
276
+ const line = data.toString();
277
+ const match = line.match(/(\d+\.?\d*)%/);
278
+ if (match && onProgress) {
279
+ const percent = parseFloat(match[1]);
280
+ if (percent > lastPercent) {
281
+ lastPercent = percent;
282
+ onProgress(percent);
283
+ }
284
+ }
285
+ });
286
+
287
+ proc.on('close', (code) => {
288
+ if (code === 0 && fs.existsSync(destPath)) {
289
+ const stats = fs.statSync(destPath);
290
+ resolve({ success: true, size: stats.size });
291
+ } else {
292
+ // Check for common yt-dlp output patterns
293
+ const patterns = fs.readdirSync(dir).filter(f => f.startsWith(path.basename(destPath, path.extname(destPath))));
294
+ if (patterns.length > 0) {
295
+ const actualFile = path.join(dir, patterns[0]);
296
+ const stats = fs.statSync(actualFile);
297
+ resolve({ success: true, size: stats.size });
298
+ } else {
299
+ resolve({ success: false, size: 0, error: errorOutput.slice(0, 200) || `Exit code ${code}` });
300
+ }
301
+ }
302
+ });
303
+
304
+ proc.on('error', (err) => {
305
+ resolve({ success: false, size: 0, error: err.message });
306
+ });
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Save document content to file.
312
+ */
313
+ export async function saveDocument(
314
+ content: string,
315
+ destPath: string,
316
+ format: 'json' | 'markdown' | 'html' | 'text' = 'markdown',
317
+ metadata?: Record<string, any>,
318
+ ): Promise<{ success: boolean; size: number; error?: string }> {
319
+ try {
320
+ const dir = path.dirname(destPath);
321
+ fs.mkdirSync(dir, { recursive: true });
322
+
323
+ let output: string;
324
+
325
+ if (format === 'json') {
326
+ output = JSON.stringify({ ...metadata, content }, null, 2);
327
+ } else if (format === 'markdown') {
328
+ // Add frontmatter if metadata exists
329
+ const frontmatter = metadata ? `---\n${Object.entries(metadata).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n')}\n---\n\n` : '';
330
+ output = frontmatter + content;
331
+ } else {
332
+ output = content;
333
+ }
334
+
335
+ fs.writeFileSync(destPath, output, 'utf-8');
336
+ return { success: true, size: Buffer.byteLength(output, 'utf-8') };
337
+ } catch (err: any) {
338
+ return { success: false, size: 0, error: err.message };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Sanitize filename by removing invalid characters.
344
+ */
345
+ export function sanitizeFilename(name: string, maxLength: number = 200): string {
346
+ return name
347
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') // Remove invalid chars
348
+ .replace(/\s+/g, '_') // Replace spaces with underscores
349
+ .replace(/_+/g, '_') // Collapse multiple underscores
350
+ .replace(/^_|_$/g, '') // Trim underscores
351
+ .slice(0, maxLength);
352
+ }
353
+
354
+ /**
355
+ * Generate filename from URL if not provided.
356
+ */
357
+ export function generateFilename(url: string, index: number, extension?: string): string {
358
+ try {
359
+ const parsedUrl = new URL(url);
360
+ const pathname = parsedUrl.pathname;
361
+ const basename = path.basename(pathname);
362
+
363
+ if (basename && basename !== '/' && basename.includes('.')) {
364
+ return sanitizeFilename(basename);
365
+ }
366
+
367
+ // Generate from hostname and index
368
+ const ext = extension || detectExtension(url);
369
+ const hostname = parsedUrl.hostname.replace(/^www\./, '');
370
+ return sanitizeFilename(`${hostname}_${index + 1}${ext}`);
371
+ } catch {
372
+ const ext = extension || '.bin';
373
+ return `download_${index + 1}${ext}`;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Detect file extension from URL.
379
+ */
380
+ function detectExtension(url: string): string {
381
+ const type = detectContentType(url);
382
+ switch (type) {
383
+ case 'image': return '.jpg';
384
+ case 'video': return '.mp4';
385
+ case 'document': return '.md';
386
+ default: return '.bin';
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Get temp directory for cookie files.
392
+ */
393
+ export function getTempDir(): string {
394
+ return path.join(os.tmpdir(), 'opencli-download');
395
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Download progress display: terminal progress bars, status updates.
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+
7
+ export interface ProgressBar {
8
+ update(current: number, total: number, label?: string): void;
9
+ complete(success: boolean, message?: string): void;
10
+ fail(error: string): void;
11
+ }
12
+
13
+ /**
14
+ * Format bytes as human-readable string (KB, MB, GB).
15
+ */
16
+ export function formatBytes(bytes: number): string {
17
+ if (bytes === 0) return '0 B';
18
+ const k = 1024;
19
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
20
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
21
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
22
+ }
23
+
24
+ /**
25
+ * Format milliseconds as human-readable duration.
26
+ */
27
+ export function formatDuration(ms: number): string {
28
+ if (ms < 1000) return `${ms}ms`;
29
+ const seconds = Math.floor(ms / 1000);
30
+ if (seconds < 60) return `${seconds}s`;
31
+ const minutes = Math.floor(seconds / 60);
32
+ const remainingSeconds = seconds % 60;
33
+ return `${minutes}m ${remainingSeconds}s`;
34
+ }
35
+
36
+ /**
37
+ * Create a simple progress bar for terminal display.
38
+ */
39
+ export function createProgressBar(filename: string, index: number, total: number): ProgressBar {
40
+ const prefix = chalk.dim(`[${index + 1}/${total}]`);
41
+ const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
42
+
43
+ return {
44
+ update(current: number, totalBytes: number, label?: string) {
45
+ const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0;
46
+ const bar = createBar(percent);
47
+ const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
48
+ const extra = label ? ` ${label}` : '';
49
+ process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
50
+ },
51
+ complete(success: boolean, message?: string) {
52
+ const icon = success ? chalk.green('✓') : chalk.red('✗');
53
+ const msg = message ? ` ${chalk.dim(message)}` : '';
54
+ process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
55
+ },
56
+ fail(error: string) {
57
+ process.stderr.write(`\r${prefix} ${chalk.red('✗')} ${truncatedName} ${chalk.red(error)}\n`);
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Create a progress bar string.
64
+ */
65
+ function createBar(percent: number, width: number = 20): string {
66
+ const filled = Math.round((percent / 100) * width);
67
+ const empty = width - filled;
68
+ return chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
69
+ }
70
+
71
+ /**
72
+ * Multi-file download progress tracker.
73
+ */
74
+ export class DownloadProgressTracker {
75
+ private completed = 0;
76
+ private failed = 0;
77
+ private skipped = 0;
78
+ private total: number;
79
+ private startTime: number;
80
+ private verbose: boolean;
81
+
82
+ constructor(total: number, verbose: boolean = true) {
83
+ this.total = total;
84
+ this.startTime = Date.now();
85
+ this.verbose = verbose;
86
+ }
87
+
88
+ onFileStart(filename: string, index: number): ProgressBar | null {
89
+ if (!this.verbose) return null;
90
+ return createProgressBar(filename, index, this.total);
91
+ }
92
+
93
+ onFileComplete(success: boolean, skipped: boolean = false): void {
94
+ if (skipped) {
95
+ this.skipped++;
96
+ } else if (success) {
97
+ this.completed++;
98
+ } else {
99
+ this.failed++;
100
+ }
101
+ }
102
+
103
+ getSummary(): string {
104
+ const elapsed = formatDuration(Date.now() - this.startTime);
105
+ const parts: string[] = [];
106
+
107
+ if (this.completed > 0) {
108
+ parts.push(chalk.green(`${this.completed} downloaded`));
109
+ }
110
+ if (this.skipped > 0) {
111
+ parts.push(chalk.yellow(`${this.skipped} skipped`));
112
+ }
113
+ if (this.failed > 0) {
114
+ parts.push(chalk.red(`${this.failed} failed`));
115
+ }
116
+
117
+ return `${parts.join(', ')} in ${elapsed}`;
118
+ }
119
+
120
+ finish(): void {
121
+ if (this.verbose) {
122
+ process.stderr.write(`\n${chalk.bold('Download complete:')} ${this.getSummary()}\n`);
123
+ }
124
+ }
125
+ }
@@ -14,6 +14,23 @@ describe('discoverClis', () => {
14
14
  });
15
15
 
16
16
  describe('executeCommand', () => {
17
+ it('accepts kebab-case option names after Commander camelCases them', async () => {
18
+ const cmd = cli({
19
+ site: 'test-engine',
20
+ name: 'kebab-arg-test',
21
+ description: 'test command with kebab-case arg',
22
+ browser: false,
23
+ strategy: Strategy.PUBLIC,
24
+ args: [
25
+ { name: 'note-id', required: true, help: 'Note ID' },
26
+ ],
27
+ func: async (_page, kwargs) => [{ noteId: kwargs['note-id'] }],
28
+ });
29
+
30
+ const result = await executeCommand(cmd, null, { 'note-id': 'abc123' });
31
+ expect(result).toEqual([{ noteId: 'abc123' }]);
32
+ });
33
+
17
34
  it('executes a command with func', async () => {
18
35
  const cmd = cli({
19
36
  site: 'test-engine',
package/src/main.ts CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import { Command } from 'commander';
10
10
  import chalk from 'chalk';
11
11
  import { discoverClis, executeCommand } from './engine.js';
12
- import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
12
+ import { Strategy, type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
13
13
  import { render as renderOutput } from './output.js';
14
14
  import { PlaywrightMCP } from './browser/index.js';
15
15
  import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
@@ -119,36 +119,19 @@ program.command('cascade').description('Strategy cascade: find simplest working
119
119
  });
120
120
 
121
121
  program.command('doctor')
122
- .description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging')
123
- .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
124
- .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
125
- .option('--token <token>', 'Override token to write instead of auto-detecting')
122
+ .description('Diagnose opencli browser bridge connectivity')
126
123
  .option('--live', 'Test browser connectivity (requires Chrome running)', false)
127
- .option('--shell-rc <path>', 'Shell startup file to update')
128
- .option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
129
124
  .action(async (opts) => {
130
- const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
131
- const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
132
- const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
125
+ const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
126
+ const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
133
127
  console.log(renderBrowserDoctorReport(report));
134
- if (opts.fix) {
135
- const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
136
- console.log();
137
- if (written.length > 0) {
138
- console.log(chalk.green('Updated files:'));
139
- for (const filePath of written) console.log(`- ${filePath}`);
140
- } else {
141
- console.log(chalk.yellow('No files were changed.'));
142
- }
143
- }
144
128
  });
145
129
 
146
130
  program.command('setup')
147
- .description('Interactive setup: configure Playwright MCP token across all detected tools')
148
- .option('--token <token>', 'Provide token directly instead of auto-detecting')
149
- .action(async (opts) => {
131
+ .description('Interactive setup: verify browser bridge connectivity')
132
+ .action(async () => {
150
133
  const { runSetup } = await import('./setup.js');
151
- await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
134
+ await runSetup({ cliVersion: PKG_VERSION });
152
135
  });
153
136
 
154
137
  program.command('completion')
@@ -200,7 +183,8 @@ for (const [, cmd] of registry) {
200
183
  // Collect named options
201
184
  for (const arg of cmd.args) {
202
185
  if (arg.positional) continue;
203
- const v = actionOpts[arg.name];
186
+ const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase());
187
+ const v = actionOpts[arg.name] ?? actionOpts[camelName];
204
188
  if (v !== undefined) kwargs[arg.name] = v;
205
189
  }
206
190
 
@@ -208,7 +192,15 @@ for (const [, cmd] of registry) {
208
192
  if (actionOpts.verbose) process.env.OPENCLI_VERBOSE = '1';
209
193
  let result: any;
210
194
  if (cmd.browser) {
211
- result = await browserSession(PlaywrightMCP, async (page) => runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }));
195
+ result = await browserSession(PlaywrightMCP, async (page) => {
196
+ // Cookie/header strategies require same-origin context for credentialed fetch.
197
+ // In CDP mode the active tab may be on an unrelated domain, causing CORS failures.
198
+ // Navigate to the command's domain first (mirrors cascade command behavior).
199
+ if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) {
200
+ try { await page.goto(`https://${cmd.domain}`); await page.wait(2); } catch {}
201
+ }
202
+ return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) });
203
+ });
212
204
  } else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); }
213
205
  if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) {
214
206
  console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`));
@@ -26,6 +26,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
26
26
  autoScroll: vi.fn(),
27
27
  installInterceptor: vi.fn(),
28
28
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
29
+ screenshot: vi.fn().mockResolvedValue(''),
29
30
  ...overrides,
30
31
  };
31
32
  }
@@ -11,6 +11,7 @@ import { stepFetch } from './steps/fetch.js';
11
11
  import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
12
12
  import { stepIntercept } from './steps/intercept.js';
13
13
  import { stepTap } from './steps/tap.js';
14
+ import { stepDownload } from './steps/download.js';
14
15
 
15
16
  /**
16
17
  * Step handler: all pipeline steps conform to this generic interface.
@@ -58,3 +59,4 @@ registerStep('sort', stepSort);
58
59
  registerStep('limit', stepLimit);
59
60
  registerStep('intercept', stepIntercept);
60
61
  registerStep('tap', stepTap);
62
+ registerStep('download', stepDownload);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { IPage } from '../../types.js';
7
- import { render, normalizeEvaluateSource } from '../template.js';
7
+ import { render } from '../template.js';
8
8
 
9
9
  export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
10
10
  const url = render(params, { args, data });
@@ -52,7 +52,7 @@ export async function stepSnapshot(page: IPage | null, params: any, _data: any,
52
52
 
53
53
  export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
54
54
  const js = String(render(params, { args, data }));
55
- let result = await page!.evaluate(normalizeEvaluateSource(js));
55
+ let result = await page!.evaluate(js);
56
56
  // MCP may return JSON as a string — auto-parse it
57
57
  if (typeof result === 'string') {
58
58
  const trimmed = result.trim();