@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
@@ -9,22 +9,32 @@
9
9
  * 5. Lazy-loading of TS modules from manifest
10
10
  * 6. Lifecycle hooks (onBeforeExecute / onAfterExecute)
11
11
  */
12
- import { getRegistry, fullName } from './registry.js';
12
+ import { getRegistry, fullName, } from './registry.js';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import * as fs from 'node:fs';
15
15
  import * as os from 'node:os';
16
16
  import { executePipeline } from './pipeline/index.js';
17
- import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
18
- import { isDiagnosticEnabled, collectDiagnostic, emitDiagnostic } from './diagnostic.js';
17
+ import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
19
18
  import { shouldUseBrowserSession } from './capabilityRouting.js';
20
19
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
20
+ import { resolveProfileContextId } from './browser/profile.js';
21
21
  import { emitHook } from './hooks.js';
22
+ import { log } from './logger.js';
22
23
  import { isElectronApp } from './electron-apps.js';
23
24
  import { probeCDP, resolveElectronEndpoint } from './launcher.js';
25
+ import { ObservationSession, exportObservationSession } from './observation/index.js';
26
+ import { resolveAdapterSourcePath } from './adapter-source.js';
24
27
  const _loadedModules = new Map();
25
28
  /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
26
29
  const _moduleMtimes = new Map();
27
30
  const _userClisDir = `${os.homedir()}/.opencli/clis/`;
31
+ function normalizeTraceMode(raw) {
32
+ if (raw === undefined || raw === null || raw === '' || raw === 'off')
33
+ return 'off';
34
+ if (raw === 'on' || raw === 'retain-on-failure')
35
+ return raw;
36
+ throw new ArgumentError(`--trace must be one of: off, on, retain-on-failure. Received: "${String(raw)}"`);
37
+ }
28
38
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
29
39
  const result = { ...kwargs };
30
40
  for (const argDef of cmdArgs) {
@@ -94,27 +104,32 @@ async function runCommand(cmd, page, kwargs, debug) {
94
104
  catch { }
95
105
  }, (err) => {
96
106
  _loadedModules.delete(modulePath);
97
- throw new AdapterLoadError(`Failed to load adapter module ${modulePath}: ${getErrorMessage(err)}`, 'Check that the adapter file exists and has no syntax errors.');
107
+ throw adapterLoadError(`Failed to load adapter module ${modulePath}: ${getErrorMessage(err)}`, 'Check that the adapter file exists and has no syntax errors.');
98
108
  });
99
109
  _loadedModules.set(modulePath, loadPromise);
100
110
  }
101
111
  await _loadedModules.get(modulePath);
102
112
  const updated = getRegistry().get(fullName(cmd));
103
113
  if (updated?.func) {
104
- if (!page && updated.browser !== false) {
105
- throw new CommandExecutionError(`Command ${fullName(cmd)} requires a browser session but none was provided`);
106
- }
107
- return updated.func(page, kwargs, debug);
114
+ return runCommandFunc(updated, page, kwargs, debug);
108
115
  }
109
116
  if (updated?.pipeline)
110
117
  return executePipeline(page, updated.pipeline, { args: kwargs, debug });
111
118
  }
112
119
  if (cmd.func)
113
- return cmd.func(page, kwargs, debug);
120
+ return runCommandFunc(cmd, page, kwargs, debug);
114
121
  if (cmd.pipeline)
115
122
  return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
116
123
  throw new CommandExecutionError(`Command ${fullName(cmd)} has no func or pipeline`, 'This is likely a bug in the adapter definition. Please report this issue.');
117
124
  }
125
+ function runCommandFunc(cmd, page, kwargs, debug) {
126
+ if (cmd.browser === false)
127
+ return cmd.func(kwargs, debug);
128
+ if (!page) {
129
+ throw new CommandExecutionError(`Command ${fullName(cmd)} requires a browser session but none was provided`);
130
+ }
131
+ return cmd.func(page, kwargs, debug);
132
+ }
118
133
  function resolvePreNav(cmd) {
119
134
  if (cmd.navigateBefore === false)
120
135
  return null;
@@ -142,6 +157,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
142
157
  throw err;
143
158
  throw new ArgumentError(getErrorMessage(err));
144
159
  }
160
+ const traceMode = normalizeTraceMode(opts.trace);
145
161
  const hookCtx = {
146
162
  command: fullName(cmd),
147
163
  args: kwargs,
@@ -149,7 +165,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
149
165
  };
150
166
  await emitHook('onBeforeExecute', hookCtx);
151
167
  let result;
152
- let diagnosticEmitted = false;
153
168
  try {
154
169
  if (shouldUseBrowserSession(cmd)) {
155
170
  const electron = isElectronApp(cmd.site);
@@ -170,9 +185,38 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
170
185
  }
171
186
  ensureRequiredEnv(cmd);
172
187
  const BrowserFactory = getBrowserFactory(cmd.site);
188
+ const contextId = resolveProfileContextId(opts.profile);
189
+ const internal = cmd;
173
190
  result = await browserSession(BrowserFactory, async (page) => {
191
+ const observation = traceMode === 'off'
192
+ ? null
193
+ : new ObservationSession({
194
+ scope: {
195
+ contextId,
196
+ workspace: `site:${cmd.site}`,
197
+ target: page.getActivePage?.(),
198
+ site: cmd.site,
199
+ command: fullName(cmd),
200
+ adapterSourcePath: resolveAdapterSourcePath(internal),
201
+ },
202
+ });
203
+ if (observation) {
204
+ observation.record({
205
+ stream: 'action',
206
+ name: 'command',
207
+ phase: 'start',
208
+ data: { args: kwargs },
209
+ });
210
+ await page.startNetworkCapture?.().catch(() => false);
211
+ }
174
212
  const preNavUrl = resolvePreNav(cmd);
175
213
  if (preNavUrl) {
214
+ observation?.record({
215
+ stream: 'action',
216
+ name: 'pre_navigate',
217
+ phase: 'start',
218
+ data: { url: preNavUrl },
219
+ });
176
220
  // Navigate directly — the extension's handleNavigate already has a fast-path
177
221
  // that skips navigation if the tab is already at the target URL.
178
222
  // This avoids an extra exec round-trip (getCurrentUrl) on first command and
@@ -180,9 +224,33 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
180
224
  // instead of about:blank.
181
225
  try {
182
226
  await page.goto(preNavUrl);
227
+ observation?.record({
228
+ stream: 'action',
229
+ name: 'pre_navigate',
230
+ phase: 'end',
231
+ data: { url: preNavUrl },
232
+ });
183
233
  }
184
234
  catch (err) {
185
- throw new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
235
+ observation?.record({
236
+ stream: 'action',
237
+ name: 'pre_navigate',
238
+ phase: 'error',
239
+ data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) },
240
+ });
241
+ const wrapped = new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
242
+ if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) {
243
+ observation.record({
244
+ stream: 'error',
245
+ message: wrapped.message,
246
+ stack: wrapped.stack,
247
+ code: wrapped.code,
248
+ hint: wrapped.hint,
249
+ });
250
+ await collectObservationEvidence(observation, page).catch(() => { });
251
+ exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport);
252
+ }
253
+ throw wrapped;
186
254
  }
187
255
  }
188
256
  // --live / OPENCLI_LIVE=1 keeps the automation window open after the
@@ -193,6 +261,15 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
193
261
  timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
194
262
  label: fullName(cmd),
195
263
  });
264
+ observation?.record({
265
+ stream: 'action',
266
+ name: 'command',
267
+ phase: 'end',
268
+ });
269
+ if (observation && traceMode === 'on') {
270
+ await collectObservationEvidence(observation, page).catch(() => { });
271
+ exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport);
272
+ }
196
273
  // Adapter commands are one-shot — close the automation window immediately
197
274
  // instead of waiting for the 30s idle timeout.
198
275
  if (!keepOpen)
@@ -200,12 +277,22 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
200
277
  return result;
201
278
  }
202
279
  catch (err) {
203
- // Collect diagnostic while page is still alive (before closing the window).
204
- if (isDiagnosticEnabled()) {
205
- const internal = cmd;
206
- const ctx = await collectDiagnostic(err, internal, page);
207
- emitDiagnostic(ctx);
208
- diagnosticEmitted = true;
280
+ if (observation) {
281
+ observation.record({
282
+ stream: 'action',
283
+ name: 'command',
284
+ phase: 'error',
285
+ data: { error: err instanceof Error ? err.message : String(err) },
286
+ });
287
+ observation.record({
288
+ stream: 'error',
289
+ message: err instanceof Error ? err.message : String(err),
290
+ stack: err instanceof Error ? err.stack : undefined,
291
+ });
292
+ if (traceMode === 'on' || traceMode === 'retain-on-failure') {
293
+ await collectObservationEvidence(observation, page).catch(() => { });
294
+ exportTraceArtifact(observation, 'failure', err, opts.onTraceExport);
295
+ }
209
296
  }
210
297
  // Close the automation window on failure too — without this, the window
211
298
  // lingers until the extension's idle timer fires (unreliable on Windows
@@ -214,7 +301,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
214
301
  await page.closeWindow?.().catch(() => { });
215
302
  throw err;
216
303
  }
217
- }, { workspace: `site:${cmd.site}`, cdpEndpoint });
304
+ }, { workspace: `site:${cmd.site}`, cdpEndpoint, contextId });
218
305
  }
219
306
  else {
220
307
  // Non-browser commands: apply timeout only when explicitly configured.
@@ -232,13 +319,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
232
319
  }
233
320
  }
234
321
  catch (err) {
235
- // Emit diagnostic if not already emitted (browser session emits with page state;
236
- // this fallback covers non-browser commands and pre-session failures like BrowserConnectError).
237
- if (isDiagnosticEnabled() && !diagnosticEmitted) {
238
- const internal = cmd;
239
- const ctx = await collectDiagnostic(err, internal, null);
240
- emitDiagnostic(ctx);
241
- }
242
322
  hookCtx.error = err;
243
323
  hookCtx.finishedAt = Date.now();
244
324
  await emitHook('onAfterExecute', hookCtx);
@@ -248,6 +328,74 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
248
328
  await emitHook('onAfterExecute', hookCtx, result);
249
329
  return result;
250
330
  }
331
+ async function collectObservationEvidence(session, page) {
332
+ const target = page.getActivePage?.() ?? session.scope.target;
333
+ const [url, snapshot, networkEntries, consoleMessages, screenshot] = await Promise.all([
334
+ page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
335
+ page.snapshot().catch(() => undefined),
336
+ page.readNetworkCapture?.().catch(() => []) ?? Promise.resolve([]),
337
+ page.consoleMessages('all').catch(() => []),
338
+ page.screenshot({ format: 'png' }).catch(() => undefined),
339
+ ]);
340
+ if (snapshot !== undefined || url !== undefined) {
341
+ session.record({ stream: 'state', url, target, snapshot, label: 'final' });
342
+ }
343
+ for (const entry of Array.isArray(networkEntries) ? networkEntries : []) {
344
+ const record = entry;
345
+ session.record({
346
+ stream: 'network',
347
+ url: String(record.url ?? ''),
348
+ method: typeof record.method === 'string' ? record.method : undefined,
349
+ status: typeof record.responseStatus === 'number' ? record.responseStatus : undefined,
350
+ contentType: typeof record.responseContentType === 'string' ? record.responseContentType : undefined,
351
+ size: typeof record.responseBodyFullSize === 'number' ? record.responseBodyFullSize : undefined,
352
+ requestHeaders: record.requestHeaders,
353
+ responseHeaders: record.responseHeaders,
354
+ requestBody: record.requestBodyPreview,
355
+ responseBody: record.responsePreview,
356
+ ts: typeof record.timestamp === 'number' ? record.timestamp : undefined,
357
+ });
358
+ }
359
+ for (const message of Array.isArray(consoleMessages) ? consoleMessages : []) {
360
+ if (message && typeof message === 'object') {
361
+ const record = message;
362
+ session.record({
363
+ stream: 'console',
364
+ level: String(record.type ?? record.level ?? 'log'),
365
+ text: String(record.text ?? record.message ?? ''),
366
+ ts: typeof record.timestamp === 'number' ? record.timestamp : undefined,
367
+ });
368
+ }
369
+ else {
370
+ session.record({ stream: 'console', level: 'log', text: String(message) });
371
+ }
372
+ }
373
+ if (typeof screenshot === 'string' && screenshot) {
374
+ session.record({ stream: 'screenshot', format: 'png', data: screenshot, label: 'final' });
375
+ }
376
+ }
377
+ function exportTraceArtifact(session, status, error, onTraceExport) {
378
+ try {
379
+ const trace = exportObservationSession(session, { error, status });
380
+ if (status === 'failure' && error !== undefined) {
381
+ attachTraceReceipt(error, trace.receipt);
382
+ }
383
+ else {
384
+ process.stderr.write(`OpenCLI trace artifact: ${trace.dir}\n`);
385
+ }
386
+ try {
387
+ onTraceExport?.(trace);
388
+ }
389
+ catch (err) {
390
+ log.warn(`[trace] Trace export callback failed: ${err instanceof Error ? err.message : String(err)}`);
391
+ }
392
+ return trace;
393
+ }
394
+ catch (err) {
395
+ log.warn(`[trace] Failed to export trace artifact: ${err instanceof Error ? err.message : String(err)}`);
396
+ return undefined;
397
+ }
398
+ }
251
399
  export function prepareCommandArgs(cmd, rawKwargs) {
252
400
  const kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
253
401
  cmd.validateArgs?.(kwargs);
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
2
5
  import { executeCommand, prepareCommandArgs } from './execution.js';
3
- import { TimeoutError } from './errors.js';
6
+ import { TimeoutError, toEnvelope } from './errors.js';
4
7
  import { cli, Strategy } from './registry.js';
5
8
  import { withTimeoutMs } from './runtime.js';
6
9
  import * as runtime from './runtime.js';
@@ -130,4 +133,171 @@ describe('executeCommand — non-browser timeout', () => {
130
133
  await executeCommand(cmd, kwargs, false, { prepared: true });
131
134
  expect(validateArgs).toHaveBeenCalledTimes(1);
132
135
  });
136
+ it('exports a profile-scoped trace artifact on browser command failure when requested', async () => {
137
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-exec-trace-'));
138
+ const prevConfigDir = process.env.OPENCLI_CONFIG_DIR;
139
+ process.env.OPENCLI_CONFIG_DIR = baseDir;
140
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
141
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
142
+ const mockPage = {
143
+ closeWindow,
144
+ startNetworkCapture: vi.fn().mockResolvedValue(true),
145
+ readNetworkCapture: vi.fn().mockResolvedValue([
146
+ {
147
+ url: 'https://api.example.com/data?token=secret',
148
+ method: 'GET',
149
+ responseStatus: 500,
150
+ responseContentType: 'application/json',
151
+ responsePreview: JSON.stringify({ password: 'secret', ok: false }),
152
+ requestHeaders: { authorization: 'Bearer secret' },
153
+ timestamp: Date.now(),
154
+ },
155
+ ]),
156
+ consoleMessages: vi.fn().mockResolvedValue([{ type: 'error', text: 'boom password=secret', timestamp: Date.now() }]),
157
+ snapshot: vi.fn().mockResolvedValue({ html: '<input type="password" value="secret">' }),
158
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('png').toString('base64')),
159
+ getCurrentUrl: vi.fn().mockResolvedValue('https://api.example.com/app'),
160
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
161
+ };
162
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
163
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
164
+ try {
165
+ const cmd = cli({
166
+ site: 'test-execution',
167
+ name: 'browser-trace-failure',
168
+ description: 'test trace export',
169
+ browser: true,
170
+ strategy: Strategy.PUBLIC,
171
+ func: async () => { throw new Error('adapter failure'); },
172
+ });
173
+ const thrown = await executeCommand(cmd, {}, false, { trace: 'retain-on-failure' }).catch((err) => err);
174
+ expect(thrown).toBeInstanceOf(Error);
175
+ expect(thrown.message).toContain('adapter failure');
176
+ const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
177
+ const traceId = fs.readdirSync(tracesRoot)[0];
178
+ const traceDir = path.join(tracesRoot, traceId);
179
+ expect(fs.existsSync(path.join(traceDir, 'trace.jsonl'))).toBe(true);
180
+ expect(fs.existsSync(path.join(traceDir, 'receipt.json'))).toBe(true);
181
+ const trace = fs.readFileSync(path.join(traceDir, 'trace.jsonl'), 'utf-8');
182
+ expect(trace).toContain('token=[REDACTED]');
183
+ expect(trace).toContain('"authorization":"[REDACTED]"');
184
+ expect(trace).not.toContain('password=secret');
185
+ expect(stderrSpy.mock.calls.flat().join('\n')).not.toContain('___OPENCLI_TRACE___');
186
+ expect(toEnvelope(thrown).trace).toMatchObject({
187
+ traceId,
188
+ dir: traceDir,
189
+ summaryPath: path.join(traceDir, 'summary.md'),
190
+ receiptPath: path.join(traceDir, 'receipt.json'),
191
+ status: 'failure',
192
+ });
193
+ expect(closeWindow).toHaveBeenCalledTimes(1);
194
+ }
195
+ finally {
196
+ if (prevConfigDir === undefined)
197
+ delete process.env.OPENCLI_CONFIG_DIR;
198
+ else
199
+ process.env.OPENCLI_CONFIG_DIR = prevConfigDir;
200
+ stderrSpy.mockRestore();
201
+ fs.rmSync(baseDir, { recursive: true, force: true });
202
+ vi.restoreAllMocks();
203
+ }
204
+ });
205
+ it('exports a trace receipt on browser command success when trace is on', async () => {
206
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-exec-trace-success-'));
207
+ const prevConfigDir = process.env.OPENCLI_CONFIG_DIR;
208
+ process.env.OPENCLI_CONFIG_DIR = baseDir;
209
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
210
+ const onTraceExport = vi.fn();
211
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
212
+ const mockPage = {
213
+ closeWindow,
214
+ startNetworkCapture: vi.fn().mockResolvedValue(true),
215
+ readNetworkCapture: vi.fn().mockResolvedValue([]),
216
+ consoleMessages: vi.fn().mockResolvedValue([]),
217
+ snapshot: vi.fn().mockResolvedValue('snapshot'),
218
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('png').toString('base64')),
219
+ getCurrentUrl: vi.fn().mockResolvedValue('https://example.com'),
220
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
221
+ };
222
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
223
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
224
+ try {
225
+ const cmd = cli({
226
+ site: 'test-execution',
227
+ name: 'browser-trace-success',
228
+ description: 'test trace export on success',
229
+ browser: true,
230
+ strategy: Strategy.PUBLIC,
231
+ func: async () => [{ ok: true }],
232
+ });
233
+ await expect(executeCommand(cmd, {}, false, { trace: 'on', onTraceExport })).resolves.toEqual([{ ok: true }]);
234
+ const stderr = stderrSpy.mock.calls.flat().join('\n');
235
+ expect(stderr).toContain('OpenCLI trace artifact:');
236
+ const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
237
+ const traceId = fs.readdirSync(tracesRoot)[0];
238
+ const receipt = JSON.parse(fs.readFileSync(path.join(tracesRoot, traceId, 'receipt.json'), 'utf-8'));
239
+ expect(receipt.status).toBe('success');
240
+ expect(receipt.traceDir).toContain(path.join(baseDir, 'profiles', 'default', 'traces'));
241
+ expect(receipt.scope).toMatchObject({
242
+ site: 'test-execution',
243
+ command: 'test-execution/browser-trace-success',
244
+ });
245
+ expect(receipt.error).toBeUndefined();
246
+ expect(onTraceExport).toHaveBeenCalledWith(expect.objectContaining({
247
+ traceId,
248
+ receipt: expect.objectContaining({ status: 'success' }),
249
+ }));
250
+ expect(closeWindow).toHaveBeenCalledTimes(1);
251
+ }
252
+ finally {
253
+ if (prevConfigDir === undefined)
254
+ delete process.env.OPENCLI_CONFIG_DIR;
255
+ else
256
+ process.env.OPENCLI_CONFIG_DIR = prevConfigDir;
257
+ stderrSpy.mockRestore();
258
+ fs.rmSync(baseDir, { recursive: true, force: true });
259
+ vi.restoreAllMocks();
260
+ }
261
+ });
262
+ it('keeps the original adapter error when trace export fails', async () => {
263
+ const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-exec-trace-fail-'));
264
+ const blockedPath = path.join(baseDir, 'not-a-dir');
265
+ fs.writeFileSync(blockedPath, 'file');
266
+ const prevConfigDir = process.env.OPENCLI_CONFIG_DIR;
267
+ process.env.OPENCLI_CONFIG_DIR = blockedPath;
268
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
269
+ const mockPage = {
270
+ closeWindow: vi.fn().mockResolvedValue(undefined),
271
+ startNetworkCapture: vi.fn().mockResolvedValue(true),
272
+ readNetworkCapture: vi.fn().mockResolvedValue([]),
273
+ consoleMessages: vi.fn().mockResolvedValue([]),
274
+ snapshot: vi.fn().mockResolvedValue('snapshot'),
275
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('png').toString('base64')),
276
+ getCurrentUrl: vi.fn().mockResolvedValue('https://example.com'),
277
+ getActivePage: vi.fn().mockReturnValue('tab-1'),
278
+ };
279
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
280
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
281
+ try {
282
+ const cmd = cli({
283
+ site: 'test-execution',
284
+ name: 'browser-trace-export-fails',
285
+ description: 'test trace export failure handling',
286
+ browser: true,
287
+ strategy: Strategy.PUBLIC,
288
+ func: async () => { throw new Error('adapter failure'); },
289
+ });
290
+ await expect(executeCommand(cmd, {}, false, { trace: 'retain-on-failure' })).rejects.toThrow('adapter failure');
291
+ expect(stderrSpy.mock.calls.flat().join('\n')).toContain('[trace] Failed to export trace artifact');
292
+ }
293
+ finally {
294
+ if (prevConfigDir === undefined)
295
+ delete process.env.OPENCLI_CONFIG_DIR;
296
+ else
297
+ process.env.OPENCLI_CONFIG_DIR = prevConfigDir;
298
+ stderrSpy.mockRestore();
299
+ fs.rmSync(baseDir, { recursive: true, force: true });
300
+ vi.restoreAllMocks();
301
+ }
302
+ });
133
303
  });
package/dist/src/main.js CHANGED
@@ -20,6 +20,7 @@ import { getCompletionsFromManifest, hasAllManifests, printCompletionScriptFast
20
20
  import { findPackageRoot, getCliManifestPath } from './package-paths.js';
21
21
  import { PKG_VERSION } from './version.js';
22
22
  import { EXIT_CODES } from './errors.js';
23
+ import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_MAJOR } from './runtime-detect.js';
23
24
  const __filename = fileURLToPath(import.meta.url);
24
25
  const __dirname = path.dirname(__filename);
25
26
  // Adapters are JS-first and live at <package-root>/clis/.
@@ -45,6 +46,15 @@ const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
45
46
  // ── Ultra-fast path: lightweight commands bypass full discovery ──────────
46
47
  // These are high-frequency or trivial paths that must not pay the startup tax.
47
48
  const argv = process.argv.slice(2);
49
+ if (typeof globalThis.Bun === 'undefined' && !isSupportedNodeVersion(process.version)) {
50
+ process.stderr.write([
51
+ `OpenCLI requires Node.js >= ${MIN_SUPPORTED_NODE_MAJOR}.0.0.`,
52
+ `Current runtime: ${process.version}`,
53
+ 'Upgrade Node.js, then retry the same command.',
54
+ '',
55
+ ].join('\n'));
56
+ process.exit(EXIT_CODES.CONFIG_ERROR);
57
+ }
48
58
  // Fast path: --version (only when it's the top-level intent, not passed to a subcommand)
49
59
  // e.g. `opencli --version` or `opencli -V`, but NOT `opencli gh --version`
50
60
  if (argv[0] === '--version' || argv[0] === '-V') {
@@ -0,0 +1,16 @@
1
+ import type { ObservationExportResult, ObservationExportStatus, ObservationTraceReceipt } from './events.js';
2
+ import { ObservationSession } from './session.js';
3
+ import { type TraceRetentionPolicyInput } from './retention.js';
4
+ export interface ExportObservationOptions {
5
+ baseDir?: string;
6
+ error?: unknown;
7
+ status?: ObservationExportStatus;
8
+ retentionPolicy?: TraceRetentionPolicyInput;
9
+ }
10
+ export declare function getTraceDirectory(contextId: string | undefined, traceId: string, baseDir?: string): string;
11
+ export declare function exportObservationSession(session: ObservationSession, opts?: ExportObservationOptions): ObservationExportResult;
12
+ export declare function buildTraceReceipt(result: Pick<ObservationExportResult, 'traceId' | 'dir' | 'summaryPath' | 'receiptPath'>, status: ObservationExportStatus, error?: unknown, opts?: {
13
+ createdAt?: string;
14
+ scope?: ObservationSession['scope'];
15
+ retentionPolicy?: TraceRetentionPolicyInput;
16
+ }): ObservationTraceReceipt;