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 +3 -0
- clawrium/cli/__init__.py +1 -0
- clawrium/cli/host.py +608 -0
- clawrium/cli/init.py +58 -0
- clawrium/cli/install.py +175 -0
- clawrium/cli/main.py +67 -0
- clawrium/cli/registry.py +108 -0
- clawrium/cli/secret.py +165 -0
- clawrium/cli/status.py +128 -0
- clawrium/core/__init__.py +1 -0
- clawrium/core/config.py +47 -0
- clawrium/core/deps.py +138 -0
- clawrium/core/hardware.py +250 -0
- clawrium/core/health.py +215 -0
- clawrium/core/hosts.py +275 -0
- clawrium/core/install.py +303 -0
- clawrium/core/keys.py +229 -0
- clawrium/core/names.py +125 -0
- clawrium/core/registry.py +373 -0
- clawrium/core/secrets.py +304 -0
- clawrium/core/ssh_connection.py +246 -0
- clawrium/platform/__init__.py +1 -0
- clawrium/platform/playbooks/base.yaml +45 -0
- clawrium/platform/registry/__init__.py +1 -0
- clawrium/platform/registry/openclaw/manifest.yaml +32 -0
- clawrium/platform/registry/openclaw/playbooks/install.yaml +31 -0
- clawrium/platform/registry/zeroclaw/__init__.py +1 -0
- clawrium/platform/registry/zeroclaw/manifest.yaml +58 -0
- clawrium/platform/registry/zeroclaw/playbooks/install.yaml +65 -0
- clawrium-0.1.0.dist-info/METADATA +94 -0
- clawrium-0.1.0.dist-info/RECORD +34 -0
- clawrium-0.1.0.dist-info/WHEEL +4 -0
- clawrium-0.1.0.dist-info/entry_points.txt +2 -0
- clawrium-0.1.0.dist-info/licenses/LICENSE +201 -0
clawrium/__init__.py
ADDED
clawrium/cli/__init__.py
ADDED
|
@@ -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)
|