@jackwener/opencli 0.9.5 → 0.9.8

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 (270) 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/CLI-ELECTRON.md +89 -36
  14. package/CONTRIBUTING.md +167 -0
  15. package/README.md +98 -32
  16. package/README.zh-CN.md +99 -33
  17. package/dist/browser/discover.js +22 -7
  18. package/dist/browser.test.js +23 -0
  19. package/dist/build-manifest.d.ts +26 -0
  20. package/dist/build-manifest.js +132 -60
  21. package/dist/build-manifest.test.d.ts +1 -0
  22. package/dist/build-manifest.test.js +26 -0
  23. package/dist/cli-manifest.json +1875 -271
  24. package/dist/clis/antigravity/model.js +2 -2
  25. package/dist/clis/antigravity/send.js +2 -2
  26. package/dist/clis/bilibili/download.d.ts +10 -0
  27. package/dist/clis/bilibili/download.js +135 -0
  28. package/dist/clis/chatgpt/ask.d.ts +1 -0
  29. package/dist/clis/chatgpt/ask.js +68 -0
  30. package/dist/clis/chatgpt/send.js +11 -0
  31. package/dist/clis/chatwise/ask.d.ts +1 -0
  32. package/dist/clis/chatwise/ask.js +76 -0
  33. package/dist/clis/chatwise/export.d.ts +1 -0
  34. package/dist/clis/chatwise/export.js +46 -0
  35. package/dist/clis/chatwise/history.d.ts +1 -0
  36. package/dist/clis/chatwise/history.js +43 -0
  37. package/dist/clis/chatwise/model.d.ts +1 -0
  38. package/dist/clis/chatwise/model.js +81 -0
  39. package/dist/clis/chatwise/new.d.ts +1 -0
  40. package/dist/clis/chatwise/new.js +18 -0
  41. package/dist/clis/chatwise/read.d.ts +1 -0
  42. package/dist/clis/chatwise/read.js +39 -0
  43. package/dist/clis/chatwise/screenshot.d.ts +1 -0
  44. package/dist/clis/chatwise/screenshot.js +27 -0
  45. package/dist/clis/chatwise/send.d.ts +1 -0
  46. package/dist/clis/chatwise/send.js +45 -0
  47. package/dist/clis/chatwise/status.d.ts +1 -0
  48. package/dist/clis/chatwise/status.js +22 -0
  49. package/dist/clis/codex/ask.d.ts +1 -0
  50. package/dist/clis/codex/ask.js +67 -0
  51. package/dist/clis/codex/export.d.ts +1 -0
  52. package/dist/clis/codex/export.js +37 -0
  53. package/dist/clis/codex/history.d.ts +1 -0
  54. package/dist/clis/codex/history.js +43 -0
  55. package/dist/clis/codex/read.js +3 -5
  56. package/dist/clis/codex/screenshot.d.ts +1 -0
  57. package/dist/clis/codex/screenshot.js +27 -0
  58. package/dist/clis/codex/send.js +3 -6
  59. package/dist/clis/codex/status.js +2 -1
  60. package/dist/clis/cursor/ask.d.ts +1 -0
  61. package/dist/clis/cursor/ask.js +69 -0
  62. package/dist/clis/cursor/composer.js +9 -28
  63. package/dist/clis/cursor/export.d.ts +1 -0
  64. package/dist/clis/cursor/export.js +51 -0
  65. package/dist/clis/cursor/history.d.ts +1 -0
  66. package/dist/clis/cursor/history.js +43 -0
  67. package/dist/clis/cursor/new.js +4 -13
  68. package/dist/clis/cursor/screenshot.d.ts +1 -0
  69. package/dist/clis/cursor/screenshot.js +31 -0
  70. package/dist/clis/discord-app/channels.d.ts +1 -0
  71. package/dist/clis/discord-app/channels.js +45 -0
  72. package/dist/clis/discord-app/members.d.ts +1 -0
  73. package/dist/clis/discord-app/members.js +38 -0
  74. package/dist/clis/discord-app/read.d.ts +1 -0
  75. package/dist/clis/discord-app/read.js +45 -0
  76. package/dist/clis/discord-app/search.d.ts +1 -0
  77. package/dist/clis/discord-app/search.js +56 -0
  78. package/dist/clis/discord-app/send.d.ts +1 -0
  79. package/dist/clis/discord-app/send.js +27 -0
  80. package/dist/clis/discord-app/servers.d.ts +1 -0
  81. package/dist/clis/discord-app/servers.js +36 -0
  82. package/dist/clis/discord-app/status.d.ts +1 -0
  83. package/dist/clis/discord-app/status.js +16 -0
  84. package/dist/clis/feishu/new.d.ts +1 -0
  85. package/dist/clis/feishu/new.js +27 -0
  86. package/dist/clis/feishu/read.d.ts +1 -0
  87. package/dist/clis/feishu/read.js +40 -0
  88. package/dist/clis/feishu/search.d.ts +1 -0
  89. package/dist/clis/feishu/search.js +30 -0
  90. package/dist/clis/feishu/send.d.ts +1 -0
  91. package/dist/clis/feishu/send.js +39 -0
  92. package/dist/clis/feishu/status.d.ts +1 -0
  93. package/dist/clis/feishu/status.js +28 -0
  94. package/dist/clis/grok/ask.d.ts +1 -0
  95. package/dist/clis/grok/ask.js +82 -0
  96. package/dist/clis/grok/debug.d.ts +1 -0
  97. package/dist/clis/grok/debug.js +45 -0
  98. package/dist/clis/jimeng/generate.yaml +84 -0
  99. package/dist/clis/jimeng/history.yaml +47 -0
  100. package/dist/clis/linux-do/categories.yaml +41 -0
  101. package/dist/clis/linux-do/category.yaml +49 -0
  102. package/dist/clis/linux-do/hot.yaml +50 -0
  103. package/dist/clis/linux-do/latest.yaml +40 -0
  104. package/dist/clis/linux-do/search.yaml +45 -0
  105. package/dist/clis/linux-do/topic.yaml +38 -0
  106. package/dist/clis/notion/export.d.ts +1 -0
  107. package/dist/clis/notion/export.js +31 -0
  108. package/dist/clis/notion/favorites.d.ts +1 -0
  109. package/dist/clis/notion/favorites.js +84 -0
  110. package/dist/clis/notion/new.d.ts +1 -0
  111. package/dist/clis/notion/new.js +34 -0
  112. package/dist/clis/notion/read.d.ts +1 -0
  113. package/dist/clis/notion/read.js +30 -0
  114. package/dist/clis/notion/search.d.ts +1 -0
  115. package/dist/clis/notion/search.js +46 -0
  116. package/dist/clis/notion/sidebar.d.ts +1 -0
  117. package/dist/clis/notion/sidebar.js +41 -0
  118. package/dist/clis/notion/status.d.ts +1 -0
  119. package/dist/clis/notion/status.js +16 -0
  120. package/dist/clis/notion/write.d.ts +1 -0
  121. package/dist/clis/notion/write.js +40 -0
  122. package/dist/clis/twitter/download.d.ts +8 -0
  123. package/dist/clis/twitter/download.js +204 -0
  124. package/dist/clis/wechat/chats.d.ts +1 -0
  125. package/dist/clis/wechat/chats.js +28 -0
  126. package/dist/clis/wechat/contacts.d.ts +1 -0
  127. package/dist/clis/wechat/contacts.js +28 -0
  128. package/dist/clis/wechat/read.d.ts +1 -0
  129. package/dist/clis/wechat/read.js +58 -0
  130. package/dist/clis/wechat/search.d.ts +1 -0
  131. package/dist/clis/wechat/search.js +31 -0
  132. package/dist/clis/wechat/send.d.ts +1 -0
  133. package/dist/clis/wechat/send.js +42 -0
  134. package/dist/clis/wechat/status.d.ts +1 -0
  135. package/dist/clis/wechat/status.js +29 -0
  136. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +10 -0
  137. package/dist/clis/xiaohongshu/creator-note-detail.js +88 -0
  138. package/dist/clis/xiaohongshu/creator-notes.d.ts +11 -0
  139. package/dist/clis/xiaohongshu/creator-notes.js +109 -0
  140. package/dist/clis/xiaohongshu/creator-profile.d.ts +10 -0
  141. package/dist/clis/xiaohongshu/creator-profile.js +54 -0
  142. package/dist/clis/xiaohongshu/creator-stats.d.ts +10 -0
  143. package/dist/clis/xiaohongshu/creator-stats.js +74 -0
  144. package/dist/clis/xiaohongshu/download.d.ts +7 -0
  145. package/dist/clis/xiaohongshu/download.js +155 -0
  146. package/dist/clis/xiaohongshu/search.js +1 -1
  147. package/dist/clis/xiaohongshu/user-helpers.d.ts +15 -0
  148. package/dist/clis/xiaohongshu/user-helpers.js +67 -0
  149. package/dist/clis/xiaohongshu/user-helpers.test.d.ts +1 -0
  150. package/dist/clis/xiaohongshu/user-helpers.test.js +81 -0
  151. package/dist/clis/xiaohongshu/user.js +46 -29
  152. package/dist/clis/zhihu/download.d.ts +11 -0
  153. package/dist/clis/zhihu/download.js +186 -0
  154. package/dist/clis/zhihu/download.test.d.ts +1 -0
  155. package/dist/clis/zhihu/download.test.js +10 -0
  156. package/dist/download/index.d.ts +79 -0
  157. package/dist/download/index.js +325 -0
  158. package/dist/download/progress.d.ts +36 -0
  159. package/dist/download/progress.js +111 -0
  160. package/dist/engine.test.js +15 -0
  161. package/dist/main.js +16 -3
  162. package/dist/pipeline/registry.js +2 -0
  163. package/dist/pipeline/steps/download.d.ts +34 -0
  164. package/dist/pipeline/steps/download.js +251 -0
  165. package/dist/pipeline/template.js +28 -0
  166. package/package.json +4 -3
  167. package/scripts/test-site.mjs +70 -0
  168. package/src/browser/discover.ts +23 -7
  169. package/src/browser.test.ts +23 -0
  170. package/src/build-manifest.test.ts +28 -0
  171. package/src/build-manifest.ts +147 -57
  172. package/src/clis/antigravity/README.md +2 -3
  173. package/src/clis/antigravity/README.zh-CN.md +2 -3
  174. package/src/clis/antigravity/SKILL.md +1 -1
  175. package/src/clis/antigravity/model.ts +2 -2
  176. package/src/clis/antigravity/send.ts +2 -2
  177. package/src/clis/bilibili/download.ts +161 -0
  178. package/src/clis/chatgpt/README.md +25 -16
  179. package/src/clis/chatgpt/README.zh-CN.md +27 -18
  180. package/src/clis/chatgpt/ask.ts +77 -0
  181. package/src/clis/chatgpt/send.ts +12 -0
  182. package/src/clis/chatwise/README.md +38 -0
  183. package/src/clis/chatwise/README.zh-CN.md +38 -0
  184. package/src/clis/chatwise/ask.ts +87 -0
  185. package/src/clis/chatwise/export.ts +51 -0
  186. package/src/clis/chatwise/history.ts +47 -0
  187. package/src/clis/chatwise/model.ts +87 -0
  188. package/src/clis/chatwise/new.ts +21 -0
  189. package/src/clis/chatwise/read.ts +42 -0
  190. package/src/clis/chatwise/screenshot.ts +33 -0
  191. package/src/clis/chatwise/send.ts +50 -0
  192. package/src/clis/chatwise/status.ts +25 -0
  193. package/src/clis/codex/ask.ts +77 -0
  194. package/src/clis/codex/export.ts +42 -0
  195. package/src/clis/codex/extract-diff.ts +1 -0
  196. package/src/clis/codex/history.ts +47 -0
  197. package/src/clis/codex/read.ts +5 -6
  198. package/src/clis/codex/screenshot.ts +33 -0
  199. package/src/clis/codex/send.ts +6 -7
  200. package/src/clis/codex/status.ts +4 -2
  201. package/src/clis/cursor/ask.ts +81 -0
  202. package/src/clis/cursor/composer.ts +9 -30
  203. package/src/clis/cursor/export.ts +57 -0
  204. package/src/clis/cursor/history.ts +47 -0
  205. package/src/clis/cursor/new.ts +4 -15
  206. package/src/clis/cursor/screenshot.ts +38 -0
  207. package/src/clis/discord-app/README.md +28 -0
  208. package/src/clis/discord-app/README.zh-CN.md +28 -0
  209. package/src/clis/discord-app/channels.ts +48 -0
  210. package/src/clis/discord-app/members.ts +41 -0
  211. package/src/clis/discord-app/read.ts +49 -0
  212. package/src/clis/discord-app/search.ts +64 -0
  213. package/src/clis/discord-app/send.ts +32 -0
  214. package/src/clis/discord-app/servers.ts +39 -0
  215. package/src/clis/discord-app/status.ts +18 -0
  216. package/src/clis/feishu/README.md +20 -0
  217. package/src/clis/feishu/README.zh-CN.md +20 -0
  218. package/src/clis/feishu/new.ts +32 -0
  219. package/src/clis/feishu/read.ts +48 -0
  220. package/src/clis/feishu/search.ts +35 -0
  221. package/src/clis/feishu/send.ts +46 -0
  222. package/src/clis/feishu/status.ts +34 -0
  223. package/src/clis/grok/ask.ts +90 -0
  224. package/src/clis/grok/debug.ts +49 -0
  225. package/src/clis/jimeng/generate.yaml +84 -0
  226. package/src/clis/jimeng/history.yaml +47 -0
  227. package/src/clis/linux-do/categories.yaml +41 -0
  228. package/src/clis/linux-do/category.yaml +49 -0
  229. package/src/clis/linux-do/hot.yaml +50 -0
  230. package/src/clis/linux-do/latest.yaml +40 -0
  231. package/src/clis/linux-do/search.yaml +45 -0
  232. package/src/clis/linux-do/topic.yaml +38 -0
  233. package/src/clis/notion/README.md +29 -0
  234. package/src/clis/notion/README.zh-CN.md +29 -0
  235. package/src/clis/notion/export.ts +36 -0
  236. package/src/clis/notion/favorites.ts +87 -0
  237. package/src/clis/notion/new.ts +39 -0
  238. package/src/clis/notion/read.ts +33 -0
  239. package/src/clis/notion/search.ts +54 -0
  240. package/src/clis/notion/sidebar.ts +44 -0
  241. package/src/clis/notion/status.ts +18 -0
  242. package/src/clis/notion/write.ts +45 -0
  243. package/src/clis/twitter/download.ts +227 -0
  244. package/src/clis/wechat/README.md +28 -0
  245. package/src/clis/wechat/README.zh-CN.md +28 -0
  246. package/src/clis/wechat/chats.ts +33 -0
  247. package/src/clis/wechat/contacts.ts +33 -0
  248. package/src/clis/wechat/read.ts +72 -0
  249. package/src/clis/wechat/search.ts +36 -0
  250. package/src/clis/wechat/send.ts +49 -0
  251. package/src/clis/wechat/status.ts +35 -0
  252. package/src/clis/xiaohongshu/creator-note-detail.ts +95 -0
  253. package/src/clis/xiaohongshu/creator-notes.ts +116 -0
  254. package/src/clis/xiaohongshu/creator-profile.ts +60 -0
  255. package/src/clis/xiaohongshu/creator-stats.ts +81 -0
  256. package/src/clis/xiaohongshu/download.ts +173 -0
  257. package/src/clis/xiaohongshu/search.ts +1 -1
  258. package/src/clis/xiaohongshu/user-helpers.test.ts +106 -0
  259. package/src/clis/xiaohongshu/user-helpers.ts +85 -0
  260. package/src/clis/xiaohongshu/user.ts +52 -32
  261. package/src/clis/zhihu/download.test.ts +12 -0
  262. package/src/clis/zhihu/download.ts +223 -0
  263. package/src/download/index.ts +395 -0
  264. package/src/download/progress.ts +125 -0
  265. package/src/engine.test.ts +17 -0
  266. package/src/main.ts +12 -3
  267. package/src/pipeline/registry.ts +2 -0
  268. package/src/pipeline/steps/download.ts +310 -0
  269. package/src/pipeline/template.ts +26 -0
  270. package/tests/e2e/browser-auth.test.ts +25 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Pipeline step: download — file download with concurrency and progress.
3
+ *
4
+ * Supports:
5
+ * - Direct HTTP downloads (images, documents)
6
+ * - yt-dlp integration for video platforms
7
+ * - Browser cookie forwarding for authenticated downloads
8
+ * - Filename templating and deduplication
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { render } from '../template.js';
13
+ import { httpDownload, ytdlpDownload, saveDocument, detectContentType, requiresYtdlp, sanitizeFilename, generateFilename, exportCookiesToNetscape, getTempDir, } from '../../download/index.js';
14
+ import { DownloadProgressTracker, formatBytes } from '../../download/progress.js';
15
+ /**
16
+ * Simple async concurrency limiter for downloads.
17
+ */
18
+ async function mapConcurrent(items, limit, fn) {
19
+ const results = new Array(items.length);
20
+ let index = 0;
21
+ async function worker() {
22
+ while (index < items.length) {
23
+ const i = index++;
24
+ results[i] = await fn(items[i], i);
25
+ }
26
+ }
27
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
28
+ await Promise.all(workers);
29
+ return results;
30
+ }
31
+ /**
32
+ * Extract cookies from browser page.
33
+ */
34
+ async function extractBrowserCookies(page, domain) {
35
+ try {
36
+ // Use browser evaluate to get document.cookie
37
+ const cookieString = await page.evaluate(`(() => document.cookie)()`);
38
+ return typeof cookieString === 'string' ? cookieString : '';
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ /**
45
+ * Extract cookies as array for yt-dlp Netscape format.
46
+ */
47
+ async function extractCookiesArray(page, domain) {
48
+ try {
49
+ const cookieString = await extractBrowserCookies(page);
50
+ if (!cookieString)
51
+ return [];
52
+ return cookieString.split(';').map((c) => {
53
+ const [name, ...rest] = c.trim().split('=');
54
+ return {
55
+ name: name || '',
56
+ value: rest.join('=') || '',
57
+ domain,
58
+ path: '/',
59
+ secure: true,
60
+ httpOnly: false,
61
+ };
62
+ }).filter((c) => c.name);
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ /**
69
+ * Download step handler for YAML pipelines.
70
+ *
71
+ * Usage in YAML:
72
+ * ```yaml
73
+ * pipeline:
74
+ * - download:
75
+ * url: ${{ item.imageUrl }}
76
+ * dir: ./downloads
77
+ * filename: ${{ item.title }}.jpg
78
+ * concurrency: 5
79
+ * skip_existing: true
80
+ * use_ytdlp: false
81
+ * type: auto
82
+ * ```
83
+ */
84
+ export async function stepDownload(page, params, data, args) {
85
+ // Parse parameters with defaults
86
+ const urlTemplate = typeof params === 'string' ? params : (params?.url ?? '');
87
+ const dirTemplate = params?.dir ?? './downloads';
88
+ const filenameTemplate = params?.filename ?? '';
89
+ const concurrency = typeof params?.concurrency === 'number' ? params.concurrency : 3;
90
+ const skipExisting = params?.skip_existing !== false;
91
+ const timeout = typeof params?.timeout === 'number' ? params.timeout * 1000 : 30000;
92
+ const useYtdlp = params?.use_ytdlp ?? false;
93
+ const ytdlpArgs = Array.isArray(params?.ytdlp_args) ? params.ytdlp_args : [];
94
+ const contentType = params?.type ?? 'auto';
95
+ const showProgress = params?.progress !== false;
96
+ const contentTemplate = params?.content;
97
+ const metadataTemplate = params?.metadata;
98
+ // Resolve output directory
99
+ const dir = String(render(dirTemplate, { args, data }));
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ // Normalize data to array
102
+ const items = Array.isArray(data) ? data : data ? [data] : [];
103
+ if (items.length === 0) {
104
+ return [];
105
+ }
106
+ // Create progress tracker
107
+ const tracker = new DownloadProgressTracker(items.length, showProgress);
108
+ // Extract cookies if browser is available
109
+ let cookies = '';
110
+ let cookiesFile;
111
+ if (page) {
112
+ cookies = await extractBrowserCookies(page);
113
+ // For yt-dlp, we need to export cookies to Netscape format
114
+ if (useYtdlp || items.some((item, index) => {
115
+ const url = String(render(urlTemplate, { args, data, item, index }));
116
+ return requiresYtdlp(url);
117
+ })) {
118
+ try {
119
+ // Try to get domain from first URL
120
+ const firstUrl = String(render(urlTemplate, { args, data, item: items[0], index: 0 }));
121
+ const domain = new URL(firstUrl).hostname;
122
+ const cookiesArray = await extractCookiesArray(page, domain);
123
+ if (cookiesArray.length > 0) {
124
+ const tempDir = getTempDir();
125
+ fs.mkdirSync(tempDir, { recursive: true });
126
+ cookiesFile = path.join(tempDir, `cookies_${Date.now()}.txt`);
127
+ exportCookiesToNetscape(cookiesArray, cookiesFile);
128
+ }
129
+ }
130
+ catch {
131
+ // Ignore cookie extraction errors
132
+ }
133
+ }
134
+ }
135
+ // Process downloads with concurrency
136
+ const results = await mapConcurrent(items, concurrency, async (item, index) => {
137
+ const startTime = Date.now();
138
+ // Render URL
139
+ const url = String(render(urlTemplate, { args, data, item, index }));
140
+ if (!url) {
141
+ tracker.onFileComplete(false);
142
+ return {
143
+ ...item,
144
+ _download: { status: 'failed', error: 'Empty URL' },
145
+ };
146
+ }
147
+ // Render filename
148
+ let filename;
149
+ if (filenameTemplate) {
150
+ filename = String(render(filenameTemplate, { args, data, item, index }));
151
+ }
152
+ else {
153
+ filename = generateFilename(url, index);
154
+ }
155
+ filename = sanitizeFilename(filename);
156
+ const destPath = path.join(dir, filename);
157
+ // Check if file exists and skip_existing is true
158
+ if (skipExisting && fs.existsSync(destPath)) {
159
+ tracker.onFileComplete(true, true);
160
+ return {
161
+ ...item,
162
+ _download: {
163
+ status: 'skipped',
164
+ path: destPath,
165
+ size: fs.statSync(destPath).size,
166
+ },
167
+ };
168
+ }
169
+ // Create progress bar for this file
170
+ const progressBar = tracker.onFileStart(filename, index);
171
+ // Determine download method
172
+ const detectedType = contentType === 'auto' ? detectContentType(url) : contentType;
173
+ const shouldUseYtdlp = useYtdlp || (detectedType === 'video' && requiresYtdlp(url));
174
+ let result;
175
+ try {
176
+ if (detectedType === 'document' && contentTemplate) {
177
+ // Save extracted content as document
178
+ const content = String(render(contentTemplate, { args, data, item, index }));
179
+ const metadata = metadataTemplate
180
+ ? Object.fromEntries(Object.entries(metadataTemplate).map(([k, v]) => [k, render(v, { args, data, item, index })]))
181
+ : undefined;
182
+ const ext = path.extname(filename).toLowerCase();
183
+ const format = ext === '.json' ? 'json' : ext === '.html' ? 'html' : 'markdown';
184
+ result = await saveDocument(content, destPath, format, metadata);
185
+ if (progressBar) {
186
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
187
+ }
188
+ }
189
+ else if (shouldUseYtdlp) {
190
+ // Use yt-dlp for video downloads
191
+ result = await ytdlpDownload(url, destPath, {
192
+ cookiesFile,
193
+ extraArgs: ytdlpArgs,
194
+ onProgress: (percent) => {
195
+ if (progressBar) {
196
+ progressBar.update(percent, 100);
197
+ }
198
+ },
199
+ });
200
+ if (progressBar) {
201
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
202
+ }
203
+ }
204
+ else {
205
+ // Direct HTTP download
206
+ result = await httpDownload(url, destPath, {
207
+ cookies,
208
+ timeout,
209
+ onProgress: (received, total) => {
210
+ if (progressBar) {
211
+ progressBar.update(received, total);
212
+ }
213
+ },
214
+ });
215
+ if (progressBar) {
216
+ progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
217
+ }
218
+ }
219
+ }
220
+ catch (err) {
221
+ result = { success: false, size: 0, error: err.message };
222
+ if (progressBar) {
223
+ progressBar.fail(err.message);
224
+ }
225
+ }
226
+ tracker.onFileComplete(result.success);
227
+ const duration = Date.now() - startTime;
228
+ return {
229
+ ...item,
230
+ _download: {
231
+ status: result.success ? 'success' : 'failed',
232
+ path: result.success ? destPath : undefined,
233
+ size: result.size,
234
+ error: result.error,
235
+ duration,
236
+ },
237
+ };
238
+ });
239
+ // Cleanup temp cookie file
240
+ if (cookiesFile && fs.existsSync(cookiesFile)) {
241
+ try {
242
+ fs.unlinkSync(cookiesFile);
243
+ }
244
+ catch {
245
+ // Ignore cleanup errors
246
+ }
247
+ }
248
+ // Show summary
249
+ tracker.finish();
250
+ return results;
251
+ }
@@ -114,6 +114,34 @@ function applyFilter(filterExpr, value) {
114
114
  return Array.isArray(value) ? value[value.length - 1] : value;
115
115
  case 'json':
116
116
  return JSON.stringify(value ?? null);
117
+ case 'slugify':
118
+ // Convert to URL-safe slug
119
+ return typeof value === 'string'
120
+ ? value
121
+ .toLowerCase()
122
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
123
+ .replace(/^-|-$/g, '')
124
+ : value;
125
+ case 'sanitize':
126
+ // Remove invalid filename characters
127
+ return typeof value === 'string'
128
+ ? value.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
129
+ : value;
130
+ case 'ext': {
131
+ // Extract file extension from URL or path
132
+ if (typeof value !== 'string')
133
+ return value;
134
+ const lastDot = value.lastIndexOf('.');
135
+ const lastSlash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
136
+ return lastDot > lastSlash ? value.slice(lastDot) : '';
137
+ }
138
+ case 'basename': {
139
+ // Extract filename from URL or path
140
+ if (typeof value !== 'string')
141
+ return value;
142
+ const parts = value.split(/[/\\]/);
143
+ return parts[parts.length - 1] || value;
144
+ }
117
145
  default:
118
146
  return value;
119
147
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.9.5",
3
+ "version": "0.9.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "description": "Make any website your CLI. AI-powered.",
8
8
  "engines": {
9
- "node": ">=18.0.0"
9
+ "node": ">=20.0.0"
10
10
  },
11
11
  "type": "module",
12
12
  "main": "dist/main.js",
@@ -25,6 +25,7 @@
25
25
  "lint": "tsc --noEmit",
26
26
  "prepublishOnly": "npm run build",
27
27
  "test": "vitest run",
28
+ "test:site": "node scripts/test-site.mjs",
28
29
  "test:watch": "vitest"
29
30
  },
30
31
  "keywords": [
@@ -43,7 +44,7 @@
43
44
  "dependencies": {
44
45
  "chalk": "^5.3.0",
45
46
  "cli-table3": "^0.6.5",
46
- "commander": "^13.1.0",
47
+ "commander": "^14.0.3",
47
48
  "js-yaml": "^4.1.0"
48
49
  },
49
50
  "devDependencies": {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+
7
+ const site = process.argv[2]?.trim();
8
+
9
+ if (!site) {
10
+ console.error('Usage: npm run test:site -- <site>');
11
+ process.exit(1);
12
+ }
13
+
14
+ const repoRoot = path.resolve(new URL('..', import.meta.url).pathname);
15
+ const srcDir = path.join(repoRoot, 'src');
16
+
17
+ function runStep(label, command, args) {
18
+ console.log(`\n==> ${label}`);
19
+ const result = spawnSync(command, args, {
20
+ cwd: repoRoot,
21
+ stdio: 'inherit',
22
+ env: process.env,
23
+ });
24
+
25
+ if (result.status !== 0) {
26
+ process.exit(result.status ?? 1);
27
+ }
28
+ }
29
+
30
+ function walk(dir) {
31
+ const files = [];
32
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
33
+ const fullPath = path.join(dir, entry.name);
34
+ if (entry.isDirectory()) {
35
+ files.push(...walk(fullPath));
36
+ } else {
37
+ files.push(fullPath);
38
+ }
39
+ }
40
+ return files;
41
+ }
42
+
43
+ function toPosix(filePath) {
44
+ return filePath.split(path.sep).join('/');
45
+ }
46
+
47
+ function findSiteTests() {
48
+ return walk(srcDir)
49
+ .filter(filePath => filePath.endsWith('.test.ts'))
50
+ .filter(filePath => {
51
+ const normalized = toPosix(path.relative(repoRoot, filePath));
52
+ return normalized.includes(`/clis/${site}/`) || normalized.includes(`/${site}.test.ts`);
53
+ })
54
+ .sort();
55
+ }
56
+
57
+ runStep('Typecheck', 'npm', ['run', 'typecheck']);
58
+ runStep('Targeted verify', 'npx', ['tsx', 'src/main.ts', 'verify', site]);
59
+
60
+ const testFiles = findSiteTests();
61
+ if (testFiles.length === 0) {
62
+ console.log(`\nNo site-specific vitest files found for "${site}". Skipping full vitest run.`);
63
+ process.exit(0);
64
+ }
65
+
66
+ runStep(
67
+ `Site tests (${site})`,
68
+ 'npx',
69
+ ['vitest', 'run', ...testFiles.map(filePath => path.relative(repoRoot, filePath))],
70
+ );
@@ -12,6 +12,19 @@ let _cachedMcpServerPath: string | null | undefined;
12
12
  let _existsSync = fs.existsSync;
13
13
  let _execSync = execSync;
14
14
 
15
+ function isSupportedMcpEntrypoint(candidate: string): boolean {
16
+ const normalized = candidate.replace(/\\/g, '/').toLowerCase();
17
+ return normalized.endsWith('/@playwright/mcp/cli.js') ||
18
+ normalized.endsWith('/mcp-server-playwright') ||
19
+ normalized.endsWith('/mcp-server-playwright.js');
20
+ }
21
+
22
+ function resolveSupportedMcpPath(candidate: string | null | undefined): string | null {
23
+ const trimmed = candidate?.trim();
24
+ if (!trimmed || !_existsSync(trimmed)) return null;
25
+ return isSupportedMcpEntrypoint(trimmed) ? trimmed : null;
26
+ }
27
+
15
28
  export function resetMcpServerPathCache(): void {
16
29
  _cachedMcpServerPath = undefined;
17
30
  }
@@ -80,8 +93,9 @@ export function findMcpServerPath(): string | null {
80
93
  // Try npx resolution (legacy package name)
81
94
  try {
82
95
  const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
83
- if (result && _existsSync(result)) {
84
- _cachedMcpServerPath = result;
96
+ const resolved = resolveSupportedMcpPath(result);
97
+ if (resolved) {
98
+ _cachedMcpServerPath = resolved;
85
99
  return _cachedMcpServerPath;
86
100
  }
87
101
  } catch {}
@@ -89,8 +103,9 @@ export function findMcpServerPath(): string | null {
89
103
  // Try which
90
104
  try {
91
105
  const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
92
- if (result && _existsSync(result)) {
93
- _cachedMcpServerPath = result;
106
+ const resolved = resolveSupportedMcpPath(result);
107
+ if (resolved) {
108
+ _cachedMcpServerPath = resolved;
94
109
  return _cachedMcpServerPath;
95
110
  }
96
111
  } catch {}
@@ -99,9 +114,10 @@ export function findMcpServerPath(): string | null {
99
114
  for (const base of candidates) {
100
115
  if (!_existsSync(base)) continue;
101
116
  try {
102
- const found = _execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
103
- if (found) {
104
- _cachedMcpServerPath = found;
117
+ const found = _execSync(`find "${base}" -type f -path "*/@playwright/mcp/cli.js" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
118
+ const resolved = resolveSupportedMcpPath(found);
119
+ if (resolved) {
120
+ _cachedMcpServerPath = resolved;
105
121
  return _cachedMcpServerPath;
106
122
  }
107
123
  } catch {}
@@ -1,4 +1,6 @@
1
1
  import { afterEach, describe, it, expect, vi } from 'vitest';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
2
4
  import { PlaywrightMCP, __test__ } from './browser/index.js';
3
5
 
4
6
  afterEach(() => {
@@ -248,6 +250,27 @@ describe('browser helpers', () => {
248
250
  });
249
251
  }
250
252
  });
253
+
254
+ it('ignores non-server playwright cli paths discovered from fallback scans', () => {
255
+ const wrongCli = '/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/lib/mcp/terminal/cli.js';
256
+ const npxCacheBase = path.join(os.homedir(), '.npm', '_npx');
257
+
258
+ const existsSync = vi.fn((candidate: any) => {
259
+ const value = String(candidate);
260
+ return value === npxCacheBase || value === wrongCli;
261
+ });
262
+
263
+ const execSync = vi.fn((command: string) => {
264
+ if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
265
+ if (String(command).includes('--package=@playwright/mcp which mcp-server-playwright')) return `${wrongCli}\n` as any;
266
+ if (String(command).includes('which mcp-server-playwright')) return '' as any;
267
+ if (String(command).includes(`find "${npxCacheBase}"`)) return `${wrongCli}\n` as any;
268
+ throw new Error(`unexpected command: ${String(command)}`);
269
+ });
270
+ __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
271
+
272
+ expect(__test__.findMcpServerPath()).toBeNull();
273
+ });
251
274
  });
252
275
 
253
276
  describe('PlaywrightMCP state', () => {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseTsArgsBlock } from './build-manifest.js';
3
+
4
+ describe('parseTsArgsBlock', () => {
5
+ it('keeps args with nested choices arrays', () => {
6
+ const args = parseTsArgsBlock(`
7
+ {
8
+ name: 'period',
9
+ type: 'string',
10
+ default: 'seven',
11
+ help: 'Stats period: seven or thirty',
12
+ choices: ['seven', 'thirty'],
13
+ },
14
+ `);
15
+
16
+ expect(args).toEqual([
17
+ {
18
+ name: 'period',
19
+ type: 'string',
20
+ default: 'seven',
21
+ required: false,
22
+ positional: undefined,
23
+ help: 'Stats period: seven or thirty',
24
+ choices: ['seven', 'thirty'],
25
+ },
26
+ ]);
27
+ });
28
+ });