codeshift 0.2.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 (65) hide show
  1. codeshift/__init__.py +8 -0
  2. codeshift/analyzer/__init__.py +5 -0
  3. codeshift/analyzer/risk_assessor.py +388 -0
  4. codeshift/api/__init__.py +1 -0
  5. codeshift/api/auth.py +182 -0
  6. codeshift/api/config.py +73 -0
  7. codeshift/api/database.py +215 -0
  8. codeshift/api/main.py +103 -0
  9. codeshift/api/models/__init__.py +55 -0
  10. codeshift/api/models/auth.py +108 -0
  11. codeshift/api/models/billing.py +92 -0
  12. codeshift/api/models/migrate.py +42 -0
  13. codeshift/api/models/usage.py +116 -0
  14. codeshift/api/routers/__init__.py +5 -0
  15. codeshift/api/routers/auth.py +440 -0
  16. codeshift/api/routers/billing.py +395 -0
  17. codeshift/api/routers/migrate.py +304 -0
  18. codeshift/api/routers/usage.py +291 -0
  19. codeshift/api/routers/webhooks.py +289 -0
  20. codeshift/cli/__init__.py +5 -0
  21. codeshift/cli/commands/__init__.py +7 -0
  22. codeshift/cli/commands/apply.py +352 -0
  23. codeshift/cli/commands/auth.py +842 -0
  24. codeshift/cli/commands/diff.py +221 -0
  25. codeshift/cli/commands/scan.py +368 -0
  26. codeshift/cli/commands/upgrade.py +436 -0
  27. codeshift/cli/commands/upgrade_all.py +518 -0
  28. codeshift/cli/main.py +221 -0
  29. codeshift/cli/quota.py +210 -0
  30. codeshift/knowledge/__init__.py +50 -0
  31. codeshift/knowledge/cache.py +167 -0
  32. codeshift/knowledge/generator.py +231 -0
  33. codeshift/knowledge/models.py +151 -0
  34. codeshift/knowledge/parser.py +270 -0
  35. codeshift/knowledge/sources.py +388 -0
  36. codeshift/knowledge_base/__init__.py +17 -0
  37. codeshift/knowledge_base/loader.py +102 -0
  38. codeshift/knowledge_base/models.py +110 -0
  39. codeshift/migrator/__init__.py +23 -0
  40. codeshift/migrator/ast_transforms.py +256 -0
  41. codeshift/migrator/engine.py +395 -0
  42. codeshift/migrator/llm_migrator.py +320 -0
  43. codeshift/migrator/transforms/__init__.py +19 -0
  44. codeshift/migrator/transforms/fastapi_transformer.py +174 -0
  45. codeshift/migrator/transforms/pandas_transformer.py +236 -0
  46. codeshift/migrator/transforms/pydantic_v1_to_v2.py +637 -0
  47. codeshift/migrator/transforms/requests_transformer.py +218 -0
  48. codeshift/migrator/transforms/sqlalchemy_transformer.py +175 -0
  49. codeshift/scanner/__init__.py +6 -0
  50. codeshift/scanner/code_scanner.py +352 -0
  51. codeshift/scanner/dependency_parser.py +473 -0
  52. codeshift/utils/__init__.py +5 -0
  53. codeshift/utils/api_client.py +266 -0
  54. codeshift/utils/cache.py +318 -0
  55. codeshift/utils/config.py +71 -0
  56. codeshift/utils/llm_client.py +221 -0
  57. codeshift/validator/__init__.py +6 -0
  58. codeshift/validator/syntax_checker.py +183 -0
  59. codeshift/validator/test_runner.py +224 -0
  60. codeshift-0.2.0.dist-info/METADATA +326 -0
  61. codeshift-0.2.0.dist-info/RECORD +65 -0
  62. codeshift-0.2.0.dist-info/WHEEL +5 -0
  63. codeshift-0.2.0.dist-info/entry_points.txt +2 -0
  64. codeshift-0.2.0.dist-info/licenses/LICENSE +21 -0
  65. codeshift-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,842 @@
1
+ """Authentication commands for Codeshift CLI."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import click
11
+ import httpx
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+ from rich.prompt import Confirm, Prompt
16
+ from rich.table import Table
17
+
18
+ console = Console()
19
+
20
+ # Config directory for storing credentials
21
+ CONFIG_DIR = Path.home() / ".config" / "codeshift"
22
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
23
+
24
+
25
+ def get_api_url() -> str:
26
+ """Get the API URL from environment or default."""
27
+ return os.environ.get("CODESHIFT_API_URL", "https://py-resolve.replit.app")
28
+
29
+
30
+ def load_credentials() -> dict[str, Any] | None:
31
+ """Load saved credentials from disk."""
32
+ if not CREDENTIALS_FILE.exists():
33
+ return None
34
+ try:
35
+ return cast(dict[str, Any], json.loads(CREDENTIALS_FILE.read_text()))
36
+ except (OSError, json.JSONDecodeError):
37
+ return None
38
+
39
+
40
+ def save_credentials(credentials: dict) -> None:
41
+ """Save credentials to disk."""
42
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+
44
+ # Set restrictive permissions
45
+ CREDENTIALS_FILE.write_text(json.dumps(credentials, indent=2))
46
+ os.chmod(CREDENTIALS_FILE, 0o600)
47
+
48
+
49
+ def delete_credentials() -> None:
50
+ """Delete saved credentials."""
51
+ if CREDENTIALS_FILE.exists():
52
+ CREDENTIALS_FILE.unlink()
53
+
54
+
55
+ def get_api_key() -> str | None:
56
+ """Get API key from environment or saved credentials."""
57
+ # Check environment first
58
+ api_key = os.environ.get("PYRESOLVE_API_KEY")
59
+ if api_key:
60
+ return api_key
61
+
62
+ # Check saved credentials
63
+ creds = load_credentials()
64
+ if creds:
65
+ return creds.get("api_key")
66
+
67
+ return None
68
+
69
+
70
+ def make_authenticated_request(
71
+ method: str,
72
+ endpoint: str,
73
+ **kwargs: Any,
74
+ ) -> httpx.Response:
75
+ """Make an authenticated request to the API."""
76
+ api_key = get_api_key()
77
+ api_url = get_api_url()
78
+
79
+ headers = kwargs.pop("headers", {})
80
+ if api_key:
81
+ headers["X-API-Key"] = api_key
82
+
83
+ url = f"{api_url}{endpoint}"
84
+
85
+ with httpx.Client(timeout=30) as client:
86
+ response = client.request(method, url, headers=headers, **kwargs)
87
+
88
+ return response
89
+
90
+
91
+ @click.command()
92
+ @click.option("--email", "-e", help="Email address for login")
93
+ @click.option("--password", "-p", help="Password for login", hide_input=True)
94
+ @click.option("--api-key", "-k", help="Use an existing API key")
95
+ @click.option("--device", "-d", is_flag=True, help="Use device code flow (for browsers)")
96
+ def login(
97
+ email: str | None,
98
+ password: str | None,
99
+ api_key: str | None,
100
+ device: bool,
101
+ ) -> None:
102
+ """Login to PyResolve to enable cloud features.
103
+
104
+ \b
105
+ Authentication methods:
106
+ 1. Email/password: codeshift login -e user@example.com -p yourpassword
107
+ 2. API key: codeshift login -k pyr_xxxxx
108
+ 3. Device flow: codeshift login --device
109
+
110
+ Your credentials are stored in ~/.config/codeshift/credentials.json
111
+
112
+ Don't have an account? Run: codeshift register
113
+ """
114
+ # Check if already logged in
115
+ existing = load_credentials()
116
+ if existing:
117
+ if not Confirm.ask("[yellow]You are already logged in. Do you want to re-authenticate?[/]"):
118
+ return
119
+
120
+ # Option 1: Use provided API key
121
+ if api_key:
122
+ _login_with_api_key(api_key)
123
+ return
124
+
125
+ # Option 2: Device code flow
126
+ if device:
127
+ _login_with_device_code()
128
+ return
129
+
130
+ # Option 3: Email/password
131
+ if not email:
132
+ email = Prompt.ask("Email")
133
+
134
+ if not password:
135
+ password = Prompt.ask("Password", password=True)
136
+
137
+ assert email is not None
138
+ assert password is not None
139
+ _login_with_password(email, password)
140
+
141
+
142
+ @click.command()
143
+ @click.option("--email", "-e", help="Email address for registration")
144
+ @click.option("--password", "-p", help="Password (min 8 characters)", hide_input=True)
145
+ @click.option("--name", "-n", help="Your full name (optional)")
146
+ def register(
147
+ email: str | None,
148
+ password: str | None,
149
+ name: str | None,
150
+ ) -> None:
151
+ """Create a new PyResolve account.
152
+
153
+ \b
154
+ Example:
155
+ codeshift register -e user@example.com -p yourpassword
156
+
157
+ Your credentials are stored in ~/.config/codeshift/credentials.json
158
+ """
159
+ # Check if already logged in
160
+ existing = load_credentials()
161
+ if existing:
162
+ if not Confirm.ask(
163
+ "[yellow]You are already logged in. Do you want to create a new account?[/]"
164
+ ):
165
+ return
166
+
167
+ if not email:
168
+ email = Prompt.ask("Email")
169
+
170
+ if not password:
171
+ password = Prompt.ask("Password (min 8 characters)", password=True)
172
+ password_confirm = Prompt.ask("Confirm password", password=True)
173
+ if password != password_confirm:
174
+ console.print("[red]Passwords do not match[/]")
175
+ raise SystemExit(1)
176
+
177
+ if len(password) < 8:
178
+ console.print("[red]Password must be at least 8 characters[/]")
179
+ raise SystemExit(1)
180
+
181
+ if not name:
182
+ name = Prompt.ask("Full name (optional)", default="")
183
+
184
+ assert email is not None
185
+ assert password is not None
186
+ _register_account(email, password, name if name else None)
187
+
188
+
189
+ def _register_account(email: str, password: str, full_name: str | None) -> None:
190
+ """Register a new account."""
191
+ api_url = get_api_url()
192
+
193
+ with Progress(
194
+ SpinnerColumn(),
195
+ TextColumn("[progress.description]{task.description}"),
196
+ console=console,
197
+ ) as progress:
198
+ task = progress.add_task("Creating account...", total=None)
199
+
200
+ try:
201
+ payload = {"email": email, "password": password}
202
+ if full_name:
203
+ payload["full_name"] = full_name
204
+
205
+ response = httpx.post(
206
+ f"{api_url}/auth/register",
207
+ json=payload,
208
+ timeout=30,
209
+ )
210
+
211
+ if response.status_code == 200:
212
+ data = response.json()
213
+
214
+ # Save credentials
215
+ save_credentials(
216
+ {
217
+ "api_key": data["api_key"],
218
+ "user_id": data["user"]["id"],
219
+ "email": data["user"]["email"],
220
+ "tier": data["user"].get("tier", "free"),
221
+ }
222
+ )
223
+
224
+ progress.update(task, completed=True)
225
+
226
+ console.print(
227
+ Panel(
228
+ f"[green]Account created successfully![/]\n\n"
229
+ f"Email: [cyan]{data['user']['email']}[/]\n"
230
+ f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]\n\n"
231
+ f"[dim]You are now logged in and ready to use PyResolve.[/]",
232
+ title="Registration Successful",
233
+ )
234
+ )
235
+ elif response.status_code == 409:
236
+ console.print(
237
+ "[red]An account with this email already exists.[/]\n"
238
+ "Run [cyan]codeshift login[/] to sign in."
239
+ )
240
+ raise SystemExit(1)
241
+ elif response.status_code == 422:
242
+ detail = response.json().get("detail", [])
243
+ if isinstance(detail, list) and detail:
244
+ msg = detail[0].get("msg", "Invalid input")
245
+ else:
246
+ msg = str(detail)
247
+ console.print(f"[red]Validation error: {msg}[/]")
248
+ raise SystemExit(1)
249
+ else:
250
+ console.print(f"[red]Registration failed: {response.text}[/]")
251
+ raise SystemExit(1)
252
+ except httpx.RequestError as e:
253
+ console.print(f"[red]Connection error: {e}[/]")
254
+ raise SystemExit(1) from e
255
+
256
+
257
+ def _login_with_api_key(api_key: str) -> None:
258
+ """Authenticate with an API key."""
259
+ api_url = get_api_url()
260
+
261
+ with Progress(
262
+ SpinnerColumn(),
263
+ TextColumn("[progress.description]{task.description}"),
264
+ console=console,
265
+ ) as progress:
266
+ task = progress.add_task("Verifying API key...", total=None)
267
+
268
+ try:
269
+ response = httpx.get(
270
+ f"{api_url}/auth/me",
271
+ headers={"X-API-Key": api_key},
272
+ timeout=30,
273
+ )
274
+
275
+ if response.status_code == 200:
276
+ user = response.json()
277
+
278
+ # Save credentials
279
+ save_credentials(
280
+ {
281
+ "api_key": api_key,
282
+ "user_id": user.get("id"),
283
+ "email": user.get("email"),
284
+ "tier": user.get("tier", "free"),
285
+ }
286
+ )
287
+
288
+ progress.update(task, completed=True)
289
+
290
+ console.print(
291
+ Panel(
292
+ f"[green]Successfully logged in![/]\n\n"
293
+ f"Email: [cyan]{user.get('email')}[/]\n"
294
+ f"Tier: [cyan]{user.get('tier', 'free')}[/]",
295
+ title="Login Successful",
296
+ )
297
+ )
298
+ elif response.status_code == 401:
299
+ console.print("[red]Invalid API key[/]")
300
+ raise SystemExit(1)
301
+ else:
302
+ console.print(f"[red]Login failed: {response.text}[/]")
303
+ raise SystemExit(1)
304
+ except httpx.RequestError as e:
305
+ console.print(f"[red]Connection error: {e}[/]")
306
+ raise SystemExit(1) from e
307
+
308
+
309
+ def _login_with_password(email: str, password: str) -> None:
310
+ """Authenticate with email and password."""
311
+ api_url = get_api_url()
312
+
313
+ with Progress(
314
+ SpinnerColumn(),
315
+ TextColumn("[progress.description]{task.description}"),
316
+ console=console,
317
+ ) as progress:
318
+ task = progress.add_task("Authenticating...", total=None)
319
+
320
+ try:
321
+ response = httpx.post(
322
+ f"{api_url}/auth/login",
323
+ json={"email": email, "password": password},
324
+ timeout=30,
325
+ )
326
+
327
+ if response.status_code == 200:
328
+ data = response.json()
329
+
330
+ # Save credentials
331
+ save_credentials(
332
+ {
333
+ "api_key": data["api_key"],
334
+ "user_id": data["user"]["id"],
335
+ "email": data["user"]["email"],
336
+ "tier": data["user"].get("tier", "free"),
337
+ }
338
+ )
339
+
340
+ progress.update(task, completed=True)
341
+
342
+ console.print(
343
+ Panel(
344
+ f"[green]Successfully logged in![/]\n\n"
345
+ f"Email: [cyan]{data['user']['email']}[/]\n"
346
+ f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]",
347
+ title="Login Successful",
348
+ )
349
+ )
350
+ elif response.status_code == 401:
351
+ console.print("[red]Invalid email or password[/]")
352
+ raise SystemExit(1)
353
+ else:
354
+ console.print(f"[red]Login failed: {response.text}[/]")
355
+ raise SystemExit(1)
356
+ except httpx.RequestError as e:
357
+ console.print(f"[red]Connection error: {e}[/]")
358
+ raise SystemExit(1) from e
359
+
360
+
361
+ def _login_with_device_code() -> None:
362
+ """Authenticate using device code flow."""
363
+ api_url = get_api_url()
364
+
365
+ try:
366
+ # Request device code
367
+ response = httpx.post(
368
+ f"{api_url}/auth/device/code",
369
+ json={"client_id": "codeshift-cli"},
370
+ timeout=30,
371
+ )
372
+
373
+ if response.status_code != 200:
374
+ console.print(f"[red]Failed to initiate device flow: {response.text}[/]")
375
+ raise SystemExit(1)
376
+
377
+ data = response.json()
378
+ device_code = data["device_code"]
379
+ user_code = data["user_code"]
380
+ verification_uri = data["verification_uri"]
381
+ expires_in = data.get("expires_in", 900)
382
+ interval = data.get("interval", 5)
383
+
384
+ # Show code to user
385
+ console.print(
386
+ Panel(
387
+ f"[bold]To authenticate, visit:[/]\n\n"
388
+ f" [cyan]{verification_uri}[/]\n\n"
389
+ f"[bold]And enter this code:[/]\n\n"
390
+ f" [green bold]{user_code}[/]\n\n"
391
+ f"[dim]This code expires in {expires_in // 60} minutes.[/]",
392
+ title="Device Authentication",
393
+ )
394
+ )
395
+
396
+ # Try to open browser
397
+ if Confirm.ask("Open browser?", default=True):
398
+ webbrowser.open(verification_uri)
399
+
400
+ # Poll for completion
401
+ with Progress(
402
+ SpinnerColumn(),
403
+ TextColumn("[progress.description]{task.description}"),
404
+ console=console,
405
+ ) as progress:
406
+ task = progress.add_task("Waiting for authentication...", total=None)
407
+
408
+ start_time = time.time()
409
+ while time.time() - start_time < expires_in:
410
+ time.sleep(interval)
411
+
412
+ try:
413
+ response = httpx.post(
414
+ f"{api_url}/auth/device/token",
415
+ json={
416
+ "device_code": device_code,
417
+ "client_id": "codeshift-cli",
418
+ },
419
+ timeout=30,
420
+ )
421
+
422
+ if response.status_code == 200:
423
+ data = response.json()
424
+
425
+ # Save credentials
426
+ save_credentials(
427
+ {
428
+ "api_key": data["api_key"],
429
+ "user_id": data["user"]["id"],
430
+ "email": data["user"]["email"],
431
+ "tier": data["user"].get("tier", "free"),
432
+ }
433
+ )
434
+
435
+ progress.update(task, completed=True)
436
+
437
+ console.print(
438
+ Panel(
439
+ f"[green]Successfully logged in![/]\n\n"
440
+ f"Email: [cyan]{data['user']['email']}[/]\n"
441
+ f"Tier: [cyan]{data['user'].get('tier', 'free')}[/]",
442
+ title="Login Successful",
443
+ )
444
+ )
445
+ return
446
+ elif response.status_code == 428:
447
+ # Authorization pending, continue polling
448
+ continue
449
+ elif response.status_code == 403:
450
+ console.print("[red]Authorization denied[/]")
451
+ raise SystemExit(1)
452
+ else:
453
+ console.print(f"[red]Authentication failed: {response.text}[/]")
454
+ raise SystemExit(1)
455
+ except httpx.RequestError:
456
+ # Network error, retry
457
+ continue
458
+
459
+ console.print("[red]Device code expired. Please try again.[/]")
460
+ raise SystemExit(1)
461
+
462
+ except httpx.RequestError as e:
463
+ console.print(f"[red]Connection error: {e}[/]")
464
+ raise SystemExit(1) from e
465
+
466
+
467
+ @click.command()
468
+ def logout() -> None:
469
+ """Logout from PyResolve and remove saved credentials."""
470
+ creds = load_credentials()
471
+
472
+ if not creds:
473
+ console.print("[yellow]Not logged in[/]")
474
+ return
475
+
476
+ # Revoke the API key on the server
477
+ api_key = creds.get("api_key")
478
+ if api_key:
479
+ try:
480
+ api_url = get_api_url()
481
+ httpx.post(
482
+ f"{api_url}/auth/logout",
483
+ headers={"X-API-Key": api_key},
484
+ timeout=30,
485
+ )
486
+ # Ignore errors - just try to revoke
487
+ except httpx.RequestError:
488
+ pass
489
+
490
+ # Delete local credentials
491
+ delete_credentials()
492
+
493
+ console.print("[green]Successfully logged out[/]")
494
+
495
+
496
+ @click.command()
497
+ def whoami() -> None:
498
+ """Show current authentication status and user info."""
499
+ creds = load_credentials()
500
+
501
+ if not creds:
502
+ console.print(
503
+ Panel(
504
+ "[yellow]Not logged in[/]\n\n" "Run [cyan]codeshift login[/] to authenticate.",
505
+ title="Authentication Status",
506
+ )
507
+ )
508
+ return
509
+
510
+ # Try to get fresh user info from API
511
+ api_key = creds.get("api_key")
512
+ if api_key:
513
+ try:
514
+ api_url = get_api_url()
515
+ response = httpx.get(
516
+ f"{api_url}/auth/me",
517
+ headers={"X-API-Key": api_key},
518
+ timeout=30,
519
+ )
520
+
521
+ if response.status_code == 200:
522
+ user = response.json()
523
+
524
+ # Update cached credentials
525
+ creds["email"] = user.get("email")
526
+ creds["tier"] = user.get("tier", "free")
527
+ creds["user_id"] = user.get("id")
528
+ save_credentials(creds)
529
+
530
+ console.print(
531
+ Panel(
532
+ f"[green]Logged in[/]\n\n"
533
+ f"Email: [cyan]{user.get('email')}[/]\n"
534
+ f"Tier: [cyan]{user.get('tier', 'free')}[/]\n"
535
+ f"User ID: [dim]{user.get('id')}[/]",
536
+ title="Authentication Status",
537
+ )
538
+ )
539
+ return
540
+ except httpx.RequestError:
541
+ pass
542
+
543
+ # Fall back to cached info
544
+ console.print(
545
+ Panel(
546
+ f"[green]Logged in[/] [dim](cached)[/]\n\n"
547
+ f"Email: [cyan]{creds.get('email', 'unknown')}[/]\n"
548
+ f"Tier: [cyan]{creds.get('tier', 'free')}[/]",
549
+ title="Authentication Status",
550
+ )
551
+ )
552
+
553
+
554
+ @click.command()
555
+ def quota() -> None:
556
+ """Show current usage quota and limits."""
557
+ api_key = get_api_key()
558
+
559
+ if not api_key:
560
+ console.print(
561
+ Panel(
562
+ "[yellow]Not logged in[/]\n\n"
563
+ "Run [cyan]codeshift login[/] to authenticate and view quota.\n\n"
564
+ "[dim]Free tier limits apply for unauthenticated usage.[/]",
565
+ title="Usage Quota",
566
+ )
567
+ )
568
+ return
569
+
570
+ try:
571
+ api_url = get_api_url()
572
+ response = httpx.get(
573
+ f"{api_url}/usage/quota",
574
+ headers={"X-API-Key": api_key},
575
+ timeout=30,
576
+ )
577
+
578
+ if response.status_code == 200:
579
+ data = response.json()
580
+
581
+ # Build progress bars
582
+ files_bar = _progress_bar(
583
+ data["files_migrated"],
584
+ data["files_limit"],
585
+ data["files_percentage"],
586
+ )
587
+ llm_bar = _progress_bar(
588
+ data["llm_calls"],
589
+ data["llm_calls_limit"],
590
+ data["llm_calls_percentage"],
591
+ )
592
+
593
+ table = Table(show_header=True, header_style="bold")
594
+ table.add_column("Resource")
595
+ table.add_column("Used")
596
+ table.add_column("Limit")
597
+ table.add_column("Progress", width=20)
598
+
599
+ table.add_row(
600
+ "File Migrations",
601
+ str(data["files_migrated"]),
602
+ str(data["files_limit"]),
603
+ files_bar,
604
+ )
605
+ table.add_row(
606
+ "LLM Calls",
607
+ str(data["llm_calls"]),
608
+ str(data["llm_calls_limit"]),
609
+ llm_bar,
610
+ )
611
+
612
+ console.print(
613
+ Panel(
614
+ table,
615
+ title=f"Usage Quota - {data['tier'].title()} Tier ({data['billing_period']})",
616
+ )
617
+ )
618
+
619
+ # Show upgrade prompt if near limit
620
+ if data["files_percentage"] > 80 or data["llm_calls_percentage"] > 80:
621
+ if data["tier"] == "free":
622
+ console.print(
623
+ "\n[yellow]Running low on quota?[/] "
624
+ "Run [cyan]codeshift upgrade-plan[/] to see upgrade options."
625
+ )
626
+ elif response.status_code == 401:
627
+ console.print("[red]Invalid credentials. Please run [cyan]codeshift login[/] again.[/]")
628
+ raise SystemExit(1)
629
+ else:
630
+ console.print(f"[red]Failed to get quota: {response.text}[/]")
631
+ raise SystemExit(1)
632
+
633
+ except httpx.RequestError as e:
634
+ console.print(f"[red]Connection error: {e}[/]")
635
+ # Show offline fallback
636
+ creds = load_credentials()
637
+ if creds:
638
+ console.print("\n[dim]Showing cached information:[/]")
639
+ console.print(f" Tier: [cyan]{creds.get('tier', 'free')}[/]")
640
+
641
+
642
+ def _progress_bar(current: int, total: int, percentage: float) -> str:
643
+ """Generate a text-based progress bar."""
644
+ width = 15
645
+ filled = int(width * percentage / 100)
646
+ empty = width - filled
647
+
648
+ if percentage >= 90:
649
+ color = "red"
650
+ elif percentage >= 70:
651
+ color = "yellow"
652
+ else:
653
+ color = "green"
654
+
655
+ bar = f"[{color}]{'█' * filled}{'░' * empty}[/] {percentage:.0f}%"
656
+ return bar
657
+
658
+
659
+ @click.command("upgrade-plan")
660
+ @click.option("--tier", "-t", type=click.Choice(["pro", "unlimited"]), help="Tier to upgrade to")
661
+ def upgrade_plan(tier: str | None) -> None:
662
+ """Show available plans or upgrade to a paid tier.
663
+
664
+ \b
665
+ Examples:
666
+ codeshift upgrade-plan # Show all plans
667
+ codeshift upgrade-plan --tier pro # Upgrade to Pro tier
668
+ """
669
+ api_key = get_api_key()
670
+ api_url = get_api_url()
671
+
672
+ # If tier specified and logged in, initiate checkout
673
+ if tier and api_key:
674
+ _initiate_upgrade(api_url, api_key, tier)
675
+ return
676
+
677
+ # Otherwise show available tiers
678
+ try:
679
+ response = httpx.get(f"{api_url}/billing/tiers", timeout=30)
680
+
681
+ if response.status_code == 200:
682
+ tiers_data = response.json()
683
+
684
+ console.print("\n[bold]Available Plans[/]\n")
685
+
686
+ for t in tiers_data:
687
+ if t["name"] == "enterprise":
688
+ price = "Custom"
689
+ elif t["price_monthly"] == 0:
690
+ price = "Free"
691
+ else:
692
+ price = f"${t['price_monthly'] / 100:.0f}/mo"
693
+
694
+ console.print(f"[bold cyan]{t['display_name']}[/] - {price}")
695
+ console.print(f" Files: {t['files_per_month']:,}/mo")
696
+ console.print(f" LLM Calls: {t['llm_calls_per_month']:,}/mo")
697
+ for feature in t["features"]:
698
+ console.print(f" • {feature}")
699
+ console.print()
700
+
701
+ if api_key:
702
+ console.print(
703
+ "[green]To upgrade, run:[/]\n"
704
+ " [cyan]codeshift upgrade-plan --tier pro[/]\n"
705
+ " [cyan]codeshift upgrade-plan --tier unlimited[/]"
706
+ )
707
+ else:
708
+ console.print("[yellow]Login first to upgrade:[/]\n" " [cyan]codeshift login[/]")
709
+ else:
710
+ console.print("[red]Failed to load pricing information[/]")
711
+
712
+ except httpx.RequestError as e:
713
+ console.print(f"[red]Connection error: {e}[/]")
714
+
715
+
716
+ def _initiate_upgrade(api_url: str, api_key: str, tier: str) -> None:
717
+ """Create checkout session and open in browser."""
718
+ with Progress(
719
+ SpinnerColumn(),
720
+ TextColumn("[progress.description]{task.description}"),
721
+ console=console,
722
+ ) as progress:
723
+ task = progress.add_task("Creating checkout session...", total=None)
724
+
725
+ try:
726
+ response = httpx.post(
727
+ f"{api_url}/billing/checkout",
728
+ headers={"X-API-Key": api_key},
729
+ json={
730
+ "tier": tier,
731
+ "success_url": "https://codeshift.dev/upgrade/success",
732
+ "cancel_url": "https://codeshift.dev/upgrade/cancel",
733
+ },
734
+ timeout=30,
735
+ )
736
+
737
+ if response.status_code == 200:
738
+ data = response.json()
739
+ checkout_url = data["checkout_url"]
740
+
741
+ progress.update(task, completed=True)
742
+
743
+ console.print(
744
+ Panel(
745
+ f"[green]Opening checkout in your browser...[/]\n\n"
746
+ f"Upgrading to: [cyan]{tier.title()}[/]\n\n"
747
+ f"[dim]If the browser doesn't open, visit:[/]\n"
748
+ f"[link={checkout_url}]{checkout_url[:60]}...[/]",
749
+ title="Checkout",
750
+ )
751
+ )
752
+
753
+ # Open browser
754
+ webbrowser.open(checkout_url)
755
+
756
+ console.print(
757
+ "\n[dim]After completing payment, your account will be "
758
+ "automatically upgraded.[/]\n"
759
+ "[dim]Run [cyan]codeshift whoami[/] to verify your new tier.[/]"
760
+ )
761
+ elif response.status_code == 401:
762
+ console.print("[red]Session expired. Please run [cyan]codeshift login[/] again.[/]")
763
+ raise SystemExit(1)
764
+ elif response.status_code == 500:
765
+ detail = response.json().get("detail", "Unknown error")
766
+ if "not configured" in detail.lower():
767
+ console.print(
768
+ "[yellow]Stripe payments are not yet configured.[/]\n"
769
+ "Please visit [cyan]https://codeshift.dev/pricing[/] to upgrade."
770
+ )
771
+ else:
772
+ console.print(f"[red]Checkout failed: {detail}[/]")
773
+ raise SystemExit(1)
774
+ else:
775
+ console.print(f"[red]Failed to create checkout: {response.text}[/]")
776
+ raise SystemExit(1)
777
+
778
+ except httpx.RequestError as e:
779
+ console.print(f"[red]Connection error: {e}[/]")
780
+ raise SystemExit(1) from e
781
+
782
+
783
+ @click.command("billing")
784
+ def billing() -> None:
785
+ """Open Stripe billing portal to manage your subscription."""
786
+ api_key = get_api_key()
787
+
788
+ if not api_key:
789
+ console.print(
790
+ "[yellow]Not logged in.[/]\n" "Run [cyan]codeshift login[/] to authenticate first."
791
+ )
792
+ raise SystemExit(1)
793
+
794
+ api_url = get_api_url()
795
+
796
+ with Progress(
797
+ SpinnerColumn(),
798
+ TextColumn("[progress.description]{task.description}"),
799
+ console=console,
800
+ ) as progress:
801
+ task = progress.add_task("Opening billing portal...", total=None)
802
+
803
+ try:
804
+ response = httpx.get(
805
+ f"{api_url}/billing/portal",
806
+ headers={"X-API-Key": api_key},
807
+ timeout=30,
808
+ )
809
+
810
+ if response.status_code == 200:
811
+ data = response.json()
812
+ portal_url = data["portal_url"]
813
+
814
+ progress.update(task, completed=True)
815
+
816
+ console.print(
817
+ Panel(
818
+ "[green]Opening billing portal in your browser...[/]\n\n"
819
+ "You can:\n"
820
+ " • Update payment method\n"
821
+ " • View invoices\n"
822
+ " • Change or cancel subscription",
823
+ title="Billing Portal",
824
+ )
825
+ )
826
+
827
+ webbrowser.open(portal_url)
828
+ elif response.status_code == 400:
829
+ console.print(
830
+ "[yellow]No billing account found.[/]\n"
831
+ "Run [cyan]codeshift upgrade-plan --tier pro[/] to subscribe first."
832
+ )
833
+ elif response.status_code == 401:
834
+ console.print("[red]Session expired. Please run [cyan]codeshift login[/] again.[/]")
835
+ raise SystemExit(1)
836
+ else:
837
+ console.print(f"[red]Failed to open billing portal: {response.text}[/]")
838
+ raise SystemExit(1)
839
+
840
+ except httpx.RequestError as e:
841
+ console.print(f"[red]Connection error: {e}[/]")
842
+ raise SystemExit(1) from e