tescmd 0.2.0__py3-none-any.whl → 0.3.1__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 (61) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +5 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +96 -14
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/main.py +27 -8
  15. tescmd/cli/mcp_cmd.py +153 -0
  16. tescmd/cli/nav.py +3 -1
  17. tescmd/cli/openclaw.py +169 -0
  18. tescmd/cli/security.py +7 -1
  19. tescmd/cli/serve.py +923 -0
  20. tescmd/cli/setup.py +18 -7
  21. tescmd/cli/sharing.py +2 -0
  22. tescmd/cli/status.py +1 -1
  23. tescmd/cli/trunk.py +8 -17
  24. tescmd/cli/user.py +16 -1
  25. tescmd/cli/vehicle.py +135 -462
  26. tescmd/deploy/github_pages.py +8 -0
  27. tescmd/mcp/__init__.py +7 -0
  28. tescmd/mcp/server.py +648 -0
  29. tescmd/models/auth.py +5 -2
  30. tescmd/openclaw/__init__.py +23 -0
  31. tescmd/openclaw/bridge.py +330 -0
  32. tescmd/openclaw/config.py +167 -0
  33. tescmd/openclaw/dispatcher.py +522 -0
  34. tescmd/openclaw/emitter.py +175 -0
  35. tescmd/openclaw/filters.py +123 -0
  36. tescmd/openclaw/gateway.py +687 -0
  37. tescmd/openclaw/telemetry_store.py +53 -0
  38. tescmd/output/rich_output.py +46 -14
  39. tescmd/protocol/commands.py +2 -2
  40. tescmd/protocol/encoder.py +16 -13
  41. tescmd/protocol/payloads.py +132 -11
  42. tescmd/protocol/session.py +8 -5
  43. tescmd/protocol/signer.py +3 -17
  44. tescmd/telemetry/__init__.py +9 -0
  45. tescmd/telemetry/cache_sink.py +154 -0
  46. tescmd/telemetry/csv_sink.py +180 -0
  47. tescmd/telemetry/dashboard.py +4 -4
  48. tescmd/telemetry/fanout.py +49 -0
  49. tescmd/telemetry/fields.py +308 -129
  50. tescmd/telemetry/mapper.py +239 -0
  51. tescmd/telemetry/server.py +26 -19
  52. tescmd/telemetry/setup.py +468 -0
  53. tescmd/telemetry/tui.py +1716 -0
  54. tescmd/triggers/__init__.py +18 -0
  55. tescmd/triggers/manager.py +264 -0
  56. tescmd/triggers/models.py +93 -0
  57. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
  58. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
  59. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  60. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  61. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/models/auth.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import base64
4
4
  import json
5
5
  import logging
6
+ import os as _os
6
7
  from typing import Any
7
8
 
8
9
  from pydantic import BaseModel
@@ -44,10 +45,10 @@ DEFAULT_SCOPES: list[str] = [
44
45
  *USER_SCOPES,
45
46
  ]
46
47
 
47
- DEFAULT_PORT: int = 8085
48
+ DEFAULT_PORT: int = int(_os.environ.get("TESCMD_OAUTH_PORT", "8085"))
48
49
  DEFAULT_REDIRECT_URI: str = f"http://localhost:{DEFAULT_PORT}/callback"
49
50
 
50
- AUTH_BASE_URL: str = "https://auth.tesla.com"
51
+ AUTH_BASE_URL: str = _os.environ.get("TESLA_AUTH_BASE_URL", "https://auth.tesla.com")
51
52
  AUTHORIZE_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/authorize"
52
53
  TOKEN_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/token"
53
54
 
@@ -97,6 +98,8 @@ def decode_jwt_scopes(token: str) -> list[str] | None:
97
98
  scp = payload.get("scp")
98
99
  if isinstance(scp, list):
99
100
  return [str(s) for s in scp]
101
+ if isinstance(scp, str):
102
+ return scp.split()
100
103
  return None
101
104
 
102
105
 
@@ -0,0 +1,23 @@
1
+ """OpenClaw integration — bridge Fleet Telemetry to an OpenClaw Gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tescmd.openclaw.bridge import TelemetryBridge
6
+ from tescmd.openclaw.config import BridgeConfig, FieldFilter, NodeCapabilities
7
+ from tescmd.openclaw.dispatcher import CommandDispatcher
8
+ from tescmd.openclaw.emitter import EventEmitter
9
+ from tescmd.openclaw.filters import DualGateFilter
10
+ from tescmd.openclaw.gateway import GatewayClient
11
+ from tescmd.openclaw.telemetry_store import TelemetryStore
12
+
13
+ __all__ = [
14
+ "BridgeConfig",
15
+ "CommandDispatcher",
16
+ "DualGateFilter",
17
+ "EventEmitter",
18
+ "FieldFilter",
19
+ "GatewayClient",
20
+ "NodeCapabilities",
21
+ "TelemetryBridge",
22
+ "TelemetryStore",
23
+ ]
@@ -0,0 +1,330 @@
1
+ """Telemetry bridge orchestrator.
2
+
3
+ Wires the pipeline: TelemetryServer.on_frame → DualGateFilter → EventEmitter
4
+ → GatewayClient. Passed as ``on_frame`` callback to ``TelemetryServer``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from tescmd.cli.main import AppContext
17
+ from tescmd.openclaw.config import BridgeConfig
18
+ from tescmd.openclaw.emitter import EventEmitter
19
+ from tescmd.openclaw.filters import DualGateFilter
20
+ from tescmd.openclaw.gateway import GatewayClient
21
+ from tescmd.openclaw.telemetry_store import TelemetryStore
22
+ from tescmd.telemetry.decoder import TelemetryFrame
23
+ from tescmd.triggers.manager import TriggerManager
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _RECONNECT_BASE = 5.0
28
+ _RECONNECT_MAX = 120.0
29
+
30
+
31
+ class TelemetryBridge:
32
+ """Orchestrates filter → emit → send for each telemetry frame.
33
+
34
+ Usage::
35
+
36
+ bridge = TelemetryBridge(config, gateway, filt, emitter)
37
+ # Pass bridge.on_frame as the telemetry server callback
38
+ server = TelemetryServer(port, decoder, bridge.on_frame, ...)
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ gateway: GatewayClient,
44
+ filt: DualGateFilter,
45
+ emitter: EventEmitter,
46
+ *,
47
+ dry_run: bool = False,
48
+ telemetry_store: TelemetryStore | None = None,
49
+ trigger_manager: TriggerManager | None = None,
50
+ vin: str = "",
51
+ client_id: str = "node-host",
52
+ ) -> None:
53
+ self._gateway = gateway
54
+ self._filter = filt
55
+ self._emitter = emitter
56
+ self._dry_run = dry_run
57
+ self._telemetry_store = telemetry_store
58
+ self._trigger_manager = trigger_manager
59
+ self._vin = vin
60
+ self._client_id = client_id
61
+ self._event_count = 0
62
+ self._drop_count = 0
63
+ self._last_event_time: float | None = None
64
+ self._first_frame_received = False
65
+ self._reconnect_at: float = 0.0
66
+ self._reconnect_backoff: float = _RECONNECT_BASE
67
+ self._shutting_down = False
68
+
69
+ @property
70
+ def event_count(self) -> int:
71
+ return self._event_count
72
+
73
+ @property
74
+ def drop_count(self) -> int:
75
+ return self._drop_count
76
+
77
+ @property
78
+ def last_event_time(self) -> float | None:
79
+ return self._last_event_time
80
+
81
+ async def _maybe_reconnect(self) -> None:
82
+ """Attempt gateway reconnection with exponential backoff."""
83
+ if self._shutting_down:
84
+ return
85
+ now = time.monotonic()
86
+ if now < self._reconnect_at:
87
+ return
88
+ logger.info("Attempting OpenClaw gateway reconnection...")
89
+ try:
90
+ await self._gateway.connect()
91
+ self._reconnect_backoff = _RECONNECT_BASE
92
+ logger.info("Reconnected to OpenClaw gateway")
93
+ except Exception:
94
+ self._reconnect_at = now + self._reconnect_backoff
95
+ logger.warning(
96
+ "Reconnection failed — next attempt in %.0fs",
97
+ self._reconnect_backoff,
98
+ )
99
+ self._reconnect_backoff = min(self._reconnect_backoff * 2, _RECONNECT_MAX)
100
+
101
+ def _build_lifecycle_event(self, event_type: str) -> dict[str, Any]:
102
+ """Build a ``req:agent`` lifecycle event (connecting/disconnecting)."""
103
+ return {
104
+ "method": "req:agent",
105
+ "params": {
106
+ "event_type": event_type,
107
+ "source": self._client_id,
108
+ "vin": self._vin,
109
+ "timestamp": datetime.now(UTC).isoformat(),
110
+ "data": {},
111
+ },
112
+ }
113
+
114
+ def make_trigger_push_callback(self) -> Any:
115
+ """Return an async callback that pushes trigger notifications to the gateway.
116
+
117
+ Suitable for passing to ``TriggerManager.add_on_fire()``. Returns
118
+ ``None`` when in dry-run mode (caller should skip registration).
119
+ """
120
+ if self._dry_run:
121
+ return None
122
+
123
+ gateway = self._gateway
124
+ client_id = self._client_id
125
+
126
+ async def _push_trigger_notification(n: Any) -> None:
127
+ if gateway.is_connected:
128
+ try:
129
+ await gateway.send_event(
130
+ {
131
+ "method": "req:agent",
132
+ "params": {
133
+ "event_type": "trigger.fired",
134
+ "source": client_id,
135
+ "vin": n.vin,
136
+ "timestamp": n.fired_at.isoformat(),
137
+ "data": n.model_dump(mode="json"),
138
+ },
139
+ }
140
+ )
141
+ except Exception:
142
+ logger.warning(
143
+ "Failed to push trigger notification (trigger=%s field=%s)",
144
+ n.trigger_id,
145
+ n.field,
146
+ exc_info=True,
147
+ )
148
+
149
+ return _push_trigger_notification
150
+
151
+ async def send_disconnecting(self) -> None:
152
+ """Send a ``node.disconnecting`` lifecycle event to the gateway.
153
+
154
+ Called during shutdown before the gateway connection is closed.
155
+ Silently ignored if the gateway is not connected.
156
+ """
157
+ self._shutting_down = True
158
+ if self._dry_run:
159
+ return
160
+ event = self._build_lifecycle_event("node.disconnecting")
161
+ try:
162
+ await self._gateway.send_event(event)
163
+ logger.info("Sent node.disconnecting event")
164
+ except Exception:
165
+ logger.warning("Failed to send disconnecting event", exc_info=True)
166
+
167
+ async def on_frame(self, frame: TelemetryFrame) -> None:
168
+ """Process a decoded telemetry frame through the filter pipeline.
169
+
170
+ For each datum in the frame, check the dual-gate filter. If it
171
+ passes, transform to an OpenClaw event and send to the gateway.
172
+ Failed sends are logged and discarded — never crash the server.
173
+ If the gateway is disconnected, a reconnection attempt is made
174
+ (with exponential backoff) before dropping events.
175
+ """
176
+ now = time.monotonic()
177
+
178
+ # Send node.connected lifecycle event on the very first frame.
179
+ if not self._first_frame_received:
180
+ self._first_frame_received = True
181
+ if not self._dry_run and self._gateway.is_connected:
182
+ lifecycle_event = self._build_lifecycle_event("node.connected")
183
+ try:
184
+ await self._gateway.send_event(lifecycle_event)
185
+ logger.info("Sent node.connected event")
186
+ except Exception:
187
+ logger.warning("Failed to send connected event", exc_info=True)
188
+
189
+ for datum in frame.data:
190
+ if not self._filter.should_emit(datum.field_name, datum.value, now):
191
+ self._drop_count += 1
192
+ continue
193
+
194
+ event = self._emitter.to_event(
195
+ field_name=datum.field_name,
196
+ value=datum.value,
197
+ vin=frame.vin,
198
+ timestamp=frame.created_at,
199
+ )
200
+
201
+ if event is None:
202
+ self._drop_count += 1
203
+ continue
204
+
205
+ self._filter.record_emit(datum.field_name, datum.value, now)
206
+ self._event_count += 1
207
+ self._last_event_time = now
208
+
209
+ if self._dry_run:
210
+ import json
211
+
212
+ print(json.dumps(event, default=str), flush=True)
213
+ continue
214
+
215
+ if not self._gateway.is_connected:
216
+ await self._maybe_reconnect()
217
+ if not self._gateway.is_connected:
218
+ self._drop_count += 1
219
+ continue
220
+
221
+ try:
222
+ await self._gateway.send_event(event)
223
+ logger.info("Sent %s event for %s", datum.field_name, frame.vin)
224
+ except Exception:
225
+ logger.warning(
226
+ "Failed to send event for %s — discarding",
227
+ datum.field_name,
228
+ exc_info=True,
229
+ )
230
+
231
+ # Update telemetry store and evaluate triggers for ALL datums
232
+ # (not just filtered ones) so read handlers always see the latest
233
+ # values and triggers fire on every change.
234
+ if self._telemetry_store is not None or self._trigger_manager is not None:
235
+ for datum in frame.data:
236
+ prev_snap = (
237
+ self._telemetry_store.get(datum.field_name)
238
+ if self._telemetry_store is not None
239
+ else None
240
+ )
241
+ prev_value = prev_snap.value if prev_snap is not None else None
242
+
243
+ if self._telemetry_store is not None:
244
+ self._telemetry_store.update(datum.field_name, datum.value, frame.created_at)
245
+
246
+ if self._trigger_manager is not None:
247
+ await self._trigger_manager.evaluate(
248
+ datum.field_name, datum.value, prev_value, frame.created_at
249
+ )
250
+
251
+
252
+ # -- Pipeline factory -------------------------------------------------------
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class OpenClawPipeline:
257
+ """Holds all components of an assembled OpenClaw pipeline."""
258
+
259
+ gateway: GatewayClient
260
+ bridge: TelemetryBridge
261
+ telemetry_store: TelemetryStore
262
+ dispatcher: Any # CommandDispatcher — avoids circular import
263
+
264
+
265
+ def build_openclaw_pipeline(
266
+ config: BridgeConfig,
267
+ vin: str,
268
+ app_ctx: AppContext,
269
+ *,
270
+ trigger_manager: TriggerManager | None = None,
271
+ dry_run: bool = False,
272
+ ) -> OpenClawPipeline:
273
+ """Construct the full OpenClaw pipeline from a :class:`BridgeConfig`.
274
+
275
+ Returns an :class:`OpenClawPipeline` containing the gateway client,
276
+ telemetry bridge, telemetry store, and command dispatcher — ready
277
+ to be connected and wired into the telemetry fanout.
278
+
279
+ After calling this, the caller should:
280
+
281
+ 1. ``await pipeline.gateway.connect_with_backoff(...)`` (unless dry-run)
282
+ 2. ``fanout.add_sink(pipeline.bridge.on_frame)``
283
+ 3. Optionally register the trigger push callback via
284
+ ``pipeline.bridge.make_trigger_push_callback()``
285
+ """
286
+ from tescmd.openclaw.dispatcher import CommandDispatcher
287
+ from tescmd.openclaw.emitter import EventEmitter
288
+ from tescmd.openclaw.filters import DualGateFilter
289
+ from tescmd.openclaw.gateway import GatewayClient
290
+ from tescmd.openclaw.telemetry_store import TelemetryStore
291
+
292
+ telemetry_store = TelemetryStore()
293
+ dispatcher = CommandDispatcher(
294
+ vin=vin,
295
+ app_ctx=app_ctx,
296
+ telemetry_store=telemetry_store,
297
+ trigger_manager=trigger_manager,
298
+ )
299
+ filt = DualGateFilter(config.telemetry)
300
+ emitter = EventEmitter(client_id=config.client_id)
301
+
302
+ from tescmd import __version__
303
+
304
+ gateway = GatewayClient(
305
+ config.gateway_url,
306
+ token=config.gateway_token,
307
+ client_id=config.client_id,
308
+ client_version=config.client_version,
309
+ display_name=f"tescmd-{__version__}-{vin}",
310
+ model_identifier=vin,
311
+ capabilities=config.capabilities,
312
+ on_request=dispatcher.dispatch,
313
+ )
314
+ bridge = TelemetryBridge(
315
+ gateway,
316
+ filt,
317
+ emitter,
318
+ dry_run=dry_run,
319
+ telemetry_store=telemetry_store,
320
+ vin=vin,
321
+ client_id=config.client_id,
322
+ trigger_manager=trigger_manager,
323
+ )
324
+
325
+ return OpenClawPipeline(
326
+ gateway=gateway,
327
+ bridge=bridge,
328
+ telemetry_store=telemetry_store,
329
+ dispatcher=dispatcher,
330
+ )
@@ -0,0 +1,167 @@
1
+ """Bridge configuration for OpenClaw Gateway integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class NodeCapabilities(BaseModel):
13
+ """Advertised capabilities for the OpenClaw node role.
14
+
15
+ Maps to the gateway connect schema fields:
16
+ - ``caps``: broad capability categories (e.g. ``"location"``, ``"climate"``)
17
+ - ``commands``: specific method names the node can handle
18
+ - ``permissions``: per-command permission booleans
19
+
20
+ The ``reads`` and ``writes`` helpers provide a logical grouping that
21
+ gets flattened into the gateway-native fields via :meth:`to_connect_params`.
22
+ """
23
+
24
+ reads: list[str] = [
25
+ "location.get",
26
+ "battery.get",
27
+ "temperature.get",
28
+ "speed.get",
29
+ "charge_state.get",
30
+ "security.get",
31
+ # Trigger reads
32
+ "trigger.list",
33
+ "trigger.poll",
34
+ ]
35
+ writes: list[str] = [
36
+ "door.lock",
37
+ "door.unlock",
38
+ "climate.on",
39
+ "climate.off",
40
+ "climate.set_temp",
41
+ "charge.start",
42
+ "charge.stop",
43
+ "charge.set_limit",
44
+ "trunk.open",
45
+ "frunk.open",
46
+ "flash_lights",
47
+ "honk_horn",
48
+ "sentry.on",
49
+ "sentry.off",
50
+ # Trigger writes
51
+ "trigger.create",
52
+ "trigger.delete",
53
+ # Convenience trigger aliases
54
+ "cabin_temp.trigger",
55
+ "outside_temp.trigger",
56
+ "battery.trigger",
57
+ "location.trigger",
58
+ # Meta-dispatch
59
+ "system.run",
60
+ ]
61
+
62
+ @property
63
+ def all_commands(self) -> list[str]:
64
+ """All command method names (reads + writes), deduplicated."""
65
+ return list(dict.fromkeys(self.reads + self.writes))
66
+
67
+ @property
68
+ def caps(self) -> list[str]:
69
+ """Unique capability categories derived from command prefixes."""
70
+ seen: dict[str, None] = {}
71
+ for cmd in self.all_commands:
72
+ category = cmd.split(".")[0] if "." in cmd else cmd
73
+ seen.setdefault(category, None)
74
+ return list(seen)
75
+
76
+ @property
77
+ def permissions(self) -> dict[str, bool]:
78
+ """Per-command permissions (all ``True`` for advertised commands)."""
79
+ return {cmd: True for cmd in self.all_commands}
80
+
81
+ def to_connect_params(self) -> dict[str, Any]:
82
+ """Return the gateway-native connect param fields."""
83
+ return {
84
+ "caps": self.caps,
85
+ "commands": self.all_commands,
86
+ "permissions": self.permissions,
87
+ }
88
+
89
+
90
+ class FieldFilter(BaseModel):
91
+ """Per-field filter configuration for the dual-gate filter."""
92
+
93
+ enabled: bool = True
94
+ granularity: float = Field(default=0.0, ge=0)
95
+ """Delta threshold — units depend on field type (meters, percent, degrees, etc.).
96
+
97
+ A value of ``0`` means any change triggers emission.
98
+ """
99
+ throttle_seconds: float = Field(default=1.0, ge=0)
100
+ """Minimum seconds between emissions for this field."""
101
+
102
+
103
+ # Default filter configurations per PRD
104
+ _DEFAULT_FILTERS: dict[str, FieldFilter] = {
105
+ "Location": FieldFilter(granularity=50.0, throttle_seconds=1.0),
106
+ "Soc": FieldFilter(granularity=5.0, throttle_seconds=10.0),
107
+ "InsideTemp": FieldFilter(granularity=5.0, throttle_seconds=30.0),
108
+ "OutsideTemp": FieldFilter(granularity=5.0, throttle_seconds=30.0),
109
+ "VehicleSpeed": FieldFilter(granularity=5.0, throttle_seconds=2.0),
110
+ "ChargeState": FieldFilter(granularity=0.0, throttle_seconds=0.0),
111
+ "DetailedChargeState": FieldFilter(granularity=0.0, throttle_seconds=0.0),
112
+ "Locked": FieldFilter(granularity=0.0, throttle_seconds=0.0),
113
+ "SentryMode": FieldFilter(granularity=0.0, throttle_seconds=0.0),
114
+ "BatteryLevel": FieldFilter(granularity=1.0, throttle_seconds=10.0),
115
+ "EstBatteryRange": FieldFilter(granularity=5.0, throttle_seconds=30.0),
116
+ "Odometer": FieldFilter(granularity=1.0, throttle_seconds=60.0),
117
+ "Gear": FieldFilter(granularity=0.0, throttle_seconds=0.0),
118
+ }
119
+
120
+
121
+ class BridgeConfig(BaseModel):
122
+ """Configuration for the OpenClaw telemetry bridge.
123
+
124
+ Loaded from ``~/.config/tescmd/bridge.json``, CLI flags, or environment
125
+ variables.
126
+ """
127
+
128
+ gateway_url: str = Field(
129
+ default_factory=lambda: __import__("os").environ.get(
130
+ "OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789"
131
+ )
132
+ )
133
+ gateway_token: str | None = Field(default=None)
134
+ client_id: str = "node-host"
135
+ client_version: str | None = None
136
+ telemetry: dict[str, FieldFilter] = Field(default_factory=lambda: dict(_DEFAULT_FILTERS))
137
+ capabilities: NodeCapabilities = Field(default_factory=NodeCapabilities)
138
+
139
+ @classmethod
140
+ def load(cls, path: Path | str | None = None) -> BridgeConfig:
141
+ """Load configuration from a JSON file.
142
+
143
+ Falls back to defaults if the file does not exist.
144
+ """
145
+ resolved = (
146
+ Path("~/.config/tescmd/bridge.json").expanduser() if path is None else Path(path)
147
+ )
148
+
149
+ if not resolved.exists():
150
+ return cls()
151
+
152
+ raw = json.loads(resolved.read_text(encoding="utf-8"))
153
+ return cls.model_validate(raw)
154
+
155
+ def merge_overrides(
156
+ self,
157
+ *,
158
+ gateway_url: str | None = None,
159
+ gateway_token: str | None = None,
160
+ ) -> BridgeConfig:
161
+ """Return a new config with CLI flag overrides applied."""
162
+ data: dict[str, Any] = self.model_dump()
163
+ if gateway_url is not None:
164
+ data["gateway_url"] = gateway_url
165
+ if gateway_token is not None:
166
+ data["gateway_token"] = gateway_token
167
+ return BridgeConfig.model_validate(data)