@jackwener/opencli 1.5.6 → 1.5.7

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 (334) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -2
  3. package/README.zh-CN.md +4 -1
  4. package/SKILL.md +879 -0
  5. package/dist/browser/cdp.d.ts +1 -0
  6. package/dist/browser/cdp.js +30 -27
  7. package/dist/browser/daemon-client.d.ts +7 -1
  8. package/dist/browser/daemon-client.js +3 -0
  9. package/dist/browser/dom-helpers.js +1 -0
  10. package/dist/browser/dom-helpers.test.js +14 -1
  11. package/dist/browser/mcp.js +18 -13
  12. package/dist/browser/page.js +22 -2
  13. package/dist/browser/page.test.d.ts +1 -0
  14. package/dist/browser/page.test.js +44 -0
  15. package/dist/browser/stealth.js +198 -0
  16. package/dist/browser/stealth.test.d.ts +1 -0
  17. package/dist/browser/stealth.test.js +134 -0
  18. package/dist/browser.test.js +1 -1
  19. package/dist/build-manifest.d.ts +1 -0
  20. package/dist/build-manifest.js +5 -1
  21. package/dist/build-manifest.test.js +2 -0
  22. package/dist/cli-manifest.json +544 -137
  23. package/dist/cli.js +20 -3
  24. package/dist/clis/antigravity/serve.d.ts +1 -1
  25. package/dist/clis/antigravity/serve.js +5 -8
  26. package/dist/clis/bilibili/subtitle.js +4 -0
  27. package/dist/clis/bilibili/subtitle.test.d.ts +1 -0
  28. package/dist/clis/bilibili/subtitle.test.js +48 -0
  29. package/dist/clis/chatwise/ask.js +0 -2
  30. package/dist/clis/chatwise/export.js +0 -2
  31. package/dist/clis/chatwise/history.js +0 -2
  32. package/dist/clis/chatwise/model.js +0 -2
  33. package/dist/clis/chatwise/new.js +1 -2
  34. package/dist/clis/chatwise/read.js +0 -2
  35. package/dist/clis/chatwise/screenshot.js +1 -2
  36. package/dist/clis/chatwise/send.js +0 -2
  37. package/dist/clis/chatwise/status.js +1 -2
  38. package/dist/clis/ctrip/search.d.ts +13 -0
  39. package/dist/clis/ctrip/search.js +73 -48
  40. package/dist/clis/ctrip/search.test.d.ts +1 -0
  41. package/dist/clis/ctrip/search.test.js +64 -0
  42. package/dist/clis/douyin/_shared/sts2.js +8 -2
  43. package/dist/clis/douyin/_shared/sts2.test.d.ts +1 -0
  44. package/dist/clis/douyin/_shared/sts2.test.js +27 -0
  45. package/dist/clis/douyin/activities.js +4 -2
  46. package/dist/clis/douyin/activities.test.js +34 -1
  47. package/dist/clis/douyin/collections.js +1 -1
  48. package/dist/clis/douyin/collections.test.js +24 -2
  49. package/dist/clis/douyin/draft.d.ts +8 -11
  50. package/dist/clis/douyin/draft.js +302 -185
  51. package/dist/clis/douyin/draft.test.d.ts +1 -1
  52. package/dist/clis/douyin/draft.test.js +357 -2
  53. package/dist/clis/douyin/hashtag.js +9 -2
  54. package/dist/clis/douyin/hashtag.test.js +35 -2
  55. package/dist/clis/douyin/profile.js +1 -1
  56. package/dist/clis/douyin/profile.test.js +36 -1
  57. package/dist/clis/douyin/videos.js +22 -5
  58. package/dist/clis/douyin/videos.test.js +45 -2
  59. package/dist/clis/facebook/search.test.d.ts +5 -0
  60. package/dist/clis/facebook/search.test.js +60 -0
  61. package/dist/clis/facebook/search.yaml +4 -3
  62. package/dist/clis/instagram/download.d.ts +16 -0
  63. package/dist/clis/instagram/download.js +225 -0
  64. package/dist/clis/instagram/download.test.d.ts +1 -0
  65. package/dist/clis/instagram/download.test.js +118 -0
  66. package/dist/clis/notebooklm/bind-current.d.ts +1 -0
  67. package/dist/clis/notebooklm/bind-current.js +29 -0
  68. package/dist/clis/notebooklm/bind-current.test.d.ts +1 -0
  69. package/dist/clis/notebooklm/bind-current.test.js +35 -0
  70. package/dist/clis/notebooklm/binding.test.d.ts +1 -0
  71. package/dist/clis/notebooklm/binding.test.js +44 -0
  72. package/dist/clis/notebooklm/compat.test.d.ts +3 -0
  73. package/dist/clis/notebooklm/compat.test.js +16 -0
  74. package/dist/clis/notebooklm/current.d.ts +1 -0
  75. package/dist/clis/notebooklm/current.js +28 -0
  76. package/dist/clis/notebooklm/get.d.ts +1 -0
  77. package/dist/clis/notebooklm/get.js +37 -0
  78. package/dist/clis/notebooklm/history.d.ts +1 -0
  79. package/dist/clis/notebooklm/history.js +25 -0
  80. package/dist/clis/notebooklm/history.test.d.ts +1 -0
  81. package/dist/clis/notebooklm/history.test.js +58 -0
  82. package/dist/clis/notebooklm/list.d.ts +1 -0
  83. package/dist/clis/notebooklm/list.js +35 -0
  84. package/dist/clis/notebooklm/note-list.d.ts +1 -0
  85. package/dist/clis/notebooklm/note-list.js +28 -0
  86. package/dist/clis/notebooklm/note-list.test.d.ts +1 -0
  87. package/dist/clis/notebooklm/note-list.test.js +56 -0
  88. package/dist/clis/notebooklm/notes-get.d.ts +1 -0
  89. package/dist/clis/notebooklm/notes-get.js +47 -0
  90. package/dist/clis/notebooklm/notes-get.test.d.ts +1 -0
  91. package/dist/clis/notebooklm/notes-get.test.js +72 -0
  92. package/dist/clis/notebooklm/rpc.d.ts +36 -0
  93. package/dist/clis/notebooklm/rpc.js +189 -0
  94. package/dist/clis/notebooklm/rpc.test.d.ts +1 -0
  95. package/dist/clis/notebooklm/rpc.test.js +105 -0
  96. package/dist/clis/notebooklm/shared.d.ts +87 -0
  97. package/dist/clis/notebooklm/shared.js +3 -0
  98. package/dist/clis/notebooklm/source-fulltext.d.ts +1 -0
  99. package/dist/clis/notebooklm/source-fulltext.js +44 -0
  100. package/dist/clis/notebooklm/source-fulltext.test.d.ts +1 -0
  101. package/dist/clis/notebooklm/source-fulltext.test.js +106 -0
  102. package/dist/clis/notebooklm/source-get.d.ts +1 -0
  103. package/dist/clis/notebooklm/source-get.js +40 -0
  104. package/dist/clis/notebooklm/source-get.test.d.ts +1 -0
  105. package/dist/clis/notebooklm/source-get.test.js +84 -0
  106. package/dist/clis/notebooklm/source-guide.d.ts +1 -0
  107. package/dist/clis/notebooklm/source-guide.js +44 -0
  108. package/dist/clis/notebooklm/source-guide.test.d.ts +1 -0
  109. package/dist/clis/notebooklm/source-guide.test.js +104 -0
  110. package/dist/clis/notebooklm/source-list.d.ts +1 -0
  111. package/dist/clis/notebooklm/source-list.js +30 -0
  112. package/dist/clis/notebooklm/status.d.ts +1 -0
  113. package/dist/clis/notebooklm/status.js +31 -0
  114. package/dist/clis/notebooklm/summary.d.ts +1 -0
  115. package/dist/clis/notebooklm/summary.js +30 -0
  116. package/dist/clis/notebooklm/summary.test.d.ts +1 -0
  117. package/dist/clis/notebooklm/summary.test.js +78 -0
  118. package/dist/clis/notebooklm/utils.d.ts +37 -0
  119. package/dist/clis/notebooklm/utils.js +739 -0
  120. package/dist/clis/notebooklm/utils.test.d.ts +1 -0
  121. package/dist/clis/notebooklm/utils.test.js +390 -0
  122. package/dist/clis/substack/utils.d.ts +4 -0
  123. package/dist/clis/substack/utils.js +8 -2
  124. package/dist/clis/substack/utils.test.d.ts +1 -0
  125. package/dist/clis/substack/utils.test.js +46 -0
  126. package/dist/clis/v2ex/hot.yaml +4 -1
  127. package/dist/clis/v2ex/latest.yaml +4 -1
  128. package/dist/clis/v2ex/topic.yaml +6 -1
  129. package/dist/clis/weixin/download.d.ts +9 -0
  130. package/dist/clis/weixin/download.js +76 -6
  131. package/dist/clis/weread/book.js +108 -2
  132. package/dist/clis/weread/commands.test.js +262 -152
  133. package/dist/clis/weread/utils.d.ts +10 -0
  134. package/dist/clis/weread/utils.js +27 -7
  135. package/dist/clis/xiaohongshu/comments.d.ts +3 -0
  136. package/dist/clis/xiaohongshu/comments.js +76 -17
  137. package/dist/clis/xiaohongshu/comments.test.js +70 -9
  138. package/dist/clis/xiaohongshu/download.d.ts +4 -1
  139. package/dist/clis/xiaohongshu/download.js +83 -22
  140. package/dist/clis/xiaohongshu/download.test.d.ts +1 -0
  141. package/dist/clis/xiaohongshu/download.test.js +75 -0
  142. package/dist/clis/xiaohongshu/note-helpers.d.ts +12 -0
  143. package/dist/clis/xiaohongshu/note-helpers.js +23 -0
  144. package/dist/clis/xiaohongshu/note.d.ts +7 -0
  145. package/dist/clis/xiaohongshu/note.js +76 -0
  146. package/dist/clis/xiaohongshu/note.test.d.ts +1 -0
  147. package/dist/clis/xiaohongshu/note.test.js +136 -0
  148. package/dist/clis/xiaohongshu/search.js +9 -0
  149. package/dist/clis/xiaohongshu/search.test.js +10 -4
  150. package/dist/clis/youtube/search.js +57 -17
  151. package/dist/clis/zhihu/question.js +19 -17
  152. package/dist/clis/zhihu/question.test.d.ts +1 -0
  153. package/dist/clis/zhihu/question.test.js +54 -0
  154. package/dist/commanderAdapter.js +9 -0
  155. package/dist/commanderAdapter.test.js +25 -0
  156. package/dist/commands/daemon.d.ts +9 -0
  157. package/dist/commands/daemon.js +124 -0
  158. package/dist/commands/daemon.test.d.ts +1 -0
  159. package/dist/commands/daemon.test.js +185 -0
  160. package/dist/completion.js +3 -1
  161. package/dist/constants.d.ts +2 -0
  162. package/dist/constants.js +2 -0
  163. package/dist/daemon.d.ts +1 -1
  164. package/dist/daemon.js +25 -14
  165. package/dist/daemon.test.d.ts +1 -0
  166. package/dist/daemon.test.js +65 -0
  167. package/dist/discovery.d.ts +9 -0
  168. package/dist/discovery.js +47 -2
  169. package/dist/electron-apps.d.ts +29 -0
  170. package/dist/electron-apps.js +65 -0
  171. package/dist/electron-apps.test.d.ts +1 -0
  172. package/dist/electron-apps.test.js +43 -0
  173. package/dist/engine.test.js +41 -9
  174. package/dist/execution.js +20 -16
  175. package/dist/idle-manager.d.ts +19 -0
  176. package/dist/idle-manager.js +54 -0
  177. package/dist/launcher.d.ts +36 -0
  178. package/dist/launcher.js +152 -0
  179. package/dist/launcher.test.d.ts +1 -0
  180. package/dist/launcher.test.js +57 -0
  181. package/dist/main.js +3 -3
  182. package/dist/registry.d.ts +1 -0
  183. package/dist/registry.js +31 -3
  184. package/dist/registry.test.js +13 -0
  185. package/dist/runtime.d.ts +5 -3
  186. package/dist/runtime.js +12 -5
  187. package/dist/serialization.d.ts +1 -0
  188. package/dist/serialization.js +3 -0
  189. package/dist/serialization.test.js +17 -1
  190. package/dist/tui.d.ts +7 -0
  191. package/dist/tui.js +52 -0
  192. package/dist/tui.test.d.ts +1 -0
  193. package/dist/tui.test.js +19 -0
  194. package/dist/weixin-download.test.js +14 -0
  195. package/docs/.vitepress/config.mts +1 -0
  196. package/docs/adapters/browser/notebooklm.md +69 -0
  197. package/docs/adapters/browser/xiaohongshu.md +19 -10
  198. package/docs/adapters/index.md +67 -66
  199. package/docs/guide/browser-bridge.md +12 -0
  200. package/docs/guide/troubleshooting.md +9 -4
  201. package/docs/superpowers/plans/2026-03-31-daemon-lifecycle-redesign.md +857 -0
  202. package/docs/superpowers/specs/2026-03-31-daemon-lifecycle-redesign.md +208 -0
  203. package/docs/zh/guide/browser-bridge.md +12 -0
  204. package/extension/dist/background.js +794 -513
  205. package/extension/src/background.test.ts +202 -2
  206. package/extension/src/background.ts +174 -10
  207. package/extension/src/cdp.ts +12 -0
  208. package/extension/src/protocol.ts +7 -5
  209. package/package.json +1 -1
  210. package/src/browser/cdp.ts +24 -17
  211. package/src/browser/daemon-client.ts +7 -1
  212. package/src/browser/dom-helpers.test.ts +15 -1
  213. package/src/browser/dom-helpers.ts +1 -0
  214. package/src/browser/mcp.ts +18 -13
  215. package/src/browser/page.test.ts +58 -0
  216. package/src/browser/page.ts +18 -2
  217. package/src/browser/stealth.test.ts +153 -0
  218. package/src/browser/stealth.ts +198 -0
  219. package/src/browser.test.ts +1 -1
  220. package/src/build-manifest.test.ts +2 -0
  221. package/src/build-manifest.ts +6 -1
  222. package/src/cli.ts +21 -3
  223. package/src/clis/antigravity/SKILL.md +3 -12
  224. package/src/clis/antigravity/serve.ts +5 -10
  225. package/src/clis/bilibili/subtitle.test.ts +60 -0
  226. package/src/clis/bilibili/subtitle.ts +4 -0
  227. package/src/clis/chatwise/ask.ts +0 -2
  228. package/src/clis/chatwise/export.ts +0 -2
  229. package/src/clis/chatwise/history.ts +0 -2
  230. package/src/clis/chatwise/model.ts +0 -2
  231. package/src/clis/chatwise/new.ts +1 -2
  232. package/src/clis/chatwise/read.ts +0 -2
  233. package/src/clis/chatwise/screenshot.ts +1 -2
  234. package/src/clis/chatwise/send.ts +0 -2
  235. package/src/clis/chatwise/status.ts +1 -2
  236. package/src/clis/ctrip/search.test.ts +73 -0
  237. package/src/clis/ctrip/search.ts +97 -47
  238. package/src/clis/douyin/_shared/sts2.test.ts +31 -0
  239. package/src/clis/douyin/_shared/sts2.ts +11 -3
  240. package/src/clis/douyin/activities.test.ts +41 -1
  241. package/src/clis/douyin/activities.ts +12 -3
  242. package/src/clis/douyin/collections.test.ts +35 -2
  243. package/src/clis/douyin/collections.ts +1 -1
  244. package/src/clis/douyin/draft.test.ts +444 -2
  245. package/src/clis/douyin/draft.ts +382 -218
  246. package/src/clis/douyin/hashtag.test.ts +42 -2
  247. package/src/clis/douyin/hashtag.ts +11 -3
  248. package/src/clis/douyin/profile.test.ts +43 -1
  249. package/src/clis/douyin/profile.ts +9 -2
  250. package/src/clis/douyin/videos.test.ts +52 -2
  251. package/src/clis/douyin/videos.ts +49 -15
  252. package/src/clis/facebook/search.test.ts +70 -0
  253. package/src/clis/facebook/search.yaml +4 -3
  254. package/src/clis/instagram/download.test.ts +159 -0
  255. package/src/clis/instagram/download.ts +286 -0
  256. package/src/clis/notebooklm/bind-current.test.ts +43 -0
  257. package/src/clis/notebooklm/bind-current.ts +36 -0
  258. package/src/clis/notebooklm/binding.test.ts +53 -0
  259. package/src/clis/notebooklm/compat.test.ts +19 -0
  260. package/src/clis/notebooklm/current.ts +38 -0
  261. package/src/clis/notebooklm/get.ts +53 -0
  262. package/src/clis/notebooklm/history.test.ts +70 -0
  263. package/src/clis/notebooklm/history.ts +36 -0
  264. package/src/clis/notebooklm/list.ts +40 -0
  265. package/src/clis/notebooklm/note-list.test.ts +64 -0
  266. package/src/clis/notebooklm/note-list.ts +42 -0
  267. package/src/clis/notebooklm/notes-get.test.ts +88 -0
  268. package/src/clis/notebooklm/notes-get.ts +67 -0
  269. package/src/clis/notebooklm/rpc.test.ts +126 -0
  270. package/src/clis/notebooklm/rpc.ts +286 -0
  271. package/src/clis/notebooklm/shared.ts +98 -0
  272. package/src/clis/notebooklm/source-fulltext.test.ts +123 -0
  273. package/src/clis/notebooklm/source-fulltext.ts +69 -0
  274. package/src/clis/notebooklm/source-get.test.ts +100 -0
  275. package/src/clis/notebooklm/source-get.ts +60 -0
  276. package/src/clis/notebooklm/source-guide.test.ts +121 -0
  277. package/src/clis/notebooklm/source-guide.ts +69 -0
  278. package/src/clis/notebooklm/source-list.ts +45 -0
  279. package/src/clis/notebooklm/status.ts +34 -0
  280. package/src/clis/notebooklm/summary.test.ts +94 -0
  281. package/src/clis/notebooklm/summary.ts +45 -0
  282. package/src/clis/notebooklm/utils.test.ts +446 -0
  283. package/src/clis/notebooklm/utils.ts +893 -0
  284. package/src/clis/substack/utils.test.ts +54 -0
  285. package/src/clis/substack/utils.ts +10 -2
  286. package/src/clis/v2ex/hot.yaml +4 -1
  287. package/src/clis/v2ex/latest.yaml +4 -1
  288. package/src/clis/v2ex/topic.yaml +6 -1
  289. package/src/clis/weixin/download.ts +95 -6
  290. package/src/clis/weread/book.ts +142 -2
  291. package/src/clis/weread/commands.test.ts +314 -154
  292. package/src/clis/weread/utils.ts +33 -4
  293. package/src/clis/xiaohongshu/comments.test.ts +85 -9
  294. package/src/clis/xiaohongshu/comments.ts +76 -17
  295. package/src/clis/xiaohongshu/download.test.ts +96 -0
  296. package/src/clis/xiaohongshu/download.ts +83 -22
  297. package/src/clis/xiaohongshu/note-helpers.ts +25 -0
  298. package/src/clis/xiaohongshu/note.test.ts +164 -0
  299. package/src/clis/xiaohongshu/note.ts +86 -0
  300. package/src/clis/xiaohongshu/search.test.ts +11 -4
  301. package/src/clis/xiaohongshu/search.ts +13 -0
  302. package/src/clis/youtube/search.ts +57 -17
  303. package/src/clis/zhihu/question.test.ts +71 -0
  304. package/src/clis/zhihu/question.ts +27 -15
  305. package/src/commanderAdapter.test.ts +30 -0
  306. package/src/commanderAdapter.ts +7 -0
  307. package/src/commands/daemon.test.ts +238 -0
  308. package/src/commands/daemon.ts +135 -0
  309. package/src/completion.ts +2 -1
  310. package/src/constants.ts +3 -0
  311. package/src/daemon.test.ts +88 -0
  312. package/src/daemon.ts +26 -14
  313. package/src/discovery.ts +52 -2
  314. package/src/electron-apps.test.ts +50 -0
  315. package/src/electron-apps.ts +89 -0
  316. package/src/engine.test.ts +45 -9
  317. package/src/execution.ts +24 -19
  318. package/src/idle-manager.ts +60 -0
  319. package/src/launcher.test.ts +67 -0
  320. package/src/launcher.ts +185 -0
  321. package/src/main.ts +3 -2
  322. package/src/registry.test.ts +15 -0
  323. package/src/registry.ts +32 -3
  324. package/src/runtime.ts +13 -7
  325. package/src/serialization.test.ts +19 -1
  326. package/src/serialization.ts +2 -0
  327. package/src/tui.test.ts +23 -0
  328. package/src/tui.ts +65 -0
  329. package/src/weixin-download.test.ts +27 -0
  330. package/tests/e2e/browser-public-extended.test.ts +6 -2
  331. package/chatwise-opencli.ps1 +0 -82
  332. package/dist/clis/chatwise/shared.d.ts +0 -2
  333. package/dist/clis/chatwise/shared.js +0 -6
  334. package/src/clis/chatwise/shared.ts +0 -8
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { IPage } from '../../types.js';
3
+ import { getRegistry } from '../../registry.js';
4
+ import { parseNoteId, buildNoteUrl } from './note-helpers.js';
5
+ import './note.js';
6
+
7
+ function createPageMock(evaluateResult: any): IPage {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
11
+ snapshot: vi.fn().mockResolvedValue(undefined),
12
+ click: vi.fn().mockResolvedValue(undefined),
13
+ typeText: vi.fn().mockResolvedValue(undefined),
14
+ pressKey: vi.fn().mockResolvedValue(undefined),
15
+ scrollTo: vi.fn().mockResolvedValue(undefined),
16
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
17
+ wait: vi.fn().mockResolvedValue(undefined),
18
+ tabs: vi.fn().mockResolvedValue([]),
19
+ closeTab: vi.fn().mockResolvedValue(undefined),
20
+ newTab: vi.fn().mockResolvedValue(undefined),
21
+ selectTab: vi.fn().mockResolvedValue(undefined),
22
+ networkRequests: vi.fn().mockResolvedValue([]),
23
+ consoleMessages: vi.fn().mockResolvedValue([]),
24
+ scroll: vi.fn().mockResolvedValue(undefined),
25
+ autoScroll: vi.fn().mockResolvedValue(undefined),
26
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
27
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
28
+ getCookies: vi.fn().mockResolvedValue([]),
29
+ screenshot: vi.fn().mockResolvedValue(''),
30
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
31
+ };
32
+ }
33
+
34
+ describe('parseNoteId', () => {
35
+ it('extracts ID from /explore/ URL', () => {
36
+ expect(parseNoteId('https://www.xiaohongshu.com/explore/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
37
+ });
38
+
39
+ it('extracts ID from /search_result/ URL with query params', () => {
40
+ expect(parseNoteId('https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc')).toBe('69c131c9000000002800be4c');
41
+ });
42
+
43
+ it('extracts ID from /note/ URL', () => {
44
+ expect(parseNoteId('https://www.xiaohongshu.com/note/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
45
+ });
46
+
47
+ it('returns raw string when no URL pattern matches', () => {
48
+ expect(parseNoteId('69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
49
+ });
50
+
51
+ it('trims whitespace', () => {
52
+ expect(parseNoteId(' 69c131c9000000002800be4c ')).toBe('69c131c9000000002800be4c');
53
+ });
54
+ });
55
+
56
+ describe('buildNoteUrl', () => {
57
+ it('returns full URL as-is when given https URL', () => {
58
+ const url = 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok';
59
+ expect(buildNoteUrl(url)).toBe(url);
60
+ });
61
+
62
+ it('constructs /explore/ URL for bare note ID', () => {
63
+ expect(buildNoteUrl('abc123')).toBe('https://www.xiaohongshu.com/explore/abc123');
64
+ });
65
+ });
66
+
67
+ describe('xiaohongshu note', () => {
68
+ const command = getRegistry().get('xiaohongshu/note');
69
+
70
+ it('is registered', () => {
71
+ expect(command).toBeDefined();
72
+ expect(command!.func).toBeTypeOf('function');
73
+ });
74
+
75
+ it('returns note content as field/value rows', async () => {
76
+ const page = createPageMock({
77
+ loginWall: false,
78
+ notFound: false,
79
+ title: '尚界Z7实车体验',
80
+ desc: '今天去看了实车,外观很帅',
81
+ author: '小红薯用户',
82
+ likes: '257',
83
+ collects: '98',
84
+ comments: '45',
85
+ tags: ['#尚界Z7', '#鸿蒙智行'],
86
+ });
87
+
88
+ const result = (await command!.func!(page, { 'note-id': '69c131c9000000002800be4c' })) as any[];
89
+
90
+ expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69c131c9000000002800be4c');
91
+ expect(result).toEqual([
92
+ { field: 'title', value: '尚界Z7实车体验' },
93
+ { field: 'author', value: '小红薯用户' },
94
+ { field: 'content', value: '今天去看了实车,外观很帅' },
95
+ { field: 'likes', value: '257' },
96
+ { field: 'collects', value: '98' },
97
+ { field: 'comments', value: '45' },
98
+ { field: 'tags', value: '#尚界Z7, #鸿蒙智行' },
99
+ ]);
100
+ });
101
+
102
+ it('parses note ID from full /explore/ URL', async () => {
103
+ const page = createPageMock({
104
+ loginWall: false, notFound: false,
105
+ title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
106
+ });
107
+
108
+ await command!.func!(page, {
109
+ 'note-id': 'https://www.xiaohongshu.com/explore/69c131c9000000002800be4c?xsec_token=abc',
110
+ });
111
+
112
+ expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69c131c9000000002800be4c');
113
+ });
114
+
115
+ it('preserves full search_result URL with xsec_token for navigation', async () => {
116
+ const page = createPageMock({
117
+ loginWall: false, notFound: false,
118
+ title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
119
+ });
120
+
121
+ const fullUrl = 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc';
122
+ await command!.func!(page, { 'note-id': fullUrl });
123
+
124
+ // Should navigate to the full URL as-is, not strip the token
125
+ expect((page.goto as any).mock.calls[0][0]).toBe(fullUrl);
126
+ });
127
+
128
+ it('throws AuthRequiredError on login wall', async () => {
129
+ const page = createPageMock({ loginWall: true, notFound: false });
130
+
131
+ await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
132
+ });
133
+
134
+ it('throws EmptyResultError when note is not found', async () => {
135
+ const page = createPageMock({ loginWall: false, notFound: true });
136
+
137
+ await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
138
+ });
139
+
140
+ it('normalizes placeholder text to 0 for zero-count metrics', async () => {
141
+ const page = createPageMock({
142
+ loginWall: false, notFound: false,
143
+ title: 'New note', desc: 'Just posted', author: 'Author',
144
+ likes: '赞', collects: '收藏', comments: '评论', tags: [],
145
+ });
146
+
147
+ const result = (await command!.func!(page, { 'note-id': 'abc123' })) as any[];
148
+ expect(result.find((r: any) => r.field === 'likes')!.value).toBe('0');
149
+ expect(result.find((r: any) => r.field === 'collects')!.value).toBe('0');
150
+ expect(result.find((r: any) => r.field === 'comments')!.value).toBe('0');
151
+ });
152
+
153
+ it('omits tags row when no tags present', async () => {
154
+ const page = createPageMock({
155
+ loginWall: false, notFound: false,
156
+ title: 'No tags', desc: 'Content', author: 'Author',
157
+ likes: '1', collects: '2', comments: '3', tags: [],
158
+ });
159
+
160
+ const result = (await command!.func!(page, { 'note-id': 'abc123' })) as any[];
161
+ expect(result.find((r: any) => r.field === 'tags')).toBeUndefined();
162
+ expect(result).toHaveLength(6);
163
+ });
164
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Xiaohongshu note — read full note content from a public note page.
3
+ *
4
+ * Extracts title, author, description text, and engagement metrics
5
+ * (likes, collects, comment count) via DOM extraction.
6
+ */
7
+
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { AuthRequiredError, EmptyResultError } from '../../errors.js';
10
+ import { parseNoteId, buildNoteUrl } from './note-helpers.js';
11
+
12
+ cli({
13
+ site: 'xiaohongshu',
14
+ name: 'note',
15
+ description: '获取小红书笔记正文和互动数据',
16
+ domain: 'www.xiaohongshu.com',
17
+ strategy: Strategy.COOKIE,
18
+ args: [
19
+ { name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
20
+ ],
21
+ columns: ['field', 'value'],
22
+ func: async (page, kwargs) => {
23
+ const raw = String(kwargs['note-id']);
24
+ const noteId = parseNoteId(raw);
25
+ const url = buildNoteUrl(raw);
26
+
27
+ await page.goto(url);
28
+ await page.wait(3);
29
+
30
+ const data = await page.evaluate(`
31
+ (() => {
32
+ const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
33
+ const notFound = /页面不见了|笔记不存在|无法浏览/.test(document.body.innerText || '')
34
+
35
+ const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
36
+
37
+ const title = clean(document.querySelector('#detail-title, .title'))
38
+ const desc = clean(document.querySelector('#detail-desc, .desc, .note-text'))
39
+ const author = clean(document.querySelector('.username, .author-wrapper .name'))
40
+ const likes = clean(document.querySelector('.like-wrapper .count'))
41
+ const collects = clean(document.querySelector('.collect-wrapper .count'))
42
+ const comments = clean(document.querySelector('.chat-wrapper .count'))
43
+
44
+ // Try to extract tags/topics
45
+ const tags = []
46
+ document.querySelectorAll('#detail-desc a.tag, #detail-desc a[href*="search_result"]').forEach(el => {
47
+ const t = (el.textContent || '').trim()
48
+ if (t) tags.push(t)
49
+ })
50
+
51
+ return { loginWall, notFound, title, desc, author, likes, collects, comments, tags }
52
+ })()
53
+ `);
54
+
55
+ if (!data || typeof data !== 'object') {
56
+ throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
57
+ }
58
+
59
+ if ((data as any).loginWall) {
60
+ throw new AuthRequiredError('www.xiaohongshu.com', 'Note content requires login');
61
+ }
62
+
63
+ if ((data as any).notFound) {
64
+ throw new EmptyResultError('xiaohongshu/note', `Note ${noteId} not found or unavailable — it may have been deleted or restricted`);
65
+ }
66
+
67
+ const d = data as any;
68
+ // XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
69
+ // normalize to '0' unless the value looks numeric.
70
+ const numOrZero = (v: string) => /^\d+/.test(v) ? v : '0';
71
+ const rows = [
72
+ { field: 'title', value: d.title || '' },
73
+ { field: 'author', value: d.author || '' },
74
+ { field: 'content', value: d.desc || '' },
75
+ { field: 'likes', value: numOrZero(d.likes || '') },
76
+ { field: 'collects', value: numOrZero(d.collects || '') },
77
+ { field: 'comments', value: numOrZero(d.comments || '') },
78
+ ];
79
+
80
+ if (d.tags?.length) {
81
+ rows.push({ field: 'tags', value: d.tags.join(', ') });
82
+ }
83
+
84
+ return rows;
85
+ },
86
+ });
@@ -41,15 +41,16 @@ describe('xiaohongshu search', () => {
41
41
  expect(cmd?.func).toBeTypeOf('function');
42
42
 
43
43
  const page = createPageMock([
44
- {
45
- loginWall: true,
46
- results: [],
47
- },
44
+ // First evaluate: early login-wall check (returns true)
45
+ true,
48
46
  ]);
49
47
 
50
48
  await expect(cmd!.func!(page, { query: '特斯拉', limit: 5 })).rejects.toThrow(
51
49
  'Xiaohongshu search results are blocked behind a login wall'
52
50
  );
51
+
52
+ // autoScroll must NOT be called when a login wall is detected early
53
+ expect(page.autoScroll).not.toHaveBeenCalled();
53
54
  });
54
55
 
55
56
  it('returns ranked results with search_result url and author_url preserved', async () => {
@@ -62,6 +63,9 @@ describe('xiaohongshu search', () => {
62
63
  'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
63
64
 
64
65
  const page = createPageMock([
66
+ // First evaluate: early login-wall check (returns false → no wall)
67
+ false,
68
+ // Second evaluate: main DOM extraction
65
69
  {
66
70
  loginWall: false,
67
71
  results: [
@@ -99,6 +103,9 @@ describe('xiaohongshu search', () => {
99
103
  expect(cmd?.func).toBeTypeOf('function');
100
104
 
101
105
  const page = createPageMock([
106
+ // First evaluate: early login-wall check (returns false → no wall)
107
+ false,
108
+ // Second evaluate: main DOM extraction
102
109
  {
103
110
  loginWall: false,
104
111
  results: [
@@ -44,6 +44,19 @@ cli({
44
44
  );
45
45
  await page.wait(3);
46
46
 
47
+ // Early login-wall detection: XHS may show a login gate instead of
48
+ // results. Check *before* autoScroll to avoid crashing on a page
49
+ // that has no meaningful content to scroll through.
50
+ const loginCheck = await page.evaluate(`
51
+ (() => /登录后查看搜索结果/.test(document.body?.innerText || ''))()
52
+ `);
53
+ if (loginCheck) {
54
+ throw new AuthRequiredError(
55
+ 'www.xiaohongshu.com',
56
+ 'Xiaohongshu search results are blocked behind a login wall',
57
+ );
58
+ }
59
+
47
60
  // Scroll a couple of times to load more results
48
61
  await page.autoScroll({ times: 2 });
49
62
 
@@ -12,32 +12,60 @@ cli({
12
12
  args: [
13
13
  { name: 'query', required: true, positional: true, help: 'Search query' },
14
14
  { name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
15
+ { name: 'type', default: '', help: 'Filter type: shorts, video, channel, playlist' },
16
+ { name: 'upload', default: '', help: 'Upload date: hour, today, week, month, year' },
17
+ { name: 'sort', default: '', help: 'Sort by: relevance, date, views, rating' },
15
18
  ],
16
- columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'],
19
+ columns: ['rank', 'title', 'channel', 'views', 'duration', 'published', 'url'],
17
20
  func: async (page, kwargs) => {
18
21
  const limit = Math.min(kwargs.limit || 20, 50);
19
- await page.goto('https://www.youtube.com');
20
- await page.wait(2);
22
+ const query = encodeURIComponent(kwargs.query);
23
+
24
+ // Build search URL with filter params
25
+ // YouTube uses sp= parameter for filters — we use the URL approach for reliability
26
+ const spMap: Record<string, string> = {
27
+ // type filters
28
+ 'shorts': 'EgIQCQ%3D%3D', // Shorts (type=9)
29
+ 'video': 'EgIQAQ%3D%3D',
30
+ 'channel': 'EgIQAg%3D%3D',
31
+ 'playlist': 'EgIQAw%3D%3D',
32
+ // upload date filters (can be combined with type via URL)
33
+ 'hour': 'EgIIAQ%3D%3D',
34
+ 'today': 'EgIIAg%3D%3D',
35
+ 'week': 'EgIIAw%3D%3D',
36
+ 'month': 'EgIIBA%3D%3D',
37
+ 'year': 'EgIIBQ%3D%3D',
38
+ };
39
+ const sortMap: Record<string, string> = {
40
+ 'date': 'CAI%3D',
41
+ 'views': 'CAM%3D',
42
+ 'rating': 'CAE%3D',
43
+ };
44
+
45
+ // YouTube only supports a single sp= parameter — pick the most specific filter.
46
+ // Priority: type > upload > sort (type is the most common use case)
47
+ let sp = '';
48
+ if (kwargs.type && spMap[kwargs.type]) sp = spMap[kwargs.type];
49
+ else if (kwargs.upload && spMap[kwargs.upload]) sp = spMap[kwargs.upload];
50
+ else if (kwargs.sort && sortMap[kwargs.sort]) sp = sortMap[kwargs.sort];
51
+
52
+ let url = `https://www.youtube.com/results?search_query=${query}`;
53
+ if (sp) url += `&sp=${sp}`;
54
+
55
+ await page.goto(url);
56
+ await page.wait(3);
21
57
  const data = await page.evaluate(`
22
58
  (async () => {
23
- const cfg = window.ytcfg?.data_ || {};
24
- const apiKey = cfg.INNERTUBE_API_KEY;
25
- const context = cfg.INNERTUBE_CONTEXT;
26
- if (!apiKey || !context) return {error: 'YouTube config not found'};
59
+ const data = window.ytInitialData;
60
+ if (!data) return {error: 'YouTube data not found'};
27
61
 
28
- const resp = await fetch('/youtubei/v1/search?key=' + apiKey + '&prettyPrint=false', {
29
- method: 'POST', credentials: 'include',
30
- headers: {'Content-Type': 'application/json'},
31
- body: JSON.stringify({context, query: '${kwargs.query.replace(/'/g, "\\'")}'})
32
- });
33
- if (!resp.ok) return {error: 'HTTP ' + resp.status};
34
-
35
- const data = await resp.json();
36
62
  const contents = data.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
37
63
  const videos = [];
38
64
  for (const section of contents) {
39
- for (const item of (section.itemSectionRenderer?.contents || [])) {
40
- if (item.videoRenderer && videos.length < ${limit}) {
65
+ const items = section.itemSectionRenderer?.contents || section.reelShelfRenderer?.items || [];
66
+ for (const item of items) {
67
+ if (videos.length >= ${limit}) break;
68
+ if (item.videoRenderer) {
41
69
  const v = item.videoRenderer;
42
70
  videos.push({
43
71
  rank: videos.length + 1,
@@ -45,8 +73,20 @@ cli({
45
73
  channel: v.ownerText?.runs?.[0]?.text || '',
46
74
  views: v.viewCountText?.simpleText || v.shortViewCountText?.simpleText || '',
47
75
  duration: v.lengthText?.simpleText || 'LIVE',
76
+ published: v.publishedTimeText?.simpleText || '',
48
77
  url: 'https://www.youtube.com/watch?v=' + v.videoId
49
78
  });
79
+ } else if (item.reelItemRenderer) {
80
+ const r = item.reelItemRenderer;
81
+ videos.push({
82
+ rank: videos.length + 1,
83
+ title: r.headline?.simpleText || '',
84
+ channel: r.navigationEndpoint?.reelWatchEndpoint?.overlay?.reelPlayerOverlayRenderer?.reelPlayerHeaderSupportedRenderers?.reelPlayerHeaderRenderer?.channelTitleText?.runs?.[0]?.text || '',
85
+ views: r.viewCountText?.simpleText || '',
86
+ duration: 'SHORT',
87
+ published: r.publishedTimeText?.simpleText || '',
88
+ url: 'https://www.youtube.com/shorts/' + r.videoId
89
+ });
50
90
  }
51
91
  }
52
92
  }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import { AuthRequiredError } from '../../errors.js';
4
+ import './question.js';
5
+
6
+ describe('zhihu question', () => {
7
+ it('returns answers even when the unused question detail request fails', async () => {
8
+ const cmd = getRegistry().get('zhihu/question');
9
+ expect(cmd?.func).toBeTypeOf('function');
10
+
11
+ const evaluate = vi.fn().mockImplementation(async (_fn: unknown, args: { questionId: string; answerLimit: number }) => {
12
+ expect(args).toEqual({ questionId: '2021881398772981878', answerLimit: 3 });
13
+ return {
14
+ ok: true,
15
+ answers: [
16
+ {
17
+ author: { name: 'alice' },
18
+ voteup_count: 12,
19
+ content: '<p>Hello <b>Zhihu</b></p>',
20
+ },
21
+ ],
22
+ };
23
+ });
24
+
25
+ const page = {
26
+ evaluate,
27
+ } as any;
28
+
29
+ await expect(
30
+ cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
31
+ ).resolves.toEqual([
32
+ {
33
+ rank: 1,
34
+ author: 'alice',
35
+ votes: 12,
36
+ content: 'Hello Zhihu',
37
+ },
38
+ ]);
39
+
40
+ expect(evaluate).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it('maps auth-like answer failures to AuthRequiredError', async () => {
44
+ const cmd = getRegistry().get('zhihu/question');
45
+ expect(cmd?.func).toBeTypeOf('function');
46
+
47
+ const page = {
48
+ evaluate: vi.fn().mockResolvedValue({ ok: false, status: 403 }),
49
+ } as any;
50
+
51
+ await expect(
52
+ cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
53
+ ).rejects.toBeInstanceOf(AuthRequiredError);
54
+ });
55
+
56
+ it('preserves non-auth fetch failures as CliError instead of login errors', async () => {
57
+ const cmd = getRegistry().get('zhihu/question');
58
+ expect(cmd?.func).toBeTypeOf('function');
59
+
60
+ const page = {
61
+ evaluate: vi.fn().mockResolvedValue({ ok: false, status: 500 }),
62
+ } as any;
63
+
64
+ await expect(
65
+ cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
66
+ ).rejects.toMatchObject({
67
+ code: 'FETCH_ERROR',
68
+ message: 'Zhihu question answers request failed with HTTP 500',
69
+ });
70
+ });
71
+ });
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
- import { AuthRequiredError } from '../../errors.js';
2
+ import { AuthRequiredError, CliError } from '../../errors.js';
3
3
 
4
4
  cli({
5
5
  site: 'zhihu',
@@ -14,27 +14,39 @@ cli({
14
14
  columns: ['rank', 'author', 'votes', 'content'],
15
15
  func: async (page, kwargs) => {
16
16
  const { id, limit = 5 } = kwargs;
17
+ const answerLimit = Number(limit);
17
18
 
18
19
  const stripHtml = (html: string) =>
19
20
  (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
20
21
 
21
- // Fetch question detail and answers in parallel via evaluate
22
- const result = await page.evaluate(`
23
- async () => {
24
- const [qResp, aResp] = await Promise.all([
25
- fetch('https://www.zhihu.com/api/v4/questions/${id}?include=data[*].detail,excerpt,answer_count,follower_count,visit_count', {credentials: 'include'}),
26
- fetch('https://www.zhihu.com/api/v4/questions/${id}/answers?limit=${limit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author', {credentials: 'include'})
27
- ]);
28
- if (!qResp.ok || !aResp.ok) return { error: true };
29
- const q = await qResp.json();
22
+ // Only fetch answers here. The question detail endpoint is not used by the
23
+ // current CLI output and can fail independently, which would incorrectly
24
+ // turn a successful answers response into a login error.
25
+ const result = await (page as any).evaluate(
26
+ async ({ questionId, answerLimit }: { questionId: string; answerLimit: number }) => {
27
+ const aResp = await fetch(
28
+ `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`,
29
+ { credentials: 'include' },
30
+ );
31
+ if (!aResp.ok) return { ok: false as const, status: aResp.status };
30
32
  const a = await aResp.json();
31
- return { question: q, answers: a.data || [] };
32
- }
33
- `);
33
+ return { ok: true as const, answers: Array.isArray(a?.data) ? a.data : [] };
34
+ },
35
+ { questionId: String(id), answerLimit },
36
+ );
34
37
 
35
- if (!result || result.error) throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
38
+ if (!result?.ok) {
39
+ if (result?.status === 401 || result?.status === 403) {
40
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
41
+ }
42
+ throw new CliError(
43
+ 'FETCH_ERROR',
44
+ `Zhihu question answers request failed with HTTP ${result?.status ?? 'unknown'}`,
45
+ 'Try again later or rerun with -v for more detail',
46
+ );
47
+ }
36
48
 
37
- const answers = (result.answers ?? []).slice(0, Number(limit)).map((a: any, i: number) => ({
49
+ const answers = result.answers.slice(0, answerLimit).map((a: any, i: number) => ({
38
50
  rank: i + 1,
39
51
  author: a.author?.name ?? 'anonymous',
40
52
  votes: a.voteup_count ?? 0,
@@ -123,3 +123,33 @@ describe('commanderAdapter boolean alias support', () => {
123
123
  expect(kwargs.undo).toBe(false);
124
124
  });
125
125
  });
126
+
127
+ describe('commanderAdapter command aliases', () => {
128
+ const cmd: CliCommand = {
129
+ site: 'notebooklm',
130
+ name: 'get',
131
+ aliases: ['metadata'],
132
+ description: 'Get notebook metadata',
133
+ browser: false,
134
+ args: [],
135
+ func: vi.fn(),
136
+ };
137
+
138
+ beforeEach(() => {
139
+ mockExecuteCommand.mockReset();
140
+ mockExecuteCommand.mockResolvedValue([]);
141
+ mockRenderOutput.mockReset();
142
+ delete process.env.OPENCLI_VERBOSE;
143
+ process.exitCode = undefined;
144
+ });
145
+
146
+ it('registers aliases with Commander so compatibility names execute the same command', async () => {
147
+ const program = new Command();
148
+ const siteCmd = program.command('notebooklm');
149
+ registerCommandToProgram(siteCmd, cmd);
150
+
151
+ await program.parseAsync(['node', 'opencli', 'notebooklm', 'metadata']);
152
+
153
+ expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
154
+ });
155
+ });
@@ -52,6 +52,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
52
52
 
53
53
  const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : '';
54
54
  const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`);
55
+ if (cmd.aliases?.length) subCmd.aliases(cmd.aliases);
55
56
 
56
57
  // Register positional args first, then named options
57
58
  const positionalArgs: typeof cmd.args = [];
@@ -103,6 +104,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
103
104
  }
104
105
 
105
106
  const result = await executeCommand(cmd, kwargs, verbose);
107
+ if (result === null || result === undefined) {
108
+ return;
109
+ }
106
110
 
107
111
  if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
108
112
  console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
@@ -293,7 +297,10 @@ export function registerAllCommands(
293
297
  program: Command,
294
298
  siteGroups: Map<string, Command>,
295
299
  ): void {
300
+ const seen = new Set<CliCommand>();
296
301
  for (const [, cmd] of getRegistry()) {
302
+ if (seen.has(cmd)) continue;
303
+ seen.add(cmd);
297
304
  let siteCmd = siteGroups.get(cmd.site);
298
305
  if (!siteCmd) {
299
306
  siteCmd = program.command(cmd.site).description(`${cmd.site} commands`);