ccproxy-api 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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,553 @@
1
+ """Authentication and credential management commands."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import UTC, datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from structlog import get_logger
14
+
15
+ from ccproxy.auth.models import ValidationResult
16
+ from ccproxy.cli.helpers import get_rich_toolkit
17
+ from ccproxy.config.settings import get_settings
18
+ from ccproxy.core.async_utils import get_claude_docker_home_dir
19
+ from ccproxy.services.credentials import CredentialsManager
20
+
21
+
22
+ app = typer.Typer(name="auth", help="Authentication and credential management")
23
+
24
+ console = Console()
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ def get_credentials_manager(
29
+ custom_paths: list[Path] | None = None,
30
+ ) -> CredentialsManager:
31
+ """Get a CredentialsManager instance with custom paths if provided."""
32
+ if custom_paths:
33
+ # Get base settings and update storage paths
34
+ settings = get_settings()
35
+ settings.auth.storage.storage_paths = custom_paths
36
+ return CredentialsManager(config=settings.auth)
37
+ else:
38
+ # Use default settings
39
+ settings = get_settings()
40
+ return CredentialsManager(config=settings.auth)
41
+
42
+
43
+ def get_docker_credential_paths() -> list[Path]:
44
+ """Get credential file paths for Docker environment."""
45
+ docker_home = Path(get_claude_docker_home_dir())
46
+ return [
47
+ docker_home / ".claude" / ".credentials.json",
48
+ docker_home / ".config" / "claude" / ".credentials.json",
49
+ Path(".credentials.json"),
50
+ ]
51
+
52
+
53
+ @app.command(name="validate")
54
+ def validate_credentials(
55
+ docker: bool = typer.Option(
56
+ False,
57
+ "--docker",
58
+ help="Use Docker credential paths (from get_claude_docker_home_dir())",
59
+ ),
60
+ credential_file: str | None = typer.Option(
61
+ None,
62
+ "--credential-file",
63
+ help="Path to specific credential file to validate",
64
+ ),
65
+ ) -> None:
66
+ """Validate Claude CLI credentials.
67
+
68
+ Checks for valid Claude credentials in standard locations:
69
+ - ~/.claude/credentials.json
70
+ - ~/.config/claude/credentials.json
71
+
72
+ With --docker flag, checks Docker credential paths:
73
+ - {docker_home}/.claude/credentials.json
74
+ - {docker_home}/.config/claude/credentials.json
75
+
76
+ With --credential-file, validates the specified file directly.
77
+
78
+ Examples:
79
+ ccproxy auth validate
80
+ ccproxy auth validate --docker
81
+ ccproxy auth validate --credential-file /path/to/credentials.json
82
+ """
83
+ toolkit = get_rich_toolkit()
84
+ toolkit.print("[bold cyan]Claude Credentials Validation[/bold cyan]", centered=True)
85
+ toolkit.print_line()
86
+
87
+ try:
88
+ # Get credential paths based on options
89
+ custom_paths = None
90
+ if credential_file:
91
+ custom_paths = [Path(credential_file)]
92
+ elif docker:
93
+ custom_paths = get_docker_credential_paths()
94
+
95
+ # Validate credentials
96
+ manager = get_credentials_manager(custom_paths)
97
+ validation_result = asyncio.run(manager.validate())
98
+
99
+ if validation_result.valid:
100
+ # Create a status table
101
+ table = Table(
102
+ show_header=True,
103
+ header_style="bold cyan",
104
+ box=box.ROUNDED,
105
+ title="Credential Status",
106
+ title_style="bold white",
107
+ )
108
+ table.add_column("Property", style="cyan")
109
+ table.add_column("Value", style="white")
110
+
111
+ # Status
112
+ status = "Valid" if not validation_result.expired else "Expired"
113
+ status_style = "green" if not validation_result.expired else "red"
114
+ table.add_row("Status", f"[{status_style}]{status}[/{status_style}]")
115
+
116
+ # Path
117
+ if validation_result.path:
118
+ table.add_row("Location", f"[dim]{validation_result.path}[/dim]")
119
+
120
+ # Subscription type
121
+ if validation_result.credentials:
122
+ sub_type = (
123
+ validation_result.credentials.claude_ai_oauth.subscription_type
124
+ or "Unknown"
125
+ )
126
+ table.add_row("Subscription", f"[bold]{sub_type}[/bold]")
127
+
128
+ # Expiration
129
+ oauth_token = validation_result.credentials.claude_ai_oauth
130
+ exp_dt = oauth_token.expires_at_datetime
131
+ now = datetime.now(UTC)
132
+ time_diff = exp_dt - now
133
+
134
+ if time_diff.total_seconds() > 0:
135
+ days = time_diff.days
136
+ hours = time_diff.seconds // 3600
137
+ exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({days}d {hours}h remaining)"
138
+ else:
139
+ exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} [red](Expired)[/red]"
140
+
141
+ table.add_row("Expires", exp_str)
142
+
143
+ # Scopes
144
+ scopes = oauth_token.scopes
145
+ if scopes:
146
+ table.add_row("Scopes", ", ".join(str(s) for s in scopes))
147
+
148
+ console.print(table)
149
+
150
+ # Success message
151
+ if not validation_result.expired:
152
+ toolkit.print(
153
+ "[green]✓[/green] Valid Claude credentials found", tag="success"
154
+ )
155
+ else:
156
+ toolkit.print(
157
+ "[yellow]![/yellow] Claude credentials found but expired",
158
+ tag="warning",
159
+ )
160
+ toolkit.print(
161
+ "\nPlease refresh your credentials by logging into Claude CLI",
162
+ tag="info",
163
+ )
164
+
165
+ else:
166
+ # No valid credentials
167
+ toolkit.print("[red]✗[/red] No credentials file found", tag="error")
168
+
169
+ console.print("\n[dim]To authenticate with Claude CLI, run:[/dim]")
170
+ console.print("[cyan]claude login[/cyan]")
171
+
172
+ except Exception as e:
173
+ toolkit.print(f"Error validating credentials: {e}", tag="error")
174
+ raise typer.Exit(1) from e
175
+
176
+
177
+ @app.command(name="info")
178
+ def credential_info(
179
+ docker: bool = typer.Option(
180
+ False,
181
+ "--docker",
182
+ help="Use Docker credential paths (from get_claude_docker_home_dir())",
183
+ ),
184
+ credential_file: str | None = typer.Option(
185
+ None,
186
+ "--credential-file",
187
+ help="Path to specific credential file to display info for",
188
+ ),
189
+ ) -> None:
190
+ """Display detailed credential information.
191
+
192
+ Shows all available information about Claude credentials including
193
+ file location, token details, and subscription information.
194
+
195
+ Examples:
196
+ ccproxy auth info
197
+ ccproxy auth info --docker
198
+ ccproxy auth info --credential-file /path/to/credentials.json
199
+ """
200
+ toolkit = get_rich_toolkit()
201
+ toolkit.print("[bold cyan]Claude Credential Information[/bold cyan]", centered=True)
202
+ toolkit.print_line()
203
+
204
+ try:
205
+ # Get credential paths based on options
206
+ custom_paths = None
207
+ if credential_file:
208
+ custom_paths = [Path(credential_file)]
209
+ elif docker:
210
+ custom_paths = get_docker_credential_paths()
211
+
212
+ # Get credentials manager and try to load credentials
213
+ manager = get_credentials_manager(custom_paths)
214
+ credentials = asyncio.run(manager.load())
215
+
216
+ if not credentials:
217
+ toolkit.print("No credential file found", tag="error")
218
+ console.print("\n[dim]Expected locations:[/dim]")
219
+ for path in manager.config.storage.storage_paths:
220
+ console.print(f" - {path}")
221
+ raise typer.Exit(1)
222
+
223
+ # Display account section
224
+ console.print("\n[bold]Account[/bold]")
225
+ oauth = credentials.claude_ai_oauth
226
+
227
+ # Login method based on subscription type
228
+ login_method = "Claude Account"
229
+ if oauth.subscription_type:
230
+ login_method = f"Claude {oauth.subscription_type.title()} Account"
231
+ console.print(f" L Login Method: {login_method}")
232
+
233
+ # Try to load saved account profile first
234
+ profile = asyncio.run(manager.get_account_profile())
235
+
236
+ if profile:
237
+ # Display saved account data
238
+ if profile.organization:
239
+ console.print(f" L Organization: {profile.organization.name}")
240
+ if profile.organization.organization_type:
241
+ console.print(
242
+ f" L Organization Type: {profile.organization.organization_type}"
243
+ )
244
+ if profile.organization.billing_type:
245
+ console.print(
246
+ f" L Billing Type: {profile.organization.billing_type}"
247
+ )
248
+ if profile.organization.rate_limit_tier:
249
+ console.print(
250
+ f" L Rate Limit Tier: {profile.organization.rate_limit_tier}"
251
+ )
252
+ else:
253
+ console.print(" L Organization: [dim]Not available[/dim]")
254
+
255
+ if profile.account:
256
+ console.print(f" L Email: {profile.account.email}")
257
+ if profile.account.full_name:
258
+ console.print(f" L Full Name: {profile.account.full_name}")
259
+ if profile.account.display_name:
260
+ console.print(f" L Display Name: {profile.account.display_name}")
261
+ console.print(
262
+ f" L Has Claude Pro: {'Yes' if profile.account.has_claude_pro else 'No'}"
263
+ )
264
+ console.print(
265
+ f" L Has Claude Max: {'Yes' if profile.account.has_claude_max else 'No'}"
266
+ )
267
+ else:
268
+ console.print(" L Email: [dim]Not available[/dim]")
269
+ else:
270
+ # No saved profile, try to fetch fresh data
271
+ try:
272
+ # First try to get a valid access token (with refresh if needed)
273
+ valid_token = asyncio.run(manager.get_access_token())
274
+ if valid_token:
275
+ profile = asyncio.run(manager.fetch_user_profile())
276
+ if profile:
277
+ # Save the profile for future use
278
+ asyncio.run(manager._save_account_profile(profile))
279
+
280
+ if profile.organization:
281
+ console.print(
282
+ f" L Organization: {profile.organization.name}"
283
+ )
284
+ else:
285
+ console.print(
286
+ " L Organization: [dim]Unable to fetch[/dim]"
287
+ )
288
+
289
+ if profile.account:
290
+ console.print(f" L Email: {profile.account.email}")
291
+ else:
292
+ console.print(" L Email: [dim]Unable to fetch[/dim]")
293
+ else:
294
+ console.print(" L Organization: [dim]Unable to fetch[/dim]")
295
+ console.print(" L Email: [dim]Unable to fetch[/dim]")
296
+
297
+ # Reload credentials after potential refresh to show updated token info
298
+ credentials = asyncio.run(manager.load())
299
+ if credentials:
300
+ oauth = credentials.claude_ai_oauth
301
+ else:
302
+ console.print(" L Organization: [dim]Token refresh failed[/dim]")
303
+ console.print(" L Email: [dim]Token refresh failed[/dim]")
304
+ except Exception as e:
305
+ logger.debug(f"Could not fetch user profile: {e}")
306
+ console.print(" L Organization: [dim]Unable to fetch[/dim]")
307
+ console.print(" L Email: [dim]Unable to fetch[/dim]")
308
+
309
+ # Create details table
310
+ console.print()
311
+ table = Table(
312
+ show_header=True,
313
+ header_style="bold cyan",
314
+ box=box.ROUNDED,
315
+ title="Credential Details",
316
+ title_style="bold white",
317
+ )
318
+ table.add_column("Property", style="cyan")
319
+ table.add_column("Value", style="white")
320
+
321
+ # File location - check if there's a credentials file or if using keyring
322
+ cred_file = asyncio.run(manager.find_credentials_file())
323
+ if cred_file:
324
+ table.add_row("File Location", str(cred_file))
325
+ else:
326
+ table.add_row("File Location", "Keyring storage")
327
+
328
+ # Token info
329
+ table.add_row("Subscription Type", oauth.subscription_type or "Unknown")
330
+ table.add_row(
331
+ "Token Expired",
332
+ "[red]Yes[/red]" if oauth.is_expired else "[green]No[/green]",
333
+ )
334
+
335
+ # Expiration details
336
+ exp_dt = oauth.expires_at_datetime
337
+ table.add_row("Expires At", exp_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
338
+
339
+ # Time until expiration
340
+ now = datetime.now(UTC)
341
+ time_diff = exp_dt - now
342
+ if time_diff.total_seconds() > 0:
343
+ days = time_diff.days
344
+ hours = (time_diff.seconds % 86400) // 3600
345
+ minutes = (time_diff.seconds % 3600) // 60
346
+ table.add_row(
347
+ "Time Remaining", f"{days} days, {hours} hours, {minutes} minutes"
348
+ )
349
+ else:
350
+ table.add_row("Time Remaining", "[red]Expired[/red]")
351
+
352
+ # Scopes
353
+ if oauth.scopes:
354
+ table.add_row("OAuth Scopes", ", ".join(oauth.scopes))
355
+
356
+ # Token preview (first and last 8 chars)
357
+ if oauth.access_token:
358
+ token_preview = f"{oauth.access_token[:8]}...{oauth.access_token[-8:]}"
359
+ table.add_row("Access Token", f"[dim]{token_preview}[/dim]")
360
+
361
+ # Account profile status
362
+ account_profile_exists = profile is not None
363
+ table.add_row(
364
+ "Account Profile",
365
+ "[green]Available[/green]"
366
+ if account_profile_exists
367
+ else "[yellow]Not saved[/yellow]",
368
+ )
369
+
370
+ console.print(table)
371
+
372
+ except Exception as e:
373
+ toolkit.print(f"Error getting credential info: {e}", tag="error")
374
+ raise typer.Exit(1) from e
375
+
376
+
377
+ @app.command(name="login")
378
+ def login_command(
379
+ docker: bool = typer.Option(
380
+ False,
381
+ "--docker",
382
+ help="Use Docker credential paths (from get_claude_docker_home_dir())",
383
+ ),
384
+ credential_file: str | None = typer.Option(
385
+ None,
386
+ "--credential-file",
387
+ help="Path to specific credential file to save to",
388
+ ),
389
+ ) -> None:
390
+ """Login to Claude using OAuth authentication.
391
+
392
+ This command will open your web browser to authenticate with Claude
393
+ and save the credentials locally.
394
+
395
+ Examples:
396
+ ccproxy auth login
397
+ ccproxy auth login --docker
398
+ ccproxy auth login --credential-file /path/to/credentials.json
399
+ """
400
+ toolkit = get_rich_toolkit()
401
+ toolkit.print("[bold cyan]Claude OAuth Login[/bold cyan]", centered=True)
402
+ toolkit.print_line()
403
+
404
+ try:
405
+ # Get credential paths based on options
406
+ custom_paths = None
407
+ if credential_file:
408
+ custom_paths = [Path(credential_file)]
409
+ elif docker:
410
+ custom_paths = get_docker_credential_paths()
411
+
412
+ # Check if already logged in
413
+ manager = get_credentials_manager(custom_paths)
414
+ validation_result = asyncio.run(manager.validate())
415
+ if validation_result.valid and not validation_result.expired:
416
+ console.print(
417
+ "[yellow]You are already logged in with valid credentials.[/yellow]"
418
+ )
419
+ console.print(
420
+ "Use [cyan]ccproxy auth info[/cyan] to view current credentials."
421
+ )
422
+
423
+ overwrite = typer.confirm(
424
+ "Do you want to login again and overwrite existing credentials?"
425
+ )
426
+ if not overwrite:
427
+ console.print("Login cancelled.")
428
+ return
429
+
430
+ # Perform OAuth login
431
+ console.print("Starting OAuth login process...")
432
+ console.print("Your browser will open for authentication.")
433
+ console.print(
434
+ "A temporary server will start on port 54545 for the OAuth callback..."
435
+ )
436
+
437
+ try:
438
+ asyncio.run(manager.login())
439
+ success = True
440
+ except Exception as e:
441
+ logger.error(f"Login failed: {e}")
442
+ success = False
443
+
444
+ if success:
445
+ toolkit.print("Successfully logged in to Claude!", tag="success")
446
+
447
+ # Show credential info
448
+ console.print("\n[dim]Credential information:[/dim]")
449
+ updated_validation = asyncio.run(manager.validate())
450
+ if updated_validation.valid and updated_validation.credentials:
451
+ oauth_token = updated_validation.credentials.claude_ai_oauth
452
+ console.print(
453
+ f" Subscription: {oauth_token.subscription_type or 'Unknown'}"
454
+ )
455
+ if oauth_token.scopes:
456
+ console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
457
+ exp_dt = oauth_token.expires_at_datetime
458
+ console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
459
+ else:
460
+ toolkit.print("Login failed. Please try again.", tag="error")
461
+ raise typer.Exit(1)
462
+
463
+ except KeyboardInterrupt:
464
+ console.print("\n[yellow]Login cancelled by user.[/yellow]")
465
+ raise typer.Exit(1) from None
466
+ except Exception as e:
467
+ toolkit.print(f"Error during login: {e}", tag="error")
468
+ raise typer.Exit(1) from e
469
+
470
+
471
+ @app.command()
472
+ def renew(
473
+ docker: bool = typer.Option(
474
+ False,
475
+ "--docker",
476
+ "-d",
477
+ help="Renew credentials for Docker environment",
478
+ ),
479
+ credential_file: Path | None = typer.Option(
480
+ None,
481
+ "--credential-file",
482
+ "-f",
483
+ help="Path to custom credential file",
484
+ ),
485
+ ) -> None:
486
+ """Force renew Claude credentials without checking expiration.
487
+
488
+ This command will refresh your access token regardless of whether it's expired.
489
+ Useful for testing or when you want to ensure you have the latest token.
490
+
491
+ Examples:
492
+ ccproxy auth renew
493
+ ccproxy auth renew --docker
494
+ ccproxy auth renew --credential-file /path/to/credentials.json
495
+ """
496
+ toolkit = get_rich_toolkit()
497
+ toolkit.print("[bold cyan]Claude Credentials Renewal[/bold cyan]", centered=True)
498
+ toolkit.print_line()
499
+
500
+ console = Console()
501
+
502
+ try:
503
+ # Get credential paths based on options
504
+ custom_paths = None
505
+ if credential_file:
506
+ custom_paths = [Path(credential_file)]
507
+ elif docker:
508
+ custom_paths = get_docker_credential_paths()
509
+
510
+ # Create credentials manager
511
+ manager = get_credentials_manager(custom_paths)
512
+
513
+ # Check if credentials exist
514
+ validation_result = asyncio.run(manager.validate())
515
+ if not validation_result.valid:
516
+ toolkit.print("[red]✗[/red] No credentials found to renew", tag="error")
517
+ console.print("\n[dim]Please login first:[/dim]")
518
+ console.print("[cyan]ccproxy auth login[/cyan]")
519
+ raise typer.Exit(1)
520
+
521
+ # Force refresh the token
522
+ console.print("[yellow]Refreshing access token...[/yellow]")
523
+ refreshed_credentials = asyncio.run(manager.refresh_token())
524
+
525
+ if refreshed_credentials:
526
+ toolkit.print(
527
+ "[green]✓[/green] Successfully renewed credentials!", tag="success"
528
+ )
529
+
530
+ # Show updated credential info
531
+ oauth_token = refreshed_credentials.claude_ai_oauth
532
+ console.print("\n[dim]Updated credential information:[/dim]")
533
+ console.print(
534
+ f" Subscription: {oauth_token.subscription_type or 'Unknown'}"
535
+ )
536
+ if oauth_token.scopes:
537
+ console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
538
+ exp_dt = oauth_token.expires_at_datetime
539
+ console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
540
+ else:
541
+ toolkit.print("[red]✗[/red] Failed to renew credentials", tag="error")
542
+ raise typer.Exit(1)
543
+
544
+ except KeyboardInterrupt:
545
+ console.print("\n[yellow]Renewal cancelled by user.[/yellow]")
546
+ raise typer.Exit(1) from None
547
+ except Exception as e:
548
+ toolkit.print(f"Error during renewal: {e}", tag="error")
549
+ raise typer.Exit(1) from e
550
+
551
+
552
+ if __name__ == "__main__":
553
+ app()
@@ -0,0 +1,14 @@
1
+ """Config command module for CCProxy API."""
2
+
3
+ from ccproxy.cli.commands.config.commands import app, config_list
4
+ from ccproxy.cli.commands.config.schema_commands import (
5
+ config_schema,
6
+ config_validate,
7
+ )
8
+
9
+
10
+ # Register schema commands with the app
11
+ app.command(name="schema")(config_schema)
12
+ app.command(name="validate")(config_validate)
13
+
14
+ __all__ = ["app", "config_list"]