alphai 0.1.2__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,615 @@
1
+ """Docker-related commands for alphai CLI.
2
+
3
+ Contains `run` and `cleanup` commands for Docker container management.
4
+ """
5
+
6
+ import sys
7
+ import time
8
+ import subprocess
9
+ import webbrowser
10
+ from typing import Optional, Dict
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.prompt import Prompt, Confirm
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+ from rich.panel import Panel
17
+
18
+ from ..client import AlphAIClient
19
+ from ..config import Config
20
+ from ..docker import DockerManager
21
+ from ..cleanup import DockerCleanupManager
22
+ from ..utils import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+ console = Console()
26
+
27
+
28
+ def _get_frontend_url(api_url: str) -> str:
29
+ """Convert API URL to frontend URL for browser opening."""
30
+ if api_url.startswith("http://localhost") or api_url.startswith("https://localhost"):
31
+ return api_url.replace("/api", "").rstrip("/")
32
+ elif "runalph.ai" in api_url:
33
+ if "/api" in api_url:
34
+ return api_url.replace("runalph.ai/api", "runalph.ai").rstrip("/")
35
+ else:
36
+ return api_url.replace("runalph.ai", "runalph.ai").rstrip("/")
37
+ else:
38
+ return api_url.replace("/api", "").rstrip("/")
39
+
40
+
41
+ def _select_organization(client: AlphAIClient) -> str:
42
+ """Interactively select an organization."""
43
+ import questionary
44
+
45
+ logger.debug("Prompting user to select organization")
46
+ console.print("[yellow]No organization specified. Please select one:[/yellow]")
47
+
48
+ with Progress(
49
+ SpinnerColumn(),
50
+ TextColumn("[progress.description]{task.description}"),
51
+ console=console
52
+ ) as progress:
53
+ task = progress.add_task("Fetching organizations...", total=None)
54
+ orgs_data = client.get_organizations()
55
+ progress.update(task, completed=1)
56
+
57
+ if not orgs_data or len(orgs_data) == 0:
58
+ logger.error("No organizations found for user")
59
+ console.print("[red]No organizations found. Please create one first.[/red]")
60
+ sys.exit(1)
61
+
62
+ org_choices = []
63
+ for org_data in orgs_data:
64
+ display_name = f"{org_data.name} ({org_data.slug})"
65
+ org_choices.append(questionary.Choice(title=display_name, value=org_data.slug))
66
+
67
+ selected_org_slug = questionary.select(
68
+ "Select organization (use ↑↓ arrows and press Enter):",
69
+ choices=org_choices,
70
+ style=questionary.Style([
71
+ ('question', 'bold'),
72
+ ('pointer', 'fg:#673ab7 bold'),
73
+ ('highlighted', 'fg:#673ab7 bold'),
74
+ ('selected', 'fg:#cc5454'),
75
+ ('instruction', 'fg:#888888 italic')
76
+ ])
77
+ ).ask()
78
+
79
+ if not selected_org_slug:
80
+ logger.warning("User cancelled organization selection")
81
+ console.print("[red]No organization selected. Exiting.[/red]")
82
+ sys.exit(1)
83
+
84
+ selected_org_name = next((o.name for o in orgs_data if o.slug == selected_org_slug), selected_org_slug)
85
+ console.print(f"[green]✓ Selected organization: {selected_org_name} ({selected_org_slug})[/green]")
86
+ logger.info(f"Organization selected: {selected_org_slug}")
87
+
88
+ return selected_org_slug
89
+
90
+
91
+ def _get_project_name() -> str:
92
+ """Interactively get project name from user."""
93
+ logger.debug("Prompting user to enter project name")
94
+ console.print("[yellow]No project specified. Please enter a project name:[/yellow]")
95
+
96
+ while True:
97
+ project = Prompt.ask("Enter project name")
98
+ if project and project.strip():
99
+ project = project.strip()
100
+ console.print(f"[green]✓ Will create project: {project}[/green]")
101
+ logger.info(f"Project name entered: {project}")
102
+ return project
103
+ else:
104
+ console.print("[red]Project name cannot be empty[/red]")
105
+
106
+
107
+ def _parse_env_vars(env: tuple) -> Dict[str, str]:
108
+ """Parse environment variables from tuple of KEY=VALUE strings."""
109
+ env_vars = {}
110
+ for e in env:
111
+ if '=' in e:
112
+ key, value = e.split('=', 1)
113
+ env_vars[key] = value
114
+ else:
115
+ console.print(f"[yellow]Warning: Invalid environment variable format: {e}[/yellow]")
116
+ logger.debug(f"Parsed {len(env_vars)} environment variables")
117
+ return env_vars
118
+
119
+
120
+ def _parse_volumes(volume: tuple) -> Dict[str, str]:
121
+ """Parse volume mounts from tuple of HOST:CONTAINER strings."""
122
+ volumes = {}
123
+ for v in volume:
124
+ if ':' in v:
125
+ host_path, container_path = v.split(':', 1)
126
+ volumes[host_path] = container_path
127
+ else:
128
+ console.print(f"[yellow]Warning: Invalid volume format: {v}[/yellow]")
129
+ logger.debug(f"Parsed {len(volumes)} volume mounts")
130
+ return volumes
131
+
132
+
133
+ def _is_jupyter_installed(docker_manager: DockerManager, container_id: str) -> bool:
134
+ """Check if Jupyter is actually installed in the container."""
135
+ try:
136
+ result = subprocess.run(
137
+ ["docker", "exec", container_id, "which", "jupyter"],
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=10
141
+ )
142
+
143
+ if result.returncode == 0:
144
+ return True
145
+
146
+ result = subprocess.run(
147
+ ["docker", "exec", container_id, "which", "jupyter-lab"],
148
+ capture_output=True,
149
+ text=True,
150
+ timeout=10
151
+ )
152
+
153
+ return result.returncode == 0
154
+
155
+ except Exception as e:
156
+ console.print(f"[yellow]Warning: Could not check Jupyter installation: {e}[/yellow]")
157
+ return False
158
+
159
+
160
+ def _is_cloudflared_installed(docker_manager: DockerManager, container_id: str) -> bool:
161
+ """Check if cloudflared is installed in the container."""
162
+ try:
163
+ result = subprocess.run(
164
+ ["docker", "exec", container_id, "which", "cloudflared"],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=10
168
+ )
169
+
170
+ return result.returncode == 0
171
+
172
+ except Exception as e:
173
+ logger.warning(f"Could not check cloudflared installation: {e}")
174
+ return False
175
+
176
+
177
+ def _install_jupyter_in_container(docker_manager: DockerManager, container_id: str) -> bool:
178
+ """Install Jupyter in a container that doesn't have it."""
179
+ package_manager = docker_manager._detect_package_manager(container_id)
180
+
181
+ if not package_manager:
182
+ console.print("[red]Could not detect package manager for Jupyter installation[/red]")
183
+ return False
184
+
185
+ try:
186
+ if package_manager in ['apt', 'apt-get']:
187
+ install_commands = [
188
+ "apt-get update",
189
+ "apt-get install -y python3-pip",
190
+ "pip3 install jupyter jupyterlab"
191
+ ]
192
+ elif package_manager in ['yum', 'dnf']:
193
+ install_commands = [
194
+ f"{package_manager} update -y",
195
+ f"{package_manager} install -y python3-pip",
196
+ "pip3 install jupyter jupyterlab"
197
+ ]
198
+ elif package_manager == 'apk':
199
+ install_commands = [
200
+ "apk update",
201
+ "apk add --no-cache python3 py3-pip",
202
+ "pip3 install jupyter jupyterlab"
203
+ ]
204
+ else:
205
+ install_commands = [
206
+ "pip3 install jupyter jupyterlab"
207
+ ]
208
+
209
+ for cmd in install_commands:
210
+ result = subprocess.run(
211
+ ["docker", "exec", "--user", "root", container_id, "bash", "-c", cmd],
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=120
215
+ )
216
+
217
+ if result.returncode != 0:
218
+ console.print(f"[red]Failed to run: {cmd}[/red]")
219
+ console.print(f"[red]Error: {result.stderr}[/red]")
220
+ return False
221
+
222
+ console.print("[green]✓ Jupyter installed successfully[/green]")
223
+ return True
224
+
225
+ except Exception as e:
226
+ console.print(f"[red]Error installing Jupyter: {e}[/red]")
227
+ return False
228
+
229
+
230
+ def _setup_jupyter_in_container(
231
+ docker_manager: DockerManager,
232
+ container_id: str,
233
+ jupyter_port: int,
234
+ jupyter_token: str
235
+ ) -> bool:
236
+ """Setup Jupyter in container if needed."""
237
+ logger.info(f"Setting up Jupyter in container {container_id[:12]}")
238
+
239
+ if not _is_jupyter_installed(docker_manager, container_id):
240
+ console.print("[yellow]Installing Jupyter in container...[/yellow]")
241
+ if not _install_jupyter_in_container(docker_manager, container_id):
242
+ logger.error("Failed to install Jupyter")
243
+ console.print("[red]Failed to install Jupyter[/red]")
244
+ return False
245
+ else:
246
+ console.print("[green]✓ Jupyter is already installed[/green]")
247
+
248
+ success, actual_token = docker_manager.ensure_jupyter_running(
249
+ container_id,
250
+ jupyter_port,
251
+ jupyter_token,
252
+ force_restart=True
253
+ )
254
+
255
+ if not success:
256
+ console.print("[yellow]⚠ Jupyter may not be running[/yellow]")
257
+ return False
258
+
259
+ logger.info("Jupyter setup completed successfully")
260
+ return True
261
+
262
+
263
+ def _connect_to_cloud(
264
+ client: AlphAIClient,
265
+ docker_manager: DockerManager,
266
+ container_id: str,
267
+ org: str,
268
+ project: str,
269
+ app_port: int,
270
+ jupyter_port: int,
271
+ jupyter_token: Optional[str]
272
+ ):
273
+ """Connect container to cloud and setup project."""
274
+ logger.info(f"Connecting project {project} in org {org} to cloud")
275
+ console.print("[yellow]Connecting to cloud...[/yellow]")
276
+
277
+ # Create the connection (tunnel + project) via API
278
+ connection_data = client.create_tunnel_with_project(
279
+ org_slug=org,
280
+ project_name=project,
281
+ app_port=app_port,
282
+ jupyter_port=jupyter_port,
283
+ jupyter_token=jupyter_token
284
+ )
285
+
286
+ if not connection_data:
287
+ logger.error("Failed to connect to cloud")
288
+ console.print("[red]Failed to connect to cloud[/red]")
289
+ return None
290
+
291
+ # Install connector agent if needed
292
+ if not _is_cloudflared_installed(docker_manager, container_id):
293
+ console.print("[yellow]Installing connector...[/yellow]")
294
+ if not docker_manager.install_cloudflared_in_container(container_id):
295
+ console.print("[yellow]Warning: Connector installation failed, but container is running[/yellow]")
296
+ return None
297
+ else:
298
+ console.print("[green]✓ Connector ready[/green]")
299
+
300
+ # Start the connector
301
+ cloudflared_token = connection_data.cloudflared_token if hasattr(connection_data, 'cloudflared_token') else connection_data.cloudflared_token
302
+ if not docker_manager.setup_tunnel_in_container(container_id, cloudflared_token):
303
+ console.print("[yellow]Warning: Connector setup failed, but container is running[/yellow]")
304
+ return None
305
+
306
+ logger.info("Cloud connection established successfully")
307
+ console.print("[green]✓ Connected to cloud[/green]")
308
+ return connection_data
309
+
310
+
311
+ def _display_deployment_summary(
312
+ container,
313
+ connection_data,
314
+ app_port: int,
315
+ jupyter_port: int,
316
+ jupyter_token: Optional[str],
317
+ config: Config,
318
+ org: str,
319
+ project: str
320
+ ):
321
+ """Display deployment summary panel."""
322
+ logger.debug("Displaying deployment summary")
323
+ console.print("\n[bold green]🎉 Setup complete![/bold green]")
324
+
325
+ summary_content = []
326
+ summary_content.append(f"[bold]Container ID:[/bold] {container.id[:12]}")
327
+ summary_content.append("")
328
+ summary_content.append("[bold blue]Local URL:[/bold blue]")
329
+ summary_content.append(f" • App: http://localhost:{app_port}")
330
+ if jupyter_token:
331
+ summary_content.append(f" • Jupyter: http://localhost:{jupyter_port}?token={jupyter_token}")
332
+ else:
333
+ summary_content.append(f" • Jupyter: http://localhost:{jupyter_port}")
334
+ summary_content.append("")
335
+ summary_content.append("[bold green]Public URL:[/bold green]")
336
+ summary_content.append(f" • App: {connection_data.app_url}")
337
+ if jupyter_token:
338
+ summary_content.append(f" • Jupyter: {connection_data.jupyter_url}?token={jupyter_token}")
339
+ else:
340
+ summary_content.append(f" • Jupyter: {connection_data.jupyter_url}")
341
+ summary_content.append("")
342
+ if jupyter_token:
343
+ summary_content.append("[bold cyan]Jupyter Token:[/bold cyan]")
344
+ summary_content.append(f" {jupyter_token}")
345
+ summary_content.append("")
346
+ summary_content.append("[bold yellow]Management:[/bold yellow]")
347
+ summary_content.append(f" • Stop container: docker stop {container.id[:12]}")
348
+ summary_content.append(f" • View logs: docker logs {container.id[:12]}")
349
+ summary_content.append(f" • Cleanup: alphai cleanup {container.id[:12]}")
350
+ summary_content.append("")
351
+ summary_content.append("[bold cyan]Quick Cleanup:[/bold cyan]")
352
+ summary_content.append(" • Press Ctrl+C to automatically cleanup all resources")
353
+
354
+ panel = Panel(
355
+ "\n".join(summary_content),
356
+ title="🚀 Deployment Summary",
357
+ title_align="left",
358
+ border_style="green"
359
+ )
360
+ console.print(panel)
361
+
362
+ with Progress(
363
+ SpinnerColumn(),
364
+ TextColumn("[progress.description]{task.description}"),
365
+ console=console
366
+ ) as progress:
367
+ task = progress.add_task("Waiting for cloud connection...", total=None)
368
+ time.sleep(5)
369
+ progress.update(task, completed=1)
370
+
371
+ # Use project slug from API response if available
372
+ frontend_url = _get_frontend_url(config.api_url)
373
+ project_slug = project
374
+ if connection_data.project_data:
375
+ if hasattr(connection_data.project_data, 'slug') and connection_data.project_data.slug:
376
+ project_slug = connection_data.project_data.slug
377
+ elif hasattr(connection_data.project_data, 'name') and connection_data.project_data.name:
378
+ project_slug = connection_data.project_data.name
379
+
380
+ project_url = f"{frontend_url}/{org}/{project_slug}"
381
+ console.print(f"\n[cyan]🌐 Opening browser to: {project_url}[/cyan]")
382
+ try:
383
+ webbrowser.open(project_url)
384
+ logger.info(f"Opened browser to {project_url}")
385
+ except Exception as e:
386
+ logger.warning(f"Could not open browser: {e}")
387
+ console.print(f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]")
388
+ console.print(f"[yellow]Please manually visit: {project_url}[/yellow]")
389
+
390
+
391
+ @click.command()
392
+ @click.option('--image', default="quay.io/jupyter/datascience-notebook:latest", required=True, help='Docker image to run')
393
+ @click.option('--app-port', default=5000, help='Application port (default: 5000)')
394
+ @click.option('--jupyter-port', default=8888, help='Jupyter port (default: 8888)')
395
+ @click.option('--name', help='Container name')
396
+ @click.option('--env', multiple=True, help='Environment variables (format: KEY=VALUE)')
397
+ @click.option('--volume', multiple=True, help='Volume mounts (format: HOST_PATH:CONTAINER_PATH)')
398
+ @click.option('--detach', '-d', is_flag=True, help='Run container in background')
399
+ @click.option('--local', is_flag=True, help='Run locally only (no cloud connection)')
400
+ @click.option('--org', help='Organization slug (interactive selection if not provided)')
401
+ @click.option('--project', help='Project name (interactive selection if not provided)')
402
+ @click.option('--command', help='Custom command to run in container (overrides default)')
403
+ @click.option('--ensure-jupyter', is_flag=True, help='Ensure Jupyter is running (auto-start if needed)')
404
+ @click.pass_context
405
+ def run(
406
+ ctx: click.Context,
407
+ image: str,
408
+ app_port: int,
409
+ jupyter_port: int,
410
+ name: Optional[str],
411
+ env: tuple,
412
+ volume: tuple,
413
+ detach: bool,
414
+ local: bool,
415
+ org: Optional[str],
416
+ project: Optional[str],
417
+ command: Optional[str],
418
+ ensure_jupyter: bool
419
+ ) -> None:
420
+ """Launch and manage local Docker containers with cloud connection."""
421
+ logger.info(f"Starting run command: image={image}, connect_cloud={not local}")
422
+ config: Config = ctx.obj['config']
423
+ client: AlphAIClient = ctx.obj['client']
424
+ docker_manager = DockerManager(console)
425
+
426
+ # Cloud connection is default behavior unless --local is specified
427
+ connect_cloud = not local
428
+
429
+ # Set up cleanup manager
430
+ cleanup_mgr = DockerCleanupManager(
431
+ console=console,
432
+ docker_manager=docker_manager,
433
+ client=client
434
+ )
435
+ cleanup_mgr.install_signal_handlers()
436
+
437
+ try:
438
+ # Validate cloud connection requirements and get org/project
439
+ if connect_cloud:
440
+ if not config.bearer_token:
441
+ logger.error("Cloud connection requested but no authentication token found")
442
+ console.print("[red]Error: Authentication required for cloud connection. Please run 'alphai login' first.[/red]")
443
+ console.print("[yellow]Tip: Use --local flag to run without cloud connection[/yellow]")
444
+ sys.exit(1)
445
+
446
+ if not org:
447
+ org = _select_organization(client)
448
+
449
+ if not project:
450
+ project = _get_project_name()
451
+
452
+ ensure_jupyter = True
453
+
454
+ # Generate Jupyter token upfront if we'll need it
455
+ jupyter_token = None
456
+ if ensure_jupyter or connect_cloud:
457
+ jupyter_token = docker_manager.generate_jupyter_token()
458
+ console.print(f"[cyan]Generated Jupyter token: {jupyter_token[:12]}...[/cyan]")
459
+ logger.debug(f"Generated Jupyter token: {jupyter_token[:12]}...")
460
+
461
+ # Parse environment variables and volumes
462
+ env_vars = _parse_env_vars(env)
463
+ volumes = _parse_volumes(volume)
464
+
465
+ # Generate Jupyter startup command if needed
466
+ startup_command = None
467
+ if command:
468
+ startup_command = command
469
+ elif ensure_jupyter or connect_cloud:
470
+ startup_command = "tail -f /dev/null"
471
+ console.print("[yellow]Using keep-alive command to control Jupyter startup[/yellow]")
472
+ else:
473
+ startup_command = "tail -f /dev/null"
474
+ console.print("[yellow]Keeping container alive for interactive use[/yellow]")
475
+
476
+ # Start the container
477
+ container = docker_manager.run_container(
478
+ image=image,
479
+ name=name,
480
+ ports={app_port: app_port, jupyter_port: jupyter_port},
481
+ environment=env_vars,
482
+ volumes=volumes,
483
+ detach=True,
484
+ command=startup_command
485
+ )
486
+
487
+ if not container:
488
+ console.print("[red]Failed to start container[/red]")
489
+ sys.exit(1)
490
+
491
+ console.print("[green]✓ Container started[/green]")
492
+ console.print(f"[blue]Container ID: {container.id[:12]}[/blue]")
493
+
494
+ # Register container for cleanup
495
+ cleanup_mgr.set_container(container.id)
496
+
497
+ # Verify container is actually running
498
+ time.sleep(2)
499
+
500
+ if not docker_manager.is_container_running(container.id):
501
+ status = docker_manager.get_container_status(container.id)
502
+ console.print("[red]Container failed to start or exited immediately[/red]")
503
+ console.print(f"[red]Status: {status}[/red]")
504
+
505
+ logs = docker_manager.get_container_logs(container.id, tail=20)
506
+ if logs:
507
+ console.print("[yellow]Container logs:[/yellow]")
508
+ console.print(f"[dim]{logs}[/dim]")
509
+
510
+ sys.exit(1)
511
+
512
+ console.print("[green]✓ Container is running[/green]")
513
+
514
+ # Install and ensure Jupyter is running if requested
515
+ if ensure_jupyter:
516
+ if not _setup_jupyter_in_container(docker_manager, container.id, jupyter_port, jupyter_token):
517
+ console.print("[yellow]⚠ Jupyter setup had issues, continuing...[/yellow]")
518
+
519
+ if connect_cloud:
520
+ # Connect to cloud
521
+ connection_data = _connect_to_cloud(
522
+ client, docker_manager, container.id,
523
+ org, project, app_port, jupyter_port, jupyter_token
524
+ )
525
+
526
+ if not connection_data:
527
+ logger.error("Failed to connect to cloud, exiting")
528
+ console.print("[red]Failed to connect to cloud[/red]")
529
+ sys.exit(1)
530
+
531
+ # Store IDs for cleanup
532
+ cleanup_mgr.set_tunnel(connection_data.id)
533
+ if connection_data.project_data and hasattr(connection_data.project_data, 'id'):
534
+ cleanup_mgr.set_project(connection_data.project_data.id)
535
+
536
+ # Display summary
537
+ _display_deployment_summary(
538
+ container, connection_data, app_port, jupyter_port,
539
+ jupyter_token, config, org, project
540
+ )
541
+ else:
542
+ # Local mode - just display local URLs
543
+ console.print(f"[blue]Application: http://localhost:{app_port}[/blue]")
544
+ if jupyter_token:
545
+ console.print(f"[blue]Jupyter: http://localhost:{jupyter_port}?token={jupyter_token}[/blue]")
546
+ console.print(f"[dim]Jupyter Token: {jupyter_token}[/dim]")
547
+ else:
548
+ console.print(f"[blue]Jupyter: http://localhost:{jupyter_port}[/blue]")
549
+ console.print(f"[dim]Check container logs for Jupyter token: docker logs {container.id[:12]}[/dim]")
550
+
551
+ console.print("\n[bold yellow]Cleanup:[/bold yellow]")
552
+ console.print(f" • Stop container: docker stop {container.id[:12]}")
553
+ console.print(f" • Quick cleanup: alphai cleanup {container.id[:12]}")
554
+ console.print(" • Press Ctrl+C to automatically stop and remove container")
555
+
556
+ if not detach:
557
+ console.print(f"[dim]Container is running in background. Use 'docker logs {container.id[:12]}' to view logs.[/dim]")
558
+
559
+ # Keep the process running and wait for Ctrl+C for cleanup
560
+ console.print("\n[bold green]🎯 Container is running! Press Ctrl+C to cleanup all resources.[/bold green]")
561
+ while True:
562
+ time.sleep(1)
563
+
564
+ except KeyboardInterrupt:
565
+ pass
566
+ finally:
567
+ cleanup_mgr.cleanup()
568
+ cleanup_mgr.restore_signal_handlers()
569
+
570
+
571
+ @click.command()
572
+ @click.argument('container_id')
573
+ @click.option('--force', is_flag=True, help='Skip confirmation and force cleanup')
574
+ @click.pass_context
575
+ def cleanup(
576
+ ctx: click.Context,
577
+ container_id: str,
578
+ force: bool
579
+ ) -> None:
580
+ """Clean up containers and projects created by alphai run.
581
+
582
+ This command performs comprehensive cleanup by:
583
+ 1. Stopping any running services in the container
584
+ 2. Stopping and removing the Docker container
585
+ 3. Cleaning up the associated project
586
+
587
+ Examples:
588
+ alphai cleanup abc123456789 # Cleanup with confirmation
589
+ alphai cleanup abc123456789 --force # Skip confirmations
590
+ """
591
+ config: Config = ctx.obj['config']
592
+ client: AlphAIClient = ctx.obj['client']
593
+ docker_manager = DockerManager(console)
594
+
595
+ # Confirmation unless force is used
596
+ if not force:
597
+ console.print(f"[yellow]Will cleanup: Container {container_id[:12]}[/yellow]")
598
+ if not Confirm.ask("Continue with cleanup?"):
599
+ console.print("[yellow]Cancelled[/yellow]")
600
+ return
601
+
602
+ console.print("[bold]🔄 Starting cleanup process...[/bold]")
603
+
604
+ # Container cleanup
605
+ success = docker_manager.cleanup_container_and_tunnel(
606
+ container_id=container_id,
607
+ force=force
608
+ )
609
+
610
+ # Summary
611
+ if success:
612
+ console.print("\n[bold green]✅ Cleanup completed successfully![/bold green]")
613
+ else:
614
+ console.print("\n[bold yellow]⚠ Cleanup completed with warnings[/bold yellow]")
615
+ console.print("[dim]Check the output above for details[/dim]")