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.
- package/LICENSE +21 -0
- package/bin.js +4 -0
- package/dist/a11y-client.js +204 -0
- package/dist/agent-skills/tabwright/SKILL.md +119 -0
- package/dist/aria-snapshot.d.ts +262 -0
- package/dist/aria-snapshot.d.ts.map +1 -0
- package/dist/aria-snapshot.js +1256 -0
- package/dist/aria-snapshot.js.map +1 -0
- package/dist/aria-snapshot.test.d.ts +2 -0
- package/dist/aria-snapshot.test.d.ts.map +1 -0
- package/dist/aria-snapshot.test.js +270 -0
- package/dist/aria-snapshot.test.js.map +1 -0
- package/dist/aria-snapshot.unit.test.d.ts +2 -0
- package/dist/aria-snapshot.unit.test.d.ts.map +1 -0
- package/dist/aria-snapshot.unit.test.js +542 -0
- package/dist/aria-snapshot.unit.test.js.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +894 -0
- package/dist/browser-config.d.ts +13 -0
- package/dist/browser-config.d.ts.map +1 -0
- package/dist/browser-config.js +126 -0
- package/dist/browser-config.js.map +1 -0
- package/dist/browser-install.d.ts +16 -0
- package/dist/browser-install.d.ts.map +1 -0
- package/dist/browser-install.js +238 -0
- package/dist/browser-install.js.map +1 -0
- package/dist/browser-launch.d.ts +17 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +44 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/builtin-capabilities.d.ts +28 -0
- package/dist/builtin-capabilities.d.ts.map +1 -0
- package/dist/builtin-capabilities.js +2673 -0
- package/dist/builtin-capabilities.js.map +1 -0
- package/dist/capability-agent-skill.d.ts +60 -0
- package/dist/capability-agent-skill.d.ts.map +1 -0
- package/dist/capability-agent-skill.js +225 -0
- package/dist/capability-agent-skill.js.map +1 -0
- package/dist/capability-auth-state.d.ts +34 -0
- package/dist/capability-auth-state.d.ts.map +1 -0
- package/dist/capability-auth-state.js +157 -0
- package/dist/capability-auth-state.js.map +1 -0
- package/dist/capability-auth.d.ts +36 -0
- package/dist/capability-auth.d.ts.map +1 -0
- package/dist/capability-auth.js +198 -0
- package/dist/capability-auth.js.map +1 -0
- package/dist/capability-cli-confirmation.test.d.ts +2 -0
- package/dist/capability-cli-confirmation.test.d.ts.map +1 -0
- package/dist/capability-cli-confirmation.test.js +79 -0
- package/dist/capability-cli-confirmation.test.js.map +1 -0
- package/dist/capability-options.d.ts +20 -0
- package/dist/capability-options.d.ts.map +1 -0
- package/dist/capability-options.js +31 -0
- package/dist/capability-options.js.map +1 -0
- package/dist/capability-options.test.d.ts +2 -0
- package/dist/capability-options.test.d.ts.map +1 -0
- package/dist/capability-options.test.js +81 -0
- package/dist/capability-options.test.js.map +1 -0
- package/dist/capability-package.d.ts +27 -0
- package/dist/capability-package.d.ts.map +1 -0
- package/dist/capability-package.js +444 -0
- package/dist/capability-package.js.map +1 -0
- package/dist/capability-package.test.d.ts +2 -0
- package/dist/capability-package.test.d.ts.map +1 -0
- package/dist/capability-package.test.js +179 -0
- package/dist/capability-package.test.js.map +1 -0
- package/dist/capability-registry.d.ts +262 -0
- package/dist/capability-registry.d.ts.map +1 -0
- package/dist/capability-registry.js +931 -0
- package/dist/capability-registry.js.map +1 -0
- package/dist/capability-registry.test.d.ts +2 -0
- package/dist/capability-registry.test.d.ts.map +1 -0
- package/dist/capability-registry.test.js +1683 -0
- package/dist/capability-registry.test.js.map +1 -0
- package/dist/capability-runner.d.ts +99 -0
- package/dist/capability-runner.d.ts.map +1 -0
- package/dist/capability-runner.js +608 -0
- package/dist/capability-runner.js.map +1 -0
- package/dist/capability-search-relevance.test.d.ts +2 -0
- package/dist/capability-search-relevance.test.d.ts.map +1 -0
- package/dist/capability-search-relevance.test.js +67 -0
- package/dist/capability-search-relevance.test.js.map +1 -0
- package/dist/capability-studio.d.ts +11 -0
- package/dist/capability-studio.d.ts.map +1 -0
- package/dist/capability-studio.js +593 -0
- package/dist/capability-studio.js.map +1 -0
- package/dist/capability-studio.test.d.ts +2 -0
- package/dist/capability-studio.test.d.ts.map +1 -0
- package/dist/capability-studio.test.js +41 -0
- package/dist/capability-studio.test.js.map +1 -0
- package/dist/cdp-log.d.ts +19 -0
- package/dist/cdp-log.d.ts.map +1 -0
- package/dist/cdp-log.js +83 -0
- package/dist/cdp-log.js.map +1 -0
- package/dist/cdp-log.test.d.ts +2 -0
- package/dist/cdp-log.test.d.ts.map +1 -0
- package/dist/cdp-log.test.js +109 -0
- package/dist/cdp-log.test.js.map +1 -0
- package/dist/cdp-relay.d.ts +19 -0
- package/dist/cdp-relay.d.ts.map +1 -0
- package/dist/cdp-relay.js +2375 -0
- package/dist/cdp-relay.js.map +1 -0
- package/dist/cdp-session.d.ts +37 -0
- package/dist/cdp-session.d.ts.map +1 -0
- package/dist/cdp-session.js +36 -0
- package/dist/cdp-session.js.map +1 -0
- package/dist/cdp-types.d.ts +65 -0
- package/dist/cdp-types.d.ts.map +1 -0
- package/dist/cdp-types.js +91 -0
- package/dist/cdp-types.js.map +1 -0
- package/dist/channel-owner-inspect.test.d.ts +2 -0
- package/dist/channel-owner-inspect.test.d.ts.map +1 -0
- package/dist/channel-owner-inspect.test.js +75 -0
- package/dist/channel-owner-inspect.test.js.map +1 -0
- package/dist/chrome-discovery.d.ts +65 -0
- package/dist/chrome-discovery.d.ts.map +1 -0
- package/dist/chrome-discovery.js +173 -0
- package/dist/chrome-discovery.js.map +1 -0
- package/dist/chrome-discovery.test.d.ts +2 -0
- package/dist/chrome-discovery.test.d.ts.map +1 -0
- package/dist/chrome-discovery.test.js +41 -0
- package/dist/chrome-discovery.test.js.map +1 -0
- package/dist/clean-html.d.ts +11 -0
- package/dist/clean-html.d.ts.map +1 -0
- package/dist/clean-html.js +106 -0
- package/dist/clean-html.js.map +1 -0
- package/dist/cli-help.test.d.ts +2 -0
- package/dist/cli-help.test.d.ts.map +1 -0
- package/dist/cli-help.test.js +98 -0
- package/dist/cli-help.test.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2709 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud-client.d.ts +56 -0
- package/dist/cloud-client.d.ts.map +1 -0
- package/dist/cloud-client.js +127 -0
- package/dist/cloud-client.js.map +1 -0
- package/dist/create-logger.d.ts +9 -0
- package/dist/create-logger.d.ts.map +1 -0
- package/dist/create-logger.js +27 -0
- package/dist/create-logger.js.map +1 -0
- package/dist/debugger-api.md +458 -0
- package/dist/debugger-examples-types.d.ts +24 -0
- package/dist/debugger-examples-types.d.ts.map +1 -0
- package/dist/debugger-examples-types.js +2 -0
- package/dist/debugger-examples-types.js.map +1 -0
- package/dist/debugger-examples.d.ts +6 -0
- package/dist/debugger-examples.d.ts.map +1 -0
- package/dist/debugger-examples.js +53 -0
- package/dist/debugger-examples.js.map +1 -0
- package/dist/debugger.d.ts +381 -0
- package/dist/debugger.d.ts.map +1 -0
- package/dist/debugger.js +630 -0
- package/dist/debugger.js.map +1 -0
- package/dist/diff-utils.d.ts +20 -0
- package/dist/diff-utils.d.ts.map +1 -0
- package/dist/diff-utils.js +50 -0
- package/dist/diff-utils.js.map +1 -0
- package/dist/diff-utils.test.d.ts +2 -0
- package/dist/diff-utils.test.d.ts.map +1 -0
- package/dist/diff-utils.test.js +137 -0
- package/dist/diff-utils.test.js.map +1 -0
- package/dist/doctor.d.ts +44 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +246 -0
- package/dist/doctor.js.map +1 -0
- package/dist/doctor.test.d.ts +2 -0
- package/dist/doctor.test.d.ts.map +1 -0
- package/dist/doctor.test.js +208 -0
- package/dist/doctor.test.js.map +1 -0
- package/dist/editor-api.md +374 -0
- package/dist/editor-examples.d.ts +11 -0
- package/dist/editor-examples.d.ts.map +1 -0
- package/dist/editor-examples.js +124 -0
- package/dist/editor-examples.js.map +1 -0
- package/dist/editor.d.ts +203 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +363 -0
- package/dist/editor.js.map +1 -0
- package/dist/env-compat.d.ts +2 -0
- package/dist/env-compat.d.ts.map +1 -0
- package/dist/env-compat.js +27 -0
- package/dist/env-compat.js.map +1 -0
- package/dist/executor.d.ts +229 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +1465 -0
- package/dist/executor.js.map +1 -0
- package/dist/executor.unit.test.d.ts +2 -0
- package/dist/executor.unit.test.d.ts.map +1 -0
- package/dist/executor.unit.test.js +143 -0
- package/dist/executor.unit.test.js.map +1 -0
- package/dist/extension/_locales/en/messages.json +278 -0
- package/dist/extension/_locales/zh_CN/messages.json +256 -0
- package/dist/extension/assets/options-CYH96Lcx.css +1349 -0
- package/dist/extension/background.js +3994 -0
- package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
- package/dist/extension/icons/icon-black-128.png +0 -0
- package/dist/extension/icons/icon-black-16.png +0 -0
- package/dist/extension/icons/icon-black-32.png +0 -0
- package/dist/extension/icons/icon-black-48.png +0 -0
- package/dist/extension/icons/icon-gray-128.png +0 -0
- package/dist/extension/icons/icon-gray-16.png +0 -0
- package/dist/extension/icons/icon-gray-32.png +0 -0
- package/dist/extension/icons/icon-gray-48.png +0 -0
- package/dist/extension/icons/icon-green-128.png +0 -0
- package/dist/extension/icons/icon-green-16.png +0 -0
- package/dist/extension/icons/icon-green-32.png +0 -0
- package/dist/extension/icons/icon-green-48.png +0 -0
- package/dist/extension/icons/tabwright-icon-black.png +0 -0
- package/dist/extension/icons/tabwright-icon-gray-disabled.png +0 -0
- package/dist/extension/icons/tabwright-icon-gray-disabled.svg +1 -0
- package/dist/extension/manifest.json +54 -0
- package/dist/extension/options.js +16145 -0
- package/dist/extension/rrweb-recorder.js +12952 -0
- package/dist/extension/src/options.html +165 -0
- package/dist/extension/src/prism-bash.min.js +1 -0
- package/dist/extension/src/prism.min.js +1 -0
- package/dist/extension/src/welcome.html +396 -0
- package/dist/extension-connection.test.d.ts +2 -0
- package/dist/extension-connection.test.d.ts.map +1 -0
- package/dist/extension-connection.test.js +708 -0
- package/dist/extension-connection.test.js.map +1 -0
- package/dist/extension-icon-warning.test.d.ts +2 -0
- package/dist/extension-icon-warning.test.d.ts.map +1 -0
- package/dist/extension-icon-warning.test.js +33 -0
- package/dist/extension-icon-warning.test.js.map +1 -0
- package/dist/ghost-browser.d.ts +62 -0
- package/dist/ghost-browser.d.ts.map +1 -0
- package/dist/ghost-browser.js +107 -0
- package/dist/ghost-browser.js.map +1 -0
- package/dist/ghost-cursor-client.js +374 -0
- package/dist/ghost-cursor-controller.d.ts +46 -0
- package/dist/ghost-cursor-controller.d.ts.map +1 -0
- package/dist/ghost-cursor-controller.js +98 -0
- package/dist/ghost-cursor-controller.js.map +1 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +79 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts +8 -0
- package/dist/htmlrewrite.d.ts.map +1 -0
- package/dist/htmlrewrite.js +339 -0
- package/dist/htmlrewrite.js.map +1 -0
- package/dist/htmlrewrite.test.d.ts +2 -0
- package/dist/htmlrewrite.test.d.ts.map +1 -0
- package/dist/htmlrewrite.test.js +7975 -0
- package/dist/htmlrewrite.test.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-port.d.ts +29 -0
- package/dist/kill-port.d.ts.map +1 -0
- package/dist/kill-port.js +190 -0
- package/dist/kill-port.js.map +1 -0
- package/dist/kitty-graphics.d.ts +22 -0
- package/dist/kitty-graphics.d.ts.map +1 -0
- package/dist/kitty-graphics.js +71 -0
- package/dist/kitty-graphics.js.map +1 -0
- package/dist/kitty-graphics.test.d.ts +2 -0
- package/dist/kitty-graphics.test.d.ts.map +1 -0
- package/dist/kitty-graphics.test.js +125 -0
- package/dist/kitty-graphics.test.js.map +1 -0
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +98 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.d.ts +20 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +56 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/mcp.d.ts +6 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +511 -0
- package/dist/mcp.js.map +1 -0
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +180 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/options-page.test.d.ts +2 -0
- package/dist/options-page.test.d.ts.map +1 -0
- package/dist/options-page.test.js +773 -0
- package/dist/options-page.test.js.map +1 -0
- package/dist/package-paths.d.ts +4 -0
- package/dist/package-paths.d.ts.map +1 -0
- package/dist/package-paths.js +30 -0
- package/dist/package-paths.js.map +1 -0
- package/dist/page-markdown.d.ts +40 -0
- package/dist/page-markdown.d.ts.map +1 -0
- package/dist/page-markdown.js +181 -0
- package/dist/page-markdown.js.map +1 -0
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/playwright-import.d.ts +19 -0
- package/dist/playwright-import.d.ts.map +1 -0
- package/dist/playwright-import.js +40 -0
- package/dist/playwright-import.js.map +1 -0
- package/dist/popup-relocation.test.d.ts +7 -0
- package/dist/popup-relocation.test.d.ts.map +1 -0
- package/dist/popup-relocation.test.js +139 -0
- package/dist/popup-relocation.test.js.map +1 -0
- package/dist/product-paths.d.ts +9 -0
- package/dist/product-paths.d.ts.map +1 -0
- package/dist/product-paths.js +24 -0
- package/dist/product-paths.js.map +1 -0
- package/dist/product-paths.test.d.ts +2 -0
- package/dist/product-paths.test.d.ts.map +1 -0
- package/dist/product-paths.test.js +39 -0
- package/dist/product-paths.test.js.map +1 -0
- package/dist/prompt.md +1004 -0
- package/dist/protocol.d.ts +248 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +59 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocol.test.d.ts +2 -0
- package/dist/protocol.test.d.ts.map +1 -0
- package/dist/protocol.test.js +31 -0
- package/dist/protocol.test.js.map +1 -0
- package/dist/react-source.d.ts +57 -0
- package/dist/react-source.d.ts.map +1 -0
- package/dist/react-source.js +254 -0
- package/dist/react-source.js.map +1 -0
- package/dist/readability.js +1674 -0
- package/dist/relay-client.d.ts +104 -0
- package/dist/relay-client.d.ts.map +1 -0
- package/dist/relay-client.js +426 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/relay-client.test.d.ts +2 -0
- package/dist/relay-client.test.d.ts.map +1 -0
- package/dist/relay-client.test.js +305 -0
- package/dist/relay-client.test.js.map +1 -0
- package/dist/relay-core.test.d.ts +2 -0
- package/dist/relay-core.test.d.ts.map +1 -0
- package/dist/relay-core.test.js +1427 -0
- package/dist/relay-core.test.js.map +1 -0
- package/dist/relay-navigation.test.d.ts +2 -0
- package/dist/relay-navigation.test.d.ts.map +1 -0
- package/dist/relay-navigation.test.js +661 -0
- package/dist/relay-navigation.test.js.map +1 -0
- package/dist/relay-session.test.d.ts +2 -0
- package/dist/relay-session.test.d.ts.map +1 -0
- package/dist/relay-session.test.js +1121 -0
- package/dist/relay-session.test.js.map +1 -0
- package/dist/relay-state.d.ts +178 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +346 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +594 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/replay-ai-index.d.ts +99 -0
- package/dist/replay-ai-index.d.ts.map +1 -0
- package/dist/replay-ai-index.js +677 -0
- package/dist/replay-ai-index.js.map +1 -0
- package/dist/replay-ai-index.test.d.ts +2 -0
- package/dist/replay-ai-index.test.d.ts.map +1 -0
- package/dist/replay-ai-index.test.js +255 -0
- package/dist/replay-ai-index.test.js.map +1 -0
- package/dist/replay-cli.test.d.ts +2 -0
- package/dist/replay-cli.test.d.ts.map +1 -0
- package/dist/replay-cli.test.js +275 -0
- package/dist/replay-cli.test.js.map +1 -0
- package/dist/replay-eval.d.ts +44 -0
- package/dist/replay-eval.d.ts.map +1 -0
- package/dist/replay-eval.js +902 -0
- package/dist/replay-eval.js.map +1 -0
- package/dist/replay-handoff.d.ts +29 -0
- package/dist/replay-handoff.d.ts.map +1 -0
- package/dist/replay-handoff.js +83 -0
- package/dist/replay-handoff.js.map +1 -0
- package/dist/replay-handoff.test.d.ts +2 -0
- package/dist/replay-handoff.test.d.ts.map +1 -0
- package/dist/replay-handoff.test.js +123 -0
- package/dist/replay-handoff.test.js.map +1 -0
- package/dist/replay-workflow-compiler.d.ts +44 -0
- package/dist/replay-workflow-compiler.d.ts.map +1 -0
- package/dist/replay-workflow-compiler.js +601 -0
- package/dist/replay-workflow-compiler.js.map +1 -0
- package/dist/replay-workflow-compiler.test.d.ts +2 -0
- package/dist/replay-workflow-compiler.test.d.ts.map +1 -0
- package/dist/replay-workflow-compiler.test.js +223 -0
- package/dist/replay-workflow-compiler.test.js.map +1 -0
- package/dist/rrweb-recording-relay.d.ts +56 -0
- package/dist/rrweb-recording-relay.d.ts.map +1 -0
- package/dist/rrweb-recording-relay.js +299 -0
- package/dist/rrweb-recording-relay.js.map +1 -0
- package/dist/rrweb-recording-relay.test.d.ts +2 -0
- package/dist/rrweb-recording-relay.test.d.ts.map +1 -0
- package/dist/rrweb-recording-relay.test.js +85 -0
- package/dist/rrweb-recording-relay.test.js.map +1 -0
- package/dist/rrweb-recording.d.ts +104 -0
- package/dist/rrweb-recording.d.ts.map +1 -0
- package/dist/rrweb-recording.js +180 -0
- package/dist/rrweb-recording.js.map +1 -0
- package/dist/scoped-fs.d.ts +95 -0
- package/dist/scoped-fs.d.ts.map +1 -0
- package/dist/scoped-fs.js +358 -0
- package/dist/scoped-fs.js.map +1 -0
- package/dist/scoped-fs.test.d.ts +5 -0
- package/dist/scoped-fs.test.d.ts.map +1 -0
- package/dist/scoped-fs.test.js +73 -0
- package/dist/scoped-fs.test.js.map +1 -0
- package/dist/selector-generator.js +7378 -0
- package/dist/skill-stub.test.d.ts +2 -0
- package/dist/skill-stub.test.d.ts.map +1 -0
- package/dist/skill-stub.test.js +22 -0
- package/dist/skill-stub.test.js.map +1 -0
- package/dist/snapshot-tools.test.d.ts +2 -0
- package/dist/snapshot-tools.test.d.ts.map +1 -0
- package/dist/snapshot-tools.test.js +856 -0
- package/dist/snapshot-tools.test.js.map +1 -0
- package/dist/start-relay-server.d.ts +6 -0
- package/dist/start-relay-server.d.ts.map +1 -0
- package/dist/start-relay-server.js +57 -0
- package/dist/start-relay-server.js.map +1 -0
- package/dist/styles-api.md +124 -0
- package/dist/styles-examples.d.ts +8 -0
- package/dist/styles-examples.d.ts.map +1 -0
- package/dist/styles-examples.js +64 -0
- package/dist/styles-examples.js.map +1 -0
- package/dist/styles.d.ts +27 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +231 -0
- package/dist/styles.js.map +1 -0
- package/dist/tabwright-agent-skill.d.ts +30 -0
- package/dist/tabwright-agent-skill.d.ts.map +1 -0
- package/dist/tabwright-agent-skill.js +74 -0
- package/dist/tabwright-agent-skill.js.map +1 -0
- package/dist/tabwright-agent-skill.test.d.ts +2 -0
- package/dist/tabwright-agent-skill.test.d.ts.map +1 -0
- package/dist/tabwright-agent-skill.test.js +51 -0
- package/dist/tabwright-agent-skill.test.js.map +1 -0
- package/dist/test-declarations.d.ts +13 -0
- package/dist/test-declarations.d.ts.map +1 -0
- package/dist/test-declarations.js +2 -0
- package/dist/test-declarations.js.map +1 -0
- package/dist/test-utils.d.ts +69 -0
- package/dist/test-utils.d.ts.map +1 -0
- package/dist/test-utils.js +448 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/test-utils.test.d.ts +2 -0
- package/dist/test-utils.test.d.ts.map +1 -0
- package/dist/test-utils.test.js +55 -0
- package/dist/test-utils.test.js.map +1 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +60 -0
- package/dist/utils.js.map +1 -0
- package/dist/wait-for-page-load.d.ts +16 -0
- package/dist/wait-for-page-load.d.ts.map +1 -0
- package/dist/wait-for-page-load.js +146 -0
- package/dist/wait-for-page-load.js.map +1 -0
- package/dist/workflow-capability.d.ts +72 -0
- package/dist/workflow-capability.d.ts.map +1 -0
- package/dist/workflow-capability.js +316 -0
- package/dist/workflow-capability.js.map +1 -0
- package/dist/workflow-script-flow.test.d.ts +2 -0
- package/dist/workflow-script-flow.test.d.ts.map +1 -0
- package/dist/workflow-script-flow.test.js +285 -0
- package/dist/workflow-script-flow.test.js.map +1 -0
- package/package.json +80 -0
- package/src/__snapshots__/x.com.processed.html +1003 -0
- package/src/__snapshots__/x.com.processed.withStyles.html +2720 -0
- package/src/a11y-client.ts +241 -0
- package/src/agent-skills/conan-config/SKILL.md +141 -0
- package/src/agent-skills/conan-config/agents/openai.yaml +4 -0
- package/src/aria-snapshot.test.ts +311 -0
- package/src/aria-snapshot.ts +1681 -0
- package/src/aria-snapshot.unit.test.ts +615 -0
- package/src/aria-snapshots/github-interactive.txt +193 -0
- package/src/aria-snapshots/github-raw.txt +309 -0
- package/src/aria-snapshots/hackernews-interactive.txt +477 -0
- package/src/aria-snapshots/hackernews-raw.txt +428 -0
- package/src/aria-snapshots/prosemirror-interactive.txt +66 -0
- package/src/aria-snapshots/prosemirror-raw.txt +145 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/assets/x.com.html +32946 -0
- package/src/browser-config.ts +179 -0
- package/src/browser-install.ts +284 -0
- package/src/browser-launch.ts +75 -0
- package/src/builtin-capabilities.ts +2765 -0
- package/src/capability-agent-skill.ts +305 -0
- package/src/capability-auth-state.ts +197 -0
- package/src/capability-auth.ts +249 -0
- package/src/capability-cli-confirmation.test.ts +90 -0
- package/src/capability-options.test.ts +89 -0
- package/src/capability-options.ts +57 -0
- package/src/capability-package.test.ts +204 -0
- package/src/capability-package.ts +555 -0
- package/src/capability-registry.test.ts +1905 -0
- package/src/capability-registry.ts +1234 -0
- package/src/capability-runner.ts +801 -0
- package/src/capability-search-relevance.test.ts +73 -0
- package/src/capability-studio.test.ts +45 -0
- package/src/capability-studio.ts +641 -0
- package/src/cdp-log.test.ts +131 -0
- package/src/cdp-log.ts +112 -0
- package/src/cdp-relay.ts +2944 -0
- package/src/cdp-session.ts +77 -0
- package/src/cdp-types.ts +158 -0
- package/src/channel-owner-inspect.test.ts +94 -0
- package/src/chrome-discovery.test.ts +50 -0
- package/src/chrome-discovery.ts +220 -0
- package/src/clean-html.ts +141 -0
- package/src/cli-help.test.ts +123 -0
- package/src/cli.ts +3181 -0
- package/src/cloud-client.ts +179 -0
- package/src/create-logger.ts +38 -0
- package/src/debugger-examples-types.ts +16 -0
- package/src/debugger-examples.ts +66 -0
- package/src/debugger.ts +708 -0
- package/src/diff-utils.test.ts +152 -0
- package/src/diff-utils.ts +70 -0
- package/src/doctor.test.ts +244 -0
- package/src/doctor.ts +321 -0
- package/src/editor-examples.ts +158 -0
- package/src/editor.ts +426 -0
- package/src/env-compat.ts +28 -0
- package/src/executor.ts +1824 -0
- package/src/executor.unit.test.ts +168 -0
- package/src/extension-connection.test.ts +855 -0
- package/src/extension-icon-warning.test.ts +45 -0
- package/src/ghost-browser.ts +149 -0
- package/src/ghost-cursor-client.ts +494 -0
- package/src/ghost-cursor-controller.ts +129 -0
- package/src/ghost-cursor.ts +123 -0
- package/src/htmlrewrite.test.ts +8008 -0
- package/src/htmlrewrite.ts +390 -0
- package/src/index.ts +53 -0
- package/src/kill-port.ts +219 -0
- package/src/kitty-graphics.test.ts +139 -0
- package/src/kitty-graphics.ts +79 -0
- package/src/locator-selector.test.ts +119 -0
- package/src/mcp-client.ts +78 -0
- package/src/mcp.ts +629 -0
- package/src/on-mouse-action.test.ts +226 -0
- package/src/options-page.test.ts +839 -0
- package/src/package-paths.ts +38 -0
- package/src/page-markdown.ts +240 -0
- package/src/performance-examples.ts +186 -0
- package/src/playwright-import.ts +59 -0
- package/src/popup-relocation.test.ts +163 -0
- package/src/product-paths.test.ts +47 -0
- package/src/product-paths.ts +28 -0
- package/src/protocol.test.ts +52 -0
- package/src/protocol.ts +351 -0
- package/src/react-source.ts +379 -0
- package/src/relay-client.test.ts +375 -0
- package/src/relay-client.ts +572 -0
- package/src/relay-core.test.ts +1603 -0
- package/src/relay-navigation.test.ts +790 -0
- package/src/relay-session.test.ts +1375 -0
- package/src/relay-state.test.ts +729 -0
- package/src/relay-state.ts +561 -0
- package/src/replay-ai-index.test.ts +278 -0
- package/src/replay-ai-index.ts +872 -0
- package/src/replay-cli.test.ts +325 -0
- package/src/replay-eval.ts +1039 -0
- package/src/replay-handoff.test.ts +163 -0
- package/src/replay-handoff.ts +109 -0
- package/src/replay-workflow-compiler.test.ts +250 -0
- package/src/replay-workflow-compiler.ts +700 -0
- package/src/resource.md +409 -0
- package/src/rrweb-recording-relay.test.ts +99 -0
- package/src/rrweb-recording-relay.ts +400 -0
- package/src/rrweb-recording.ts +322 -0
- package/src/scoped-fs.test.ts +80 -0
- package/src/scoped-fs.ts +420 -0
- package/src/skill-stub.test.ts +24 -0
- package/src/skill.md +1413 -0
- package/src/snapshot-tools.test.ts +1017 -0
- package/src/snapshots/hacker-news-accessibility-full.md +81 -0
- package/src/snapshots/hacker-news-accessibility-interactive.md +43 -0
- package/src/snapshots/page-markdown-output.txt +16 -0
- package/src/snapshots/shadcn-ui-accessibility-full.md +201 -0
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +109 -0
- package/src/start-relay-server.ts +69 -0
- package/src/styles-examples.ts +84 -0
- package/src/styles.ts +343 -0
- package/src/tabwright-agent-skill.test.ts +61 -0
- package/src/tabwright-agent-skill.ts +105 -0
- package/src/test-declarations.ts +13 -0
- package/src/test-utils.test.ts +70 -0
- package/src/test-utils.ts +566 -0
- package/src/utils.ts +78 -0
- package/src/wait-for-page-load.ts +198 -0
- package/src/workflow-capability.ts +417 -0
- 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()
|