@jackwener/opencli 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +44 -5
  3. package/README.zh-CN.md +44 -5
  4. package/SKILL.md +317 -5
  5. package/TESTING.md +4 -4
  6. package/dist/browser/errors.d.ts +2 -1
  7. package/dist/browser/errors.js +9 -10
  8. package/dist/build-manifest.js +1 -3
  9. package/dist/cli-manifest.json +2573 -989
  10. package/dist/cli.js +42 -2
  11. package/dist/clis/bilibili/download.js +20 -65
  12. package/dist/clis/bilibili/utils.js +2 -1
  13. package/dist/clis/chaoxing/assignments.js +2 -1
  14. package/dist/clis/doubao/ask.d.ts +1 -0
  15. package/dist/clis/doubao/ask.js +35 -0
  16. package/dist/clis/doubao/common.d.ts +23 -0
  17. package/dist/clis/doubao/common.js +564 -0
  18. package/dist/clis/doubao/new.d.ts +1 -0
  19. package/dist/clis/doubao/new.js +20 -0
  20. package/dist/clis/doubao/read.d.ts +1 -0
  21. package/dist/clis/doubao/read.js +19 -0
  22. package/dist/clis/doubao/send.d.ts +1 -0
  23. package/dist/clis/doubao/send.js +22 -0
  24. package/dist/clis/doubao/status.d.ts +1 -0
  25. package/dist/clis/doubao/status.js +24 -0
  26. package/dist/clis/doubao-app/ask.d.ts +1 -0
  27. package/dist/clis/doubao-app/ask.js +53 -0
  28. package/dist/clis/doubao-app/common.d.ts +37 -0
  29. package/dist/clis/doubao-app/common.js +110 -0
  30. package/dist/clis/doubao-app/dump.d.ts +1 -0
  31. package/dist/clis/doubao-app/dump.js +24 -0
  32. package/dist/clis/doubao-app/new.d.ts +1 -0
  33. package/dist/clis/doubao-app/new.js +20 -0
  34. package/dist/clis/doubao-app/read.d.ts +1 -0
  35. package/dist/clis/doubao-app/read.js +18 -0
  36. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  37. package/dist/clis/doubao-app/screenshot.js +18 -0
  38. package/dist/clis/doubao-app/send.d.ts +1 -0
  39. package/dist/clis/doubao-app/send.js +27 -0
  40. package/dist/clis/doubao-app/status.d.ts +1 -0
  41. package/dist/clis/doubao-app/status.js +16 -0
  42. package/dist/clis/hackernews/ask.yaml +38 -0
  43. package/dist/clis/hackernews/best.yaml +38 -0
  44. package/dist/clis/hackernews/jobs.yaml +36 -0
  45. package/dist/clis/hackernews/new.yaml +38 -0
  46. package/dist/clis/hackernews/search.yaml +44 -0
  47. package/dist/clis/hackernews/show.yaml +38 -0
  48. package/dist/clis/hackernews/top.yaml +3 -1
  49. package/dist/clis/hackernews/user.yaml +25 -0
  50. package/dist/clis/twitter/download.js +13 -97
  51. package/dist/clis/twitter/thread.js +2 -1
  52. package/dist/clis/v2ex/member.yaml +29 -0
  53. package/dist/clis/v2ex/node.yaml +34 -0
  54. package/dist/clis/v2ex/nodes.yaml +31 -0
  55. package/dist/clis/v2ex/replies.yaml +32 -0
  56. package/dist/clis/v2ex/user.yaml +34 -0
  57. package/dist/clis/weibo/search.d.ts +1 -0
  58. package/dist/clis/weibo/search.js +73 -0
  59. package/dist/clis/weixin/download.d.ts +12 -0
  60. package/dist/clis/weixin/download.js +183 -0
  61. package/dist/clis/xiaohongshu/download.js +12 -60
  62. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  63. package/dist/clis/xiaohongshu/publish.js +352 -0
  64. package/dist/clis/xiaohongshu/search.js +47 -15
  65. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  66. package/dist/clis/xiaohongshu/search.test.js +114 -0
  67. package/dist/clis/yollomi/background.d.ts +4 -0
  68. package/dist/clis/yollomi/background.js +45 -0
  69. package/dist/clis/yollomi/edit.d.ts +5 -0
  70. package/dist/clis/yollomi/edit.js +56 -0
  71. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  72. package/dist/clis/yollomi/face-swap.js +43 -0
  73. package/dist/clis/yollomi/generate.d.ts +9 -0
  74. package/dist/clis/yollomi/generate.js +100 -0
  75. package/dist/clis/yollomi/models.d.ts +1 -0
  76. package/dist/clis/yollomi/models.js +33 -0
  77. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  78. package/dist/clis/yollomi/object-remover.js +42 -0
  79. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  80. package/dist/clis/yollomi/remove-bg.js +38 -0
  81. package/dist/clis/yollomi/restore.d.ts +4 -0
  82. package/dist/clis/yollomi/restore.js +38 -0
  83. package/dist/clis/yollomi/try-on.d.ts +4 -0
  84. package/dist/clis/yollomi/try-on.js +46 -0
  85. package/dist/clis/yollomi/upload.d.ts +7 -0
  86. package/dist/clis/yollomi/upload.js +71 -0
  87. package/dist/clis/yollomi/upscale.d.ts +4 -0
  88. package/dist/clis/yollomi/upscale.js +53 -0
  89. package/dist/clis/yollomi/utils.d.ts +45 -0
  90. package/dist/clis/yollomi/utils.js +180 -0
  91. package/dist/clis/yollomi/video.d.ts +5 -0
  92. package/dist/clis/yollomi/video.js +56 -0
  93. package/dist/clis/zhihu/download.d.ts +1 -5
  94. package/dist/clis/zhihu/download.js +20 -126
  95. package/dist/clis/zhihu/download.test.js +7 -5
  96. package/dist/clis/zhihu/question.js +2 -1
  97. package/dist/commanderAdapter.js +4 -6
  98. package/dist/daemon.js +5 -2
  99. package/dist/discovery.js +10 -10
  100. package/dist/download/article-download.d.ts +59 -0
  101. package/dist/download/article-download.js +178 -0
  102. package/dist/download/media-download.d.ts +49 -0
  103. package/dist/download/media-download.js +112 -0
  104. package/dist/errors.d.ts +23 -2
  105. package/dist/errors.js +58 -2
  106. package/dist/errors.test.d.ts +1 -0
  107. package/dist/errors.test.js +59 -0
  108. package/dist/execution.js +9 -10
  109. package/dist/explore.js +4 -2
  110. package/dist/external.d.ts +15 -0
  111. package/dist/external.js +48 -2
  112. package/dist/external.test.d.ts +1 -0
  113. package/dist/external.test.js +64 -0
  114. package/dist/main.js +10 -0
  115. package/dist/plugin.d.ts +4 -0
  116. package/dist/plugin.js +45 -23
  117. package/dist/plugin.test.js +6 -1
  118. package/dist/record.d.ts +47 -0
  119. package/dist/record.js +545 -0
  120. package/dist/registry.d.ts +7 -2
  121. package/dist/registry.js +2 -6
  122. package/dist/runtime.d.ts +3 -1
  123. package/dist/runtime.js +10 -3
  124. package/dist/validate.js +1 -3
  125. package/docs/.vitepress/config.mts +1 -0
  126. package/docs/adapters/browser/doubao.md +35 -0
  127. package/docs/adapters/browser/hackernews.md +20 -4
  128. package/docs/adapters/browser/tiktok.md +1 -1
  129. package/docs/adapters/browser/v2ex.md +31 -10
  130. package/docs/adapters/browser/weibo.md +4 -0
  131. package/docs/adapters/browser/weixin.md +33 -0
  132. package/docs/adapters/browser/xiaohongshu.md +8 -6
  133. package/docs/adapters/browser/yollomi.md +69 -0
  134. package/docs/adapters/desktop/doubao-app.md +35 -0
  135. package/docs/adapters/index.md +16 -5
  136. package/docs/advanced/download.md +4 -0
  137. package/package.json +3 -1
  138. package/src/browser/errors.ts +17 -11
  139. package/src/build-manifest.ts +2 -3
  140. package/src/cli.ts +45 -2
  141. package/src/clis/bilibili/download.ts +25 -83
  142. package/src/clis/bilibili/utils.ts +2 -1
  143. package/src/clis/chaoxing/assignments.ts +2 -1
  144. package/src/clis/doubao/ask.ts +40 -0
  145. package/src/clis/doubao/common.ts +619 -0
  146. package/src/clis/doubao/new.ts +22 -0
  147. package/src/clis/doubao/read.ts +20 -0
  148. package/src/clis/doubao/send.ts +25 -0
  149. package/src/clis/doubao/status.ts +27 -0
  150. package/src/clis/doubao-app/ask.ts +60 -0
  151. package/src/clis/doubao-app/common.ts +116 -0
  152. package/src/clis/doubao-app/dump.ts +28 -0
  153. package/src/clis/doubao-app/new.ts +21 -0
  154. package/src/clis/doubao-app/read.ts +21 -0
  155. package/src/clis/doubao-app/screenshot.ts +19 -0
  156. package/src/clis/doubao-app/send.ts +30 -0
  157. package/src/clis/doubao-app/status.ts +17 -0
  158. package/src/clis/hackernews/ask.yaml +38 -0
  159. package/src/clis/hackernews/best.yaml +38 -0
  160. package/src/clis/hackernews/jobs.yaml +36 -0
  161. package/src/clis/hackernews/new.yaml +38 -0
  162. package/src/clis/hackernews/search.yaml +44 -0
  163. package/src/clis/hackernews/show.yaml +38 -0
  164. package/src/clis/hackernews/top.yaml +3 -1
  165. package/src/clis/hackernews/user.yaml +25 -0
  166. package/src/clis/twitter/download.ts +13 -111
  167. package/src/clis/twitter/thread.ts +2 -1
  168. package/src/clis/v2ex/member.yaml +29 -0
  169. package/src/clis/v2ex/node.yaml +34 -0
  170. package/src/clis/v2ex/nodes.yaml +31 -0
  171. package/src/clis/v2ex/replies.yaml +32 -0
  172. package/src/clis/v2ex/user.yaml +34 -0
  173. package/src/clis/weibo/search.ts +78 -0
  174. package/src/clis/weixin/download.ts +199 -0
  175. package/src/clis/xiaohongshu/download.ts +12 -71
  176. package/src/clis/xiaohongshu/publish.ts +392 -0
  177. package/src/clis/xiaohongshu/search.test.ts +134 -0
  178. package/src/clis/xiaohongshu/search.ts +49 -15
  179. package/src/clis/yollomi/background.ts +48 -0
  180. package/src/clis/yollomi/edit.ts +58 -0
  181. package/src/clis/yollomi/face-swap.ts +45 -0
  182. package/src/clis/yollomi/generate.ts +95 -0
  183. package/src/clis/yollomi/models.ts +38 -0
  184. package/src/clis/yollomi/object-remover.ts +44 -0
  185. package/src/clis/yollomi/remove-bg.ts +40 -0
  186. package/src/clis/yollomi/restore.ts +40 -0
  187. package/src/clis/yollomi/try-on.ts +48 -0
  188. package/src/clis/yollomi/upload.ts +78 -0
  189. package/src/clis/yollomi/upscale.ts +49 -0
  190. package/src/clis/yollomi/utils.ts +202 -0
  191. package/src/clis/yollomi/video.ts +61 -0
  192. package/src/clis/zhihu/download.test.ts +7 -5
  193. package/src/clis/zhihu/download.ts +23 -158
  194. package/src/clis/zhihu/question.ts +2 -1
  195. package/src/commanderAdapter.ts +4 -7
  196. package/src/daemon.ts +5 -2
  197. package/src/discovery.ts +26 -26
  198. package/src/download/article-download.ts +272 -0
  199. package/src/download/media-download.ts +178 -0
  200. package/src/errors.test.ts +79 -0
  201. package/src/errors.ts +92 -2
  202. package/src/execution.ts +14 -10
  203. package/src/explore.ts +4 -2
  204. package/src/external.test.ts +88 -0
  205. package/src/external.ts +56 -2
  206. package/src/generate.ts +2 -1
  207. package/src/main.ts +10 -0
  208. package/src/plugin.test.ts +7 -1
  209. package/src/plugin.ts +49 -25
  210. package/src/record.ts +617 -0
  211. package/src/registry.ts +9 -5
  212. package/src/runtime.ts +16 -4
  213. package/src/validate.ts +2 -3
  214. package/tests/e2e/browser-auth.test.ts +10 -1
  215. package/tests/e2e/browser-public.test.ts +13 -8
  216. package/tests/e2e/public-commands.test.ts +209 -21
  217. package/tests/smoke/api-health.test.ts +65 -6
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { cli, Strategy } from '../../registry.js';
10
+ import { AuthRequiredError } from '../../errors.js';
10
11
 
11
12
  cli({
12
13
  site: 'xiaohongshu',
@@ -18,7 +19,7 @@ cli({
18
19
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
19
20
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
20
21
  ],
21
- columns: ['rank', 'title', 'author', 'likes'],
22
+ columns: ['rank', 'title', 'author', 'likes', 'url'],
22
23
  func: async (page, kwargs) => {
23
24
  const keyword = encodeURIComponent(kwargs.query);
24
25
  await page.goto(
@@ -29,34 +30,67 @@ cli({
29
30
  // Scroll a couple of times to load more results
30
31
  await page.autoScroll({ times: 2 });
31
32
 
32
- const data = await page.evaluate(`
33
+ const payload = await page.evaluate(`
33
34
  (() => {
34
- const notes = document.querySelectorAll('section.note-item');
35
+ const loginWall = /登录后查看搜索结果/.test(document.body.innerText || '');
36
+
37
+ const normalizeUrl = (href) => {
38
+ if (!href) return '';
39
+ if (href.startsWith('http://') || href.startsWith('https://')) return href;
40
+ if (href.startsWith('/')) return 'https://www.xiaohongshu.com' + href;
41
+ return '';
42
+ };
43
+
44
+ const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
45
+
35
46
  const results = [];
36
- notes.forEach(el => {
47
+ const seen = new Set();
48
+
49
+ document.querySelectorAll('section.note-item').forEach(el => {
37
50
  // Skip "related searches" sections
38
51
  if (el.classList.contains('query-note-item')) return;
39
52
 
40
- const titleEl = el.querySelector('.title, .note-title, a.title');
41
- const nameEl = el.querySelector('.name, .author-name, .nick-name');
53
+ const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
54
+ const nameEl = el.querySelector('a.author .name, .name, .author-name, .nick-name, a.author');
42
55
  const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
43
- const linkEl = el.querySelector('a[href*="/explore/"], a[href*="/search_result/"], a[href*="/note/"]');
56
+ // Prefer search_result link (preserves xsec_token) over generic /explore/ link
57
+ const detailLinkEl =
58
+ el.querySelector('a.cover.mask') ||
59
+ el.querySelector('a[href*="/search_result/"]') ||
60
+ el.querySelector('a[href*="/explore/"]') ||
61
+ el.querySelector('a[href*="/note/"]');
62
+ const authorLinkEl = el.querySelector('a.author, a[href*="/user/profile/"]');
44
63
 
45
- const href = linkEl?.getAttribute('href') || '';
46
- const noteId = href.match(/\\/(?:explore|note)\\/([a-zA-Z0-9]+)/)?.[1] || '';
64
+ const url = normalizeUrl(detailLinkEl?.getAttribute('href') || '');
65
+ if (!url) return;
66
+
67
+ const key = url;
68
+ if (seen.has(key)) return;
69
+ seen.add(key);
47
70
 
48
71
  results.push({
49
- title: (titleEl?.textContent || '').trim(),
50
- author: (nameEl?.textContent || '').trim(),
51
- likes: (likesEl?.textContent || '0').trim(),
52
- url: noteId ? 'https://www.xiaohongshu.com/explore/' + noteId : '',
72
+ title: cleanText(titleEl?.textContent || ''),
73
+ author: cleanText(nameEl?.textContent || ''),
74
+ likes: cleanText(likesEl?.textContent || '0'),
75
+ url,
76
+ author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
53
77
  });
54
78
  });
55
- return results;
79
+
80
+ return {
81
+ loginWall,
82
+ results,
83
+ };
56
84
  })()
57
85
  `);
58
86
 
59
- if (!Array.isArray(data)) return [];
87
+ if (!payload || typeof payload !== 'object') return [];
88
+
89
+ if ((payload as any).loginWall) {
90
+ throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
91
+ }
92
+
93
+ const data: any[] = Array.isArray((payload as any).results) ? (payload as any).results : [];
60
94
  return data
61
95
  .filter((item: any) => item.title)
62
96
  .slice(0, kwargs.limit)
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Yollomi AI background generator — POST /api/ai/ai-background-generator
3
+ */
4
+
5
+ import * as path from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'background',
14
+ description: 'Generate AI background for a product/object image (5 credits)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'image', positional: true, required: true, help: 'Image URL (upload via "opencli yollomi upload" first)' },
19
+ { name: 'prompt', default: '', help: 'Background description (optional)' },
20
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
21
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
22
+ ],
23
+ columns: ['status', 'file', 'size', 'url'],
24
+ func: async (page, kwargs) => {
25
+ const imageUrl = kwargs.image as string;
26
+ const prompt = kwargs.prompt as string;
27
+
28
+ process.stderr.write(chalk.dim('Generating background...\n'));
29
+ const data = await yollomiPost(page, '/api/ai/ai-background-generator', {
30
+ images: [imageUrl],
31
+ prompt: prompt || undefined,
32
+ aspect_ratio: '1:1',
33
+ });
34
+
35
+ const url = data.image || (data.images?.[0]);
36
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Try a different image');
37
+
38
+ if (kwargs['no-download']) return [{ status: 'generated', file: '-', size: '-', url }];
39
+
40
+ try {
41
+ const filename = `yollomi_bg_${Date.now()}.png`;
42
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
43
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
44
+ } catch {
45
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
46
+ }
47
+ },
48
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Yollomi image editing — POST /api/ai/qwen-image-edit
3
+ * Matches frontend workspace-generator.tsx for qwen-image-edit model.
4
+ */
5
+
6
+ import * as path from 'node:path';
7
+ import chalk from 'chalk';
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { CliError } from '../../errors.js';
10
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
11
+
12
+ cli({
13
+ site: 'yollomi',
14
+ name: 'edit',
15
+ description: 'Edit images with AI text prompts (Qwen image edit)',
16
+ domain: YOLLOMI_DOMAIN,
17
+ strategy: Strategy.COOKIE,
18
+ args: [
19
+ { name: 'image', positional: true, required: true, help: 'Input image URL (upload via "opencli yollomi upload" first)' },
20
+ { name: 'prompt', positional: true, required: true, help: 'Editing instruction (e.g. "Make it look vintage")' },
21
+ { name: 'model', default: 'qwen-image-edit', choices: ['qwen-image-edit', 'qwen-image-edit-plus'], help: 'Edit model' },
22
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
23
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
24
+ ],
25
+ columns: ['status', 'file', 'size', 'credits', 'url'],
26
+ func: async (page, kwargs) => {
27
+ const imageInput = kwargs.image as string;
28
+ const prompt = kwargs.prompt as string;
29
+ const modelId = kwargs.model as string;
30
+
31
+ let body: Record<string, unknown>;
32
+ if (modelId === 'qwen-image-edit-plus') {
33
+ body = { prompt, images: [imageInput] };
34
+ } else {
35
+ body = { image: imageInput, prompt, go_fast: true, output_format: 'png' };
36
+ }
37
+
38
+ const apiPath = modelId === 'qwen-image-edit-plus' ? '/api/ai/qwen-image-edit-plus' : '/api/ai/qwen-image-edit';
39
+ process.stderr.write(chalk.dim(`Editing with ${modelId}...\n`));
40
+ const data = await yollomiPost(page, apiPath, body);
41
+
42
+ const images: string[] = data.images || (data.image ? [data.image] : []);
43
+ if (!images.length) throw new CliError('EMPTY_RESPONSE', 'No result', 'Try a different prompt');
44
+
45
+ const credits = data.remainingCredits;
46
+ const url = images[0];
47
+ if (kwargs['no-download']) return [{ status: 'edited', file: '-', size: '-', credits: credits ?? '-', url }];
48
+
49
+ try {
50
+ const filename = `yollomi_edit_${Date.now()}.png`;
51
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
52
+ if (credits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`));
53
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url }];
54
+ } catch {
55
+ return [{ status: 'download-failed', file: '-', size: '-', credits: credits ?? '-', url }];
56
+ }
57
+ },
58
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Yollomi face swap — POST /api/ai/face-swap
3
+ * Uses swap_image / input_image field names matching the frontend.
4
+ */
5
+
6
+ import * as path from 'node:path';
7
+ import chalk from 'chalk';
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { CliError } from '../../errors.js';
10
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
11
+
12
+ cli({
13
+ site: 'yollomi',
14
+ name: 'face-swap',
15
+ description: 'Swap faces between two photos (3 credits)',
16
+ domain: YOLLOMI_DOMAIN,
17
+ strategy: Strategy.COOKIE,
18
+ args: [
19
+ { name: 'source', required: true, help: 'Source face image URL' },
20
+ { name: 'target', required: true, help: 'Target photo URL' },
21
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
22
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
23
+ ],
24
+ columns: ['status', 'file', 'size', 'url'],
25
+ func: async (page, kwargs) => {
26
+ process.stderr.write(chalk.dim('Swapping faces...\n'));
27
+ const data = await yollomiPost(page, '/api/ai/face-swap', {
28
+ swap_image: kwargs.source as string,
29
+ input_image: kwargs.target as string,
30
+ });
31
+
32
+ const url = data.image || (data.images?.[0]);
33
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Make sure both images contain clear faces');
34
+
35
+ if (kwargs['no-download']) return [{ status: 'swapped', file: '-', size: '-', url }];
36
+
37
+ try {
38
+ const filename = `yollomi_faceswap_${Date.now()}.jpg`;
39
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
40
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
41
+ } catch {
42
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
43
+ }
44
+ },
45
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Yollomi text-to-image / image-to-image generation.
3
+ *
4
+ * Uses per-model routes exactly like the frontend:
5
+ * POST /api/ai/z-image-turbo { prompt, width, height, ... }
6
+ * POST /api/ai/nano-banana { prompt, aspect_ratio, ... }
7
+ * POST /api/ai/flux-2-pro { prompt, aspectRatio, imageUrl?, ... }
8
+ */
9
+
10
+ import * as path from 'node:path';
11
+ import chalk from 'chalk';
12
+ import { cli, Strategy } from '../../registry.js';
13
+ import { CliError } from '../../errors.js';
14
+ import { YOLLOMI_DOMAIN, yollomiPost, resolveImageInput, downloadOutput, fmtBytes, MODEL_ROUTES } from './utils.js';
15
+
16
+ function getDimensions(ratio: string): { width: number; height: number } {
17
+ const map: Record<string, [number, number]> = {
18
+ '1:1': [1024, 1024], '16:9': [1344, 768], '9:16': [768, 1344],
19
+ '4:3': [1152, 896], '3:4': [896, 1152],
20
+ };
21
+ const [w, h] = map[ratio] || [1024, 1024];
22
+ return { width: w, height: h };
23
+ }
24
+
25
+ cli({
26
+ site: 'yollomi',
27
+ name: 'generate',
28
+ description: 'Generate images with AI (text-to-image or image-to-image)',
29
+ domain: YOLLOMI_DOMAIN,
30
+ strategy: Strategy.COOKIE,
31
+ args: [
32
+ { name: 'prompt', positional: true, required: true, help: 'Text prompt describing the image' },
33
+ { name: 'model', default: 'z-image-turbo', help: 'Model ID (z-image-turbo, flux-schnell, nano-banana, flux-2-pro, ...)' },
34
+ { name: 'ratio', default: '1:1', choices: ['1:1', '16:9', '9:16', '4:3', '3:4'], help: 'Aspect ratio' },
35
+ { name: 'image', help: 'Input image URL for image-to-image (upload via "opencli yollomi upload" first)' },
36
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
37
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URLs, skip download' },
38
+ ],
39
+ columns: ['index', 'status', 'file', 'size', 'url'],
40
+ func: async (page, kwargs) => {
41
+ const prompt = kwargs.prompt as string;
42
+ const modelId = kwargs.model as string;
43
+ const ratio = kwargs.ratio as string;
44
+
45
+ const apiPath = MODEL_ROUTES[modelId];
46
+ if (!apiPath) throw new CliError('INVALID_MODEL', `Unknown model: ${modelId}`, 'Run "opencli yollomi models --type image" to see available models');
47
+
48
+ let body: Record<string, unknown>;
49
+
50
+ if (modelId === 'z-image-turbo') {
51
+ const { width, height } = getDimensions(ratio);
52
+ body = { prompt, width, height, output_format: 'jpg', output_quality: 85, guidance_scale: 0, num_inference_steps: 8 };
53
+ } else if (modelId === 'flux-2-pro') {
54
+ body = { prompt, aspectRatio: ratio, outputNumber: 1 };
55
+ if (kwargs.image) body.imageUrl = kwargs.image as string;
56
+ } else if (modelId === 'flux-kontext-pro') {
57
+ body = { prompt, output_format: 'jpg' };
58
+ if (kwargs.image) body.imageUrl = kwargs.image as string;
59
+ if (ratio !== '1:1') body.aspect_ratio = ratio;
60
+ } else {
61
+ body = { prompt, aspect_ratio: ratio };
62
+ if (kwargs.image) body.imageUrl = kwargs.image as string;
63
+ }
64
+
65
+ process.stderr.write(chalk.dim(`Generating with ${modelId}...\n`));
66
+ const data = await yollomiPost(page, apiPath, body);
67
+
68
+ const images: string[] = data.images || (data.image ? [data.image] : []);
69
+ if (!images.length) throw new CliError('EMPTY_RESPONSE', 'No images returned', 'Try a different prompt or model');
70
+
71
+ const noDownload = kwargs['no-download'] as boolean;
72
+ const outputDir = kwargs.output as string;
73
+ const results: any[] = [];
74
+
75
+ for (let i = 0; i < images.length; i++) {
76
+ const url = images[i];
77
+ if (noDownload) {
78
+ results.push({ index: i + 1, status: 'generated', file: '-', size: '-', url });
79
+ continue;
80
+ }
81
+ try {
82
+ const urlPath = (() => { try { return new URL(url).pathname; } catch { return url; } })();
83
+ const ext = urlPath.endsWith('.png') || urlPath.endsWith('.webp') ? urlPath.slice(urlPath.lastIndexOf('.')) : '.jpg';
84
+ const filename = `yollomi_${modelId}_${Date.now()}_${i + 1}${ext}`;
85
+ const { path: fp, size } = await downloadOutput(url, outputDir, filename);
86
+ results.push({ index: i + 1, status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url });
87
+ } catch {
88
+ results.push({ index: i + 1, status: 'download-failed', file: '-', size: '-', url });
89
+ }
90
+ }
91
+
92
+ if (data.remainingCredits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`));
93
+ return results;
94
+ },
95
+ });
@@ -0,0 +1,38 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { IMAGE_MODELS, VIDEO_MODELS, TOOL_MODELS } from './utils.js';
3
+
4
+ cli({
5
+ site: 'yollomi',
6
+ name: 'models',
7
+ description: 'List available Yollomi AI models (image, video, tools)',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'type', default: 'all', choices: ['all', 'image', 'video', 'tool'], help: 'Filter by model type' },
12
+ ],
13
+ columns: ['type', 'model', 'credits', 'description'],
14
+ func: async (_page, kwargs) => {
15
+ const filter = kwargs.type as string;
16
+ const rows: { type: string; model: string; credits: number | string; description: string }[] = [];
17
+
18
+ if (filter === 'all' || filter === 'image') {
19
+ for (const [id, info] of Object.entries(IMAGE_MODELS)) {
20
+ rows.push({ type: 'image', model: id, credits: info.credits, description: info.description });
21
+ }
22
+ }
23
+
24
+ if (filter === 'all' || filter === 'video') {
25
+ for (const [id, info] of Object.entries(VIDEO_MODELS)) {
26
+ rows.push({ type: 'video', model: id, credits: info.credits, description: info.description });
27
+ }
28
+ }
29
+
30
+ if (filter === 'all' || filter === 'tool') {
31
+ for (const [id, info] of Object.entries(TOOL_MODELS)) {
32
+ rows.push({ type: 'tool', model: id, credits: info.credits, description: info.description });
33
+ }
34
+ }
35
+
36
+ return rows;
37
+ },
38
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Yollomi object remover — POST /api/ai/object-remover
3
+ */
4
+
5
+ import * as path from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'object-remover',
14
+ description: 'Remove unwanted objects from images (3 credits)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'image', positional: true, required: true, help: 'Image URL' },
19
+ { name: 'mask', positional: true, required: true, help: 'Mask image URL (white = area to remove)' },
20
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
21
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
22
+ ],
23
+ columns: ['status', 'file', 'size', 'url'],
24
+ func: async (page, kwargs) => {
25
+ process.stderr.write(chalk.dim('Removing object...\n'));
26
+ const data = await yollomiPost(page, '/api/ai/object-remover', {
27
+ image: kwargs.image as string,
28
+ mask: kwargs.mask as string,
29
+ });
30
+
31
+ const url = data.image || (data.images?.[0]);
32
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Check image and mask');
33
+
34
+ if (kwargs['no-download']) return [{ status: 'removed', file: '-', size: '-', url }];
35
+
36
+ try {
37
+ const filename = `yollomi_removed_${Date.now()}.png`;
38
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
39
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
40
+ } catch {
41
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
42
+ }
43
+ },
44
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Yollomi background removal — POST /api/ai/remove-bg (free, 0 credits)
3
+ */
4
+
5
+ import * as path from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'remove-bg',
14
+ description: 'Remove image background with AI (free)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'image', positional: true, required: true, help: 'Image URL to remove background from' },
19
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
20
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
21
+ ],
22
+ columns: ['status', 'file', 'size', 'url'],
23
+ func: async (page, kwargs) => {
24
+ process.stderr.write(chalk.dim('Removing background...\n'));
25
+ const data = await yollomiPost(page, '/api/ai/remove-bg', { imageUrl: kwargs.image as string });
26
+
27
+ const url = data.image || (data.images?.[0]);
28
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Check the input image URL');
29
+
30
+ if (kwargs['no-download']) return [{ status: 'processed', file: '-', size: '-', url }];
31
+
32
+ try {
33
+ const filename = `yollomi_nobg_${Date.now()}.png`;
34
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
35
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
36
+ } catch {
37
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
38
+ }
39
+ },
40
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Yollomi photo restoration — POST /api/ai/photo-restoration
3
+ */
4
+
5
+ import * as path from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'restore',
14
+ description: 'Restore old or damaged photos with AI (4 credits)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'image', positional: true, required: true, help: 'Image URL to restore' },
19
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
20
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
21
+ ],
22
+ columns: ['status', 'file', 'size', 'url'],
23
+ func: async (page, kwargs) => {
24
+ process.stderr.write(chalk.dim('Restoring photo...\n'));
25
+ const data = await yollomiPost(page, '/api/ai/photo-restoration', { imageUrl: kwargs.image as string });
26
+
27
+ const url = data.image || (data.images?.[0]);
28
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Check the input image');
29
+
30
+ if (kwargs['no-download']) return [{ status: 'restored', file: '-', size: '-', url }];
31
+
32
+ try {
33
+ const filename = `yollomi_restored_${Date.now()}.jpg`;
34
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
35
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
36
+ } catch {
37
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
38
+ }
39
+ },
40
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Yollomi virtual try-on — POST /api/ai/virtual-try-on
3
+ */
4
+
5
+ import * as path from 'node:path';
6
+ import chalk from 'chalk';
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
+
11
+ cli({
12
+ site: 'yollomi',
13
+ name: 'try-on',
14
+ description: 'Virtual try-on — see how clothes look on a person (3 credits)',
15
+ domain: YOLLOMI_DOMAIN,
16
+ strategy: Strategy.COOKIE,
17
+ args: [
18
+ { name: 'person', required: true, help: 'Person photo URL (upload via "opencli yollomi upload" first)' },
19
+ { name: 'cloth', required: true, help: 'Clothing image URL' },
20
+ { name: 'cloth-type', default: 'upper', choices: ['upper', 'lower', 'overall'], help: 'Clothing type' },
21
+ { name: 'output', default: './yollomi-output', help: 'Output directory' },
22
+ { name: 'no-download', type: 'boolean', default: false, help: 'Only show URL' },
23
+ ],
24
+ columns: ['status', 'file', 'size', 'url'],
25
+ func: async (page, kwargs) => {
26
+ process.stderr.write(chalk.dim('Processing virtual try-on...\n'));
27
+ const data = await yollomiPost(page, '/api/ai/virtual-try-on', {
28
+ person_image: kwargs.person as string,
29
+ cloth_image: kwargs.cloth as string,
30
+ cloth_type: kwargs['cloth-type'] as string,
31
+ output_format: 'png',
32
+ output_quality: 100,
33
+ });
34
+
35
+ const url = data.image || (data.images?.[0]);
36
+ if (!url) throw new CliError('EMPTY_RESPONSE', 'No result', 'Check both images have clear subjects');
37
+
38
+ if (kwargs['no-download']) return [{ status: 'generated', file: '-', size: '-', url }];
39
+
40
+ try {
41
+ const filename = `yollomi_tryon_${Date.now()}.png`;
42
+ const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename);
43
+ return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), url }];
44
+ } catch {
45
+ return [{ status: 'download-failed', file: '-', size: '-', url }];
46
+ }
47
+ },
48
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Yollomi image upload — POST /api/upload (FormData)
3
+ *
4
+ * Uploads a local file to Yollomi's R2 storage and returns the URL.
5
+ * The URL can then be used as input for image-to-image, face-swap, etc.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import chalk from 'chalk';
11
+ import { cli, Strategy } from '../../registry.js';
12
+ import { CliError } from '../../errors.js';
13
+ import { YOLLOMI_DOMAIN, ensureOnYollomi, fmtBytes } from './utils.js';
14
+
15
+ const MIME_MAP: Record<string, string> = {
16
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
17
+ '.png': 'image/png', '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime',
20
+ };
21
+
22
+ cli({
23
+ site: 'yollomi',
24
+ name: 'upload',
25
+ description: 'Upload an image or video to Yollomi (returns URL for other commands)',
26
+ domain: YOLLOMI_DOMAIN,
27
+ strategy: Strategy.COOKIE,
28
+ args: [
29
+ { name: 'file', positional: true, required: true, help: 'Local file path to upload' },
30
+ ],
31
+ columns: ['status', 'file', 'size', 'url'],
32
+ func: async (page, kwargs) => {
33
+ const filePath = path.resolve(kwargs.file as string);
34
+ if (!fs.existsSync(filePath)) throw new CliError('FILE_NOT_FOUND', `File not found: ${filePath}`, 'Provide a valid file path');
35
+
36
+ const ext = path.extname(filePath).toLowerCase();
37
+ const mime = MIME_MAP[ext];
38
+ if (!mime) throw new CliError('INVALID_TYPE', `Unsupported file type: ${ext}`, 'Supported: jpg, png, gif, webp, mp4, mov');
39
+
40
+ const data = fs.readFileSync(filePath);
41
+ // Note: base64 encoding inflates size ~33%. Video cap is conservative to avoid
42
+ // OOM when the base64 string is injected into the browser JS engine via page.evaluate().
43
+ const maxSize = mime.startsWith('video/') ? 20 * 1024 * 1024 : 10 * 1024 * 1024;
44
+ if (data.length > maxSize) throw new CliError('FILE_TOO_LARGE', `File too large: ${fmtBytes(data.length)}`, `Max ${mime.startsWith('video/') ? '20MB' : '10MB'} (upload larger videos from a URL)`);
45
+
46
+ const b64 = data.toString('base64');
47
+ const fileName = path.basename(filePath);
48
+
49
+ process.stderr.write(chalk.dim(`Uploading ${fileName} (${fmtBytes(data.length)})...\n`));
50
+ await ensureOnYollomi(page);
51
+
52
+ const result = await page.evaluate(`
53
+ (async () => {
54
+ try {
55
+ const raw = atob(${JSON.stringify(b64)});
56
+ const arr = new Uint8Array(raw.length);
57
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
58
+ const file = new File([arr], ${JSON.stringify(fileName)}, { type: ${JSON.stringify(mime)} });
59
+ const fd = new FormData();
60
+ fd.append('file', file);
61
+ const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
62
+ const json = await res.json();
63
+ return { ok: res.ok, status: res.status, data: json };
64
+ } catch (err) {
65
+ return { ok: false, status: 0, data: { error: err.message } };
66
+ }
67
+ })()
68
+ `);
69
+
70
+ if (!result?.ok) {
71
+ throw new CliError('UPLOAD_ERROR', result?.data?.error || 'Upload failed', 'Make sure you are logged in to yollomi.com');
72
+ }
73
+
74
+ const url = result.data.url;
75
+ process.stderr.write(chalk.green(`Uploaded! Use this URL as input for other commands.\n`));
76
+ return [{ status: 'uploaded', file: fileName, size: fmtBytes(data.length), url }];
77
+ },
78
+ });