clonebox 0.1.1__py3-none-any.whl → 0.1.2__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.
clonebox/cli.py ADDED
@@ -0,0 +1,884 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CloneBox CLI - Interactive command-line interface for creating VMs.
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ import json
9
+ import argparse
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ from datetime import datetime
13
+
14
+ import yaml
15
+
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich.panel import Panel
19
+ from rich.progress import Progress, SpinnerColumn, TextColumn
20
+ from rich import print as rprint
21
+ import questionary
22
+ from questionary import Style
23
+
24
+ from clonebox import __version__
25
+ from clonebox.cloner import SelectiveVMCloner, VMConfig
26
+ from clonebox.detector import SystemDetector
27
+
28
+
29
+ # Custom questionary style
30
+ custom_style = Style([
31
+ ('qmark', 'fg:cyan bold'),
32
+ ('question', 'bold'),
33
+ ('answer', 'fg:green'),
34
+ ('pointer', 'fg:cyan bold'),
35
+ ('highlighted', 'fg:cyan bold'),
36
+ ('selected', 'fg:green'),
37
+ ('separator', 'fg:gray'),
38
+ ('instruction', 'fg:gray italic'),
39
+ ])
40
+
41
+ console = Console()
42
+
43
+
44
+ def print_banner():
45
+ """Print the CloneBox banner."""
46
+ banner = """
47
+ ╔═══════════════════════════════════════════════════════════════╗
48
+ ║ ____ _ ____ ║
49
+ ║ / ___|| | ___ _ __ ___| _ \\ ___ __ __ ║
50
+ ║ | | | | / _ \\ | '_ \\ / _ \\ |_) |/ _ \\\\ \\/ / ║
51
+ ║ | |___ | || (_) || | | | __/ _ <| (_) |> < ║
52
+ ║ \\____||_| \\___/ |_| |_|\\___|_| \\_\\\\___//_/\\_\\ ║
53
+ ║ ║
54
+ ║ Clone your workstation to an isolated VM ║
55
+ ╚═══════════════════════════════════════════════════════════════╝
56
+ """
57
+ console.print(banner, style="cyan")
58
+ console.print(f" Version {__version__}\n", style="dim")
59
+
60
+
61
+ def interactive_mode():
62
+ """Run the interactive VM creation wizard."""
63
+ print_banner()
64
+
65
+ console.print("[bold cyan]🔍 Detecting system state...[/]\n")
66
+
67
+ with Progress(
68
+ SpinnerColumn(),
69
+ TextColumn("[progress.description]{task.description}"),
70
+ console=console,
71
+ transient=True
72
+ ) as progress:
73
+ task = progress.add_task("Scanning services, apps, and paths...", total=None)
74
+ detector = SystemDetector()
75
+ snapshot = detector.detect_all()
76
+ sys_info = detector.get_system_info()
77
+ docker_containers = detector.detect_docker_containers()
78
+
79
+ # Show system info
80
+ console.print(Panel(
81
+ f"[bold]Hostname:[/] {sys_info['hostname']}\n"
82
+ f"[bold]User:[/] {sys_info['user']}\n"
83
+ f"[bold]CPU:[/] {sys_info['cpu_count']} cores\n"
84
+ f"[bold]RAM:[/] {sys_info['memory_available_gb']:.1f} / {sys_info['memory_total_gb']:.1f} GB available\n"
85
+ f"[bold]Disk:[/] {sys_info['disk_free_gb']:.1f} / {sys_info['disk_total_gb']:.1f} GB free",
86
+ title="[bold cyan]System Info[/]",
87
+ border_style="cyan"
88
+ ))
89
+
90
+ console.print()
91
+
92
+ # === VM Name ===
93
+ vm_name = questionary.text(
94
+ "VM name:",
95
+ default="clonebox-vm",
96
+ style=custom_style
97
+ ).ask()
98
+
99
+ if not vm_name:
100
+ console.print("[red]Cancelled.[/]")
101
+ return
102
+
103
+ # === RAM ===
104
+ max_ram = int(sys_info['memory_available_gb'] * 1024 * 0.75) # 75% of available
105
+ default_ram = min(4096, max_ram)
106
+
107
+ ram_mb = questionary.text(
108
+ f"RAM (MB) [max recommended: {max_ram}]:",
109
+ default=str(default_ram),
110
+ style=custom_style
111
+ ).ask()
112
+ ram_mb = int(ram_mb) if ram_mb else default_ram
113
+
114
+ # === vCPUs ===
115
+ max_vcpus = sys_info['cpu_count']
116
+ default_vcpus = max(2, max_vcpus // 2)
117
+
118
+ vcpus = questionary.text(
119
+ f"vCPUs [max: {max_vcpus}]:",
120
+ default=str(default_vcpus),
121
+ style=custom_style
122
+ ).ask()
123
+ vcpus = int(vcpus) if vcpus else default_vcpus
124
+
125
+ # === Services Selection ===
126
+ console.print("\n[bold cyan]📦 Select services to include in VM:[/]")
127
+
128
+ service_choices = []
129
+ for svc in snapshot.running_services:
130
+ label = f"{svc.name} ({svc.status})"
131
+ if svc.description:
132
+ label += f" - {svc.description[:40]}"
133
+ service_choices.append(questionary.Choice(label, value=svc.name))
134
+
135
+ selected_services = []
136
+ if service_choices:
137
+ selected_services = questionary.checkbox(
138
+ "Services (space to select, enter to confirm):",
139
+ choices=service_choices,
140
+ style=custom_style
141
+ ).ask() or []
142
+ else:
143
+ console.print("[dim] No interesting services detected[/]")
144
+
145
+ # === Applications/Processes Selection ===
146
+ console.print("\n[bold cyan]🚀 Select applications to track:[/]")
147
+
148
+ app_choices = []
149
+ for app in snapshot.running_apps[:20]: # Limit to top 20
150
+ label = f"{app.name} (PID: {app.pid}, {app.memory_mb:.0f} MB)"
151
+ if app.working_dir:
152
+ label += f" @ {app.working_dir[:30]}"
153
+ app_choices.append(questionary.Choice(label, value=app))
154
+
155
+ selected_apps = []
156
+ if app_choices:
157
+ selected_apps = questionary.checkbox(
158
+ "Applications (will add their working dirs):",
159
+ choices=app_choices,
160
+ style=custom_style
161
+ ).ask() or []
162
+ else:
163
+ console.print("[dim] No interesting applications detected[/]")
164
+
165
+ # === Docker Containers ===
166
+ if docker_containers:
167
+ console.print("\n[bold cyan]🐳 Docker containers detected:[/]")
168
+
169
+ container_choices = [
170
+ questionary.Choice(
171
+ f"{c['name']} ({c['image']}) - {c['status']}",
172
+ value=c['name']
173
+ )
174
+ for c in docker_containers
175
+ ]
176
+
177
+ selected_containers = questionary.checkbox(
178
+ "Containers (will share docker socket):",
179
+ choices=container_choices,
180
+ style=custom_style
181
+ ).ask() or []
182
+
183
+ # If any docker selected, add docker socket
184
+ if selected_containers:
185
+ if "docker" not in selected_services:
186
+ selected_services.append("docker")
187
+
188
+ # === Paths Selection ===
189
+ console.print("\n[bold cyan]📁 Select paths to mount in VM:[/]")
190
+
191
+ # Group paths by type
192
+ path_groups = {}
193
+ for p in snapshot.paths:
194
+ if p.type not in path_groups:
195
+ path_groups[p.type] = []
196
+ path_groups[p.type].append(p)
197
+
198
+ path_choices = []
199
+ for ptype in ["project", "config", "data"]:
200
+ if ptype in path_groups:
201
+ for p in path_groups[ptype]:
202
+ size_str = f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "?"
203
+ label = f"[{ptype}] {p.path} ({size_str})"
204
+ if p.description:
205
+ label += f" - {p.description}"
206
+ path_choices.append(questionary.Choice(label, value=p.path))
207
+
208
+ selected_paths = []
209
+ if path_choices:
210
+ selected_paths = questionary.checkbox(
211
+ "Paths (will be bind-mounted read-write):",
212
+ choices=path_choices,
213
+ style=custom_style
214
+ ).ask() or []
215
+
216
+ # Add working directories from selected applications
217
+ for app in selected_apps:
218
+ if app.working_dir and app.working_dir not in selected_paths:
219
+ selected_paths.append(app.working_dir)
220
+
221
+ # === Additional Packages ===
222
+ console.print("\n[bold cyan]📦 Additional packages to install:[/]")
223
+
224
+ common_packages = [
225
+ "build-essential", "git", "curl", "wget", "vim", "htop",
226
+ "python3", "python3-pip", "python3-venv",
227
+ "nodejs", "npm",
228
+ "docker.io", "docker-compose",
229
+ "nginx", "postgresql", "redis",
230
+ ]
231
+
232
+ pkg_choices = [questionary.Choice(pkg, value=pkg) for pkg in common_packages]
233
+
234
+ selected_packages = questionary.checkbox(
235
+ "Packages (space to select):",
236
+ choices=pkg_choices,
237
+ style=custom_style
238
+ ).ask() or []
239
+
240
+ # Add custom packages
241
+ custom_pkgs = questionary.text(
242
+ "Additional packages (space-separated):",
243
+ default="",
244
+ style=custom_style
245
+ ).ask()
246
+
247
+ if custom_pkgs:
248
+ selected_packages.extend(custom_pkgs.split())
249
+
250
+ # === Base Image ===
251
+ base_image = questionary.text(
252
+ "Base image path (optional, leave empty for blank disk):",
253
+ default="",
254
+ style=custom_style
255
+ ).ask()
256
+
257
+ # === GUI ===
258
+ enable_gui = questionary.confirm(
259
+ "Enable SPICE graphics (GUI)?",
260
+ default=True,
261
+ style=custom_style
262
+ ).ask()
263
+
264
+ # === Summary ===
265
+ console.print("\n")
266
+
267
+ # Build paths mapping
268
+ paths_mapping = {}
269
+ for idx, host_path in enumerate(selected_paths):
270
+ guest_path = f"/mnt/host{idx}"
271
+ paths_mapping[host_path] = guest_path
272
+
273
+ # Summary table
274
+ summary_table = Table(title="VM Configuration Summary", border_style="cyan")
275
+ summary_table.add_column("Setting", style="bold")
276
+ summary_table.add_column("Value")
277
+
278
+ summary_table.add_row("Name", vm_name)
279
+ summary_table.add_row("RAM", f"{ram_mb} MB")
280
+ summary_table.add_row("vCPUs", str(vcpus))
281
+ summary_table.add_row("Services", ", ".join(selected_services) or "None")
282
+ summary_table.add_row("Packages", ", ".join(selected_packages[:5]) + ("..." if len(selected_packages) > 5 else "") or "None")
283
+ summary_table.add_row("Paths", f"{len(paths_mapping)} bind mounts")
284
+ summary_table.add_row("GUI", "Yes (SPICE)" if enable_gui else "No")
285
+
286
+ console.print(summary_table)
287
+
288
+ if paths_mapping:
289
+ console.print("\n[bold]Bind mounts:[/]")
290
+ for host, guest in paths_mapping.items():
291
+ console.print(f" [cyan]{host}[/] → [green]{guest}[/]")
292
+
293
+ console.print()
294
+
295
+ # === Confirm ===
296
+ if not questionary.confirm("Create VM with these settings?", default=True, style=custom_style).ask():
297
+ console.print("[yellow]Cancelled.[/]")
298
+ return
299
+
300
+ # === Create VM ===
301
+ console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
302
+
303
+ config = VMConfig(
304
+ name=vm_name,
305
+ ram_mb=ram_mb,
306
+ vcpus=vcpus,
307
+ gui=enable_gui,
308
+ base_image=base_image if base_image else None,
309
+ paths=paths_mapping,
310
+ packages=selected_packages,
311
+ services=selected_services,
312
+ )
313
+
314
+ try:
315
+ cloner = SelectiveVMCloner()
316
+
317
+ # Check prerequisites
318
+ checks = cloner.check_prerequisites()
319
+ if not all(checks.values()):
320
+ console.print("[yellow]⚠️ Prerequisites check:[/]")
321
+ for check, passed in checks.items():
322
+ icon = "✅" if passed else "❌"
323
+ console.print(f" {icon} {check}")
324
+
325
+ if not checks["libvirt_connected"]:
326
+ console.print("\n[red]Cannot proceed without libvirt connection.[/]")
327
+ console.print("Try: [cyan]sudo systemctl start libvirtd[/]")
328
+ return
329
+
330
+ vm_uuid = cloner.create_vm(config, console=console)
331
+
332
+ # Ask to start
333
+ if questionary.confirm("Start VM now?", default=True, style=custom_style).ask():
334
+ cloner.start_vm(vm_name, open_viewer=enable_gui, console=console)
335
+ console.print("\n[bold green]🎉 VM is running![/]")
336
+
337
+ if paths_mapping:
338
+ console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
339
+ for idx, (host, guest) in enumerate(paths_mapping.items()):
340
+ console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
341
+
342
+ console.print(f"\n[dim]VM UUID: {vm_uuid}[/]")
343
+
344
+ except Exception as e:
345
+ console.print(f"\n[red]❌ Error: {e}[/]")
346
+ raise
347
+
348
+
349
+ def cmd_create(args):
350
+ """Create VM from JSON config."""
351
+ config_data = json.loads(args.config)
352
+
353
+ config = VMConfig(
354
+ name=args.name,
355
+ ram_mb=args.ram,
356
+ vcpus=args.vcpus,
357
+ gui=not args.no_gui,
358
+ base_image=args.base_image,
359
+ paths=config_data.get("paths", {}),
360
+ packages=config_data.get("packages", []),
361
+ services=config_data.get("services", []),
362
+ )
363
+
364
+ cloner = SelectiveVMCloner()
365
+ vm_uuid = cloner.create_vm(config, console=console)
366
+
367
+ if args.start:
368
+ cloner.start_vm(args.name, open_viewer=not args.no_gui, console=console)
369
+
370
+ console.print(f"[green]✅ VM created: {vm_uuid}[/]")
371
+
372
+
373
+ def cmd_start(args):
374
+ """Start a VM or create from .clonebox.yaml."""
375
+ name = args.name
376
+
377
+ # Check if it's a path (contains / or . or ~)
378
+ if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
379
+ # Treat as path - load .clonebox.yaml
380
+ target_path = Path(name).expanduser().resolve()
381
+
382
+ if target_path.is_dir():
383
+ config_file = target_path / CLONEBOX_CONFIG_FILE
384
+ else:
385
+ config_file = target_path
386
+
387
+ if not config_file.exists():
388
+ console.print(f"[red]❌ Config not found: {config_file}[/]")
389
+ console.print(f"[dim]Run 'clonebox clone {target_path}' first to generate config[/]")
390
+ return
391
+
392
+ console.print(f"[bold cyan]📦 Loading config: {config_file}[/]\n")
393
+
394
+ config = load_clonebox_config(config_file)
395
+ vm_name = config["vm"]["name"]
396
+
397
+ # Check if VM already exists
398
+ cloner = SelectiveVMCloner()
399
+ try:
400
+ existing_vms = [v["name"] for v in cloner.list_vms()]
401
+ if vm_name in existing_vms:
402
+ console.print(f"[cyan]VM '{vm_name}' exists, starting...[/]")
403
+ cloner.start_vm(vm_name, open_viewer=not args.no_viewer, console=console)
404
+ return
405
+ except:
406
+ pass
407
+
408
+ # Create new VM from config
409
+ console.print(f"[cyan]Creating VM '{vm_name}' from config...[/]\n")
410
+ vm_uuid = create_vm_from_config(config, start=True)
411
+ console.print(f"\n[bold green]🎉 VM '{vm_name}' is running![/]")
412
+ console.print(f"[dim]UUID: {vm_uuid}[/]")
413
+
414
+ if config.get("paths"):
415
+ console.print("\n[bold]Inside VM, mount paths with:[/]")
416
+ for idx, (host, guest) in enumerate(config["paths"].items()):
417
+ console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
418
+ return
419
+
420
+ # Default: treat as VM name
421
+ if not name:
422
+ # No argument - check current directory for .clonebox.yaml
423
+ config_file = Path.cwd() / CLONEBOX_CONFIG_FILE
424
+ if config_file.exists():
425
+ console.print(f"[cyan]Found {CLONEBOX_CONFIG_FILE} in current directory[/]")
426
+ args.name = "."
427
+ return cmd_start(args)
428
+ else:
429
+ console.print("[red]❌ No VM name specified and no .clonebox.yaml in current directory[/]")
430
+ console.print("[dim]Usage: clonebox start <vm-name> or clonebox start .[/]")
431
+ return
432
+
433
+ cloner = SelectiveVMCloner()
434
+ cloner.start_vm(name, open_viewer=not args.no_viewer, console=console)
435
+
436
+
437
+ def cmd_stop(args):
438
+ """Stop a VM."""
439
+ cloner = SelectiveVMCloner()
440
+ cloner.stop_vm(args.name, force=args.force, console=console)
441
+
442
+
443
+ def cmd_delete(args):
444
+ """Delete a VM."""
445
+ if not args.yes:
446
+ if not questionary.confirm(
447
+ f"Delete VM '{args.name}' and its storage?",
448
+ default=False,
449
+ style=custom_style
450
+ ).ask():
451
+ console.print("[yellow]Cancelled.[/]")
452
+ return
453
+
454
+ cloner = SelectiveVMCloner()
455
+ cloner.delete_vm(args.name, delete_storage=not args.keep_storage, console=console)
456
+
457
+
458
+ def cmd_list(args):
459
+ """List all VMs."""
460
+ cloner = SelectiveVMCloner()
461
+ vms = cloner.list_vms()
462
+
463
+ if not vms:
464
+ console.print("[dim]No VMs found.[/]")
465
+ return
466
+
467
+ table = Table(title="Virtual Machines", border_style="cyan")
468
+ table.add_column("Name", style="bold")
469
+ table.add_column("State")
470
+ table.add_column("UUID", style="dim")
471
+
472
+ for vm in vms:
473
+ state_style = "green" if vm["state"] == "running" else "dim"
474
+ table.add_row(vm["name"], f"[{state_style}]{vm['state']}[/]", vm["uuid"][:8])
475
+
476
+ console.print(table)
477
+
478
+
479
+ CLONEBOX_CONFIG_FILE = ".clonebox.yaml"
480
+
481
+
482
+ def deduplicate_list(items: list, key=None) -> list:
483
+ """Remove duplicates from list, preserving order."""
484
+ seen = set()
485
+ result = []
486
+ for item in items:
487
+ k = key(item) if key else item
488
+ if k not in seen:
489
+ seen.add(k)
490
+ result.append(item)
491
+ return result
492
+
493
+
494
+ def generate_clonebox_yaml(snapshot, detector, deduplicate: bool = True,
495
+ target_path: str = None, vm_name: str = None) -> str:
496
+ """Generate YAML config from system snapshot."""
497
+ sys_info = detector.get_system_info()
498
+
499
+ # Collect services
500
+ services = [s.name for s in snapshot.running_services]
501
+ if deduplicate:
502
+ services = deduplicate_list(services)
503
+
504
+ # Collect paths with types
505
+ paths_by_type = {"project": [], "config": [], "data": []}
506
+ for p in snapshot.paths:
507
+ if p.type in paths_by_type:
508
+ paths_by_type[p.type].append(p.path)
509
+
510
+ if deduplicate:
511
+ for ptype in paths_by_type:
512
+ paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype])
513
+
514
+ # Collect working directories from running apps
515
+ working_dirs = []
516
+ for app in snapshot.applications:
517
+ if app.working_dir and app.working_dir != "/" and app.working_dir.startswith("/home"):
518
+ working_dirs.append(app.working_dir)
519
+
520
+ if deduplicate:
521
+ working_dirs = deduplicate_list(working_dirs)
522
+
523
+ # If target_path specified, prioritize it
524
+ if target_path:
525
+ target_path = str(Path(target_path).resolve())
526
+ if target_path not in paths_by_type["project"]:
527
+ paths_by_type["project"].insert(0, target_path)
528
+
529
+ # Build paths mapping
530
+ paths_mapping = {}
531
+ idx = 0
532
+ for host_path in paths_by_type["project"][:5]: # Limit projects
533
+ paths_mapping[host_path] = f"/mnt/project{idx}"
534
+ idx += 1
535
+
536
+ for host_path in working_dirs[:3]: # Limit working dirs
537
+ if host_path not in paths_mapping:
538
+ paths_mapping[host_path] = f"/mnt/workdir{idx}"
539
+ idx += 1
540
+
541
+ # Determine VM name
542
+ if not vm_name:
543
+ if target_path:
544
+ vm_name = f"clone-{Path(target_path).name}"
545
+ else:
546
+ vm_name = f"clone-{sys_info['hostname']}"
547
+
548
+ # Calculate recommended resources
549
+ ram_mb = min(4096, int(sys_info['memory_available_gb'] * 1024 * 0.5))
550
+ vcpus = max(2, sys_info['cpu_count'] // 2)
551
+
552
+ # Build config
553
+ config = {
554
+ "version": "1",
555
+ "generated": datetime.now().isoformat(),
556
+ "vm": {
557
+ "name": vm_name,
558
+ "ram_mb": ram_mb,
559
+ "vcpus": vcpus,
560
+ "gui": True,
561
+ "base_image": None,
562
+ },
563
+ "services": services,
564
+ "packages": [
565
+ "build-essential", "git", "curl", "vim",
566
+ "python3", "python3-pip",
567
+ ],
568
+ "paths": paths_mapping,
569
+ "detected": {
570
+ "running_apps": [
571
+ {"name": a.name, "cwd": a.working_dir, "memory_mb": round(a.memory_mb)}
572
+ for a in snapshot.applications[:10]
573
+ ],
574
+ "all_paths": {
575
+ "projects": paths_by_type["project"],
576
+ "configs": paths_by_type["config"][:5],
577
+ "data": paths_by_type["data"][:5],
578
+ }
579
+ }
580
+ }
581
+
582
+ return yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
583
+
584
+
585
+ def load_clonebox_config(path: Path) -> dict:
586
+ """Load .clonebox.yaml config file."""
587
+ config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
588
+
589
+ if not config_file.exists():
590
+ raise FileNotFoundError(f"Config file not found: {config_file}")
591
+
592
+ with open(config_file) as f:
593
+ return yaml.safe_load(f)
594
+
595
+
596
+ def create_vm_from_config(config: dict, start: bool = False, user_session: bool = False) -> str:
597
+ """Create VM from YAML config dict."""
598
+ vm_config = VMConfig(
599
+ name=config["vm"]["name"],
600
+ ram_mb=config["vm"].get("ram_mb", 4096),
601
+ vcpus=config["vm"].get("vcpus", 4),
602
+ gui=config["vm"].get("gui", True),
603
+ base_image=config["vm"].get("base_image"),
604
+ paths=config.get("paths", {}),
605
+ packages=config.get("packages", []),
606
+ services=config.get("services", []),
607
+ user_session=user_session,
608
+ )
609
+
610
+ cloner = SelectiveVMCloner(user_session=user_session)
611
+
612
+ # Check prerequisites and show detailed info
613
+ checks = cloner.check_prerequisites()
614
+
615
+ if not checks["images_dir_writable"]:
616
+ console.print(f"[yellow]⚠️ Storage directory: {checks['images_dir']}[/]")
617
+ if "images_dir_error" in checks:
618
+ console.print(f"[red]{checks['images_dir_error']}[/]")
619
+ raise PermissionError(checks["images_dir_error"])
620
+
621
+ console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
622
+
623
+ vm_uuid = cloner.create_vm(vm_config, console=console)
624
+
625
+ if start:
626
+ cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
627
+
628
+ return vm_uuid
629
+
630
+
631
+ def cmd_clone(args):
632
+ """Generate clone config from path and optionally create VM."""
633
+ target_path = Path(args.path).resolve()
634
+
635
+ if not target_path.exists():
636
+ console.print(f"[red]❌ Path does not exist: {target_path}[/]")
637
+ return
638
+
639
+ console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
640
+
641
+ # Detect system state
642
+ with Progress(
643
+ SpinnerColumn(),
644
+ TextColumn("[progress.description]{task.description}"),
645
+ console=console,
646
+ transient=True
647
+ ) as progress:
648
+ progress.add_task("Scanning system...", total=None)
649
+ detector = SystemDetector()
650
+ snapshot = detector.detect_all()
651
+
652
+ # Generate config
653
+ vm_name = args.name or f"clone-{target_path.name}"
654
+ yaml_content = generate_clonebox_yaml(
655
+ snapshot, detector,
656
+ deduplicate=args.dedupe,
657
+ target_path=str(target_path),
658
+ vm_name=vm_name
659
+ )
660
+
661
+ # Save config file
662
+ config_file = target_path / CLONEBOX_CONFIG_FILE if target_path.is_dir() else target_path.parent / CLONEBOX_CONFIG_FILE
663
+ config_file.write_text(yaml_content)
664
+ console.print(f"[green]✅ Config saved: {config_file}[/]\n")
665
+
666
+ # Show config
667
+ console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="cyan"))
668
+
669
+ # Open in editor if requested
670
+ if args.edit:
671
+ editor = os.environ.get("EDITOR", "nano")
672
+ console.print(f"[cyan]Opening {editor}...[/]")
673
+ os.system(f"{editor} {config_file}")
674
+ # Reload after edit
675
+ yaml_content = config_file.read_text()
676
+
677
+ # Ask to create VM
678
+ if args.run:
679
+ create_now = True
680
+ else:
681
+ create_now = questionary.confirm(
682
+ "Create VM with this config?",
683
+ default=True,
684
+ style=custom_style
685
+ ).ask()
686
+
687
+ if create_now:
688
+ config = yaml.safe_load(yaml_content)
689
+ user_session = getattr(args, 'user', False)
690
+
691
+ console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
692
+ if user_session:
693
+ console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
694
+
695
+ try:
696
+ vm_uuid = create_vm_from_config(config, start=True, user_session=user_session)
697
+ console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
698
+ console.print(f"[dim]UUID: {vm_uuid}[/]")
699
+
700
+ # Show mount instructions
701
+ if config.get("paths"):
702
+ console.print("\n[bold]Inside VM, mount paths with:[/]")
703
+ for idx, (host, guest) in enumerate(config["paths"].items()):
704
+ console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
705
+ except PermissionError as e:
706
+ console.print(f"[red]❌ Permission Error:[/]\n{e}")
707
+ console.print(f"\n[yellow]💡 Try running with --user flag:[/]")
708
+ console.print(f" [cyan]clonebox clone {target_path} --user[/]")
709
+ except Exception as e:
710
+ console.print(f"[red]❌ Error: {e}[/]")
711
+ else:
712
+ console.print(f"\n[dim]To create VM later, run:[/]")
713
+ console.print(f" [cyan]clonebox start {target_path}[/]")
714
+
715
+
716
+ def cmd_detect(args):
717
+ """Detect and show system state."""
718
+ console.print("[bold cyan]🔍 Detecting system state...[/]\n")
719
+
720
+ detector = SystemDetector()
721
+ snapshot = detector.detect_all()
722
+
723
+ # JSON output
724
+ if args.json:
725
+ result = {
726
+ "services": [{"name": s.name, "status": s.status} for s in snapshot.running_services],
727
+ "applications": [{"name": a.name, "pid": a.pid, "cwd": a.working_dir} for a in snapshot.applications],
728
+ "paths": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in snapshot.paths],
729
+ }
730
+ print(json.dumps(result, indent=2))
731
+ return
732
+
733
+ # YAML output
734
+ if args.yaml:
735
+ result = generate_clonebox_yaml(snapshot, detector, deduplicate=args.dedupe)
736
+
737
+ if args.output:
738
+ output_path = Path(args.output)
739
+ output_path.write_text(result)
740
+ console.print(f"[green]✅ Config saved to: {output_path}[/]")
741
+ else:
742
+ print(result)
743
+ return
744
+
745
+ # Services
746
+ services = detector.detect_services()
747
+ running = [s for s in services if s.status == "running"]
748
+
749
+ if running:
750
+ table = Table(title="Running Services", border_style="green")
751
+ table.add_column("Service")
752
+ table.add_column("Status")
753
+ table.add_column("Enabled")
754
+
755
+ for svc in running:
756
+ table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
757
+
758
+ console.print(table)
759
+
760
+ # Applications
761
+ apps = detector.detect_applications()
762
+
763
+ if apps:
764
+ console.print()
765
+ table = Table(title="Running Applications", border_style="blue")
766
+ table.add_column("Name")
767
+ table.add_column("PID")
768
+ table.add_column("Memory")
769
+ table.add_column("Working Dir")
770
+
771
+ for app in apps[:15]:
772
+ table.add_row(
773
+ app.name,
774
+ str(app.pid),
775
+ f"{app.memory_mb:.0f} MB",
776
+ app.working_dir[:40] if app.working_dir else ""
777
+ )
778
+
779
+ console.print(table)
780
+
781
+ # Paths
782
+ paths = detector.detect_paths()
783
+
784
+ if paths:
785
+ console.print()
786
+ table = Table(title="Detected Paths", border_style="yellow")
787
+ table.add_column("Type")
788
+ table.add_column("Path")
789
+ table.add_column("Size")
790
+
791
+ for p in paths[:20]:
792
+ table.add_row(
793
+ f"[cyan]{p.type}[/]",
794
+ p.path,
795
+ f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
796
+ )
797
+
798
+ console.print(table)
799
+
800
+
801
+ def main():
802
+ """Main entry point."""
803
+ parser = argparse.ArgumentParser(
804
+ prog="clonebox",
805
+ description="Clone your workstation environment to an isolated VM"
806
+ )
807
+ parser.add_argument("--version", action="version", version=f"clonebox {__version__}")
808
+
809
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
810
+
811
+ # Interactive mode (default)
812
+ parser.set_defaults(func=lambda args: interactive_mode())
813
+
814
+ # Create command
815
+ create_parser = subparsers.add_parser("create", help="Create VM from config")
816
+ create_parser.add_argument("--name", "-n", default="clonebox-vm", help="VM name")
817
+ create_parser.add_argument("--config", "-c", required=True,
818
+ help='JSON config: {"paths": {}, "packages": [], "services": []}')
819
+ create_parser.add_argument("--ram", type=int, default=4096, help="RAM in MB")
820
+ create_parser.add_argument("--vcpus", type=int, default=4, help="Number of vCPUs")
821
+ create_parser.add_argument("--base-image", help="Path to base qcow2 image")
822
+ create_parser.add_argument("--no-gui", action="store_true", help="Disable SPICE graphics")
823
+ create_parser.add_argument("--start", "-s", action="store_true", help="Start VM after creation")
824
+ create_parser.set_defaults(func=cmd_create)
825
+
826
+ # Start command
827
+ start_parser = subparsers.add_parser("start", help="Start a VM")
828
+ start_parser.add_argument("name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml")
829
+ start_parser.add_argument("--no-viewer", action="store_true", help="Don't open virt-viewer")
830
+ start_parser.set_defaults(func=cmd_start)
831
+
832
+ # Stop command
833
+ stop_parser = subparsers.add_parser("stop", help="Stop a VM")
834
+ stop_parser.add_argument("name", help="VM name")
835
+ stop_parser.add_argument("--force", "-f", action="store_true", help="Force stop")
836
+ stop_parser.set_defaults(func=cmd_stop)
837
+
838
+ # Delete command
839
+ delete_parser = subparsers.add_parser("delete", help="Delete a VM")
840
+ delete_parser.add_argument("name", help="VM name")
841
+ delete_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
842
+ delete_parser.add_argument("--keep-storage", action="store_true", help="Keep disk images")
843
+ delete_parser.set_defaults(func=cmd_delete)
844
+
845
+ # List command
846
+ list_parser = subparsers.add_parser("list", aliases=["ls"], help="List VMs")
847
+ list_parser.set_defaults(func=cmd_list)
848
+
849
+ # Detect command
850
+ detect_parser = subparsers.add_parser("detect", help="Detect system state")
851
+ detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
852
+ detect_parser.add_argument("--yaml", action="store_true", help="Output as YAML config")
853
+ detect_parser.add_argument("--dedupe", action="store_true", help="Remove duplicate entries")
854
+ detect_parser.add_argument("-o", "--output", help="Save output to file")
855
+ detect_parser.set_defaults(func=cmd_detect)
856
+
857
+ # Clone command
858
+ clone_parser = subparsers.add_parser("clone", help="Generate clone config from path")
859
+ clone_parser.add_argument("path", nargs="?", default=".", help="Path to clone (default: current dir)")
860
+ clone_parser.add_argument("--name", "-n", help="VM name (default: directory name)")
861
+ clone_parser.add_argument("--run", "-r", action="store_true", help="Create and start VM immediately")
862
+ clone_parser.add_argument("--edit", "-e", action="store_true", help="Open config in editor before creating")
863
+ clone_parser.add_argument("--dedupe", action="store_true", default=True, help="Remove duplicate entries")
864
+ clone_parser.add_argument("--user", "-u", action="store_true",
865
+ help="Use user session (qemu:///session) - no root required, stores in ~/.local/share/libvirt/")
866
+ clone_parser.set_defaults(func=cmd_clone)
867
+
868
+ args = parser.parse_args()
869
+
870
+ if hasattr(args, "func"):
871
+ try:
872
+ args.func(args)
873
+ except KeyboardInterrupt:
874
+ console.print("\n[yellow]Interrupted.[/]")
875
+ sys.exit(1)
876
+ except Exception as e:
877
+ console.print(f"[red]Error: {e}[/]")
878
+ sys.exit(1)
879
+ else:
880
+ interactive_mode()
881
+
882
+
883
+ if __name__ == "__main__":
884
+ main()