@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
@@ -0,0 +1,440 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ export const CLAUDE_DOMAIN = 'claude.ai';
4
+ export const CLAUDE_URL = 'https://claude.ai/new';
5
+ export const COMPOSER_SELECTOR = '[data-testid="chat-input"]';
6
+ export const MESSAGE_SELECTOR = '.font-claude-response';
7
+ export const MODEL_DROPDOWN_SELECTOR = '[data-testid="model-selector-dropdown"]';
8
+
9
+ const MODEL_DISPLAY_NAMES = {
10
+ sonnet: 'Sonnet 4.6',
11
+ opus: 'Opus 4.7',
12
+ haiku: 'Haiku 4.5',
13
+ };
14
+
15
+ export async function isOnClaude(page) {
16
+ const url = await page.evaluate('window.location.href').catch(() => '');
17
+ if (typeof url !== 'string' || !url) return false;
18
+ try {
19
+ const h = new URL(url).hostname;
20
+ return h === CLAUDE_DOMAIN || h.endsWith(`.${CLAUDE_DOMAIN}`);
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export async function ensureOnClaude(page) {
27
+ if (await isOnClaude(page)) return false;
28
+ await page.goto(CLAUDE_URL);
29
+ await page.wait(3);
30
+ return true;
31
+ }
32
+
33
+ export async function getPageState(page) {
34
+ return page.evaluate(`(() => {
35
+ var composer = document.querySelector('${COMPOSER_SELECTOR}');
36
+ var userMenu = document.querySelector('[data-testid="user-menu-button"]');
37
+ return {
38
+ url: window.location.href,
39
+ title: document.title,
40
+ hasComposer: !!composer,
41
+ isLoggedIn: !!userMenu,
42
+ };
43
+ })()`);
44
+ }
45
+
46
+ export async function ensureClaudeLogin(page, message = 'Claude requires a logged-in browser session.') {
47
+ const state = await getPageState(page);
48
+ if (!state.isLoggedIn) {
49
+ throw new AuthRequiredError(CLAUDE_DOMAIN, message);
50
+ }
51
+ return state;
52
+ }
53
+
54
+ export async function ensureClaudeComposer(page, message = 'Claude composer is not available on the current page.') {
55
+ const state = await ensureClaudeLogin(page, message);
56
+ if (!state.hasComposer) {
57
+ throw new CommandExecutionError(message);
58
+ }
59
+ return state;
60
+ }
61
+
62
+ export function requireNonEmptyPrompt(prompt, commandName) {
63
+ const text = String(prompt ?? '').trim();
64
+ if (!text) {
65
+ throw new ArgumentError(
66
+ `${commandName} prompt cannot be empty`,
67
+ `Example: opencli ${commandName} "hello"`,
68
+ );
69
+ }
70
+ return text;
71
+ }
72
+
73
+ export function requirePositiveInt(value, flagLabel, hint) {
74
+ if (!Number.isInteger(value) || value < 1) {
75
+ throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ export function requireConversationId(value) {
81
+ const id = String(value ?? '').trim();
82
+ if (!id) {
83
+ throw new ArgumentError(
84
+ 'claude detail requires a conversation id',
85
+ 'Example: opencli claude detail 123e4567-e89b-12d3-a456-426614174000',
86
+ );
87
+ }
88
+ return id;
89
+ }
90
+
91
+ export async function getVisibleMessages(page) {
92
+ const result = await page.evaluate(`(() => {
93
+ var nodes = document.querySelectorAll('[data-testid="user-message"], ${MESSAGE_SELECTOR}');
94
+ var rows = [];
95
+ Array.from(nodes).forEach(function(el) {
96
+ var isUser = el.getAttribute('data-testid') === 'user-message';
97
+ var raw = (el.innerText || '').trim();
98
+ if (!isUser) {
99
+ var parts = raw.split(/\\n\\n+/);
100
+ while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
101
+ raw = parts.join('\\n\\n').trim();
102
+ }
103
+ if (raw) rows.push({ role: isUser ? 'user' : 'assistant', text: raw });
104
+ });
105
+ return rows;
106
+ })()`);
107
+ if (!Array.isArray(result)) return [];
108
+ return result.map(function(r, i) { return { Index: i, Role: r.role, Text: r.text }; });
109
+ }
110
+
111
+ export async function getConversationList(page) {
112
+ if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) {
113
+ await page.goto('https://claude.ai/recents');
114
+ await page.wait(3);
115
+ }
116
+ const items = await page.evaluate(`(() => {
117
+ var links = Array.from(document.querySelectorAll('a[href*="/chat/"]'));
118
+ return links.map(function(link, i) {
119
+ var href = link.getAttribute('href') || '';
120
+ var idMatch = href.match(/\\/chat\\/([a-f0-9-]+)/);
121
+ return {
122
+ Index: i + 1,
123
+ Id: idMatch ? idMatch[1] : href,
124
+ Title: (link.innerText || '').trim().split('\\n')[0].trim() || '(untitled)',
125
+ Url: href.startsWith('http') ? href : ('https://claude.ai' + href),
126
+ };
127
+ });
128
+ })()`);
129
+ return Array.isArray(items) ? items : [];
130
+ }
131
+
132
+ export async function selectModel(page, modelName) {
133
+ const display = MODEL_DISPLAY_NAMES[String(modelName).toLowerCase()];
134
+ if (!display) return { ok: false };
135
+
136
+ const opened = await page.evaluate(`(() => {
137
+ var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
138
+ if (!trigger) return { ok: false };
139
+ var label = trigger.getAttribute('aria-label') || '';
140
+ if (label.indexOf(${JSON.stringify(display)}) >= 0) {
141
+ return { ok: true, toggled: false };
142
+ }
143
+ trigger.click();
144
+ return { ok: true, opened: true };
145
+ })()`);
146
+
147
+ if (!opened?.ok) return opened;
148
+ if (!opened.opened) return opened;
149
+
150
+ await page.wait(0.6);
151
+
152
+ return page.evaluate(`(() => {
153
+ var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]'));
154
+ var target = items.find(function(el) { return (el.innerText || '').indexOf(${JSON.stringify(display)}) >= 0; });
155
+ if (!target) return { ok: false };
156
+ // Free-tier locked options carry an inline "Upgrade" button next to the label.
157
+ var upgrade = target.querySelector('button');
158
+ if (upgrade && (upgrade.innerText || '').toLowerCase().indexOf('upgrade') >= 0) {
159
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
160
+ return { ok: false, upgrade: true };
161
+ }
162
+ var alreadySelected = target.getAttribute('aria-checked') === 'true';
163
+ if (!alreadySelected) target.click();
164
+ else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
165
+ return { ok: true, toggled: !alreadySelected };
166
+ })()`);
167
+ }
168
+
169
+ export async function setAdaptiveThinking(page, enabled) {
170
+ const opened = await page.evaluate(`(() => {
171
+ var trigger = document.querySelector('${MODEL_DROPDOWN_SELECTOR}');
172
+ if (!trigger) return { ok: false };
173
+ trigger.click();
174
+ return { ok: true };
175
+ })()`);
176
+ if (!opened?.ok) return { ok: false };
177
+
178
+ await page.wait(0.6);
179
+
180
+ return page.evaluate(`(() => {
181
+ var items = Array.from(document.querySelectorAll('div[role="menuitem"]'));
182
+ var target = items.find(function(el) { return (el.innerText || '').indexOf('Adaptive thinking') >= 0; });
183
+ if (!target) {
184
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
185
+ return { ok: false };
186
+ }
187
+ var isActive = target.getAttribute('aria-checked') === 'true';
188
+ if (${enabled} !== isActive) target.click();
189
+ else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
190
+ return { ok: true, toggled: ${enabled} !== isActive };
191
+ })()`);
192
+ }
193
+
194
+ export async function sendMessage(page, prompt) {
195
+ const promptJson = JSON.stringify(prompt);
196
+ const composerReady = await page.evaluate(`(() => {
197
+ var box = document.querySelector('${COMPOSER_SELECTOR}');
198
+ if (!box) return false;
199
+ box.focus();
200
+ // ProseMirror editors hold content in nested <p>; clear via Range/delete
201
+ // rather than .value or textContent, which the editor won't notice.
202
+ var sel = window.getSelection();
203
+ sel.removeAllRanges();
204
+ var range = document.createRange();
205
+ range.selectNodeContents(box);
206
+ sel.addRange(range);
207
+ document.execCommand('delete', false);
208
+ return true;
209
+ })()`);
210
+ if (!composerReady) return { ok: false, reason: 'composer not found' };
211
+
212
+ let typedNatively = false;
213
+ if (page.nativeType) {
214
+ try {
215
+ await page.nativeType(prompt);
216
+ typedNatively = true;
217
+ } catch (err) {
218
+ const msg = String(err?.message || err);
219
+ if (!msg.includes('Unknown action') && !msg.includes('not supported')) throw err;
220
+ }
221
+ }
222
+ if (!typedNatively) {
223
+ await page.evaluate(`(() => {
224
+ var box = document.querySelector('${COMPOSER_SELECTOR}');
225
+ if (!box) return;
226
+ box.focus();
227
+ document.execCommand('insertText', false, ${promptJson});
228
+ })()`);
229
+ }
230
+
231
+ await page.wait(1.2);
232
+
233
+ return page.evaluate(`(() => {
234
+ var ariaCandidates = [
235
+ 'button[aria-label="Send Message"]',
236
+ 'button[aria-label="Send message"]',
237
+ 'button[aria-label="Send"]',
238
+ 'button[aria-label*="Send"]',
239
+ ];
240
+ for (var i = 0; i < ariaCandidates.length; i++) {
241
+ var btn = document.querySelector(ariaCandidates[i]);
242
+ if (btn && !btn.disabled) { btn.click(); return { ok: true }; }
243
+ }
244
+ // Fallback: rightmost enabled button with an svg in the composer container.
245
+ var box = document.querySelector('${COMPOSER_SELECTOR}');
246
+ if (box) {
247
+ var c = box.parentElement;
248
+ for (var hop = 0; hop < 6 && c; hop++) {
249
+ var btns = Array.from(c.querySelectorAll('button')).filter(function(b) { return !b.disabled && b.querySelector('svg'); });
250
+ if (btns.length) { btns[btns.length - 1].click(); return { ok: true, method: 'fallback' }; }
251
+ c = c.parentElement;
252
+ }
253
+ }
254
+ var box2 = document.querySelector('${COMPOSER_SELECTOR}');
255
+ if (box2) {
256
+ box2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
257
+ return { ok: true, method: 'enter' };
258
+ }
259
+ return { ok: false, reason: 'send button not found' };
260
+ })()`);
261
+ }
262
+
263
+ export async function getBubbleCount(page) {
264
+ const count = await page.evaluate(`(() => {
265
+ return document.querySelectorAll('${MESSAGE_SELECTOR}').length;
266
+ })()`);
267
+ return count || 0;
268
+ }
269
+
270
+ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
271
+ const startTime = Date.now();
272
+ let lastText = '';
273
+ let stableCount = 0;
274
+
275
+ while (Date.now() - startTime < timeoutMs) {
276
+ await page.wait(3);
277
+
278
+ let result;
279
+ try {
280
+ result = await page.evaluate(`(() => {
281
+ var bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
282
+ // Adaptive thinking renders "Thought process" labels at the top
283
+ // of the response (often duplicated for the expand/collapse widget).
284
+ // Strip them so the row value is the actual answer text.
285
+ var texts = Array.from(bubbles).map(function(b) {
286
+ var raw = (b.innerText || '').trim();
287
+ // Drop leading paragraphs that are widget labels:
288
+ // "Thought process" / "Thought for Xs" — Adaptive thinking expand widget
289
+ // "View uploaded image" / "View attachment" — file thumbnail label
290
+ // These render twice (collapsed + expanded) and are followed by a blank line.
291
+ var parts = raw.split(/\\n\\n+/);
292
+ while (parts.length > 1 && /^(Thought|View)\\b/i.test(parts[0])) parts.shift();
293
+ return parts.join('\\n\\n').trim();
294
+ }).filter(Boolean);
295
+ return {
296
+ count: texts.length,
297
+ last: texts[texts.length - 1] || '',
298
+ streaming: !!document.querySelector('[data-is-streaming="true"]'),
299
+ };
300
+ })()`);
301
+ } catch {
302
+ continue;
303
+ }
304
+
305
+ if (!result) continue;
306
+
307
+ const candidate = result.last;
308
+ if (!candidate || candidate === prompt.trim()) continue;
309
+ if (result.count <= baselineCount) continue;
310
+ if (result.streaming) {
311
+ lastText = candidate;
312
+ stableCount = 0;
313
+ continue;
314
+ }
315
+
316
+ if (candidate === lastText) {
317
+ stableCount++;
318
+ if (stableCount >= 3) return candidate;
319
+ } else {
320
+ stableCount = 0;
321
+ lastText = candidate;
322
+ }
323
+ }
324
+
325
+ return lastText || null;
326
+ }
327
+
328
+ async function waitForFilePreview(page, fileName) {
329
+ for (let attempt = 0; attempt < 12; attempt++) {
330
+ await page.wait(1);
331
+ const ready = await page.evaluate(`(() => {
332
+ // Claude renders attachments as data-testid="file-thumbnail" cards with
333
+ // a sibling Remove button. Either signal indicates the file took.
334
+ if (document.querySelector('[data-testid="file-thumbnail"]')) return true;
335
+ var removeBtn = Array.from(document.querySelectorAll('button'))
336
+ .find(function(b) { return (b.getAttribute('aria-label') || '') === 'Remove'; });
337
+ return !!removeBtn;
338
+ })()`);
339
+ if (ready) return true;
340
+ }
341
+ return false;
342
+ }
343
+
344
+ export async function sendWithFile(page, filePath, prompt) {
345
+ const fs = await import('node:fs');
346
+ const path = await import('node:path');
347
+ const absPath = path.default.resolve(filePath);
348
+
349
+ if (!fs.default.existsSync(absPath)) {
350
+ return { ok: false, reason: `File not found: ${absPath}` };
351
+ }
352
+
353
+ const stats = fs.default.statSync(absPath);
354
+ if (stats.size > 30 * 1024 * 1024) {
355
+ return { ok: false, reason: `File too large (${(stats.size / 1024 / 1024).toFixed(1)} MB). Max: 30 MB` };
356
+ }
357
+
358
+ const fileName = path.default.basename(absPath);
359
+
360
+ let uploaded = false;
361
+ if (page.setFileInput) {
362
+ try {
363
+ // Upload via CDP so the file content does not cross the daemon body
364
+ // limit, then trigger React's controlled onChange manually because
365
+ // CDP assigns .files without firing the synthetic event React listens for.
366
+ await page.setFileInput([absPath], 'input[data-testid="file-upload"]');
367
+ const fired = await page.evaluate(`(() => {
368
+ var inp = document.querySelector('input[data-testid="file-upload"]');
369
+ if (!inp) return { ok: false, reason: 'file input not found' };
370
+ var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
371
+ if (propsKey && typeof inp[propsKey].onChange === 'function') {
372
+ inp[propsKey].onChange({ target: { files: inp.files } });
373
+ return { ok: true, via: 'react' };
374
+ }
375
+ inp.dispatchEvent(new Event('change', { bubbles: true }));
376
+ return { ok: true, via: 'native' };
377
+ })()`);
378
+ if (!fired?.ok) return fired;
379
+ uploaded = true;
380
+ } catch (err) {
381
+ const msg = String(err?.message || err);
382
+ if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed')) {
383
+ throw err;
384
+ }
385
+ }
386
+ }
387
+
388
+ if (!uploaded) {
389
+ const content = fs.default.readFileSync(absPath);
390
+ const base64 = content.toString('base64');
391
+ const fallbackResult = await page.evaluate(`(async () => {
392
+ var binary = atob('${base64}');
393
+ var bytes = new Uint8Array(binary.length);
394
+ for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
395
+
396
+ var file = new File([bytes], ${JSON.stringify(fileName)});
397
+ var dt = new DataTransfer();
398
+ dt.items.add(file);
399
+
400
+ var inp = document.querySelector('input[data-testid="file-upload"]');
401
+ if (!inp) return { ok: false, reason: 'file input not found' };
402
+
403
+ var propsKey = Object.keys(inp).find(function(k) { return k.startsWith('__reactProps$'); });
404
+ if (!propsKey || typeof inp[propsKey].onChange !== 'function') {
405
+ return { ok: false, reason: 'React onChange not found' };
406
+ }
407
+
408
+ inp.files = dt.files;
409
+ inp[propsKey].onChange({ target: { files: inp.files } });
410
+ return { ok: true };
411
+ })()`);
412
+ if (fallbackResult && !fallbackResult.ok) return fallbackResult;
413
+ }
414
+
415
+ const ready = await waitForFilePreview(page, fileName);
416
+ if (!ready) return { ok: false, reason: 'file preview did not appear' };
417
+
418
+ return sendMessage(page, prompt);
419
+ }
420
+
421
+ // Retries on CDP "Promise was collected" errors caused by Claude SPA route changes.
422
+ export async function withRetry(fn, retries = 2) {
423
+ for (let i = 0; i <= retries; i++) {
424
+ try {
425
+ return await fn();
426
+ } catch (err) {
427
+ const msg = String(err?.message || err);
428
+ if (i < retries && msg.includes('Promise was collected')) {
429
+ await new Promise(r => setTimeout(r, 2000));
430
+ continue;
431
+ }
432
+ throw err;
433
+ }
434
+ }
435
+ }
436
+
437
+ export function parseBoolFlag(value) {
438
+ if (typeof value === 'boolean') return value;
439
+ return String(value ?? '').trim().toLowerCase() === 'true';
440
+ }
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
6
+ import { parseBoolFlag, sendWithFile, selectModel, requireConversationId, requireNonEmptyPrompt, requirePositiveInt } from './utils.js';
7
+
8
+ describe('claude parseBoolFlag', () => {
9
+ it('returns booleans unchanged', () => {
10
+ expect(parseBoolFlag(true)).toBe(true);
11
+ expect(parseBoolFlag(false)).toBe(false);
12
+ });
13
+
14
+ it('treats only "true" string (case-insensitive) as true', () => {
15
+ expect(parseBoolFlag('true')).toBe(true);
16
+ expect(parseBoolFlag('TRUE')).toBe(true);
17
+ expect(parseBoolFlag('1')).toBe(false);
18
+ expect(parseBoolFlag('yes')).toBe(false);
19
+ expect(parseBoolFlag('')).toBe(false);
20
+ expect(parseBoolFlag(null)).toBe(false);
21
+ expect(parseBoolFlag(undefined)).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe('claude argument helpers', () => {
26
+ it('rejects blank prompts', () => {
27
+ expect(() => requireNonEmptyPrompt(' ', 'claude ask')).toThrow(ArgumentError);
28
+ });
29
+
30
+ it('rejects non-positive integers for numeric flags', () => {
31
+ expect(() => requirePositiveInt(0, 'claude ask --timeout')).toThrow(ArgumentError);
32
+ expect(() => requirePositiveInt(-1, 'claude history --limit')).toThrow(ArgumentError);
33
+ });
34
+
35
+ it('rejects missing conversation ids', () => {
36
+ expect(() => requireConversationId(' ')).toThrow(ArgumentError);
37
+ });
38
+ });
39
+
40
+ describe('claude sendWithFile', () => {
41
+ const tempDirs = [];
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ while (tempDirs.length) {
46
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ it('prefers page.setFileInput, then sends after preview appears', async () => {
51
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
52
+ tempDirs.push(dir);
53
+ const filePath = path.join(dir, 'cat.png');
54
+ fs.writeFileSync(filePath, 'fake');
55
+
56
+ const page = {
57
+ nativeType: vi.fn().mockResolvedValue(undefined),
58
+ setFileInput: vi.fn().mockResolvedValue(undefined),
59
+ wait: vi.fn().mockResolvedValue(undefined),
60
+ evaluate: vi.fn()
61
+ .mockResolvedValueOnce({ ok: true, via: 'react' }) // React onChange fired after setFileInput
62
+ .mockResolvedValueOnce(true) // waitForFilePreview hit
63
+ .mockResolvedValueOnce(true) // composer ready
64
+ .mockResolvedValueOnce({ ok: true }), // send button click
65
+ };
66
+
67
+ const result = await sendWithFile(page, filePath, 'describe this');
68
+
69
+ expect(result).toEqual({ ok: true });
70
+ expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[data-testid="file-upload"]');
71
+ expect(page.nativeType).toHaveBeenCalledWith('describe this');
72
+ });
73
+
74
+ it('returns file-not-found when path does not exist', async () => {
75
+ const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
76
+ const result = await sendWithFile(page, '/no/such/file.png', 'hi');
77
+ expect(result.ok).toBe(false);
78
+ expect(result.reason).toContain('File not found');
79
+ });
80
+
81
+ it('rejects oversized files before any upload attempt', async () => {
82
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-claude-'));
83
+ tempDirs.push(dir);
84
+ const filePath = path.join(dir, 'big.bin');
85
+ fs.writeFileSync(filePath, Buffer.alloc(31 * 1024 * 1024));
86
+
87
+ const page = { setFileInput: vi.fn(), evaluate: vi.fn(), wait: vi.fn() };
88
+ const result = await sendWithFile(page, filePath, 'hi');
89
+
90
+ expect(result.ok).toBe(false);
91
+ expect(result.reason).toMatch(/too large/);
92
+ expect(page.setFileInput).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ describe('claude selectModel', () => {
97
+ afterEach(() => {
98
+ vi.restoreAllMocks();
99
+ });
100
+
101
+ it('rejects unknown model keys without touching the page', async () => {
102
+ const page = { evaluate: vi.fn() };
103
+
104
+ const result = await selectModel(page, 'gpt5');
105
+
106
+ expect(result).toEqual({ ok: false });
107
+ expect(page.evaluate).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('returns toggled=false when the dropdown already shows the requested model', async () => {
111
+ const page = {
112
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true, toggled: false }),
113
+ wait: vi.fn(),
114
+ };
115
+
116
+ const result = await selectModel(page, 'sonnet');
117
+
118
+ expect(result).toEqual({ ok: true, toggled: false });
119
+ expect(page.wait).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('opens the dropdown and clicks the matching radio', async () => {
123
+ const page = {
124
+ evaluate: vi.fn()
125
+ .mockResolvedValueOnce({ ok: true, opened: true })
126
+ .mockResolvedValueOnce({ ok: true, toggled: true }),
127
+ wait: vi.fn().mockResolvedValue(undefined),
128
+ };
129
+
130
+ const result = await selectModel(page, 'haiku');
131
+
132
+ expect(result).toEqual({ ok: true, toggled: true });
133
+ expect(page.evaluate).toHaveBeenCalledTimes(2);
134
+ });
135
+
136
+ it('flags upgrade-required when picking a paid model on free tier', async () => {
137
+ const page = {
138
+ evaluate: vi.fn()
139
+ .mockResolvedValueOnce({ ok: true, opened: true })
140
+ .mockResolvedValueOnce({ ok: false, upgrade: true }),
141
+ wait: vi.fn().mockResolvedValue(undefined),
142
+ };
143
+
144
+ const result = await selectModel(page, 'opus');
145
+
146
+ expect(result).toEqual({ ok: false, upgrade: true });
147
+ });
148
+ });
package/clis/codex/ask.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { SelectorError } from '@jackwener/opencli/errors';
2
+ import { selectorError } from '@jackwener/opencli/errors';
3
3
  export const askCommand = cli({
4
4
  site: 'codex',
5
5
  name: 'ask',
@@ -34,7 +34,7 @@ export const askCommand = cli({
34
34
  })(${JSON.stringify(text)})
35
35
  `);
36
36
  if (!injected)
37
- throw new SelectorError('Codex input element');
37
+ throw selectorError('Codex input element');
38
38
  await page.wait(0.5);
39
39
  await page.pressKey('Enter');
40
40
  // Poll for new content
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { SelectorError } from '@jackwener/opencli/errors';
2
+ import { selectorError } from '@jackwener/opencli/errors';
3
3
  export const sendCommand = cli({
4
4
  site: 'codex',
5
5
  name: 'send',
@@ -28,7 +28,7 @@ export const sendCommand = cli({
28
28
  })(${JSON.stringify(textToInsert)})
29
29
  `);
30
30
  if (!injected)
31
- throw new SelectorError('Codex Composer input element');
31
+ throw selectorError('Codex Composer input element');
32
32
  // Wait for the UI to register the input
33
33
  await page.wait(0.5);
34
34
  // Simulate Enter key to submit
@@ -34,7 +34,7 @@ cli({
34
34
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
35
35
  ],
36
36
  columns: ['rank', 'name', 'type', 'score', 'price', 'url'],
37
- func: async (_page, kwargs) => {
37
+ func: async (kwargs) => {
38
38
  const query = String(kwargs.query || '').trim();
39
39
  if (!query) {
40
40
  throw new ArgumentError('Search keyword cannot be empty');
@@ -25,7 +25,7 @@ describe('ctrip search', () => {
25
25
  ],
26
26
  },
27
27
  }), { status: 200 })));
28
- const result = await command.func(null, { query: '苏州', limit: 3 });
28
+ const result = await command.func({ query: '苏州', limit: 3 });
29
29
  expect(result).toEqual([
30
30
  {
31
31
  rank: 1,
@@ -46,11 +46,11 @@ describe('ctrip search', () => {
46
46
  ]);
47
47
  });
48
48
  it('rejects empty queries', async () => {
49
- await expect(command.func(null, { query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
49
+ await expect(command.func({ query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
50
50
  });
51
51
  it('surfaces fetch failures as CliError', async () => {
52
52
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 503 })));
53
- await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toMatchObject({
53
+ await expect(command.func({ query: '苏州', limit: 3 })).rejects.toMatchObject({
54
54
  code: 'FETCH_ERROR',
55
55
  message: 'ctrip search failed with status 503',
56
56
  });
@@ -59,6 +59,6 @@ describe('ctrip search', () => {
59
59
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
60
60
  Response: { searchResults: [] },
61
61
  }), { status: 200 })));
62
- await expect(command.func(null, { query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
62
+ await expect(command.func({ query: '苏州', limit: 3 })).rejects.toThrow('ctrip search returned no data');
63
63
  });
64
64
  });
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { SelectorError } from '@jackwener/opencli/errors';
2
+ import { selectorError } from '@jackwener/opencli/errors';
3
3
  export const askCommand = cli({
4
4
  site: 'cursor',
5
5
  name: 'ask',
@@ -28,7 +28,7 @@ export const askCommand = cli({
28
28
  return true;
29
29
  })(${JSON.stringify(text)})`);
30
30
  if (!injected)
31
- throw new SelectorError('Cursor input element');
31
+ throw selectorError('Cursor input element');
32
32
  await page.wait(0.5);
33
33
  await page.pressKey('Enter');
34
34
  // Poll until a new assistant message appears or timeout