browser-control-and-automation-cli 0.1.1.dev3__py3-none-any.whl

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 (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
browser_cli/network.py ADDED
@@ -0,0 +1,147 @@
1
+ """Shared network record models, filtering, and buffering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from collections import deque
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ STATIC_RESOURCE_TYPES = frozenset({"image", "stylesheet", "script", "font", "media"})
12
+ DEFAULT_NETWORK_RECENT_LIMIT = 200
13
+ DEFAULT_NETWORK_CAPTURE_LIMIT = 200
14
+
15
+
16
+ @dataclass(slots=True, frozen=True)
17
+ class NetworkRecordFilter:
18
+ url_contains: str | None = None
19
+ url_regex: str | None = None
20
+ method: str | None = None
21
+ status: int | None = None
22
+ resource_type: str | None = None
23
+ mime_contains: str | None = None
24
+ include_static: bool = False
25
+ _compiled_url_regex: re.Pattern[str] | None = field(init=False, default=None, repr=False)
26
+
27
+ def __post_init__(self) -> None:
28
+ compiled = re.compile(self.url_regex) if self.url_regex else None
29
+ object.__setattr__(self, "_compiled_url_regex", compiled)
30
+
31
+ def matches(self, record: dict[str, Any]) -> bool:
32
+ resource_type = str(record.get("resource_type") or "")
33
+ if not self.include_static and is_static_resource_type(resource_type):
34
+ return False
35
+ url = str(record.get("url") or "")
36
+ if self.url_contains and self.url_contains not in url:
37
+ return False
38
+ if self._compiled_url_regex and self._compiled_url_regex.search(url) is None:
39
+ return False
40
+ if self.method and str(record.get("method") or "").upper() != self.method.upper():
41
+ return False
42
+ if self.status is not None and int(record.get("status") or 0) != self.status:
43
+ return False
44
+ if self.resource_type and resource_type != self.resource_type:
45
+ return False
46
+ return not (
47
+ self.mime_contains
48
+ and self.mime_contains.lower() not in str(record.get("mime_type") or "").lower()
49
+ )
50
+
51
+
52
+ def is_static_resource_type(resource_type: str | None) -> bool:
53
+ return str(resource_type or "").lower() in STATIC_RESOURCE_TYPES
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class _NetworkWaiter:
58
+ record_filter: NetworkRecordFilter
59
+ future: asyncio.Future[dict[str, Any]]
60
+
61
+
62
+ class NetworkRecordStore:
63
+ """Keep a bounded recent buffer plus an explicit capture-session buffer."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ recent_limit: int = DEFAULT_NETWORK_RECENT_LIMIT,
69
+ capture_limit: int = DEFAULT_NETWORK_CAPTURE_LIMIT,
70
+ ) -> None:
71
+ self._recent_records: deque[dict[str, Any]] = deque(maxlen=recent_limit)
72
+ self._captured_records: deque[dict[str, Any]] = deque(maxlen=capture_limit)
73
+ self._waiters: list[_NetworkWaiter] = []
74
+ self._capturing = False
75
+
76
+ @property
77
+ def capturing(self) -> bool:
78
+ return self._capturing
79
+
80
+ def start_capture(self) -> None:
81
+ self._captured_records.clear()
82
+ self._capturing = True
83
+
84
+ def stop_capture(self) -> None:
85
+ self._capturing = False
86
+
87
+ def clear(self) -> None:
88
+ self._recent_records.clear()
89
+ self._captured_records.clear()
90
+ for waiter in self._waiters:
91
+ if waiter.future.done():
92
+ continue
93
+ waiter.future.cancel()
94
+ self._waiters.clear()
95
+
96
+ def add_record(self, record: dict[str, Any]) -> None:
97
+ self._recent_records.append(record)
98
+ if self._capturing:
99
+ self._captured_records.append(record)
100
+ remaining_waiters: list[_NetworkWaiter] = []
101
+ for waiter in self._waiters:
102
+ if waiter.future.done():
103
+ continue
104
+ if waiter.record_filter.matches(record):
105
+ waiter.future.set_result(record)
106
+ continue
107
+ remaining_waiters.append(waiter)
108
+ self._waiters = remaining_waiters
109
+
110
+ def get_captured_records(
111
+ self,
112
+ *,
113
+ record_filter: NetworkRecordFilter,
114
+ clear: bool = True,
115
+ ) -> list[dict[str, Any]]:
116
+ matched: list[dict[str, Any]] = []
117
+ if not clear:
118
+ for record in self._captured_records:
119
+ if record_filter.matches(record):
120
+ matched.append(record)
121
+ return matched
122
+ retained: deque[dict[str, Any]] = deque(maxlen=self._captured_records.maxlen)
123
+ for record in self._captured_records:
124
+ if record_filter.matches(record):
125
+ matched.append(record)
126
+ continue
127
+ retained.append(record)
128
+ self._captured_records = retained
129
+ return matched
130
+
131
+ async def wait_for_record(
132
+ self,
133
+ *,
134
+ record_filter: NetworkRecordFilter,
135
+ timeout_seconds: float = 30.0,
136
+ ) -> dict[str, Any]:
137
+ for record in reversed(self._recent_records):
138
+ if record_filter.matches(record):
139
+ return record
140
+ loop = asyncio.get_running_loop()
141
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
142
+ waiter = _NetworkWaiter(record_filter=record_filter, future=future)
143
+ self._waiters.append(waiter)
144
+ try:
145
+ return await asyncio.wait_for(future, timeout=timeout_seconds)
146
+ finally:
147
+ self._waiters = [item for item in self._waiters if item.future is not future]
@@ -0,0 +1 @@
1
+ """Output helpers."""
@@ -0,0 +1,10 @@
1
+ """JSON rendering helpers for daemon-backed commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def render_json_payload(payload: dict[str, Any]) -> str:
10
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -0,0 +1,7 @@
1
+ """Output rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def render_output(body: str) -> str:
7
+ return body
@@ -0,0 +1 @@
1
+ """Packaged Browser CLI skills shipped with the installed distribution."""
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: browser-cli-converge
3
+ description: Turn validated Browser CLI exploration into stable task.py execution logic and task validation.
4
+ ---
5
+
6
+ # Browser CLI Converge
7
+
8
+ ## Overview
9
+
10
+ Use this skill after exploration has already validated the success path.
11
+ Its job is to encode that evidence into stable `task.py` logic and keep the
12
+ implementation aligned with `task.meta.json`.
13
+
14
+ ## When to Use
15
+
16
+ Use this skill when:
17
+
18
+ - the success path is already validated
19
+ - the task now needs executable Browser CLI task code
20
+ - waits, assertions, and artifacts are known well enough to encode
21
+
22
+ Do not use this skill when:
23
+
24
+ - the site still has unresolved evidence gaps
25
+ - the task mode is still unclear
26
+ - validation failures show the metadata is incomplete
27
+
28
+ ## Hard Rules
29
+
30
+ - task.py is the single source of execution logic
31
+ - browser interactions must go through `browser_cli.task_runtime.Flow`
32
+ - task code must stay aligned with task.meta.json
33
+ - keep exploration-only retries, branches, and debug logic out of the final task
34
+ - validate with `browser-cli task validate`
35
+ - use `browser-cli task run` when runtime proof is needed
36
+ - if validation exposes an evidence gap, go back to `browser-cli-explore`
37
+
38
+ ## Phase Order
39
+
40
+ 1. Read the validated `task.meta.json`
41
+ 2. Encode the stable success path in `task.py`
42
+ 3. Add explicit waits, assertions, and artifact writes
43
+ 4. Verify metadata-code alignment
44
+ 5. Run `browser-cli task validate`
45
+ 6. Run `browser-cli task run` if the task shape requires live proof
46
+ 7. If evidence is missing, return to exploration instead of guessing
47
+
48
+ ## Done Criteria
49
+
50
+ This skill is complete when:
51
+
52
+ - `task.py` replays the validated path
53
+ - waits and assertions are explicit
54
+ - the code and metadata describe the same workflow
55
+ - task validation passes
56
+
57
+ ## Common Mistakes
58
+
59
+ - bypassing the task runtime with direct Playwright
60
+ - encoding guesses instead of validated waits or refs
61
+ - letting metadata and code drift apart
62
+ - patching around a missing exploration lesson instead of going back
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: browser-cli-delivery
3
+ description: Orchestrate Browser CLI exploration, convergence, validation, and optional automation packaging for reusable web tasks.
4
+ ---
5
+
6
+ # Browser CLI Delivery
7
+
8
+ ## Overview
9
+
10
+ Use this as the main entrypoint when the user wants a reusable Browser CLI web
11
+ task rather than one-off browsing. The default endpoint is stable
12
+ `task.py + task.meta.json`. `automation.toml` generation and publish are
13
+ optional user-driven branches.
14
+
15
+ ## When to Use
16
+
17
+ Use this skill when:
18
+
19
+ - the user wants a reusable browser task
20
+ - the work may require exploration, iteration, and validation
21
+ - the final deliverable should match Browser CLI task artifacts
22
+
23
+ Do not use this skill when:
24
+
25
+ - one-off browsing is enough
26
+ - the task is not Browser CLI based
27
+ - the work is already scoped to only one lower-level skill
28
+
29
+ ## Hard Rules
30
+
31
+ - this is the main user-facing skill
32
+ - call `browser-cli-explore` when evidence is missing
33
+ - call `browser-cli-converge` when the success path is validated
34
+ - default completion is `task.py + task.meta.json`
35
+ - `automation.toml` and publish are optional and require user choice
36
+ - If validation fails because evidence is missing, go back to explore
37
+ - do not publish by default
38
+
39
+ ## Phase Order
40
+
41
+ 1. Preflight: confirm Browser CLI, Python environment, login/profile, and site assumptions
42
+ 2. Explore: call `browser-cli-explore` to validate the task mode and capture feedback
43
+ 3. Converge: call `browser-cli-converge` to encode the stable path in `task.py`
44
+ 4. Validate: run task validation and decide whether to fix code or return to explore
45
+ 5. Optional automation: ask whether to create `automation.toml`
46
+ 6. Optional publish: ask whether to run Browser CLI automation publish
47
+
48
+ ## Done Criteria
49
+
50
+ This skill is complete when:
51
+
52
+ - `task.py + task.meta.json` are stable
53
+ - validation passed
54
+ - optional automation work is either completed or intentionally skipped by the user
55
+
56
+ ## Common Mistakes
57
+
58
+ - skipping metadata capture
59
+ - converging before the success path is real
60
+ - generating automation packaging too early
61
+ - treating one successful page run as enough evidence
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: browser-cli-explore
3
+ description: Explore real websites with Browser CLI, validate task mode, and distill durable feedback into task metadata.
4
+ ---
5
+
6
+ # Browser CLI Explore
7
+
8
+ ## Overview
9
+
10
+ Use `browser-cli` to explore a site, test candidate paths, and distill only the
11
+ durable findings needed to build a reusable task. The primary output of this
12
+ skill is structured knowledge in `task.meta.json`, not final task code.
13
+
14
+ ## When to Use
15
+
16
+ Use this skill when:
17
+
18
+ - a web task still needs exploration or validation
19
+ - the page depends on real browser state, cookies, login, or rendering
20
+ - the next useful artifact is better task metadata, not yet final `task.py`
21
+
22
+ Do not use this skill when:
23
+
24
+ - the success path is already validated end to end
25
+ - the work is only task-code refactoring with no evidence gap
26
+ - the task is pure API work with no Browser CLI dependency
27
+
28
+ ## Hard Rules
29
+
30
+ - browser-cli is the primary browser execution path
31
+ - choose the task mode before broad exploration
32
+ - capture only observations that change the next decision
33
+ - update `task.meta.json` as a rolling feedback sink
34
+ - treat these metadata sections as required destinations for durable knowledge:
35
+ `environment`, `success_path`, `recovery_hints`, `failures`, `knowledge`
36
+ - stop once the evidence is strong enough for deterministic implementation
37
+ - Do not record raw logs, chat transcripts, or exploratory dead ends in metadata
38
+ - Do not turn one lucky run into stable knowledge without a verification step
39
+
40
+ ## Phase Order
41
+
42
+ 1. Confirm the site-specific preflight assumptions:
43
+ login state, cookies, locale, browser profile, writable artifacts, Python env
44
+ 2. Choose the task mode:
45
+ `ref-driven`, `content-first`, `lazy-scroll`, `login-state-first`, or
46
+ `browser-state/network-assisted`
47
+ 3. Explore with the smallest reliable Browser CLI signal
48
+ 4. Capture durable findings into `task.meta.json`
49
+ 5. Stop when the success path, waits, refs, and failure lessons are clear enough
50
+ for `task.py`
51
+
52
+ ## Metadata Capture Rules
53
+
54
+ - `environment`: site, entry URL, login requirements, profile assumptions,
55
+ browser assumptions
56
+ - `success_path`: validated steps, key refs, assertions, artifacts
57
+ - `recovery_hints`: retryable steps, alternate paths, stale-ref strategy, wait
58
+ points, anti-bot recovery
59
+ - `failures`: repeatable failure modes and the lesson each one teaches
60
+ - `knowledge`: stable selectors/roles, semantic-ref notes, pagination,
61
+ lazy-load, anti-bot, and output interpretation rules
62
+
63
+ ## Done Criteria
64
+
65
+ This skill is complete when:
66
+
67
+ - the task mode is known
68
+ - the stable path is understood
69
+ - the fragile points are documented
70
+ - `task.meta.json` contains enough evidence for `browser-cli-converge`
71
+
72
+ ## Common Mistakes
73
+
74
+ - exploring with direct Playwright instead of Browser CLI
75
+ - jumping straight from browsing to `task.py`
76
+ - keeping the useful lessons only in chat
77
+ - recording logs instead of reusable metadata
@@ -0,0 +1 @@
1
+ """Chrome profile discovery."""
@@ -0,0 +1,185 @@
1
+ """Managed Chrome profile discovery for Browser CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from browser_cli.errors import BrowserUnavailableError, ProfileUnavailableError
12
+
13
+ DEFAULT_PROFILE_DIRECTORY = "Default"
14
+ LOCK_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
15
+ DEFAULT_MANAGED_ROOT = Path(".browser-cli") / "default-profile"
16
+ LOCAL_STATE_FILENAME = "Local State"
17
+
18
+
19
+ @dataclass(slots=True, frozen=True)
20
+ class ChromeEnvironment:
21
+ executable_path: Path | None
22
+ user_data_dir: Path
23
+ profile_directory: str = DEFAULT_PROFILE_DIRECTORY
24
+ profile_name: str | None = None
25
+ source: str = "chrome"
26
+ fallback_reason: str | None = None
27
+
28
+
29
+ def discover_chrome_environment(
30
+ profile_directory: str | None = None,
31
+ *,
32
+ allow_default_fallback: bool = True,
33
+ fallback_home: Path | None = None,
34
+ ) -> ChromeEnvironment:
35
+ executable_path = discover_chrome_executable()
36
+ _ = allow_default_fallback
37
+ return _discover_managed_environment(
38
+ executable_path,
39
+ profile_directory,
40
+ home=fallback_home,
41
+ )
42
+
43
+
44
+ def _discover_managed_environment(
45
+ executable_path: Path,
46
+ profile_directory: str | None,
47
+ *,
48
+ home: Path | None,
49
+ ) -> ChromeEnvironment:
50
+ user_data_dir = discover_default_profile_dir(home=home)
51
+ user_data_dir.mkdir(parents=True, exist_ok=True)
52
+ selected_profile_directory = profile_directory or DEFAULT_PROFILE_DIRECTORY
53
+ selected_profile_name = None
54
+ profile_path = user_data_dir / selected_profile_directory
55
+ profile_path.mkdir(parents=True, exist_ok=True)
56
+
57
+ lock_files = [
58
+ str(user_data_dir / name) for name in LOCK_FILES if (user_data_dir / name).exists()
59
+ ]
60
+ if lock_files:
61
+ joined = ", ".join(lock_files)
62
+ raise ProfileUnavailableError(
63
+ "Browser CLI managed Chrome profile appears to be in use. "
64
+ f"Close Browser CLI Chrome first. Lock files: {joined}"
65
+ )
66
+
67
+ return ChromeEnvironment(
68
+ executable_path=executable_path,
69
+ user_data_dir=user_data_dir,
70
+ profile_directory=selected_profile_directory,
71
+ profile_name=selected_profile_name,
72
+ source="managed",
73
+ )
74
+
75
+
76
+ def discover_chrome_executable(platform: str | None = None) -> Path:
77
+ resolved_platform = platform or sys.platform
78
+ candidates: list[Path]
79
+
80
+ if resolved_platform == "darwin":
81
+ candidates = [
82
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
83
+ ]
84
+ elif resolved_platform.startswith("linux"):
85
+ candidates = []
86
+ for binary_name in ("google-chrome", "google-chrome-stable"):
87
+ binary_path = shutil.which(binary_name)
88
+ if binary_path:
89
+ candidates.append(Path(binary_path))
90
+ candidates.extend(
91
+ [
92
+ Path("/opt/google/chrome/chrome"),
93
+ Path("/usr/bin/google-chrome"),
94
+ Path("/usr/bin/google-chrome-stable"),
95
+ ]
96
+ )
97
+ else:
98
+ raise BrowserUnavailableError(
99
+ f"Unsupported platform for Chrome discovery: {resolved_platform}"
100
+ )
101
+
102
+ for candidate in candidates:
103
+ if candidate.exists():
104
+ return candidate
105
+
106
+ raise BrowserUnavailableError("Stable Google Chrome was not found on this machine.")
107
+
108
+
109
+ def discover_user_data_dir(platform: str | None = None, home: Path | None = None) -> Path:
110
+ resolved_platform = platform or sys.platform
111
+ home_dir = home or Path.home()
112
+
113
+ if resolved_platform == "darwin":
114
+ return home_dir / "Library" / "Application Support" / "Google" / "Chrome"
115
+ if resolved_platform.startswith("linux"):
116
+ return home_dir / ".config" / "google-chrome"
117
+ raise ProfileUnavailableError(
118
+ f"Unsupported platform for profile discovery: {resolved_platform}"
119
+ )
120
+
121
+
122
+ def discover_default_profile_dir(home: Path | None = None) -> Path:
123
+ home_dir = home or Path.home()
124
+ return home_dir / DEFAULT_MANAGED_ROOT
125
+
126
+
127
+ def discover_local_state_path(user_data_dir: Path) -> Path:
128
+ return user_data_dir / LOCAL_STATE_FILENAME
129
+
130
+
131
+ def load_profile_info_cache(user_data_dir: Path) -> dict[str, dict]:
132
+ local_state_path = discover_local_state_path(user_data_dir)
133
+ try:
134
+ payload = json.loads(local_state_path.read_text(encoding="utf-8"))
135
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
136
+ return {}
137
+ info_cache = payload.get("profile", {}).get("info_cache", {})
138
+ if not isinstance(info_cache, dict):
139
+ return {}
140
+ return {str(key): value for key, value in info_cache.items() if isinstance(value, dict)}
141
+
142
+
143
+ def resolve_profile_directory(
144
+ user_data_dir: Path, *, preferred: str | None = None
145
+ ) -> tuple[str, str | None]:
146
+ if preferred:
147
+ return preferred, load_profile_info_cache(user_data_dir).get(preferred, {}).get("name")
148
+
149
+ info_cache = load_profile_info_cache(user_data_dir)
150
+ ranked_profiles: list[tuple[float, str, str | None]] = []
151
+ for directory, metadata in info_cache.items():
152
+ profile_path = user_data_dir / directory
153
+ if not profile_path.exists():
154
+ continue
155
+ active_time = metadata.get("active_time")
156
+ try:
157
+ rank = float(active_time) if active_time is not None else 0.0
158
+ except (TypeError, ValueError):
159
+ rank = 0.0
160
+ ranked_profiles.append((rank, directory, metadata.get("name")))
161
+ if ranked_profiles:
162
+ ranked_profiles.sort(key=lambda item: (item[0], item[1]), reverse=True)
163
+ _rank, directory, name = ranked_profiles[0]
164
+ return directory, name if isinstance(name, str) else None
165
+
166
+ default_path = user_data_dir / DEFAULT_PROFILE_DIRECTORY
167
+ if default_path.exists():
168
+ return DEFAULT_PROFILE_DIRECTORY, None
169
+
170
+ fallback_directories: list[Path] = sorted(
171
+ (
172
+ entry
173
+ for entry in user_data_dir.iterdir()
174
+ if entry.is_dir()
175
+ and (entry.name.startswith("Profile ") or entry.name == DEFAULT_PROFILE_DIRECTORY)
176
+ ),
177
+ key=lambda entry: entry.stat().st_mtime,
178
+ reverse=True,
179
+ )
180
+ if fallback_directories:
181
+ return fallback_directories[0].name, None
182
+
183
+ raise ProfileUnavailableError(
184
+ f"Chrome profile does not exist: expected a recent profile under {user_data_dir}"
185
+ )
@@ -0,0 +1,17 @@
1
+ """Semantic ref models, snapshot generation, and locator resolution."""
2
+
3
+ from browser_cli.refs.generator import SemanticSnapshotGenerator, SnapshotOptions
4
+ from browser_cli.refs.models import RefData, SemanticSnapshot, SnapshotMetadata
5
+ from browser_cli.refs.registry import PageSnapshotState, SnapshotRegistry
6
+ from browser_cli.refs.resolver import SemanticRefResolver
7
+
8
+ __all__ = [
9
+ "PageSnapshotState",
10
+ "RefData",
11
+ "SemanticRefResolver",
12
+ "SemanticSnapshot",
13
+ "SemanticSnapshotGenerator",
14
+ "SnapshotMetadata",
15
+ "SnapshotOptions",
16
+ "SnapshotRegistry",
17
+ ]