Files
hermes-agent/pm/defaults.py
teknium1 27062c3474 fix: install cua-driver and the Browser Use CLI by default again
Computer use and browser use are meant to work out of the box. The PM rewrite
(3d12e86ef1) and the MSIX installer rework (47f4ab3a17) dropped the
install-time cua-driver fetch (7060ac7bed) and the Browser Use CLI install
(baa6b2e34d). On a fresh install the computer_use check_fn therefore stayed
False, so the tool never reached the model and its lazy ensure could not fire,
and browser_exec quietly fell back to the built-in tools.

- cua-driver is a default PM package, so the installers, a bare
  `hermes pm install` and `hermes update` carry it on every target it builds
  for. Adds the missing Android gap (the lock has no bionic artifact).
- The shared default-tool step, which the installers (via source completion)
  and `hermes update` both run, provisions the Browser Use CLI for the default
  and explicit Browser Use backends. `--skip-browser` declines it along with
  agent-browser, and `off`/Camofox never use it.
- Installers regain --skip-computer-use / -SkipComputerUse (recorded as
  `--without cua-driver`).
- The update message stops calling every default "browser tools".
2026-09-27 19:18:58 -07:00

90 lines
3.6 KiB
Python

"""Optional packages the default install carries, and the user's opt-outs.
A ``default`` package (see ``Package.default``) joins the installers' PM stage,
a bare ``hermes pm install`` and ``hermes update`` on every target it builds
for. The user can decline one (``install.sh --skip-browser`` /
``--skip-computer-use``, ``install.ps1 -SkipBrowser`` / ``-SkipComputerUse``,
``hermes pm install --without NAME``). The choice
is recorded per installation beside PM's other install state, so a later
update or bare install never re-adds it. An explicit
``hermes pm install NAME`` clears it.
Stdlib-only apart from PM's own modules: the installers run this before any
application dependency exists.
"""
from __future__ import annotations
import json
from collections.abc import Iterable
from pathlib import Path
DECLINED_FILENAME = "declined-packages.json"
def declined_path(project_root: Path | None = None) -> Path:
from pm.environments import install_state_dir
from pm.paths import repo_root
return install_state_dir(repo_root() if project_root is None else Path(project_root)) / DECLINED_FILENAME
def declined(project_root: Path | None = None) -> frozenset[str]:
try:
data = json.loads(declined_path(project_root).read_text(encoding="utf-8-sig"))
except FileNotFoundError:
return frozenset()
except (OSError, ValueError) as exc:
# Silently treating a damaged record as "nothing declined" would
# re-add a package the user explicitly refused.
from pm.package import InstallError
raise InstallError("defaults", f"cannot read {declined_path(project_root)}: {exc}",
"fix or delete the file, then retry") from exc
names = data.get("declined") if isinstance(data, dict) else None
return frozenset(name for name in names or () if isinstance(name, str))
def record_declined(*, add: Iterable[str] = (), remove: Iterable[str] = (),
project_root: Path | None = None) -> frozenset[str]:
"""Update the recorded opt-outs; returns the new set. Writes only on change."""
from pm.filesystem import durable_write_bytes
current = declined(project_root)
updated = (current | set(add)) - set(remove)
if updated != current:
body = json.dumps({"schema": 1, "declined": sorted(updated)}, indent=2) + "\n"
durable_write_bytes(declined_path(project_root), body.encode("utf-8"))
return frozenset(updated)
def default_package_names() -> list[str]:
"""Every package that may be declined: the ``default`` ones, any target."""
from pm.registry import all_packages, get_package
return [name for name in all_packages() if get_package(name).default and not get_package(name).internal]
def default_packages(names: list[str], *, target: str | None = None,
declined_names: frozenset[str] | None = None) -> list[str]:
"""The optional defaults among ``names`` this install should carry.
Excludes targets the package has no build for (its ``gaps``) and packages
the user declined. ``names`` is the lockfile's package list.
"""
from pm.registry import get_package
from pm.store import current_target
target = current_target() if target is None else target
refused = declined() if declined_names is None else declined_names
selected = []
for name in names:
try:
package = get_package(name)
except KeyError:
continue # Lockfile/definition skew mid-update; drift() skips these too.
if package.default and not package.internal and name not in refused \
and package.missing_reason(target) is None:
selected.append(name)
return selected