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
@@ -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}")
@@ -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()