unique-sdk 2026.28.0.dev14__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.
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/PKG-INFO +1 -1
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/pyproject.toml +1 -1
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/cli.py +403 -0
- unique_sdk-2026.28.0.dev16/unique_sdk/cli/commands/browser.py +354 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/README.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_requestor.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_resource.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_api_version.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_error.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_http_client.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_list_object.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_object_classes.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_request_options.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_object.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_ql.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_unique_response.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_util.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_version.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/_webhook.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_acronyms.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_agentic_table.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_analytics_order.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_benchmarking.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_briefing.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_chat_completion.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_content.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_embedding.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_event.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_folder.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_group.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_integrated.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_llm_models.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_mcp.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_assessment.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_execution.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_log.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_tool.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_module.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_scheduled_task.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search_string.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_short_term_memory.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_space.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_user.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_web_search.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/__main__.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/cite_file.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/files.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/folders.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/mcp.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/navigation.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/read.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/search.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/subagent.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/web_search.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/web_search_config.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/config.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/formatting.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/metadata_filter.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/shell.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/state.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/analytics_order_run.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/benchmarking_run.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/chat_history.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/chat_in_space.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/file_io.py +0 -0
- {unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/sources.py +0 -0
- {unique_sdk-2026.28.0.dev14 → 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.
|
|
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>
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/__init__.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_acronyms.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_agentic_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_benchmarking.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_briefing.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_content.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_elicitation.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_embedding.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_event.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_folder.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_group.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_integrated.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_llm_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_log.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_message_tool.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_module.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_search_string.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_space.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/api_resources/_web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/cite_file.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/elicitation.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/folders.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/navigation.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/scheduled_tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/subagent.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/cli/commands/web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/analytics_order_run.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev14 → unique_sdk-2026.28.0.dev16}/unique_sdk/utils/benchmarking_run.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|