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,336 @@
|
|
|
1
|
+
"""Install and runtime diagnostics for Browser CLI environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from argparse import Namespace
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from browser_cli.automation.service.client import (
|
|
15
|
+
_probe_automation_service,
|
|
16
|
+
read_automation_service_run_info,
|
|
17
|
+
)
|
|
18
|
+
from browser_cli.constants import AppPaths, get_app_paths
|
|
19
|
+
from browser_cli.daemon.client import run_info_is_compatible, send_command
|
|
20
|
+
from browser_cli.daemon.transport import probe_socket, read_run_info
|
|
21
|
+
from browser_cli.outputs.json import render_json_payload
|
|
22
|
+
|
|
23
|
+
LOCK_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True, frozen=True)
|
|
27
|
+
class DoctorCheck:
|
|
28
|
+
id: str
|
|
29
|
+
status: str
|
|
30
|
+
summary: str
|
|
31
|
+
details: str | None = None
|
|
32
|
+
next: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_doctor_command(args: Namespace) -> str:
|
|
36
|
+
report = collect_doctor_report()
|
|
37
|
+
if getattr(args, "json", False):
|
|
38
|
+
return render_json_payload({"ok": True, "data": report, "meta": {"action": "doctor"}})
|
|
39
|
+
return render_doctor_report(report)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def collect_doctor_report() -> dict[str, Any]:
|
|
43
|
+
app_paths = get_app_paths()
|
|
44
|
+
daemon_payload = _daemon_runtime_payload()
|
|
45
|
+
checks = [
|
|
46
|
+
_package_check(),
|
|
47
|
+
_chrome_check(),
|
|
48
|
+
_playwright_check(),
|
|
49
|
+
_home_check(app_paths),
|
|
50
|
+
_managed_profile_check(),
|
|
51
|
+
_daemon_check(app_paths),
|
|
52
|
+
_automation_service_check(app_paths),
|
|
53
|
+
_extension_check(daemon_payload),
|
|
54
|
+
]
|
|
55
|
+
overall_status = "pass"
|
|
56
|
+
if any(item.status == "fail" for item in checks):
|
|
57
|
+
overall_status = "fail"
|
|
58
|
+
elif any(item.status == "warn" for item in checks):
|
|
59
|
+
overall_status = "warn"
|
|
60
|
+
return {
|
|
61
|
+
"overall_status": overall_status,
|
|
62
|
+
"checks": [asdict(item) for item in checks],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_doctor_report(report: dict[str, Any]) -> str:
|
|
67
|
+
lines = [f"Doctor: {report['overall_status']}", ""]
|
|
68
|
+
for check in report["checks"]:
|
|
69
|
+
details = f" ({check['details']})" if check.get("details") else ""
|
|
70
|
+
lines.append(f"{check['id']}: {check['status']} - {check['summary']}{details}")
|
|
71
|
+
if check.get("next"):
|
|
72
|
+
lines.append(f"Next: {check['next']}")
|
|
73
|
+
lines.append("")
|
|
74
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _package_check() -> DoctorCheck:
|
|
78
|
+
entrypoint = importlib.util.find_spec("browser_cli")
|
|
79
|
+
if entrypoint is None:
|
|
80
|
+
return DoctorCheck(
|
|
81
|
+
id="package",
|
|
82
|
+
status="fail",
|
|
83
|
+
summary="Python package import failed.",
|
|
84
|
+
next=(
|
|
85
|
+
"reinstall browser-control-and-automation-cli in this Python environment "
|
|
86
|
+
"and re-run browser-cli doctor"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
return DoctorCheck(
|
|
90
|
+
id="package",
|
|
91
|
+
status="pass",
|
|
92
|
+
summary="Python package import works.",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _chrome_check() -> DoctorCheck:
|
|
97
|
+
try:
|
|
98
|
+
executable = _discover_chrome_executable()
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
return DoctorCheck(
|
|
101
|
+
id="chrome",
|
|
102
|
+
status="fail",
|
|
103
|
+
summary="Stable Google Chrome was not found.",
|
|
104
|
+
details=str(exc),
|
|
105
|
+
next="install stable Google Chrome and re-run browser-cli doctor",
|
|
106
|
+
)
|
|
107
|
+
return DoctorCheck(
|
|
108
|
+
id="chrome",
|
|
109
|
+
status="pass",
|
|
110
|
+
summary="Stable Google Chrome was found.",
|
|
111
|
+
details=str(executable),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _playwright_check() -> DoctorCheck:
|
|
116
|
+
if importlib.util.find_spec("playwright") is None:
|
|
117
|
+
return DoctorCheck(
|
|
118
|
+
id="playwright",
|
|
119
|
+
status="fail",
|
|
120
|
+
summary="Playwright Python package is not installed.",
|
|
121
|
+
next=(
|
|
122
|
+
"reinstall browser-control-and-automation-cli with uv tool install "
|
|
123
|
+
"--reinstall browser-control-and-automation-cli, or run uv sync --dev "
|
|
124
|
+
"in a repository checkout, then re-run browser-cli doctor"
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return DoctorCheck(
|
|
128
|
+
id="playwright",
|
|
129
|
+
status="pass",
|
|
130
|
+
summary="Playwright Python package import works.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _home_check(app_paths: AppPaths) -> DoctorCheck:
|
|
135
|
+
if not app_paths.home.exists():
|
|
136
|
+
return DoctorCheck(
|
|
137
|
+
id="home",
|
|
138
|
+
status="fail",
|
|
139
|
+
summary="Browser CLI home does not exist yet.",
|
|
140
|
+
details=str(app_paths.home),
|
|
141
|
+
next="create the Browser CLI home directory or run a Browser CLI command that initializes it, then re-run browser-cli doctor",
|
|
142
|
+
)
|
|
143
|
+
if not _is_writable_directory(app_paths.home):
|
|
144
|
+
return DoctorCheck(
|
|
145
|
+
id="home",
|
|
146
|
+
status="fail",
|
|
147
|
+
summary="Browser CLI home is not writable.",
|
|
148
|
+
details=str(app_paths.home),
|
|
149
|
+
next="set BROWSER_CLI_HOME to a writable directory and re-run browser-cli doctor",
|
|
150
|
+
)
|
|
151
|
+
return DoctorCheck(
|
|
152
|
+
id="home",
|
|
153
|
+
status="pass",
|
|
154
|
+
summary="Browser CLI home is ready.",
|
|
155
|
+
details=str(app_paths.home),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _managed_profile_check() -> DoctorCheck:
|
|
160
|
+
profile_root = get_app_paths().home / "default-profile"
|
|
161
|
+
if not profile_root.exists():
|
|
162
|
+
return DoctorCheck(
|
|
163
|
+
id="managed_profile",
|
|
164
|
+
status="fail",
|
|
165
|
+
summary="Managed profile directory does not exist yet.",
|
|
166
|
+
details=str(profile_root),
|
|
167
|
+
next="run browser-cli read https://example.com once to initialize the managed profile, then re-run browser-cli doctor",
|
|
168
|
+
)
|
|
169
|
+
if not _is_writable_directory(profile_root):
|
|
170
|
+
return DoctorCheck(
|
|
171
|
+
id="managed_profile",
|
|
172
|
+
status="fail",
|
|
173
|
+
summary="Managed profile directory is not writable.",
|
|
174
|
+
details=str(profile_root),
|
|
175
|
+
next="fix permissions for the Browser CLI home directory and re-run browser-cli doctor",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
lock_paths = [
|
|
179
|
+
profile_root / lock_name for lock_name in LOCK_FILES if (profile_root / lock_name).exists()
|
|
180
|
+
]
|
|
181
|
+
if lock_paths:
|
|
182
|
+
return DoctorCheck(
|
|
183
|
+
id="managed_profile",
|
|
184
|
+
status="warn",
|
|
185
|
+
summary="Managed profile directory exists but appears to be in use.",
|
|
186
|
+
details=", ".join(str(path) for path in lock_paths),
|
|
187
|
+
next="close Browser CLI-owned Chrome windows or inspect browser-cli status",
|
|
188
|
+
)
|
|
189
|
+
return DoctorCheck(
|
|
190
|
+
id="managed_profile",
|
|
191
|
+
status="pass",
|
|
192
|
+
summary="Managed profile directory is writable.",
|
|
193
|
+
details=str(profile_root),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _daemon_check(app_paths: AppPaths) -> DoctorCheck:
|
|
198
|
+
run_info = read_run_info()
|
|
199
|
+
return _runtime_reachability_check(
|
|
200
|
+
id="daemon",
|
|
201
|
+
metadata_exists=run_info is not None,
|
|
202
|
+
reachable=probe_socket(),
|
|
203
|
+
metadata_path=app_paths.run_info_path,
|
|
204
|
+
reachable_summary="Browser daemon is reachable.",
|
|
205
|
+
unreachable_summary="Daemon runtime metadata exists but the daemon is not reachable.",
|
|
206
|
+
missing_summary="Daemon is not running yet.",
|
|
207
|
+
reachable_details=app_paths.socket_path,
|
|
208
|
+
unreachable_next="run browser-cli reload",
|
|
209
|
+
missing_next="run browser-cli read https://example.com or browser-cli status when you are ready",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _automation_service_check(app_paths: AppPaths) -> DoctorCheck:
|
|
214
|
+
run_info = read_automation_service_run_info()
|
|
215
|
+
return _runtime_reachability_check(
|
|
216
|
+
id="automation_service",
|
|
217
|
+
metadata_exists=run_info is not None,
|
|
218
|
+
reachable=_probe_automation_service(run_info),
|
|
219
|
+
metadata_path=app_paths.automation_service_run_info_path,
|
|
220
|
+
reachable_summary="Automation service is reachable.",
|
|
221
|
+
unreachable_summary="Automation service metadata exists but service is not reachable.",
|
|
222
|
+
missing_summary="Automation service is not running yet.",
|
|
223
|
+
reachable_details=app_paths.automation_service_run_info_path,
|
|
224
|
+
unreachable_next="run browser-cli automation status or browser-cli automation ui after restarting the service",
|
|
225
|
+
missing_next="run browser-cli automation status or browser-cli automation ui when you need published automation management",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _extension_check(daemon_payload: dict[str, Any] | None) -> DoctorCheck:
|
|
230
|
+
if daemon_payload is None:
|
|
231
|
+
return DoctorCheck(
|
|
232
|
+
id="extension",
|
|
233
|
+
status="warn",
|
|
234
|
+
summary="Extension reachability is unavailable until the daemon is running.",
|
|
235
|
+
next="run browser-cli status after the daemon starts if you need extension mode",
|
|
236
|
+
)
|
|
237
|
+
extension = dict(daemon_payload.get("extension") or {})
|
|
238
|
+
if bool(extension.get("connected")) and bool(extension.get("capability_complete")):
|
|
239
|
+
return DoctorCheck(
|
|
240
|
+
id="extension",
|
|
241
|
+
status="pass",
|
|
242
|
+
summary="Browser CLI extension is connected and capability-complete.",
|
|
243
|
+
)
|
|
244
|
+
if bool(extension.get("connected")):
|
|
245
|
+
missing = ", ".join(str(item) for item in (extension.get("missing_capabilities") or []))
|
|
246
|
+
details = missing or "required capability set is incomplete"
|
|
247
|
+
return DoctorCheck(
|
|
248
|
+
id="extension",
|
|
249
|
+
status="warn",
|
|
250
|
+
summary="Browser CLI extension is connected but incomplete.",
|
|
251
|
+
details=details,
|
|
252
|
+
next="reload the extension or run browser-cli reload after reconnecting it",
|
|
253
|
+
)
|
|
254
|
+
return DoctorCheck(
|
|
255
|
+
id="extension",
|
|
256
|
+
status="warn",
|
|
257
|
+
summary="Browser CLI extension is not connected.",
|
|
258
|
+
next="start with managed profile mode, or connect the extension if you need real Chrome behavior",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _discover_chrome_executable() -> Path:
|
|
263
|
+
if sys.platform == "darwin":
|
|
264
|
+
candidates = [Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")]
|
|
265
|
+
elif sys.platform.startswith("linux"):
|
|
266
|
+
candidates = []
|
|
267
|
+
for binary_name in ("google-chrome", "google-chrome-stable"):
|
|
268
|
+
binary_path = shutil.which(binary_name)
|
|
269
|
+
if binary_path:
|
|
270
|
+
candidates.append(Path(binary_path))
|
|
271
|
+
candidates.extend(
|
|
272
|
+
[
|
|
273
|
+
Path("/opt/google/chrome/chrome"),
|
|
274
|
+
Path("/usr/bin/google-chrome"),
|
|
275
|
+
Path("/usr/bin/google-chrome-stable"),
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
raise RuntimeError(f"Unsupported platform for Chrome discovery: {sys.platform}")
|
|
280
|
+
|
|
281
|
+
for candidate in candidates:
|
|
282
|
+
if candidate.exists():
|
|
283
|
+
return candidate
|
|
284
|
+
raise RuntimeError("Stable Google Chrome was not found on this machine.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _daemon_runtime_payload() -> dict[str, Any] | None:
|
|
288
|
+
run_info = read_run_info()
|
|
289
|
+
if not probe_socket() or not run_info_is_compatible(run_info):
|
|
290
|
+
return None
|
|
291
|
+
try:
|
|
292
|
+
response = send_command("runtime-status", {"warmup": False}, start_if_needed=False)
|
|
293
|
+
except Exception:
|
|
294
|
+
return None
|
|
295
|
+
data = response.get("data")
|
|
296
|
+
return dict(data) if isinstance(data, dict) else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_writable_directory(path: Path) -> bool:
|
|
300
|
+
return path.is_dir() and os.access(path, os.W_OK)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _runtime_reachability_check(
|
|
304
|
+
*,
|
|
305
|
+
id: str,
|
|
306
|
+
metadata_exists: bool,
|
|
307
|
+
reachable: bool,
|
|
308
|
+
metadata_path: Path,
|
|
309
|
+
reachable_summary: str,
|
|
310
|
+
unreachable_summary: str,
|
|
311
|
+
missing_summary: str,
|
|
312
|
+
reachable_details: Path,
|
|
313
|
+
unreachable_next: str,
|
|
314
|
+
missing_next: str,
|
|
315
|
+
) -> DoctorCheck:
|
|
316
|
+
if reachable:
|
|
317
|
+
return DoctorCheck(
|
|
318
|
+
id=id,
|
|
319
|
+
status="pass",
|
|
320
|
+
summary=reachable_summary,
|
|
321
|
+
details=str(reachable_details),
|
|
322
|
+
)
|
|
323
|
+
if metadata_exists:
|
|
324
|
+
return DoctorCheck(
|
|
325
|
+
id=id,
|
|
326
|
+
status="warn",
|
|
327
|
+
summary=unreachable_summary,
|
|
328
|
+
details=str(metadata_path),
|
|
329
|
+
next=unreachable_next,
|
|
330
|
+
)
|
|
331
|
+
return DoctorCheck(
|
|
332
|
+
id=id,
|
|
333
|
+
status="warn",
|
|
334
|
+
summary=missing_summary,
|
|
335
|
+
next=missing_next,
|
|
336
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Install packaged Browser CLI skills into a target skills directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from importlib.abc import Traversable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from browser_cli.errors import InvalidInputError, OperationFailedError
|
|
15
|
+
|
|
16
|
+
PUBLIC_SKILL_NAMES = (
|
|
17
|
+
"browser-cli-converge",
|
|
18
|
+
"browser-cli-delivery",
|
|
19
|
+
"browser-cli-explore",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class PackagedSkill:
|
|
25
|
+
name: str
|
|
26
|
+
source: Traversable | Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _packaged_skills_root() -> Traversable:
|
|
30
|
+
return resources.files("browser_cli.packaged_skills")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover_packaged_skills() -> list[PackagedSkill]:
|
|
34
|
+
root = _packaged_skills_root()
|
|
35
|
+
discovered: list[PackagedSkill] = []
|
|
36
|
+
for name in PUBLIC_SKILL_NAMES:
|
|
37
|
+
skill_root = root.joinpath(name)
|
|
38
|
+
if not skill_root.is_dir():
|
|
39
|
+
raise InvalidInputError(f"Packaged skill is missing from this build: {name}")
|
|
40
|
+
skill_doc = skill_root.joinpath("SKILL.md")
|
|
41
|
+
if not skill_doc.is_file():
|
|
42
|
+
raise InvalidInputError(
|
|
43
|
+
f"Packaged skill is incomplete in this build: {name} is missing SKILL.md"
|
|
44
|
+
)
|
|
45
|
+
discovered.append(PackagedSkill(name=name, source=skill_root))
|
|
46
|
+
return discovered
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_skills_target_path(target: str | None) -> Path:
|
|
50
|
+
if target:
|
|
51
|
+
return Path(target).expanduser().resolve()
|
|
52
|
+
return Path.home() / ".agents" / "skills"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def install_skills_from_paths(
|
|
56
|
+
skills: list[PackagedSkill],
|
|
57
|
+
target: Path,
|
|
58
|
+
*,
|
|
59
|
+
dry_run: bool = False,
|
|
60
|
+
) -> list[tuple[str, str]]:
|
|
61
|
+
results: list[tuple[str, str]] = []
|
|
62
|
+
if not dry_run:
|
|
63
|
+
try:
|
|
64
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise OperationFailedError(
|
|
67
|
+
f"Could not create skills target directory {target}: {exc}"
|
|
68
|
+
) from exc
|
|
69
|
+
for skill in skills:
|
|
70
|
+
destination = target / skill.name
|
|
71
|
+
exists = destination.exists()
|
|
72
|
+
status = (
|
|
73
|
+
"would update"
|
|
74
|
+
if dry_run and exists
|
|
75
|
+
else "would install"
|
|
76
|
+
if dry_run
|
|
77
|
+
else "updated"
|
|
78
|
+
if exists
|
|
79
|
+
else "installed"
|
|
80
|
+
)
|
|
81
|
+
if not dry_run:
|
|
82
|
+
_install_one_skill(skill, destination)
|
|
83
|
+
results.append((skill.name, status))
|
|
84
|
+
return results
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _install_one_skill(skill: PackagedSkill, destination: Path) -> None:
|
|
88
|
+
source = skill.source if isinstance(skill.source, Traversable) else Path(skill.source)
|
|
89
|
+
staged = destination.with_name(f"{destination.name}.tmp-{uuid.uuid4().hex}")
|
|
90
|
+
try:
|
|
91
|
+
_copy_skill_tree(source, staged)
|
|
92
|
+
backup = _swap_staged_directory(staged, destination)
|
|
93
|
+
if backup is not None:
|
|
94
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
if staged.exists():
|
|
97
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
98
|
+
raise OperationFailedError(
|
|
99
|
+
f"Could not install skill {skill.name} to {destination}: {exc}"
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _copy_skill_tree(source: Traversable | Path, destination: Path) -> None:
|
|
104
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
105
|
+
for child in source.iterdir():
|
|
106
|
+
child_destination = destination / child.name
|
|
107
|
+
if child.is_dir():
|
|
108
|
+
_copy_skill_tree(child, child_destination)
|
|
109
|
+
continue
|
|
110
|
+
with child.open("rb") as handle, child_destination.open("wb") as output:
|
|
111
|
+
shutil.copyfileobj(handle, output)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _swap_staged_directory(staged: Path, destination: Path) -> Path | None:
|
|
115
|
+
backup: Path | None = None
|
|
116
|
+
if destination.exists():
|
|
117
|
+
backup = destination.with_name(f"{destination.name}.bak-{uuid.uuid4().hex}")
|
|
118
|
+
os.replace(destination, backup)
|
|
119
|
+
try:
|
|
120
|
+
os.replace(staged, destination)
|
|
121
|
+
except OSError:
|
|
122
|
+
if backup is not None and backup.exists() and not destination.exists():
|
|
123
|
+
os.replace(backup, destination)
|
|
124
|
+
raise
|
|
125
|
+
return backup
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_install_skills_command(args: argparse.Namespace) -> str:
|
|
129
|
+
skills = discover_packaged_skills()
|
|
130
|
+
target = get_skills_target_path(getattr(args, "target", None))
|
|
131
|
+
results = install_skills_from_paths(skills, target, dry_run=bool(args.dry_run))
|
|
132
|
+
mode = "(dry-run) " if args.dry_run else ""
|
|
133
|
+
lines = [f"{mode}Installing skills to {target}:", ""]
|
|
134
|
+
for skill_name, status in results:
|
|
135
|
+
lines.append(f" {skill_name}: {status}")
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append(f"Total: {len(results)} skill(s)")
|
|
138
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Filesystem path discovery command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
|
|
7
|
+
from browser_cli.constants import get_app_paths
|
|
8
|
+
from browser_cli.outputs.json import render_json_payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _paths_payload() -> dict[str, str]:
|
|
12
|
+
app_paths = get_app_paths()
|
|
13
|
+
return {
|
|
14
|
+
"home": str(app_paths.home),
|
|
15
|
+
"tasks_dir": str(app_paths.tasks_dir),
|
|
16
|
+
"automations_dir": str(app_paths.automations_dir),
|
|
17
|
+
"artifacts_dir": str(app_paths.artifacts_dir),
|
|
18
|
+
"daemon_log_path": str(app_paths.daemon_log_path),
|
|
19
|
+
"automation_db_path": str(app_paths.automation_db_path),
|
|
20
|
+
"automation_service_run_info_path": str(app_paths.automation_service_run_info_path),
|
|
21
|
+
"automation_service_log_path": str(app_paths.automation_service_log_path),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_paths_command(args: Namespace) -> str:
|
|
26
|
+
payload = _paths_payload()
|
|
27
|
+
if getattr(args, "json", False):
|
|
28
|
+
return render_json_payload({"ok": True, "data": payload, "meta": {"action": "paths"}})
|
|
29
|
+
lines = ["Paths", ""]
|
|
30
|
+
lines.extend(f"{key}: {value}" for key, value in payload.items())
|
|
31
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Read command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from argparse import Namespace
|
|
7
|
+
|
|
8
|
+
from browser_cli.outputs.render import render_output
|
|
9
|
+
from browser_cli.task_runtime import BrowserCliTaskClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_url(url: str) -> str:
|
|
13
|
+
if "://" in url:
|
|
14
|
+
return url
|
|
15
|
+
return f"https://{url}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_read_command(args: Namespace) -> str:
|
|
19
|
+
client = BrowserCliTaskClient()
|
|
20
|
+
result = client.read(
|
|
21
|
+
normalize_url(args.url),
|
|
22
|
+
output_mode="snapshot" if args.snapshot else "html",
|
|
23
|
+
scroll_bottom=bool(args.scroll_bottom),
|
|
24
|
+
)
|
|
25
|
+
if result.used_fallback_profile and result.fallback_profile_dir:
|
|
26
|
+
message = (
|
|
27
|
+
"Info: primary Chrome profile unavailable; using fallback profile at "
|
|
28
|
+
f"{result.fallback_profile_dir}"
|
|
29
|
+
)
|
|
30
|
+
if result.fallback_reason:
|
|
31
|
+
message += f". Reason: {result.fallback_reason}"
|
|
32
|
+
sys.stderr.write(message + "\n")
|
|
33
|
+
return render_output(result.body)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Lifecycle reload command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
|
|
7
|
+
from browser_cli.daemon.client import (
|
|
8
|
+
cleanup_runtime,
|
|
9
|
+
ensure_daemon_running,
|
|
10
|
+
send_command,
|
|
11
|
+
wait_for_daemon_stop,
|
|
12
|
+
)
|
|
13
|
+
from browser_cli.errors import BrowserCliError, OperationFailedError
|
|
14
|
+
|
|
15
|
+
from .status import collect_status_report, render_status_report
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_reload_command(_args: Namespace) -> str:
|
|
19
|
+
graceful_stop = False
|
|
20
|
+
forced_cleanup = False
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
stop_response = send_command("stop", start_if_needed=False)
|
|
24
|
+
graceful_stop = not bool((stop_response.get("data") or {}).get("already_stopped"))
|
|
25
|
+
except BrowserCliError:
|
|
26
|
+
graceful_stop = False
|
|
27
|
+
|
|
28
|
+
if not wait_for_daemon_stop():
|
|
29
|
+
forced_cleanup = True
|
|
30
|
+
|
|
31
|
+
if cleanup_runtime(fast_kill=True):
|
|
32
|
+
forced_cleanup = True
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
ensure_daemon_running()
|
|
36
|
+
after = collect_status_report(warmup=False)
|
|
37
|
+
except BrowserCliError as exc:
|
|
38
|
+
raise OperationFailedError(
|
|
39
|
+
"Reload cleared Browser CLI runtime state, but restart failed. "
|
|
40
|
+
f"Reason: {exc}. Run `browser-cli status` and inspect the daemon log for details."
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
lines = [
|
|
44
|
+
"Reload: complete",
|
|
45
|
+
f"- graceful stop: {'yes' if graceful_stop else 'no'}",
|
|
46
|
+
f"- forced cleanup: {'yes' if forced_cleanup else 'no'}",
|
|
47
|
+
f"- result: {after.overall_status}",
|
|
48
|
+
"",
|
|
49
|
+
render_status_report(after).rstrip(),
|
|
50
|
+
]
|
|
51
|
+
return "\n".join(lines) + "\n"
|