@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,338 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const {
5
+ mockEnsureOnClaude,
6
+ mockEnsureClaudeComposer,
7
+ mockSelectModel,
8
+ mockSetAdaptiveThinking,
9
+ mockSendMessage,
10
+ mockSendWithFile,
11
+ mockGetBubbleCount,
12
+ mockWaitForResponse,
13
+ mockParseBoolFlag,
14
+ mockRequireNonEmptyPrompt,
15
+ mockRequirePositiveInt,
16
+ mockWithRetry,
17
+ } = vi.hoisted(() => ({
18
+ mockEnsureOnClaude: vi.fn(),
19
+ mockEnsureClaudeComposer: vi.fn(),
20
+ mockSelectModel: vi.fn(),
21
+ mockSetAdaptiveThinking: vi.fn(),
22
+ mockSendMessage: vi.fn(),
23
+ mockSendWithFile: vi.fn(),
24
+ mockGetBubbleCount: vi.fn(),
25
+ mockWaitForResponse: vi.fn(),
26
+ mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
27
+ mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
28
+ mockRequirePositiveInt: vi.fn((v) => Number(v)),
29
+ mockWithRetry: vi.fn(async (fn) => fn()),
30
+ }));
31
+
32
+ vi.mock('./utils.js', () => ({
33
+ CLAUDE_DOMAIN: 'claude.ai',
34
+ CLAUDE_URL: 'https://claude.ai/new',
35
+ ensureOnClaude: mockEnsureOnClaude,
36
+ ensureClaudeComposer: mockEnsureClaudeComposer,
37
+ selectModel: mockSelectModel,
38
+ setAdaptiveThinking: mockSetAdaptiveThinking,
39
+ sendMessage: mockSendMessage,
40
+ sendWithFile: mockSendWithFile,
41
+ getBubbleCount: mockGetBubbleCount,
42
+ waitForResponse: mockWaitForResponse,
43
+ parseBoolFlag: mockParseBoolFlag,
44
+ requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
45
+ requirePositiveInt: mockRequirePositiveInt,
46
+ withRetry: mockWithRetry,
47
+ }));
48
+
49
+ import { askCommand } from './ask.js';
50
+
51
+ describe('claude ask basic flow', () => {
52
+ const page = {
53
+ wait: vi.fn().mockResolvedValue(undefined),
54
+ goto: vi.fn().mockResolvedValue(undefined),
55
+ evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
56
+ };
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ page.evaluate.mockResolvedValue('https://claude.ai/new');
61
+ mockEnsureOnClaude.mockResolvedValue(false);
62
+ mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
63
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
64
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
65
+ mockSendMessage.mockResolvedValue({ ok: true });
66
+ mockSendWithFile.mockResolvedValue({ ok: true });
67
+ mockGetBubbleCount.mockResolvedValue(0);
68
+ mockWaitForResponse.mockResolvedValue('hello there');
69
+ mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
70
+ mockRequirePositiveInt.mockImplementation((v) => Number(v));
71
+ });
72
+
73
+ it('returns the assistant response on a fresh chat', async () => {
74
+ const rows = await askCommand.func(page, {
75
+ prompt: 'hi',
76
+ timeout: 120,
77
+ new: false,
78
+ model: 'sonnet',
79
+ think: false,
80
+ });
81
+
82
+ expect(rows).toEqual([{ response: 'hello there' }]);
83
+ expect(mockSendMessage).toHaveBeenCalledWith(page, 'hi');
84
+ expect(mockWaitForResponse).toHaveBeenCalledWith(page, 0, 'hi', 120000);
85
+ });
86
+
87
+ it('navigates to /new when --new is set', async () => {
88
+ await askCommand.func(page, {
89
+ prompt: 'hi',
90
+ timeout: 120,
91
+ new: true,
92
+ model: 'sonnet',
93
+ think: false,
94
+ });
95
+
96
+ expect(page.goto).toHaveBeenCalledWith('https://claude.ai/new');
97
+ expect(mockEnsureOnClaude).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('throws EmptyResultError when waitForResponse yields nothing', async () => {
101
+ mockWaitForResponse.mockResolvedValue(null);
102
+
103
+ await expect(askCommand.func(page, {
104
+ prompt: 'hi',
105
+ timeout: 60,
106
+ new: false,
107
+ model: 'sonnet',
108
+ think: false,
109
+ })).rejects.toThrow(EmptyResultError);
110
+ });
111
+
112
+ it('throws CommandExecutionError when send fails', async () => {
113
+ mockSendMessage.mockResolvedValue({ ok: false, reason: 'composer not found' });
114
+
115
+ await expect(askCommand.func(page, {
116
+ prompt: 'hi',
117
+ timeout: 120,
118
+ new: false,
119
+ model: 'sonnet',
120
+ think: false,
121
+ })).rejects.toThrow(/composer not found/);
122
+ });
123
+ });
124
+
125
+ describe('claude ask --model handling', () => {
126
+ const page = {
127
+ wait: vi.fn().mockResolvedValue(undefined),
128
+ goto: vi.fn().mockResolvedValue(undefined),
129
+ evaluate: vi.fn(),
130
+ };
131
+
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ mockEnsureOnClaude.mockResolvedValue(false);
135
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
136
+ mockSendMessage.mockResolvedValue({ ok: true });
137
+ mockGetBubbleCount.mockResolvedValue(0);
138
+ mockWaitForResponse.mockResolvedValue('reply');
139
+ });
140
+
141
+ it('rejects --model opus on free tier with usage-error guidance', async () => {
142
+ page.evaluate.mockResolvedValue('https://claude.ai/new');
143
+ mockSelectModel.mockResolvedValue({ ok: false, upgrade: true });
144
+
145
+ await expect(askCommand.func(page, {
146
+ prompt: 'hi',
147
+ timeout: 120,
148
+ new: false,
149
+ model: 'opus',
150
+ think: false,
151
+ })).rejects.toMatchObject(new ArgumentError(
152
+ 'opus model requires a paid Claude plan.',
153
+ 'Pick --model sonnet or --model haiku, or upgrade your account.',
154
+ ));
155
+ });
156
+
157
+ it('skips model selection inside an existing conversation', async () => {
158
+ page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
159
+
160
+ const rows = await askCommand.func(page, {
161
+ prompt: 'continue',
162
+ timeout: 120,
163
+ new: false,
164
+ model: 'sonnet',
165
+ think: false,
166
+ });
167
+
168
+ expect(rows).toEqual([{ response: 'reply' }]);
169
+ expect(mockSelectModel).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('fails fast when --model is explicit inside an existing conversation', async () => {
173
+ page.evaluate.mockResolvedValue('https://claude.ai/chat/abc-123');
174
+
175
+ await expect(askCommand.func(page, {
176
+ prompt: 'continue',
177
+ timeout: 120,
178
+ new: false,
179
+ model: 'opus',
180
+ think: false,
181
+ __opencliOptionSources: { model: 'cli' },
182
+ })).rejects.toMatchObject(new ArgumentError(
183
+ 'Cannot switch to opus model inside an existing conversation.',
184
+ 'Re-run with --new to start a fresh chat before selecting a model.',
185
+ ));
186
+
187
+ expect(mockSelectModel).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ describe('claude ask --think', () => {
192
+ const page = {
193
+ wait: vi.fn().mockResolvedValue(undefined),
194
+ goto: vi.fn().mockResolvedValue(undefined),
195
+ evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
196
+ };
197
+
198
+ beforeEach(() => {
199
+ vi.clearAllMocks();
200
+ mockEnsureOnClaude.mockResolvedValue(false);
201
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
202
+ mockSendMessage.mockResolvedValue({ ok: true });
203
+ mockGetBubbleCount.mockResolvedValue(0);
204
+ mockWaitForResponse.mockResolvedValue('reply');
205
+ });
206
+
207
+ it('toggles Adaptive thinking when --think is set', async () => {
208
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: true });
209
+
210
+ await askCommand.func(page, {
211
+ prompt: 'reason carefully',
212
+ timeout: 120,
213
+ new: false,
214
+ model: 'sonnet',
215
+ think: true,
216
+ });
217
+
218
+ expect(mockSetAdaptiveThinking).toHaveBeenCalledWith(page, true);
219
+ });
220
+
221
+ it('throws when --think requested but toggle fails', async () => {
222
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
223
+
224
+ await expect(askCommand.func(page, {
225
+ prompt: 'reason carefully',
226
+ timeout: 120,
227
+ new: false,
228
+ model: 'sonnet',
229
+ think: true,
230
+ })).rejects.toThrow(/Adaptive thinking/);
231
+ });
232
+
233
+ it('does not throw when --think is false and toggle returns ok=false', async () => {
234
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: false });
235
+
236
+ await expect(askCommand.func(page, {
237
+ prompt: 'hi',
238
+ timeout: 120,
239
+ new: false,
240
+ model: 'sonnet',
241
+ think: false,
242
+ })).resolves.toEqual([{ response: 'reply' }]);
243
+ });
244
+
245
+ it('fails fast when prompt validation rejects an empty prompt', async () => {
246
+ mockRequireNonEmptyPrompt.mockImplementation(() => {
247
+ throw new ArgumentError('claude ask prompt cannot be empty');
248
+ });
249
+
250
+ await expect(askCommand.func(page, {
251
+ prompt: '',
252
+ timeout: 120,
253
+ new: false,
254
+ model: 'sonnet',
255
+ think: false,
256
+ })).rejects.toThrow(ArgumentError);
257
+ });
258
+
259
+ it('fails fast when timeout validation rejects a non-positive value', async () => {
260
+ mockRequirePositiveInt.mockImplementation(() => {
261
+ throw new ArgumentError('claude ask --timeout must be a positive integer');
262
+ });
263
+
264
+ await expect(askCommand.func(page, {
265
+ prompt: 'hi',
266
+ timeout: 0,
267
+ new: false,
268
+ model: 'sonnet',
269
+ think: false,
270
+ })).rejects.toThrow(ArgumentError);
271
+ });
272
+ });
273
+
274
+ describe('claude ask --file', () => {
275
+ const page = {
276
+ wait: vi.fn().mockResolvedValue(undefined),
277
+ goto: vi.fn().mockResolvedValue(undefined),
278
+ evaluate: vi.fn().mockResolvedValue('https://claude.ai/new'),
279
+ };
280
+
281
+ beforeEach(() => {
282
+ vi.clearAllMocks();
283
+ mockEnsureOnClaude.mockResolvedValue(false);
284
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
285
+ mockSetAdaptiveThinking.mockResolvedValue({ ok: true, toggled: false });
286
+ mockSendWithFile.mockResolvedValue({ ok: true });
287
+ mockGetBubbleCount.mockResolvedValue(3);
288
+ mockWaitForResponse.mockResolvedValue('the image shows a cat');
289
+ mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
290
+ mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
291
+ mockRequirePositiveInt.mockImplementation((v) => Number(v));
292
+ });
293
+
294
+ it('routes through sendWithFile and captures baseline before sending', async () => {
295
+ const rows = await askCommand.func(page, {
296
+ prompt: 'describe this',
297
+ timeout: 120,
298
+ new: false,
299
+ model: 'sonnet',
300
+ think: false,
301
+ file: '/tmp/cat.png',
302
+ });
303
+
304
+ expect(rows).toEqual([{ response: 'the image shows a cat' }]);
305
+ expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
306
+ expect(mockSendWithFile).toHaveBeenCalledWith(page, '/tmp/cat.png', 'describe this');
307
+ expect(mockSendMessage).not.toHaveBeenCalled();
308
+ expect(mockWaitForResponse).toHaveBeenCalledWith(page, 3, 'describe this', 120000);
309
+ });
310
+
311
+ it('surfaces file upload failure as CommandExecutionError', async () => {
312
+ mockSendWithFile.mockResolvedValue({ ok: false, reason: 'file preview did not appear' });
313
+
314
+ await expect(askCommand.func(page, {
315
+ prompt: 'describe this',
316
+ timeout: 120,
317
+ new: false,
318
+ model: 'sonnet',
319
+ think: false,
320
+ file: '/tmp/cat.png',
321
+ })).rejects.toThrow(/file preview did not appear/);
322
+ });
323
+
324
+ it('absorbs "Promise was collected" SPA navigation error after send', async () => {
325
+ mockSendWithFile.mockRejectedValue(new Error('Promise was collected'));
326
+
327
+ const rows = await askCommand.func(page, {
328
+ prompt: 'describe this',
329
+ timeout: 120,
330
+ new: false,
331
+ model: 'sonnet',
332
+ think: false,
333
+ file: '/tmp/cat.png',
334
+ });
335
+
336
+ expect(rows).toEqual([{ response: 'the image shows a cat' }]);
337
+ });
338
+ });
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const {
5
+ mockEnsureOnClaude,
6
+ mockEnsureClaudeComposer,
7
+ mockEnsureClaudeLogin,
8
+ mockSendMessage,
9
+ mockParseBoolFlag,
10
+ mockRequireNonEmptyPrompt,
11
+ mockGetVisibleMessages,
12
+ mockGetConversationList,
13
+ mockRequirePositiveInt,
14
+ mockRequireConversationId,
15
+ mockWithRetry,
16
+ } = vi.hoisted(() => ({
17
+ mockEnsureOnClaude: vi.fn(),
18
+ mockEnsureClaudeComposer: vi.fn(),
19
+ mockEnsureClaudeLogin: vi.fn(),
20
+ mockSendMessage: vi.fn(),
21
+ mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
22
+ mockRequireNonEmptyPrompt: vi.fn((v) => String(v ?? '')),
23
+ mockGetVisibleMessages: vi.fn(),
24
+ mockGetConversationList: vi.fn(),
25
+ mockRequirePositiveInt: vi.fn((v) => Number(v)),
26
+ mockRequireConversationId: vi.fn((v) => String(v ?? '').trim()),
27
+ mockWithRetry: vi.fn(async (fn) => fn()),
28
+ }));
29
+
30
+ vi.mock('./utils.js', () => ({
31
+ CLAUDE_DOMAIN: 'claude.ai',
32
+ CLAUDE_URL: 'https://claude.ai/new',
33
+ ensureOnClaude: mockEnsureOnClaude,
34
+ ensureClaudeComposer: mockEnsureClaudeComposer,
35
+ ensureClaudeLogin: mockEnsureClaudeLogin,
36
+ sendMessage: mockSendMessage,
37
+ parseBoolFlag: mockParseBoolFlag,
38
+ requireNonEmptyPrompt: mockRequireNonEmptyPrompt,
39
+ getVisibleMessages: mockGetVisibleMessages,
40
+ getConversationList: mockGetConversationList,
41
+ requirePositiveInt: mockRequirePositiveInt,
42
+ requireConversationId: mockRequireConversationId,
43
+ withRetry: mockWithRetry,
44
+ }));
45
+
46
+ import { sendCommand } from './send.js';
47
+ import { newCommand } from './new.js';
48
+ import { readCommand } from './read.js';
49
+ import { historyCommand } from './history.js';
50
+ import { detailCommand } from './detail.js';
51
+
52
+ describe('claude command-level fail-fast contracts', () => {
53
+ const page = {
54
+ goto: vi.fn().mockResolvedValue(undefined),
55
+ wait: vi.fn().mockResolvedValue(undefined),
56
+ };
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ mockEnsureOnClaude.mockResolvedValue(false);
61
+ mockEnsureClaudeComposer.mockResolvedValue({ isLoggedIn: true, hasComposer: true });
62
+ mockEnsureClaudeLogin.mockResolvedValue({ isLoggedIn: true });
63
+ mockSendMessage.mockResolvedValue({ ok: true });
64
+ mockRequireNonEmptyPrompt.mockImplementation((v) => String(v ?? ''));
65
+ mockGetVisibleMessages.mockResolvedValue([{ Index: 0, Role: 'assistant', Text: 'hi' }]);
66
+ mockGetConversationList.mockResolvedValue([{ Index: 1, Id: 'abc', Title: 'Hi', Url: 'https://claude.ai/chat/abc' }]);
67
+ mockRequirePositiveInt.mockImplementation((v) => Number(v));
68
+ mockRequireConversationId.mockImplementation((v) => String(v ?? '').trim());
69
+ });
70
+
71
+ it('send rejects empty prompt via ArgumentError', async () => {
72
+ mockRequireNonEmptyPrompt.mockImplementation(() => {
73
+ throw new ArgumentError('claude send prompt cannot be empty');
74
+ });
75
+
76
+ await expect(sendCommand.func(page, { prompt: '', new: false })).rejects.toThrow(ArgumentError);
77
+ });
78
+
79
+ it('send surfaces auth failure from composer readiness', async () => {
80
+ mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude send requires a logged-in Claude session.'));
81
+
82
+ await expect(sendCommand.func(page, { prompt: 'hi', new: false })).rejects.toThrow(AuthRequiredError);
83
+ });
84
+
85
+ it('new no longer false-succeeds on login wall', async () => {
86
+ mockEnsureClaudeComposer.mockRejectedValue(new AuthRequiredError('claude.ai', 'Claude new requires a logged-in Claude session with a visible composer.'));
87
+
88
+ await expect(newCommand.func(page)).rejects.toThrow(AuthRequiredError);
89
+ });
90
+
91
+ it('read throws EmptyResultError instead of a placeholder row', async () => {
92
+ mockGetVisibleMessages.mockResolvedValue([]);
93
+
94
+ await expect(readCommand.func(page)).rejects.toThrow(EmptyResultError);
95
+ });
96
+
97
+ it('history rejects invalid --limit values instead of silently coercing them', async () => {
98
+ mockRequirePositiveInt.mockImplementation(() => {
99
+ throw new ArgumentError('claude history --limit must be a positive integer');
100
+ });
101
+
102
+ await expect(historyCommand.func(page, { limit: 0 })).rejects.toThrow(ArgumentError);
103
+ });
104
+
105
+ it('history throws EmptyResultError on an empty /recents page', async () => {
106
+ mockGetConversationList.mockResolvedValue([]);
107
+
108
+ await expect(historyCommand.func(page, { limit: 20 })).rejects.toThrow(EmptyResultError);
109
+ });
110
+
111
+ it('detail rejects a missing conversation id', async () => {
112
+ mockRequireConversationId.mockImplementation(() => {
113
+ throw new ArgumentError('claude detail requires a conversation id');
114
+ });
115
+
116
+ await expect(detailCommand.func(page, { id: '' })).rejects.toThrow(ArgumentError);
117
+ });
118
+ });
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
4
+
5
+ export const detailCommand = cli({
6
+ site: 'claude',
7
+ name: 'detail',
8
+ description: 'Open a Claude conversation by ID and read its messages',
9
+ domain: CLAUDE_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ { name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
15
+ ],
16
+ columns: ['Index', 'Role', 'Text'],
17
+
18
+ func: async (page, kwargs) => {
19
+ const id = requireConversationId(kwargs.id);
20
+
21
+ await page.goto(`https://claude.ai/chat/${id}`);
22
+ await page.wait(4);
23
+ await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
24
+
25
+ const messages = await getVisibleMessages(page);
26
+ if (messages.length > 0) return messages;
27
+ throw new EmptyResultError('claude detail', `No visible Claude messages were found for conversation ${id}.`);
28
+ },
29
+ });
@@ -0,0 +1,31 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { CLAUDE_DOMAIN, getConversationList, ensureClaudeLogin, requirePositiveInt } from './utils.js';
4
+
5
+ export const historyCommand = cli({
6
+ site: 'claude',
7
+ name: 'history',
8
+ description: 'List conversation history from Claude /recents',
9
+ domain: CLAUDE_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
15
+ ],
16
+ columns: ['Index', 'Id', 'Title', 'Url'],
17
+
18
+ func: async (page, kwargs) => {
19
+ const limit = requirePositiveInt(
20
+ Number(kwargs.limit ?? 20),
21
+ 'claude history --limit',
22
+ 'Example: opencli claude history --limit 20',
23
+ );
24
+ const conversations = await getConversationList(page);
25
+ await ensureClaudeLogin(page, 'Claude history requires a logged-in Claude session.');
26
+ if (conversations.length === 0) {
27
+ throw new EmptyResultError('claude history', 'No Claude conversation history was visible on /recents.');
28
+ }
29
+ return conversations.slice(0, limit);
30
+ },
31
+ });
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CLAUDE_DOMAIN, CLAUDE_URL, ensureClaudeComposer } from './utils.js';
3
+
4
+ export const newCommand = cli({
5
+ site: 'claude',
6
+ name: 'new',
7
+ description: 'Start a new conversation in Claude',
8
+ domain: CLAUDE_DOMAIN,
9
+ strategy: Strategy.COOKIE,
10
+ browser: true,
11
+ navigateBefore: false,
12
+ args: [],
13
+ columns: ['Status'],
14
+
15
+ func: async (page) => {
16
+ await page.goto(CLAUDE_URL);
17
+ await page.wait(2);
18
+ await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.');
19
+ return [{ Status: 'New chat started' }];
20
+ },
21
+ });
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { CLAUDE_DOMAIN, ensureOnClaude, getVisibleMessages, ensureClaudeLogin } from './utils.js';
4
+
5
+ export const readCommand = cli({
6
+ site: 'claude',
7
+ name: 'read',
8
+ description: 'Read the current Claude conversation',
9
+ domain: CLAUDE_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [],
14
+ columns: ['Index', 'Role', 'Text'],
15
+
16
+ func: async (page) => {
17
+ await ensureOnClaude(page);
18
+ await page.wait(3);
19
+ await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.');
20
+ const messages = await getVisibleMessages(page);
21
+ if (messages.length > 0) return messages;
22
+ throw new EmptyResultError('claude read', 'No visible Claude messages were found in the current conversation.');
23
+ },
24
+ });
@@ -0,0 +1,41 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
4
+
5
+ export const sendCommand = cli({
6
+ site: 'claude',
7
+ name: 'send',
8
+ description: 'Send a prompt to Claude without waiting for the response',
9
+ domain: CLAUDE_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ { name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
15
+ { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
16
+ ],
17
+ columns: ['Status', 'SubmittedBy', 'InjectedText'],
18
+
19
+ func: async (page, kwargs) => {
20
+ const prompt = requireNonEmptyPrompt(kwargs.prompt, 'claude send');
21
+
22
+ if (parseBoolFlag(kwargs.new)) {
23
+ await page.goto(CLAUDE_URL);
24
+ await page.wait(3);
25
+ } else {
26
+ await ensureOnClaude(page);
27
+ await page.wait(2);
28
+ }
29
+ await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.'));
30
+
31
+ const sendResult = await withRetry(() => sendMessage(page, prompt));
32
+ if (!sendResult?.ok) {
33
+ throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
34
+ }
35
+ return [{
36
+ Status: 'Success',
37
+ SubmittedBy: sendResult.method || 'send-button',
38
+ InjectedText: prompt,
39
+ }];
40
+ },
41
+ });
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CLAUDE_DOMAIN, ensureOnClaude, getPageState } from './utils.js';
3
+
4
+ export const statusCommand = cli({
5
+ site: 'claude',
6
+ name: 'status',
7
+ description: 'Check Claude page availability and login state',
8
+ domain: CLAUDE_DOMAIN,
9
+ strategy: Strategy.COOKIE,
10
+ browser: true,
11
+ navigateBefore: false,
12
+ args: [],
13
+ columns: ['Status', 'Login', 'Url'],
14
+
15
+ func: async (page) => {
16
+ await ensureOnClaude(page);
17
+ const state = await getPageState(page);
18
+ return [{
19
+ Status: state.hasComposer ? 'Connected' : 'Page not ready',
20
+ Login: state.isLoggedIn ? 'Yes' : 'No',
21
+ Url: state.url,
22
+ }];
23
+ },
24
+ });