unique-sdk 2026.28.0.dev15__tar.gz → 2026.28.0.dev16__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 (87) hide show
  1. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/PKG-INFO +1 -1
  2. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/pyproject.toml +1 -1
  3. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/cli.py +403 -0
  4. unique_sdk-2026.28.0.dev16/unique_sdk/cli/commands/browser.py +354 -0
  5. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/README.md +0 -0
  6. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/__init__.py +0 -0
  7. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_requestor.py +0 -0
  8. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_resource.py +0 -0
  9. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_version.py +0 -0
  10. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_error.py +0 -0
  11. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_http_client.py +0 -0
  12. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_list_object.py +0 -0
  13. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_object_classes.py +0 -0
  14. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_request_options.py +0 -0
  15. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_object.py +0 -0
  16. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_ql.py +0 -0
  17. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_response.py +0 -0
  18. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_util.py +0 -0
  19. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_version.py +0 -0
  20. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/_webhook.py +0 -0
  21. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/__init__.py +0 -0
  22. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_acronyms.py +0 -0
  23. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_agentic_table.py +0 -0
  24. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_analytics_order.py +0 -0
  25. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_benchmarking.py +0 -0
  26. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_briefing.py +0 -0
  27. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_chat_completion.py +0 -0
  28. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_content.py +0 -0
  29. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  30. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_elicitation.py +0 -0
  31. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_embedding.py +0 -0
  32. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_event.py +0 -0
  33. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_folder.py +0 -0
  34. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_group.py +0 -0
  35. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_integrated.py +0 -0
  36. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_llm_models.py +0 -0
  37. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_mcp.py +0 -0
  38. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message.py +0 -0
  39. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_assessment.py +0 -0
  40. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_execution.py +0 -0
  41. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_log.py +0 -0
  42. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_tool.py +0 -0
  43. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_module.py +0 -0
  44. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  45. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search.py +0 -0
  46. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search_string.py +0 -0
  47. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  48. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_space.py +0 -0
  49. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_user.py +0 -0
  50. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_web_search.py +0 -0
  51. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/__init__.py +0 -0
  52. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/__main__.py +0 -0
  53. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/__init__.py +0 -0
  54. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  55. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/cite_file.py +0 -0
  56. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  57. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/elicitation.py +0 -0
  58. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/files.py +0 -0
  59. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/folders.py +0 -0
  60. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/mcp.py +0 -0
  61. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/navigation.py +0 -0
  62. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/read.py +0 -0
  63. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  64. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/search.py +0 -0
  65. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/subagent.py +0 -0
  66. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/web_search.py +0 -0
  67. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/web_search_config.py +0 -0
  68. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/config.py +0 -0
  69. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/formatting.py +0 -0
  70. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/metadata_filter.py +0 -0
  71. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/shell.py +0 -0
  72. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
  73. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  74. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  75. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  76. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  77. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  78. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  79. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  80. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/state.py +0 -0
  81. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/analytics_order_run.py +0 -0
  82. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/benchmarking_run.py +0 -0
  83. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/chat_history.py +0 -0
  84. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/chat_in_space.py +0 -0
  85. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/file_io.py +0 -0
  86. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/sources.py +0 -0
  87. {unique_sdk-2026.28.0.dev15 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.28.0.dev15
3
+ Version: 2026.28.0.dev16
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.28.0.dev15"
3
+ version = "2026.28.0.dev16"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -2,12 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from collections.abc import Callable
6
7
  from typing import Any
7
8
 
8
9
  import click
9
10
 
10
11
  from unique_sdk.cli import __version__
12
+ from unique_sdk.cli.commands.browser import (
13
+ cmd_browser_action,
14
+ cmd_browser_control,
15
+ cmd_browser_download,
16
+ cmd_browser_status,
17
+ )
18
+ from unique_sdk.cli.commands.browser import (
19
+ is_error_output as _is_browser_error_output,
20
+ )
11
21
  from unique_sdk.cli.commands.cite_file import cmd_cite_file
12
22
  from unique_sdk.cli.commands.cite_file import (
13
23
  is_error_output as _is_cite_error_output,
@@ -129,6 +139,7 @@ Examples:
129
139
  unique-cli web-search search "x" Search the web via the public API
130
140
  unique-cli web-search crawl URL Crawl a URL via the public API
131
141
  unique-cli dynamic-frontend list List manageable Dynamic Frontend spaces
142
+ unique-cli browser get-dom Read the user's live Chrome tab (a11y tree)
132
143
  """
133
144
 
134
145
 
@@ -1550,6 +1561,398 @@ def elicit_respond(
1550
1561
  )
1551
1562
 
1552
1563
 
1564
+ # -- Browser Steering ------------------------------------------------------
1565
+
1566
+
1567
+ _BROWSER_HELP = """\
1568
+ Steer the user's live, signed-in Chrome tab via the Unique browser extension.
1569
+
1570
+ \b
1571
+ Commands talk to the browser-bridge relay, which forwards each action to the
1572
+ extension over the user's outbound WebSocket. You never see the page directly —
1573
+ work from the DOM snapshot `get-dom` returns.
1574
+
1575
+ \b
1576
+ Core loop:
1577
+ 1. read unique-cli browser get-dom (pruned a11y tree + `ref`s)
1578
+ 2. act unique-cli browser click --ref e42 (use a ref from the latest DOM)
1579
+ 3. re-read after anything that changes the page; refs are per-snapshot only.
1580
+
1581
+ \b
1582
+ Every subcommand prints a JSON envelope: {"ok": true, "result": ...} or
1583
+ {"ok": false, "error": "<code>", ...}. On `browser_not_connected`, relay the
1584
+ remediation to the user and stop until the extension is installed and signed in.
1585
+
1586
+ \b
1587
+ Examples:
1588
+ unique-cli browser status
1589
+ unique-cli browser get-dom
1590
+ unique-cli browser navigate --url https://example.com
1591
+ unique-cli browser click --ref e42
1592
+ unique-cli browser fill --ref e10 --text "hello@unique.ch"
1593
+ unique-cli browser download "https://portal/report.pdf" ./output/report.pdf
1594
+ """
1595
+
1596
+
1597
+ def _browser_target_args(ref: str | None, selector: str | None) -> dict[str, Any]:
1598
+ """Build the {ref|selector} arg map, preferring an explicit ref.
1599
+
1600
+ Empty strings are treated as absent so ``--ref ""`` / ``--selector ""``
1601
+ cannot bypass the missing-target guard and reach the bridge.
1602
+ """
1603
+ args: dict[str, Any] = {}
1604
+ if ref:
1605
+ args["ref"] = ref
1606
+ if selector:
1607
+ args["selector"] = selector
1608
+ return args
1609
+
1610
+
1611
+ def _browser_missing_target(verb: str) -> str:
1612
+ """JSON error envelope for a verb invoked without --ref or --selector."""
1613
+ return json.dumps(
1614
+ {
1615
+ "ok": False,
1616
+ "error": "browser_missing_target",
1617
+ "message": f"{verb} requires --ref (from the latest get-dom) or --selector.",
1618
+ },
1619
+ indent=2,
1620
+ )
1621
+
1622
+
1623
+ def _browser_missing_condition(verb: str, required: str) -> str:
1624
+ """JSON error envelope for a verb invoked without a required condition."""
1625
+ return json.dumps(
1626
+ {
1627
+ "ok": False,
1628
+ "error": "browser_missing_target",
1629
+ "message": f"{verb} requires {required}.",
1630
+ },
1631
+ indent=2,
1632
+ )
1633
+
1634
+
1635
+ @main.group(help=_BROWSER_HELP)
1636
+ def browser() -> None:
1637
+ pass
1638
+
1639
+
1640
+ @browser.command(name="status")
1641
+ @click.pass_context
1642
+ def browser_status(ctx: click.Context) -> None:
1643
+ """Check whether a browser extension is connected for this user."""
1644
+ emit(cmd_browser_status(LazyState.get(ctx)), is_error=_is_browser_error_output)
1645
+
1646
+
1647
+ @browser.command(name="get-dom")
1648
+ @click.option(
1649
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1650
+ )
1651
+ @click.pass_context
1652
+ def browser_get_dom(ctx: click.Context, tab_id: int | None) -> None:
1653
+ """Return a pruned accessibility tree with `ref` handles for the active tab."""
1654
+ emit(
1655
+ cmd_browser_action(LazyState.get(ctx), "get-dom", {}, tab_id=tab_id),
1656
+ is_error=_is_browser_error_output,
1657
+ )
1658
+
1659
+
1660
+ @browser.command(name="screenshot")
1661
+ @click.option(
1662
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1663
+ )
1664
+ @click.pass_context
1665
+ def browser_screenshot(ctx: click.Context, tab_id: int | None) -> None:
1666
+ """Capture a PNG of the visible tab (returned as a data URL). Costs tokens."""
1667
+ emit(
1668
+ cmd_browser_action(LazyState.get(ctx), "screenshot", {}, tab_id=tab_id),
1669
+ is_error=_is_browser_error_output,
1670
+ )
1671
+
1672
+
1673
+ @browser.command(name="navigate")
1674
+ @click.option("--url", required=True, help="URL to load in the active tab.")
1675
+ @click.option(
1676
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1677
+ )
1678
+ @click.pass_context
1679
+ def browser_navigate(ctx: click.Context, url: str, tab_id: int | None) -> None:
1680
+ """Load a URL in the active tab and wait for load."""
1681
+ emit(
1682
+ cmd_browser_action(LazyState.get(ctx), "navigate", {"url": url}, tab_id=tab_id),
1683
+ is_error=_is_browser_error_output,
1684
+ )
1685
+
1686
+
1687
+ @browser.command(name="click")
1688
+ @click.option("--ref", default=None, help="Element ref from the latest get-dom.")
1689
+ @click.option("--selector", default=None, help="CSS selector (fallback when no ref).")
1690
+ @click.option(
1691
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1692
+ )
1693
+ @click.pass_context
1694
+ def browser_click(
1695
+ ctx: click.Context, ref: str | None, selector: str | None, tab_id: int | None
1696
+ ) -> None:
1697
+ """Click an element by `--ref` (preferred) or `--selector`."""
1698
+ if not ref and not selector:
1699
+ emit(
1700
+ _browser_missing_target("click"),
1701
+ is_error=_is_browser_error_output,
1702
+ )
1703
+ return
1704
+ args = _browser_target_args(ref, selector)
1705
+ emit(
1706
+ cmd_browser_action(LazyState.get(ctx), "click", args, tab_id=tab_id),
1707
+ is_error=_is_browser_error_output,
1708
+ )
1709
+
1710
+
1711
+ @browser.command(name="type")
1712
+ @click.option("--text", required=True, help="Text to append into the field.")
1713
+ @click.option("--ref", default=None, help="Element ref from the latest get-dom.")
1714
+ @click.option("--selector", default=None, help="CSS selector (fallback when no ref).")
1715
+ @click.option(
1716
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1717
+ )
1718
+ @click.pass_context
1719
+ def browser_type(
1720
+ ctx: click.Context,
1721
+ text: str,
1722
+ ref: str | None,
1723
+ selector: str | None,
1724
+ tab_id: int | None,
1725
+ ) -> None:
1726
+ """Append text into a field (does not clear existing value; see `fill`)."""
1727
+ if not ref and not selector:
1728
+ emit(_browser_missing_target("type"), is_error=_is_browser_error_output)
1729
+ return
1730
+ args = _browser_target_args(ref, selector)
1731
+ args["text"] = text
1732
+ emit(
1733
+ cmd_browser_action(LazyState.get(ctx), "type", args, tab_id=tab_id),
1734
+ is_error=_is_browser_error_output,
1735
+ )
1736
+
1737
+
1738
+ @browser.command(name="fill")
1739
+ @click.option("--text", required=True, help="Replacement value for the field.")
1740
+ @click.option("--ref", default=None, help="Element ref from the latest get-dom.")
1741
+ @click.option("--selector", default=None, help="CSS selector (fallback when no ref).")
1742
+ @click.option(
1743
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1744
+ )
1745
+ @click.pass_context
1746
+ def browser_fill(
1747
+ ctx: click.Context,
1748
+ text: str,
1749
+ ref: str | None,
1750
+ selector: str | None,
1751
+ tab_id: int | None,
1752
+ ) -> None:
1753
+ """Replace a field's entire value."""
1754
+ if not ref and not selector:
1755
+ emit(_browser_missing_target("fill"), is_error=_is_browser_error_output)
1756
+ return
1757
+ args = _browser_target_args(ref, selector)
1758
+ args["text"] = text
1759
+ emit(
1760
+ cmd_browser_action(LazyState.get(ctx), "fill", args, tab_id=tab_id),
1761
+ is_error=_is_browser_error_output,
1762
+ )
1763
+
1764
+
1765
+ @browser.command(name="select")
1766
+ @click.option("--value", required=True, help="Option value to select.")
1767
+ @click.option("--ref", default=None, help="Element ref from the latest get-dom.")
1768
+ @click.option("--selector", default=None, help="CSS selector (fallback when no ref).")
1769
+ @click.option(
1770
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1771
+ )
1772
+ @click.pass_context
1773
+ def browser_select(
1774
+ ctx: click.Context,
1775
+ value: str,
1776
+ ref: str | None,
1777
+ selector: str | None,
1778
+ tab_id: int | None,
1779
+ ) -> None:
1780
+ """Choose a `<select>` option by value."""
1781
+ if not ref and not selector:
1782
+ emit(_browser_missing_target("select"), is_error=_is_browser_error_output)
1783
+ return
1784
+ args = _browser_target_args(ref, selector)
1785
+ args["value"] = value
1786
+ emit(
1787
+ cmd_browser_action(LazyState.get(ctx), "select", args, tab_id=tab_id),
1788
+ is_error=_is_browser_error_output,
1789
+ )
1790
+
1791
+
1792
+ @browser.command(name="press")
1793
+ @click.option("--key", required=True, help="Key to send, e.g. Enter, Tab, Escape.")
1794
+ @click.option("--ref", default=None, help="Focus this element first (optional).")
1795
+ @click.option(
1796
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1797
+ )
1798
+ @click.pass_context
1799
+ def browser_press(
1800
+ ctx: click.Context, key: str, ref: str | None, tab_id: int | None
1801
+ ) -> None:
1802
+ """Send a key event (optionally after focusing `--ref`)."""
1803
+ args: dict[str, Any] = {"key": key}
1804
+ if ref:
1805
+ args["ref"] = ref
1806
+ emit(
1807
+ cmd_browser_action(LazyState.get(ctx), "press", args, tab_id=tab_id),
1808
+ is_error=_is_browser_error_output,
1809
+ )
1810
+
1811
+
1812
+ @browser.command(name="scroll")
1813
+ @click.option("--ref", default=None, help="Scroll this element into view (optional).")
1814
+ @click.option(
1815
+ "--y", type=int, default=None, help="Absolute vertical scroll position (px)."
1816
+ )
1817
+ @click.option(
1818
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1819
+ )
1820
+ @click.pass_context
1821
+ def browser_scroll(
1822
+ ctx: click.Context, ref: str | None, y: int | None, tab_id: int | None
1823
+ ) -> None:
1824
+ """Scroll the page, or to a specific element / y offset."""
1825
+ args: dict[str, Any] = {}
1826
+ if ref:
1827
+ args["ref"] = ref
1828
+ if y is not None:
1829
+ args["y"] = y
1830
+ emit(
1831
+ cmd_browser_action(LazyState.get(ctx), "scroll", args, tab_id=tab_id),
1832
+ is_error=_is_browser_error_output,
1833
+ )
1834
+
1835
+
1836
+ @browser.command(name="wait-for")
1837
+ @click.option(
1838
+ "--selector", default=None, help="Wait until this CSS selector is present."
1839
+ )
1840
+ @click.option("--text", default=None, help="Wait until this text appears on the page.")
1841
+ @click.option("--timeout-ms", type=int, default=None, help="Max wait in milliseconds.")
1842
+ @click.option(
1843
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1844
+ )
1845
+ @click.pass_context
1846
+ def browser_wait_for(
1847
+ ctx: click.Context,
1848
+ selector: str | None,
1849
+ text: str | None,
1850
+ timeout_ms: int | None,
1851
+ tab_id: int | None,
1852
+ ) -> None:
1853
+ """Wait for a selector or text to appear before continuing."""
1854
+ if not selector and not text:
1855
+ emit(
1856
+ _browser_missing_condition("wait-for", "--selector or --text"),
1857
+ is_error=_is_browser_error_output,
1858
+ )
1859
+ return
1860
+ args: dict[str, Any] = {}
1861
+ if selector:
1862
+ args["selector"] = selector
1863
+ if text:
1864
+ args["text"] = text
1865
+ if timeout_ms is not None:
1866
+ args["timeoutMs"] = timeout_ms
1867
+ emit(
1868
+ cmd_browser_action(LazyState.get(ctx), "wait-for", args, tab_id=tab_id),
1869
+ is_error=_is_browser_error_output,
1870
+ )
1871
+
1872
+
1873
+ @browser.command(name="list-tabs")
1874
+ @click.pass_context
1875
+ def browser_list_tabs(ctx: click.Context) -> None:
1876
+ """List open tabs (tabId, title, url)."""
1877
+ emit(
1878
+ cmd_browser_action(LazyState.get(ctx), "list-tabs", {}),
1879
+ is_error=_is_browser_error_output,
1880
+ )
1881
+
1882
+
1883
+ @browser.command(name="open-tab")
1884
+ @click.option("--url", required=True, help="URL to open in a new tab.")
1885
+ @click.pass_context
1886
+ def browser_open_tab(ctx: click.Context, url: str) -> None:
1887
+ """Open a new tab at the given URL."""
1888
+ emit(
1889
+ cmd_browser_action(LazyState.get(ctx), "open-tab", {"url": url}),
1890
+ is_error=_is_browser_error_output,
1891
+ )
1892
+
1893
+
1894
+ @browser.command(name="extract-links")
1895
+ @click.option(
1896
+ "--tab-id", type=int, default=None, help="Target tab id (default: active tab)."
1897
+ )
1898
+ @click.pass_context
1899
+ def browser_extract_links(ctx: click.Context, tab_id: int | None) -> None:
1900
+ """Return all visible links on the active tab."""
1901
+ emit(
1902
+ cmd_browser_action(LazyState.get(ctx), "extract-links", {}, tab_id=tab_id),
1903
+ is_error=_is_browser_error_output,
1904
+ )
1905
+
1906
+
1907
+ @browser.command(name="download")
1908
+ @click.argument("url")
1909
+ @click.argument("dest")
1910
+ @click.option("--tab-id", type=int, default=None, help="Tab whose session to use.")
1911
+ @click.pass_context
1912
+ def browser_download(
1913
+ ctx: click.Context, url: str, dest: str, tab_id: int | None
1914
+ ) -> None:
1915
+ """Download URL using the page session and save it to workspace path DEST.
1916
+
1917
+ \b
1918
+ Fetches the resource with the tab's existing login/session (essential for
1919
+ files behind a sign-in) and writes the bytes to DEST. It does NOT upload to
1920
+ the knowledge base or attach to the chat — do that as a separate follow-up
1921
+ (e.g. `unique-cli upload <file> <folder>`).
1922
+
1923
+ \b
1924
+ Examples:
1925
+ unique-cli browser download "https://portal/report.pdf" ./output/report.pdf
1926
+ """
1927
+ emit(
1928
+ cmd_browser_download(LazyState.get(ctx), url, dest, tab_id=tab_id),
1929
+ is_error=_is_browser_error_output,
1930
+ )
1931
+
1932
+
1933
+ @browser.command(name="open-panel")
1934
+ @click.pass_context
1935
+ def browser_open_panel(ctx: click.Context) -> None:
1936
+ """Open the Unique side panel in the user's browser (extension shell)."""
1937
+ emit(
1938
+ cmd_browser_control(LazyState.get(ctx), "open-panel", {}),
1939
+ is_error=_is_browser_error_output,
1940
+ )
1941
+
1942
+
1943
+ @browser.command(name="focus-tab")
1944
+ @click.option(
1945
+ "--tab-id", type=int, required=True, help="Tab id to bring to the foreground."
1946
+ )
1947
+ @click.pass_context
1948
+ def browser_focus_tab(ctx: click.Context, tab_id: int) -> None:
1949
+ """Bring a specific tab to the foreground (extension shell control)."""
1950
+ emit(
1951
+ cmd_browser_control(LazyState.get(ctx), "focus-tab", {"tabId": tab_id}),
1952
+ is_error=_is_browser_error_output,
1953
+ )
1954
+
1955
+
1553
1956
  # -- Web Search ------------------------------------------------------------
1554
1957
 
1555
1958
 
@@ -0,0 +1,354 @@
1
+ """Browser steering command: drive the user's signed-in Chrome tab.
2
+
3
+ ``unique-cli browser <verb>`` talks to the **browser-bridge** relay service,
4
+ which forwards each action to the Unique Chrome extension over the user's
5
+ outbound WebSocket. The agent never sees the page directly — it works from the
6
+ DOM snapshot / result JSON the extension returns.
7
+
8
+ Unlike the knowledge-base commands (which hit the platform ``api_base`` via
9
+ :mod:`unique_sdk` resources), the bridge lives at its own base URL supplied by
10
+ the Swappable Intelligence runner in ``.unique-browser.json``:
11
+
12
+ {"bridgeUrl": "https://gateway.<cluster>/browser-bridge",
13
+ "installUrl": "https://..."}
14
+
15
+ The command therefore issues plain HTTP requests to
16
+ ``{bridgeUrl}/public/browser/{status,action,control,download}`` with the same
17
+ identity headers the SDK sends (``Authorization`` / ``x-user-id`` /
18
+ ``x-company-id`` / ``x-app-id``). The gateway authenticates the request and the
19
+ bridge enforces the host allowlist / kill-switch itself; the CLI only needs the
20
+ HTTP base.
21
+
22
+ Every subcommand prints a JSON envelope to stdout:
23
+
24
+ success -> {"ok": true, "result": <verb-specific payload>}
25
+ failure -> {"ok": false, "error": "<code>", "message": "...", ...}
26
+
27
+ so the agent can ``json.loads`` the output and branch on ``ok``. Error
28
+ envelopes cause a non-zero exit so Bash ``&&`` chains stop cleanly. The bridge's
29
+ ``browser_not_connected`` body (installUrl + remediation) is passed through
30
+ verbatim under ``ok: false``.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ from pathlib import Path
38
+ from typing import Any
39
+ from urllib.parse import unquote
40
+
41
+ import requests
42
+
43
+ from unique_sdk.cli.state import ShellState
44
+
45
+ BROWSER_ERROR_PREFIX = "browser:"
46
+ BROWSER_CONFIG_FILENAME = ".unique-browser.json"
47
+ ENV_CONFIG_PATH = "UNIQUE_BROWSER_CONFIG"
48
+
49
+ # Bridge controller base (`@Controller('public/browser')`), appended to the
50
+ # configured bridge base URL. Keep in sync with the browser-bridge service.
51
+ _BRIDGE_API_PREFIX = "public/browser"
52
+
53
+ # Generous defaults: page loads / waits can legitimately take a while, and the
54
+ # bridge already caps its own per-action timeout. Downloads stream large files.
55
+ _ACTION_TIMEOUT_SECONDS = 120
56
+ _DOWNLOAD_TIMEOUT_SECONDS = 600
57
+ _DOWNLOAD_CHUNK_BYTES = 64 * 1024
58
+
59
+
60
+ class BrowserConfigError(Exception):
61
+ """Raised when ``.unique-browser.json`` is missing or has no ``bridgeUrl``."""
62
+
63
+
64
+ def resolve_config_path(config_path: str | None = None) -> Path:
65
+ """Locate ``.unique-browser.json`` (explicit arg → env → cwd)."""
66
+ if config_path:
67
+ return Path(config_path).expanduser()
68
+ env_path = os.environ.get(ENV_CONFIG_PATH)
69
+ if env_path:
70
+ return Path(env_path).expanduser()
71
+ return Path.cwd() / BROWSER_CONFIG_FILENAME
72
+
73
+
74
+ def load_browser_config(config_path: str | None = None) -> dict[str, Any]:
75
+ """Read and validate the browser bridge config.
76
+
77
+ Returns a dict with at least ``bridgeUrl`` (str) and an optional
78
+ ``installUrl`` (str | None). Raises :class:`BrowserConfigError` with an
79
+ agent-actionable message when browser control is not wired up for this
80
+ turn, so the caller can relay it instead of dialing an empty URL.
81
+ """
82
+ path = resolve_config_path(config_path)
83
+ if not path.is_file():
84
+ raise BrowserConfigError(
85
+ f"browser steering is not enabled for this task ({BROWSER_CONFIG_FILENAME} "
86
+ "not found). Ask the operator to enable Browser Control on the assistant."
87
+ )
88
+ try:
89
+ data = json.loads(path.read_text(encoding="utf-8"))
90
+ except (json.JSONDecodeError, OSError) as exc:
91
+ raise BrowserConfigError(f"could not read {path}: {exc}") from exc
92
+ if not isinstance(data, dict):
93
+ raise BrowserConfigError(f"{path} is not a JSON object")
94
+ bridge_url = data.get("bridgeUrl")
95
+ if not isinstance(bridge_url, str) or not bridge_url.strip():
96
+ raise BrowserConfigError(
97
+ f"{path} has no 'bridgeUrl'; browser steering cannot reach the bridge."
98
+ )
99
+ return data
100
+
101
+
102
+ def _bridge_endpoint(bridge_url: str, endpoint: str) -> str:
103
+ return f"{bridge_url.rstrip('/')}/{_BRIDGE_API_PREFIX}/{endpoint}"
104
+
105
+
106
+ def _auth_headers(state: ShellState, *, json_body: bool) -> dict[str, str]:
107
+ """Identity headers mirroring ``unique_sdk`` request headers.
108
+
109
+ On a secured cluster / localhost the gateway injects identity from the
110
+ request context and ``api_key`` / ``app_id`` may be empty; we still send
111
+ ``x-user-id`` / ``x-company-id`` so the bridge can key the connection.
112
+ """
113
+ headers: dict[str, str] = {"Accept": "application/json"}
114
+ config = state.config
115
+ if config.api_key:
116
+ headers["Authorization"] = f"Bearer {config.api_key}"
117
+ if config.user_id:
118
+ headers["x-user-id"] = config.user_id
119
+ if config.company_id:
120
+ headers["x-company-id"] = config.company_id
121
+ if config.app_id:
122
+ headers["x-app-id"] = config.app_id
123
+ if json_body:
124
+ headers["Content-Type"] = "application/json"
125
+ return headers
126
+
127
+
128
+ def _ok(result: Any) -> str:
129
+ return json.dumps({"ok": True, "result": result}, indent=2, ensure_ascii=False)
130
+
131
+
132
+ def _err(code: str, message: str, **extra: Any) -> str:
133
+ body: dict[str, Any] = {"ok": False, "error": code, "message": message}
134
+ body.update(extra)
135
+ return json.dumps(body, indent=2, ensure_ascii=False)
136
+
137
+
138
+ def _error_from_response(resp: requests.Response) -> str:
139
+ """Translate a non-2xx bridge response into an ``ok: false`` envelope.
140
+
141
+ The bridge returns structured JSON bodies — ``browser_not_connected``
142
+ (424, with ``installUrl`` + ``remediation``) and ``{error, message}`` for
143
+ action failures. Pass those through verbatim so the agent sees the exact
144
+ remediation the skill documents; fall back to a generic envelope when the
145
+ body is not JSON.
146
+ """
147
+ try:
148
+ body = resp.json()
149
+ except ValueError:
150
+ return _err(
151
+ "browser_bridge_error",
152
+ (resp.text or f"bridge returned HTTP {resp.status_code}").strip(),
153
+ status=resp.status_code,
154
+ )
155
+ if isinstance(body, dict):
156
+ passthrough = dict(body)
157
+ passthrough.setdefault("error", "browser_bridge_error")
158
+ passthrough.setdefault("message", f"bridge returned HTTP {resp.status_code}")
159
+ passthrough["ok"] = False
160
+ return json.dumps(passthrough, indent=2, ensure_ascii=False)
161
+ return _err(
162
+ "browser_bridge_error",
163
+ f"bridge returned HTTP {resp.status_code}",
164
+ status=resp.status_code,
165
+ )
166
+
167
+
168
+ # ── Verb dispatch ─────────────────────────────────────────────────────────────
169
+
170
+
171
+ def cmd_browser_status(state: ShellState, *, config_path: str | None = None) -> str:
172
+ """Probe bridge connectivity for the current user."""
173
+ try:
174
+ config = load_browser_config(config_path)
175
+ except BrowserConfigError as exc:
176
+ return _err("browser_not_configured", str(exc))
177
+
178
+ url = _bridge_endpoint(config["bridgeUrl"], "status")
179
+ try:
180
+ resp = requests.get(
181
+ url,
182
+ headers=_auth_headers(state, json_body=False),
183
+ timeout=_ACTION_TIMEOUT_SECONDS,
184
+ )
185
+ except requests.RequestException as exc:
186
+ return _err(
187
+ "browser_bridge_unreachable", f"could not reach the browser bridge: {exc}"
188
+ )
189
+ if not resp.ok:
190
+ return _error_from_response(resp)
191
+ try:
192
+ return _ok(resp.json())
193
+ except ValueError:
194
+ return _err("browser_bridge_error", "bridge returned a non-JSON status body")
195
+
196
+
197
+ def _post_verb(
198
+ state: ShellState,
199
+ endpoint: str,
200
+ payload: dict[str, Any],
201
+ *,
202
+ config_path: str | None,
203
+ ) -> str:
204
+ """Shared POST for the ``action`` and ``control`` endpoints."""
205
+ try:
206
+ config = load_browser_config(config_path)
207
+ except BrowserConfigError as exc:
208
+ return _err("browser_not_configured", str(exc))
209
+
210
+ url = _bridge_endpoint(config["bridgeUrl"], endpoint)
211
+ try:
212
+ resp = requests.post(
213
+ url,
214
+ headers=_auth_headers(state, json_body=True),
215
+ data=json.dumps(payload),
216
+ timeout=_ACTION_TIMEOUT_SECONDS,
217
+ )
218
+ except requests.RequestException as exc:
219
+ return _err(
220
+ "browser_bridge_unreachable", f"could not reach the browser bridge: {exc}"
221
+ )
222
+ if not resp.ok:
223
+ return _error_from_response(resp)
224
+ try:
225
+ body = resp.json()
226
+ except ValueError:
227
+ return _err("browser_bridge_error", "bridge returned a non-JSON body")
228
+ # Controller responds with {ok: true, result}. Surface the result directly
229
+ # under our own envelope so the shape is identical across every verb.
230
+ if isinstance(body, dict) and "result" in body:
231
+ return _ok(body["result"])
232
+ return _ok(body)
233
+
234
+
235
+ def cmd_browser_action(
236
+ state: ShellState,
237
+ verb: str,
238
+ args: dict[str, Any],
239
+ *,
240
+ tab_id: int | None = None,
241
+ config_path: str | None = None,
242
+ ) -> str:
243
+ """Run a DOM / interaction verb against the active (or given) tab."""
244
+ payload: dict[str, Any] = {"verb": verb, "args": args}
245
+ if tab_id is not None:
246
+ payload["tabId"] = tab_id
247
+ return _post_verb(state, "action", payload, config_path=config_path)
248
+
249
+
250
+ def cmd_browser_control(
251
+ state: ShellState,
252
+ verb: str,
253
+ args: dict[str, Any],
254
+ *,
255
+ config_path: str | None = None,
256
+ ) -> str:
257
+ """Run a control verb that steers the extension shell (panel/tab focus)."""
258
+ payload: dict[str, Any] = {"verb": verb, "args": args}
259
+ return _post_verb(state, "control", payload, config_path=config_path)
260
+
261
+
262
+ def cmd_browser_download(
263
+ state: ShellState,
264
+ url: str,
265
+ dest: str,
266
+ *,
267
+ tab_id: int | None = None,
268
+ config_path: str | None = None,
269
+ ) -> str:
270
+ """Download *url* using the page's session and stream it to *dest*.
271
+
272
+ Writes bytes to the workspace path ``dest`` (creating parent directories).
273
+ Does not upload to the knowledge base or attach to the chat — that is a
274
+ separate follow-up command. Returns an envelope describing the saved file.
275
+ """
276
+ try:
277
+ config = load_browser_config(config_path)
278
+ except BrowserConfigError as exc:
279
+ return _err("browser_not_configured", str(exc))
280
+
281
+ payload: dict[str, Any] = {"url": url}
282
+ if tab_id is not None:
283
+ payload["tabId"] = tab_id
284
+
285
+ endpoint = _bridge_endpoint(config["bridgeUrl"], "download")
286
+ try:
287
+ resp = requests.post(
288
+ endpoint,
289
+ headers=_auth_headers(state, json_body=True),
290
+ data=json.dumps(payload),
291
+ timeout=_DOWNLOAD_TIMEOUT_SECONDS,
292
+ stream=True,
293
+ )
294
+ except requests.RequestException as exc:
295
+ return _err(
296
+ "browser_bridge_unreachable", f"could not reach the browser bridge: {exc}"
297
+ )
298
+
299
+ if not resp.ok:
300
+ # Error bodies are small JSON — read fully (not streamed) before mapping.
301
+ return _error_from_response(resp)
302
+
303
+ dest_path = Path(dest).expanduser()
304
+
305
+ total = 0
306
+ try:
307
+ # Parent creation lives inside the try so a permission / filesystem
308
+ # failure surfaces as an ok:false envelope instead of a traceback.
309
+ if dest_path.parent and not dest_path.parent.exists():
310
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
311
+ with dest_path.open("wb") as handle:
312
+ for chunk in resp.iter_content(chunk_size=_DOWNLOAD_CHUNK_BYTES):
313
+ if chunk:
314
+ handle.write(chunk)
315
+ total += len(chunk)
316
+ except requests.RequestException as exc:
317
+ # Network failures during streaming (timeout, dropped connection,
318
+ # chunked-encoding error) — drop any truncated bytes so a later step
319
+ # can't mistake the partial file for a complete download.
320
+ dest_path.unlink(missing_ok=True)
321
+ return _err(
322
+ "browser_bridge_unreachable",
323
+ f"download stream from the browser bridge failed: {exc}",
324
+ )
325
+ except OSError as exc:
326
+ dest_path.unlink(missing_ok=True)
327
+ return _err(
328
+ "browser_download_write_failed",
329
+ f"could not prepare or write {dest_path}: {exc}",
330
+ )
331
+
332
+ file_name_header = resp.headers.get("X-Browser-File-Name")
333
+ file_name = unquote(file_name_header) if file_name_header else dest_path.name
334
+ result = {
335
+ "path": str(dest_path),
336
+ "bytes": total,
337
+ "mimeType": resp.headers.get("Content-Type"),
338
+ "fileName": file_name,
339
+ }
340
+ return _ok(result)
341
+
342
+
343
+ def is_error_output(output: str) -> bool:
344
+ """Return ``True`` when a JSON envelope reports ``ok: false``.
345
+
346
+ Lets the Click layer translate a failure envelope into a non-zero exit
347
+ without special-casing each error code. Non-JSON or non-``ok`` payloads
348
+ (e.g. the ``status`` body) are treated as success.
349
+ """
350
+ try:
351
+ data = json.loads(output)
352
+ except (json.JSONDecodeError, TypeError):
353
+ return False
354
+ return isinstance(data, dict) and data.get("ok") is False