tabwright 1.0.0

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 (599) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +4 -0
  3. package/dist/a11y-client.js +204 -0
  4. package/dist/agent-skills/tabwright/SKILL.md +119 -0
  5. package/dist/aria-snapshot.d.ts +262 -0
  6. package/dist/aria-snapshot.d.ts.map +1 -0
  7. package/dist/aria-snapshot.js +1256 -0
  8. package/dist/aria-snapshot.js.map +1 -0
  9. package/dist/aria-snapshot.test.d.ts +2 -0
  10. package/dist/aria-snapshot.test.d.ts.map +1 -0
  11. package/dist/aria-snapshot.test.js +270 -0
  12. package/dist/aria-snapshot.test.js.map +1 -0
  13. package/dist/aria-snapshot.unit.test.d.ts +2 -0
  14. package/dist/aria-snapshot.unit.test.d.ts.map +1 -0
  15. package/dist/aria-snapshot.unit.test.js +542 -0
  16. package/dist/aria-snapshot.unit.test.js.map +1 -0
  17. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  18. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  19. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  20. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  21. package/dist/bippy.js +894 -0
  22. package/dist/browser-config.d.ts +13 -0
  23. package/dist/browser-config.d.ts.map +1 -0
  24. package/dist/browser-config.js +126 -0
  25. package/dist/browser-config.js.map +1 -0
  26. package/dist/browser-install.d.ts +16 -0
  27. package/dist/browser-install.d.ts.map +1 -0
  28. package/dist/browser-install.js +238 -0
  29. package/dist/browser-install.js.map +1 -0
  30. package/dist/browser-launch.d.ts +17 -0
  31. package/dist/browser-launch.d.ts.map +1 -0
  32. package/dist/browser-launch.js +44 -0
  33. package/dist/browser-launch.js.map +1 -0
  34. package/dist/builtin-capabilities.d.ts +28 -0
  35. package/dist/builtin-capabilities.d.ts.map +1 -0
  36. package/dist/builtin-capabilities.js +2673 -0
  37. package/dist/builtin-capabilities.js.map +1 -0
  38. package/dist/capability-agent-skill.d.ts +60 -0
  39. package/dist/capability-agent-skill.d.ts.map +1 -0
  40. package/dist/capability-agent-skill.js +225 -0
  41. package/dist/capability-agent-skill.js.map +1 -0
  42. package/dist/capability-auth-state.d.ts +34 -0
  43. package/dist/capability-auth-state.d.ts.map +1 -0
  44. package/dist/capability-auth-state.js +157 -0
  45. package/dist/capability-auth-state.js.map +1 -0
  46. package/dist/capability-auth.d.ts +36 -0
  47. package/dist/capability-auth.d.ts.map +1 -0
  48. package/dist/capability-auth.js +198 -0
  49. package/dist/capability-auth.js.map +1 -0
  50. package/dist/capability-cli-confirmation.test.d.ts +2 -0
  51. package/dist/capability-cli-confirmation.test.d.ts.map +1 -0
  52. package/dist/capability-cli-confirmation.test.js +79 -0
  53. package/dist/capability-cli-confirmation.test.js.map +1 -0
  54. package/dist/capability-options.d.ts +20 -0
  55. package/dist/capability-options.d.ts.map +1 -0
  56. package/dist/capability-options.js +31 -0
  57. package/dist/capability-options.js.map +1 -0
  58. package/dist/capability-options.test.d.ts +2 -0
  59. package/dist/capability-options.test.d.ts.map +1 -0
  60. package/dist/capability-options.test.js +81 -0
  61. package/dist/capability-options.test.js.map +1 -0
  62. package/dist/capability-package.d.ts +27 -0
  63. package/dist/capability-package.d.ts.map +1 -0
  64. package/dist/capability-package.js +444 -0
  65. package/dist/capability-package.js.map +1 -0
  66. package/dist/capability-package.test.d.ts +2 -0
  67. package/dist/capability-package.test.d.ts.map +1 -0
  68. package/dist/capability-package.test.js +179 -0
  69. package/dist/capability-package.test.js.map +1 -0
  70. package/dist/capability-registry.d.ts +262 -0
  71. package/dist/capability-registry.d.ts.map +1 -0
  72. package/dist/capability-registry.js +931 -0
  73. package/dist/capability-registry.js.map +1 -0
  74. package/dist/capability-registry.test.d.ts +2 -0
  75. package/dist/capability-registry.test.d.ts.map +1 -0
  76. package/dist/capability-registry.test.js +1683 -0
  77. package/dist/capability-registry.test.js.map +1 -0
  78. package/dist/capability-runner.d.ts +99 -0
  79. package/dist/capability-runner.d.ts.map +1 -0
  80. package/dist/capability-runner.js +608 -0
  81. package/dist/capability-runner.js.map +1 -0
  82. package/dist/capability-search-relevance.test.d.ts +2 -0
  83. package/dist/capability-search-relevance.test.d.ts.map +1 -0
  84. package/dist/capability-search-relevance.test.js +67 -0
  85. package/dist/capability-search-relevance.test.js.map +1 -0
  86. package/dist/capability-studio.d.ts +11 -0
  87. package/dist/capability-studio.d.ts.map +1 -0
  88. package/dist/capability-studio.js +593 -0
  89. package/dist/capability-studio.js.map +1 -0
  90. package/dist/capability-studio.test.d.ts +2 -0
  91. package/dist/capability-studio.test.d.ts.map +1 -0
  92. package/dist/capability-studio.test.js +41 -0
  93. package/dist/capability-studio.test.js.map +1 -0
  94. package/dist/cdp-log.d.ts +19 -0
  95. package/dist/cdp-log.d.ts.map +1 -0
  96. package/dist/cdp-log.js +83 -0
  97. package/dist/cdp-log.js.map +1 -0
  98. package/dist/cdp-log.test.d.ts +2 -0
  99. package/dist/cdp-log.test.d.ts.map +1 -0
  100. package/dist/cdp-log.test.js +109 -0
  101. package/dist/cdp-log.test.js.map +1 -0
  102. package/dist/cdp-relay.d.ts +19 -0
  103. package/dist/cdp-relay.d.ts.map +1 -0
  104. package/dist/cdp-relay.js +2375 -0
  105. package/dist/cdp-relay.js.map +1 -0
  106. package/dist/cdp-session.d.ts +37 -0
  107. package/dist/cdp-session.d.ts.map +1 -0
  108. package/dist/cdp-session.js +36 -0
  109. package/dist/cdp-session.js.map +1 -0
  110. package/dist/cdp-types.d.ts +65 -0
  111. package/dist/cdp-types.d.ts.map +1 -0
  112. package/dist/cdp-types.js +91 -0
  113. package/dist/cdp-types.js.map +1 -0
  114. package/dist/channel-owner-inspect.test.d.ts +2 -0
  115. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  116. package/dist/channel-owner-inspect.test.js +75 -0
  117. package/dist/channel-owner-inspect.test.js.map +1 -0
  118. package/dist/chrome-discovery.d.ts +65 -0
  119. package/dist/chrome-discovery.d.ts.map +1 -0
  120. package/dist/chrome-discovery.js +173 -0
  121. package/dist/chrome-discovery.js.map +1 -0
  122. package/dist/chrome-discovery.test.d.ts +2 -0
  123. package/dist/chrome-discovery.test.d.ts.map +1 -0
  124. package/dist/chrome-discovery.test.js +41 -0
  125. package/dist/chrome-discovery.test.js.map +1 -0
  126. package/dist/clean-html.d.ts +11 -0
  127. package/dist/clean-html.d.ts.map +1 -0
  128. package/dist/clean-html.js +106 -0
  129. package/dist/clean-html.js.map +1 -0
  130. package/dist/cli-help.test.d.ts +2 -0
  131. package/dist/cli-help.test.d.ts.map +1 -0
  132. package/dist/cli-help.test.js +98 -0
  133. package/dist/cli-help.test.js.map +1 -0
  134. package/dist/cli.d.ts +3 -0
  135. package/dist/cli.d.ts.map +1 -0
  136. package/dist/cli.js +2709 -0
  137. package/dist/cli.js.map +1 -0
  138. package/dist/cloud-client.d.ts +56 -0
  139. package/dist/cloud-client.d.ts.map +1 -0
  140. package/dist/cloud-client.js +127 -0
  141. package/dist/cloud-client.js.map +1 -0
  142. package/dist/create-logger.d.ts +9 -0
  143. package/dist/create-logger.d.ts.map +1 -0
  144. package/dist/create-logger.js +27 -0
  145. package/dist/create-logger.js.map +1 -0
  146. package/dist/debugger-api.md +458 -0
  147. package/dist/debugger-examples-types.d.ts +24 -0
  148. package/dist/debugger-examples-types.d.ts.map +1 -0
  149. package/dist/debugger-examples-types.js +2 -0
  150. package/dist/debugger-examples-types.js.map +1 -0
  151. package/dist/debugger-examples.d.ts +6 -0
  152. package/dist/debugger-examples.d.ts.map +1 -0
  153. package/dist/debugger-examples.js +53 -0
  154. package/dist/debugger-examples.js.map +1 -0
  155. package/dist/debugger.d.ts +381 -0
  156. package/dist/debugger.d.ts.map +1 -0
  157. package/dist/debugger.js +630 -0
  158. package/dist/debugger.js.map +1 -0
  159. package/dist/diff-utils.d.ts +20 -0
  160. package/dist/diff-utils.d.ts.map +1 -0
  161. package/dist/diff-utils.js +50 -0
  162. package/dist/diff-utils.js.map +1 -0
  163. package/dist/diff-utils.test.d.ts +2 -0
  164. package/dist/diff-utils.test.d.ts.map +1 -0
  165. package/dist/diff-utils.test.js +137 -0
  166. package/dist/diff-utils.test.js.map +1 -0
  167. package/dist/doctor.d.ts +44 -0
  168. package/dist/doctor.d.ts.map +1 -0
  169. package/dist/doctor.js +246 -0
  170. package/dist/doctor.js.map +1 -0
  171. package/dist/doctor.test.d.ts +2 -0
  172. package/dist/doctor.test.d.ts.map +1 -0
  173. package/dist/doctor.test.js +208 -0
  174. package/dist/doctor.test.js.map +1 -0
  175. package/dist/editor-api.md +374 -0
  176. package/dist/editor-examples.d.ts +11 -0
  177. package/dist/editor-examples.d.ts.map +1 -0
  178. package/dist/editor-examples.js +124 -0
  179. package/dist/editor-examples.js.map +1 -0
  180. package/dist/editor.d.ts +203 -0
  181. package/dist/editor.d.ts.map +1 -0
  182. package/dist/editor.js +363 -0
  183. package/dist/editor.js.map +1 -0
  184. package/dist/env-compat.d.ts +2 -0
  185. package/dist/env-compat.d.ts.map +1 -0
  186. package/dist/env-compat.js +27 -0
  187. package/dist/env-compat.js.map +1 -0
  188. package/dist/executor.d.ts +229 -0
  189. package/dist/executor.d.ts.map +1 -0
  190. package/dist/executor.js +1465 -0
  191. package/dist/executor.js.map +1 -0
  192. package/dist/executor.unit.test.d.ts +2 -0
  193. package/dist/executor.unit.test.d.ts.map +1 -0
  194. package/dist/executor.unit.test.js +143 -0
  195. package/dist/executor.unit.test.js.map +1 -0
  196. package/dist/extension/_locales/en/messages.json +278 -0
  197. package/dist/extension/_locales/zh_CN/messages.json +256 -0
  198. package/dist/extension/assets/options-CYH96Lcx.css +1349 -0
  199. package/dist/extension/background.js +3994 -0
  200. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  201. package/dist/extension/icons/icon-black-128.png +0 -0
  202. package/dist/extension/icons/icon-black-16.png +0 -0
  203. package/dist/extension/icons/icon-black-32.png +0 -0
  204. package/dist/extension/icons/icon-black-48.png +0 -0
  205. package/dist/extension/icons/icon-gray-128.png +0 -0
  206. package/dist/extension/icons/icon-gray-16.png +0 -0
  207. package/dist/extension/icons/icon-gray-32.png +0 -0
  208. package/dist/extension/icons/icon-gray-48.png +0 -0
  209. package/dist/extension/icons/icon-green-128.png +0 -0
  210. package/dist/extension/icons/icon-green-16.png +0 -0
  211. package/dist/extension/icons/icon-green-32.png +0 -0
  212. package/dist/extension/icons/icon-green-48.png +0 -0
  213. package/dist/extension/icons/tabwright-icon-black.png +0 -0
  214. package/dist/extension/icons/tabwright-icon-gray-disabled.png +0 -0
  215. package/dist/extension/icons/tabwright-icon-gray-disabled.svg +1 -0
  216. package/dist/extension/manifest.json +54 -0
  217. package/dist/extension/options.js +16145 -0
  218. package/dist/extension/rrweb-recorder.js +12952 -0
  219. package/dist/extension/src/options.html +165 -0
  220. package/dist/extension/src/prism-bash.min.js +1 -0
  221. package/dist/extension/src/prism.min.js +1 -0
  222. package/dist/extension/src/welcome.html +396 -0
  223. package/dist/extension-connection.test.d.ts +2 -0
  224. package/dist/extension-connection.test.d.ts.map +1 -0
  225. package/dist/extension-connection.test.js +708 -0
  226. package/dist/extension-connection.test.js.map +1 -0
  227. package/dist/extension-icon-warning.test.d.ts +2 -0
  228. package/dist/extension-icon-warning.test.d.ts.map +1 -0
  229. package/dist/extension-icon-warning.test.js +33 -0
  230. package/dist/extension-icon-warning.test.js.map +1 -0
  231. package/dist/ghost-browser.d.ts +62 -0
  232. package/dist/ghost-browser.d.ts.map +1 -0
  233. package/dist/ghost-browser.js +107 -0
  234. package/dist/ghost-browser.js.map +1 -0
  235. package/dist/ghost-cursor-client.js +374 -0
  236. package/dist/ghost-cursor-controller.d.ts +46 -0
  237. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  238. package/dist/ghost-cursor-controller.js +98 -0
  239. package/dist/ghost-cursor-controller.js.map +1 -0
  240. package/dist/ghost-cursor.d.ts +27 -0
  241. package/dist/ghost-cursor.d.ts.map +1 -0
  242. package/dist/ghost-cursor.js +79 -0
  243. package/dist/ghost-cursor.js.map +1 -0
  244. package/dist/htmlrewrite.d.ts +8 -0
  245. package/dist/htmlrewrite.d.ts.map +1 -0
  246. package/dist/htmlrewrite.js +339 -0
  247. package/dist/htmlrewrite.js.map +1 -0
  248. package/dist/htmlrewrite.test.d.ts +2 -0
  249. package/dist/htmlrewrite.test.d.ts.map +1 -0
  250. package/dist/htmlrewrite.test.js +7975 -0
  251. package/dist/htmlrewrite.test.js.map +1 -0
  252. package/dist/index.d.ts +20 -0
  253. package/dist/index.d.ts.map +1 -0
  254. package/dist/index.js +11 -0
  255. package/dist/index.js.map +1 -0
  256. package/dist/kill-port.d.ts +29 -0
  257. package/dist/kill-port.d.ts.map +1 -0
  258. package/dist/kill-port.js +190 -0
  259. package/dist/kill-port.js.map +1 -0
  260. package/dist/kitty-graphics.d.ts +22 -0
  261. package/dist/kitty-graphics.d.ts.map +1 -0
  262. package/dist/kitty-graphics.js +71 -0
  263. package/dist/kitty-graphics.js.map +1 -0
  264. package/dist/kitty-graphics.test.d.ts +2 -0
  265. package/dist/kitty-graphics.test.d.ts.map +1 -0
  266. package/dist/kitty-graphics.test.js +125 -0
  267. package/dist/kitty-graphics.test.js.map +1 -0
  268. package/dist/locator-selector.test.d.ts +2 -0
  269. package/dist/locator-selector.test.d.ts.map +1 -0
  270. package/dist/locator-selector.test.js +98 -0
  271. package/dist/locator-selector.test.js.map +1 -0
  272. package/dist/mcp-client.d.ts +20 -0
  273. package/dist/mcp-client.d.ts.map +1 -0
  274. package/dist/mcp-client.js +56 -0
  275. package/dist/mcp-client.js.map +1 -0
  276. package/dist/mcp.d.ts +6 -0
  277. package/dist/mcp.d.ts.map +1 -0
  278. package/dist/mcp.js +511 -0
  279. package/dist/mcp.js.map +1 -0
  280. package/dist/on-mouse-action.test.d.ts +2 -0
  281. package/dist/on-mouse-action.test.d.ts.map +1 -0
  282. package/dist/on-mouse-action.test.js +180 -0
  283. package/dist/on-mouse-action.test.js.map +1 -0
  284. package/dist/options-page.test.d.ts +2 -0
  285. package/dist/options-page.test.d.ts.map +1 -0
  286. package/dist/options-page.test.js +773 -0
  287. package/dist/options-page.test.js.map +1 -0
  288. package/dist/package-paths.d.ts +4 -0
  289. package/dist/package-paths.d.ts.map +1 -0
  290. package/dist/package-paths.js +30 -0
  291. package/dist/package-paths.js.map +1 -0
  292. package/dist/page-markdown.d.ts +40 -0
  293. package/dist/page-markdown.d.ts.map +1 -0
  294. package/dist/page-markdown.js +181 -0
  295. package/dist/page-markdown.js.map +1 -0
  296. package/dist/performance-examples.d.ts +5 -0
  297. package/dist/performance-examples.d.ts.map +1 -0
  298. package/dist/performance-examples.js +112 -0
  299. package/dist/performance-examples.js.map +1 -0
  300. package/dist/performance-profiling.md +417 -0
  301. package/dist/playwright-import.d.ts +19 -0
  302. package/dist/playwright-import.d.ts.map +1 -0
  303. package/dist/playwright-import.js +40 -0
  304. package/dist/playwright-import.js.map +1 -0
  305. package/dist/popup-relocation.test.d.ts +7 -0
  306. package/dist/popup-relocation.test.d.ts.map +1 -0
  307. package/dist/popup-relocation.test.js +139 -0
  308. package/dist/popup-relocation.test.js.map +1 -0
  309. package/dist/product-paths.d.ts +9 -0
  310. package/dist/product-paths.d.ts.map +1 -0
  311. package/dist/product-paths.js +24 -0
  312. package/dist/product-paths.js.map +1 -0
  313. package/dist/product-paths.test.d.ts +2 -0
  314. package/dist/product-paths.test.d.ts.map +1 -0
  315. package/dist/product-paths.test.js +39 -0
  316. package/dist/product-paths.test.js.map +1 -0
  317. package/dist/prompt.md +1004 -0
  318. package/dist/protocol.d.ts +248 -0
  319. package/dist/protocol.d.ts.map +1 -0
  320. package/dist/protocol.js +59 -0
  321. package/dist/protocol.js.map +1 -0
  322. package/dist/protocol.test.d.ts +2 -0
  323. package/dist/protocol.test.d.ts.map +1 -0
  324. package/dist/protocol.test.js +31 -0
  325. package/dist/protocol.test.js.map +1 -0
  326. package/dist/react-source.d.ts +57 -0
  327. package/dist/react-source.d.ts.map +1 -0
  328. package/dist/react-source.js +254 -0
  329. package/dist/react-source.js.map +1 -0
  330. package/dist/readability.js +1674 -0
  331. package/dist/relay-client.d.ts +104 -0
  332. package/dist/relay-client.d.ts.map +1 -0
  333. package/dist/relay-client.js +426 -0
  334. package/dist/relay-client.js.map +1 -0
  335. package/dist/relay-client.test.d.ts +2 -0
  336. package/dist/relay-client.test.d.ts.map +1 -0
  337. package/dist/relay-client.test.js +305 -0
  338. package/dist/relay-client.test.js.map +1 -0
  339. package/dist/relay-core.test.d.ts +2 -0
  340. package/dist/relay-core.test.d.ts.map +1 -0
  341. package/dist/relay-core.test.js +1427 -0
  342. package/dist/relay-core.test.js.map +1 -0
  343. package/dist/relay-navigation.test.d.ts +2 -0
  344. package/dist/relay-navigation.test.d.ts.map +1 -0
  345. package/dist/relay-navigation.test.js +661 -0
  346. package/dist/relay-navigation.test.js.map +1 -0
  347. package/dist/relay-session.test.d.ts +2 -0
  348. package/dist/relay-session.test.d.ts.map +1 -0
  349. package/dist/relay-session.test.js +1121 -0
  350. package/dist/relay-session.test.js.map +1 -0
  351. package/dist/relay-state.d.ts +178 -0
  352. package/dist/relay-state.d.ts.map +1 -0
  353. package/dist/relay-state.js +346 -0
  354. package/dist/relay-state.js.map +1 -0
  355. package/dist/relay-state.test.d.ts +2 -0
  356. package/dist/relay-state.test.d.ts.map +1 -0
  357. package/dist/relay-state.test.js +594 -0
  358. package/dist/relay-state.test.js.map +1 -0
  359. package/dist/replay-ai-index.d.ts +99 -0
  360. package/dist/replay-ai-index.d.ts.map +1 -0
  361. package/dist/replay-ai-index.js +677 -0
  362. package/dist/replay-ai-index.js.map +1 -0
  363. package/dist/replay-ai-index.test.d.ts +2 -0
  364. package/dist/replay-ai-index.test.d.ts.map +1 -0
  365. package/dist/replay-ai-index.test.js +255 -0
  366. package/dist/replay-ai-index.test.js.map +1 -0
  367. package/dist/replay-cli.test.d.ts +2 -0
  368. package/dist/replay-cli.test.d.ts.map +1 -0
  369. package/dist/replay-cli.test.js +275 -0
  370. package/dist/replay-cli.test.js.map +1 -0
  371. package/dist/replay-eval.d.ts +44 -0
  372. package/dist/replay-eval.d.ts.map +1 -0
  373. package/dist/replay-eval.js +902 -0
  374. package/dist/replay-eval.js.map +1 -0
  375. package/dist/replay-handoff.d.ts +29 -0
  376. package/dist/replay-handoff.d.ts.map +1 -0
  377. package/dist/replay-handoff.js +83 -0
  378. package/dist/replay-handoff.js.map +1 -0
  379. package/dist/replay-handoff.test.d.ts +2 -0
  380. package/dist/replay-handoff.test.d.ts.map +1 -0
  381. package/dist/replay-handoff.test.js +123 -0
  382. package/dist/replay-handoff.test.js.map +1 -0
  383. package/dist/replay-workflow-compiler.d.ts +44 -0
  384. package/dist/replay-workflow-compiler.d.ts.map +1 -0
  385. package/dist/replay-workflow-compiler.js +601 -0
  386. package/dist/replay-workflow-compiler.js.map +1 -0
  387. package/dist/replay-workflow-compiler.test.d.ts +2 -0
  388. package/dist/replay-workflow-compiler.test.d.ts.map +1 -0
  389. package/dist/replay-workflow-compiler.test.js +223 -0
  390. package/dist/replay-workflow-compiler.test.js.map +1 -0
  391. package/dist/rrweb-recording-relay.d.ts +56 -0
  392. package/dist/rrweb-recording-relay.d.ts.map +1 -0
  393. package/dist/rrweb-recording-relay.js +299 -0
  394. package/dist/rrweb-recording-relay.js.map +1 -0
  395. package/dist/rrweb-recording-relay.test.d.ts +2 -0
  396. package/dist/rrweb-recording-relay.test.d.ts.map +1 -0
  397. package/dist/rrweb-recording-relay.test.js +85 -0
  398. package/dist/rrweb-recording-relay.test.js.map +1 -0
  399. package/dist/rrweb-recording.d.ts +104 -0
  400. package/dist/rrweb-recording.d.ts.map +1 -0
  401. package/dist/rrweb-recording.js +180 -0
  402. package/dist/rrweb-recording.js.map +1 -0
  403. package/dist/scoped-fs.d.ts +95 -0
  404. package/dist/scoped-fs.d.ts.map +1 -0
  405. package/dist/scoped-fs.js +358 -0
  406. package/dist/scoped-fs.js.map +1 -0
  407. package/dist/scoped-fs.test.d.ts +5 -0
  408. package/dist/scoped-fs.test.d.ts.map +1 -0
  409. package/dist/scoped-fs.test.js +73 -0
  410. package/dist/scoped-fs.test.js.map +1 -0
  411. package/dist/selector-generator.js +7378 -0
  412. package/dist/skill-stub.test.d.ts +2 -0
  413. package/dist/skill-stub.test.d.ts.map +1 -0
  414. package/dist/skill-stub.test.js +22 -0
  415. package/dist/skill-stub.test.js.map +1 -0
  416. package/dist/snapshot-tools.test.d.ts +2 -0
  417. package/dist/snapshot-tools.test.d.ts.map +1 -0
  418. package/dist/snapshot-tools.test.js +856 -0
  419. package/dist/snapshot-tools.test.js.map +1 -0
  420. package/dist/start-relay-server.d.ts +6 -0
  421. package/dist/start-relay-server.d.ts.map +1 -0
  422. package/dist/start-relay-server.js +57 -0
  423. package/dist/start-relay-server.js.map +1 -0
  424. package/dist/styles-api.md +124 -0
  425. package/dist/styles-examples.d.ts +8 -0
  426. package/dist/styles-examples.d.ts.map +1 -0
  427. package/dist/styles-examples.js +64 -0
  428. package/dist/styles-examples.js.map +1 -0
  429. package/dist/styles.d.ts +27 -0
  430. package/dist/styles.d.ts.map +1 -0
  431. package/dist/styles.js +231 -0
  432. package/dist/styles.js.map +1 -0
  433. package/dist/tabwright-agent-skill.d.ts +30 -0
  434. package/dist/tabwright-agent-skill.d.ts.map +1 -0
  435. package/dist/tabwright-agent-skill.js +74 -0
  436. package/dist/tabwright-agent-skill.js.map +1 -0
  437. package/dist/tabwright-agent-skill.test.d.ts +2 -0
  438. package/dist/tabwright-agent-skill.test.d.ts.map +1 -0
  439. package/dist/tabwright-agent-skill.test.js +51 -0
  440. package/dist/tabwright-agent-skill.test.js.map +1 -0
  441. package/dist/test-declarations.d.ts +13 -0
  442. package/dist/test-declarations.d.ts.map +1 -0
  443. package/dist/test-declarations.js +2 -0
  444. package/dist/test-declarations.js.map +1 -0
  445. package/dist/test-utils.d.ts +69 -0
  446. package/dist/test-utils.d.ts.map +1 -0
  447. package/dist/test-utils.js +448 -0
  448. package/dist/test-utils.js.map +1 -0
  449. package/dist/test-utils.test.d.ts +2 -0
  450. package/dist/test-utils.test.d.ts.map +1 -0
  451. package/dist/test-utils.test.js +55 -0
  452. package/dist/test-utils.test.js.map +1 -0
  453. package/dist/utils.d.ts +26 -0
  454. package/dist/utils.d.ts.map +1 -0
  455. package/dist/utils.js +60 -0
  456. package/dist/utils.js.map +1 -0
  457. package/dist/wait-for-page-load.d.ts +16 -0
  458. package/dist/wait-for-page-load.d.ts.map +1 -0
  459. package/dist/wait-for-page-load.js +146 -0
  460. package/dist/wait-for-page-load.js.map +1 -0
  461. package/dist/workflow-capability.d.ts +72 -0
  462. package/dist/workflow-capability.d.ts.map +1 -0
  463. package/dist/workflow-capability.js +316 -0
  464. package/dist/workflow-capability.js.map +1 -0
  465. package/dist/workflow-script-flow.test.d.ts +2 -0
  466. package/dist/workflow-script-flow.test.d.ts.map +1 -0
  467. package/dist/workflow-script-flow.test.js +285 -0
  468. package/dist/workflow-script-flow.test.js.map +1 -0
  469. package/package.json +80 -0
  470. package/src/__snapshots__/x.com.processed.html +1003 -0
  471. package/src/__snapshots__/x.com.processed.withStyles.html +2720 -0
  472. package/src/a11y-client.ts +241 -0
  473. package/src/agent-skills/conan-config/SKILL.md +141 -0
  474. package/src/agent-skills/conan-config/agents/openai.yaml +4 -0
  475. package/src/aria-snapshot.test.ts +311 -0
  476. package/src/aria-snapshot.ts +1681 -0
  477. package/src/aria-snapshot.unit.test.ts +615 -0
  478. package/src/aria-snapshots/github-interactive.txt +193 -0
  479. package/src/aria-snapshots/github-raw.txt +309 -0
  480. package/src/aria-snapshots/hackernews-interactive.txt +477 -0
  481. package/src/aria-snapshots/hackernews-raw.txt +428 -0
  482. package/src/aria-snapshots/prosemirror-interactive.txt +66 -0
  483. package/src/aria-snapshots/prosemirror-raw.txt +145 -0
  484. package/src/assets/aria-labels-hacker-news.png +0 -0
  485. package/src/assets/aria-labels-old-reddit.png +0 -0
  486. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  487. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  488. package/src/assets/x.com.html +32946 -0
  489. package/src/browser-config.ts +179 -0
  490. package/src/browser-install.ts +284 -0
  491. package/src/browser-launch.ts +75 -0
  492. package/src/builtin-capabilities.ts +2765 -0
  493. package/src/capability-agent-skill.ts +305 -0
  494. package/src/capability-auth-state.ts +197 -0
  495. package/src/capability-auth.ts +249 -0
  496. package/src/capability-cli-confirmation.test.ts +90 -0
  497. package/src/capability-options.test.ts +89 -0
  498. package/src/capability-options.ts +57 -0
  499. package/src/capability-package.test.ts +204 -0
  500. package/src/capability-package.ts +555 -0
  501. package/src/capability-registry.test.ts +1905 -0
  502. package/src/capability-registry.ts +1234 -0
  503. package/src/capability-runner.ts +801 -0
  504. package/src/capability-search-relevance.test.ts +73 -0
  505. package/src/capability-studio.test.ts +45 -0
  506. package/src/capability-studio.ts +641 -0
  507. package/src/cdp-log.test.ts +131 -0
  508. package/src/cdp-log.ts +112 -0
  509. package/src/cdp-relay.ts +2944 -0
  510. package/src/cdp-session.ts +77 -0
  511. package/src/cdp-types.ts +158 -0
  512. package/src/channel-owner-inspect.test.ts +94 -0
  513. package/src/chrome-discovery.test.ts +50 -0
  514. package/src/chrome-discovery.ts +220 -0
  515. package/src/clean-html.ts +141 -0
  516. package/src/cli-help.test.ts +123 -0
  517. package/src/cli.ts +3181 -0
  518. package/src/cloud-client.ts +179 -0
  519. package/src/create-logger.ts +38 -0
  520. package/src/debugger-examples-types.ts +16 -0
  521. package/src/debugger-examples.ts +66 -0
  522. package/src/debugger.ts +708 -0
  523. package/src/diff-utils.test.ts +152 -0
  524. package/src/diff-utils.ts +70 -0
  525. package/src/doctor.test.ts +244 -0
  526. package/src/doctor.ts +321 -0
  527. package/src/editor-examples.ts +158 -0
  528. package/src/editor.ts +426 -0
  529. package/src/env-compat.ts +28 -0
  530. package/src/executor.ts +1824 -0
  531. package/src/executor.unit.test.ts +168 -0
  532. package/src/extension-connection.test.ts +855 -0
  533. package/src/extension-icon-warning.test.ts +45 -0
  534. package/src/ghost-browser.ts +149 -0
  535. package/src/ghost-cursor-client.ts +494 -0
  536. package/src/ghost-cursor-controller.ts +129 -0
  537. package/src/ghost-cursor.ts +123 -0
  538. package/src/htmlrewrite.test.ts +8008 -0
  539. package/src/htmlrewrite.ts +390 -0
  540. package/src/index.ts +53 -0
  541. package/src/kill-port.ts +219 -0
  542. package/src/kitty-graphics.test.ts +139 -0
  543. package/src/kitty-graphics.ts +79 -0
  544. package/src/locator-selector.test.ts +119 -0
  545. package/src/mcp-client.ts +78 -0
  546. package/src/mcp.ts +629 -0
  547. package/src/on-mouse-action.test.ts +226 -0
  548. package/src/options-page.test.ts +839 -0
  549. package/src/package-paths.ts +38 -0
  550. package/src/page-markdown.ts +240 -0
  551. package/src/performance-examples.ts +186 -0
  552. package/src/playwright-import.ts +59 -0
  553. package/src/popup-relocation.test.ts +163 -0
  554. package/src/product-paths.test.ts +47 -0
  555. package/src/product-paths.ts +28 -0
  556. package/src/protocol.test.ts +52 -0
  557. package/src/protocol.ts +351 -0
  558. package/src/react-source.ts +379 -0
  559. package/src/relay-client.test.ts +375 -0
  560. package/src/relay-client.ts +572 -0
  561. package/src/relay-core.test.ts +1603 -0
  562. package/src/relay-navigation.test.ts +790 -0
  563. package/src/relay-session.test.ts +1375 -0
  564. package/src/relay-state.test.ts +729 -0
  565. package/src/relay-state.ts +561 -0
  566. package/src/replay-ai-index.test.ts +278 -0
  567. package/src/replay-ai-index.ts +872 -0
  568. package/src/replay-cli.test.ts +325 -0
  569. package/src/replay-eval.ts +1039 -0
  570. package/src/replay-handoff.test.ts +163 -0
  571. package/src/replay-handoff.ts +109 -0
  572. package/src/replay-workflow-compiler.test.ts +250 -0
  573. package/src/replay-workflow-compiler.ts +700 -0
  574. package/src/resource.md +409 -0
  575. package/src/rrweb-recording-relay.test.ts +99 -0
  576. package/src/rrweb-recording-relay.ts +400 -0
  577. package/src/rrweb-recording.ts +322 -0
  578. package/src/scoped-fs.test.ts +80 -0
  579. package/src/scoped-fs.ts +420 -0
  580. package/src/skill-stub.test.ts +24 -0
  581. package/src/skill.md +1413 -0
  582. package/src/snapshot-tools.test.ts +1017 -0
  583. package/src/snapshots/hacker-news-accessibility-full.md +81 -0
  584. package/src/snapshots/hacker-news-accessibility-interactive.md +43 -0
  585. package/src/snapshots/page-markdown-output.txt +16 -0
  586. package/src/snapshots/shadcn-ui-accessibility-full.md +201 -0
  587. package/src/snapshots/shadcn-ui-accessibility-interactive.md +109 -0
  588. package/src/start-relay-server.ts +69 -0
  589. package/src/styles-examples.ts +84 -0
  590. package/src/styles.ts +343 -0
  591. package/src/tabwright-agent-skill.test.ts +61 -0
  592. package/src/tabwright-agent-skill.ts +105 -0
  593. package/src/test-declarations.ts +13 -0
  594. package/src/test-utils.test.ts +70 -0
  595. package/src/test-utils.ts +566 -0
  596. package/src/utils.ts +78 -0
  597. package/src/wait-for-page-load.ts +198 -0
  598. package/src/workflow-capability.ts +417 -0
  599. package/src/workflow-script-flow.test.ts +388 -0
package/src/cli.ts ADDED
@@ -0,0 +1,3181 @@
1
+ #!/usr/bin/env node
2
+
3
+ import './env-compat.js'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import util from 'node:util'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { goke, openInBrowser } from 'goke'
9
+ import { z } from 'zod'
10
+ import pc from 'picocolors'
11
+
12
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
13
+ Buffer.prototype[util.inspect.custom] = function () {
14
+ return `<Buffer ${this.length} bytes>`
15
+ }
16
+ import { killPortProcess } from './kill-port.js'
17
+ import { canEmitKittyGraphics, emitKittyImage } from './kitty-graphics.js'
18
+ import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'
19
+ import {
20
+ ensureRelayServer,
21
+ RELAY_PORT,
22
+ waitForConnectedExtensions,
23
+ getExtensionOutdatedWarning,
24
+ getExtensionStatus,
25
+ getExtensionsStatus,
26
+ getLocalRelayHttpBaseUrl,
27
+ getRelayServerFeatures,
28
+ getRelayServerVersion,
29
+ selectImplicitExtension,
30
+ type ExtensionStatus,
31
+ } from './relay-client.js'
32
+ import { discoverChromeInstances, resolveDirectInput, type DiscoveredInstance } from './chrome-discovery.js'
33
+ import { getCloudClient, loadCloudAuth, saveCloudAuth, CloudClient, buildLiveUrl } from './cloud-client.js'
34
+ import {
35
+ createCapability,
36
+ getCapabilitySafetySummary,
37
+ listCapabilities,
38
+ readCapabilityScript,
39
+ routeCapabilities,
40
+ searchCapabilities,
41
+ toCapabilityContract,
42
+ toCapabilitySummary,
43
+ updateCapabilityManifest,
44
+ updateCapabilityScript,
45
+ type CapabilityManifestPatch,
46
+ type CapabilityRecord,
47
+ } from './capability-registry.js'
48
+ import { initCapabilityAgentSkill, installCapabilityAgentSkill, showCapabilityAgentSkill } from './capability-agent-skill.js'
49
+ import {
50
+ getTabwrightAgentSkillStatus,
51
+ installTabwrightAgentSkill,
52
+ type TabwrightAgentSkillTarget,
53
+ } from './tabwright-agent-skill.js'
54
+ import { installCapabilityPackage, packCapability } from './capability-package.js'
55
+ import { refreshCapabilityAuthWithExecutor } from './capability-auth.js'
56
+ import {
57
+ finalizeCapabilityRun,
58
+ normalizeCapabilityExecutionText,
59
+ prepareCapabilityRun,
60
+ readCapabilityExecutionObservation,
61
+ runNodeCapability,
62
+ } from './capability-runner.js'
63
+ import { installBuiltinCapabilitySuite, isBuiltinCapabilitySuite } from './builtin-capabilities.js'
64
+ import { createReplayAiIndexFromRecording, saveReplayAiIndex } from './replay-ai-index.js'
65
+ import { listSavedRrwebRecordings } from './rrweb-recording-relay.js'
66
+ import {
67
+ compileReplayWorkflow,
68
+ UnsupportedReplayWorkflowError,
69
+ type ReplayWorkflowAnalysis,
70
+ } from './replay-workflow-compiler.js'
71
+ import {
72
+ buildReplayCreateCommand,
73
+ buildReplayIndexCommand,
74
+ buildReplayMakeCommand,
75
+ buildReplayRunCommand,
76
+ replayCapabilityId,
77
+ toCompactReplayAiIndex,
78
+ } from './replay-handoff.js'
79
+ import { formatReplayEvalReport, runReplayEval } from './replay-eval.js'
80
+ import type { ExecuteResult } from './executor.js'
81
+ import { buildDoctorReport, formatDoctorReport, type DoctorSession } from './doctor.js'
82
+
83
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
84
+
85
+ const cli = goke('tabwright')
86
+
87
+ cli.on('command:*', () => {
88
+ const firstArg = cli.args[0]
89
+ if (!firstArg) {
90
+ return
91
+ }
92
+
93
+ const hasMatchingCommandPrefix = cli.commands.some((command) => {
94
+ if (command.name === '') {
95
+ return false
96
+ }
97
+ return command.name.split(' ')[0] === firstArg
98
+ })
99
+
100
+ if (hasMatchingCommandPrefix) {
101
+ return
102
+ }
103
+
104
+ console.error('Run "tabwright --help" for usage information.')
105
+ })
106
+
107
+ cli
108
+ .command('browser start [binaryPath]', 'Start Chromium or Chrome for Testing with the bundled Tabwright extension')
109
+ .hidden()
110
+ .option('--user-data-dir <dir>', 'Persistent browser profile directory used for the managed browser')
111
+ .option('--headless', 'Run the browser in headless mode')
112
+ .option('--headed', 'Force headed mode even on Linux without DISPLAY/WAYLAND_DISPLAY')
113
+ .option('--disable-sandbox', 'Disable the browser sandbox, useful on some VPS setups')
114
+ .action(async (binaryPath, options) => {
115
+ if (options.headless && options.headed) {
116
+ console.error('Error: --headless and --headed cannot be used together.')
117
+ process.exit(1)
118
+ }
119
+
120
+ try {
121
+ // Avoid loading playwright-core during generic CLI startup/help. This command
122
+ // is the only path that needs browser discovery and bundled extension launch.
123
+ const [{ getBrowserLaunchArgs, getDefaultBrowserUserDataDir, startBrowserProcess }, { resolveBrowserExecutablePath, shouldUseHeadlessByDefault }, { getBundledExtensionPath }] = await Promise.all([
124
+ import('./browser-launch.js'),
125
+ import('./browser-config.js'),
126
+ import('./package-paths.js'),
127
+ ])
128
+
129
+ await ensureRelayServer({ logger: console })
130
+
131
+ const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
132
+ const extensionPath = getBundledExtensionPath()
133
+ const userDataDir = path.resolve(options.userDataDir || getDefaultBrowserUserDataDir())
134
+ const headless = options.headed ? false : options.headless ? true : shouldUseHeadlessByDefault()
135
+ const args = getBrowserLaunchArgs({
136
+ extensionPath,
137
+ userDataDir,
138
+ headless,
139
+ noSandbox: options.disableSandbox,
140
+ })
141
+
142
+ const { pid } = startBrowserProcess({
143
+ browserPath,
144
+ args,
145
+ userDataDir,
146
+ })
147
+
148
+ const connectedExtensions = await waitForConnectedExtensions({
149
+ timeoutMs: 15000,
150
+ pollIntervalMs: 250,
151
+ logger: console,
152
+ })
153
+
154
+ console.log(`Browser started (pid ${pid}).`)
155
+ console.log(` Binary: ${browserPath}`)
156
+ console.log(` Extension: ${extensionPath}`)
157
+ console.log(` Profile: ${userDataDir}`)
158
+ console.log(` Mode: ${headless ? 'headless' : 'headed'}`)
159
+ console.log(' Replay recording: rrweb DOM capture enabled')
160
+
161
+ if (connectedExtensions.length > 0) {
162
+ console.log('Tabwright extension connected to the relay server.')
163
+ return
164
+ }
165
+
166
+ console.log('Browser started, but the extension has not connected yet.')
167
+ console.log(`Check logs at: ${LOG_FILE_PATH}`)
168
+ } catch (error: any) {
169
+ console.error(`Error: ${error.message}`)
170
+ process.exit(1)
171
+ }
172
+ },
173
+ )
174
+
175
+ cli
176
+ .command('browser install', 'Download Chrome for Testing for headless browser automation')
177
+ .action(async () => {
178
+ try {
179
+ const { installChrome } = await import('./browser-install.js')
180
+ await installChrome()
181
+ } catch (error: any) {
182
+ console.error(`Error: ${error.message}`)
183
+ process.exit(1)
184
+ }
185
+ })
186
+
187
+ cli
188
+ .command('', 'Start the MCP server or controls the browser with -e')
189
+ .option('--host <host>', 'Remote relay server host to connect to (or use TABWRIGHT_HOST env var)')
190
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
191
+ .option('-s, --session <name>', 'Session ID (required for -e, get one with `tabwright session new`)')
192
+ .option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
193
+ .option('-f, --file <path>', 'Execute JavaScript from a file and exit')
194
+ .option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
195
+ .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
196
+ .action(async (options) => {
197
+ if (options.patchright) {
198
+ process.env.TABWRIGHT_PATCHRIGHT = '1'
199
+ }
200
+
201
+ if (options.eval && options.file) {
202
+ console.error('Error: -e and -f cannot be used together.')
203
+ process.exit(1)
204
+ }
205
+
206
+ // If -e or -f flag is provided, execute code via relay server
207
+ const code = (() => {
208
+ if (options.eval) {
209
+ return options.eval
210
+ }
211
+ if (options.file) {
212
+ const filePath = path.resolve(options.file)
213
+ if (!fs.existsSync(filePath)) {
214
+ console.error(`Error: File not found: ${filePath}`)
215
+ process.exit(1)
216
+ }
217
+ return fs.readFileSync(filePath, 'utf-8')
218
+ }
219
+ return null
220
+ })()
221
+
222
+ if (code) {
223
+ await executeCode({
224
+ code,
225
+ timeout: options.timeout || 10000,
226
+ sessionId: options.session,
227
+ host: options.host,
228
+ token: options.token,
229
+ })
230
+ return
231
+ }
232
+
233
+ // Otherwise start the MCP server
234
+ // For direct CDP in MCP mode, use TABWRIGHT_DIRECT env var
235
+ const { startMcp } = await import('./mcp.js')
236
+ await startMcp({
237
+ host: options.host,
238
+ token: options.token,
239
+ })
240
+ })
241
+
242
+ async function getServerUrl(host?: string): Promise<string> {
243
+ if (!host && !process.env.TABWRIGHT_HOST) {
244
+ return await getLocalRelayHttpBaseUrl(RELAY_PORT)
245
+ }
246
+ const serverHost = host || process.env.TABWRIGHT_HOST || '127.0.0.1'
247
+ const { httpBaseUrl } = parseRelayHost(serverHost, RELAY_PORT)
248
+ return httpBaseUrl
249
+ }
250
+
251
+ // Centralized header builder so every CLI subcommand sends the token consistently.
252
+ // Falls back to TABWRIGHT_TOKEN env var when --token is not provided.
253
+ function buildAuthHeaders({ token, json }: { token?: string; json?: boolean }): Record<string, string> {
254
+ const headers: Record<string, string> = {}
255
+ if (json) {
256
+ headers['Content-Type'] = 'application/json'
257
+ }
258
+ const effectiveToken = token || process.env.TABWRIGHT_TOKEN
259
+ if (effectiveToken) {
260
+ headers['Authorization'] = `Bearer ${effectiveToken}`
261
+ }
262
+ return headers
263
+ }
264
+
265
+ async function fetchExtensionsStatus({ host, token }: { host?: string; token?: string } = {}): Promise<ExtensionStatus[]> {
266
+ try {
267
+ const serverUrl = await getServerUrl(host)
268
+ const headers = buildAuthHeaders({ token })
269
+ const response = await fetch(`${serverUrl}/extensions/status`, {
270
+ signal: AbortSignal.timeout(2000),
271
+ headers,
272
+ })
273
+ if (!response.ok) {
274
+ const fallback = await fetch(`${serverUrl}/extension/status`, {
275
+ signal: AbortSignal.timeout(2000),
276
+ headers,
277
+ })
278
+ if (!fallback.ok) {
279
+ return []
280
+ }
281
+ const fallbackData = (await fallback.json()) as {
282
+ connected: boolean
283
+ activeTargets: number
284
+ browser: string | null
285
+ profile: { email: string; id: string } | null
286
+ playwriterVersion?: string | null
287
+ protocolVersion?: number
288
+ features?: string[]
289
+ connectionHealth?: 'ready' | 'limited' | 'legacy'
290
+ missingFeatures?: string[]
291
+ }
292
+ if (!fallbackData?.connected) {
293
+ return []
294
+ }
295
+ return [
296
+ {
297
+ extensionId: 'default',
298
+ stableKey: undefined,
299
+ browser: fallbackData?.browser,
300
+ profile: fallbackData?.profile,
301
+ activeTargets: fallbackData?.activeTargets,
302
+ playwriterVersion: fallbackData?.playwriterVersion || null,
303
+ protocolVersion: fallbackData?.protocolVersion,
304
+ features: fallbackData?.features,
305
+ connectionHealth: fallbackData?.connectionHealth,
306
+ missingFeatures: fallbackData?.missingFeatures,
307
+ },
308
+ ]
309
+ }
310
+ const data = (await response.json()) as {
311
+ extensions: ExtensionStatus[]
312
+ }
313
+ return data?.extensions || []
314
+ } catch {
315
+ return []
316
+ }
317
+ }
318
+
319
+ async function executeCode(options: {
320
+ code: string
321
+ timeout: number
322
+ sessionId?: string
323
+ host?: string
324
+ token?: string
325
+ }): Promise<void> {
326
+ const { code, timeout, host, token } = options
327
+ const cwd = process.cwd()
328
+ const sessionId = options.sessionId ? String(options.sessionId) : process.env.TABWRIGHT_SESSION
329
+
330
+ // Session is required
331
+ if (!sessionId) {
332
+ console.error('Error: -s/--session is required.')
333
+ console.error('Always run `tabwright session new` first to get a session ID to use.')
334
+ process.exit(1)
335
+ }
336
+
337
+ // Ensure relay server is running (only for local)
338
+ if (!host && !process.env.TABWRIGHT_HOST) {
339
+ const restarted = await ensureRelayServer({ logger: console })
340
+ if (restarted) {
341
+ const connectedExtensions = await waitForConnectedExtensions({
342
+ logger: console,
343
+ timeoutMs: 10000,
344
+ pollIntervalMs: 250,
345
+ })
346
+ if (connectedExtensions.length === 0) {
347
+ console.error('Warning: Extension not connected. Commands may fail.')
348
+ }
349
+ }
350
+ }
351
+
352
+ const serverUrl = await getServerUrl(host)
353
+
354
+ // Warn once if extension is outdated
355
+ const extensionStatus = await getExtensionStatus()
356
+ const outdatedWarning = getExtensionOutdatedWarning(extensionStatus?.playwriterVersion)
357
+ if (outdatedWarning) {
358
+ console.error(outdatedWarning)
359
+ }
360
+
361
+ // Build request URL with token if provided
362
+ const executeUrl = `${serverUrl}/cli/execute`
363
+
364
+ try {
365
+ const response = await fetch(executeUrl, {
366
+ method: 'POST',
367
+ headers: buildAuthHeaders({ token, json: true }),
368
+ body: JSON.stringify({ sessionId, code, timeout, cwd }),
369
+ })
370
+
371
+ if (!response.ok) {
372
+ const text = await response.text()
373
+ console.error(`Error: ${response.status} ${text}`)
374
+ process.exit(1)
375
+ }
376
+
377
+ const result = (await response.json()) as {
378
+ text: string
379
+ images: Array<{ data: string; mimeType: string }>
380
+ screenshots: Array<{ path: string; base64: string; snapshot: string; labelCount: number }>
381
+ isError: boolean
382
+ isCloud?: boolean
383
+ }
384
+
385
+ // Print output
386
+ if (result.text) {
387
+ if (result.isError) {
388
+ console.error(result.text)
389
+ } else {
390
+ console.log(result.text)
391
+ }
392
+ }
393
+
394
+ // Emit images via Kitty Graphics Protocol when AGENT_GRAPHICS=kitty.
395
+ // Agents with kitty-graphics-agent intercept these escape sequences and pass
396
+ // the PNG images to the LLM as media parts — no extra tool call needed.
397
+ const kittyEnabled = canEmitKittyGraphics()
398
+
399
+ // Track emitted base64 to avoid duplicates (screenshots appear in both
400
+ // result.screenshots and result.images from the same screenshotCollector)
401
+ const emittedImages = new Set<string>()
402
+
403
+ if (result.screenshots && result.screenshots.length > 0) {
404
+ for (const s of result.screenshots) {
405
+ if (kittyEnabled && s.base64) {
406
+ emitKittyImage({ base64: s.base64 })
407
+ emittedImages.add(s.base64)
408
+ }
409
+ console.log(`\nScreenshot saved to: ${s.path}`)
410
+ console.log(`Labels shown: ${s.labelCount}\n`)
411
+ console.log(`Accessibility snapshot:\n${s.snapshot}`)
412
+ }
413
+ }
414
+
415
+ // Emit resized images from resizeImageForAgent() calls that aren't
416
+ // already emitted as part of labeled screenshots
417
+ if (kittyEnabled && result.images && result.images.length > 0) {
418
+ for (const img of result.images) {
419
+ if (img.data && !emittedImages.has(img.data)) {
420
+ emitKittyImage({ base64: img.data })
421
+ emittedImages.add(img.data)
422
+ }
423
+ }
424
+ }
425
+
426
+ if (result.isCloud) {
427
+ console.error(pc.dim(`\nCloud session. Run \`tabwright session delete ${sessionId}\` when done.`))
428
+ }
429
+
430
+ if (result.isError) {
431
+ process.exit(1)
432
+ }
433
+ } catch (error: any) {
434
+ if (error.cause?.code === 'ECONNREFUSED') {
435
+ console.error('Error: Cannot connect to relay server.')
436
+ console.error('The Tabwright relay server should start automatically. Check logs at:')
437
+ console.error(` ${LOG_FILE_PATH}`)
438
+ } else {
439
+ console.error(`Error: ${error.message}`)
440
+ }
441
+ process.exit(1)
442
+ }
443
+ }
444
+
445
+ type CliExecuteResult = ExecuteResult & { isCloud?: boolean }
446
+
447
+ interface CapabilityRunOptions {
448
+ input?: string
449
+ inputJson?: string
450
+ session?: string
451
+ host?: string
452
+ token?: string
453
+ timeout?: number
454
+ force?: boolean
455
+ confirm?: string
456
+ browser?: string
457
+ json?: boolean
458
+ keepSession?: boolean
459
+ }
460
+
461
+ interface CapabilityRefreshAuthOptions {
462
+ session?: string
463
+ host?: string
464
+ token?: string
465
+ timeout?: number
466
+ browser?: string
467
+ json?: boolean
468
+ keepSession?: boolean
469
+ }
470
+
471
+ interface CapabilityInstallOptions {
472
+ project?: boolean
473
+ force?: boolean
474
+ draft?: boolean
475
+ skipAgentSkills?: boolean
476
+ withAgentSkill?: boolean
477
+ json?: boolean
478
+ }
479
+
480
+ interface CapabilityPackOptions {
481
+ output?: string
482
+ force?: boolean
483
+ json?: boolean
484
+ }
485
+
486
+ function parseCapabilityInput(options: { input?: string; inputJson?: string }): unknown {
487
+ const rawInput = options.inputJson || options.input || '{}'
488
+ try {
489
+ return JSON.parse(rawInput)
490
+ } catch (error) {
491
+ throw new Error(`Invalid JSON input: ${rawInput}`, { cause: error })
492
+ }
493
+ }
494
+
495
+ async function requestCliExecute(options: {
496
+ code: string
497
+ timeout: number
498
+ sessionId: string
499
+ host?: string
500
+ token?: string
501
+ includeStructuredResult?: boolean
502
+ }): Promise<CliExecuteResult> {
503
+ if (!options.host && !process.env.TABWRIGHT_HOST) {
504
+ await ensureRelayServer({ logger: console })
505
+ }
506
+ const serverUrl = await getServerUrl(options.host)
507
+
508
+ const response = await fetch(`${serverUrl}/cli/execute`, {
509
+ method: 'POST',
510
+ headers: buildAuthHeaders({ token: options.token, json: true }),
511
+ body: JSON.stringify({
512
+ sessionId: options.sessionId,
513
+ code: options.code,
514
+ timeout: options.timeout,
515
+ cwd: process.cwd(),
516
+ includeStructuredResult: options.includeStructuredResult,
517
+ }),
518
+ })
519
+ if (!response.ok) {
520
+ const text = await response.text()
521
+ throw new Error(`Execute failed: ${response.status} ${text}`)
522
+ }
523
+ return (await response.json()) as CliExecuteResult
524
+ }
525
+
526
+ async function createCapabilityRunSession(options: {
527
+ browser: string
528
+ host?: string
529
+ token?: string
530
+ }): Promise<{ sessionId: string; autoCreated: boolean }> {
531
+ const isLocal = !options.host && !process.env.TABWRIGHT_HOST
532
+ await ensureRelayForSessionCreation(isLocal)
533
+ const serverUrl = await getServerUrl(options.host)
534
+
535
+ const body = (() => {
536
+ if (options.browser === 'headless') {
537
+ return { headless: true, cwd: process.cwd() }
538
+ }
539
+ if (options.browser === 'user') {
540
+ return { cwd: process.cwd() }
541
+ }
542
+ return { extensionId: options.browser, cwd: process.cwd() }
543
+ })()
544
+
545
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
546
+ method: 'POST',
547
+ headers: buildAuthHeaders({ token: options.token, json: true }),
548
+ body: JSON.stringify(body),
549
+ })
550
+ if (!response.ok) {
551
+ const text = await response.text()
552
+ throw new Error(`Failed to create ${options.browser} session: ${response.status} ${text}`)
553
+ }
554
+ const result = (await response.json()) as { id: string }
555
+ return { sessionId: result.id, autoCreated: true }
556
+ }
557
+
558
+ async function deleteCapabilityRunSession(options: { sessionId: string; host?: string; token?: string }): Promise<void> {
559
+ const serverUrl = await getServerUrl(options.host)
560
+ await fetch(`${serverUrl}/cli/session/delete`, {
561
+ method: 'POST',
562
+ headers: buildAuthHeaders({ token: options.token, json: true }),
563
+ body: JSON.stringify({ sessionId: options.sessionId }),
564
+ }).catch(() => {})
565
+ }
566
+
567
+ function printCapabilityList(options: { capabilities: CapabilityRecord[]; json?: boolean }): void {
568
+ if (options.json) {
569
+ console.log(JSON.stringify(options.capabilities.map(toCapabilitySummary), null, 2))
570
+ return
571
+ }
572
+ if (options.capabilities.length === 0) {
573
+ console.log('No capabilities found.')
574
+ return
575
+ }
576
+ const idWidth = Math.max(2, ...options.capabilities.map((capability) => capability.manifest.id.length))
577
+ const statusWidth = Math.max(6, ...options.capabilities.map((capability) => capability.manifest.status.length))
578
+ console.log(`${'ID'.padEnd(idWidth)} ${'Status'.padEnd(statusWidth)} Runtime Location Title`)
579
+ console.log(`${'-'.repeat(idWidth)} ${'-'.repeat(statusWidth)} ------- -------- -----`)
580
+ options.capabilities.forEach((capability) => {
581
+ console.log(
582
+ `${capability.manifest.id.padEnd(idWidth)} ${capability.manifest.status.padEnd(statusWidth)} ${capability.manifest.runtime.padEnd(7)} ${capability.location.padEnd(8)} ${capability.manifest.title}`,
583
+ )
584
+ })
585
+ }
586
+
587
+ function printCapabilitySearch(options: {
588
+ query: string
589
+ results: ReturnType<typeof searchCapabilities>
590
+ json?: boolean
591
+ }): void {
592
+ if (options.json) {
593
+ console.log(
594
+ JSON.stringify(
595
+ options.results.map((result) => {
596
+ return {
597
+ score: result.score,
598
+ reasons: result.reasons,
599
+ ...toCapabilityContract(result.capability),
600
+ }
601
+ }),
602
+ null,
603
+ 2,
604
+ ),
605
+ )
606
+ return
607
+ }
608
+ if (options.results.length === 0) {
609
+ console.log(`No capabilities matched: ${options.query}`)
610
+ return
611
+ }
612
+ const lines = options.results.map((result) => {
613
+ const safety = getCapabilitySafetySummary(result.capability)
614
+ const autonomy = safety.sideEffect === 'mixed' ? 'mixed' : safety.requiresConfirmation ? 'confirm' : safety.sideEffect
615
+ return `${result.capability.manifest.id} score=${result.score} ${result.capability.manifest.runtime}/${autonomy} ${result.capability.manifest.title}`
616
+ })
617
+ console.log(lines.join('\n'))
618
+ }
619
+
620
+ function printCapabilityRoutes(options: {
621
+ task: string
622
+ routes: ReturnType<typeof routeCapabilities>
623
+ json?: boolean
624
+ }): void {
625
+ const routeSummaries = options.routes.map((route) => {
626
+ return {
627
+ shellCommand: route.shellCommand,
628
+ command: route.command,
629
+ capabilityId: route.capability.manifest.id,
630
+ operation: route.operation,
631
+ id: route.capability.manifest.id,
632
+ title: route.capability.manifest.title,
633
+ location: route.capability.location,
634
+ routingHint:
635
+ route.operation === undefined
636
+ ? route.capability.manifest.routingHint
637
+ : route.capability.manifest.operations[route.operation]?.routingHint,
638
+ input: route.input,
639
+ commandWarning: route.commandWarning,
640
+ executionHint: route.executionHint,
641
+ reasons: route.reasons,
642
+ matchedText: route.matchedText,
643
+ }
644
+ })
645
+ if (options.json) {
646
+ console.log(JSON.stringify(routeSummaries, null, 2))
647
+ return
648
+ }
649
+ if (routeSummaries.length === 0) {
650
+ console.log(`No exact direct-run capability matched: ${options.task}`)
651
+ console.log(`Next: tabwright capability search ${quoteShell(options.task)}`)
652
+ return
653
+ }
654
+ console.log(
655
+ routeSummaries
656
+ .map((route) => {
657
+ return `run: ${route.shellCommand}\ncapabilityId: ${route.capabilityId}\nwarning: ${route.commandWarning}`
658
+ })
659
+ .join('\n'),
660
+ )
661
+ }
662
+
663
+ function quoteShell(value: string): string {
664
+ return `'${value.replace(/'/g, "'\\''")}'`
665
+ }
666
+
667
+ function readCapabilityManifestPatchFromFile(filePath: string): CapabilityManifestPatch {
668
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf-8'))
669
+ if (!isRecord(parsed)) {
670
+ throw new Error('Capability contract file must contain a JSON object')
671
+ }
672
+ return parsed as CapabilityManifestPatch
673
+ }
674
+
675
+ function isRecord(value: unknown): value is Record<string, unknown> {
676
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
677
+ }
678
+
679
+ async function refreshCapabilityAuthFromCli(id: string, options: CapabilityRefreshAuthOptions): Promise<void> {
680
+ const session = options.session || process.env.TABWRIGHT_SESSION
681
+ const sessionInfo = session
682
+ ? { sessionId: session, autoCreated: false }
683
+ : await createCapabilityRunSession({
684
+ browser: options.browser || 'user',
685
+ host: options.host,
686
+ token: options.token,
687
+ })
688
+
689
+ try {
690
+ const result = await refreshCapabilityAuthWithExecutor({
691
+ id,
692
+ cwd: process.cwd(),
693
+ timeout: options.timeout || 10000,
694
+ executor: {
695
+ execute: (code, timeout, executeOptions) => {
696
+ return requestCliExecute({
697
+ code,
698
+ timeout: timeout || 10000,
699
+ sessionId: sessionInfo.sessionId,
700
+ host: options.host,
701
+ token: options.token,
702
+ includeStructuredResult: executeOptions?.includeStructuredResult,
703
+ })
704
+ },
705
+ },
706
+ })
707
+ if (options.json) {
708
+ console.log(
709
+ JSON.stringify(
710
+ {
711
+ capability: result.capability.manifest.id,
712
+ saved: result.saved,
713
+ secretKey: result.secretKey,
714
+ cookieCount: result.cookieCount,
715
+ cookieNames: result.cookieNames,
716
+ urls: result.urls,
717
+ expiresAt: result.expiresAt,
718
+ path: result.path,
719
+ },
720
+ null,
721
+ 2,
722
+ ),
723
+ )
724
+ return
725
+ }
726
+ console.log(`Refreshed ${result.capability.manifest.id} auth at ${result.path}`)
727
+ console.log(`Saved ${result.cookieCount} cookies into secret "${result.secretKey}".`)
728
+ if (result.expiresAt) {
729
+ console.log(`Expires at ${result.expiresAt}.`)
730
+ }
731
+ } finally {
732
+ if (sessionInfo.autoCreated && !options.keepSession) {
733
+ await deleteCapabilityRunSession({
734
+ sessionId: sessionInfo.sessionId,
735
+ host: options.host,
736
+ token: options.token,
737
+ })
738
+ }
739
+ }
740
+ }
741
+
742
+ async function runCapabilityFromCli(id: string, options: CapabilityRunOptions): Promise<void> {
743
+ const input = parseCapabilityInput({ input: options.input, inputJson: options.inputJson })
744
+ const prepared = prepareCapabilityRun({
745
+ id,
746
+ input,
747
+ cwd: process.cwd(),
748
+ force: options.force,
749
+ confirmation: options.confirm,
750
+ })
751
+ if (prepared.capability.manifest.runtime === 'node') {
752
+ const result = await runNodeCapability({
753
+ id,
754
+ input,
755
+ cwd: process.cwd(),
756
+ force: options.force,
757
+ confirmation: options.confirm,
758
+ timeout: options.timeout || 10000,
759
+ })
760
+ if (options.json) {
761
+ console.log(
762
+ JSON.stringify(
763
+ {
764
+ capability: result.capability.manifest.id,
765
+ output: result.output,
766
+ text: result.text,
767
+ isError: result.isError,
768
+ },
769
+ null,
770
+ 2,
771
+ ),
772
+ )
773
+ return
774
+ }
775
+ console.log(JSON.stringify(result.output, null, 2))
776
+ return
777
+ }
778
+
779
+ const session = options.session || process.env.TABWRIGHT_SESSION
780
+ const sessionInfo = session
781
+ ? { sessionId: session, autoCreated: false }
782
+ : await createCapabilityRunSession({
783
+ browser: options.browser || 'headless',
784
+ host: options.host,
785
+ token: options.token,
786
+ })
787
+
788
+ const start = Date.now()
789
+ try {
790
+ const result = await requestCliExecute({
791
+ code: prepared.code,
792
+ timeout: options.timeout || 10000,
793
+ sessionId: sessionInfo.sessionId,
794
+ host: options.host,
795
+ token: options.token,
796
+ includeStructuredResult: true,
797
+ }).catch((error: unknown) => {
798
+ finalizeCapabilityRun({
799
+ capability: prepared.capability,
800
+ operation: prepared.operation,
801
+ cwd: process.cwd(),
802
+ inputHash: prepared.inputHash,
803
+ startedAt: start,
804
+ execution: {
805
+ status: 'error',
806
+ output: undefined,
807
+ error: error instanceof Error ? error.message : String(error),
808
+ },
809
+ })
810
+ throw error
811
+ })
812
+ const observation = readCapabilityExecutionObservation(result.structuredResult)
813
+ const isExecutionError = result.isError || Boolean(observation.error)
814
+ const normalizedText = normalizeCapabilityExecutionText({
815
+ text: result.text,
816
+ output: observation.output,
817
+ error: observation.error,
818
+ })
819
+ const finalized = finalizeCapabilityRun({
820
+ capability: prepared.capability,
821
+ operation: prepared.operation,
822
+ cwd: process.cwd(),
823
+ inputHash: prepared.inputHash,
824
+ startedAt: start,
825
+ execution: {
826
+ status: isExecutionError ? 'error' : 'success',
827
+ output: observation.output,
828
+ error: isExecutionError ? observation.error || normalizedText : undefined,
829
+ observedNetworkUrls: observation.observedNetworkUrls,
830
+ url: observation.url,
831
+ },
832
+ })
833
+ if (finalized.contractError) {
834
+ throw finalized.contractError
835
+ }
836
+
837
+ if (options.json) {
838
+ console.log(
839
+ JSON.stringify(
840
+ {
841
+ capability: prepared.capability.manifest.id,
842
+ output: observation.output,
843
+ text: normalizedText,
844
+ isError: isExecutionError,
845
+ },
846
+ null,
847
+ 2,
848
+ ),
849
+ )
850
+ } else {
851
+ console.log(JSON.stringify(observation.output, null, 2))
852
+ if (isExecutionError) {
853
+ console.error(normalizedText)
854
+ }
855
+ }
856
+
857
+ if (isExecutionError) {
858
+ process.exit(1)
859
+ }
860
+ } finally {
861
+ if (sessionInfo.autoCreated && !options.keepSession) {
862
+ await deleteCapabilityRunSession({
863
+ sessionId: sessionInfo.sessionId,
864
+ host: options.host,
865
+ token: options.token,
866
+ })
867
+ }
868
+ }
869
+ }
870
+
871
+ // Session management commands
872
+ // Unified browser option type used in the multi-browser selection table
873
+ interface BrowserOption {
874
+ key: string
875
+ type: 'extension' | 'direct' | 'cloud' | 'headless'
876
+ browser: string
877
+ profile: string
878
+ /** For extension entries */
879
+ extensionId?: string | null
880
+ /** For direct CDP entries */
881
+ wsUrl?: string
882
+ /** Raw profile data from discovery (for passing to relay) */
883
+ profiles?: Array<{ name: string; email: string }>
884
+ /** For cloud entries — active BU session's cloud session ID (if VM is running) */
885
+ activeCloudSessionId?: string
886
+ }
887
+
888
+ function exitWithError(error: unknown): never {
889
+ const message = error instanceof Error ? error.message : String(error)
890
+ console.error(`Error: ${message}`)
891
+ process.exit(1)
892
+ }
893
+
894
+ function buildNestedExampleInput(pathValue: string): Record<string, unknown> {
895
+ const parts = pathValue.split('.').filter((part) => {
896
+ return part.length > 0
897
+ })
898
+ if (parts.length === 0) {
899
+ return { value: '...' }
900
+ }
901
+ return parts.reduceRight<Record<string, unknown> | string>((current, part) => {
902
+ if (typeof current === 'string') {
903
+ return { [part]: current }
904
+ }
905
+ return { [part]: current }
906
+ }, '...') as Record<string, unknown>
907
+ }
908
+
909
+ function toReplayCompilerSummary(analysis: ReplayWorkflowAnalysis): ReplayWorkflowAnalysis & { supported: boolean } {
910
+ return {
911
+ supported: analysis.actionKind !== 'unknown',
912
+ ...analysis,
913
+ }
914
+ }
915
+
916
+ function buildReplayNeedsAiHandoff(options: {
917
+ replayId: string
918
+ capabilityId: string
919
+ goal?: string
920
+ analysis: ReplayWorkflowAnalysis
921
+ }) {
922
+ const index = createReplayAiIndexFromRecording(options.replayId)
923
+ return {
924
+ status: 'needs_ai' as const,
925
+ replay: {
926
+ id: options.replayId,
927
+ url: index.url,
928
+ },
929
+ capabilityWritten: false,
930
+ compiler: toReplayCompilerSummary(options.analysis),
931
+ evidence: toCompactReplayAiIndex(index),
932
+ next: {
933
+ action: 'author_capability' as const,
934
+ inspectCommand: buildReplayIndexCommand({ replayId: options.replayId, full: true }),
935
+ createCommand: buildReplayCreateCommand({
936
+ capabilityId: options.capabilityId,
937
+ title: `Workflow from replay ${options.replayId}`,
938
+ description: options.goal,
939
+ }),
940
+ },
941
+ }
942
+ }
943
+
944
+ function printReplayNeedsAiHandoff(options: {
945
+ handoff: ReturnType<typeof buildReplayNeedsAiHandoff>
946
+ json?: boolean
947
+ }): void {
948
+ if (options.json) {
949
+ console.log(JSON.stringify(options.handoff, null, 2))
950
+ return
951
+ }
952
+ console.log(`Replay ${options.handoff.replay.id} needs AI authoring.`)
953
+ console.log('No capability was written.')
954
+ options.handoff.compiler.reasons.forEach((reason) => {
955
+ console.log(`- ${reason}`)
956
+ })
957
+ console.log(`Inspect full evidence: ${options.handoff.next.inspectCommand}`)
958
+ console.log(`Create a browser scaffold: ${options.handoff.next.createCommand}`)
959
+ }
960
+
961
+ cli
962
+ .command('replay list', 'List saved rrweb replays and the next commands for each recording')
963
+ .option('--limit <n>', z.number().default(10).describe('Maximum number of recordings'))
964
+ .option('--json', 'Print JSON')
965
+ .action((options: { limit?: number; json?: boolean }) => {
966
+ try {
967
+ const recordings = listSavedRrwebRecordings({ limit: options.limit || 10 }).map((recording) => {
968
+ return {
969
+ id: recording.id,
970
+ url: recording.url,
971
+ savedAt: recording.savedAt,
972
+ durationMs: recording.duration,
973
+ eventCount: recording.eventCount,
974
+ commands: {
975
+ inspect: buildReplayIndexCommand({ replayId: recording.id }),
976
+ make: buildReplayMakeCommand({
977
+ replayId: recording.id,
978
+ capabilityId: replayCapabilityId(recording.id),
979
+ }),
980
+ },
981
+ }
982
+ })
983
+ if (options.json) {
984
+ console.log(
985
+ JSON.stringify(
986
+ {
987
+ recordings,
988
+ next:
989
+ recordings.length > 0
990
+ ? recordings[0]?.commands
991
+ : { action: 'record', command: 'Use replay.start() and replay.stop() in a Tabwright session.' },
992
+ },
993
+ null,
994
+ 2,
995
+ ),
996
+ )
997
+ return
998
+ }
999
+ if (recordings.length === 0) {
1000
+ console.log('No saved replays. Use replay.start() and replay.stop() in a Tabwright session first.')
1001
+ return
1002
+ }
1003
+ recordings.forEach((recording) => {
1004
+ console.log(`${recording.id} ${recording.url || '-'} ${recording.eventCount} events`)
1005
+ console.log(` Inspect: ${recording.commands.inspect}`)
1006
+ console.log(` Make: ${recording.commands.make}`)
1007
+ })
1008
+ } catch (error) {
1009
+ exitWithError(error)
1010
+ }
1011
+ })
1012
+
1013
+ cli
1014
+ .command('replay index <replayId>', 'Build an AI-readable index from an rrweb replay')
1015
+ .option('--write', 'Save the index under ~/.tabwright/replay-ai-indexes')
1016
+ .option('--full', 'Include page text and the full interactive-element inventory')
1017
+ .option('--json', 'Print JSON')
1018
+ .action((replayId: string, options: { write?: boolean; full?: boolean; json?: boolean }) => {
1019
+ try {
1020
+ const index = createReplayAiIndexFromRecording(replayId)
1021
+ const saved = options.write ? saveReplayAiIndex(index) : undefined
1022
+ if (options.json) {
1023
+ const outputIndex = options.full ? index : toCompactReplayAiIndex(index)
1024
+ console.log(JSON.stringify(saved ? { index: outputIndex, saved } : { index: outputIndex }, null, 2))
1025
+ return
1026
+ }
1027
+ console.log(`Replay: ${index.replayId}`)
1028
+ console.log(`URL: ${index.url || '-'}`)
1029
+ console.log(`Actions: ${index.actions.length}`)
1030
+ console.log(`Fields: ${index.fields.length}`)
1031
+ console.log(`Annotations: ${index.annotations.length}`)
1032
+ console.log(`Stats: ${JSON.stringify(index.stats)}`)
1033
+ if (saved) {
1034
+ console.log(`Saved: ${saved.path}`)
1035
+ }
1036
+ index.annotations.slice(0, 8).forEach((annotation, index) => {
1037
+ const target = annotation.target?.label || annotation.target?.selectorHints[0] || annotation.target?.tagName || 'target'
1038
+ console.log(`${index + 1}. ${pc.magenta('annotation')} ${target}: ${annotation.text}`)
1039
+ })
1040
+ index.actions.slice(0, 12).forEach((action, index) => {
1041
+ const value = action.value === undefined ? '' : ` = ${JSON.stringify(action.value)}`
1042
+ console.log(`${index + 1}. ${pc.cyan(action.kind)} ${action.label}${value}`)
1043
+ })
1044
+ } catch (error) {
1045
+ exitWithError(error)
1046
+ }
1047
+ })
1048
+
1049
+ cli
1050
+ .command('replay compile <replayId> <capabilityId>', 'Compile an rrweb replay into a draft workflow capability')
1051
+ .option('--title <title>', 'Capability title')
1052
+ .option('--description <description>', 'Capability description')
1053
+ .option('--goal <goal>', 'User goal to store as capability description')
1054
+ .option('--value-input-path <path>', 'Input path for the appended value (default: value)')
1055
+ .option('--force', 'Overwrite an existing capability')
1056
+ .option('--json', 'Print JSON')
1057
+ .action(
1058
+ (
1059
+ replayId: string,
1060
+ capabilityId: string,
1061
+ options: {
1062
+ title?: string
1063
+ description?: string
1064
+ goal?: string
1065
+ valueInputPath?: string
1066
+ force?: boolean
1067
+ json?: boolean
1068
+ },
1069
+ ) => {
1070
+ try {
1071
+ const valueInputPath = options.valueInputPath || 'value'
1072
+ const compiled = compileReplayWorkflow({
1073
+ replayId,
1074
+ id: capabilityId,
1075
+ cwd: process.cwd(),
1076
+ title: options.title,
1077
+ description: options.description || options.goal,
1078
+ valueInputPath,
1079
+ overwrite: options.force,
1080
+ })
1081
+ const runCommand = buildReplayRunCommand({
1082
+ capabilityId,
1083
+ input: buildNestedExampleInput(valueInputPath),
1084
+ })
1085
+ if (options.json) {
1086
+ console.log(
1087
+ JSON.stringify(
1088
+ {
1089
+ status: 'compiled',
1090
+ replay: {
1091
+ id: replayId,
1092
+ url: compiled.analysis.url,
1093
+ },
1094
+ compiler: toReplayCompilerSummary(compiled.analysis),
1095
+ capability: compiled.saved.capability,
1096
+ next: {
1097
+ requiresUserConfirmation: true,
1098
+ runCommand,
1099
+ },
1100
+ },
1101
+ null,
1102
+ 2,
1103
+ ),
1104
+ )
1105
+ return
1106
+ }
1107
+ console.log(`Compiled ${replayId} into ${capabilityId}`)
1108
+ console.log(`Action: ${compiled.analysis.actionKind}`)
1109
+ console.log(`Confidence: ${compiled.analysis.confidence}`)
1110
+ console.log(`Observed value: ${compiled.analysis.demonstratedValue || '-'}`)
1111
+ console.log('Requires explicit user confirmation before running.')
1112
+ console.log(`After approval: ${runCommand}`)
1113
+ } catch (error) {
1114
+ if (error instanceof UnsupportedReplayWorkflowError) {
1115
+ printReplayNeedsAiHandoff({
1116
+ handoff: buildReplayNeedsAiHandoff({
1117
+ replayId,
1118
+ capabilityId,
1119
+ goal: options.description || options.goal,
1120
+ analysis: error.analysis,
1121
+ }),
1122
+ json: options.json,
1123
+ })
1124
+ return
1125
+ }
1126
+ exitWithError(error)
1127
+ }
1128
+ },
1129
+ )
1130
+
1131
+ cli
1132
+ .command('replay make <replayId> <capabilityId>', 'Index and compile a replay into a runnable draft capability')
1133
+ .option('--title <title>', 'Capability title')
1134
+ .option('--description <description>', 'Capability description')
1135
+ .option('--goal <goal>', 'User goal to store as capability description')
1136
+ .option('--value-input-path <path>', 'Input path for the appended value (default: value)')
1137
+ .option('--write-index', 'Save the generated replay index under ~/.tabwright/replay-ai-indexes')
1138
+ .option('--force', 'Overwrite an existing capability')
1139
+ .option('--json', 'Print JSON')
1140
+ .action(
1141
+ (
1142
+ replayId: string,
1143
+ capabilityId: string,
1144
+ options: {
1145
+ title?: string
1146
+ description?: string
1147
+ goal?: string
1148
+ valueInputPath?: string
1149
+ writeIndex?: boolean
1150
+ force?: boolean
1151
+ json?: boolean
1152
+ },
1153
+ ) => {
1154
+ try {
1155
+ const valueInputPath = options.valueInputPath || 'value'
1156
+ const index = createReplayAiIndexFromRecording(replayId)
1157
+ const savedIndex = options.writeIndex ? saveReplayAiIndex(index) : undefined
1158
+ const compiled = compileReplayWorkflow({
1159
+ replayId,
1160
+ id: capabilityId,
1161
+ cwd: process.cwd(),
1162
+ title: options.title,
1163
+ description: options.description || options.goal,
1164
+ valueInputPath,
1165
+ overwrite: options.force,
1166
+ })
1167
+ const runCommand = buildReplayRunCommand({
1168
+ capabilityId,
1169
+ input: buildNestedExampleInput(valueInputPath),
1170
+ })
1171
+ if (options.json) {
1172
+ console.log(
1173
+ JSON.stringify(
1174
+ {
1175
+ status: 'compiled',
1176
+ replay: {
1177
+ id: replayId,
1178
+ url: index.url,
1179
+ },
1180
+ savedIndex,
1181
+ compiler: toReplayCompilerSummary(compiled.analysis),
1182
+ evidence: toCompactReplayAiIndex(index),
1183
+ capability: compiled.saved.capability,
1184
+ next: {
1185
+ requiresUserConfirmation: true,
1186
+ runCommand,
1187
+ },
1188
+ },
1189
+ null,
1190
+ 2,
1191
+ ),
1192
+ )
1193
+ return
1194
+ }
1195
+ console.log(`Replay: ${index.replayId}`)
1196
+ console.log(`URL: ${index.url || '-'}`)
1197
+ console.log(`Actions: ${index.actions.length}`)
1198
+ console.log(`Fields: ${index.fields.length}`)
1199
+ console.log(`Annotations: ${index.annotations.length}`)
1200
+ if (savedIndex) {
1201
+ console.log(`Saved index: ${savedIndex.path}`)
1202
+ }
1203
+ console.log(`Compiled capability: ${capabilityId}`)
1204
+ console.log(`Action: ${compiled.analysis.actionKind}`)
1205
+ console.log(`Confidence: ${compiled.analysis.confidence}`)
1206
+ console.log(`Observed value: ${compiled.analysis.demonstratedValue || '-'}`)
1207
+ console.log('Requires explicit user confirmation before running.')
1208
+ console.log(`After approval: ${runCommand}`)
1209
+ } catch (error) {
1210
+ if (error instanceof UnsupportedReplayWorkflowError) {
1211
+ printReplayNeedsAiHandoff({
1212
+ handoff: buildReplayNeedsAiHandoff({
1213
+ replayId,
1214
+ capabilityId,
1215
+ goal: options.description || options.goal,
1216
+ analysis: error.analysis,
1217
+ }),
1218
+ json: options.json,
1219
+ })
1220
+ return
1221
+ }
1222
+ exitWithError(error)
1223
+ }
1224
+ },
1225
+ )
1226
+
1227
+ cli
1228
+ .command('replay eval', 'Run replay-to-capability evaluation cases against local example pages')
1229
+ .option('--case <id>', 'Run one evaluation case')
1230
+ .option('--json', 'Print JSON')
1231
+ .option('--report <path>', 'Write an HTML report')
1232
+ .option('--keep-artifacts', 'Keep generated temporary recordings and capabilities')
1233
+ .option('--headed', 'Run the evaluation browser in headed mode')
1234
+ .action(
1235
+ async (options: { case?: string; json?: boolean; report?: string; keepArtifacts?: boolean; headed?: boolean }) => {
1236
+ try {
1237
+ const report = await runReplayEval({
1238
+ caseId: options.case,
1239
+ reportPath: options.report,
1240
+ keepArtifacts: options.keepArtifacts,
1241
+ headed: options.headed,
1242
+ })
1243
+ if (options.json) {
1244
+ console.log(JSON.stringify(report, null, 2))
1245
+ return
1246
+ }
1247
+ console.log(formatReplayEvalReport(report))
1248
+ if (options.report) {
1249
+ console.log(`Report: ${path.resolve(options.report)}`)
1250
+ }
1251
+ if (report.failed > 0) {
1252
+ process.exitCode = 1
1253
+ }
1254
+ } catch (error) {
1255
+ exitWithError(error)
1256
+ }
1257
+ },
1258
+ )
1259
+
1260
+ cli
1261
+ .command('capability list', 'List saved Tabwright capabilities')
1262
+ .option('--json', 'Print JSON')
1263
+ .action((options: { json?: boolean }) => {
1264
+ try {
1265
+ printCapabilityList({ capabilities: listCapabilities({ cwd: process.cwd() }), json: options.json })
1266
+ } catch (error) {
1267
+ exitWithError(error)
1268
+ }
1269
+ })
1270
+
1271
+ cli
1272
+ .command('capability route <task>', 'Find an exact-match direct-run capability for a concrete task or URL')
1273
+ .option('--limit <n>', z.number().default(3).describe('Maximum number of routes'))
1274
+ .option('--json', 'Print JSON')
1275
+ .action((task: string, options: { limit?: number; json?: boolean }) => {
1276
+ try {
1277
+ printCapabilityRoutes({
1278
+ task,
1279
+ routes: routeCapabilities({ task, cwd: process.cwd(), limit: options.limit || 3 }),
1280
+ json: options.json,
1281
+ })
1282
+ } catch (error) {
1283
+ exitWithError(error)
1284
+ }
1285
+ })
1286
+
1287
+ cli
1288
+ .command('capability search <query>', 'Search saved Tabwright capabilities by user intent')
1289
+ .option('--limit <n>', z.number().default(10).describe('Maximum number of results'))
1290
+ .option('--json', 'Print JSON')
1291
+ .action((query: string, options: { limit?: number; json?: boolean }) => {
1292
+ try {
1293
+ printCapabilitySearch({
1294
+ query,
1295
+ results: searchCapabilities({ query, cwd: process.cwd(), limit: options.limit || 10 }),
1296
+ json: options.json,
1297
+ })
1298
+ } catch (error) {
1299
+ exitWithError(error)
1300
+ }
1301
+ })
1302
+
1303
+ cli
1304
+ .command('capability describe <id>', 'Print the AI-readable contract for a capability')
1305
+ .option('--json', 'Print JSON')
1306
+ .action((id: string, options: { json?: boolean }) => {
1307
+ try {
1308
+ const capability = listCapabilities({ cwd: process.cwd() }).find((candidate) => {
1309
+ return candidate.manifest.id === id
1310
+ })
1311
+ if (!capability) {
1312
+ throw new Error(`Capability not found: ${id}`)
1313
+ }
1314
+ const contract = toCapabilityContract(capability)
1315
+ if (options.json) {
1316
+ console.log(JSON.stringify(contract, null, 2))
1317
+ return
1318
+ }
1319
+ console.log(JSON.stringify(contract, null, 2))
1320
+ } catch (error) {
1321
+ exitWithError(error)
1322
+ }
1323
+ })
1324
+
1325
+ cli
1326
+ .command('capability show <id>', 'Show a saved Tabwright capability')
1327
+ .option('--json', 'Print JSON')
1328
+ .option('--script', 'Print the script source')
1329
+ .action((id: string, options: { json?: boolean; script?: boolean }) => {
1330
+ try {
1331
+ const capability = listCapabilities({ cwd: process.cwd() }).find((candidate) => {
1332
+ return candidate.manifest.id === id
1333
+ })
1334
+ if (!capability) {
1335
+ throw new Error(`Capability not found: ${id}`)
1336
+ }
1337
+ if (options.script) {
1338
+ console.log(readCapabilityScript({ id, cwd: process.cwd() }))
1339
+ return
1340
+ }
1341
+ if (options.json) {
1342
+ console.log(JSON.stringify(toCapabilitySummary(capability), null, 2))
1343
+ return
1344
+ }
1345
+ console.log(`${capability.manifest.title} (${capability.manifest.id})`)
1346
+ console.log(`Status: ${capability.manifest.status}`)
1347
+ console.log(`Runtime: ${capability.manifest.runtime}`)
1348
+ console.log(`Location: ${capability.location}`)
1349
+ console.log(`Directory: ${capability.dir}`)
1350
+ if (capability.manifest.description) {
1351
+ console.log(`Description: ${capability.manifest.description}`)
1352
+ }
1353
+ console.log(`Match: ${capability.manifest.match.length > 0 ? capability.manifest.match.join(', ') : '-'}`)
1354
+ console.log(`Permissions: ${capability.manifest.permissions.join(', ') || '-'}`)
1355
+ console.log(`Entry: ${capability.manifest.entry}`)
1356
+ } catch (error) {
1357
+ exitWithError(error)
1358
+ }
1359
+ })
1360
+
1361
+ cli
1362
+ .command('capability create <id>', 'Create a draft Tabwright capability')
1363
+ .option('--title <title>', 'Capability title')
1364
+ .option('--description <description>', 'Capability description')
1365
+ .option('--project', 'Create under .tabwright/capabilities in the current project')
1366
+ .option('--runtime <browser|node>', 'Capability runtime (default: browser)')
1367
+ .option('--force', 'Overwrite an existing capability')
1368
+ .option('--json', 'Print JSON')
1369
+ .action(
1370
+ (
1371
+ id: string,
1372
+ options: { title?: string; description?: string; project?: boolean; runtime?: string; force?: boolean; json?: boolean },
1373
+ ) => {
1374
+ try {
1375
+ if (options.runtime && options.runtime !== 'browser' && options.runtime !== 'node') {
1376
+ throw new Error(`Invalid runtime: ${options.runtime}`)
1377
+ }
1378
+ const runtime = options.runtime === 'browser' || options.runtime === 'node' ? options.runtime : undefined
1379
+ const capability = createCapability({
1380
+ id,
1381
+ title: options.title,
1382
+ description: options.description,
1383
+ location: options.project ? 'project' : 'user',
1384
+ cwd: process.cwd(),
1385
+ overwrite: options.force,
1386
+ createdBy: 'ai',
1387
+ runtime,
1388
+ })
1389
+ if (options.json) {
1390
+ console.log(JSON.stringify(toCapabilitySummary(capability), null, 2))
1391
+ return
1392
+ }
1393
+ console.log(`Created ${capability.manifest.id} at ${capability.dir}`)
1394
+ } catch (error) {
1395
+ exitWithError(error)
1396
+ }
1397
+ },
1398
+ )
1399
+
1400
+ cli
1401
+ .command('capability skill init <id>', 'Create an editable agent skill scaffold for a saved capability')
1402
+ .option('--force', 'Overwrite an existing agent skill draft')
1403
+ .option('--json', 'Print JSON')
1404
+ .action((id: string, options: { force?: boolean; json?: boolean }) => {
1405
+ try {
1406
+ const result = initCapabilityAgentSkill({
1407
+ id,
1408
+ cwd: process.cwd(),
1409
+ overwrite: options.force,
1410
+ })
1411
+ if (options.json) {
1412
+ console.log(JSON.stringify(result, null, 2))
1413
+ return
1414
+ }
1415
+ console.log(`Created agent skill draft for ${result.capabilityId}: ${result.dir}`)
1416
+ result.files.forEach((file) => {
1417
+ console.log(`- ${file.status}: ${file.path}`)
1418
+ })
1419
+ console.log('')
1420
+ console.log('Next:')
1421
+ result.next.forEach((step) => {
1422
+ console.log(` ${step}`)
1423
+ })
1424
+ } catch (error) {
1425
+ exitWithError(error)
1426
+ }
1427
+ })
1428
+
1429
+ cli
1430
+ .command('capability skill install <id>', 'Install an edited capability agent skill into Codex')
1431
+ .option('--force', 'Overwrite an existing installed agent skill')
1432
+ .option('--codex-home <dir>', 'Codex home directory (defaults to CODEX_HOME or ~/.codex)')
1433
+ .option('--json', 'Print JSON')
1434
+ .action((id: string, options: { force?: boolean; codexHome?: string; json?: boolean }) => {
1435
+ try {
1436
+ const result = installCapabilityAgentSkill({
1437
+ id,
1438
+ cwd: process.cwd(),
1439
+ overwrite: options.force,
1440
+ codexHome: options.codexHome,
1441
+ })
1442
+ if (options.json) {
1443
+ console.log(JSON.stringify(result, null, 2))
1444
+ return
1445
+ }
1446
+ console.log(`Installed agent skill for ${result.capabilityId}: ${result.dir}`)
1447
+ result.files.forEach((file) => {
1448
+ console.log(`- ${file.status}: ${file.path}`)
1449
+ })
1450
+ console.log('')
1451
+ console.log('Next:')
1452
+ result.next.forEach((step) => {
1453
+ console.log(` ${step}`)
1454
+ })
1455
+ } catch (error) {
1456
+ exitWithError(error)
1457
+ }
1458
+ })
1459
+
1460
+ cli
1461
+ .command('capability skill show <id>', 'Show the editable agent skill draft for a saved capability')
1462
+ .option('--json', 'Print JSON')
1463
+ .action((id: string, options: { json?: boolean }) => {
1464
+ try {
1465
+ const result = showCapabilityAgentSkill({
1466
+ id,
1467
+ cwd: process.cwd(),
1468
+ })
1469
+ if (options.json) {
1470
+ console.log(JSON.stringify(result, null, 2))
1471
+ return
1472
+ }
1473
+ const skill = result.files.find((file) => {
1474
+ return file.relativePath === 'SKILL.md'
1475
+ })
1476
+ if (!skill) {
1477
+ throw new Error(`Agent skill draft missing SKILL.md for ${id}`)
1478
+ }
1479
+ console.log(skill.content)
1480
+ } catch (error) {
1481
+ exitWithError(error)
1482
+ }
1483
+ })
1484
+
1485
+ cli
1486
+ .command('capability pack <id>', 'Pack a capability for safe sharing')
1487
+ .option('-o, --output <path>', 'Output .tgz path (default: <id>.tgz)')
1488
+ .option('--force', 'Overwrite an existing package')
1489
+ .option('--json', 'Print JSON')
1490
+ .action(async (id: string, options: CapabilityPackOptions) => {
1491
+ try {
1492
+ const packed = await packCapability({
1493
+ id,
1494
+ cwd: process.cwd(),
1495
+ output: options.output,
1496
+ overwrite: options.force,
1497
+ })
1498
+ if (options.json) {
1499
+ console.log(JSON.stringify(packed, null, 2))
1500
+ return
1501
+ }
1502
+ console.log(`Packed ${packed.capabilityId}: ${packed.path}`)
1503
+ console.log(`Integrity: ${packed.integrity}`)
1504
+ console.log('Included files:')
1505
+ packed.files.map((file) => {
1506
+ console.log(`- ${file}`)
1507
+ return file
1508
+ })
1509
+ } catch (error) {
1510
+ exitWithError(error)
1511
+ }
1512
+ })
1513
+
1514
+ cli
1515
+ .command('capability install <source>', 'Install a built-in suite, capability directory, local .tgz, or .tgz URL')
1516
+ .option('--project', 'Install under .tabwright/capabilities in the current project')
1517
+ .option('--force', 'Overwrite existing installed capabilities')
1518
+ .option('--draft', 'Install a built-in suite as draft instead of trusted')
1519
+ .option('--skip-agent-skills', 'Do not install bundled agent skills such as Codex skills')
1520
+ .option('--with-agent-skill', 'Install a shared package agent skill after reviewing the source')
1521
+ .option('--json', 'Print JSON')
1522
+ .action(async (source: string, options: CapabilityInstallOptions) => {
1523
+ try {
1524
+ if (isBuiltinCapabilitySuite(source)) {
1525
+ const installed = installBuiltinCapabilitySuite({
1526
+ suite: source,
1527
+ cwd: process.cwd(),
1528
+ location: options.project ? 'project' : 'user',
1529
+ overwrite: options.force,
1530
+ trust: options.draft ? false : true,
1531
+ installAgentSkills: options.skipAgentSkills ? false : true,
1532
+ })
1533
+ const summary = {
1534
+ type: 'builtin',
1535
+ suite: installed.suite,
1536
+ capabilities: installed.capabilities.map((capability) => {
1537
+ return toCapabilitySummary(capability)
1538
+ }),
1539
+ agentSkills: installed.agentSkills,
1540
+ next: installed.capabilities.flatMap((capability) => {
1541
+ if (capability.manifest.auth.refresh !== 'from-browser') {
1542
+ return []
1543
+ }
1544
+ return [`tabwright capability refresh-auth ${capability.manifest.id} --browser user --json`]
1545
+ }),
1546
+ }
1547
+ if (options.json) {
1548
+ console.log(JSON.stringify(summary, null, 2))
1549
+ return
1550
+ }
1551
+ console.log(`Installed ${installed.suite}:`)
1552
+ installed.capabilities.map((capability) => {
1553
+ console.log(`- ${capability.manifest.id} (${capability.location}, ${capability.manifest.status})`)
1554
+ return capability.manifest.id
1555
+ })
1556
+ if (installed.agentSkills.length > 0) {
1557
+ console.log('')
1558
+ console.log('Installed agent skills:')
1559
+ installed.agentSkills.map((agentSkill) => {
1560
+ console.log(`- ${agentSkill.target}:${agentSkill.name} at ${agentSkill.dir}`)
1561
+ return agentSkill.name
1562
+ })
1563
+ }
1564
+ if (summary.next.length > 0) {
1565
+ console.log('')
1566
+ console.log('Refresh auth with:')
1567
+ summary.next.map((command) => {
1568
+ console.log(` ${command}`)
1569
+ return command
1570
+ })
1571
+ }
1572
+ return
1573
+ }
1574
+
1575
+ const installed = await installCapabilityPackage({
1576
+ source,
1577
+ cwd: process.cwd(),
1578
+ location: options.project ? 'project' : 'user',
1579
+ overwrite: options.force,
1580
+ })
1581
+ const agentSkill =
1582
+ installed.agentSkillAvailable && options.withAgentSkill
1583
+ ? installCapabilityAgentSkill({
1584
+ id: installed.capability.manifest.id,
1585
+ cwd: process.cwd(),
1586
+ overwrite: options.force,
1587
+ capability: installed.capability,
1588
+ })
1589
+ : null
1590
+ const next = [
1591
+ `tabwright capability describe ${installed.capability.manifest.id} --json`,
1592
+ ...(installed.agentSkillAvailable && !agentSkill
1593
+ ? [`Review the packaged agent skill, then install it with: tabwright capability skill install ${installed.capability.manifest.id}`]
1594
+ : []),
1595
+ ...(installed.capability.manifest.auth.refresh === 'from-browser'
1596
+ ? [`tabwright capability refresh-auth ${installed.capability.manifest.id} --browser user --json`]
1597
+ : []),
1598
+ `Validate with capability run --force before trusting ${installed.capability.manifest.id}.`,
1599
+ ]
1600
+ const summary = {
1601
+ type: 'package',
1602
+ source: installed.source,
1603
+ capability: toCapabilitySummary(installed.capability),
1604
+ files: installed.files,
1605
+ integrity: installed.integrity,
1606
+ agentSkill,
1607
+ next,
1608
+ }
1609
+ if (options.json) {
1610
+ console.log(JSON.stringify(summary, null, 2))
1611
+ return
1612
+ }
1613
+ console.log(`Installed ${installed.capability.manifest.id} as draft: ${installed.capability.dir}`)
1614
+ console.log(`Integrity: ${installed.integrity}`)
1615
+ if (agentSkill) {
1616
+ console.log(`Installed agent skill: ${agentSkill.dir}`)
1617
+ }
1618
+ console.log('')
1619
+ console.log('Next:')
1620
+ next.map((step) => {
1621
+ console.log(` ${step}`)
1622
+ return step
1623
+ })
1624
+ } catch (error) {
1625
+ exitWithError(error)
1626
+ }
1627
+ })
1628
+
1629
+ cli
1630
+ .command('capability update <id>', 'Update a capability script from a file')
1631
+ .option('--from-file <path>', 'Path to the new script.js source')
1632
+ .option('--contract-file <path>', 'Path to a JSON manifest contract patch')
1633
+ .option('--title <title>', 'Update title')
1634
+ .option('--description <description>', 'Update description')
1635
+ .option('--json', 'Print JSON')
1636
+ .action(
1637
+ (
1638
+ id: string,
1639
+ options: { fromFile?: string; contractFile?: string; title?: string; description?: string; json?: boolean },
1640
+ ) => {
1641
+ try {
1642
+ let capability: CapabilityRecord | null = null
1643
+ if (options.fromFile) {
1644
+ const sourcePath = path.resolve(options.fromFile)
1645
+ capability = updateCapabilityScript({ id, cwd: process.cwd(), source: fs.readFileSync(sourcePath, 'utf-8') })
1646
+ }
1647
+ if (options.contractFile) {
1648
+ const patch = readCapabilityManifestPatchFromFile(options.contractFile)
1649
+ const currentCapability =
1650
+ capability ||
1651
+ listCapabilities({ cwd: process.cwd() }).find((candidate) => {
1652
+ return candidate.manifest.id === id
1653
+ }) ||
1654
+ null
1655
+ if (currentCapability?.manifest.status === 'trusted' && !patch.status) {
1656
+ patch.status = 'draft'
1657
+ }
1658
+ capability = updateCapabilityManifest({ id, cwd: process.cwd(), patch })
1659
+ }
1660
+ if (options.title || options.description) {
1661
+ const patch: CapabilityManifestPatch = {}
1662
+ if (options.title) {
1663
+ patch.title = options.title
1664
+ }
1665
+ if (options.description) {
1666
+ patch.description = options.description
1667
+ }
1668
+ capability = updateCapabilityManifest({
1669
+ id,
1670
+ cwd: process.cwd(),
1671
+ patch,
1672
+ })
1673
+ }
1674
+ if (!capability) {
1675
+ throw new Error('Nothing to update. Pass --from-file, --contract-file, --title, or --description.')
1676
+ }
1677
+ if (options.json) {
1678
+ console.log(JSON.stringify(toCapabilitySummary(capability), null, 2))
1679
+ return
1680
+ }
1681
+ console.log(`Updated ${capability.manifest.id}. Status: ${capability.manifest.status}`)
1682
+ } catch (error) {
1683
+ exitWithError(error)
1684
+ }
1685
+ },
1686
+ )
1687
+
1688
+ cli
1689
+ .command('capability trust <id>', 'Mark a capability trusted')
1690
+ .action((id: string) => {
1691
+ try {
1692
+ const capability = updateCapabilityManifest({ id, cwd: process.cwd(), patch: { status: 'trusted' } })
1693
+ console.log(`Trusted ${capability.manifest.id}`)
1694
+ } catch (error) {
1695
+ exitWithError(error)
1696
+ }
1697
+ })
1698
+
1699
+ cli
1700
+ .command('capability draft <id>', 'Mark a capability draft')
1701
+ .action((id: string) => {
1702
+ try {
1703
+ const capability = updateCapabilityManifest({ id, cwd: process.cwd(), patch: { status: 'draft' } })
1704
+ console.log(`Marked ${capability.manifest.id} as draft`)
1705
+ } catch (error) {
1706
+ exitWithError(error)
1707
+ }
1708
+ })
1709
+
1710
+ cli
1711
+ .command('capability disable <id>', 'Disable a capability')
1712
+ .action((id: string) => {
1713
+ try {
1714
+ const capability = updateCapabilityManifest({ id, cwd: process.cwd(), patch: { status: 'disabled' } })
1715
+ console.log(`Disabled ${capability.manifest.id}`)
1716
+ } catch (error) {
1717
+ exitWithError(error)
1718
+ }
1719
+ })
1720
+
1721
+ cli
1722
+ .command('capability refresh-auth <id>', 'Refresh a capability auth secret from the current browser session')
1723
+ .option('-s, --session <id>', 'Existing Tabwright session id')
1724
+ .option('--host <host>', 'Remote relay server host')
1725
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
1726
+ .option('--browser <headless|user|key>', 'Runtime when --session is omitted (default: user)')
1727
+ .option('--keep-session', 'Keep auto-created session alive after refresh')
1728
+ .option('--json', 'Print JSON envelope')
1729
+ .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
1730
+ .action(async (id: string, options: CapabilityRefreshAuthOptions) => {
1731
+ try {
1732
+ await refreshCapabilityAuthFromCli(id, options)
1733
+ } catch (error) {
1734
+ exitWithError(error)
1735
+ }
1736
+ })
1737
+
1738
+ cli
1739
+ .command('capability run <id>', 'Run a Tabwright capability')
1740
+ .option('--input <json>', 'JSON input object')
1741
+ .option('--input-json <json>', 'JSON input object')
1742
+ .option('-s, --session <id>', 'Existing Tabwright session id')
1743
+ .option('--host <host>', 'Remote relay server host')
1744
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
1745
+ .option('--browser <headless|user|key>', 'Runtime when --session is omitted (default: headless)')
1746
+ .option('--force', 'Run draft capabilities or bypass URL match checks')
1747
+ .option('--confirm <capability-id>', 'Repeat the capability id after explicit user approval of its side effect')
1748
+ .option('--keep-session', 'Keep auto-created session alive after run')
1749
+ .option('--json', 'Print JSON envelope')
1750
+ .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
1751
+ .action(async (id: string, options: CapabilityRunOptions) => {
1752
+ try {
1753
+ await runCapabilityFromCli(id, options)
1754
+ } catch (error) {
1755
+ exitWithError(error)
1756
+ }
1757
+ })
1758
+
1759
+ cli
1760
+ .command('studio', 'Start the local Tabwright capability studio')
1761
+ .option('--host <host>', z.string().default('127.0.0.1').describe('Host to bind to'))
1762
+ .option('--port <port>', z.number().default(19989).describe('Port to bind to'))
1763
+ .option('--open', 'Open the studio URL in the default browser')
1764
+ .action(async (options: { host?: string; port?: number; open?: boolean }) => {
1765
+ try {
1766
+ const { startCapabilityStudio } = await import('./capability-studio.js')
1767
+ const server = await startCapabilityStudio({
1768
+ host: options.host || '127.0.0.1',
1769
+ port: options.port || 19989,
1770
+ cwd: process.cwd(),
1771
+ })
1772
+ const url = `http://${server.host}:${server.port}`
1773
+ console.log(`Tabwright capability studio: ${url}`)
1774
+ console.log('Press Ctrl+C to stop.')
1775
+ if (options.open) {
1776
+ await openInBrowser(url)
1777
+ }
1778
+ } catch (error) {
1779
+ exitWithError(error)
1780
+ }
1781
+ })
1782
+
1783
+ cli
1784
+ .command('session new', 'Create a new session and print the session ID')
1785
+ .option('--host <host>', 'Remote relay server host')
1786
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
1787
+ .option('--browser <key>', 'Browser key when multiple browsers are available. Special values: "headless" (launch headless Chrome, no extension), "cloud" (cloud browser with stealth/proxies)')
1788
+ .option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
1789
+ .option('--direct [endpoint]', 'Use direct CDP connection without the extension. Enable debugging first at chrome://inspect/#remote-debugging or launch Chrome with --remote-debugging-port=9222. Auto-discovers instances or accepts an explicit ws:// endpoint')
1790
+ .option('--proxy <region>', 'Enable residential proxy for cloud browser (e.g. us, de, jp). Disabled by default. Use for anti-detection or geo-targeting.')
1791
+ .option('--custom-proxy <url>', 'Custom proxy for cloud browser (host:port or user:pass@host:port)')
1792
+ .option('--timeout <minutes>', 'Cloud browser timeout in minutes (1-240, default 60)')
1793
+ .option('--disable-proxy-bandwidth-acceleration', 'Allow loading images, video, and fonts when proxy is enabled (they are blocked by default to save proxy bandwidth)')
1794
+ .action(async (options) => {
1795
+ if (options.patchright) {
1796
+ process.env.TABWRIGHT_PATCHRIGHT = '1'
1797
+ }
1798
+
1799
+ const isLocal = !options.host && !process.env.TABWRIGHT_HOST
1800
+
1801
+ // --browser headless: launch headless Chrome via chromium.launch(), no extension
1802
+ if (options.browser === 'headless') {
1803
+ try {
1804
+ await ensureRelayForSessionCreation(isLocal)
1805
+ const serverUrl = await getServerUrl(options.host)
1806
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
1807
+ method: 'POST',
1808
+ headers: buildAuthHeaders({ token: options.token, json: true }),
1809
+ body: JSON.stringify({ headless: true, cwd: process.cwd() }),
1810
+ })
1811
+ if (!response.ok) {
1812
+ const text = await response.text()
1813
+ if (text.includes('Could not find a supported browser binary')) {
1814
+ console.error('No Chrome browser found. Install one first:')
1815
+ console.error('')
1816
+ console.error(' tabwright browser install')
1817
+ console.error('')
1818
+ console.error('This downloads Chrome for Testing from Google.')
1819
+ process.exit(1)
1820
+ }
1821
+ console.error(`Error: ${response.status} ${text}`)
1822
+ process.exit(1)
1823
+ }
1824
+ const result = (await response.json()) as { id: string }
1825
+ console.log(`Session ${result.id} created (headless). Use with: tabwright -s ${result.id} -e "..."`)
1826
+ console.log(pc.dim('NOTE: Recording unavailable in headless mode.'))
1827
+ } catch (error: any) {
1828
+ if (error.message?.includes('Could not find a supported browser binary')) {
1829
+ console.error('No Chrome browser found. Install one first:')
1830
+ console.error('')
1831
+ console.error(' tabwright browser install')
1832
+ console.error('')
1833
+ console.error('This downloads Chrome for Testing from Google.')
1834
+ process.exit(1)
1835
+ }
1836
+ console.error(`Error: ${error.message}`)
1837
+ process.exit(1)
1838
+ }
1839
+ return
1840
+ }
1841
+ // goke 6.6: optional-value flags are string | undefined
1842
+ // `--direct ws://...` → 'ws://...' (explicit endpoint)
1843
+ // `--direct` → '' (bare flag, auto-discover)
1844
+ // (omitted) → undefined (don't use direct CDP)
1845
+ const directEndpoint = options.direct || null
1846
+
1847
+ // If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
1848
+ if (directEndpoint) {
1849
+ let cdpEndpoint: string
1850
+ try {
1851
+ cdpEndpoint = await resolveDirectInput(directEndpoint)
1852
+ } catch (error: any) {
1853
+ console.error(`Error: ${error.message}`)
1854
+ process.exit(1)
1855
+ }
1856
+ await ensureRelayForSessionCreation(isLocal)
1857
+ const serverUrl = await getServerUrl(options.host)
1858
+ const result = await createDirectSession({ serverUrl, cdpEndpoint, token: options.token })
1859
+ console.log(`Session ${result.id} created (direct CDP). Use with: tabwright -s ${result.id} -e "..."`)
1860
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
1861
+ return
1862
+ }
1863
+
1864
+ // If --direct with no endpoint, discover Chrome instances
1865
+ if (options.direct === '') {
1866
+ if (!isLocal) {
1867
+ console.error('Error: --direct auto-discovery only works locally.')
1868
+ console.error('For remote relay, pass an explicit endpoint reachable from the relay host:')
1869
+ console.error(' tabwright session new --host <host> --direct ws://relay-host:9222/devtools/browser/...')
1870
+ process.exit(1)
1871
+ }
1872
+ await ensureRelayForSessionCreation(isLocal)
1873
+ console.log(pc.dim('Discovering Chrome instances with debugging enabled...'))
1874
+ const instances = await discoverChromeInstances()
1875
+
1876
+ if (instances.length === 0) {
1877
+ console.error('No Chrome instances with debugging enabled found.')
1878
+ console.error('')
1879
+ console.error('Enable debugging in one of these ways:')
1880
+ console.error(' 1. Open chrome://inspect/#remote-debugging in Chrome')
1881
+ console.error(' 2. Launch Chrome with: chrome --remote-debugging-port=9222')
1882
+ process.exit(1)
1883
+ }
1884
+
1885
+ if (instances.length === 1 && !options.browser) {
1886
+ const instance = instances[0]
1887
+ const serverUrl = await getServerUrl(options.host)
1888
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles, token: options.token })
1889
+ const profileLabel = formatInstanceProfiles(instance)
1890
+ console.log(
1891
+ `Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: tabwright -s ${result.id} -e "..."`,
1892
+ )
1893
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
1894
+ return
1895
+ }
1896
+
1897
+ // Multiple instances or --browser specified
1898
+ const directOptions = instances.map((instance) => {
1899
+ return instanceToBrowserOption(instance)
1900
+ })
1901
+
1902
+ if (options.browser) {
1903
+ const selected = directOptions.find((opt) => {
1904
+ return opt.key === options.browser
1905
+ })
1906
+ if (!selected) {
1907
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false })
1908
+ console.error(`Browser not found: ${options.browser}`)
1909
+ console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '))
1910
+ process.exit(1)
1911
+ }
1912
+ const serverUrl = await getServerUrl(options.host)
1913
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
1914
+ console.log(`Session ${result.id} created (direct CDP). Use with: tabwright -s ${result.id} -e "..."`)
1915
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
1916
+ return
1917
+ }
1918
+
1919
+ printBrowserTable(directOptions)
1920
+ console.log('\nRun again with --browser <key>.')
1921
+ process.exit(1)
1922
+ }
1923
+
1924
+ // Default mode: extension-based (existing behavior)
1925
+ let extensions: ExtensionStatus[] = []
1926
+
1927
+ if (isLocal) {
1928
+ await ensureRelayServer({ logger: console })
1929
+ extensions = await waitForConnectedExtensions({
1930
+ timeoutMs: 12000,
1931
+ pollIntervalMs: 250,
1932
+ settleMs: 750,
1933
+ logger: console,
1934
+ })
1935
+
1936
+ if (extensions.length === 0) {
1937
+ console.log(pc.dim('Waiting briefly for extension to reconnect...'))
1938
+ extensions = await waitForConnectedExtensions({
1939
+ timeoutMs: 10000,
1940
+ pollIntervalMs: 250,
1941
+ settleMs: 750,
1942
+ logger: console,
1943
+ })
1944
+ }
1945
+ } else {
1946
+ extensions = await fetchExtensionsStatus({ host: options.host, token: options.token })
1947
+ }
1948
+
1949
+ if (extensions.length === 0) {
1950
+ // Before giving up, check if cloud browsers are available
1951
+ const cloudOptions = await discoverCloudBrowsers()
1952
+ if (cloudOptions.length > 0) {
1953
+ // Cloud-only user: skip extension requirement, show cloud options
1954
+ await ensureRelayForSessionCreation(isLocal)
1955
+ const allOptions: BrowserOption[] = [...cloudOptions]
1956
+
1957
+ if (options.browser) {
1958
+ const selected = allOptions.find((opt) => { return opt.key === options.browser })
1959
+ if (!selected) {
1960
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: true })
1961
+ console.error(`Browser not found: ${options.browser}`)
1962
+ console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
1963
+ process.exit(1)
1964
+ }
1965
+ const serverUrl = await getServerUrl(options.host)
1966
+ // Reuse existing running VM if selected, otherwise create new
1967
+ const result = selected.activeCloudSessionId
1968
+ ? await attachExistingCloudSession({
1969
+ serverUrl,
1970
+ cloudSessionId: selected.activeCloudSessionId,
1971
+ blockProxyResources: computeBlockProxyResources(options),
1972
+ token: options.token,
1973
+ })
1974
+ : await createCloudSession({
1975
+ serverUrl,
1976
+ proxyRegion: options.proxy,
1977
+ customProxy: options.customProxy,
1978
+ timeout: parseCloudTimeout(options.timeout),
1979
+ blockProxyResources: computeBlockProxyResources(options),
1980
+ token: options.token,
1981
+ })
1982
+ console.log(`Session ${result.id} created (cloud). Use with: tabwright -s ${result.id} -e "..."`)
1983
+ if (result.liveUrl) {
1984
+ console.log(pc.dim(`Live view: ${result.liveUrl}`))
1985
+ }
1986
+ return
1987
+ }
1988
+
1989
+ console.log('\nNo local browsers detected, but cloud browsers are available:\n')
1990
+ printBrowserTable(allOptions)
1991
+ console.log('\nRun again with --browser <key>.')
1992
+ process.exit(1)
1993
+ }
1994
+
1995
+ if (options.browser) {
1996
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false })
1997
+ }
1998
+ console.error('No connected browsers detected. Click the Tabwright extension icon.')
1999
+ console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'))
2000
+ console.error(pc.dim('Tip: Run `tabwright cloud login` to use cloud browsers.'))
2001
+ process.exit(1)
2002
+ }
2003
+
2004
+ // Warn if any connected extension was built with an older tabwright version
2005
+ for (const ext of extensions) {
2006
+ const warning = getExtensionOutdatedWarning(ext.playwriterVersion)
2007
+ if (warning) {
2008
+ console.error(warning)
2009
+ break
2010
+ }
2011
+ }
2012
+
2013
+ // Auto-select only when the extension choice is unambiguous. With multiple
2014
+ // profiles, a single extension with enabled tabs is the user's active choice.
2015
+ const implicitExtension = options.browser ? null : selectImplicitExtension(extensions)
2016
+ if (implicitExtension) {
2017
+ const selectedExtension = implicitExtension
2018
+ try {
2019
+ const serverUrl = await getServerUrl(options.host)
2020
+ const extensionId =
2021
+ selectedExtension.extensionId === 'default'
2022
+ ? null
2023
+ : selectedExtension.stableKey || selectedExtension.extensionId
2024
+ const cwd = process.cwd()
2025
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
2026
+ method: 'POST',
2027
+ headers: buildAuthHeaders({ token: options.token, json: true }),
2028
+ body: JSON.stringify({ extensionId, cwd }),
2029
+ })
2030
+ if (!response.ok) {
2031
+ const text = await response.text()
2032
+ console.error(`Error: ${response.status} ${text}`)
2033
+ process.exit(1)
2034
+ }
2035
+ const result = (await response.json()) as { id: string; extensionId: string | null }
2036
+ console.log(`Session ${result.id} created. Use with: tabwright -s ${result.id} -e "..."`)
2037
+ printCloudTip()
2038
+ } catch (error: any) {
2039
+ console.error(`Error: ${error.message}`)
2040
+ process.exit(1)
2041
+ }
2042
+ return
2043
+ }
2044
+
2045
+ // Multiple extensions: also discover direct CDP instances and cloud browsers.
2046
+ // Direct discovery only works locally — remote relay can't reach local Chrome debug ports.
2047
+ const directInstances = isLocal ? await (async () => {
2048
+ console.log(pc.dim('Discovering additional Chrome instances...'))
2049
+ return await discoverChromeInstances()
2050
+ })() : []
2051
+
2052
+ // Fetch cloud browser slots if user is logged in
2053
+ const cloudOptions = await discoverCloudBrowsers()
2054
+
2055
+ const allOptions: BrowserOption[] = [
2056
+ ...extensions.map((ext) => {
2057
+ return {
2058
+ key: ext.stableKey || ext.extensionId,
2059
+ type: 'extension' as const,
2060
+ browser: ext.browser || 'Chrome',
2061
+ profile: ext.profile?.email || '(not signed in)',
2062
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
2063
+ }
2064
+ }),
2065
+ ...directInstances.map((instance) => {
2066
+ return instanceToBrowserOption(instance)
2067
+ }),
2068
+ ...cloudOptions,
2069
+ ]
2070
+
2071
+ if (options.browser) {
2072
+ const selected = allOptions.find((opt) => {
2073
+ return opt.key === options.browser
2074
+ })
2075
+ if (!selected) {
2076
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: cloudOptions.length > 0 })
2077
+ console.error(`Browser not found: ${options.browser}`)
2078
+ console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
2079
+ process.exit(1)
2080
+ }
2081
+
2082
+ try {
2083
+ const serverUrl = await getServerUrl(options.host)
2084
+ if (selected.type === 'cloud') {
2085
+ // Reuse existing running VM if selected, otherwise create new
2086
+ const result = selected.activeCloudSessionId
2087
+ ? await attachExistingCloudSession({
2088
+ serverUrl,
2089
+ cloudSessionId: selected.activeCloudSessionId,
2090
+ blockProxyResources: computeBlockProxyResources(options),
2091
+ token: options.token,
2092
+ })
2093
+ : await createCloudSession({
2094
+ serverUrl,
2095
+ proxyRegion: options.proxy,
2096
+ customProxy: options.customProxy,
2097
+ timeout: parseCloudTimeout(options.timeout),
2098
+ blockProxyResources: computeBlockProxyResources(options),
2099
+ token: options.token,
2100
+ })
2101
+ console.log(`Session ${result.id} created (cloud). Use with: tabwright -s ${result.id} -e "..."`)
2102
+ if (result.liveUrl) {
2103
+ console.log(pc.dim(`Live view: ${result.liveUrl}`))
2104
+ }
2105
+ } else if (selected.type === 'direct') {
2106
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
2107
+ console.log(`Session ${result.id} created (direct CDP). Use with: tabwright -s ${result.id} -e "..."`)
2108
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
2109
+ } else {
2110
+ const cwd = process.cwd()
2111
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
2112
+ method: 'POST',
2113
+ headers: buildAuthHeaders({ token: options.token, json: true }),
2114
+ body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
2115
+ })
2116
+ if (!response.ok) {
2117
+ const text = await response.text()
2118
+ console.error(`Error: ${response.status} ${text}`)
2119
+ process.exit(1)
2120
+ }
2121
+ const result = (await response.json()) as { id: string }
2122
+ console.log(`Session ${result.id} created. Use with: tabwright -s ${result.id} -e "..."`)
2123
+ printCloudTip()
2124
+ }
2125
+ } catch (error: any) {
2126
+ console.error(`Error: ${error.message}`)
2127
+ process.exit(1)
2128
+ }
2129
+ return
2130
+ }
2131
+
2132
+ // Show unified table
2133
+ console.log('\nMultiple browsers detected:\n')
2134
+ printBrowserTable(allOptions)
2135
+ console.log('\nRun again with --browser <key>.')
2136
+ process.exit(1)
2137
+ })
2138
+
2139
+ async function ensureRelayForSessionCreation(isLocal: boolean): Promise<void> {
2140
+ if (isLocal) {
2141
+ await ensureRelayServer({ logger: console })
2142
+ }
2143
+ }
2144
+
2145
+ async function createDirectSession({
2146
+ serverUrl,
2147
+ cdpEndpoint,
2148
+ browser,
2149
+ profiles,
2150
+ token,
2151
+ }: {
2152
+ serverUrl: string
2153
+ cdpEndpoint: string
2154
+ browser?: string
2155
+ profiles?: Array<{ name: string; email: string }>
2156
+ token?: string
2157
+ }): Promise<{ id: string }> {
2158
+ const cwd = process.cwd()
2159
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
2160
+ method: 'POST',
2161
+ headers: buildAuthHeaders({ token, json: true }),
2162
+ body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
2163
+ })
2164
+ if (!response.ok) {
2165
+ const text = await response.text()
2166
+ throw new Error(`${response.status} ${text}`)
2167
+ }
2168
+ return (await response.json()) as { id: string }
2169
+ }
2170
+
2171
+ function instanceToBrowserOption(instance: DiscoveredInstance): BrowserOption {
2172
+ return {
2173
+ key: `direct:${instance.port}`,
2174
+ type: 'direct',
2175
+ browser: instance.browser,
2176
+ profile: formatInstanceProfiles(instance),
2177
+ wsUrl: instance.wsUrl,
2178
+ profiles: instance.profiles,
2179
+ }
2180
+ }
2181
+
2182
+ function formatInstanceProfiles(instance: DiscoveredInstance): string {
2183
+ if (instance.profiles.length === 0) {
2184
+ return '(unknown)'
2185
+ }
2186
+ return instance.profiles
2187
+ .map((p) => {
2188
+ return p.email ? `${p.name} (${p.email})` : p.name
2189
+ })
2190
+ .join(', ')
2191
+ }
2192
+
2193
+ /** Discover cloud sessions from the website API, if logged in.
2194
+ * Also adds a "cloud-new" option to create a new cloud browser. */
2195
+ async function discoverCloudBrowsers(): Promise<BrowserOption[]> {
2196
+ const client = getCloudClient()
2197
+ if (!client) return []
2198
+
2199
+ try {
2200
+ const { sessions } = await client.getStatus()
2201
+ const options: BrowserOption[] = sessions.map((s) => {
2202
+ return {
2203
+ key: `cloud-${s.index}`,
2204
+ type: 'cloud' as const,
2205
+ browser: 'Chromium',
2206
+ profile: `(running, expires ${new Date(s.timeoutAt).toLocaleTimeString()})`,
2207
+ activeCloudSessionId: s.cloudSessionId,
2208
+ }
2209
+ })
2210
+ // Always offer a "cloud-new" option to spin up a fresh VM
2211
+ options.push({
2212
+ key: 'cloud',
2213
+ type: 'cloud' as const,
2214
+ browser: 'Chromium',
2215
+ profile: '(new cloud browser)',
2216
+ })
2217
+ return options
2218
+ } catch (error) {
2219
+ const msg = error instanceof Error ? error.message : String(error)
2220
+ console.error(pc.dim(`Cloud browser discovery failed: ${msg}`))
2221
+ return []
2222
+ }
2223
+ }
2224
+
2225
+ /** Compute whether to block images/video/fonts for proxy bandwidth savings.
2226
+ * Enabled by default when proxy or custom-proxy is set, disabled via
2227
+ * --disable-proxy-bandwidth-acceleration. */
2228
+ function computeBlockProxyResources(options: { proxy?: string; customProxy?: string; disableProxyBandwidthAcceleration?: boolean }): boolean | undefined {
2229
+ const proxyEnabled = !!(options.proxy || options.customProxy)
2230
+ if (!proxyEnabled) return undefined // no proxy, no blocking needed
2231
+ if (options.disableProxyBandwidthAcceleration) return false
2232
+ return true
2233
+ }
2234
+
2235
+ /** Check if user requested a cloud browser that isn't available.
2236
+ * Shows helpful login/subscribe instructions instead of a generic "not found" error.
2237
+ * @param hasCloudOptions whether any cloud options were discovered (to distinguish
2238
+ * "not logged in" from "typo in cloud key") */
2239
+ async function handleCloudBrowserNotFound(browserKey: string, { hasCloudOptions }: { hasCloudOptions: boolean }): Promise<boolean> {
2240
+ if (!browserKey.startsWith('cloud')) return false
2241
+ // If cloud options exist, this is a typo (e.g. cloud-99) — let the
2242
+ // generic "Browser not found" message show the available list instead.
2243
+ if (hasCloudOptions) return false
2244
+ const auth = loadCloudAuth()
2245
+ if (!auth) {
2246
+ console.error('Cloud browsers require authentication.')
2247
+ console.error('')
2248
+ console.error(' Option 1: Run `tabwright cloud login` (interactive browser flow)')
2249
+ console.error(' Option 2: Set TABWRIGHT_API_KEY env var (create one at playwriter.dev/dashboard)')
2250
+ console.error('')
2251
+ console.error(' Then subscribe at playwriter.dev/dashboard and run `tabwright session new --browser cloud`')
2252
+ } else {
2253
+ // Verify token is still valid with a quick API check
2254
+ const client = getCloudClient()
2255
+ const tokenValid = await (async () => {
2256
+ if (!client) return false
2257
+ try {
2258
+ await client.getStatus()
2259
+ return true
2260
+ } catch {
2261
+ return false
2262
+ }
2263
+ })()
2264
+
2265
+ if (!tokenValid) {
2266
+ console.error('Cloud authentication expired. Please re-authenticate.')
2267
+ console.error('')
2268
+ console.error(' Run `tabwright cloud login` or set TABWRIGHT_API_KEY env var.')
2269
+ } else {
2270
+ console.error('No cloud browser sessions available.')
2271
+ console.error('')
2272
+ console.error(' You are logged in, but you may need an active subscription.')
2273
+ console.error(' Run `tabwright cloud subscribe` to manage your plan.')
2274
+ console.error(' Then run `tabwright session new --browser cloud` to start a cloud browser.')
2275
+ }
2276
+ }
2277
+ process.exit(1)
2278
+ }
2279
+
2280
+ function printCloudTip(): void {
2281
+ console.log('')
2282
+ console.log(
2283
+ pc.dim('Tip: Need stealth browsing, VPS control, or auto CAPTCHA solving? Run `tabwright cloud login` or set TABWRIGHT_API_KEY'),
2284
+ )
2285
+ console.log(
2286
+ pc.dim(' to control a browser in the cloud instead of local Chrome.'),
2287
+ )
2288
+ }
2289
+
2290
+ /** Parse a custom proxy string (host:port or user:pass@host:port) into an object. */
2291
+ function parseCustomProxy(proxyStr: string): { host: string; port: number; username?: string; password?: string } {
2292
+ // Format: [user:pass@]host:port
2293
+ const atIdx = proxyStr.lastIndexOf('@')
2294
+ let hostPort: string
2295
+ let username: string | undefined
2296
+ let password: string | undefined
2297
+
2298
+ if (atIdx !== -1) {
2299
+ const userPass = proxyStr.slice(0, atIdx)
2300
+ hostPort = proxyStr.slice(atIdx + 1)
2301
+ const colonIdx = userPass.indexOf(':')
2302
+ if (colonIdx !== -1) {
2303
+ username = userPass.slice(0, colonIdx)
2304
+ password = userPass.slice(colonIdx + 1)
2305
+ } else {
2306
+ username = userPass
2307
+ }
2308
+ } else {
2309
+ hostPort = proxyStr
2310
+ }
2311
+
2312
+ const lastColon = hostPort.lastIndexOf(':')
2313
+ if (lastColon === -1) {
2314
+ throw new Error(`Invalid proxy format: missing port in "${proxyStr}". Expected host:port or user:pass@host:port`)
2315
+ }
2316
+ const host = hostPort.slice(0, lastColon)
2317
+ const port = parseInt(hostPort.slice(lastColon + 1), 10)
2318
+ if (isNaN(port)) {
2319
+ throw new Error(`Invalid proxy port in "${proxyStr}"`)
2320
+ }
2321
+
2322
+ return { host, port, username, password }
2323
+ }
2324
+
2325
+ /** Parse and validate the --timeout CLI option (integer 1-240). */
2326
+ function parseCloudTimeout(value: string | undefined): number | undefined {
2327
+ if (value === undefined) return undefined
2328
+ if (!/^\d+$/.test(value)) {
2329
+ throw new Error('--timeout must be an integer from 1 to 240')
2330
+ }
2331
+ const timeout = Number(value)
2332
+ if (timeout < 1 || timeout > 240) {
2333
+ throw new Error('--timeout must be between 1 and 240 minutes')
2334
+ }
2335
+ return timeout
2336
+ }
2337
+
2338
+ /** Connect to a cloud browser and create a tabwright session via the relay. */
2339
+ async function createCloudSession({
2340
+ serverUrl,
2341
+ proxyRegion,
2342
+ customProxy,
2343
+ timeout,
2344
+ blockProxyResources,
2345
+ token,
2346
+ }: {
2347
+ serverUrl: string
2348
+ proxyRegion?: string
2349
+ customProxy?: string
2350
+ /** Cloud browser timeout in minutes (1-240, default 60) */
2351
+ timeout?: number
2352
+ /** Block images/video/fonts to save proxy bandwidth (default: true when proxy is enabled) */
2353
+ blockProxyResources?: boolean
2354
+ token?: string
2355
+ }): Promise<{ id: string; liveUrl: string | null }> {
2356
+ const client = getCloudClient()
2357
+ if (!client) {
2358
+ throw new Error('Not logged in to cloud. Run `tabwright cloud login` first.')
2359
+ }
2360
+
2361
+ const connectResult = await client.connect({
2362
+ proxyRegion,
2363
+ customProxy: customProxy ? parseCustomProxy(customProxy) : undefined,
2364
+ timeout,
2365
+ })
2366
+
2367
+ if (!connectResult.cdpUrl) {
2368
+ throw new Error('Cloud browser returned no CDP URL. The VM may have failed to start.')
2369
+ }
2370
+
2371
+ // Normalize https:// CDP URL to wss:// for the relay
2372
+ const cdpEndpoint = await resolveDirectInput(connectResult.cdpUrl)
2373
+
2374
+ // Create a tabwright session via the relay using the CDP URL (same as --direct).
2375
+ // Also pass cloud metadata so the relay can track idle timeout and auto-disconnect.
2376
+ const auth = loadCloudAuth()!
2377
+ const cwd = process.cwd()
2378
+ let response: Response
2379
+ try {
2380
+ response = await fetch(`${serverUrl}/cli/session/new`, {
2381
+ method: 'POST',
2382
+ headers: buildAuthHeaders({ token, json: true }),
2383
+ body: JSON.stringify({
2384
+ cdpEndpoint,
2385
+ cwd,
2386
+ browser: 'Chromium (cloud)',
2387
+ cloud: {
2388
+ cloudSessionId: connectResult.cloudSessionId,
2389
+ cloudBaseUrl: auth.baseUrl,
2390
+ cloudToken: auth.token,
2391
+ timeoutAt: connectResult.timeoutAt,
2392
+ blockProxyResources,
2393
+ },
2394
+ }),
2395
+ })
2396
+ } catch (cause) {
2397
+ // Relay session creation failed — stop the cloud VM so we don't leak a paid resource
2398
+ await client.disconnect(connectResult.cloudSessionId).catch(() => {})
2399
+ throw new Error('Failed to create relay session', { cause })
2400
+ }
2401
+
2402
+ if (!response.ok) {
2403
+ await client.disconnect(connectResult.cloudSessionId).catch(() => {})
2404
+ const text = await response.text()
2405
+ throw new Error(`${response.status} ${text}`)
2406
+ }
2407
+ const result = (await response.json()) as { id: string }
2408
+
2409
+ return { id: result.id, liveUrl: connectResult.cdpUrl ? buildLiveUrl(connectResult.cdpUrl, auth.baseUrl) : null }
2410
+ }
2411
+
2412
+ /** Reattach to an existing running cloud browser VM instead of creating a new one.
2413
+ * Fetches the session's cdpUrl from the cloud API and creates a relay session. */
2414
+ async function attachExistingCloudSession({
2415
+ serverUrl,
2416
+ cloudSessionId,
2417
+ blockProxyResources,
2418
+ token,
2419
+ }: {
2420
+ serverUrl: string
2421
+ cloudSessionId: string
2422
+ blockProxyResources?: boolean
2423
+ token?: string
2424
+ }): Promise<{ id: string; liveUrl: string | null }> {
2425
+ const client = getCloudClient()
2426
+ if (!client) {
2427
+ throw new Error('Not logged in to cloud. Run `tabwright cloud login` first.')
2428
+ }
2429
+
2430
+ const session = await client.getSessionStatus(cloudSessionId)
2431
+ if (!session || session.status !== 'active') {
2432
+ throw new Error('Cloud session is no longer active. It may have timed out.')
2433
+ }
2434
+ if (!session.cdpUrl) {
2435
+ throw new Error('Cloud session has no CDP URL available.')
2436
+ }
2437
+
2438
+ const cdpEndpoint = await resolveDirectInput(session.cdpUrl)
2439
+ const auth = loadCloudAuth()!
2440
+ const cwd = process.cwd()
2441
+
2442
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
2443
+ method: 'POST',
2444
+ headers: buildAuthHeaders({ token, json: true }),
2445
+ body: JSON.stringify({
2446
+ cdpEndpoint,
2447
+ cwd,
2448
+ browser: 'Chromium (cloud)',
2449
+ cloud: {
2450
+ cloudSessionId,
2451
+ cloudBaseUrl: auth.baseUrl,
2452
+ cloudToken: auth.token,
2453
+ timeoutAt: session.timeoutAt,
2454
+ blockProxyResources,
2455
+ },
2456
+ }),
2457
+ })
2458
+
2459
+ if (!response.ok) {
2460
+ const text = await response.text()
2461
+ throw new Error(`${response.status} ${text}`)
2462
+ }
2463
+ const result = (await response.json()) as { id: string }
2464
+
2465
+ return { id: result.id, liveUrl: session.cdpUrl ? buildLiveUrl(session.cdpUrl, auth.baseUrl) : null }
2466
+ }
2467
+
2468
+ function printBrowserTable(options: BrowserOption[]): void {
2469
+ const typeLabels = options.map((opt) => {
2470
+ if (opt.type === 'direct') return '--direct'
2471
+ if (opt.type === 'cloud') return 'cloud'
2472
+ return opt.type
2473
+ })
2474
+ const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length))
2475
+ const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length))
2476
+ const browserWidth = Math.max(7, ...options.map((opt) => opt.browser.length))
2477
+
2478
+ console.log(
2479
+ 'KEY'.padEnd(keyWidth) + ' ' + 'TYPE'.padEnd(typeWidth) + ' ' + 'BROWSER'.padEnd(browserWidth) + ' ' + 'PROFILE',
2480
+ )
2481
+ console.log('-'.repeat(keyWidth + typeWidth + browserWidth + 20))
2482
+ for (let i = 0; i < options.length; i++) {
2483
+ const opt = options[i]
2484
+ console.log(
2485
+ opt.key.padEnd(keyWidth) +
2486
+ ' ' +
2487
+ typeLabels[i].padEnd(typeWidth) +
2488
+ ' ' +
2489
+ opt.browser.padEnd(browserWidth) +
2490
+ ' ' +
2491
+ opt.profile,
2492
+ )
2493
+ }
2494
+ }
2495
+
2496
+ cli
2497
+ .command('doctor', 'Check Tabwright readiness and print the single best next step')
2498
+ .option('--host <host>', 'Remote relay server host')
2499
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
2500
+ .option('--json', 'Print a machine-readable health report')
2501
+ .action(async (options) => {
2502
+ const isRemote = Boolean(options.host || process.env.TABWRIGHT_HOST)
2503
+ const relayStartup = isRemote
2504
+ ? { started: false, error: null }
2505
+ : await ensureRelayServer({ logger: options.json ? undefined : console })
2506
+ .then((started) => {
2507
+ return { started: started === true, error: null }
2508
+ })
2509
+ .catch((error: unknown) => {
2510
+ return {
2511
+ started: false,
2512
+ error: error instanceof Error ? error.message : String(error),
2513
+ }
2514
+ })
2515
+
2516
+ const serverUrl = await getServerUrl(options.host)
2517
+ const headers = buildAuthHeaders({ token: options.token })
2518
+ const [relayVersion, relayFeatures, initialExtensions, sessions] = await Promise.all([
2519
+ isRemote
2520
+ ? fetch(`${serverUrl}/version`, { headers, signal: AbortSignal.timeout(2000) })
2521
+ .then(async (response) => {
2522
+ if (!response.ok) {
2523
+ return null
2524
+ }
2525
+ const result = (await response.json()) as { version?: string }
2526
+ return result.version || null
2527
+ })
2528
+ .catch(() => {
2529
+ return null
2530
+ })
2531
+ : getRelayServerVersion(RELAY_PORT),
2532
+ isRemote
2533
+ ? fetch(`${serverUrl}/features`, { headers, signal: AbortSignal.timeout(2000) })
2534
+ .then(async (response) => {
2535
+ if (!response.ok) {
2536
+ return null
2537
+ }
2538
+ const result: unknown = await response.json()
2539
+ if (!result || typeof result !== 'object') {
2540
+ return null
2541
+ }
2542
+ const features = (result as { features?: unknown }).features
2543
+ return Array.isArray(features) && features.every((feature) => typeof feature === 'string')
2544
+ ? features
2545
+ : null
2546
+ })
2547
+ .catch(() => {
2548
+ return null
2549
+ })
2550
+ : getRelayServerFeatures(RELAY_PORT),
2551
+ isRemote ? fetchExtensionsStatus({ host: options.host, token: options.token }) : getExtensionsStatus(RELAY_PORT),
2552
+ fetch(`${serverUrl}/cli/sessions`, { headers, signal: AbortSignal.timeout(2000) })
2553
+ .then(async (response) => {
2554
+ if (!response.ok) {
2555
+ return []
2556
+ }
2557
+ const result = (await response.json()) as { sessions?: DoctorSession[] }
2558
+ return result.sessions || []
2559
+ })
2560
+ .catch(() => {
2561
+ return []
2562
+ }),
2563
+ ])
2564
+ const extensions =
2565
+ relayStartup.started && initialExtensions.length === 0
2566
+ ? await waitForConnectedExtensions({
2567
+ timeoutMs: 4000,
2568
+ pollIntervalMs: 200,
2569
+ logger: options.json ? undefined : console,
2570
+ })
2571
+ : initialExtensions
2572
+
2573
+ const report = buildDoctorReport({
2574
+ version: VERSION,
2575
+ cwd: process.cwd(),
2576
+ remote: isRemote,
2577
+ relayVersion,
2578
+ relayFeatures,
2579
+ relayError: relayStartup.error,
2580
+ extensions,
2581
+ sessions,
2582
+ capabilityCount: listCapabilities({ cwd: process.cwd() }).length,
2583
+ skillStatus: getTabwrightAgentSkillStatus(),
2584
+ })
2585
+
2586
+ if (options.json) {
2587
+ console.log(JSON.stringify(report, null, 2))
2588
+ return
2589
+ }
2590
+ console.log(formatDoctorReport(report))
2591
+ })
2592
+
2593
+ cli
2594
+ .command('session list', 'List all active sessions')
2595
+ .option('--host <host>', 'Remote relay server host')
2596
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
2597
+ .action(async (options) => {
2598
+ if (!options.host && !process.env.TABWRIGHT_HOST) {
2599
+ await ensureRelayServer({ logger: console })
2600
+ }
2601
+
2602
+ const serverUrl = await getServerUrl(options.host)
2603
+ let sessions: Array<{
2604
+ id: string
2605
+ stateKeys: string[]
2606
+ browser: string | null
2607
+ profile: { email: string; id: string } | null
2608
+ extensionId: string | null
2609
+ cwd: string | null
2610
+ }> = []
2611
+
2612
+ try {
2613
+ const response = await fetch(`${serverUrl}/cli/sessions`, {
2614
+ headers: buildAuthHeaders({ token: options.token }),
2615
+ signal: AbortSignal.timeout(2000),
2616
+ })
2617
+ if (!response.ok) {
2618
+ console.error(`Error: ${response.status} ${await response.text()}`)
2619
+ process.exit(1)
2620
+ }
2621
+ const result = (await response.json()) as {
2622
+ sessions: Array<{
2623
+ id: string
2624
+ stateKeys: string[]
2625
+ browser: string | null
2626
+ profile: { email: string; id: string } | null
2627
+ extensionId: string | null
2628
+ cwd: string | null
2629
+ }>
2630
+ }
2631
+ sessions = result.sessions
2632
+ } catch (error: any) {
2633
+ console.error(`Error: ${error.message}`)
2634
+ process.exit(1)
2635
+ }
2636
+
2637
+ if (sessions.length === 0) {
2638
+ console.log('No active sessions')
2639
+ return
2640
+ }
2641
+
2642
+ const idWidth = Math.max(2, ...sessions.map((session) => String(session.id).length))
2643
+ const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length))
2644
+ const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1))
2645
+ const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1))
2646
+ const cwdWidth = Math.max(3, ...sessions.map((session) => (session.cwd || '').length || 1))
2647
+ const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1))
2648
+
2649
+ console.log(
2650
+ 'ID'.padEnd(idWidth) +
2651
+ ' ' +
2652
+ 'BROWSER'.padEnd(browserWidth) +
2653
+ ' ' +
2654
+ 'PROFILE'.padEnd(profileWidth) +
2655
+ ' ' +
2656
+ 'EXT'.padEnd(extensionWidth) +
2657
+ ' ' +
2658
+ 'CWD'.padEnd(cwdWidth) +
2659
+ ' ' +
2660
+ 'STATE KEYS',
2661
+ )
2662
+ console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + cwdWidth + stateWidth + 10))
2663
+
2664
+ for (const session of sessions) {
2665
+ const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'
2666
+ const profileLabel = session.profile?.email || '-'
2667
+ const cwdLabel = session.cwd || '-'
2668
+ console.log(
2669
+ String(session.id).padEnd(idWidth) +
2670
+ ' ' +
2671
+ (session.browser || 'Chrome').padEnd(browserWidth) +
2672
+ ' ' +
2673
+ profileLabel.padEnd(profileWidth) +
2674
+ ' ' +
2675
+ (session.extensionId || '-').padEnd(extensionWidth) +
2676
+ ' ' +
2677
+ cwdLabel.padEnd(cwdWidth) +
2678
+ ' ' +
2679
+ stateStr,
2680
+ )
2681
+ }
2682
+ })
2683
+
2684
+ cli
2685
+ .command('session delete <sessionId>', 'Delete a session and clear its state')
2686
+ .option('--host <host>', 'Remote relay server host')
2687
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
2688
+ .action(async (sessionId, options) => {
2689
+ const serverUrl = await getServerUrl(options.host)
2690
+
2691
+ if (!options.host && !process.env.TABWRIGHT_HOST) {
2692
+ await ensureRelayServer({ logger: console })
2693
+ }
2694
+
2695
+ try {
2696
+ const response = await fetch(`${serverUrl}/cli/session/delete`, {
2697
+ method: 'POST',
2698
+ headers: buildAuthHeaders({ token: options.token, json: true }),
2699
+ body: JSON.stringify({ sessionId }),
2700
+ })
2701
+
2702
+ if (!response.ok) {
2703
+ const result = (await response.json()) as { error: string }
2704
+ console.error(`Error: ${result.error}`)
2705
+ process.exit(1)
2706
+ }
2707
+
2708
+ console.log(`Session ${sessionId} deleted.`)
2709
+ } catch (error: any) {
2710
+ console.error(`Error: ${error.message}`)
2711
+ process.exit(1)
2712
+ }
2713
+ })
2714
+
2715
+ cli
2716
+ .command('session reset <sessionId>', 'Reset the browser connection for a session')
2717
+ .option('--host <host>', 'Remote relay server host')
2718
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
2719
+ .action(async (sessionId, options) => {
2720
+ const cwd = process.cwd()
2721
+ const serverUrl = await getServerUrl(options.host)
2722
+
2723
+ if (!options.host && !process.env.TABWRIGHT_HOST) {
2724
+ await ensureRelayServer({ logger: console })
2725
+ }
2726
+
2727
+ try {
2728
+ const response = await fetch(`${serverUrl}/cli/reset`, {
2729
+ method: 'POST',
2730
+ headers: buildAuthHeaders({ token: options.token, json: true }),
2731
+ body: JSON.stringify({ sessionId, cwd }),
2732
+ })
2733
+
2734
+ if (!response.ok) {
2735
+ const text = await response.text()
2736
+ console.error(`Error: ${response.status} ${text}`)
2737
+ process.exit(1)
2738
+ }
2739
+
2740
+ const result = (await response.json()) as { success: boolean; pageUrl: string; pagesCount: number }
2741
+ console.log(
2742
+ `Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`,
2743
+ )
2744
+ } catch (error: any) {
2745
+ console.error(`Error: ${error.message}`)
2746
+ process.exit(1)
2747
+ }
2748
+ })
2749
+
2750
+ cli
2751
+ .command(
2752
+ 'serve',
2753
+ `Start the relay server on this machine (must be the same host where Chrome is running). Remote clients (Docker, other machines) connect via TABWRIGHT_HOST. Use --host localhost for Docker (no token needed) — containers reach it via host.docker.internal. Use --host 0.0.0.0 for LAN/internet access (requires --token).`,
2754
+ )
2755
+ .option('--host [host]', z.string().default('0.0.0.0').describe('Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)'))
2756
+ .option('--token <token>', 'Authentication token, required when --host is 0.0.0.0 (or use TABWRIGHT_TOKEN env var)')
2757
+ .option('--replace', 'Kill existing server if running')
2758
+ .action(async (options) => {
2759
+ const token = options.token || process.env.TABWRIGHT_TOKEN
2760
+ const isPublicHost = options.host === '0.0.0.0' || options.host === '::'
2761
+ if (isPublicHost && !token) {
2762
+ console.error('Error: Authentication token is required when binding to a public host.')
2763
+ console.error('Provide --token <token> or set TABWRIGHT_TOKEN environment variable.')
2764
+ process.exit(1)
2765
+ }
2766
+
2767
+ // Expose the token to in-process callers so
2768
+ // they can attach Authorization: Bearer ... when calling the relay's own
2769
+ // privileged endpoints. Required because we no longer bypass auth for
2770
+ // loopback — see commit history for the tunnel-agent threat model.
2771
+ if (token) {
2772
+ process.env.TABWRIGHT_TOKEN = token
2773
+ }
2774
+
2775
+ // Check if server is already running on the port
2776
+ const net = await import('node:net')
2777
+ const isPortInUse = await new Promise<boolean>((resolve) => {
2778
+ const socket = new net.Socket()
2779
+ socket.setTimeout(500)
2780
+ socket.on('connect', () => {
2781
+ socket.destroy()
2782
+ resolve(true)
2783
+ })
2784
+ socket.on('timeout', () => {
2785
+ socket.destroy()
2786
+ resolve(false)
2787
+ })
2788
+ socket.on('error', () => {
2789
+ resolve(false)
2790
+ })
2791
+ socket.connect(RELAY_PORT, '127.0.0.1')
2792
+ })
2793
+
2794
+ if (isPortInUse) {
2795
+ if (!options.replace) {
2796
+ console.log(`Tabwright server is already running on port ${RELAY_PORT}`)
2797
+ console.log('Tip: Use --replace to kill the existing server and start a new one.')
2798
+ process.exit(0)
2799
+ }
2800
+
2801
+ // Kill existing process on the port
2802
+ console.log(`Killing existing server on port ${RELAY_PORT}...`)
2803
+ await killPortProcess({ port: RELAY_PORT })
2804
+ }
2805
+
2806
+ // Lazy-load heavy dependencies only when serve command is used
2807
+ const { createFileLogger } = await import('./create-logger.js')
2808
+ const { startTabwrightCDPRelayServer } = await import('./cdp-relay.js')
2809
+
2810
+ const logger = createFileLogger()
2811
+
2812
+ process.title = 'tabwright-serve'
2813
+
2814
+ process.on('uncaughtException', async (err) => {
2815
+ await logger.error('Uncaught Exception:', err)
2816
+ process.exit(1)
2817
+ })
2818
+
2819
+ process.on('unhandledRejection', async (reason) => {
2820
+ await logger.error('Unhandled Rejection:', reason)
2821
+ process.exit(1)
2822
+ })
2823
+
2824
+ const server = await startTabwrightCDPRelayServer({
2825
+ port: RELAY_PORT,
2826
+ host: options.host,
2827
+ token,
2828
+ logger,
2829
+ })
2830
+
2831
+ console.log('Tabwright CDP relay server started')
2832
+ console.log(` Host: ${options.host}`)
2833
+ console.log(` Port: ${RELAY_PORT}`)
2834
+ console.log(` Token: ${token ? '(configured)' : '(none)'}`)
2835
+ console.log(` Logs: ${logger.logFilePath}`)
2836
+ console.log(` CDP Logs: ${LOG_CDP_FILE_PATH}`)
2837
+ console.log('')
2838
+ console.log(`CDP endpoint: http://${options.host}:${RELAY_PORT}${token ? '?token=<token>' : ''}`)
2839
+ console.log('')
2840
+ console.log('Press Ctrl+C to stop.')
2841
+
2842
+ process.on('SIGINT', () => {
2843
+ console.log('\nShutting down...')
2844
+ server.close()
2845
+ process.exit(0)
2846
+ })
2847
+
2848
+ process.on('SIGTERM', () => {
2849
+ console.log('\nShutting down...')
2850
+ server.close()
2851
+ process.exit(0)
2852
+ })
2853
+ })
2854
+
2855
+ cli
2856
+ .command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
2857
+ .option('--host <host>', z.string().describe('Remote relay server host'))
2858
+ .option('--token <token>', 'Authentication token (or use TABWRIGHT_TOKEN env var)')
2859
+ .action(async (options) => {
2860
+ const isLocal = !options.host && !process.env.TABWRIGHT_HOST
2861
+
2862
+ // Start relay if local so the extension can connect, then fetch in parallel
2863
+ if (isLocal) {
2864
+ await ensureRelayServer({ logger: console })
2865
+ }
2866
+
2867
+ const [extensions, directInstances] = await Promise.all([
2868
+ isLocal
2869
+ ? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
2870
+ : fetchExtensionsStatus({ host: options.host, token: options.token }),
2871
+ isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
2872
+ ])
2873
+
2874
+ const cloudOptions = await discoverCloudBrowsers()
2875
+
2876
+ // Check if a Chrome binary is available for headless mode
2877
+ const headlessOption: BrowserOption[] = await (async () => {
2878
+ try {
2879
+ const { resolveBrowserExecutablePath } = await import('./browser-config.js')
2880
+ resolveBrowserExecutablePath()
2881
+ return [{
2882
+ key: 'headless',
2883
+ type: 'headless' as const,
2884
+ browser: 'Chrome (Headless)',
2885
+ profile: '-',
2886
+ }]
2887
+ } catch {
2888
+ return []
2889
+ }
2890
+ })()
2891
+
2892
+ const allOptions: BrowserOption[] = [
2893
+ ...extensions.map((ext) => {
2894
+ return {
2895
+ key: ext.stableKey || ext.extensionId,
2896
+ type: 'extension' as const,
2897
+ browser: ext.browser || 'Chrome',
2898
+ profile: ext.profile?.email || '(not signed in)',
2899
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
2900
+ }
2901
+ }),
2902
+ ...directInstances.map(instanceToBrowserOption),
2903
+ ...headlessOption,
2904
+ ...cloudOptions,
2905
+ ]
2906
+
2907
+ if (allOptions.length === 0) {
2908
+ console.log('No browsers detected.\n')
2909
+ console.log(' Extension: click the Tabwright icon on a tab to connect')
2910
+ console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome')
2911
+ console.log(' Headless: run `tabwright browser install` then `--browser headless`')
2912
+ console.log(' Cloud: run `tabwright cloud login` to connect cloud browsers')
2913
+ return
2914
+ }
2915
+
2916
+ printBrowserTable(allOptions)
2917
+ console.log('')
2918
+
2919
+ const hasDirectInstances = allOptions.some((opt) => {
2920
+ return opt.type === 'direct'
2921
+ })
2922
+ if (hasDirectInstances) {
2923
+ console.log(pc.dim('Connect with: tabwright session new --direct'))
2924
+ console.log(pc.dim('Chrome may ask to approve the debugging connection.'))
2925
+ } else {
2926
+ console.log(pc.dim('Use with: tabwright session new [--browser <key>]'))
2927
+ }
2928
+
2929
+ const hasCloud = allOptions.some((opt) => {
2930
+ return opt.type === 'cloud'
2931
+ })
2932
+ if (!hasCloud) {
2933
+ printCloudTip()
2934
+ }
2935
+ })
2936
+
2937
+ // ── Cloud commands ──────────────────────────────────────────────────
2938
+
2939
+ cli
2940
+ .command('cloud login', 'Authenticate with playwriter.dev to use cloud browsers')
2941
+ .option('--base-url <url>', 'Website base URL (default: https://playwriter.dev)')
2942
+ .action(async (options) => {
2943
+ const baseUrl = options.baseUrl || process.env.TABWRIGHT_CLOUD_URL || 'https://playwriter.dev'
2944
+
2945
+ // Use the better-auth client SDK so we don't hardcode endpoint URLs.
2946
+ // Hardcoded URLs broke before when better-auth changed paths between versions.
2947
+ const { createAuthClient } = await import('better-auth/client')
2948
+ const { deviceAuthorizationClient } = await import('better-auth/client/plugins')
2949
+ const client = createAuthClient({
2950
+ baseURL: baseUrl,
2951
+ plugins: [deviceAuthorizationClient()],
2952
+ })
2953
+
2954
+ console.log('Requesting device authorization...')
2955
+ const { data: deviceData, error: requestError } = await client.device.code({
2956
+ client_id: 'tabwright-cli',
2957
+ })
2958
+ if (requestError || !deviceData) {
2959
+ console.error(`Error: failed to request device code — ${requestError?.error_description || requestError?.error || 'unknown error'}`)
2960
+ process.exit(1)
2961
+ }
2962
+
2963
+ const verificationUrl = deviceData.verification_uri_complete || `${baseUrl}/device?user_code=${deviceData.user_code}`
2964
+ console.log(`\nOpen this URL in your browser:\n ${verificationUrl}\n`)
2965
+ console.log(`Code: ${deviceData.user_code}\n`)
2966
+
2967
+ await openInBrowser(verificationUrl)
2968
+
2969
+ console.log('Waiting for approval...')
2970
+ const pollInterval = (deviceData.interval || 5) * 1000
2971
+ const deadline = Date.now() + (deviceData.expires_in || 300) * 1000
2972
+
2973
+ while (Date.now() < deadline) {
2974
+ await new Promise((r) => { setTimeout(r, pollInterval) })
2975
+ const { data: tokenData, error: pollError } = await client.device.token({
2976
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
2977
+ device_code: deviceData.device_code,
2978
+ client_id: 'tabwright-cli',
2979
+ })
2980
+ if (tokenData?.access_token) {
2981
+ saveCloudAuth({ token: tokenData.access_token, baseUrl })
2982
+ console.log(pc.green('\nLogged in successfully!'))
2983
+ console.log('Cloud browsers will now appear in `tabwright session new`.')
2984
+ return
2985
+ }
2986
+ if (pollError?.error === 'authorization_pending' || pollError?.error === 'slow_down') {
2987
+ continue
2988
+ }
2989
+ if (pollError) {
2990
+ console.error(`\nError: Device authorization failed — ${pollError.error_description || pollError.error}`)
2991
+ process.exit(1)
2992
+ }
2993
+ }
2994
+
2995
+ console.error('\nError: Device authorization timed out.')
2996
+ process.exit(1)
2997
+ })
2998
+
2999
+ cli
3000
+ .command('cloud subscribe', 'Open the subscription page to purchase cloud browser sessions')
3001
+ .action(async () => {
3002
+ const auth = loadCloudAuth()
3003
+ if (!auth) {
3004
+ console.error('Not logged in. Run `tabwright cloud login` first.')
3005
+ process.exit(1)
3006
+ }
3007
+ const subscribeUrl = new URL('/dashboard', auth.baseUrl).toString()
3008
+ console.log(`Open your browser to manage your subscription:\n ${subscribeUrl}\n`)
3009
+ await openInBrowser(subscribeUrl)
3010
+ })
3011
+
3012
+ cli
3013
+ .command('cloud status', 'Show active cloud browser sessions')
3014
+ .action(async () => {
3015
+ const client = getCloudClient()
3016
+ if (!client) {
3017
+ console.error('Not logged in. Run `tabwright cloud login` first.')
3018
+ process.exit(1)
3019
+ }
3020
+
3021
+ try {
3022
+ const { sessions } = await client.getStatus()
3023
+
3024
+ if (sessions.length === 0) {
3025
+ console.log('No active cloud sessions.')
3026
+ console.log(pc.dim('Start one with: tabwright session new --browser cloud'))
3027
+ return
3028
+ }
3029
+
3030
+ const keyWidth = Math.max(3, ...sessions.map((s) => `cloud-${s.index}`.length))
3031
+ console.log('KEY'.padEnd(keyWidth) + ' ' + 'STATUS'.padEnd(10) + ' ' + 'DETAILS')
3032
+ console.log('-'.repeat(keyWidth + 30))
3033
+
3034
+ for (const s of sessions) {
3035
+ const key = `cloud-${s.index}`
3036
+ const timeoutAt = new Date(s.timeoutAt).toLocaleTimeString()
3037
+ console.log(
3038
+ key.padEnd(keyWidth) +
3039
+ ' ' +
3040
+ pc.green('running'.padEnd(10)) +
3041
+ ' ' +
3042
+ `expires ${timeoutAt}`,
3043
+ )
3044
+ }
3045
+ } catch (error) {
3046
+ const msg = error instanceof Error ? error.message : String(error)
3047
+ console.error(`Error: ${msg}`)
3048
+ process.exit(1)
3049
+ }
3050
+ })
3051
+
3052
+ cli
3053
+ .command('cloud live [key]', 'Open a live browser view for an active cloud session')
3054
+ .action(async (key) => {
3055
+ const client = getCloudClient()
3056
+ if (!client) {
3057
+ console.error('Not logged in. Run `tabwright cloud login` first.')
3058
+ process.exit(1)
3059
+ }
3060
+
3061
+ try {
3062
+ const { sessions } = await client.getStatus()
3063
+ if (sessions.length === 0) {
3064
+ console.log('No active cloud sessions.')
3065
+ console.log(pc.dim('Start one with: tabwright session new --browser cloud'))
3066
+ process.exit(1)
3067
+ }
3068
+
3069
+ let session: (typeof sessions)[number] | undefined
3070
+ if (key) {
3071
+ // Match by cloud-N key or by cloudSessionId
3072
+ session = sessions.find((s) => {
3073
+ return `cloud-${s.index}` === key || s.cloudSessionId === key || s.browserUseSessionId === key
3074
+ })
3075
+ if (!session) {
3076
+ console.error(`No active session matching "${key}".`)
3077
+ console.error('Active sessions: ' + sessions.map((s) => { return `cloud-${s.index}` }).join(', '))
3078
+ process.exit(1)
3079
+ }
3080
+ } else if (sessions.length === 1) {
3081
+ session = sessions[0]!
3082
+ } else {
3083
+ console.log('Multiple active sessions. Specify one:\n')
3084
+ for (const s of sessions) {
3085
+ console.log(` cloud-${s.index} (expires ${new Date(s.timeoutAt).toLocaleTimeString()})`)
3086
+ }
3087
+ console.log(`\nUsage: tabwright cloud live cloud-1`)
3088
+ process.exit(1)
3089
+ }
3090
+
3091
+ if (!session.cdpUrl) {
3092
+ console.error('Session has no CDP URL — it may still be starting.')
3093
+ process.exit(1)
3094
+ }
3095
+ const auth = loadCloudAuth()!
3096
+ const liveUrl = buildLiveUrl(session.cdpUrl, auth.baseUrl)
3097
+ console.log(liveUrl)
3098
+ await openInBrowser(liveUrl)
3099
+ } catch (error) {
3100
+ const msg = error instanceof Error ? error.message : String(error)
3101
+ console.error(`Error: ${msg}`)
3102
+ process.exit(1)
3103
+ }
3104
+ })
3105
+
3106
+ cli.command('logfile', 'Print the path to the relay server log file').action(() => {
3107
+ console.log(`relay: ${LOG_FILE_PATH}`)
3108
+ console.log(`cdp: ${LOG_CDP_FILE_PATH}`)
3109
+ })
3110
+
3111
+ cli
3112
+ .command('skill install', 'Install the Tabwright agent skill bundled with this CLI')
3113
+ .option('--target <target>', 'Agent skill target (default: codex)')
3114
+ .option('--codex-home <dir>', 'Codex home directory (defaults to CODEX_HOME or ~/.codex)')
3115
+ .option('--force', 'Overwrite an installed Tabwright skill from another CLI build')
3116
+ .option('--json', 'Print JSON')
3117
+ .action((options: { target?: string; codexHome?: string; force?: boolean; json?: boolean }) => {
3118
+ try {
3119
+ const target = parseTabwrightAgentSkillTarget(options.target)
3120
+ const result = installTabwrightAgentSkill({
3121
+ target,
3122
+ codexHome: options.codexHome,
3123
+ overwrite: options.force,
3124
+ })
3125
+ if (options.json) {
3126
+ console.log(JSON.stringify(result, null, 2))
3127
+ return
3128
+ }
3129
+ console.log(`Tabwright skill ${result.fileStatus}: ${result.installedPath}`)
3130
+ result.next.forEach((step) => {
3131
+ console.log(`Next: ${step}`)
3132
+ })
3133
+ } catch (error) {
3134
+ exitWithError(error)
3135
+ }
3136
+ })
3137
+
3138
+ cli
3139
+ .command('skill status', 'Check whether the installed Tabwright agent skill matches this CLI')
3140
+ .option('--target <target>', 'Agent skill target (default: codex)')
3141
+ .option('--codex-home <dir>', 'Codex home directory (defaults to CODEX_HOME or ~/.codex)')
3142
+ .option('--json', 'Print JSON')
3143
+ .action((options: { target?: string; codexHome?: string; json?: boolean }) => {
3144
+ try {
3145
+ const status = getTabwrightAgentSkillStatus({
3146
+ target: parseTabwrightAgentSkillTarget(options.target),
3147
+ codexHome: options.codexHome,
3148
+ })
3149
+ if (options.json) {
3150
+ console.log(JSON.stringify(status, null, 2))
3151
+ return
3152
+ }
3153
+ console.log(`Tabwright skill (${status.target}): ${status.state}`)
3154
+ console.log(`Bundled: ${status.bundledPath}`)
3155
+ console.log(`Installed: ${status.installedPath}`)
3156
+ if (status.state !== 'current') {
3157
+ console.log(`Next: ${status.installCommand}`)
3158
+ }
3159
+ } catch (error) {
3160
+ exitWithError(error)
3161
+ }
3162
+ })
3163
+
3164
+ cli.command('skill', 'Print the full tabwright usage instructions').action(() => {
3165
+ const skillPath = path.join(__dirname, '..', 'src', 'skill.md')
3166
+ const content = fs.readFileSync(skillPath, 'utf-8')
3167
+ console.log(content)
3168
+ })
3169
+
3170
+ function parseTabwrightAgentSkillTarget(value: string | undefined): TabwrightAgentSkillTarget {
3171
+ if (!value || value === 'codex') {
3172
+ return 'codex'
3173
+ }
3174
+ throw new Error(`Unsupported Tabwright skill target: ${value}. Expected codex.`)
3175
+ }
3176
+
3177
+ cli.help()
3178
+ cli.completions()
3179
+ cli.version(VERSION)
3180
+
3181
+ await cli.parse()