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