@jackwener/opencli 1.7.22 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `opencli suno generate` — submit a Suno music-generation request, wait for
3
+ * both clips to finish, and download selected formats locally.
4
+ *
5
+ * Targets the /api/generate/v2-web/ endpoint (cookie auth). Each generation
6
+ * returns 2 candidate clips by design — both are downloaded so the caller
7
+ * can A/B them.
8
+ *
9
+ * Modes:
10
+ * - Custom (when --lyrics is provided): API receives prompt(lyrics)+tags
11
+ * +title+negative_tags. Use this for professional control over lyrics,
12
+ * structure metatags, style, and exclusions.
13
+ * - Simple (default): API receives a description in `prompt`; Suno picks
14
+ * the lyrics, tags, and title.
15
+ *
16
+ * Creative knobs (--weirdness / --style-weight) map directly to the
17
+ * `metadata.control_sliders` the web UI exposes.
18
+ */
19
+ import { cli, Strategy } from '@jackwener/opencli/registry';
20
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
21
+ import {
22
+ DEFAULT_SUNO_MODEL,
23
+ SUNO_DOMAIN,
24
+ SUNO_MODELS,
25
+ SUNO_URL,
26
+ checkSunoCaptcha,
27
+ clampSlider,
28
+ downloadSunoClip,
29
+ ensureSunoSession,
30
+ normalizeBooleanFlag,
31
+ parseFormats,
32
+ pollSunoClips,
33
+ requirePositiveInt,
34
+ resolveSunoOutputDir,
35
+ submitSunoGeneration,
36
+ } from './utils.js';
37
+
38
+ import * as crypto from 'node:crypto';
39
+ import * as os from 'node:os';
40
+
41
+ function displayPath(filePath) {
42
+ if (!filePath) return '-';
43
+ const home = os.homedir();
44
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
45
+ }
46
+
47
+ export const generateCommand = cli({
48
+ site: 'suno',
49
+ name: 'generate',
50
+ access: 'write',
51
+ description: 'Generate music with Suno (V5.5 chirp-fenix by default) and download clips locally',
52
+ domain: SUNO_DOMAIN,
53
+ strategy: Strategy.COOKIE,
54
+ browser: true,
55
+ siteSession: 'persistent',
56
+ navigateBefore: false,
57
+ defaultFormat: 'plain',
58
+ args: [
59
+ { name: 'prompt', positional: true, required: false, help: 'Simple-mode description (ignored when --lyrics is provided)' },
60
+ { name: 'lyrics', help: 'Custom-mode lyrics (with [Verse]/[Chorus] metatags). Triggers Custom mode.' },
61
+ { name: 'tags', help: 'Custom-mode style tags (genre, BPM, instruments...). Used with --lyrics.' },
62
+ { name: 'negative-tags', help: 'Custom-mode style exclusions (e.g. "no vocals, no autotune"). Used with --lyrics.' },
63
+ { name: 'title', help: 'Song title (default: auto-derived from prompt)' },
64
+ { name: 'instrumental', type: 'boolean', default: false, help: 'No vocals' },
65
+ { name: 'model', help: `Model id: ${SUNO_MODELS.join(', ')}. Default: ${DEFAULT_SUNO_MODEL}` },
66
+ { name: 'weirdness', help: 'Creative weirdness slider (0..1). Default: 0.5' },
67
+ { name: 'style-weight', help: 'Style adherence slider (0..1). Default: 0.5' },
68
+ { name: 'formats', help: 'Comma-separated download formats: mp3, m4a, wav, video, cover, metadata. Default: mp3,metadata' },
69
+ { name: 'op', help: 'Output directory (default: ~/Music/suno)' },
70
+ { name: 'timeout', type: 'int', default: 300, help: 'Max seconds to wait for clips to finish (default: 300)' },
71
+ { name: 'sd', type: 'boolean', default: false, help: 'Skip download; only print clip ids and Suno URLs' },
72
+ { name: 'confirm-paid', type: 'boolean', default: false, help: 'Required to allow paid downloads (wav). Without it, paid formats are skipped with a warning.' },
73
+ ],
74
+ columns: ['status', 'clip', 'title', 'files', 'link'],
75
+ func: async (page, kwargs) => {
76
+ const lyrics = kwargs.lyrics ? String(kwargs.lyrics) : '';
77
+ const tags = kwargs.tags ? String(kwargs.tags) : '';
78
+ const negativeTags = kwargs['negative-tags'] ? String(kwargs['negative-tags']) : '';
79
+ const description = kwargs.prompt ? String(kwargs.prompt) : '';
80
+ const titleArg = kwargs.title ? String(kwargs.title) : '';
81
+ const model = kwargs.model ? String(kwargs.model).trim() : DEFAULT_SUNO_MODEL;
82
+ if (!SUNO_MODELS.includes(model)) {
83
+ throw new ArgumentError(`Unsupported --model "${model}"`, `Choices: ${SUNO_MODELS.join(', ')}`);
84
+ }
85
+
86
+ const isCustom = lyrics.trim() !== '';
87
+ if (!isCustom && !description.trim()) {
88
+ throw new ArgumentError(
89
+ 'Either provide a Simple-mode prompt as the positional argument, or pass --lyrics for Custom mode.',
90
+ 'Examples:\n opencli suno generate "lo-fi study beat, 80 bpm"\n opencli suno generate --lyrics "[Verse]\\n..." --tags "synthwave, 120 bpm"',
91
+ );
92
+ }
93
+ if (!isCustom && (tags || negativeTags)) {
94
+ throw new ArgumentError('--tags and --negative-tags only apply in Custom mode (alongside --lyrics).');
95
+ }
96
+
97
+ const requestedFormats = parseFormats(kwargs.formats);
98
+ const confirmPaid = normalizeBooleanFlag(kwargs['confirm-paid']);
99
+ const skipDownload = normalizeBooleanFlag(kwargs.sd);
100
+ const PAID_FORMATS = new Set(['wav']);
101
+ const skippedPaid = [];
102
+ const formats = requestedFormats.filter(f => {
103
+ if (PAID_FORMATS.has(f) && !confirmPaid) {
104
+ skippedPaid.push(f);
105
+ return false;
106
+ }
107
+ return true;
108
+ });
109
+ if (!skipDownload && !formats.length) {
110
+ throw new ArgumentError('All requested formats require --confirm-paid true', 'Add --confirm-paid true or include a free format such as mp3 or metadata.');
111
+ }
112
+ const outputDir = resolveSunoOutputDir(kwargs.op);
113
+ const timeout = requirePositiveInt(kwargs.timeout, '--timeout');
114
+ const makeInstrumental = normalizeBooleanFlag(kwargs.instrumental);
115
+ const weirdness = clampSlider(kwargs.weirdness, '--weirdness', 0.5);
116
+ const styleWeight = clampSlider(kwargs['style-weight'], '--style-weight', 0.5);
117
+
118
+ // Title: required by API. Auto-derive from first 60 chars of source prompt if not provided.
119
+ const titleSource = titleArg || (isCustom ? (tags || lyrics.split('\n')[0]) : description);
120
+ const title = titleSource.replace(/\s+/g, ' ').trim().slice(0, 60) || 'Untitled';
121
+
122
+ const session = await ensureSunoSession(page);
123
+ if (!session.planId) {
124
+ throw new CommandExecutionError(
125
+ `Suno generation needs a resolved plan id for the user_tier field, but billing/info did not surface one for this account (subscription_type=${session.planKey}). Verify the account is active at ${SUNO_URL}/account, then retry.`,
126
+ );
127
+ }
128
+ const deviceId = session.deviceId;
129
+ const captcha = await checkSunoCaptcha(page, deviceId);
130
+ if (!captcha?.ok) {
131
+ throw new CommandExecutionError(
132
+ `Suno captcha pre-flight failed${captcha?.status ? ` (HTTP ${captcha.status})` : ''}.`,
133
+ `Open ${SUNO_URL}/create in Chrome and verify the account is ready, then retry.`,
134
+ );
135
+ }
136
+ if (captcha?.required) {
137
+ throw new CommandExecutionError(
138
+ 'Suno requires a CAPTCHA challenge for this account/IP right now.',
139
+ `Open ${SUNO_URL}/create in Chrome, solve a Create challenge once, then retry.`,
140
+ );
141
+ }
142
+ if (session.totalCreditsAvailable < 10) {
143
+ const b = session.breakdown;
144
+ throw new CommandExecutionError(
145
+ `Suno generation needs ~10 credits; you have ${session.totalCreditsAvailable} (monthly ${b.monthlyRemaining}/${b.monthlyLimit} + packs ${b.purchasedPacks} + leftover ${b.pack}). Top up at ${SUNO_URL}/account.`,
146
+ );
147
+ }
148
+
149
+ const transactionUuid = crypto.randomUUID();
150
+ const createSessionToken = crypto.randomUUID();
151
+
152
+ const submission = await submitSunoGeneration(page, {
153
+ mode: isCustom ? 'custom' : 'simple',
154
+ model,
155
+ title,
156
+ lyrics,
157
+ tags,
158
+ negativeTags,
159
+ description,
160
+ makeInstrumental,
161
+ weirdness,
162
+ styleWeight,
163
+ userTier: session.planId,
164
+ createSessionToken,
165
+ transactionUuid,
166
+ deviceId,
167
+ });
168
+
169
+ if (!Array.isArray(submission.clips)) {
170
+ throw new CommandExecutionError('Suno generation returned malformed clips payload.');
171
+ }
172
+ const clipIds = submission.clips.map(c => c?.id);
173
+ if (clipIds.some(id => !id)) {
174
+ throw new CommandExecutionError('Suno generation returned malformed clip identity.');
175
+ }
176
+ if (!clipIds.length) {
177
+ throw new CommandExecutionError('Suno accepted the request but returned no clip ids.');
178
+ }
179
+
180
+ const clips = await pollSunoClips(page, clipIds, timeout, deviceId);
181
+ const completed = clips.filter(c => c.status === 'complete');
182
+ if (!completed.length) {
183
+ const errors = clips.map(c => `${c.id.slice(0, 8)}:${c.status}`).join(', ');
184
+ throw new CommandExecutionError(`All Suno clips failed (${errors}). Open ${SUNO_URL}/song/${clipIds[0]} to inspect.`);
185
+ }
186
+
187
+ const rows = [];
188
+ for (const clip of clips) {
189
+ const link = `${SUNO_URL}/song/${clip.id}`;
190
+ if (clip.status !== 'complete') {
191
+ rows.push({
192
+ status: `❌ ${clip.status}`,
193
+ clip: clip.id.slice(0, 8),
194
+ title: clip.title || '(untitled)',
195
+ files: '-',
196
+ link: `🔗 ${link}`,
197
+ });
198
+ continue;
199
+ }
200
+ if (skipDownload) {
201
+ rows.push({
202
+ status: '🎵 generated',
203
+ clip: clip.id.slice(0, 8),
204
+ title: clip.title || '(untitled)',
205
+ files: '📁 -',
206
+ link: `🔗 ${link}`,
207
+ });
208
+ continue;
209
+ }
210
+ const result = await downloadSunoClip(page, clip, outputDir, formats, deviceId);
211
+ if (!result.written.some(w => w.ok)) {
212
+ throw new CommandExecutionError(`Suno download wrote no files for clip ${clip.id}`);
213
+ }
214
+ const writtenSummary = result.written
215
+ .map(w => w.ok ? `${w.format}:${displayPath(w.file)}` : `${w.format}:✗(${w.reason})`)
216
+ .join(' | ');
217
+ const skippedSummary = skippedPaid.length
218
+ ? ` | skipped(needs --confirm-paid):${skippedPaid.join(',')}`
219
+ : '';
220
+ const anyFailed = result.written.some(w => !w.ok);
221
+ rows.push({
222
+ status: anyFailed ? '⚠ partial' : '✅ saved',
223
+ clip: clip.id.slice(0, 8),
224
+ title: clip.title || '(untitled)',
225
+ files: `📁 ${writtenSummary}${skippedSummary}`,
226
+ link: `🔗 ${link}`,
227
+ });
228
+ }
229
+ return rows;
230
+ },
231
+ });
@@ -0,0 +1,252 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ ensureSunoSession: vi.fn(),
5
+ checkSunoCaptcha: vi.fn(),
6
+ submitSunoGeneration: vi.fn(),
7
+ pollSunoClips: vi.fn(),
8
+ downloadSunoClip: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('./utils.js', () => ({
12
+ DEFAULT_SUNO_MODEL: 'chirp-fenix',
13
+ SUNO_DOMAIN: 'suno.com',
14
+ SUNO_MODELS: ['chirp-fenix', 'chirp-bluejay', 'chirp-v4', 'chirp-v3-5'],
15
+ SUNO_URL: 'https://suno.com',
16
+ ensureSunoSession: mocks.ensureSunoSession,
17
+ checkSunoCaptcha: mocks.checkSunoCaptcha,
18
+ submitSunoGeneration: mocks.submitSunoGeneration,
19
+ pollSunoClips: mocks.pollSunoClips,
20
+ downloadSunoClip: mocks.downloadSunoClip,
21
+ clampSlider: (value, _label, def) => (value === undefined || value === '' || value === null ? def : Number(value)),
22
+ normalizeBooleanFlag: (value, fallback = false) => {
23
+ if (typeof value === 'boolean') return value;
24
+ if (value == null || value === '') return fallback;
25
+ const s = String(value).trim().toLowerCase();
26
+ return s === 'true' || s === '1' || s === 'yes' || s === 'on';
27
+ },
28
+ parseFormats: (value) => {
29
+ if (!value) return ['mp3', 'metadata'];
30
+ const raw = Array.isArray(value) ? value.join(',') : String(value);
31
+ return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
32
+ },
33
+ requirePositiveInt: (value) => {
34
+ const n = Number(value);
35
+ if (!Number.isInteger(n) || n < 1) throw new Error('positive int required');
36
+ return n;
37
+ },
38
+ resolveSunoOutputDir: (value) => value || '/tmp/suno-test',
39
+ }));
40
+
41
+ const { generateCommand } = await import('./generate.js');
42
+
43
+ function createPage() {
44
+ return {
45
+ goto: vi.fn().mockResolvedValue(undefined),
46
+ wait: vi.fn().mockResolvedValue(undefined),
47
+ evaluate: vi.fn().mockResolvedValue(undefined),
48
+ };
49
+ }
50
+
51
+ const okSession = {
52
+ ok: true,
53
+ planId: '3eaebef3-ef46-446a-931c-3d50cd1514f1',
54
+ planKey: 'pro',
55
+ totalCreditsAvailable: 2000,
56
+ breakdown: { pack: 0, purchasedPacks: 0, monthlyRemaining: 2000, monthlyLimit: 2500, monthlyUsed: 500 },
57
+ deviceId: 'device-uuid',
58
+ };
59
+ const okCaptcha = { ok: true, required: false };
60
+ const okSubmission = { id: 'batch-id', clips: [{ id: 'clip-a-id', status: 'submitted' }, { id: 'clip-b-id', status: 'submitted' }] };
61
+ const okClips = [
62
+ { id: 'clip-a-id', status: 'complete', title: 'Clip A', audio_url: 'https://cdn1.suno.ai/clip-a-id.mp3' },
63
+ { id: 'clip-b-id', status: 'complete', title: 'Clip B', audio_url: 'https://cdn1.suno.ai/clip-b-id.mp3' },
64
+ ];
65
+
66
+ beforeEach(() => {
67
+ vi.restoreAllMocks();
68
+ mocks.ensureSunoSession.mockReset().mockResolvedValue(okSession);
69
+ mocks.checkSunoCaptcha.mockReset().mockResolvedValue(okCaptcha);
70
+ mocks.submitSunoGeneration.mockReset().mockResolvedValue(okSubmission);
71
+ mocks.pollSunoClips.mockReset().mockResolvedValue(okClips);
72
+ mocks.downloadSunoClip.mockReset().mockResolvedValue({ slug: 'clip', written: [{ format: 'mp3', file: '/tmp/x.mp3', ok: true }, { format: 'metadata', file: '/tmp/x.json', ok: true }] });
73
+ });
74
+
75
+ describe('suno generate argument validation', () => {
76
+ it('rejects calls with neither a Simple prompt nor --lyrics', async () => {
77
+ await expect(generateCommand.func(createPage(), { sd: true, timeout: 60 })).rejects.toMatchObject({
78
+ code: 'ARGUMENT',
79
+ message: expect.stringContaining('Either provide a Simple-mode prompt'),
80
+ });
81
+ });
82
+
83
+ it('rejects --tags / --negative-tags in Simple mode', async () => {
84
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', tags: 'lo-fi', sd: true, timeout: 60 })).rejects.toMatchObject({
85
+ code: 'ARGUMENT',
86
+ message: expect.stringContaining('only apply in Custom mode'),
87
+ });
88
+ });
89
+
90
+ it('rejects unsupported --model values', async () => {
91
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', model: 'chirp-vNEXT', sd: true, timeout: 60 })).rejects.toMatchObject({
92
+ code: 'ARGUMENT',
93
+ message: expect.stringContaining('Unsupported --model'),
94
+ });
95
+ });
96
+
97
+ it('refuses to submit when planId is null (free-tier without resolved user_tier) (#1704)', async () => {
98
+ mocks.ensureSunoSession.mockResolvedValue({ ...okSession, planId: null, planKey: 'free' });
99
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
100
+ code: 'COMMAND_EXEC',
101
+ message: expect.stringContaining('plan id'),
102
+ });
103
+ expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('refuses to submit when credits are below the per-song minimum', async () => {
107
+ mocks.ensureSunoSession.mockResolvedValue({ ...okSession, totalCreditsAvailable: 5, breakdown: { ...okSession.breakdown, monthlyRemaining: 5 } });
108
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
109
+ code: 'COMMAND_EXEC',
110
+ message: expect.stringContaining('needs ~10 credits'),
111
+ });
112
+ expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('refuses to submit when captcha is required (out-of-scope for headless flow)', async () => {
116
+ mocks.checkSunoCaptcha.mockResolvedValue({ ok: true, required: true });
117
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
118
+ code: 'COMMAND_EXEC',
119
+ message: expect.stringContaining('CAPTCHA challenge'),
120
+ });
121
+ expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('refuses to submit when captcha pre-flight fails', async () => {
125
+ mocks.checkSunoCaptcha.mockResolvedValue({ ok: false, status: 500 });
126
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
127
+ code: 'COMMAND_EXEC',
128
+ message: expect.stringContaining('captcha pre-flight failed'),
129
+ });
130
+ expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ describe('suno generate Simple mode payload', () => {
135
+ it('routes the positional prompt through `description` (Simple mode)', async () => {
136
+ await generateCommand.func(createPage(), { prompt: 'lo-fi study beat', sd: true, timeout: 60 });
137
+ expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
138
+ mode: 'simple',
139
+ description: 'lo-fi study beat',
140
+ lyrics: '',
141
+ tags: '',
142
+ negativeTags: '',
143
+ userTier: okSession.planId,
144
+ deviceId: okSession.deviceId,
145
+ }));
146
+ });
147
+ });
148
+
149
+ describe('suno generate Custom mode payload', () => {
150
+ it('routes --lyrics through `lyrics` and --tags through `tags`', async () => {
151
+ await generateCommand.func(createPage(), {
152
+ lyrics: '[Verse]\\nfoo',
153
+ tags: 'synthwave, 95 BPM',
154
+ 'negative-tags': 'vocals',
155
+ title: 'Night Rain',
156
+ sd: true,
157
+ timeout: 60,
158
+ });
159
+ expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
160
+ mode: 'custom',
161
+ lyrics: '[Verse]\\nfoo',
162
+ tags: 'synthwave, 95 BPM',
163
+ negativeTags: 'vocals',
164
+ title: 'Night Rain',
165
+ }));
166
+ });
167
+
168
+ it('threads --weirdness and --style-weight as numeric sliders', async () => {
169
+ await generateCommand.func(createPage(), {
170
+ lyrics: '[Verse]\\nfoo',
171
+ weirdness: '0.74',
172
+ 'style-weight': '0.57',
173
+ sd: true,
174
+ timeout: 60,
175
+ });
176
+ expect(mocks.submitSunoGeneration).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
177
+ weirdness: 0.74,
178
+ styleWeight: 0.57,
179
+ }));
180
+ });
181
+ });
182
+
183
+ describe('suno generate paid-format guard', () => {
184
+ it('skips wav by default and surfaces the skip in the result row', async () => {
185
+ const out = await generateCommand.func(createPage(), {
186
+ prompt: 'foo',
187
+ formats: 'mp3,wav,metadata',
188
+ timeout: 60,
189
+ });
190
+ // downloadSunoClip should be called with mp3+metadata, NOT wav
191
+ expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), ['mp3', 'metadata'], okSession.deviceId);
192
+ expect(out[0].files).toContain('skipped(needs --confirm-paid):wav');
193
+ });
194
+
195
+ it('includes wav when --confirm-paid is true', async () => {
196
+ await generateCommand.func(createPage(), {
197
+ prompt: 'foo',
198
+ formats: 'mp3,wav',
199
+ 'confirm-paid': true,
200
+ timeout: 60,
201
+ });
202
+ expect(mocks.downloadSunoClip).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), ['mp3', 'wav'], okSession.deviceId);
203
+ });
204
+
205
+ it('rejects before generation when every requested format is paid and unconfirmed', async () => {
206
+ await expect(generateCommand.func(createPage(), {
207
+ prompt: 'foo',
208
+ formats: 'wav',
209
+ timeout: 60,
210
+ })).rejects.toMatchObject({
211
+ code: 'ARGUMENT',
212
+ message: expect.stringContaining('All requested formats require'),
213
+ });
214
+ expect(mocks.submitSunoGeneration).not.toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ describe('suno generate failure paths', () => {
219
+ it('reports all-failed clips with a typed error', async () => {
220
+ mocks.pollSunoClips.mockResolvedValue([
221
+ { id: 'clip-a-id', status: 'error' },
222
+ { id: 'clip-b-id', status: 'error' },
223
+ ]);
224
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
225
+ code: 'COMMAND_EXEC',
226
+ message: expect.stringContaining('All Suno clips failed'),
227
+ });
228
+ });
229
+
230
+ it('fails typed when generation response clips are malformed', async () => {
231
+ mocks.submitSunoGeneration.mockResolvedValue({ clips: [{ status: 'submitted' }] });
232
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 })).rejects.toMatchObject({
233
+ code: 'COMMAND_EXEC',
234
+ message: expect.stringContaining('malformed clip identity'),
235
+ });
236
+ });
237
+
238
+ it('fails typed when download post-condition writes no files', async () => {
239
+ mocks.downloadSunoClip.mockResolvedValue({ slug: 'clip', written: [{ format: 'mp3', file: null, ok: false, reason: 'HTTP 500' }] });
240
+ await expect(generateCommand.func(createPage(), { prompt: 'foo', formats: 'mp3', timeout: 60 })).rejects.toMatchObject({
241
+ code: 'COMMAND_EXEC',
242
+ message: expect.stringContaining('wrote no files'),
243
+ });
244
+ });
245
+
246
+ it('skip-download mode returns generated rows without invoking downloadSunoClip', async () => {
247
+ const out = await generateCommand.func(createPage(), { prompt: 'foo', sd: true, timeout: 60 });
248
+ expect(out).toHaveLength(2);
249
+ expect(out[0].status).toBe('🎵 generated');
250
+ expect(mocks.downloadSunoClip).not.toHaveBeenCalled();
251
+ });
252
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `opencli suno list` — list recent clips in the user's library. Lets agents
3
+ * discover clip ids without needing to remember them, and feed them to
4
+ * `opencli suno download`.
5
+ */
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+ import {
9
+ STUDIO_API,
10
+ SUNO_DOMAIN,
11
+ SUNO_URL,
12
+ ensureSunoSession,
13
+ requireNonNegativeInt,
14
+ requirePositiveInt,
15
+ unwrapEvaluateResult,
16
+ } from './utils.js';
17
+
18
+ export const listCommand = cli({
19
+ site: 'suno',
20
+ name: 'list',
21
+ access: 'read',
22
+ description: 'List recent Suno clips in your library (id, title, status, created_at, link)',
23
+ domain: SUNO_DOMAIN,
24
+ strategy: Strategy.COOKIE,
25
+ browser: true,
26
+ siteSession: 'persistent',
27
+ navigateBefore: false,
28
+ args: [
29
+ { name: 'limit', type: 'int', default: 20, help: 'Max clips to list (default: 20)' },
30
+ { name: 'page', type: 'int', default: 0, help: 'Pagination offset, 0-based (default: 0)' },
31
+ ],
32
+ columns: ['rank', 'clip', 'title', 'status', 'created', 'link'],
33
+ func: async (page, kwargs) => {
34
+ const limit = requirePositiveInt(kwargs.limit, '--limit');
35
+ const pageOffset = requireNonNegativeInt(kwargs.page ?? 0, '--page');
36
+
37
+ const session = await ensureSunoSession(page);
38
+ const deviceId = session.deviceId;
39
+
40
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
41
+ const browserToken = JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) });
42
+ const res = await fetch('${STUDIO_API}/api/feed/v2?page=${pageOffset}', {
43
+ headers: {
44
+ 'Authorization': 'Bearer ' + (await window.Clerk.session.getToken()),
45
+ 'browser-token': browserToken,
46
+ 'device-id': ${JSON.stringify(deviceId)},
47
+ },
48
+ });
49
+ if (!res.ok) return { ok: false, status: res.status };
50
+ const data = await res.json().catch(() => null);
51
+ if (!data || !Array.isArray(data.clips)) return { ok: false, error: 'malformed clips payload' };
52
+ return { ok: true, clips: data.clips };
53
+ })()`));
54
+
55
+ if (!result?.ok) {
56
+ throw new CommandExecutionError(result?.error || `Suno feed lookup failed (HTTP ${result?.status || '?'}).`);
57
+ }
58
+ if (!Array.isArray(result.clips)) {
59
+ throw new CommandExecutionError('Suno feed lookup returned malformed clips payload');
60
+ }
61
+ if (result.clips.length === 0) {
62
+ throw new EmptyResultError('suno list', 'No Suno clips found in your library.');
63
+ }
64
+
65
+ return result.clips.slice(0, limit).map((c, i) => {
66
+ if (!c || typeof c.id !== 'string' || !c.id) {
67
+ throw new CommandExecutionError('Suno feed lookup returned malformed clip identity');
68
+ }
69
+ return {
70
+ rank: i + 1 + pageOffset * limit,
71
+ clip: c.id.slice(0, 8),
72
+ title: c.title || '(untitled)',
73
+ status: c.status || '?',
74
+ created: (c.created_at || '').replace('T', ' ').replace(/\..*$/, '').replace(/Z$/, ''),
75
+ link: `${SUNO_URL}/song/${c.id}`,
76
+ };
77
+ });
78
+ },
79
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `opencli suno status` — quick health check: login state, plan, credit
3
+ * breakdown, captcha readiness. Lets agents pre-flight before spending
4
+ * generate credits.
5
+ */
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
8
+ import {
9
+ SUNO_DOMAIN,
10
+ checkSunoCaptcha,
11
+ ensureSunoSession,
12
+ } from './utils.js';
13
+
14
+ export const statusCommand = cli({
15
+ site: 'suno',
16
+ name: 'status',
17
+ access: 'read',
18
+ description: 'Check Suno login, plan, credit balance, and captcha readiness',
19
+ domain: SUNO_DOMAIN,
20
+ strategy: Strategy.COOKIE,
21
+ browser: true,
22
+ siteSession: 'persistent',
23
+ navigateBefore: false,
24
+ args: [],
25
+ columns: ['Status', 'Plan', 'Credits', 'Monthly', 'Captcha'],
26
+ func: async (page) => {
27
+ let session;
28
+ try {
29
+ session = await ensureSunoSession(page);
30
+ } catch (err) {
31
+ if (err instanceof AuthRequiredError) {
32
+ return [{
33
+ Status: 'Not logged in',
34
+ Plan: '-',
35
+ Credits: '-',
36
+ Monthly: '-',
37
+ Captcha: '-',
38
+ }];
39
+ }
40
+ throw err;
41
+ }
42
+ let captcha;
43
+ try {
44
+ captcha = await checkSunoCaptcha(page, session.deviceId);
45
+ } catch (err) {
46
+ // Conservative: assume captcha is required when the pre-flight
47
+ // probe fails, so the displayed status doesn't claim "Not required"
48
+ // for an unverified state.
49
+ captcha = { required: true };
50
+ }
51
+ const b = session.breakdown;
52
+ // ensureSunoSession surfaces planKey derived from billing/info's
53
+ // subscription_type vs plans[] lookup; planId may be null for accounts
54
+ // whose plan cannot be resolved against plans[] (e.g., new schema fields).
55
+ return [{
56
+ Status: 'Connected',
57
+ Plan: session.planKey,
58
+ Credits: String(session.totalCreditsAvailable),
59
+ Monthly: `${b.monthlyRemaining}/${b.monthlyLimit}`,
60
+ Captcha: captcha?.required === true ? 'Required (solve in UI)' : 'Not required',
61
+ }];
62
+ },
63
+ });