booking-agent-cli 0.1.0__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 (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,287 @@
1
+ """Out-of-process Google OAuth callback listener.
2
+
3
+ ``start`` binds the callback port in the calling process, hands the listening
4
+ socket to a detached child, records the child, and returns. The calling command
5
+ therefore exits right away, and a harness that kills it cannot take the listener
6
+ down with it or leave the port bound: the child stops on its own at its deadline.
7
+
8
+ ``status`` and ``cleanup`` read and clear the record, so a later command can
9
+ report the result or release a listener that is no longer wanted.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import signal
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ from booking_agent.secure_file import ensure_private_dir, write_private_text
25
+
26
+ STATUS_PENDING = "pending"
27
+ STATUS_CONNECTED = "connected"
28
+ STATUS_TIMEOUT = "timeout"
29
+ STATUS_FAILED = "failed"
30
+ STATUS_NONE = "none"
31
+
32
+ # A child is only signalled while it can still be ours: past this grace the pid may
33
+ # belong to an unrelated process, and the child has already exited on its own.
34
+ _KILL_GRACE_SECONDS = 30
35
+ _BIND_HOST = "0.0.0.0"
36
+
37
+
38
+ class CallbackPortBusy(RuntimeError):
39
+ """The OAuth redirect port is held by another process."""
40
+
41
+ def __init__(self, port: int):
42
+ super().__init__(f"Port {port} is already in use, so the sign-in callback cannot start.")
43
+ self.port = port
44
+
45
+
46
+ def _state_dir() -> Path:
47
+ from booking_agent.settings import booking_agent_home
48
+
49
+ return booking_agent_home() / ".calendar-oauth"
50
+
51
+
52
+ def _record_path() -> Path:
53
+ return _state_dir() / "listener.json"
54
+
55
+
56
+ def _result_path() -> Path:
57
+ return _state_dir() / "result.json"
58
+
59
+
60
+ def _read_json(path: Path) -> Optional[Dict[str, Any]]:
61
+ try:
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ except (OSError, ValueError):
64
+ return None
65
+ return data if isinstance(data, dict) else None
66
+
67
+
68
+ def _remove(path: Path) -> None:
69
+ try:
70
+ path.unlink()
71
+ except FileNotFoundError:
72
+ pass
73
+ except OSError:
74
+ pass
75
+
76
+
77
+ def pid_alive(pid: int) -> bool:
78
+ if pid <= 0:
79
+ return False
80
+ if os.name == "nt":
81
+ out = subprocess.run(
82
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
83
+ capture_output=True, text=True, check=False,
84
+ ).stdout
85
+ return str(pid) in out
86
+ try:
87
+ os.kill(pid, 0)
88
+ except ProcessLookupError:
89
+ return False
90
+ except PermissionError:
91
+ return True
92
+ return True
93
+
94
+
95
+ def _terminate(pid: int) -> None:
96
+ if os.name == "nt":
97
+ subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, check=False)
98
+ return
99
+ try:
100
+ os.kill(pid, signal.SIGTERM)
101
+ except OSError:
102
+ return
103
+ for _ in range(20):
104
+ if not pid_alive(pid):
105
+ return
106
+ time.sleep(0.1)
107
+ try:
108
+ os.kill(pid, signal.SIGKILL)
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def _record_owns_live_child(record: Dict[str, Any]) -> bool:
114
+ pid = int(record.get("pid") or 0)
115
+ deadline = float(record.get("deadline") or 0)
116
+ return time.time() < deadline + _KILL_GRACE_SECONDS and pid_alive(pid)
117
+
118
+
119
+ def cleanup() -> bool:
120
+ """Stop a recorded listener and forget it. True when a running child was stopped."""
121
+ record = _read_json(_record_path())
122
+ stopped = False
123
+ if record and _record_owns_live_child(record):
124
+ _terminate(int(record["pid"]))
125
+ stopped = True
126
+ _remove(_record_path())
127
+ _remove(_result_path())
128
+ return stopped
129
+
130
+
131
+ def _bind(port: int) -> socket.socket:
132
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
133
+ if os.name != "nt":
134
+ # Only lets a port in TIME_WAIT be reused; a live listener still refuses the bind.
135
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
136
+ try:
137
+ sock.bind((_BIND_HOST, port))
138
+ sock.listen(16)
139
+ except OSError as exc:
140
+ sock.close()
141
+ raise CallbackPortBusy(port) from exc
142
+ return sock
143
+
144
+
145
+ def start(
146
+ *,
147
+ port: int,
148
+ state: str,
149
+ pkce: Dict[str, str],
150
+ mode: str,
151
+ timeout: int,
152
+ ) -> Dict[str, Any]:
153
+ """Start the callback child for one sign-in attempt and return its record."""
154
+ cleanup()
155
+ ensure_private_dir(_state_dir())
156
+ sock = _bind(port)
157
+ deadline = time.time() + timeout
158
+ payload = {
159
+ "state": state,
160
+ "pkce": pkce,
161
+ "deadline": deadline,
162
+ "port": port,
163
+ "result_path": str(_result_path()),
164
+ }
165
+ kwargs: Dict[str, Any] = {
166
+ "stdin": subprocess.PIPE,
167
+ "stdout": subprocess.DEVNULL,
168
+ "stderr": subprocess.DEVNULL,
169
+ "env": dict(os.environ),
170
+ }
171
+ if os.name == "nt":
172
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
173
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0,
174
+ )
175
+ sock.close()
176
+ else:
177
+ sock.set_inheritable(True)
178
+ payload["fd"] = sock.fileno()
179
+ kwargs["pass_fds"] = (sock.fileno(),)
180
+ kwargs["start_new_session"] = True
181
+ try:
182
+ child = subprocess.Popen(
183
+ [sys.executable, "-m", "booking_agent.calendar.oauth_listener"], **kwargs,
184
+ )
185
+ assert child.stdin is not None
186
+ child.stdin.write(json.dumps(payload).encode("utf-8"))
187
+ child.stdin.close()
188
+ finally:
189
+ if os.name != "nt":
190
+ sock.close()
191
+ record = {
192
+ "pid": child.pid,
193
+ "port": port,
194
+ "mode": mode,
195
+ "state": state,
196
+ "deadline": deadline,
197
+ "started_at": time.time(),
198
+ }
199
+ write_private_text(_record_path(), json.dumps(record))
200
+ return record
201
+
202
+
203
+ def status() -> Dict[str, Any]:
204
+ """Current sign-in attempt: ``pending``, ``connected``, ``timeout``, ``failed``, or ``none``."""
205
+ record = _read_json(_record_path())
206
+ if not record:
207
+ return {"status": STATUS_NONE}
208
+ out = {"status": STATUS_PENDING, "mode": record.get("mode"), "deadline": record.get("deadline")}
209
+ result = _read_json(_result_path())
210
+ if result and result.get("state") == record.get("state"):
211
+ out["status"] = result.get("status") or STATUS_FAILED
212
+ if result.get("error"):
213
+ out["error"] = result["error"]
214
+ return out
215
+ if time.time() >= float(record.get("deadline") or 0):
216
+ out["status"] = STATUS_TIMEOUT
217
+ return out
218
+ if not pid_alive(int(record.get("pid") or 0)):
219
+ out["status"] = STATUS_FAILED
220
+ out["error"] = "The sign-in listener stopped before Google Calendar was connected."
221
+ return out
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Child process
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ def _write_result(path: str, state: str, status_value: str, error: Optional[str] = None) -> None:
230
+ body: Dict[str, Any] = {"state": state, "status": status_value}
231
+ if error:
232
+ body["error"] = error
233
+ write_private_text(Path(path), json.dumps(body))
234
+
235
+
236
+ async def _serve(payload: Dict[str, Any]) -> None:
237
+ import uvicorn
238
+
239
+ from booking_agent.calendar import google
240
+
241
+ state = payload["state"]
242
+ google._PKCE_STORE[state] = dict(payload["pkce"])
243
+ if "fd" in payload:
244
+ config = uvicorn.Config(google._build_callback_app(), fd=int(payload["fd"]), log_level="warning")
245
+ else:
246
+ config = uvicorn.Config(
247
+ google._build_callback_app(), host=_BIND_HOST, port=int(payload["port"]), log_level="warning",
248
+ )
249
+ server = uvicorn.Server(config)
250
+ result_path = payload["result_path"]
251
+ deadline = float(payload["deadline"])
252
+
253
+ async def _watch() -> None:
254
+ while not server.should_exit:
255
+ if state in google._TOKEN_STORE:
256
+ _write_result(result_path, state, STATUS_CONNECTED)
257
+ # Let the success page reach the browser before the listener stops.
258
+ await asyncio.sleep(1.5)
259
+ server.should_exit = True
260
+ return
261
+ if google._OAUTH_FAILURES.get(state):
262
+ _write_result(result_path, state, STATUS_FAILED, google._OAUTH_FAILURES[state])
263
+ await asyncio.sleep(1.5)
264
+ server.should_exit = True
265
+ return
266
+ if time.time() >= deadline:
267
+ _write_result(result_path, state, STATUS_TIMEOUT)
268
+ server.should_exit = True
269
+ return
270
+ await asyncio.sleep(0.5)
271
+
272
+ watcher = asyncio.create_task(_watch())
273
+ try:
274
+ await server.serve()
275
+ except (OSError, SystemExit) as exc:
276
+ _write_result(result_path, state, STATUS_FAILED, f"The sign-in listener could not start ({type(exc).__name__}).")
277
+ finally:
278
+ watcher.cancel()
279
+
280
+
281
+ def main() -> None:
282
+ payload = json.loads(sys.stdin.read() or "{}")
283
+ asyncio.run(_serve(payload))
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,97 @@
1
+ """Google Calendar OAuth scope helpers for the Booking Agent CLI."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Optional, Tuple
5
+
6
+ import httpx
7
+
8
+ from booking_agent.http_deadlines import coerce_timeout
9
+
10
+ SCOPE_FREEBUSY = "https://www.googleapis.com/auth/calendar.freebusy"
11
+ SCOPE_EVENTS = "https://www.googleapis.com/auth/calendar.events"
12
+ SCOPE_FULL = "https://www.googleapis.com/auth/calendar"
13
+
14
+ CALENDAR_MODE_NONE = "none"
15
+ CALENDAR_MODE_FREEBUSY = "freebusy"
16
+ CALENDAR_MODE_EVENTS = "events"
17
+
18
+ TOKENINFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo"
19
+
20
+ TOKENINFO_ERROR_INVALID = "invalid_token"
21
+ TOKENINFO_ERROR_HTTP = "tokeninfo_http_error"
22
+ TOKENINFO_ERROR_UNAVAILABLE = "tokeninfo_unavailable"
23
+
24
+ _WRITE_SCOPE_EXACT = {SCOPE_EVENTS, SCOPE_FULL}
25
+
26
+
27
+ def oauth_scope_param(mode: str) -> str:
28
+ """Space-delimited scope string for Google OAuth consent."""
29
+ if mode == CALENDAR_MODE_EVENTS:
30
+ return f"{SCOPE_FREEBUSY} {SCOPE_EVENTS}"
31
+ return SCOPE_FREEBUSY
32
+
33
+
34
+ def scope_string(tokeninfo: Dict[str, Any]) -> str:
35
+ return str(tokeninfo.get("scope") or "")
36
+
37
+
38
+ def supports_freebusy(scope: str) -> bool:
39
+ if not scope:
40
+ return False
41
+ parts = scope.split()
42
+ if SCOPE_FULL in parts or SCOPE_FREEBUSY in parts:
43
+ return True
44
+ if supports_events(scope):
45
+ return True
46
+ return "calendar.readonly" in scope or "calendar.freebusy" in scope
47
+
48
+
49
+ def supports_events(scope: str) -> bool:
50
+ if not scope:
51
+ return False
52
+ parts = scope.split()
53
+ if any(p in _WRITE_SCOPE_EXACT for p in parts):
54
+ return True
55
+ return SCOPE_FULL in parts
56
+
57
+
58
+ def scope_satisfies_mode(scope: str, mode: str) -> bool:
59
+ if mode == CALENDAR_MODE_EVENTS:
60
+ return supports_freebusy(scope) and supports_events(scope)
61
+ if mode == CALENDAR_MODE_FREEBUSY:
62
+ return supports_freebusy(scope)
63
+ return False
64
+
65
+
66
+ def fetch_tokeninfo(access_token: str) -> Dict[str, Any]:
67
+ with httpx.Client(timeout=coerce_timeout(15.0)) as client:
68
+ resp = client.get(TOKENINFO_URL, params={"access_token": access_token})
69
+ resp.raise_for_status()
70
+ return resp.json()
71
+
72
+
73
+ def tokeninfo_error(exc: Exception) -> Dict[str, Any]:
74
+ """Classify a tokeninfo failure without echoing the request URL or token."""
75
+ if isinstance(exc, httpx.HTTPStatusError):
76
+ status = exc.response.status_code
77
+ code = TOKENINFO_ERROR_INVALID if status in (400, 401) else TOKENINFO_ERROR_HTTP
78
+ return {"error": code, "http_status": status}
79
+ return {"error": TOKENINFO_ERROR_UNAVAILABLE}
80
+
81
+
82
+ def inspect_access_token(access_token: str) -> Tuple[Optional[str], Dict[str, Any]]:
83
+ """Return (scope_string, tokeninfo) or (None, sanitized_error_dict) on failure."""
84
+ try:
85
+ info = fetch_tokeninfo(access_token)
86
+ except (httpx.HTTPError, ValueError) as e:
87
+ return None, tokeninfo_error(e)
88
+ if not isinstance(info, dict):
89
+ return None, {"error": TOKENINFO_ERROR_UNAVAILABLE}
90
+ return scope_string(info), info
91
+
92
+
93
+ def connection_capabilities(scope: str) -> Dict[str, bool]:
94
+ return {
95
+ "supports_freebusy": supports_freebusy(scope),
96
+ "supports_events": supports_events(scope),
97
+ }
@@ -0,0 +1,116 @@
1
+ """Per-host circuit breaker so a failing merchant or registry is not hammered."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Union
7
+
8
+ import httpx
9
+
10
+ from . import settings
11
+ from booking_agent.observability import emit_event
12
+
13
+
14
+ class CircuitOpenError(RuntimeError):
15
+ """Raised when the circuit for a host is open and the cooldown has not elapsed."""
16
+
17
+ def __init__(self, host: str, retry_after: float) -> None:
18
+ super().__init__(
19
+ f"circuit open for {host}; retry after {retry_after:.0f}s"
20
+ )
21
+ self.host = host
22
+ self.retry_after = retry_after
23
+
24
+
25
+ def _host_key(url: Union[str, httpx.URL]) -> str:
26
+ parsed = httpx.URL(url) if isinstance(url, str) else url
27
+ host = parsed.host or ""
28
+ return host.lower()
29
+
30
+
31
+ @dataclass
32
+ class _Bucket:
33
+ failures: int = 0
34
+ opened_at: Optional[float] = None
35
+ half_open_emitted: bool = False
36
+
37
+
38
+ class HostCircuitBreaker:
39
+ """Counts consecutive failures per host and sheds load once the threshold trips.
40
+
41
+ A success closes the circuit. After ``cooldown_seconds`` an open circuit
42
+ becomes half-open: the next call is allowed through; another failure reopens
43
+ it immediately.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ failure_threshold: int = 5,
50
+ cooldown_seconds: float = 30.0,
51
+ disabled: bool = False,
52
+ ) -> None:
53
+ self.failure_threshold = max(1, failure_threshold)
54
+ self.cooldown_seconds = max(0.0, cooldown_seconds)
55
+ self.disabled = disabled
56
+ self._buckets: dict[str, _Bucket] = {}
57
+
58
+ @classmethod
59
+ def from_env(cls) -> "HostCircuitBreaker":
60
+ return cls(
61
+ failure_threshold=settings.get_int("BOOKING_AGENT_CIRCUIT_FAILURE_THRESHOLD"),
62
+ cooldown_seconds=settings.get_float("BOOKING_AGENT_CIRCUIT_COOLDOWN_SECONDS"),
63
+ disabled=settings.get_bool("BOOKING_AGENT_CIRCUIT_DISABLED"),
64
+ )
65
+
66
+ def guard(self, url: Union[str, httpx.URL]) -> None:
67
+ if self.disabled:
68
+ return
69
+ host = _host_key(url)
70
+ bucket = self._buckets.get(host)
71
+ if bucket is None or bucket.opened_at is None:
72
+ return
73
+ elapsed = time.monotonic() - bucket.opened_at
74
+ if elapsed >= self.cooldown_seconds:
75
+ if not bucket.half_open_emitted:
76
+ bucket.half_open_emitted = True
77
+ emit_event("circuit_half_open", host=host)
78
+ return
79
+ raise CircuitOpenError(host, retry_after=self.cooldown_seconds - elapsed)
80
+
81
+ def record_failure(self, url: Union[str, httpx.URL]) -> None:
82
+ if self.disabled:
83
+ return
84
+ host = _host_key(url)
85
+ bucket = self._buckets.setdefault(host, _Bucket())
86
+ bucket.failures += 1
87
+ if bucket.failures >= self.failure_threshold:
88
+ if bucket.opened_at is None:
89
+ bucket.opened_at = time.monotonic()
90
+ bucket.half_open_emitted = False
91
+ emit_event("circuit_open", host=host)
92
+ elif bucket.half_open_emitted:
93
+ bucket.opened_at = time.monotonic()
94
+ bucket.half_open_emitted = False
95
+ emit_event("circuit_open", host=host)
96
+
97
+ def record_success(self, url: Union[str, httpx.URL]) -> None:
98
+ if self.disabled:
99
+ return
100
+ host = _host_key(url)
101
+ self._buckets[host] = _Bucket()
102
+
103
+
104
+ _default: Optional[HostCircuitBreaker] = None
105
+
106
+
107
+ def default_breaker() -> HostCircuitBreaker:
108
+ global _default
109
+ if _default is None:
110
+ _default = HostCircuitBreaker.from_env()
111
+ return _default
112
+
113
+
114
+ def reset_default_breaker() -> None:
115
+ global _default
116
+ _default = None
@@ -0,0 +1,51 @@
1
+ """booking-agent configuration (USP env subset).
2
+
3
+ Reads settings (process environment, then the shipped defaults) and exposes them
4
+ as a small Config object.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+ from . import settings
12
+ from .platform_profile import platform_profile_url
13
+
14
+
15
+ @dataclass
16
+ class Config:
17
+ """Resolved USP configuration."""
18
+
19
+ usp_agent_profile_url: str = ""
20
+ usp_platform_name: Optional[str] = None
21
+ usp_platform_contact: Optional[str] = None
22
+ usp_registry_url: Optional[str] = None
23
+ # Enforcement posture for the USP Section 10.1.6 privileged-operation floor:
24
+ # required | preferred. None means "use the default".
25
+ usp_auth_scoped_credential: Optional[str] = None
26
+
27
+ google_calendar_client_id: str = ""
28
+ google_calendar_client_secret: str = ""
29
+ google_calendar_redirect_port: int = 8098
30
+
31
+
32
+ def _optional_strip(value: Optional[str]) -> Optional[str]:
33
+ if value is None:
34
+ return None
35
+ stripped = value.strip()
36
+ return stripped or None
37
+
38
+
39
+ def load_config() -> Config:
40
+ """Build a Config from the process environment and the shipped defaults."""
41
+ get = settings.get
42
+ return Config(
43
+ usp_agent_profile_url=platform_profile_url(),
44
+ usp_platform_name=get("USP_PLATFORM_NAME"),
45
+ usp_platform_contact=get("USP_PLATFORM_CONTACT"),
46
+ usp_registry_url=get("USP_REGISTRY_URL"),
47
+ usp_auth_scoped_credential=_optional_strip(get("BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL")),
48
+ google_calendar_client_id=get("GOOGLE_CALENDAR_CLIENT_ID") or "",
49
+ google_calendar_client_secret=get("GOOGLE_CALENDAR_CLIENT_SECRET") or "",
50
+ google_calendar_redirect_port=settings.get_int("GOOGLE_CALENDAR_REDIRECT_PORT"),
51
+ )
@@ -0,0 +1,50 @@
1
+ # Shipped defaults for booking-agent. Do not edit this copy.
2
+ # Override a value in the process environment or ~/.booking-agent/.env.
3
+ # `booking-agent config show` prints each effective value and its source.
4
+ # A `# NAME=` line has no default.
5
+
6
+ # --- Platform identity ---------------------------------------------------------
7
+ # Fetched by businesses from the USP-Agent and UCP-Agent headers.
8
+ USP_AGENT_PROFILE_URL=https://profile.booking-agent.dev/platform-profile.json
9
+ USP_REGISTRY_URL=https://www.wixapis.com/_api/usp-registry/registry/search_services
10
+ USP_PLATFORM_NAME=USP Agent
11
+ USP_PLATFORM_CONTACT=support@booking-agent.dev
12
+
13
+ # --- Install -------------------------------------------------------------------
14
+ # Sessions, tokens, and traces. Holds the user .env, so override it only in the
15
+ # process environment.
16
+ BOOKING_AGENT_SESSION_DIR=~/.booking-agent
17
+ BOOKING_AGENT_MAX_SESSIONS=30
18
+ LINK_CLI_BIN=link-cli
19
+
20
+ # --- Authorization -------------------------------------------------------------
21
+ # Booking-scoped credential floor: required or preferred.
22
+ BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL=required
23
+
24
+ # --- HTTP (seconds unless named otherwise) -------------------------------------
25
+ BOOKING_AGENT_HTTP_TIMEOUT=15
26
+ BOOKING_AGENT_HTTP_CONNECT_TIMEOUT=5
27
+ BOOKING_AGENT_HTTP_MAX_RETRIES=4
28
+ BOOKING_AGENT_HTTP_BACKOFF_BASE=1.0
29
+ BOOKING_AGENT_HTTP_BACKOFF_MAX=300.0
30
+ BOOKING_AGENT_CIRCUIT_FAILURE_THRESHOLD=5
31
+ BOOKING_AGENT_CIRCUIT_COOLDOWN_SECONDS=30
32
+ BOOKING_AGENT_CIRCUIT_DISABLED=0
33
+ # Minimum gap between requests to one business host.
34
+ BOOKING_AGENT_MERCHANT_REQUEST_DELAY_MS=200
35
+ BOOKING_AGENT_MERCHANT_HOST_CONCURRENCY=3
36
+ BOOKING_AGENT_DISCOVERY_CONCURRENCY=8
37
+ BOOKING_AGENT_PROFILE_CACHE_TTL_SECONDS=900
38
+ # 1 writes a redacted request log to <session dir>/http_trace.log.
39
+ BOOKING_AGENT_HTTP_TRACE=0
40
+
41
+ # --- Address -------------------------------------------------------------------
42
+ BOOKING_AGENT_GEOCODER_URL=https://nominatim.openstreetmap.org/search
43
+
44
+ # --- Google Calendar -----------------------------------------------------------
45
+ # The operator's OAuth app, needed only for a local calendar sign-in.
46
+ # GOOGLE_CALENDAR_CLIENT_ID=
47
+ # GOOGLE_CALENDAR_CLIENT_SECRET=
48
+ GOOGLE_CALENDAR_REDIRECT_PORT=8098
49
+ # A folder under the session dir, or an absolute path.
50
+ GOOGLE_CALENDAR_TOKEN_CACHE_DIR=.google-calendar-tokens