clawrium 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clawrium/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Clawrium - CLI tool for managing AI assistant fleets."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CLI commands for Clawrium."""
clawrium/cli/host.py ADDED
@@ -0,0 +1,608 @@
1
+ """Host management commands for Clawrium."""
2
+
3
+ import getpass
4
+ import shlex
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+
8
+ import paramiko
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape as rich_escape
12
+ from rich.table import Table
13
+
14
+ from clawrium.core.hosts import (
15
+ add_host,
16
+ get_host,
17
+ get_host_by_key_id,
18
+ load_hosts,
19
+ remove_host,
20
+ update_host,
21
+ HostsFileCorruptedError,
22
+ )
23
+ from clawrium.core.keys import (
24
+ generate_host_keypair,
25
+ get_host_private_key,
26
+ read_public_key,
27
+ delete_host_keys,
28
+ InvalidKeyIdError,
29
+ )
30
+ from clawrium.core.ssh_connection import (
31
+ get_ssh_config,
32
+ test_ssh_connection,
33
+ accept_host_key,
34
+ HostKeyVerificationRequired,
35
+ )
36
+ from clawrium.core.hardware import gather_hardware
37
+
38
+ __all__ = ["host_app"]
39
+
40
+ console = Console()
41
+
42
+ host_app = typer.Typer(
43
+ name="host",
44
+ help="Manage hosts in your fleet",
45
+ no_args_is_help=True,
46
+ )
47
+
48
+
49
+ @host_app.command()
50
+ def init(
51
+ hostname: str = typer.Argument(..., help="Host IP or hostname to initialize"),
52
+ user: Optional[str] = typer.Option(
53
+ None,
54
+ "--user",
55
+ "-u",
56
+ help="SSH user for initial connection (default: current user)",
57
+ ),
58
+ ) -> None:
59
+ """Initialize a host for Clawrium management.
60
+
61
+ Generates a per-host SSH keypair and attempts to configure the xclm
62
+ management user on the remote host. If SSH access fails, displays
63
+ manual setup commands.
64
+ """
65
+ # Step 1: Generate keypair if not exists
66
+ private_key = get_host_private_key(hostname)
67
+ if private_key:
68
+ console.print(f"Using existing keypair for '{hostname}'")
69
+ else:
70
+ console.print(f"Generating SSH keypair for '{hostname}'...")
71
+ private_key_path, public_key_path = generate_host_keypair(hostname)
72
+ console.print(f"[green]Keypair created:[/green] {public_key_path}")
73
+ private_key = private_key_path
74
+
75
+ # Read the public key for display/setup
76
+ public_key_content = read_public_key(hostname)
77
+
78
+ # Step 2: Determine connection user
79
+ connection_user = user or getpass.getuser()
80
+
81
+ # Step 3: Try to connect to host
82
+ console.print(f"\nAttempting connection to {hostname} as {connection_user}...")
83
+
84
+ client = paramiko.SSHClient()
85
+ client.load_system_host_keys()
86
+ # Use RejectPolicy - we'll handle unknown hosts via HostKeyVerificationRequired
87
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
88
+
89
+ auto_setup_success = False
90
+ try:
91
+ # Try to connect with current user's default keys
92
+ client.connect(hostname=hostname, username=connection_user, timeout=10)
93
+
94
+ transport = client.get_transport()
95
+ if transport and transport.is_active():
96
+ console.print("[green]Connection successful![/green]")
97
+ console.print("Setting up xclm management user...")
98
+
99
+ # Execute setup commands (no shell injection - public key written via stdin)
100
+ setup_commands = [
101
+ ("sudo useradd -m -s /bin/bash xclm 2>/dev/null || true", None),
102
+ (
103
+ 'echo "xclm ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/xclm',
104
+ None,
105
+ ),
106
+ ("sudo chmod 440 /etc/sudoers.d/xclm", None),
107
+ ("sudo mkdir -p /home/xclm/.ssh", None),
108
+ ("sudo chmod 700 /home/xclm/.ssh", None),
109
+ (
110
+ "sudo tee /home/xclm/.ssh/authorized_keys",
111
+ public_key_content,
112
+ ), # Write via stdin
113
+ ("sudo chmod 600 /home/xclm/.ssh/authorized_keys", None),
114
+ ("sudo chown -R xclm:xclm /home/xclm/.ssh", None),
115
+ ]
116
+
117
+ for cmd, stdin_data in setup_commands:
118
+ stdin, stdout, stderr = client.exec_command(cmd)
119
+ if stdin_data:
120
+ stdin.write(stdin_data + "\n")
121
+ stdin.channel.shutdown_write()
122
+ # Drain both stdout and stderr before checking exit status to prevent buffer hangs (W4 fix)
123
+ stdout.read()
124
+ stderr_output = stderr.read().decode().strip()
125
+ exit_status = stdout.channel.recv_exit_status()
126
+ if exit_status != 0 and "useradd" not in cmd:
127
+ console.print(
128
+ f"[yellow]Warning:[/yellow] Setup step failed (exit {exit_status})"
129
+ )
130
+ if stderr_output:
131
+ # Escape stderr to prevent Rich markup injection (W2 fix)
132
+ console.print(f" {rich_escape(stderr_output)}")
133
+
134
+ # Verify xclm connection works
135
+ console.print("\nVerifying xclm access...")
136
+ success, message = test_ssh_connection(
137
+ hostname=hostname, port=22, user="xclm", key_filename=str(private_key)
138
+ )
139
+
140
+ if success:
141
+ console.print("[green]xclm user configured successfully![/green]")
142
+ console.print(f"\nNext step: [cyan]clm host add {hostname}[/cyan]")
143
+ auto_setup_success = True
144
+ else:
145
+ console.print(
146
+ f"[yellow]Warning:[/yellow] xclm verification failed: {message}"
147
+ )
148
+ console.print("You may need to complete setup manually.")
149
+
150
+ except HostKeyVerificationRequired as e:
151
+ console.print(f"\n[yellow]Unknown host key for {e.hostname}[/yellow]")
152
+ console.print(f" Key type: {e.key_type}")
153
+ console.print(f" Fingerprint: {e.fingerprint}")
154
+ console.print(
155
+ "\n[yellow]Warning:[/yellow] Verify this fingerprint matches the host's actual key."
156
+ )
157
+
158
+ if typer.confirm("\nAccept this host key and retry?"):
159
+ accept_host_key(hostname, 22, expected_fingerprint=e.fingerprint)
160
+ console.print("Host key saved. Please run 'clm host init' again.")
161
+ else:
162
+ console.print("Connection cancelled.")
163
+ raise typer.Exit(code=1)
164
+ except paramiko.SSHException as e:
165
+ # Handle unknown host key from RejectPolicy
166
+ if "not found in known_hosts" in str(e) or "Server" in str(e):
167
+ console.print(f"\n[yellow]Unknown host key for {hostname}[/yellow]")
168
+ console.print(
169
+ "Run 'ssh-keyscan' or connect manually first to add the host key."
170
+ )
171
+ else:
172
+ console.print(f"[yellow]SSH error:[/yellow] {e}")
173
+ except paramiko.AuthenticationException as e:
174
+ console.print(f"[yellow]Authentication failed:[/yellow] {e}")
175
+ except Exception as e:
176
+ console.print(f"[yellow]Could not connect:[/yellow] {e}")
177
+ finally:
178
+ client.close()
179
+
180
+ # Step 4: If auto-setup failed, show manual commands
181
+ if not auto_setup_success:
182
+ console.print("\n[yellow]Manual setup required.[/yellow]")
183
+ console.print("\nRun these commands on the target host:\n")
184
+ console.print("[dim]# Create xclm user[/dim]")
185
+ console.print("sudo useradd -m -s /bin/bash xclm")
186
+ console.print("")
187
+ console.print("[dim]# Grant passwordless sudo[/dim]")
188
+ console.print(
189
+ 'echo "xclm ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/xclm'
190
+ )
191
+ console.print("sudo chmod 440 /etc/sudoers.d/xclm")
192
+ console.print("")
193
+ console.print("[dim]# Setup SSH access[/dim]")
194
+ console.print("sudo mkdir -p /home/xclm/.ssh")
195
+ console.print("sudo chmod 700 /home/xclm/.ssh")
196
+ # Shell-escape public key to prevent injection and escape Rich markup
197
+ # Use soft_wrap=False to keep command on one line for easy copy-paste
198
+ escaped_key = shlex.quote(public_key_content) if public_key_content else "''"
199
+ console.print(
200
+ f"echo {rich_escape(escaped_key)} | sudo tee /home/xclm/.ssh/authorized_keys",
201
+ soft_wrap=False,
202
+ )
203
+ console.print("sudo chmod 600 /home/xclm/.ssh/authorized_keys")
204
+ console.print("sudo chown -R xclm:xclm /home/xclm/.ssh")
205
+ console.print("")
206
+ console.print(f"Then run: [cyan]clm host add {hostname}[/cyan]")
207
+ # Exit non-zero so scripts can detect failure (B2 fix)
208
+ raise typer.Exit(code=1)
209
+
210
+
211
+ @host_app.command()
212
+ def add(
213
+ hostname: str = typer.Argument(..., help="Host IP address or hostname"),
214
+ port: Optional[int] = typer.Option(
215
+ None, "--port", "-p", help="SSH port (default: 22)"
216
+ ),
217
+ user: Optional[str] = typer.Option(
218
+ None, "--user", "-u", help="SSH user (default: xclm)"
219
+ ),
220
+ alias: Optional[str] = typer.Option(
221
+ None, "--alias", "-a", help="Friendly name for this host"
222
+ ),
223
+ tags: Optional[str] = typer.Option(
224
+ None, "--tags", "-t", help="Comma-separated tags"
225
+ ),
226
+ ) -> None:
227
+ """Add a new host to the fleet.
228
+
229
+ Requires keypair to exist (run 'clm host init' first).
230
+ Tests SSH connection before saving. Detects hardware capabilities
231
+ automatically after successful connection.
232
+ """
233
+ # Determine key_id: Try hostname first, fall back to alias
234
+ # This ensures `clm host init 192.168.1.10` + `clm host add 192.168.1.10 --alias mybox` works
235
+ from clawrium.core.keys import validate_key_id
236
+
237
+ # Try hostname first (most common case: init and add use same identifier)
238
+ host_key = get_host_private_key(hostname)
239
+ key_lookup_id = hostname
240
+
241
+ # Fall back to alias if hostname key doesn't exist and alias is provided
242
+ if not host_key and alias:
243
+ host_key = get_host_private_key(alias)
244
+ key_lookup_id = alias
245
+
246
+ # Validate the resolved key_id to prevent path traversal
247
+ try:
248
+ validate_key_id(key_lookup_id)
249
+ except InvalidKeyIdError as e:
250
+ console.print(f"[red]Error:[/red] {e}")
251
+ raise typer.Exit(code=1)
252
+
253
+ # Check for per-host keypair (enforces init-first workflow)
254
+ if not host_key:
255
+ console.print(f"[red]Error:[/red] No keypair found for '{hostname}'")
256
+ if alias:
257
+ console.print(f" Also checked alias '{alias}'")
258
+ console.print(f"Run 'clm host init {hostname}' first to generate keys")
259
+ raise typer.Exit(code=1)
260
+
261
+ # Check for duplicate hostname, alias, or key_id
262
+ try:
263
+ existing = get_host(hostname)
264
+ if existing:
265
+ console.print(f"[red]Error:[/red] Host '{hostname}' already exists")
266
+ raise typer.Exit(code=1)
267
+
268
+ if alias:
269
+ existing_alias = get_host(alias)
270
+ if existing_alias:
271
+ console.print(f"[red]Error:[/red] Alias '{alias}' already in use")
272
+ raise typer.Exit(code=1)
273
+
274
+ # Check key_id uniqueness to prevent cross-host key collision
275
+ existing_key_id = get_host_by_key_id(key_lookup_id)
276
+ if existing_key_id:
277
+ console.print(
278
+ f"[red]Error:[/red] key_id '{key_lookup_id}' already in use by host '{existing_key_id.get('hostname')}'"
279
+ )
280
+ raise typer.Exit(code=1)
281
+ except HostsFileCorruptedError as e:
282
+ console.print(f"[red]Error:[/red] {e}")
283
+ raise typer.Exit(code=1)
284
+
285
+ # Load SSH config for hostname resolution
286
+ ssh_config = get_ssh_config(hostname)
287
+
288
+ # CLI flags override defaults
289
+ final_hostname = ssh_config.get(
290
+ "hostname", hostname
291
+ ) # Resolve HostName from SSH config
292
+ final_port = port if port is not None else int(ssh_config.get("port", 22))
293
+ final_user = user if user is not None else "xclm" # Always default to xclm
294
+ final_key = str(host_key) # Use per-host key
295
+
296
+ console.print(
297
+ f"Testing connection to {final_hostname}:{final_port} as {final_user}..."
298
+ )
299
+
300
+ # Test connection (per D-10)
301
+ try:
302
+ result = test_ssh_connection(
303
+ hostname=final_hostname,
304
+ port=final_port,
305
+ user=final_user,
306
+ key_filename=final_key,
307
+ )
308
+ success, message = result
309
+ except HostKeyVerificationRequired as e:
310
+ # TOFU: Show fingerprint and ask user to verify
311
+ console.print(f"\n[yellow]Unknown host key for {e.hostname}[/yellow]")
312
+ console.print(f" Key type: {e.key_type}")
313
+ console.print(f" Fingerprint: {e.fingerprint}")
314
+ console.print(
315
+ "\n[yellow]Warning:[/yellow] Verify this fingerprint matches the host's actual key."
316
+ )
317
+ console.print(
318
+ "If this is your first connection to this host, this is expected."
319
+ )
320
+
321
+ if not typer.confirm("\nAccept this host key and continue?"):
322
+ console.print("Connection cancelled.")
323
+ raise typer.Exit(code=1)
324
+
325
+ # Accept the host key with fingerprint verification
326
+ if not accept_host_key(
327
+ final_hostname, final_port, expected_fingerprint=e.fingerprint
328
+ ):
329
+ console.print(
330
+ "[red]Error:[/red] Failed to save host key (fingerprint may have changed)"
331
+ )
332
+ raise typer.Exit(code=1)
333
+
334
+ # Retry connection
335
+ result = test_ssh_connection(
336
+ hostname=final_hostname,
337
+ port=final_port,
338
+ user=final_user,
339
+ key_filename=final_key,
340
+ )
341
+ success, message = result
342
+
343
+ if not success:
344
+ console.print(f"[red]Connection failed:[/red] {message}")
345
+ raise typer.Exit(code=1)
346
+
347
+ console.print("[green]Connection successful![/green]")
348
+
349
+ # Detect hardware (per D-06)
350
+ console.print("Detecting hardware capabilities...")
351
+ try:
352
+ hardware = gather_hardware(
353
+ hostname=final_hostname, user=final_user, port=final_port, ssh_key=final_key
354
+ )
355
+ console.print(
356
+ f"[green]Hardware detected:[/green] {hardware['architecture']}, "
357
+ f"{hardware['processor_cores']} cores, "
358
+ f"{hardware['memtotal_mb']}MB RAM"
359
+ )
360
+ except Exception as e:
361
+ console.print(f"[yellow]Warning:[/yellow] Could not detect hardware: {e}")
362
+ hardware = {}
363
+
364
+ # Build host record (per D-04)
365
+ # Store resolved hostname for portability; keep original input as ssh_config_host
366
+ # for SSH config lookup. Do not store key_path for security - look up from SSH config.
367
+ now = datetime.now(timezone.utc).isoformat()
368
+
369
+ # Determine display alias
370
+ display_alias = alias or (hostname if hostname != final_hostname else None)
371
+
372
+ host = {
373
+ "hostname": final_hostname, # Resolved hostname for direct connections
374
+ "key_id": key_lookup_id, # Key storage identifier (alias, hostname, or generated name)
375
+ "port": final_port,
376
+ "user": final_user,
377
+ "auth_method": "key",
378
+ "hardware": hardware,
379
+ "metadata": {
380
+ "added_at": now,
381
+ "last_seen": now,
382
+ "tags": [t.strip() for t in tags.split(",")] if tags else [],
383
+ },
384
+ }
385
+ # Only add optional fields if they have values (avoid null pollution)
386
+ if hostname != final_hostname:
387
+ host["ssh_config_host"] = hostname
388
+ if display_alias:
389
+ host["alias"] = display_alias
390
+
391
+ add_host(host)
392
+ console.print(
393
+ f"[green]Host '{display_alias or hostname}' added successfully![/green]"
394
+ )
395
+
396
+
397
+ @host_app.command(name="list")
398
+ def list_hosts() -> None:
399
+ """List all registered hosts."""
400
+ try:
401
+ hosts = load_hosts()
402
+ except HostsFileCorruptedError as e:
403
+ console.print(f"[red]Error:[/red] {e}")
404
+ raise typer.Exit(code=1)
405
+
406
+ if not hosts:
407
+ console.print("No hosts registered. Use 'clm host add' to add a host.")
408
+ return
409
+
410
+ table = Table(title="Registered Hosts")
411
+
412
+ table.add_column("Alias", style="cyan")
413
+ table.add_column("Host", style="white")
414
+ table.add_column("Architecture", style="yellow")
415
+ table.add_column("Cores", justify="right")
416
+ table.add_column("Memory (GB)", justify="right")
417
+ table.add_column("Tags", style="dim")
418
+
419
+ for host in hosts:
420
+ hw = host.get("hardware", {})
421
+ meta = host.get("metadata", {})
422
+
423
+ # Format memory as GB with 1 decimal
424
+ mem_gb = (
425
+ round(hw.get("memtotal_mb", 0) / 1024, 1) if hw.get("memtotal_mb") else "-"
426
+ )
427
+
428
+ table.add_row(
429
+ host.get("alias") or "-",
430
+ host["hostname"],
431
+ hw.get("architecture", "?"),
432
+ str(hw.get("processor_cores", "?")),
433
+ str(mem_gb),
434
+ ", ".join(meta.get("tags", [])) or "-",
435
+ )
436
+
437
+ console.print(table)
438
+
439
+
440
+ @host_app.command()
441
+ def remove(
442
+ hostname: str = typer.Argument(..., help="Host hostname or alias to remove"),
443
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
444
+ ) -> None:
445
+ """Remove a host from the fleet.
446
+
447
+ Prompts for confirmation unless --force is specified.
448
+ """
449
+ # Find host by hostname or alias
450
+ try:
451
+ host = get_host(hostname)
452
+ except HostsFileCorruptedError as e:
453
+ console.print(f"[red]Error:[/red] {e}")
454
+ raise typer.Exit(code=1)
455
+
456
+ if not host:
457
+ console.print(f"[red]Error:[/red] Host '{hostname}' not found")
458
+ raise typer.Exit(code=1)
459
+
460
+ display_name = host.get("alias") or host["hostname"]
461
+
462
+ # Confirmation (per D-18)
463
+ if not force:
464
+ confirmed = typer.confirm(
465
+ f"Remove host '{display_name}'? This cannot be undone."
466
+ )
467
+ if not confirmed:
468
+ console.print("Cancelled.")
469
+ raise typer.Exit(code=0) # Clean exit on user cancel, not error
470
+
471
+ # Remove by actual hostname
472
+ success = remove_host(host["hostname"])
473
+ if success:
474
+ # Also delete per-host keys using key_id
475
+ key_id = host.get("key_id") or host["hostname"] # Fallback for old records
476
+ keys_deleted = delete_host_keys(key_id)
477
+ console.print(f"[green]Host '{display_name}' removed successfully.[/green]")
478
+ if keys_deleted:
479
+ console.print(f"[dim]Keypair for '{key_id}' deleted.[/dim]")
480
+ else:
481
+ console.print("[red]Error:[/red] Failed to remove host")
482
+ raise typer.Exit(code=1)
483
+
484
+
485
+ @host_app.command()
486
+ def status(
487
+ hostname: str = typer.Argument(..., help="Host hostname or alias to check"),
488
+ refresh: bool = typer.Option(
489
+ False, "--refresh", "-r", help="Re-detect hardware capabilities"
490
+ ),
491
+ ) -> None:
492
+ """Check status of a host.
493
+
494
+ Shows connection status, hostname verification, and last seen time.
495
+ Use --refresh to update hardware information.
496
+ """
497
+ # Find host
498
+ try:
499
+ host = get_host(hostname)
500
+ except HostsFileCorruptedError as e:
501
+ console.print(f"[red]Error:[/red] {e}")
502
+ raise typer.Exit(code=1)
503
+
504
+ if not host:
505
+ console.print(f"[red]Error:[/red] Host '{hostname}' not found")
506
+ raise typer.Exit(code=1)
507
+
508
+ display_name = host.get("alias") or host["hostname"]
509
+ console.print(f"Checking status of '{display_name}'...")
510
+
511
+ # Get per-host key using key_id
512
+ key_id = host.get("key_id") or host["hostname"] # Fallback for old records
513
+ host_key = get_host_private_key(key_id)
514
+ if host_key is None:
515
+ console.print(f"[red]Error:[/red] No keypair found for '{key_id}'")
516
+ console.print(f"Run 'clm host init {key_id}' to regenerate keys")
517
+ raise typer.Exit(code=1)
518
+ ssh_key = str(host_key)
519
+
520
+ # Test connection
521
+ try:
522
+ result = test_ssh_connection(
523
+ hostname=host["hostname"],
524
+ port=host.get("port", 22),
525
+ user=host.get("user", "xclm"),
526
+ key_filename=ssh_key,
527
+ )
528
+ success, message = result
529
+ except HostKeyVerificationRequired:
530
+ success = False
531
+ message = "Host key verification required"
532
+ console.print(
533
+ f"[yellow]Note:[/yellow] Run 'clm host remove {hostname} && clm host add {host['hostname']}' to re-verify the host key."
534
+ )
535
+
536
+ # Refresh hardware BEFORE building table if requested (per D-06)
537
+ hw = host.get("hardware", {})
538
+ if refresh and success:
539
+ console.print("Refreshing hardware information...")
540
+ try:
541
+ hw = gather_hardware(
542
+ hostname=host["hostname"],
543
+ user=host.get("user", "xclm"),
544
+ port=host.get("port", 22),
545
+ ssh_key=ssh_key,
546
+ )
547
+
548
+ # Update host record atomically to prevent TOCTOU races (B3 fix)
549
+ def apply_hardware_update(h: dict) -> dict:
550
+ h["hardware"] = hw
551
+ h["metadata"]["last_seen"] = datetime.now(timezone.utc).isoformat()
552
+ return h
553
+
554
+ try:
555
+ if update_host(host["hostname"], apply_hardware_update):
556
+ console.print("[green]Hardware information updated.[/green]\n")
557
+ else:
558
+ console.print("[yellow]Warning:[/yellow] Host not found during update\n")
559
+ except Exception as e:
560
+ console.print(f"[red]Error saving host data:[/red] {e}")
561
+ raise typer.Exit(code=1)
562
+ except typer.Exit:
563
+ raise
564
+ except Exception as e:
565
+ console.print(
566
+ f"[yellow]Warning:[/yellow] Could not refresh hardware: {e}\n"
567
+ )
568
+ elif refresh and not success:
569
+ console.print(
570
+ "[yellow]Cannot refresh hardware: host is not connected[/yellow]\n"
571
+ )
572
+
573
+ # Display status table
574
+ table = Table(title=f"Host Status: {display_name}")
575
+ table.add_column("Property", style="cyan")
576
+ table.add_column("Value")
577
+
578
+ if success:
579
+ table.add_row("Connection", "[green]Connected[/green]")
580
+ else:
581
+ table.add_row("Connection", f"[red]Disconnected[/red] ({message})")
582
+
583
+ table.add_row("Hostname", host["hostname"])
584
+ if host.get("ssh_config_host"):
585
+ table.add_row("SSH Config", host["ssh_config_host"])
586
+ table.add_row("Port", str(host.get("port", 22)))
587
+ table.add_row("User", host.get("user", "xclm"))
588
+
589
+ meta = host.get("metadata", {})
590
+ table.add_row("Added", meta.get("added_at", "Unknown"))
591
+ table.add_row("Last Seen", meta.get("last_seen", "Unknown"))
592
+ table.add_row("Tags", ", ".join(meta.get("tags", [])) or "-")
593
+
594
+ if hw:
595
+ table.add_row("Architecture", hw.get("architecture", "?"))
596
+ table.add_row("CPU Cores", str(hw.get("processor_cores", "?")))
597
+ table.add_row("Memory", f"{round(hw.get('memtotal_mb', 0) / 1024, 1)} GB")
598
+ gpu = hw.get("gpu", {})
599
+ if gpu.get("present"):
600
+ table.add_row("GPU", gpu.get("vendor") or "Unknown")
601
+ else:
602
+ table.add_row("GPU", "None detected")
603
+
604
+ console.print(table)
605
+
606
+ # Exit 1 if host is disconnected (for scripting)
607
+ if not success:
608
+ raise typer.Exit(code=1)
clawrium/cli/init.py ADDED
@@ -0,0 +1,58 @@
1
+ """Init command for Clawrium."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from clawrium.core.config import init_config_dir
8
+ from clawrium.core.deps import check_all_dependencies
9
+
10
+ __all__ = ["init"]
11
+
12
+ console = Console()
13
+
14
+
15
+ def init() -> None:
16
+ """Initialize Clawrium configuration directory and check dependencies.
17
+
18
+ Creates the configuration directory at ~/.config/clawrium/
19
+ (or XDG_CONFIG_HOME/clawrium/ if set) and verifies that all
20
+ required dependencies are available.
21
+
22
+ Exits with code 1 if any dependency is missing.
23
+ """
24
+ # Create config directory
25
+ config_dir = init_config_dir()
26
+ console.print("[green]Clawrium initialized![/green]")
27
+ console.print(f"Config directory: {config_dir}")
28
+ console.print()
29
+
30
+ # Check dependencies
31
+ deps = check_all_dependencies()
32
+
33
+ table = Table(title="Dependency Status")
34
+ table.add_column("Dependency", style="cyan")
35
+ table.add_column("Status")
36
+ table.add_column("Version/Path")
37
+ table.add_column("Action Required")
38
+
39
+ all_found = True
40
+ for dep in deps:
41
+ if dep.found:
42
+ status = "[green]OK[/green]"
43
+ else:
44
+ status = "[red]MISSING[/red]"
45
+ all_found = False
46
+
47
+ version_or_path = dep.version or dep.path or "-"
48
+ action = dep.install_hint if not dep.found else "-"
49
+ table.add_row(dep.name, status, version_or_path, action)
50
+
51
+ console.print(table)
52
+
53
+ if not all_found:
54
+ console.print()
55
+ console.print(
56
+ "[yellow]Some dependencies are missing. Please install them before continuing.[/yellow]"
57
+ )
58
+ raise typer.Exit(code=1)