real-browser-cli 0.16.8__tar.gz → 0.16.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/PKG-INFO +25 -1
  2. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/README.md +24 -0
  3. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/navigate.py +8 -6
  4. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/mcp/server.py +29 -7
  5. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/navigation.py +35 -21
  6. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/tabs.py +17 -1
  7. real_browser_cli-0.16.9/browser_cli/url_match.py +138 -0
  8. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/manifest.json +1 -1
  9. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/pyproject.toml +1 -1
  10. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_mcp.py +36 -1
  11. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_new_feature_commands.py +44 -5
  12. real_browser_cli-0.16.9/tests/test_url_match.py +84 -0
  13. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/uv.lock +183 -160
  14. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.claude/settings.local.json +0 -0
  15. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.gitea/workflows/package-extension.yml +0 -0
  16. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.gitea/workflows/publish.yml +0 -0
  17. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.gitea/workflows/testing.yml +0 -0
  18. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.gitignore +0 -0
  19. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/.python-version +0 -0
  20. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/CLAUDE.md +0 -0
  21. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/LICENSE +0 -0
  22. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/PRIVACY.md +0 -0
  23. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/__init__.py +0 -0
  24. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/async_sdk.py +0 -0
  25. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/__init__.py +0 -0
  26. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/agent.py +0 -0
  27. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/keys.py +0 -0
  28. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/pq.py +0 -0
  29. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/server_identity.py +0 -0
  30. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/auth/signing.py +0 -0
  31. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/cli.py +0 -0
  32. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/client/__init__.py +0 -0
  33. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/client/auth.py +0 -0
  34. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/client/core.py +0 -0
  35. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/client/messages.py +0 -0
  36. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/client/targets.py +0 -0
  37. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/command_security.py +0 -0
  38. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/__init__.py +0 -0
  39. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/auth.py +0 -0
  40. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/clients.py +0 -0
  41. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/completion.py +0 -0
  42. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/doctor.py +0 -0
  43. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/dom.py +0 -0
  44. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/events.py +0 -0
  45. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/extension.py +0 -0
  46. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/extract.py +0 -0
  47. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/groups.py +0 -0
  48. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/install.py +0 -0
  49. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/page.py +0 -0
  50. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/perf.py +0 -0
  51. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/raw.py +0 -0
  52. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/remote.py +0 -0
  53. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/rendering/__init__.py +0 -0
  54. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/rendering/common.py +0 -0
  55. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/rendering/labels.py +0 -0
  56. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/rendering/tabs_tree.py +0 -0
  57. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/rendering/windows_tree.py +0 -0
  58. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/script.py +0 -0
  59. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/search.py +0 -0
  60. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/serve.py +0 -0
  61. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/serve_http.py +0 -0
  62. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/session.py +0 -0
  63. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/storage.py +0 -0
  64. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/tabs.py +0 -0
  65. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/watch.py +0 -0
  66. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/windows.py +0 -0
  67. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/commands/workspace.py +0 -0
  68. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/compat/__init__.py +0 -0
  69. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/compat/auth.py +0 -0
  70. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/compat/commands.py +0 -0
  71. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/constants.py +0 -0
  72. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/endpoints.py +0 -0
  73. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/errors.py +0 -0
  74. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/framing.py +0 -0
  75. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/local_transport.py +0 -0
  76. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/markdown/__init__.py +0 -0
  77. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/markdown/html.py +0 -0
  78. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/markdown/render.py +0 -0
  79. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/mcp/__init__.py +0 -0
  80. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/mcp/naming.py +0 -0
  81. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/mcp/serialization.py +0 -0
  82. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/mcp/targets.py +0 -0
  83. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/models.py +0 -0
  84. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/native/__init__.py +0 -0
  85. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/native/host.py +0 -0
  86. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/native/local_server.py +0 -0
  87. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/native/protocol.py +0 -0
  88. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/platform.py +0 -0
  89. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/registry.py +0 -0
  90. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/__init__.py +0 -0
  91. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/auth.py +0 -0
  92. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/known_hosts.py +0 -0
  93. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/pool.py +0 -0
  94. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/registry.py +0 -0
  95. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/socket.py +0 -0
  96. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/remote/transport.py +0 -0
  97. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/__init__.py +0 -0
  98. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/base.py +0 -0
  99. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/browser_data.py +0 -0
  100. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/decorators.py +0 -0
  101. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/dom.py +0 -0
  102. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/extension.py +0 -0
  103. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/factories.py +0 -0
  104. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/groups.py +0 -0
  105. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/perf.py +0 -0
  106. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/routing.py +0 -0
  107. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/session.py +0 -0
  108. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/windows.py +0 -0
  109. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/sdk/workflow_decorators.py +0 -0
  110. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/search/__init__.py +0 -0
  111. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/search/engines.py +0 -0
  112. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/__init__.py +0 -0
  113. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/auth.py +0 -0
  114. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/challenge.py +0 -0
  115. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/control.py +0 -0
  116. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/logging.py +0 -0
  117. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/proxy.py +0 -0
  118. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/runtime.py +0 -0
  119. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/serve/security.py +0 -0
  120. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/transport/__init__.py +0 -0
  121. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/transport/binary.py +0 -0
  122. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/transport/codecs.py +0 -0
  123. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/browser_cli/version_manager.py +0 -0
  124. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/examples/demo.py +0 -0
  125. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/examples/demo.sh +0 -0
  126. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/content.js +0 -0
  127. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/icon.svg +0 -0
  128. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/icons/icon-128.png +0 -0
  129. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/icons/icon-16.png +0 -0
  130. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/icons/icon-32.png +0 -0
  131. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/icons/icon-48.png +0 -0
  132. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/browser-api.ts +0 -0
  133. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/classes/CommandGroup.ts +0 -0
  134. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/classes/CommandRegistry.ts +0 -0
  135. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/classes/JobManager.ts +0 -0
  136. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/classes/NativeConnection.ts +0 -0
  137. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/autosave.ts +0 -0
  138. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/browser-data.ts +0 -0
  139. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/dom.ts +0 -0
  140. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/extension.ts +0 -0
  141. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/groups.ts +0 -0
  142. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/injected.ts +0 -0
  143. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/navigation.ts +0 -0
  144. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/perf.ts +0 -0
  145. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/session-snapshot.ts +0 -0
  146. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/session.ts +0 -0
  147. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/tabs-query.ts +0 -0
  148. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/tabs.ts +0 -0
  149. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/commands/windows.ts +0 -0
  150. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/dispatch.ts +0 -0
  151. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/dom-ops.ts +0 -0
  152. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/extract.ts +0 -0
  153. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/markdown/code.ts +0 -0
  154. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/markdown/index.ts +0 -0
  155. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/markdown/renderer.ts +0 -0
  156. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/markdown/root.ts +0 -0
  157. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/markdown/utils.ts +0 -0
  158. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content/page-info.ts +0 -0
  159. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/content-dispatch.ts +0 -0
  160. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/errors.ts +0 -0
  161. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/group-helpers.ts +0 -0
  162. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/index.ts +0 -0
  163. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/scripting.ts +0 -0
  164. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/storage.ts +0 -0
  165. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/tab-groups.ts +0 -0
  166. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/tab-helpers.ts +0 -0
  167. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/core/throttle.ts +0 -0
  168. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/index.ts +0 -0
  169. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/command-args.ts +0 -0
  170. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/index.ts +0 -0
  171. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/jobs.ts +0 -0
  172. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/json.ts +0 -0
  173. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/messages.ts +0 -0
  174. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/session.ts +0 -0
  175. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/tabs.ts +0 -0
  176. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/src/types/webextension.ts +0 -0
  177. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/autosave.test.ts +0 -0
  178. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/browser-api.test.ts +0 -0
  179. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/chrome-mock.ts +0 -0
  180. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/clients.test.ts +0 -0
  181. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/jobs.test.ts +0 -0
  182. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/navigation.test.ts +0 -0
  183. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/paging.test.ts +0 -0
  184. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/url-match.test.ts +0 -0
  185. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/extension/test/windows.test.ts +0 -0
  186. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/justfile +0 -0
  187. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/.claude/settings.local.json +0 -0
  188. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/.gitignore +0 -0
  189. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/README.md +0 -0
  190. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/credentials/BrowserCliApi.credentials.ts +0 -0
  191. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts +0 -0
  192. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg +0 -0
  193. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/nodes/BrowserCli/protocol.ts +0 -0
  194. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts +0 -0
  195. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts +0 -0
  196. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/package-lock.json +0 -0
  197. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/package.json +0 -0
  198. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/scripts/copy-assets.mjs +0 -0
  199. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/scripts/run-tests.mjs +0 -0
  200. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/test/protocol.test.ts +0 -0
  201. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/test/request.test.ts +0 -0
  202. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/test-dist/protocol.test.mjs +0 -0
  203. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/test-dist/request.test.mjs +0 -0
  204. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/n8n-nodes-browser-cli/tsconfig.json +0 -0
  205. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/package-lock.json +0 -0
  206. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/package.json +0 -0
  207. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/scripts/gen_extension_key.py +0 -0
  208. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/scripts/package_extension.py +0 -0
  209. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/scripts/package_verified_crx.sh +0 -0
  210. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/scripts/setup_verified_crx_key.sh +0 -0
  211. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/shell.nix +0 -0
  212. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/conftest.py +0 -0
  213. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_api.py +0 -0
  214. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_auth.py +0 -0
  215. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_cli.py +0 -0
  216. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_client.py +0 -0
  217. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_commands_cli.py +0 -0
  218. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_compat.py +0 -0
  219. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_dom.py +0 -0
  220. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_extension_error_page_handling.py +0 -0
  221. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_extension_packaging.py +0 -0
  222. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_extract.py +0 -0
  223. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_framing.py +0 -0
  224. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_groups.py +0 -0
  225. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_known_hosts.py +0 -0
  226. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_markdown_security.py +0 -0
  227. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_native_host.py +0 -0
  228. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_nav.py +0 -0
  229. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_page.py +0 -0
  230. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_perf.py +0 -0
  231. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_performance_integration.py +0 -0
  232. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_platform.py +0 -0
  233. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_refactor_boundaries.py +0 -0
  234. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_registry.py +0 -0
  235. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_remote_pool.py +0 -0
  236. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_remote_protocol_matrix.py +0 -0
  237. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_remote_registry.py +0 -0
  238. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_rendering.py +0 -0
  239. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_serve.py +0 -0
  240. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_serve_security.py +0 -0
  241. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_session.py +0 -0
  242. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_storage.py +0 -0
  243. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_tabs.py +0 -0
  244. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_transport.py +0 -0
  245. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tests/test_windows.py +0 -0
  246. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/tsconfig.json +0 -0
  247. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/webstore-assets/screenshots/screenshot-1.png +0 -0
  248. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/webstore-assets/screenshots/screenshot-2.png +0 -0
  249. {real_browser_cli-0.16.8 → real_browser_cli-0.16.9}/webstore-assets/screenshots/screenshot-3.png +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: real-browser-cli
3
- Version: 0.16.8
3
+ Version: 0.16.9
4
4
  Summary: Control your real running browser from the terminal or Python SDK
5
5
  Project-URL: Homepage, https://git.yiprawr.dev/Automatisation/browser-cli
6
6
  Project-URL: Repository, https://git.yiprawr.dev/Automatisation/browser-cli
@@ -275,6 +275,21 @@ optional `tab_id` and act on the active tab when it is omitted, so a caller
275
275
  does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
276
276
  reports the tab it closed.
277
277
 
278
+ `browser_tabs_open` does not duplicate pages. It takes over an already-open tab
279
+ whose URL is nearly the same and reports `reused` next to the tab metadata, so
280
+ an agent that re-opens a page keeps working in the tab the user already has.
281
+ The `reuse` argument selects the match:
282
+
283
+ | `reuse` | Matches |
284
+ |---------|---------|
285
+ | `similar` (default) | Same site and page, ignoring fragment, trailing slash, query order, `www.` and default ports — plus any tab of the site when the requested URL is its root |
286
+ | `exact` | Only a character-for-character identical URL |
287
+ | `origin` | Any tab of the same site, whatever its path |
288
+ | `none` | Never reuse, always open a new tab |
289
+
290
+ A reused tab is only navigated when it is not already on the requested page, so
291
+ no reload throws away page state.
292
+
278
293
  Available tools:
279
294
  - `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
280
295
  - `browser_navigate`, `browser_page_info`
@@ -340,6 +355,12 @@ browser-cli nav open https://example.com --focus # bring opened tab/w
340
355
  browser-cli nav open https://example.com --window work # into a named window
341
356
  browser-cli nav open https://example.com --group research # into a tab group (name or ID)
342
357
 
358
+ # Reuse an open tab instead of duplicating a page
359
+ browser-cli nav open https://example.com/app --reuse # nearly identical URL (fragment, trailing slash, query order, www)
360
+ browser-cli nav open https://example.com/app --reuse-exact # only a character-for-character match
361
+ browser-cli nav open https://example.com/app --reuse-domain # any tab of the same site
362
+ browser-cli nav open https://example.com/app --reuse-title "App" # tab whose title contains the text
363
+
343
364
  # Reload
344
365
  browser-cli nav reload # reload active tab
345
366
  browser-cli nav reload 1234 # reload tab by ID
@@ -552,6 +573,9 @@ b.nav.search("google", "python asyncio")
552
573
  tabs = b.tabs.list() # list[Tab]; in multi-browser mode each tab.browser is set
553
574
  tab = b.tabs.open("https://example.com") # returns a bound Tab object
554
575
  tab = b.tabs.open("https://example.com", wait=True, timeout=10)
576
+ tab = b.tabs.open("https://example.com/app", reuse=True) # reuse a tab with a nearly identical URL
577
+ tab = b.tabs.open("https://example.com/app", reuse="origin") # reuse any tab of that site
578
+ existing = b.nav.reusable_tab("https://example.com/app", reuse=True) # Tab or None, without touching the browser
555
579
  active = b.tabs.active() # active Tab object
556
580
  tab = b.tabs.get(1234) # tab by ID
557
581
  tab = b.tabs.first("github") # first matching tab or None
@@ -179,6 +179,21 @@ optional `tab_id` and act on the active tab when it is omitted, so a caller
179
179
  does not need a preceding `browser_tabs_list` round trip. `browser_tabs_close`
180
180
  reports the tab it closed.
181
181
 
182
+ `browser_tabs_open` does not duplicate pages. It takes over an already-open tab
183
+ whose URL is nearly the same and reports `reused` next to the tab metadata, so
184
+ an agent that re-opens a page keeps working in the tab the user already has.
185
+ The `reuse` argument selects the match:
186
+
187
+ | `reuse` | Matches |
188
+ |---------|---------|
189
+ | `similar` (default) | Same site and page, ignoring fragment, trailing slash, query order, `www.` and default ports — plus any tab of the site when the requested URL is its root |
190
+ | `exact` | Only a character-for-character identical URL |
191
+ | `origin` | Any tab of the same site, whatever its path |
192
+ | `none` | Never reuse, always open a new tab |
193
+
194
+ A reused tab is only navigated when it is not already on the requested page, so
195
+ no reload throws away page state.
196
+
182
197
  Available tools:
183
198
  - `browser_tabs_list`, `browser_tabs_open`, `browser_tabs_close`
184
199
  - `browser_navigate`, `browser_page_info`
@@ -244,6 +259,12 @@ browser-cli nav open https://example.com --focus # bring opened tab/w
244
259
  browser-cli nav open https://example.com --window work # into a named window
245
260
  browser-cli nav open https://example.com --group research # into a tab group (name or ID)
246
261
 
262
+ # Reuse an open tab instead of duplicating a page
263
+ browser-cli nav open https://example.com/app --reuse # nearly identical URL (fragment, trailing slash, query order, www)
264
+ browser-cli nav open https://example.com/app --reuse-exact # only a character-for-character match
265
+ browser-cli nav open https://example.com/app --reuse-domain # any tab of the same site
266
+ browser-cli nav open https://example.com/app --reuse-title "App" # tab whose title contains the text
267
+
247
268
  # Reload
248
269
  browser-cli nav reload # reload active tab
249
270
  browser-cli nav reload 1234 # reload tab by ID
@@ -456,6 +477,9 @@ b.nav.search("google", "python asyncio")
456
477
  tabs = b.tabs.list() # list[Tab]; in multi-browser mode each tab.browser is set
457
478
  tab = b.tabs.open("https://example.com") # returns a bound Tab object
458
479
  tab = b.tabs.open("https://example.com", wait=True, timeout=10)
480
+ tab = b.tabs.open("https://example.com/app", reuse=True) # reuse a tab with a nearly identical URL
481
+ tab = b.tabs.open("https://example.com/app", reuse="origin") # reuse any tab of that site
482
+ existing = b.nav.reusable_tab("https://example.com/app", reuse=True) # Tab or None, without touching the browser
459
483
  active = b.tabs.active() # active Tab object
460
484
  tab = b.tabs.get(1234) # tab by ID
461
485
  tab = b.tabs.first("github") # first matching tab or None
@@ -13,13 +13,14 @@ def nav_group():
13
13
  @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
14
14
  @click.option("--window", "window_name", default=None, help="Open in named window")
15
15
  @click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
16
- @click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
16
+ @click.option("--reuse", is_flag=True, help="Reuse an existing tab with a nearly identical URL (ignores fragment, trailing slash, query order)")
17
+ @click.option("--reuse-exact", is_flag=True, help="Reuse an existing tab only when its URL matches character for character")
17
18
  @click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
18
19
  @click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
19
20
  @handle_errors
20
- def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
21
+ def cmd_open(url, focus, window_name, group_name, reuse, reuse_exact, reuse_domain, reuse_title):
21
22
  """Open URL in a new tab without stealing focus by default."""
22
- client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
23
+ client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse="exact" if reuse_exact else reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
23
24
  suffix = ""
24
25
  if group_name:
25
26
  suffix = f" in group '{group_name}'"
@@ -76,13 +77,14 @@ def cmd_focus(pattern):
76
77
  @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
77
78
  @click.option("--window", "window_name", default=None, help="Open in named window")
78
79
  @click.option("--group", "group_name", default=None, help="Open in tab group")
79
- @click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
80
+ @click.option("--reuse", is_flag=True, help="Reuse an existing tab with a nearly identical URL (ignores fragment, trailing slash, query order)")
81
+ @click.option("--reuse-exact", is_flag=True, help="Reuse an existing tab only when its URL matches character for character")
80
82
  @click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
81
83
  @click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
82
84
  @handle_errors
83
- def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
85
+ def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_exact, reuse_domain, reuse_title):
84
86
  """Open URL in a new tab and wait until fully loaded."""
85
- tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
87
+ tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse="exact" if reuse_exact else reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
86
88
  console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
87
89
 
88
90
  @nav_group.command("wait")
@@ -12,12 +12,12 @@ import json
12
12
  import os
13
13
  from collections.abc import Callable
14
14
  from typing import Any, Literal
15
- from urllib.parse import urlsplit
16
15
 
17
16
  from browser_cli import BrowserCLI
18
17
  from browser_cli.mcp.naming import resolve_tool_prefix, tool_name
19
18
  from browser_cli.mcp.serialization import structured
20
19
  from browser_cli.mcp.targets import resolve_tab_id
20
+ from browser_cli.url_match import find_reusable_tab, needs_navigation, tab_id as read_tab_id, tab_url
21
21
 
22
22
  ClientFactory = Callable[..., BrowserCLI]
23
23
 
@@ -25,6 +25,11 @@ _SERVER_INSTRUCTIONS = """Control a real, user-visible browser through browser-c
25
25
  The server is stateless: pass browser, remote, and key on each tool call when a
26
26
  specific target is required. Tool calls affect the user's actual browser. Read
27
27
  current tabs/page state instead of assuming IDs or content from an earlier call.
28
+ Reuse before opening: tabs_open takes over an already-open tab whose URL is
29
+ nearly the same (same site and page, or any tab of that site when you ask for
30
+ its root) and reports reused=true with that tab's ID. Pass reuse="none" only
31
+ when a separate tab is really wanted, and reuse="origin" to take over any tab
32
+ of the site. Use tabs_list(search=...) first when you want to pick the tab.
28
33
  """
29
34
 
30
35
  def _client(factory: ClientFactory, browser: str | None, remote: str | None, key: str | None) -> BrowserCLI:
@@ -74,7 +79,10 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
74
79
  remote: str | None = None,
75
80
  key: str | None = None,
76
81
  ) -> list[dict[str, Any]]:
77
- """List current tabs, optionally filtering by URL or title substring."""
82
+ """List current tabs, optionally filtering by URL or title substring.
83
+
84
+ Check this before opening anything so an already-open page gets reused.
85
+ """
78
86
  tabs = structured(_client(client_factory, browser, remote, key).tabs.list())
79
87
  if not search:
80
88
  return tabs
@@ -92,15 +100,29 @@ def create_server(*, client_factory: ClientFactory = BrowserCLI, tool_prefix: st
92
100
  timeout: float = 30.0,
93
101
  background: bool = False,
94
102
  focus: bool = False,
103
+ reuse: Literal["similar", "exact", "origin", "none"] = "similar",
95
104
  browser: str | None = None,
96
105
  remote: str | None = None,
97
106
  key: str | None = None,
98
107
  ) -> dict[str, Any]:
99
- """Open a URL in a new real-browser tab and return its current metadata."""
100
- tab = _client(client_factory, browser, remote, key).tabs.open(
101
- url, wait=wait, timeout=timeout, background=background, focus=focus
102
- )
103
- return structured(tab)
108
+ """Open a URL in the real browser, reusing an already-open tab with a nearly identical URL.
109
+
110
+ Returns the tab metadata plus ``reused``. ``reuse`` selects the match:
111
+ ``similar`` (default: same site and page, or any tab of that site when the
112
+ requested URL is its root), ``exact``, ``origin`` (any tab of the site), or
113
+ ``none`` to always create a new tab.
114
+ """
115
+ client = _client(client_factory, browser, remote, key)
116
+ existing = find_reusable_tab(structured(client.tabs.list()), url, reuse)
117
+ if existing is not None:
118
+ target = read_tab_id(existing)
119
+ if needs_navigation(tab_url(existing), url):
120
+ client.nav.to(target, url)
121
+ if focus:
122
+ client.tabs.activate(target)
123
+ return {**structured(client.tabs.status(target)), "reused": True}
124
+ tab = client.tabs.open(url, wait=wait, timeout=timeout, background=background, focus=focus)
125
+ return {**structured(tab), "reused": False}
104
126
 
105
127
  @mcp.tool(name=tool_name("tabs_close", prefix))
106
128
  def tabs_close(
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from browser_cli.models import Tab
5
5
  from browser_cli.sdk.base import Namespace, sdk_command
6
+ from browser_cli.url_match import find_reusable_tab, needs_navigation, normalize_mode
6
7
 
7
8
  def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
8
9
  return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
@@ -21,18 +22,20 @@ class NavigationNS(Namespace):
21
22
  focus: bool = False,
22
23
  window: str | None = None,
23
24
  group: str | None = None,
24
- reuse: bool = False,
25
+ reuse: bool | str = False,
25
26
  reuse_domain: bool = False,
26
27
  reuse_title: str | None = None,
27
28
  ) -> None:
28
29
  """Open *url* in a new tab without stealing OS focus by default.
29
30
 
30
31
  ``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
31
- instead of creating a new one.
32
+ instead of creating a new one. ``reuse=True`` matches nearly identical URLs;
33
+ pass ``reuse='exact'`` or ``reuse='origin'`` to tighten or widen that.
32
34
  """
33
- tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
35
+ tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
34
36
  if tab is not None:
35
- self.to(tab.id, url)
37
+ if needs_navigation(tab.url, url):
38
+ self.to(tab.id, url)
36
39
  if focus:
37
40
  self._c.tabs.activate(tab.id)
38
41
  return None
@@ -48,14 +51,15 @@ class NavigationNS(Namespace):
48
51
  focus: bool = False,
49
52
  window: str | None = None,
50
53
  group: str | None = None,
51
- reuse: bool = False,
54
+ reuse: bool | str = False,
52
55
  reuse_domain: bool = False,
53
56
  reuse_title: str | None = None,
54
57
  ) -> Tab:
55
58
  """Open *url* in a new tab and block until fully loaded. Returns the Tab."""
56
- tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
59
+ tab = self.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
57
60
  if tab is not None:
58
- self.to(tab.id, url)
61
+ if needs_navigation(tab.url, url):
62
+ self.to(tab.id, url)
59
63
  if focus:
60
64
  self._c.tabs.activate(tab.id)
61
65
  return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
@@ -91,21 +95,31 @@ class NavigationNS(Namespace):
91
95
  def to(self, tab_id: int, url: str) -> None:
92
96
  """Navigate a specific tab to *url* in place."""
93
97
 
94
- def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
95
- if not (reuse or reuse_domain or reuse_title):
98
+ def reusable_tab(
99
+ self,
100
+ url: str,
101
+ *,
102
+ reuse: bool | str = False,
103
+ reuse_domain: bool = False,
104
+ reuse_title: str | None = None,
105
+ ) -> Tab | None:
106
+ """Return the open tab that already fits *url*, or ``None``.
107
+
108
+ ``reuse`` takes ``True`` (nearly identical URL), ``'exact'``, ``'similar'``,
109
+ ``'origin'`` or ``'none'``; ``reuse_title`` falls back to a title substring.
110
+ """
111
+ mode = normalize_mode(reuse, reuse_domain)
112
+ if mode == "none" and not reuse_title:
96
113
  return None
97
- from urllib.parse import urlparse
98
- wanted = urlparse(url)
99
- wanted_host = wanted.netloc.lower()
100
- for tab in self._c.tabs.list():
101
- tab_url = tab.url or ""
102
- parsed = urlparse(tab_url)
103
- if reuse and tab_url == url:
104
- return tab
105
- if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
106
- return tab
107
- if reuse_title and reuse_title.lower() in (tab.title or "").lower():
108
- return tab
114
+ tabs = self._c.tabs.list()
115
+ match = find_reusable_tab(tabs, url, mode)
116
+ if match is not None:
117
+ return match
118
+ if reuse_title:
119
+ needle = reuse_title.lower()
120
+ for tab in tabs:
121
+ if needle in (tab.title or "").lower():
122
+ return tab
109
123
  return None
110
124
 
111
125
  def search(
@@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable
5
5
 
6
6
  from browser_cli.models import BrowserCounts, Tab
7
7
  from browser_cli.sdk.base import Namespace
8
+ from browser_cli.url_match import needs_navigation
8
9
 
9
10
  # Keep SDK-driven bulk closes comfortably below the native-host response
10
11
  # timeout. The extension can close larger batches, but real browsers may take
@@ -32,14 +33,29 @@ class TabsNS(Namespace):
32
33
  focus: bool = False,
33
34
  window: str | None = None,
34
35
  group: str | None = None,
36
+ reuse: bool | str = False,
37
+ reuse_domain: bool = False,
38
+ reuse_title: str | None = None,
35
39
  ) -> Tab:
36
40
  """Open *url* in a new tab and return a bound :class:`Tab`.
37
41
 
38
42
  Set ``wait=True`` to block until the page reaches ``readyState=complete``.
39
43
  Pass ``focus=True`` to explicitly bring the created tab/window forward.
44
+ ``reuse=True`` navigates an already-open tab with a nearly identical URL
45
+ instead of creating a duplicate.
40
46
  """
41
47
  if wait:
42
- return self._c.nav.open_wait(url, timeout=timeout, background=background, focus=focus, window=window, group=group)
48
+ return self._c.nav.open_wait(
49
+ url, timeout=timeout, background=background, focus=focus, window=window, group=group,
50
+ reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title,
51
+ )
52
+ existing = self._c.nav.reusable_tab(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
53
+ if existing is not None:
54
+ if needs_navigation(existing.url, url):
55
+ self._c.nav.to(existing.id, url)
56
+ if focus:
57
+ self.activate(existing.id)
58
+ return self.status(existing.id)
43
59
  return self.require_tab(
44
60
  self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}),
45
61
  "navigate.open returned unexpected data",
@@ -0,0 +1,138 @@
1
+ """Match a requested URL against already-open tabs.
2
+
3
+ Agents and scripts usually want the tab that already shows a page instead of a
4
+ duplicate of it. These helpers answer "is this tab already (nearly) the URL I
5
+ am about to open?" without touching the browser.
6
+
7
+ Modes:
8
+ ``none`` never reuse a tab
9
+ ``exact`` only a byte-identical URL
10
+ ``similar`` same site and page, ignoring fragment, trailing slash, query
11
+ order, ``www.`` and default ports; a request for the site root
12
+ also matches any tab on that site
13
+ ``origin`` any tab on the same site, whatever its path
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Any, Literal
19
+ from urllib.parse import parse_qsl, urlsplit
20
+
21
+ ReuseMode = Literal["none", "exact", "similar", "origin"]
22
+
23
+ REUSE_MODES: tuple[str, ...] = ("none", "exact", "similar", "origin")
24
+
25
+ _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
26
+ _ROOT_PATHS = {"", "/"}
27
+
28
+ @dataclass(frozen=True)
29
+ class UrlKey:
30
+ """Canonical, comparable form of a URL."""
31
+
32
+ scheme: str
33
+ host: str
34
+ port: int | None
35
+ path: str
36
+ query: tuple[tuple[str, str], ...]
37
+
38
+ @property
39
+ def origin(self) -> tuple[str, str, int | None]:
40
+ return (self.scheme, self.host, self.port)
41
+
42
+ @property
43
+ def is_root(self) -> bool:
44
+ return self.path == "" and not self.query
45
+
46
+ def url_key(url: str | None) -> UrlKey | None:
47
+ """Return the canonical key of *url*, or ``None`` when it is not comparable."""
48
+ if not url:
49
+ return None
50
+ parts = urlsplit(url.strip())
51
+ if not parts.scheme or not parts.hostname:
52
+ return None
53
+ scheme = parts.scheme.lower()
54
+ host = parts.hostname.lower()
55
+ if host.startswith("www.") and len(host) > 4:
56
+ host = host[4:]
57
+ port = parts.port
58
+ if port is not None and _DEFAULT_PORTS.get(scheme) == port:
59
+ port = None
60
+ path = parts.path.rstrip("/")
61
+ query = tuple(sorted(parse_qsl(parts.query, keep_blank_values=True)))
62
+ return UrlKey(scheme=scheme, host=host, port=port, path=path, query=query)
63
+
64
+ def normalize_mode(reuse: bool | str | None, reuse_domain: bool = False) -> ReuseMode:
65
+ """Fold the boolean flags and explicit mode names into one :data:`ReuseMode`."""
66
+ if reuse_domain:
67
+ return "origin"
68
+ if reuse is True:
69
+ return "similar"
70
+ if reuse is False or reuse is None:
71
+ return "none"
72
+ mode = str(reuse).lower()
73
+ if mode not in REUSE_MODES:
74
+ raise ValueError(f"Unknown reuse mode '{reuse}'. Use one of: {', '.join(REUSE_MODES)}")
75
+ return mode # type: ignore[return-value]
76
+
77
+ def urls_match(url: str, candidate: str | None, mode: ReuseMode) -> bool:
78
+ """Report whether *candidate* may be reused for *url* under *mode*."""
79
+ if mode == "none" or not candidate:
80
+ return False
81
+ if mode == "exact":
82
+ return candidate == url
83
+ wanted, other = url_key(url), url_key(candidate)
84
+ if wanted is None or other is None:
85
+ return False
86
+ if wanted.origin != other.origin:
87
+ return False
88
+ if mode == "origin":
89
+ return True
90
+ return wanted.path == other.path or wanted.is_root
91
+
92
+ def needs_navigation(current: str | None, url: str) -> bool:
93
+ """Report whether a reused tab still has to be navigated to *url*."""
94
+ if current == url:
95
+ return False
96
+ wanted, other = url_key(url), url_key(current)
97
+ return wanted is None or other is None or wanted != other
98
+
99
+ def tab_url(tab: Any) -> str | None:
100
+ """Read a tab URL from an SDK ``Tab`` or a serialized tab dict."""
101
+ if isinstance(tab, dict):
102
+ value = tab.get("url")
103
+ else:
104
+ value = getattr(tab, "url", None)
105
+ return value if isinstance(value, str) else None
106
+
107
+ def tab_id(tab: Any) -> int | None:
108
+ """Read a tab ID from an SDK ``Tab`` or a serialized tab dict."""
109
+ value = tab.get("id") if isinstance(tab, dict) else getattr(tab, "id", None)
110
+ return value if isinstance(value, int) else None
111
+
112
+ def find_reusable_tab(tabs: Any, url: str, mode: ReuseMode):
113
+ """Return the open tab that best fits *url*, or ``None``.
114
+
115
+ Closer matches win: an identical URL beats a canonical match, which beats a
116
+ looser site-level match; among equally loose matches the tab closest to the
117
+ requested page wins.
118
+ """
119
+ if mode == "none":
120
+ return None
121
+ wanted = url_key(url)
122
+ best, best_rank = None, None
123
+ for tab in tabs or ():
124
+ candidate = tab_url(tab)
125
+ if not urls_match(url, candidate, mode):
126
+ continue
127
+ rank = _rank(wanted, candidate)
128
+ if best_rank is None or rank < best_rank:
129
+ best, best_rank = tab, rank
130
+ return best
131
+
132
+ def _rank(wanted: UrlKey | None, candidate: str | None) -> tuple[int, int]:
133
+ """Lower is a closer match: exact string, canonical, then shallowest path."""
134
+ other = url_key(candidate)
135
+ if wanted is not None and other is not None and wanted == other:
136
+ return (0, 0)
137
+ depth = other.path.count("/") if other is not None else 99
138
+ return (1, depth)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "browser-cli",
4
- "version": "0.16.8",
4
+ "version": "0.16.9",
5
5
  "description": "Control your browser from the terminal or Python SDK",
6
6
  "browser_specific_settings": {
7
7
  "gecko": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "real-browser-cli"
3
- version = "0.16.8"
3
+ version = "0.16.9"
4
4
  description = "Control your real running browser from the terminal or Python SDK"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -27,6 +27,7 @@ class FakeClient:
27
27
  open=self.tabs_open,
28
28
  close=self.tabs_close,
29
29
  active=self.tabs_active,
30
+ activate=self.tabs_activate,
30
31
  status=self.tabs_status,
31
32
  screenshot=self.tabs_screenshot,
32
33
  )
@@ -54,6 +55,9 @@ class FakeClient:
54
55
  self.calls.append(("active",))
55
56
  return SimpleNamespace(id=7)
56
57
 
58
+ def tabs_activate(self, tab_id):
59
+ self.calls.append(("activate", tab_id))
60
+
57
61
  def tabs_status(self, tab_id):
58
62
  self.calls.append(("status", tab_id))
59
63
  return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
@@ -207,6 +211,7 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
207
211
  "url": "https://example.com",
208
212
  "wait": True,
209
213
  "focus": True,
214
+ "reuse": "none",
210
215
  })
211
216
  navigated = await client.call_tool("browser_navigate", {
212
217
  "tab_id": 8,
@@ -216,7 +221,7 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
216
221
  typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
217
222
 
218
223
  assert opened.structured_content == {
219
- "id": 8, "title": "Opened", "url": "https://example.com"
224
+ "id": 8, "title": "Opened", "url": "https://example.com", "reused": False
220
225
  }
221
226
  assert navigated.structured_content["id"] == 8
222
227
  assert clicked.structured_content["url"] == "https://example.com"
@@ -230,6 +235,36 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
230
235
  ("navigate", 8, "https://example.com/next"), ("status", 8)
231
236
  ]
232
237
 
238
+ @pytest.mark.anyio
239
+ async def test_tabs_open_reuses_a_tab_that_already_shows_the_page(client):
240
+ opened = await client.call_tool("browser_tabs_open", {"url": "https://www.example.com/#top"})
241
+
242
+ assert opened.structured_content == {
243
+ "id": 7, "title": "Navigated", "url": "https://example.com/next", "reused": True
244
+ }
245
+ assert FakeClient.instances[0].calls == [("status", 7)] # already there → no navigation
246
+
247
+ @pytest.mark.anyio
248
+ async def test_tabs_open_reuses_a_site_tab_and_navigates_it(client):
249
+ opened = await client.call_tool(
250
+ "browser_tabs_open",
251
+ {"url": "https://browser-host.example/app?view=logs", "focus": True},
252
+ )
253
+
254
+ assert opened.structured_content["reused"] is True
255
+ assert FakeClient.instances[0].calls == [
256
+ ("navigate", 9, "https://browser-host.example/app?view=logs"),
257
+ ("activate", 9),
258
+ ("status", 9),
259
+ ]
260
+
261
+ @pytest.mark.anyio
262
+ async def test_tabs_open_does_not_reuse_an_unrelated_tab(client):
263
+ opened = await client.call_tool("browser_tabs_open", {"url": "https://other.example/docs"})
264
+
265
+ assert opened.structured_content["reused"] is False
266
+ assert FakeClient.instances[0].calls[0][0] == "open"
267
+
233
268
  @pytest.mark.anyio
234
269
  async def test_tab_tools_default_to_the_active_tab(client):
235
270
  navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
@@ -34,21 +34,60 @@ def test_session_export_cli_prints_json():
34
34
  assert result.exit_code == 0
35
35
  assert '"name": "work"' in result.output
36
36
 
37
- def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
38
- calls = []
37
+ def _reuse_sender(calls, tab_url="https://example.com"):
38
+ tab = {"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": tab_url}
39
39
 
40
40
  def sender(command, args=None, **kwargs):
41
41
  calls.append((command, args))
42
42
  if command == "tabs.list":
43
- return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
43
+ return [tab]
44
+ if command in {"tabs.status", "navigate.open"}:
45
+ return dict(tab)
44
46
  return {}
47
+ return sender
48
+
49
+ def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
50
+ calls = []
45
51
 
46
- BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
52
+ BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls, "https://example.com/app?a=1")).nav.open(
53
+ "https://example.com/app?a=2", reuse=True
54
+ )
47
55
  assert calls == [
48
56
  ("tabs.list", {}),
49
- ("navigate.to", {"tabId": 7, "url": "https://example.com"}),
57
+ ("navigate.to", {"tabId": 7, "url": "https://example.com/app?a=2"}),
50
58
  ]
51
59
 
60
+ def test_nav_open_reuse_matches_nearly_identical_urls():
61
+ calls = []
62
+
63
+ BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls, "https://example.com/app?a=1&b=2")).nav.open(
64
+ "https://www.example.com/app/?b=2&a=1#top", reuse=True
65
+ )
66
+ assert calls == [("tabs.list", {})] # same page already open → no reload, no new tab
67
+
68
+ def test_nav_open_reuse_exact_opens_a_new_tab_for_a_different_url():
69
+ calls = []
70
+
71
+ BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).nav.open(
72
+ "https://example.com/", reuse="exact"
73
+ )
74
+ assert [command for command, _ in calls] == ["tabs.list", "navigate.open"]
75
+
76
+ def test_tabs_open_reuse_returns_the_existing_tab():
77
+ calls = []
78
+
79
+ tab = BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).tabs.open(
80
+ "https://example.com/deep/page", reuse="origin"
81
+ )
82
+ assert tab.id == 7
83
+ assert [command for command, _ in calls] == ["tabs.list", "navigate.to", "tabs.status"]
84
+
85
+ def test_tabs_open_without_reuse_always_creates_a_tab():
86
+ calls = []
87
+
88
+ BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).tabs.open("https://example.com")
89
+ assert [command for command, _ in calls] == ["navigate.open"]
90
+
52
91
  def _tree_sender(tabs, groups):
53
92
  def sender(command, args=None, **kwargs):
54
93
  if command == "tabs.list":