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
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Lifecycle status command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from browser_cli import __version__
|
|
11
|
+
from browser_cli.automation.service.client import (
|
|
12
|
+
read_automation_service_run_info,
|
|
13
|
+
request_automation_service,
|
|
14
|
+
)
|
|
15
|
+
from browser_cli.constants import get_app_paths
|
|
16
|
+
from browser_cli.daemon.client import run_info_is_compatible, send_command
|
|
17
|
+
from browser_cli.daemon.transport import DAEMON_RUNTIME_VERSION, probe_socket, read_run_info
|
|
18
|
+
from browser_cli.errors import BrowserCliError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class StatusReport:
|
|
23
|
+
overall_status: str
|
|
24
|
+
daemon_state: str
|
|
25
|
+
runtime: dict[str, Any]
|
|
26
|
+
daemon: dict[str, Any]
|
|
27
|
+
backend: dict[str, Any]
|
|
28
|
+
browser: dict[str, Any]
|
|
29
|
+
guidance: list[str]
|
|
30
|
+
presentation: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
stability: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
automation_service: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
live_error: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_status_command(_args: Namespace) -> str:
|
|
37
|
+
return render_status_report(collect_status_report())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_status_report(*, warmup: bool = False) -> StatusReport:
|
|
41
|
+
app_paths = get_app_paths()
|
|
42
|
+
run_info = read_run_info()
|
|
43
|
+
socket_exists = app_paths.socket_path.exists()
|
|
44
|
+
socket_reachable = probe_socket()
|
|
45
|
+
compatibility: bool | None = None
|
|
46
|
+
if run_info is not None:
|
|
47
|
+
compatibility = run_info_is_compatible(run_info)
|
|
48
|
+
daemon_state = _classify_daemon_state(
|
|
49
|
+
run_info=run_info,
|
|
50
|
+
socket_exists=socket_exists,
|
|
51
|
+
socket_reachable=socket_reachable,
|
|
52
|
+
compatibility=compatibility,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
live_payload: dict[str, Any] | None = None
|
|
56
|
+
live_error: str | None = None
|
|
57
|
+
if socket_reachable and compatibility is not False:
|
|
58
|
+
try:
|
|
59
|
+
response = send_command("runtime-status", {"warmup": warmup}, start_if_needed=False)
|
|
60
|
+
live_payload = dict(response.get("data") or {})
|
|
61
|
+
except BrowserCliError as exc:
|
|
62
|
+
live_error = str(exc)
|
|
63
|
+
|
|
64
|
+
runtime_section = {
|
|
65
|
+
"home": str(app_paths.home),
|
|
66
|
+
"socket": str(app_paths.socket_path),
|
|
67
|
+
"run_info": str(app_paths.run_info_path),
|
|
68
|
+
"daemon_log": str(app_paths.daemon_log_path),
|
|
69
|
+
"package_version": __version__,
|
|
70
|
+
"runtime_version": DAEMON_RUNTIME_VERSION,
|
|
71
|
+
}
|
|
72
|
+
daemon_section = {
|
|
73
|
+
"state": daemon_state,
|
|
74
|
+
"socket_exists": socket_exists,
|
|
75
|
+
"socket_reachable": socket_reachable,
|
|
76
|
+
"run_info_exists": run_info is not None,
|
|
77
|
+
"runtime_compatibility": compatibility,
|
|
78
|
+
"pid": _int_or_none((run_info or {}).get("pid")),
|
|
79
|
+
"started_at": _float_or_none((run_info or {}).get("started_at")),
|
|
80
|
+
"recorded_package_version": (run_info or {}).get("package_version"),
|
|
81
|
+
"recorded_runtime_version": (run_info or {}).get("runtime_version"),
|
|
82
|
+
}
|
|
83
|
+
automation_run_info = read_automation_service_run_info()
|
|
84
|
+
automation_service_section = {
|
|
85
|
+
"running": False,
|
|
86
|
+
"pid": _int_or_none((automation_run_info or {}).get("pid")),
|
|
87
|
+
"url": (
|
|
88
|
+
f"http://{automation_run_info['host']}:{automation_run_info['port']}/"
|
|
89
|
+
if automation_run_info
|
|
90
|
+
and automation_run_info.get("host")
|
|
91
|
+
and automation_run_info.get("port")
|
|
92
|
+
else None
|
|
93
|
+
),
|
|
94
|
+
"automation_count": 0,
|
|
95
|
+
"queued_runs": 0,
|
|
96
|
+
"running_runs": 0,
|
|
97
|
+
}
|
|
98
|
+
if automation_run_info:
|
|
99
|
+
try:
|
|
100
|
+
automation_status = request_automation_service(
|
|
101
|
+
"GET", "/api/service/status", start_if_needed=False
|
|
102
|
+
)
|
|
103
|
+
automation_data = dict(automation_status.get("data") or {})
|
|
104
|
+
metrics = dict(automation_data.get("metrics") or {})
|
|
105
|
+
automation_service_section.update(
|
|
106
|
+
{
|
|
107
|
+
"running": True,
|
|
108
|
+
"automation_count": int(metrics.get("automation_count") or 0),
|
|
109
|
+
"queued_runs": int(metrics.get("queued_runs") or 0),
|
|
110
|
+
"running_runs": int(metrics.get("running_runs") or 0),
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
except BrowserCliError:
|
|
114
|
+
automation_service_section["running"] = False
|
|
115
|
+
backend_section = _build_backend_section(live_payload, live_error=live_error)
|
|
116
|
+
browser_section = _build_browser_section(live_payload)
|
|
117
|
+
presentation = dict((live_payload or {}).get("presentation") or {})
|
|
118
|
+
stability = dict((live_payload or {}).get("stability") or {})
|
|
119
|
+
overall_status = str(presentation.get("overall_state") or "") or _classify_overall_status(
|
|
120
|
+
daemon_state=daemon_state,
|
|
121
|
+
compatibility=compatibility,
|
|
122
|
+
live_payload=live_payload,
|
|
123
|
+
live_error=live_error,
|
|
124
|
+
)
|
|
125
|
+
guidance = list(presentation.get("recovery_guidance") or []) or _build_guidance(
|
|
126
|
+
overall_status=overall_status,
|
|
127
|
+
daemon_state=daemon_state,
|
|
128
|
+
live_payload=live_payload,
|
|
129
|
+
live_error=live_error,
|
|
130
|
+
)
|
|
131
|
+
return StatusReport(
|
|
132
|
+
overall_status=overall_status,
|
|
133
|
+
daemon_state=daemon_state,
|
|
134
|
+
runtime=runtime_section,
|
|
135
|
+
daemon=daemon_section,
|
|
136
|
+
automation_service=automation_service_section,
|
|
137
|
+
backend=backend_section,
|
|
138
|
+
browser=browser_section,
|
|
139
|
+
guidance=guidance,
|
|
140
|
+
presentation=presentation,
|
|
141
|
+
stability=stability,
|
|
142
|
+
live_error=live_error,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_status_report(report: StatusReport) -> str:
|
|
147
|
+
summary_reason = report.presentation.get("summary_reason") or "-"
|
|
148
|
+
available_actions = ", ".join(report.presentation.get("available_actions") or []) or "none"
|
|
149
|
+
lines = [f"Status: {report.overall_status}", "", f"Summary: {summary_reason}", ""]
|
|
150
|
+
lines.extend(
|
|
151
|
+
[
|
|
152
|
+
"Runtime",
|
|
153
|
+
f" home: {report.runtime['home']}",
|
|
154
|
+
f" socket: {report.runtime['socket']}",
|
|
155
|
+
f" run-info: {report.runtime['run_info']}",
|
|
156
|
+
f" daemon log: {report.runtime['daemon_log']}",
|
|
157
|
+
f" package version: {report.runtime['package_version']}",
|
|
158
|
+
f" runtime version: {report.runtime['runtime_version']}",
|
|
159
|
+
"",
|
|
160
|
+
"Daemon",
|
|
161
|
+
f" state: {report.daemon['state']}",
|
|
162
|
+
f" pid: {_display_value(report.daemon['pid'])}",
|
|
163
|
+
f" started at: {_format_timestamp(report.daemon['started_at'])}",
|
|
164
|
+
f" socket exists: {_yes_no(report.daemon['socket_exists'])}",
|
|
165
|
+
f" socket reachable: {_yes_no(report.daemon['socket_reachable'])}",
|
|
166
|
+
f" runtime compatibility: {_compatibility_text(report.daemon['runtime_compatibility'])}",
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
if report.daemon["recorded_package_version"] or report.daemon["recorded_runtime_version"]:
|
|
170
|
+
lines.extend(
|
|
171
|
+
[
|
|
172
|
+
f" recorded package version: {_display_value(report.daemon['recorded_package_version'])}",
|
|
173
|
+
f" recorded runtime version: {_display_value(report.daemon['recorded_runtime_version'])}",
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
lines.extend(
|
|
177
|
+
[
|
|
178
|
+
"",
|
|
179
|
+
"Automation Service",
|
|
180
|
+
f" running: {_yes_no(report.automation_service['running'])}",
|
|
181
|
+
f" pid: {_display_value(report.automation_service['pid'])}",
|
|
182
|
+
f" url: {_display_value(report.automation_service['url'])}",
|
|
183
|
+
f" automation count: {_display_value(report.automation_service['automation_count'])}",
|
|
184
|
+
f" queued runs: {_display_value(report.automation_service['queued_runs'])}",
|
|
185
|
+
f" running runs: {_display_value(report.automation_service['running_runs'])}",
|
|
186
|
+
"",
|
|
187
|
+
"Backend",
|
|
188
|
+
f" browser started: {_yes_no(report.backend['browser_started'])}",
|
|
189
|
+
f" active driver: {_display_value(report.backend['active_driver'])}",
|
|
190
|
+
f" extension connected: {_yes_no(report.backend['extension_connected'])}",
|
|
191
|
+
f" extension capability complete: {_yes_no(report.backend['extension_capability_complete'])}",
|
|
192
|
+
f" extension missing capabilities: {report.backend['extension_missing_capabilities']}",
|
|
193
|
+
f" pending rebind: {report.backend['pending_rebind']}",
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
if report.live_error:
|
|
197
|
+
lines.append(f" live daemon error: {report.live_error}")
|
|
198
|
+
lines.extend(
|
|
199
|
+
[
|
|
200
|
+
"",
|
|
201
|
+
"Browser",
|
|
202
|
+
f" profile source: {_display_value(report.browser['profile_source'])}",
|
|
203
|
+
f" profile dir: {_display_value(report.browser['profile_dir'])}",
|
|
204
|
+
f" profile directory: {_display_value(report.browser['profile_directory'])}",
|
|
205
|
+
f" workspace window: {report.browser['workspace_window']}",
|
|
206
|
+
f" workspace tab count: {report.browser['tab_count']}",
|
|
207
|
+
f" active tab: {report.browser['active_tab']}",
|
|
208
|
+
f" busy tab count: {report.browser['busy_tab_count']}",
|
|
209
|
+
"",
|
|
210
|
+
"Stability",
|
|
211
|
+
f" active command: {_display_value(report.stability.get('active_command'))}",
|
|
212
|
+
f" command depth: {_display_value(report.stability.get('command_depth'))}",
|
|
213
|
+
f" commands started: {_display_value(report.stability.get('commands_started'))}",
|
|
214
|
+
f" driver switches: {_display_value(report.stability.get('driver_switches'))}",
|
|
215
|
+
f" workspace rebuilds: {_display_value(report.stability.get('workspace_rebuilds'))}",
|
|
216
|
+
f" extension disconnects: {_display_value(report.stability.get('extension_disconnects'))}",
|
|
217
|
+
f" cleanup failures: {_display_value(report.stability.get('cleanup_failures'))}",
|
|
218
|
+
f" last cleanup error: {_display_value(report.stability.get('last_cleanup_error'))}",
|
|
219
|
+
"",
|
|
220
|
+
"Guidance",
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
for item in report.guidance:
|
|
224
|
+
lines.append(f"- {item}")
|
|
225
|
+
lines.extend(["", f"Available actions: {available_actions}"])
|
|
226
|
+
return "\n".join(lines) + "\n"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _classify_daemon_state(
|
|
230
|
+
*,
|
|
231
|
+
run_info: dict[str, Any] | None,
|
|
232
|
+
socket_exists: bool,
|
|
233
|
+
socket_reachable: bool,
|
|
234
|
+
compatibility: bool | None,
|
|
235
|
+
) -> str:
|
|
236
|
+
if socket_reachable:
|
|
237
|
+
if compatibility is False:
|
|
238
|
+
return "incompatible"
|
|
239
|
+
return "running"
|
|
240
|
+
if run_info is not None or socket_exists:
|
|
241
|
+
return "stale"
|
|
242
|
+
return "stopped"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _classify_overall_status(
|
|
246
|
+
*,
|
|
247
|
+
daemon_state: str,
|
|
248
|
+
compatibility: bool | None,
|
|
249
|
+
live_payload: dict[str, Any] | None,
|
|
250
|
+
live_error: str | None,
|
|
251
|
+
) -> str:
|
|
252
|
+
if daemon_state == "stopped":
|
|
253
|
+
return "stopped"
|
|
254
|
+
if daemon_state in {"stale", "incompatible"} or compatibility is False:
|
|
255
|
+
return "broken"
|
|
256
|
+
if live_error:
|
|
257
|
+
return "broken"
|
|
258
|
+
if not live_payload:
|
|
259
|
+
return "healthy"
|
|
260
|
+
if not bool(live_payload.get("browser_started")):
|
|
261
|
+
return "healthy"
|
|
262
|
+
pending_rebind = live_payload.get("pending_rebind")
|
|
263
|
+
if pending_rebind:
|
|
264
|
+
return "degraded"
|
|
265
|
+
active_driver = str(live_payload.get("active_driver") or "")
|
|
266
|
+
extension = dict(live_payload.get("extension") or {})
|
|
267
|
+
if active_driver != "extension":
|
|
268
|
+
return "degraded"
|
|
269
|
+
if not bool(extension.get("capability_complete")):
|
|
270
|
+
return "degraded"
|
|
271
|
+
return "healthy"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_backend_section(
|
|
275
|
+
live_payload: dict[str, Any] | None, *, live_error: str | None
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
if not live_payload:
|
|
278
|
+
return {
|
|
279
|
+
"browser_started": False,
|
|
280
|
+
"active_driver": "not-started" if not live_error else "unknown",
|
|
281
|
+
"extension_connected": False,
|
|
282
|
+
"extension_capability_complete": False,
|
|
283
|
+
"extension_missing_capabilities": "unknown",
|
|
284
|
+
"pending_rebind": "none",
|
|
285
|
+
}
|
|
286
|
+
extension = dict(live_payload.get("extension") or {})
|
|
287
|
+
pending = live_payload.get("pending_rebind")
|
|
288
|
+
if pending:
|
|
289
|
+
pending_text = (
|
|
290
|
+
f"{pending.get('target') or 'unknown'} ({pending.get('reason') or 'pending'})"
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
pending_text = "none"
|
|
294
|
+
return {
|
|
295
|
+
"browser_started": bool(live_payload.get("browser_started")),
|
|
296
|
+
"active_driver": live_payload.get("active_driver") or "not-started",
|
|
297
|
+
"extension_connected": bool(extension.get("connected")),
|
|
298
|
+
"extension_capability_complete": bool(extension.get("capability_complete")),
|
|
299
|
+
"extension_missing_capabilities": (
|
|
300
|
+
", ".join(sorted(str(item) for item in (extension.get("missing_capabilities") or [])))
|
|
301
|
+
or "none"
|
|
302
|
+
),
|
|
303
|
+
"pending_rebind": pending_text,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _build_browser_section(live_payload: dict[str, Any] | None) -> dict[str, Any]:
|
|
308
|
+
if not live_payload:
|
|
309
|
+
return {
|
|
310
|
+
"profile_source": None,
|
|
311
|
+
"profile_dir": None,
|
|
312
|
+
"profile_directory": None,
|
|
313
|
+
"workspace_window": "unknown",
|
|
314
|
+
"tab_count": 0,
|
|
315
|
+
"active_tab": "none",
|
|
316
|
+
"busy_tab_count": 0,
|
|
317
|
+
}
|
|
318
|
+
workspace_state = dict(live_payload.get("workspace_window_state") or {})
|
|
319
|
+
window_id = workspace_state.get("window_id")
|
|
320
|
+
tabs = dict(live_payload.get("tabs") or {})
|
|
321
|
+
active_tab = _select_active_tab(tabs)
|
|
322
|
+
active_tab_text = "none"
|
|
323
|
+
if active_tab is not None:
|
|
324
|
+
active_tab_text = f"{active_tab.get('page_id')} {active_tab.get('url') or ''}".strip()
|
|
325
|
+
workspace_window = "absent"
|
|
326
|
+
if window_id is not None:
|
|
327
|
+
workspace_window = f"present (window {window_id})"
|
|
328
|
+
return {
|
|
329
|
+
"profile_source": live_payload.get("profile_source"),
|
|
330
|
+
"profile_dir": live_payload.get("profile_dir"),
|
|
331
|
+
"profile_directory": live_payload.get("profile_directory"),
|
|
332
|
+
"workspace_window": workspace_window,
|
|
333
|
+
"tab_count": int(tabs.get("count") or 0),
|
|
334
|
+
"active_tab": active_tab_text,
|
|
335
|
+
"busy_tab_count": int(tabs.get("busy_count") or 0),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _select_active_tab(tabs: dict[str, Any]) -> dict[str, Any] | None:
|
|
340
|
+
records = list(tabs.get("records") or [])
|
|
341
|
+
active_by_agent = dict(tabs.get("active_by_agent") or {})
|
|
342
|
+
for agent_id in ("public",):
|
|
343
|
+
page_id = active_by_agent.get(agent_id)
|
|
344
|
+
if not page_id:
|
|
345
|
+
continue
|
|
346
|
+
for record in records:
|
|
347
|
+
if record.get("page_id") == page_id:
|
|
348
|
+
return dict(record)
|
|
349
|
+
for record in records:
|
|
350
|
+
if record.get("busy"):
|
|
351
|
+
return dict(record)
|
|
352
|
+
if records:
|
|
353
|
+
return dict(records[0])
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _build_guidance(
|
|
358
|
+
*,
|
|
359
|
+
overall_status: str,
|
|
360
|
+
daemon_state: str,
|
|
361
|
+
live_payload: dict[str, Any] | None,
|
|
362
|
+
live_error: str | None,
|
|
363
|
+
) -> list[str]:
|
|
364
|
+
if overall_status == "stopped":
|
|
365
|
+
return [
|
|
366
|
+
"Daemon is not running. Run `browser-cli reload` or any browser command such as `browser-cli open <url>`.",
|
|
367
|
+
"If the extension should be active, open the Browser CLI extension popup and confirm it is connected after restart.",
|
|
368
|
+
]
|
|
369
|
+
if daemon_state in {"stale", "incompatible"}:
|
|
370
|
+
return [
|
|
371
|
+
"Runtime artifacts look stale or incompatible. Run `browser-cli reload` to reset Browser CLI state.",
|
|
372
|
+
"If restart still fails, inspect the daemon log path shown above.",
|
|
373
|
+
]
|
|
374
|
+
if live_error:
|
|
375
|
+
return [
|
|
376
|
+
"The daemon is reachable but runtime diagnostics failed. Run `browser-cli reload` to force a clean restart.",
|
|
377
|
+
"If the problem persists, inspect the daemon log path shown above.",
|
|
378
|
+
]
|
|
379
|
+
if not live_payload or not bool(live_payload.get("browser_started")):
|
|
380
|
+
return [
|
|
381
|
+
"Daemon is running and idle. The browser backend will start on the next browser command.",
|
|
382
|
+
]
|
|
383
|
+
active_driver = str(live_payload.get("active_driver") or "")
|
|
384
|
+
pending_rebind = live_payload.get("pending_rebind")
|
|
385
|
+
extension = dict(live_payload.get("extension") or {})
|
|
386
|
+
if pending_rebind:
|
|
387
|
+
return [
|
|
388
|
+
"A driver switch is pending and will apply at the next safe idle point.",
|
|
389
|
+
"Browser CLI remains usable while the rebind is pending.",
|
|
390
|
+
]
|
|
391
|
+
if active_driver == "playwright":
|
|
392
|
+
return [
|
|
393
|
+
"Browser CLI is currently using the managed profile backend.",
|
|
394
|
+
"If you expect real Chrome mode, check the extension popup or `chrome://extensions`, then run `browser-cli reload` if needed.",
|
|
395
|
+
]
|
|
396
|
+
if not bool(extension.get("capability_complete")):
|
|
397
|
+
missing = ", ".join(
|
|
398
|
+
sorted(str(item) for item in (extension.get("missing_capabilities") or []))
|
|
399
|
+
)
|
|
400
|
+
return [
|
|
401
|
+
"The extension is connected but its required capability set is incomplete.",
|
|
402
|
+
f"Missing capabilities: {missing or 'unknown'}.",
|
|
403
|
+
"Reload the extension and run `browser-cli reload` if Browser CLI should switch to extension mode.",
|
|
404
|
+
]
|
|
405
|
+
return [
|
|
406
|
+
"Browser CLI is healthy.",
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _yes_no(value: bool) -> str:
|
|
411
|
+
return "yes" if value else "no"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _compatibility_text(value: bool | None) -> str:
|
|
415
|
+
if value is None:
|
|
416
|
+
return "unknown"
|
|
417
|
+
return "yes" if value else "no"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _display_value(value: Any) -> str:
|
|
421
|
+
if value is None or value == "":
|
|
422
|
+
return "-"
|
|
423
|
+
return str(value)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_timestamp(value: float | None) -> str:
|
|
427
|
+
if value is None:
|
|
428
|
+
return "-"
|
|
429
|
+
return datetime.fromtimestamp(value).isoformat(timespec="seconds")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _int_or_none(value: Any) -> int | None:
|
|
433
|
+
try:
|
|
434
|
+
parsed = int(value)
|
|
435
|
+
except (TypeError, ValueError):
|
|
436
|
+
return None
|
|
437
|
+
return parsed if parsed > 0 else None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _float_or_none(value: Any) -> float | None:
|
|
441
|
+
try:
|
|
442
|
+
return float(value)
|
|
443
|
+
except (TypeError, ValueError):
|
|
444
|
+
return None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Task CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from browser_cli.errors import InvalidInputError
|
|
9
|
+
from browser_cli.outputs.json import render_json_payload
|
|
10
|
+
from browser_cli.task_runtime import parse_input_overrides, run_task_entrypoint, validate_task_dir
|
|
11
|
+
from browser_cli.task_runtime.templates import (
|
|
12
|
+
EXAMPLE_CATALOG,
|
|
13
|
+
TASK_TEMPLATE_FILES,
|
|
14
|
+
render_template_bundle,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_task_command(args: Namespace) -> str:
|
|
19
|
+
if args.task_subcommand == "examples":
|
|
20
|
+
return "\n".join(f"{name}: {summary}" for name, summary in EXAMPLE_CATALOG) + "\n"
|
|
21
|
+
|
|
22
|
+
if args.task_subcommand == "template":
|
|
23
|
+
output = getattr(args, "output", None)
|
|
24
|
+
print_template = bool(getattr(args, "print_template", False))
|
|
25
|
+
if output:
|
|
26
|
+
output_dir = Path(output).expanduser().resolve()
|
|
27
|
+
existing_files = [name for name in TASK_TEMPLATE_FILES if (output_dir / name).exists()]
|
|
28
|
+
if existing_files:
|
|
29
|
+
joined = ", ".join(existing_files)
|
|
30
|
+
raise InvalidInputError(
|
|
31
|
+
f"Template output would overwrite existing files in {output_dir}: {joined}"
|
|
32
|
+
)
|
|
33
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
for name, body in TASK_TEMPLATE_FILES.items():
|
|
35
|
+
(output_dir / name).write_text(body, encoding="utf-8")
|
|
36
|
+
if print_template:
|
|
37
|
+
return f"Template written to {output_dir}\n\n{render_template_bundle()}"
|
|
38
|
+
return f"Template written to {output_dir}\n"
|
|
39
|
+
return render_template_bundle()
|
|
40
|
+
|
|
41
|
+
task_dir = Path(args.path).expanduser().resolve()
|
|
42
|
+
if args.task_subcommand == "validate":
|
|
43
|
+
metadata = validate_task_dir(task_dir)
|
|
44
|
+
return render_json_payload(
|
|
45
|
+
{
|
|
46
|
+
"ok": True,
|
|
47
|
+
"data": {
|
|
48
|
+
"valid": True,
|
|
49
|
+
"task": {
|
|
50
|
+
"path": str(task_dir / "task.py"),
|
|
51
|
+
"meta_path": str(task_dir / "task.meta.json"),
|
|
52
|
+
"id": str(metadata["task"]["id"]),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"meta": {"action": "task-validate"},
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if args.task_subcommand == "run":
|
|
60
|
+
result = run_task_entrypoint(
|
|
61
|
+
task_path=task_dir / "task.py",
|
|
62
|
+
entrypoint="run",
|
|
63
|
+
inputs=parse_input_overrides(
|
|
64
|
+
getattr(args, "set_values", None),
|
|
65
|
+
getattr(args, "inputs_json", None),
|
|
66
|
+
),
|
|
67
|
+
artifacts_dir=task_dir / "artifacts",
|
|
68
|
+
)
|
|
69
|
+
return render_json_payload({"ok": True, "data": result, "meta": {"action": "task-run"}})
|
|
70
|
+
|
|
71
|
+
raise InvalidInputError(f"Unsupported task subcommand: {args.task_subcommand}")
|
browser_cli/constants.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Runtime paths and shared constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
APP_HOME_ENV = "BROWSER_CLI_HOME"
|
|
12
|
+
DEFAULT_HOME_DIRNAME = ".browser-cli"
|
|
13
|
+
DEFAULT_PUBLIC_AGENT_ID = "public"
|
|
14
|
+
EXTENSION_HOST_ENV = "BROWSER_CLI_EXTENSION_HOST"
|
|
15
|
+
EXTENSION_PORT_ENV = "BROWSER_CLI_EXTENSION_PORT"
|
|
16
|
+
DEFAULT_EXTENSION_HOST = "127.0.0.1"
|
|
17
|
+
DEFAULT_EXTENSION_PORT = 19825
|
|
18
|
+
AUTOMATION_SERVICE_HOST_ENV = "BROWSER_CLI_AUTOMATION_HOST"
|
|
19
|
+
AUTOMATION_SERVICE_PORT_ENV = "BROWSER_CLI_AUTOMATION_PORT"
|
|
20
|
+
DEFAULT_AUTOMATION_SERVICE_HOST = "127.0.0.1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True, frozen=True)
|
|
24
|
+
class AppPaths:
|
|
25
|
+
home: Path
|
|
26
|
+
run_dir: Path
|
|
27
|
+
socket_path: Path
|
|
28
|
+
run_info_path: Path
|
|
29
|
+
daemon_log_path: Path
|
|
30
|
+
artifacts_dir: Path
|
|
31
|
+
tasks_dir: Path
|
|
32
|
+
automations_dir: Path
|
|
33
|
+
automation_runs_dir: Path
|
|
34
|
+
automation_db_path: Path
|
|
35
|
+
automation_service_run_info_path: Path
|
|
36
|
+
automation_service_log_path: Path
|
|
37
|
+
automation_service_host: str
|
|
38
|
+
automation_service_port: int | None
|
|
39
|
+
extension_host: str
|
|
40
|
+
extension_port: int
|
|
41
|
+
extension_ws_path: str
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def extension_ws_url(self) -> str:
|
|
45
|
+
return f"ws://{self.extension_host}:{self.extension_port}{self.extension_ws_path}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_app_paths() -> AppPaths:
|
|
49
|
+
configured_home = os.environ.get(APP_HOME_ENV)
|
|
50
|
+
home = (
|
|
51
|
+
Path(configured_home).expanduser()
|
|
52
|
+
if configured_home
|
|
53
|
+
else Path.home() / DEFAULT_HOME_DIRNAME
|
|
54
|
+
)
|
|
55
|
+
run_dir = home / "run"
|
|
56
|
+
socket_path = run_dir / "browser-cli.sock"
|
|
57
|
+
if len(str(socket_path)) > 90:
|
|
58
|
+
digest = hashlib.sha1(str(home).encode("utf-8")).hexdigest()[:12]
|
|
59
|
+
socket_path = Path(tempfile.gettempdir()) / f"browser-cli-{digest}.sock"
|
|
60
|
+
automation_service_host = (
|
|
61
|
+
os.environ.get(AUTOMATION_SERVICE_HOST_ENV, DEFAULT_AUTOMATION_SERVICE_HOST).strip()
|
|
62
|
+
or DEFAULT_AUTOMATION_SERVICE_HOST
|
|
63
|
+
)
|
|
64
|
+
raw_automation_service_port = os.environ.get(AUTOMATION_SERVICE_PORT_ENV, "").strip()
|
|
65
|
+
automation_service_port: int | None = None
|
|
66
|
+
if raw_automation_service_port:
|
|
67
|
+
try:
|
|
68
|
+
automation_service_port = int(raw_automation_service_port)
|
|
69
|
+
except ValueError:
|
|
70
|
+
automation_service_port = None
|
|
71
|
+
extension_host = (
|
|
72
|
+
os.environ.get(EXTENSION_HOST_ENV, DEFAULT_EXTENSION_HOST).strip() or DEFAULT_EXTENSION_HOST
|
|
73
|
+
)
|
|
74
|
+
raw_extension_port = os.environ.get(EXTENSION_PORT_ENV, str(DEFAULT_EXTENSION_PORT)).strip()
|
|
75
|
+
try:
|
|
76
|
+
extension_port = int(raw_extension_port)
|
|
77
|
+
except ValueError:
|
|
78
|
+
extension_port = DEFAULT_EXTENSION_PORT
|
|
79
|
+
return AppPaths(
|
|
80
|
+
home=home,
|
|
81
|
+
run_dir=run_dir,
|
|
82
|
+
socket_path=socket_path,
|
|
83
|
+
run_info_path=run_dir / "daemon.json",
|
|
84
|
+
daemon_log_path=run_dir / "daemon.log",
|
|
85
|
+
artifacts_dir=home / "artifacts",
|
|
86
|
+
tasks_dir=home / "tasks",
|
|
87
|
+
automations_dir=home / "automations",
|
|
88
|
+
automation_runs_dir=home / "automations" / "runs",
|
|
89
|
+
automation_db_path=home / "automations.db",
|
|
90
|
+
automation_service_run_info_path=run_dir / "automation-service.json",
|
|
91
|
+
automation_service_log_path=run_dir / "automation-service.log",
|
|
92
|
+
automation_service_host=automation_service_host,
|
|
93
|
+
automation_service_port=automation_service_port,
|
|
94
|
+
extension_host=extension_host,
|
|
95
|
+
extension_port=extension_port,
|
|
96
|
+
extension_ws_path="/ext",
|
|
97
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Daemon package."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Run the browser-cli daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from browser_cli.constants import APP_HOME_ENV
|
|
11
|
+
|
|
12
|
+
from .server import BrowserDaemonServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _configure_logging() -> None:
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
19
|
+
force=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
25
|
+
parser.add_argument("--home")
|
|
26
|
+
args, _unknown = parser.parse_known_args()
|
|
27
|
+
if args.home:
|
|
28
|
+
os.environ[APP_HOME_ENV] = args.home
|
|
29
|
+
_configure_logging()
|
|
30
|
+
asyncio.run(BrowserDaemonServer().serve())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__": # pragma: no cover
|
|
34
|
+
main()
|