ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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 (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,553 @@
1
+ """CLI command for handling confirmation requests via SSE stream."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import structlog
12
+ import typer
13
+ from structlog import get_logger
14
+
15
+ from ccproxy.api.services.permission_service import PermissionRequest
16
+ from ccproxy.api.ui.permission_handler_protocol import ConfirmationHandlerProtocol
17
+ from ccproxy.api.ui.terminal_permission_handler import (
18
+ TerminalPermissionHandler as TextualPermissionHandler,
19
+ )
20
+ from ccproxy.config.settings import get_settings
21
+
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ app = typer.Typer(
26
+ name="confirmation-handler",
27
+ help="Connect to the API server and handle confirmation requests",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+
32
+ class SSEConfirmationHandler:
33
+ """Handles confirmation requests received via SSE stream."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_url: str,
38
+ terminal_handler: ConfirmationHandlerProtocol,
39
+ ui: bool = True,
40
+ auth_token: str | None = None,
41
+ auto_reconnect: bool = True,
42
+ ):
43
+ self.api_url = api_url.rstrip("/")
44
+ self.terminal_handler = terminal_handler
45
+ self.client: httpx.AsyncClient | None = None
46
+ self.max_retries = 5
47
+ self.base_delay = 1.0
48
+ self.max_delay = 60.0
49
+ self.ui = ui
50
+ self.auth_token = auth_token
51
+ self.auto_reconnect = auto_reconnect
52
+
53
+ self._ongoing_requests: dict[str, asyncio.Task[bool]] = {}
54
+ self._resolved_requests: dict[str, tuple[bool, str]] = {}
55
+ self._resolved_by_us: set[str] = set()
56
+
57
+ async def __aenter__(self) -> "SSEConfirmationHandler":
58
+ """Async context manager entry."""
59
+ headers = {}
60
+ if self.auth_token:
61
+ headers["Authorization"] = f"Bearer {self.auth_token}"
62
+
63
+ self.client = httpx.AsyncClient(timeout=300.0, headers=headers) # 5 minutes
64
+ return self
65
+
66
+ async def __aexit__(
67
+ self,
68
+ exc_type: type[BaseException] | None,
69
+ exc_val: BaseException | None,
70
+ exc_tb: object,
71
+ ) -> None:
72
+ """Async context manager exit."""
73
+ if self.client:
74
+ await self.client.aclose()
75
+ self.client = None
76
+
77
+ async def handle_event(self, event_type: str, data: dict[str, Any]) -> None:
78
+ """Handle an SSE event by dispatching to specific handlers.
79
+
80
+ Args:
81
+ event_type: Type of the event
82
+ data: Event data
83
+ """
84
+ if event_type == "ping":
85
+ return
86
+
87
+ from ccproxy.models.permissions import EventType
88
+
89
+ handler_map = {
90
+ EventType.PERMISSION_REQUEST.value: self._handle_permission_request,
91
+ EventType.PERMISSION_RESOLVED.value: self._handle_permission_resolved,
92
+ }
93
+
94
+ handler = handler_map.get(event_type)
95
+ if handler:
96
+ await handler(data)
97
+ else:
98
+ logger.warning("unhandled_sse_event", event_type=event_type)
99
+
100
+ async def _handle_permission_request(self, data: dict[str, Any]) -> None:
101
+ """Handle a confirmation request event.
102
+
103
+ Args:
104
+ data: Event data containing request details
105
+ """
106
+ request_id = data.get("request_id")
107
+ if not request_id:
108
+ logger.warning("permission_request_missing_id", data=data)
109
+ return
110
+
111
+ # Check if this request was already resolved by another handler
112
+ if request_id in self._resolved_requests:
113
+ allowed, reason = self._resolved_requests[request_id]
114
+ logger.info(
115
+ "permission_already_resolved_by_other_handler",
116
+ request_id=request_id,
117
+ allowed=allowed,
118
+ reason=reason,
119
+ )
120
+ logger.info(
121
+ "permission_already_handled",
122
+ request_id=request_id[:8],
123
+ reason=reason,
124
+ )
125
+ return
126
+
127
+ logger.info(
128
+ "permission_request_received",
129
+ request_id=request_id,
130
+ tool_name=data.get("tool_name"),
131
+ )
132
+
133
+ try:
134
+ # Map request_id to id field for PermissionRequest model
135
+ request_data = dict(data)
136
+ if "request_id" in request_data:
137
+ request_data["id"] = request_data.pop("request_id")
138
+ request = PermissionRequest.model_validate(request_data)
139
+ except Exception as e:
140
+ logger.error(
141
+ "permission_request_validation_failed", data=data, error=str(e)
142
+ )
143
+ return
144
+
145
+ if self.ui and request_id is not None:
146
+ task = asyncio.create_task(
147
+ self._handle_permission_with_cancellation(request)
148
+ )
149
+ self._ongoing_requests[request_id] = task
150
+
151
+ async def _handle_permission_resolved(self, data: dict[str, Any]) -> None:
152
+ """Handle a confirmation resolved event.
153
+
154
+ Args:
155
+ data: Event data containing resolution details
156
+ """
157
+ request_id = data.get("request_id")
158
+ allowed = data.get("allowed", False)
159
+
160
+ if request_id is not None and allowed is not None:
161
+ reason = (
162
+ "approved by another handler"
163
+ if allowed
164
+ else "denied by another handler"
165
+ )
166
+ self._resolved_requests[request_id] = (allowed, reason)
167
+
168
+ was_resolved_by_us = (
169
+ request_id is not None and request_id in self._resolved_by_us
170
+ )
171
+
172
+ if request_id is not None and request_id in self._ongoing_requests:
173
+ task = self._ongoing_requests[request_id]
174
+ if not task.done() and not was_resolved_by_us:
175
+ logger.info(
176
+ "cancelling_ongoing_confirmation",
177
+ request_id=request_id,
178
+ allowed=allowed,
179
+ )
180
+
181
+ status_text = "approved" if allowed else "denied"
182
+ self.terminal_handler.cancel_confirmation(
183
+ request_id, f"{status_text} by another handler"
184
+ )
185
+
186
+ task.cancel()
187
+
188
+ with contextlib.suppress(TimeoutError, asyncio.CancelledError):
189
+ await asyncio.wait_for(task, timeout=0.1)
190
+
191
+ logger.info(
192
+ "permission_cancelled_by_other_handler",
193
+ request_id=request_id[:8],
194
+ status=status_text,
195
+ )
196
+
197
+ if request_id is not None:
198
+ self._ongoing_requests.pop(request_id, None)
199
+
200
+ if request_id is not None:
201
+ self._resolved_by_us.discard(request_id)
202
+
203
+ async def _handle_permission_with_cancellation(
204
+ self, request: PermissionRequest
205
+ ) -> bool:
206
+ """Handle permission with cancellation support.
207
+
208
+ Args:
209
+ request: The permission request to handle
210
+ """
211
+ try:
212
+ allowed = await self.terminal_handler.handle_permission(request)
213
+
214
+ if request.id in self._resolved_requests:
215
+ logger.info(
216
+ "permission_resolved_while_processing",
217
+ request_id=request.id,
218
+ our_result=allowed,
219
+ )
220
+ return False
221
+
222
+ self._resolved_by_us.add(request.id)
223
+
224
+ await self.send_response(request.id, allowed)
225
+
226
+ await asyncio.sleep(0.5)
227
+
228
+ return allowed
229
+
230
+ except asyncio.CancelledError:
231
+ logger.info(
232
+ "permission_cancelled",
233
+ request_id=request.id,
234
+ )
235
+ raise
236
+
237
+ except Exception as e:
238
+ logger.error(
239
+ "permission_handling_error",
240
+ request_id=request.id,
241
+ error=str(e),
242
+ exc_info=True,
243
+ )
244
+ # Only send response if not already resolved
245
+ if request.id not in self._resolved_requests:
246
+ # If response fails, it might already be resolved
247
+ with contextlib.suppress(Exception):
248
+ await self.send_response(request.id, False)
249
+ return False
250
+
251
+ async def send_response(self, request_id: str, allowed: bool) -> None:
252
+ """Send a confirmation response to the API.
253
+
254
+ Args:
255
+ request_id: ID of the confirmation request
256
+ allowed: Whether to allow or deny
257
+ """
258
+ if not self.client:
259
+ logger.error("send_response_no_client", request_id=request_id)
260
+ return
261
+
262
+ try:
263
+ response = await self.client.post(
264
+ f"{self.api_url}/permissions/{request_id}/respond",
265
+ json={"allowed": allowed},
266
+ )
267
+
268
+ if response.status_code == 200:
269
+ logger.info(
270
+ "permission_response_sent",
271
+ request_id=request_id,
272
+ allowed=allowed,
273
+ )
274
+ elif response.status_code == 409:
275
+ # Already resolved by another handler
276
+ logger.info(
277
+ "permission_already_resolved",
278
+ request_id=request_id,
279
+ status_code=response.status_code,
280
+ )
281
+ else:
282
+ logger.error(
283
+ "permission_response_failed",
284
+ request_id=request_id,
285
+ status_code=response.status_code,
286
+ response=response.text,
287
+ )
288
+
289
+ except Exception as e:
290
+ logger.error(
291
+ "permission_response_error",
292
+ request_id=request_id,
293
+ error=str(e),
294
+ exc_info=True,
295
+ )
296
+
297
+ async def parse_sse_stream(
298
+ self, response: httpx.Response
299
+ ) -> AsyncIterator[tuple[str, dict[str, Any]]]:
300
+ """Parse SSE events from the response stream.
301
+
302
+ Args:
303
+ response: The httpx response with streaming content
304
+
305
+ Yields:
306
+ Tuples of (event_type, data)
307
+ """
308
+ buffer = ""
309
+ async for chunk in response.aiter_text():
310
+ buffer += chunk
311
+
312
+ buffer = buffer.replace("\r\n", "\n")
313
+
314
+ while "\n\n" in buffer:
315
+ event_text, buffer = buffer.split("\n\n", 1)
316
+
317
+ if not event_text.strip():
318
+ continue
319
+
320
+ event_type = "message"
321
+ data_lines = []
322
+
323
+ for line in event_text.split("\n"):
324
+ line = line.strip()
325
+ if line.startswith("event:"):
326
+ event_type = line[6:].strip()
327
+ elif line.startswith("data:"):
328
+ data_lines.append(line[5:].strip())
329
+
330
+ if data_lines:
331
+ try:
332
+ data_json = " ".join(data_lines)
333
+ data = json.loads(data_json)
334
+ yield event_type, data
335
+ except json.JSONDecodeError as e:
336
+ logger.error(
337
+ "sse_parse_error",
338
+ event_type=event_type,
339
+ data=" ".join(data_lines),
340
+ error=str(e),
341
+ )
342
+
343
+ async def run(self) -> None:
344
+ """Run the SSE client with reconnection logic."""
345
+ if not self.client:
346
+ logger.error("run_no_client")
347
+ return
348
+
349
+ stream_url = f"{self.api_url}/permissions/stream"
350
+ retry_count = 0
351
+
352
+ logger.info(
353
+ "connecting_to_permission_stream",
354
+ url=stream_url,
355
+ )
356
+ print(f"Connecting to confirmation stream at {stream_url}...")
357
+
358
+ while retry_count <= self.max_retries:
359
+ try:
360
+ await self._connect_and_handle_stream(stream_url)
361
+ # If we get here, connection ended gracefully
362
+ if self.auto_reconnect:
363
+ # Reset retry count and reconnect
364
+ retry_count = 0
365
+ print("Connection closed. Reconnecting...")
366
+ await asyncio.sleep(1.0) # Brief pause before reconnecting
367
+ continue
368
+ else:
369
+ print("Connection closed. Exiting (auto-reconnect disabled).")
370
+ break
371
+
372
+ except KeyboardInterrupt:
373
+ logger.info("permission_handler_shutdown_requested")
374
+ break
375
+
376
+ except (
377
+ httpx.ConnectError,
378
+ httpx.TimeoutException,
379
+ httpx.ReadTimeout,
380
+ ) as e:
381
+ retry_count += 1
382
+ if retry_count > self.max_retries:
383
+ logger.error(
384
+ "connection_failed_max_retries",
385
+ max_retries=self.max_retries,
386
+ )
387
+ raise typer.Exit(1) from None
388
+
389
+ # Exponential backoff with jitter
390
+ delay = min(self.base_delay * (2 ** (retry_count - 1)), self.max_delay)
391
+
392
+ logger.warning(
393
+ "connection_failed_retrying",
394
+ attempt=retry_count,
395
+ max_retries=self.max_retries,
396
+ retry_delay=delay,
397
+ error=str(e),
398
+ )
399
+
400
+ print(
401
+ f"Connection failed (attempt {retry_count}/{self.max_retries}). Retrying in {delay}s..."
402
+ )
403
+
404
+ await asyncio.sleep(delay)
405
+ continue
406
+
407
+ except Exception as e:
408
+ logger.error("sse_client_error", error=str(e), exc_info=True)
409
+ raise typer.Exit(1) from e
410
+
411
+ async def _connect_and_handle_stream(self, stream_url: str) -> None:
412
+ """Connect to the stream and handle events."""
413
+ if not self.client:
414
+ logger.error("connect_no_client")
415
+ return
416
+
417
+ async with self.client.stream("GET", stream_url) as response:
418
+ if response.status_code != 200:
419
+ error_text = ""
420
+ try:
421
+ error_bytes = await response.aread()
422
+ error_text = error_bytes.decode("utf-8")
423
+ except Exception:
424
+ error_text = "Unable to read error response"
425
+
426
+ logger.error(
427
+ "sse_connection_failed",
428
+ status_code=response.status_code,
429
+ response=error_text,
430
+ )
431
+
432
+ if response.status_code in (502, 503, 504):
433
+ # Server errors - retry
434
+ raise httpx.ConnectError(
435
+ f"Server error: HTTP {response.status_code}"
436
+ )
437
+ else:
438
+ # Client errors - don't retry
439
+ logger.error(
440
+ "sse_connection_client_error",
441
+ status_code=response.status_code,
442
+ response=error_text,
443
+ )
444
+ raise typer.Exit(1)
445
+
446
+ logger.info(
447
+ "sse_connection_established",
448
+ url=stream_url,
449
+ message="Connected to confirmation stream. Waiting for requests...",
450
+ )
451
+ print("✓ Connected to confirmation stream. Waiting for requests...")
452
+
453
+ async for event_type, data in self.parse_sse_stream(response):
454
+ try:
455
+ await self.handle_event(event_type, data)
456
+ except Exception as e:
457
+ logger.error(
458
+ "sse_event_error",
459
+ event_type=event_type,
460
+ error=str(e),
461
+ exc_info=True,
462
+ )
463
+
464
+
465
+ @app.command()
466
+ def connect(
467
+ api_url: str | None = typer.Option(
468
+ None,
469
+ "--api-url",
470
+ "-u",
471
+ help="API server URL (defaults to settings)",
472
+ ),
473
+ no_ui: bool = typer.Option(False, "--no-ui", help="Disable UI mode"),
474
+ verbose: int = typer.Option(
475
+ 0,
476
+ "-v",
477
+ "--verbose",
478
+ count=True,
479
+ help="Increase verbosity (-v for INFO, -vv for DEBUG)",
480
+ ),
481
+ auth_token: str | None = typer.Option(
482
+ None,
483
+ "--auth-token",
484
+ "-t",
485
+ help="Bearer token for API authentication (overrides config)",
486
+ envvar="CCPROXY_AUTH_TOKEN",
487
+ ),
488
+ no_reconnect: bool = typer.Option(
489
+ False,
490
+ "--no-reconnect",
491
+ help="Disable automatic reconnection when connection is lost",
492
+ ),
493
+ ) -> None:
494
+ """Connect to the API server and handle confirmation requests.
495
+
496
+ This command connects to the CCProxy API server via Server-Sent Events
497
+ and handles permission confirmation requests in the terminal.
498
+
499
+ """
500
+ # Configure logging level based on verbosity
501
+ # Handle case where verbose might be OptionInfo (in tests) or int (runtime)
502
+ verbose_count = verbose if isinstance(verbose, int) else 0
503
+
504
+ if verbose_count >= 2:
505
+ log_level = logging.DEBUG
506
+ elif verbose_count >= 1:
507
+ log_level = logging.INFO
508
+ else:
509
+ log_level = logging.WARNING
510
+
511
+ # Configure root logger level
512
+ logging.basicConfig(level=log_level)
513
+
514
+ # Configure structlog to respect the same log level
515
+ structlog.configure(
516
+ wrapper_class=structlog.make_filtering_bound_logger(log_level),
517
+ )
518
+
519
+ settings = get_settings()
520
+
521
+ # Use provided URL or default from settings
522
+ if not api_url:
523
+ api_url = f"http://{settings.server.host}:{settings.server.port}"
524
+
525
+ # Determine auth token: CLI arg > config setting > None
526
+ token = auth_token or settings.security.auth_token
527
+
528
+ # Create handlers based on UI mode selection
529
+ terminal_handler: ConfirmationHandlerProtocol = TextualPermissionHandler()
530
+
531
+ async def run_handler() -> None:
532
+ """Run the handler with proper resource management."""
533
+ async with SSEConfirmationHandler(
534
+ api_url,
535
+ terminal_handler,
536
+ not no_ui,
537
+ auth_token=token,
538
+ auto_reconnect=not no_reconnect,
539
+ ) as sse_handler:
540
+ await sse_handler.run()
541
+
542
+ # Run the async handler
543
+ try:
544
+ asyncio.run(run_handler())
545
+ except KeyboardInterrupt:
546
+ logger.info("permission_handler_stopped")
547
+ except Exception as e:
548
+ logger.error("permission_handler_error", error=str(e), exc_info=True)
549
+ raise typer.Exit(1) from e
550
+
551
+
552
+ if __name__ == "__main__":
553
+ app()