@jackwener/opencli 1.7.8 → 1.7.10

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 (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  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/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
package/clis/36kr/news.js CHANGED
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'summary', 'date', 'url'],
15
- func: async (_page, kwargs) => {
15
+ func: async (kwargs) => {
16
16
  const count = Math.min(kwargs.limit || 20, 50);
17
17
  const resp = await fetch('https://www.36kr.com/feed', {
18
18
  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
@@ -24,7 +24,7 @@ describe('apple-podcasts search command', () => {
24
24
  }),
25
25
  });
26
26
  vi.stubGlobal('fetch', fetchMock);
27
- const result = await cmd.func(null, {
27
+ const result = await cmd.func({
28
28
  query: 'machine learning',
29
29
  keyword: 'sports',
30
30
  limit: 5,
@@ -60,7 +60,7 @@ describe('apple-podcasts top command', () => {
60
60
  }),
61
61
  });
62
62
  vi.stubGlobal('fetch', fetchMock);
63
- await cmd.func(null, { country: 'US', limit: 1 });
63
+ await cmd.func({ country: 'US', limit: 1 });
64
64
  const [, options] = fetchMock.mock.calls[0] ?? [];
65
65
  expect(options).toBeDefined();
66
66
  expect(options.signal).toBeDefined();
@@ -81,7 +81,7 @@ describe('apple-podcasts top command', () => {
81
81
  }),
82
82
  });
83
83
  vi.stubGlobal('fetch', fetchMock);
84
- const result = await cmd.func(null, { country: 'US', limit: 2 });
84
+ const result = await cmd.func({ country: 'US', limit: 2 });
85
85
  expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
86
86
  signal: expect.any(Object),
87
87
  }));
@@ -94,6 +94,6 @@ describe('apple-podcasts top command', () => {
94
94
  const cmd = getRegistry().get('apple-podcasts/top');
95
95
  expect(cmd?.func).toBeTypeOf('function');
96
96
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
97
- await expect(cmd.func(null, { country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
97
+ await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
98
98
  });
99
99
  });
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
13
13
  ],
14
14
  columns: ['title', 'duration', 'date'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const limit = Math.max(1, Math.min(Number(args.limit), 200));
17
17
  // results[0] is the podcast itself; the rest are episodes
18
18
  const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
13
  ],
14
14
  columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const term = encodeURIComponent(args.query);
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
18
18
  const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
@@ -14,7 +14,7 @@ cli({
14
14
  { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
15
15
  ],
16
16
  columns: ['rank', 'title', 'author', 'id'],
17
- func: async (_page, args) => {
17
+ func: async (args) => {
18
18
  const limit = Math.max(1, Math.min(Number(args.limit), 100));
19
19
  const country = String(args.country || 'us').trim().toLowerCase();
20
20
  const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
12
12
  ],
13
13
  columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
16
16
  const entries = parseEntries(xml);
17
17
  if (!entries.length)
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
13
13
  ],
14
14
  columns: ['id', 'title', 'authors', 'published', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
17
17
  const query = encodeURIComponent(`all:${args.query}`);
18
18
  const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  /**
4
4
  * band mentions — Show Band notifications where you were @mentioned.
@@ -52,7 +52,7 @@ cli({
52
52
  await page.wait(0.5);
53
53
  }
54
54
  if (!bellReady) {
55
- throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
55
+ throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
56
56
  }
57
57
  // Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
58
58
  // getInterceptedRequests() clears the array on each call, so captures are accumulated
@@ -80,7 +80,7 @@ cli({
80
80
  return true;
81
81
  }`);
82
82
  if (!bellClicked) {
83
- throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
83
+ throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
84
84
  }
85
85
  const requests = await waitForOneCapture();
86
86
  // Find the get_news response (has result_data.news); get_news_count responses do not.
package/clis/bbc/news.js CHANGED
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'description', 'url'],
15
- func: async (page, kwargs) => {
15
+ func: async (kwargs) => {
16
16
  const count = Math.min(kwargs.limit || 20, 50);
17
17
  const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
18
18
  if (!resp.ok)
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
3
3
  import { apiGet, resolveBvid } from './utils.js';
4
4
  cli({
5
5
  site: 'bilibili',
@@ -23,7 +23,7 @@ cli({
23
23
  return state?.videoData?.cid;
24
24
  })()`);
25
25
  if (!cid) {
26
- throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
26
+ throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
27
27
  }
28
28
  // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
29
29
  // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('economics', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('industries', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('main', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('markets', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('politics', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('tech', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -2,6 +2,7 @@
2
2
  * BOSS直聘 job search — browser cookie API.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
5
6
  import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
6
7
  /** City name → BOSS Zhipin city code mapping */
7
8
  const CITY_CODES = {
@@ -22,8 +23,16 @@ const CITY_CODES = {
22
23
  '香港': '101320100',
23
24
  };
24
25
  const EXP_MAP = {
25
- '不限': '0', '在校/应届': '108', '应届': '108', '1年以内': '101',
26
- '1-3年': '102', '3-5年': '103', '5-10年': '104', '10年以上': '105',
26
+ '不限': '0',
27
+ '在校/应届': '108',
28
+ '在校生': '108', '在校': '108',
29
+ '应届生': '102', '应届': '102',
30
+ '经验不限': '101',
31
+ '1年以内': '103',
32
+ '1-3年': '104',
33
+ '3-5年': '105',
34
+ '5-10年': '106',
35
+ '10年以上': '107',
27
36
  };
28
37
  const DEGREE_MAP = {
29
38
  '不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
@@ -38,6 +47,10 @@ const INDUSTRY_MAP = {
38
47
  '人工智能': '100901', '大数据': '100902', '金融': '100101',
39
48
  '教育培训': '100200', '医疗健康': '100300',
40
49
  };
50
+ const JOB_TYPE_MAP = {
51
+ '不限': '0', '全职': '1901', '实习': '1902', '兼职': '1903',
52
+ };
53
+ const JOB_TYPE_CODES = new Set(Object.values(JOB_TYPE_MAP));
41
54
  function resolveCity(input) {
42
55
  if (!input)
43
56
  return '101010100';
@@ -62,35 +75,54 @@ function resolveMap(input, map) {
62
75
  }
63
76
  return input;
64
77
  }
78
+ function resolveJobType(input) {
79
+ if (!input)
80
+ return '';
81
+ if (JOB_TYPE_MAP[input] !== undefined)
82
+ return JOB_TYPE_MAP[input];
83
+ if (JOB_TYPE_CODES.has(input))
84
+ return input;
85
+ throw new ArgumentError(`Invalid jobType: ${input}`, 'Use one of: 全职, 兼职, 实习, 不限');
86
+ }
87
+ function formatBossOnline(value) {
88
+ if (value === true)
89
+ return 'Y';
90
+ if (value === false)
91
+ return 'N';
92
+ return '';
93
+ }
65
94
  cli({
66
95
  site: 'boss',
67
96
  name: 'search',
68
- description: 'BOSS直聘搜索职位',
97
+ description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
69
98
  domain: 'www.zhipin.com',
70
99
  strategy: Strategy.COOKIE,
71
100
  navigateBefore: false,
72
101
  browser: true,
73
102
  args: [
74
- { name: 'query', required: true, positional: true, help: 'Search keyword (e.g. AI agent, 前端)' },
103
+ { name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
75
104
  { name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
76
- { name: 'experience', default: '', help: 'Experience: 应届/1年以内/1-3年/3-5年/5-10年/10年以上' },
105
+ { name: 'experience', default: '', help: 'Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上' },
77
106
  { name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
78
107
  { name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
79
108
  { name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
109
+ { name: 'jobType', default: '', help: 'Job type: 全职/兼职/实习(不传=不限,混合校招与实习)' },
80
110
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
81
111
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
82
112
  ],
83
- columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
113
+ columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'bossOnline', 'security_id', 'url'],
84
114
  func: async (page, kwargs) => {
85
115
  requirePage(page);
116
+ const query = String(kwargs.query ?? '').trim();
86
117
  const cityCode = resolveCity(kwargs.city);
87
118
  verbose('Navigating to set referrer context...');
88
- await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`);
119
+ await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
89
120
  await new Promise(r => setTimeout(r, 1000));
90
121
  const expVal = resolveMap(kwargs.experience, EXP_MAP);
91
122
  const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
92
123
  const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
93
124
  const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
125
+ const jobTypeVal = resolveJobType(kwargs.jobType);
94
126
  const limit = kwargs.limit || 15;
95
127
  let currentPage = kwargs.page || 1;
96
128
  let allJobs = [];
@@ -101,7 +133,7 @@ cli({
101
133
  }
102
134
  const qs = new URLSearchParams({
103
135
  scene: '1',
104
- query: kwargs.query,
136
+ query,
105
137
  city: cityCode,
106
138
  page: String(currentPage),
107
139
  pageSize: '15',
@@ -114,6 +146,8 @@ cli({
114
146
  qs.set('salary', salaryVal);
115
147
  if (industryVal)
116
148
  qs.set('industry', industryVal);
149
+ if (jobTypeVal)
150
+ qs.set('jobType', jobTypeVal);
117
151
  const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
118
152
  verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
119
153
  const data = await bossFetch(page, targetUrl);
@@ -135,6 +169,7 @@ cli({
135
169
  degree: j.jobDegree,
136
170
  skills: (j.skills || []).join(','),
137
171
  boss: j.bossName + ' · ' + j.bossTitle,
172
+ bossOnline: formatBossOnline(j.bossOnline),
138
173
  security_id: j.securityId || '',
139
174
  url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
140
175
  });
@@ -153,3 +188,9 @@ cli({
153
188
  return allJobs;
154
189
  },
155
190
  });
191
+ export const __test__ = {
192
+ EXP_MAP,
193
+ resolveMap,
194
+ resolveJobType,
195
+ formatBossOnline,
196
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError } from '@jackwener/opencli/errors';
4
+ import { __test__ } from './search.js';
5
+ import './search.js';
6
+
7
+ function createPageMock(response) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ wait: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: vi.fn().mockResolvedValue(response),
12
+ };
13
+ }
14
+
15
+ describe('boss search', () => {
16
+ const command = getRegistry().get('boss/search');
17
+
18
+ it('keeps legacy 在校/应届 experience input compatible', () => {
19
+ expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
20
+ expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
21
+ });
22
+
23
+ it('fails fast on invalid jobType values', async () => {
24
+ expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
25
+ });
26
+
27
+ it('accepts supported jobType labels and raw codes', () => {
28
+ expect(__test__.resolveJobType('全职')).toBe('1901');
29
+ expect(__test__.resolveJobType('实习')).toBe('1902');
30
+ expect(__test__.resolveJobType('兼职')).toBe('1903');
31
+ expect(__test__.resolveJobType('1902')).toBe('1902');
32
+ });
33
+
34
+ it('keeps empty query empty and sends jobType filter to the API', async () => {
35
+ const page = createPageMock({
36
+ code: 0,
37
+ zpData: {
38
+ hasMore: false,
39
+ jobList: [
40
+ {
41
+ encryptJobId: 'abc',
42
+ securityId: 'sec',
43
+ jobName: '前端开发实习生',
44
+ salaryDesc: '150-200/天',
45
+ brandName: 'OpenCLI',
46
+ cityName: '北京',
47
+ areaDistrict: '海淀区',
48
+ businessDistrict: '',
49
+ jobExperience: '在校/应届',
50
+ jobDegree: '本科',
51
+ skills: ['JavaScript'],
52
+ bossName: '张三',
53
+ bossTitle: '技术负责人',
54
+ bossOnline: false,
55
+ },
56
+ ],
57
+ },
58
+ });
59
+
60
+ const rows = await command.func(page, {
61
+ query: undefined,
62
+ city: '北京',
63
+ jobType: '实习',
64
+ limit: 1,
65
+ page: 1,
66
+ });
67
+
68
+ expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
69
+ const fetchScript = page.evaluate.mock.calls.at(-1)[0];
70
+ expect(fetchScript).toContain('query=');
71
+ expect(fetchScript).not.toContain('query=undefined');
72
+ expect(fetchScript).toContain('jobType=1902');
73
+ expect(rows[0]).toMatchObject({
74
+ name: '前端开发实习生',
75
+ bossOnline: 'N',
76
+ });
77
+ });
78
+ });
package/clis/boss/send.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
8
  import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
9
- import { EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
9
+ import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
10
10
  cli({
11
11
  site: 'boss',
12
12
  name: 'send',
@@ -30,12 +30,12 @@ cli({
30
30
  const friendName = friend.name || '候选人';
31
31
  const clicked = await clickCandidateInList(page, numericUid);
32
32
  if (!clicked) {
33
- throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
33
+ throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
34
34
  }
35
35
  await page.wait({ time: 2 });
36
36
  const sent = await typeAndSendMessage(page, kwargs.text);
37
37
  if (!sent) {
38
- throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
38
+ throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
39
39
  }
40
40
  await page.wait({ time: 1 });
41
41
  return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
@@ -1,7 +1,9 @@
1
1
  import * as os from 'node:os';
2
2
  import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
3
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
4
5
  import { saveBase64ToFile } from '@jackwener/opencli/utils';
6
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
5
7
  import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
6
8
 
7
9
  const CHATGPT_DOMAIN = 'chatgpt.com';
@@ -24,6 +26,22 @@ function displayPath(filePath) {
24
26
  return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
25
27
  }
26
28
 
29
+ export function resolveOutputDir(value) {
30
+ const raw = String(value || '').trim();
31
+ if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt');
32
+ if (raw === '~') return os.homedir();
33
+ if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2));
34
+ return path.resolve(raw);
35
+ }
36
+
37
+ export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) {
38
+ let candidate = path.join(dir, `${baseName}${ext}`);
39
+ for (let index = 1; existsSync(candidate); index += 1) {
40
+ candidate = path.join(dir, `${baseName}_${index}${ext}`);
41
+ }
42
+ return candidate;
43
+ }
44
+
27
45
  async function currentChatGPTLink(page) {
28
46
  const url = await page.evaluate('window.location.href').catch(() => '');
29
47
  return typeof url === 'string' && url ? url : 'https://chatgpt.com';
@@ -41,13 +59,13 @@ export const imageCommand = cli({
41
59
  timeoutSeconds: 240,
42
60
  args: [
43
61
  { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
44
- { name: 'op', default: '~/Pictures/chatgpt', help: 'Output directory' },
62
+ { name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
45
63
  { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
46
64
  ],
47
65
  columns: ['status', 'file', 'link'],
48
66
  func: async (page, kwargs) => {
49
67
  const prompt = kwargs.prompt;
50
- const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt');
68
+ const outputDir = resolveOutputDir(kwargs.op);
51
69
  const skipDownloadRaw = kwargs.sd;
52
70
  const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
53
71
  const timeout = 120;
@@ -63,12 +81,23 @@ export const imageCommand = cli({
63
81
  return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
64
82
  }
65
83
 
66
- // Wait for response and images
67
- const urls = await waitForChatGPTImages(page, beforeUrls, timeout);
68
- const link = await currentChatGPTLink(page);
84
+ // ChatGPT briefly navigates to /c/{id} after sending, then may
85
+ // redirect back to the home page. Poll until we capture the /c/ URL.
86
+ let convUrl = '';
87
+ for (let ci = 0; ci < 10; ci++) {
88
+ const url = await currentChatGPTLink(page);
89
+ if (url.includes('/c/')) { convUrl = url; break; }
90
+ await page.wait(2);
91
+ }
92
+ if (!convUrl) {
93
+ convUrl = await currentChatGPTLink(page);
94
+ }
95
+
96
+ const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl);
97
+ const link = convUrl;
69
98
 
70
99
  if (!urls.length) {
71
- return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
100
+ throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`);
72
101
  }
73
102
 
74
103
  if (skipDownload) {
@@ -78,7 +107,7 @@ export const imageCommand = cli({
78
107
  // Export and save images
79
108
  const assets = await getChatGPTImageAssets(page, urls);
80
109
  if (!assets.length) {
81
- return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
110
+ throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`);
82
111
  }
83
112
 
84
113
  const stamp = Date.now();
@@ -88,7 +117,7 @@ export const imageCommand = cli({
88
117
  const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
89
118
  const suffix = assets.length > 1 ? `_${index + 1}` : '';
90
119
  const ext = extFromMime(asset.mimeType);
91
- const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`);
120
+ const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext);
92
121
  await saveBase64ToFile(base64, filePath);
93
122
  results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
94
123
  }
@@ -0,0 +1,92 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ getChatGPTVisibleImageUrls: vi.fn(),
7
+ sendChatGPTMessage: vi.fn(),
8
+ waitForChatGPTImages: vi.fn(),
9
+ getChatGPTImageAssets: vi.fn(),
10
+ saveBase64ToFile: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('./utils.js', () => ({
14
+ getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
15
+ sendChatGPTMessage: mocks.sendChatGPTMessage,
16
+ waitForChatGPTImages: mocks.waitForChatGPTImages,
17
+ getChatGPTImageAssets: mocks.getChatGPTImageAssets,
18
+ }));
19
+
20
+ vi.mock('@jackwener/opencli/utils', () => ({
21
+ saveBase64ToFile: mocks.saveBase64ToFile,
22
+ }));
23
+
24
+ const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
25
+
26
+ function createPage() {
27
+ return {
28
+ goto: vi.fn().mockResolvedValue(undefined),
29
+ wait: vi.fn().mockResolvedValue(undefined),
30
+ evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
31
+ };
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.restoreAllMocks();
36
+ mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
37
+ mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
38
+ mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
39
+ mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
40
+ url: 'https://images.example/generated.png',
41
+ dataUrl: 'data:image/png;base64,aGVsbG8=',
42
+ mimeType: 'image/png',
43
+ }]);
44
+ mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
45
+ });
46
+
47
+ describe('chatgpt image output paths', () => {
48
+ it('expands the default and explicit home-relative output directories', () => {
49
+ expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
50
+ expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
51
+ expect(resolveOutputDir('~')).toBe(os.homedir());
52
+ });
53
+
54
+ it('generates a non-overwriting file path when a timestamp collision exists', () => {
55
+ const dir = '/tmp/chatgpt';
56
+ const taken = new Set([
57
+ path.join(dir, 'chatgpt_123.png'),
58
+ path.join(dir, 'chatgpt_123_1.png'),
59
+ ]);
60
+
61
+ expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
62
+ });
63
+ });
64
+
65
+ describe('chatgpt image failure contracts', () => {
66
+ it('fails fast when image generation detection finds no new images', async () => {
67
+ mocks.waitForChatGPTImages.mockResolvedValue([]);
68
+
69
+ await expect(imageCommand.func(createPage(), {
70
+ prompt: 'cat',
71
+ op: '',
72
+ sd: false,
73
+ })).rejects.toMatchObject({
74
+ code: 'EMPTY_RESULT',
75
+ message: expect.stringContaining('chatgpt image returned no data'),
76
+ hint: expect.stringContaining('No generated images were detected'),
77
+ });
78
+ });
79
+
80
+ it('fails fast when generated image assets cannot be exported', async () => {
81
+ mocks.getChatGPTImageAssets.mockResolvedValue([]);
82
+
83
+ await expect(imageCommand.func(createPage(), {
84
+ prompt: 'cat',
85
+ op: '',
86
+ sd: false,
87
+ })).rejects.toMatchObject({
88
+ code: 'COMMAND_EXEC',
89
+ message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
90
+ });
91
+ });
92
+ });