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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- 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 @@
|
|
|
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
|
+
]
|