Spaces:
Running on Zero
Running on Zero
GitHub Actions
Quality improvements: Unicode chars, Token class, imports, type hints, formatting
3f78ea8 | """HearthNet CLI β `hearthnet` command.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import os | |
| import sys | |
| import urllib.parse | |
| import zipfile | |
| from pathlib import Path | |
| import click | |
| # --------------------------------------------------------------------------- | |
| # HTTP helpers | |
| # --------------------------------------------------------------------------- | |
| _ALLOWED_SCHEMES = {"http", "https"} | |
| _ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"} | |
| def _validate_local_url(url: str) -> None: | |
| """Raise ValueError if the URL is not a local node URL (security boundary).""" | |
| parsed = urllib.parse.urlparse(url) | |
| if parsed.scheme not in _ALLOWED_SCHEMES: | |
| raise ValueError(f"URL scheme must be http/https, got: {parsed.scheme!r}") | |
| host = parsed.hostname or "" | |
| if host not in _ALLOWED_HOSTS: | |
| raise ValueError( | |
| f"CLI only connects to local node. Got host: {host!r}. " | |
| "Use --base-url http://localhost:<port> to override." | |
| ) | |
| def _http_get(url: str) -> dict: | |
| _validate_local_url(url) | |
| try: | |
| import httpx | |
| resp = httpx.get(url, timeout=5) | |
| resp.raise_for_status() | |
| return resp.json() | |
| except ImportError: | |
| import urllib.error | |
| import urllib.request | |
| try: | |
| with urllib.request.urlopen(url, timeout=5) as r: # nosec B310 - URL validated to http/https local host | |
| return json.loads(r.read().decode()) | |
| except urllib.error.URLError as exc: | |
| raise ConnectionError(str(exc)) from exc | |
| except Exception as exc: | |
| msg = str(exc).lower() | |
| if any(kw in msg for kw in ("connect", "refused", "unreachable", "network")): | |
| raise ConnectionError(str(exc)) from exc | |
| raise | |
| def _http_post(url: str, body: str) -> dict: | |
| _validate_local_url(url) | |
| try: | |
| import httpx | |
| resp = httpx.post( | |
| url, content=body, headers={"Content-Type": "application/json"}, timeout=30 | |
| ) | |
| resp.raise_for_status() | |
| return resp.json() | |
| except ImportError: | |
| import urllib.error | |
| import urllib.request | |
| req = urllib.request.Request( | |
| url, | |
| data=body.encode(), | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=30) as r: # nosec B310 - URL validated to http/https local host | |
| return json.loads(r.read().decode()) | |
| except urllib.error.URLError as exc: | |
| raise ConnectionError(str(exc)) from exc | |
| except Exception as exc: | |
| msg = str(exc).lower() | |
| if any(kw in msg for kw in ("connect", "refused", "unreachable", "network")): | |
| raise ConnectionError(str(exc)) from exc | |
| raise | |
| # --------------------------------------------------------------------------- | |
| # CLI group | |
| # --------------------------------------------------------------------------- | |
| def main(ctx: click.Context, config_path: str | None) -> None: | |
| """HearthNet β community-owned local AI mesh.""" | |
| ctx.ensure_object(dict) | |
| ctx.obj["config_path"] = Path(config_path) if config_path else None | |
| # --------------------------------------------------------------------------- | |
| # init | |
| # --------------------------------------------------------------------------- | |
| def init(name: str | None, profile: str, non_interactive: bool) -> None: | |
| """Bootstrap a new HearthNet node. Generates keypair, writes config.""" | |
| config_dir = Path.home() / ".hearthnet" | |
| config_dir.mkdir(parents=True, exist_ok=True) | |
| keys_dir = config_dir / "keys" | |
| keys_dir.mkdir(parents=True, exist_ok=True) | |
| if not name and not non_interactive: | |
| name = click.prompt("Node display name", default=f"HearthNode-{os.urandom(2).hex()}") | |
| elif not name: | |
| name = f"HearthNode-{os.urandom(2).hex()}" | |
| try: | |
| from hearthnet.identity import load_or_generate | |
| kp = load_or_generate(keys_dir) | |
| click.echo(f"Node ID : {kp.node_id_full}") | |
| click.echo(f"Short ID : {kp.node_id_short}") | |
| except Exception as exc: | |
| click.echo(f"Warning: could not generate keypair ({exc}). Skipping.", err=True) | |
| config_file = config_dir / "config.toml" | |
| if not config_file.exists(): | |
| config_file.write_text( | |
| f'[node]\nname = "{name}"\nprofile = "{profile}"\n\n[identity]\nkeys_dir = "{keys_dir}"\n' | |
| ) | |
| click.echo(f"Config written to {config_file}") | |
| else: | |
| click.echo(f"Config already exists at {config_file} β not overwritten.") | |
| # --------------------------------------------------------------------------- | |
| # run | |
| # --------------------------------------------------------------------------- | |
| def run(ctx: click.Context, no_ui: bool, debug: bool) -> None: | |
| """Start the HearthNet node.""" | |
| if debug: | |
| import logging | |
| logging.basicConfig(level=logging.DEBUG) | |
| click.echo("HearthNet node startingβ¦") | |
| if not no_ui: | |
| try: | |
| from app import demo # type: ignore[import] | |
| demo.launch() | |
| except Exception as exc: | |
| click.echo(f"Could not start Gradio UI: {exc}", err=True) | |
| click.echo("Try `hearthnet run --no-ui` to start without UI.") | |
| sys.exit(1) | |
| else: | |
| click.echo("Running in headless mode. Press Ctrl+C to stop.") | |
| try: | |
| asyncio.run(_headless()) | |
| except KeyboardInterrupt: | |
| click.echo("Shutting down.") | |
| async def _headless() -> None: | |
| while True: | |
| await asyncio.sleep(3600) | |
| # --------------------------------------------------------------------------- | |
| # status | |
| # --------------------------------------------------------------------------- | |
| def status(ctx: click.Context, as_json: bool, host: str, port: int) -> None: | |
| """Show node status (requires a running node).""" | |
| url = f"http://{host}:{port}/health" | |
| try: | |
| data = _http_get(url) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| if as_json: | |
| click.echo(json.dumps(data, indent=2)) | |
| else: | |
| click.echo(f"Status : {data.get('status', 'unknown')}") | |
| click.echo(f"Node ID : {data.get('node_id', 'N/A')}") | |
| click.echo(f"Version : {data.get('version', 'N/A')}") | |
| extras = {k: v for k, v in data.items() if k not in ("status", "node_id", "version")} | |
| for k, v in extras.items(): | |
| click.echo(f"{k:<10}: {v}") | |
| # --------------------------------------------------------------------------- | |
| # caps | |
| # --------------------------------------------------------------------------- | |
| def caps( | |
| remote_only: bool, | |
| local_only: bool, | |
| name_pattern: str | None, | |
| host: str, | |
| port: int, | |
| ) -> None: | |
| """List capability entries.""" | |
| url = f"http://{host}:{port}/bus/v1/capabilities" | |
| try: | |
| data = _http_get(url) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| entries = data if isinstance(data, list) else data.get("capabilities", []) | |
| if remote_only: | |
| entries = [e for e in entries if not e.get("local", False)] | |
| elif local_only: | |
| entries = [e for e in entries if e.get("local", False)] | |
| if name_pattern: | |
| entries = [e for e in entries if name_pattern.lower() in e.get("name", "").lower()] | |
| if not entries: | |
| click.echo("No capabilities found.") | |
| return | |
| click.echo(f"{'NAME':<30} {'VERSION':<10} {'STABILITY':<12} {'LOCAL'}") | |
| click.echo("-" * 60) | |
| for entry in entries: | |
| click.echo( | |
| f"{entry.get('name', '?'):<30} " | |
| f"{entry.get('version', '?'):<10} " | |
| f"{entry.get('stability', '?'):<12} " | |
| f"{'yes' if entry.get('local') else 'no'}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # call | |
| # --------------------------------------------------------------------------- | |
| def call(capability: str, body: str, stream: bool, host: str, port: int) -> None: | |
| """Make a one-shot capability call.""" | |
| # Validate body is valid JSON before sending | |
| try: | |
| json.loads(body) | |
| except json.JSONDecodeError as exc: | |
| click.echo(f"Invalid JSON body: {exc}", err=True) | |
| sys.exit(1) | |
| url = f"http://{host}:{port}/bus/v1/call" | |
| payload = json.dumps({"capability": capability, "body": json.loads(body)}) | |
| try: | |
| result = _http_post(url, payload) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| click.echo(json.dumps(result, indent=2)) | |
| # --------------------------------------------------------------------------- | |
| # doctor | |
| # --------------------------------------------------------------------------- | |
| def doctor(check: str | None) -> None: | |
| """Run self-diagnostics.""" | |
| try: | |
| from hearthnet.observability.doctor import run_all, run_one | |
| results = [run_one(check)] if check else run_all() | |
| all_passed = all(r.passed for r in results) | |
| for r in results: | |
| icon = "β" if r.passed else "β" | |
| click.echo(f" {icon} {r.check.name:<25} {r.message}") | |
| if not r.passed and r.check.fix_hint: | |
| click.echo(f" β fix: {r.check.fix_hint}") | |
| sys.exit(0 if all_passed else 1) | |
| except Exception as exc: | |
| click.echo(f"doctor crashed: {exc}", err=True) | |
| sys.exit(2) | |
| # --------------------------------------------------------------------------- | |
| # trace | |
| # --------------------------------------------------------------------------- | |
| def trace(n: int, capability: str | None, host: str, port: int) -> None: | |
| """Show recent call traces.""" | |
| url = f"http://{host}:{port}/trace/recent?n={n}" | |
| if capability: | |
| url += f"&capability={capability}" | |
| try: | |
| data = _http_get(url) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| entries = data if isinstance(data, list) else data.get("traces", []) | |
| if not entries: | |
| click.echo("No traces found.") | |
| return | |
| for entry in entries: | |
| ts = entry.get("ts", "?") | |
| cap = entry.get("capability", "?") | |
| dur = entry.get("duration_ms", "?") | |
| ok = "OK" if entry.get("success", True) else "ERR" | |
| click.echo(f" [{ts}] {cap:<30} {dur:>6}ms {ok}") | |
| # --------------------------------------------------------------------------- | |
| # export | |
| # --------------------------------------------------------------------------- | |
| def export(out: str | None) -> None: | |
| """Export all local data (GDPR right-to-export).""" | |
| config_dir = Path.home() / ".hearthnet" | |
| out_path = Path(out) if out else Path.cwd() / "hearthnet-export.zip" | |
| try: | |
| with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: | |
| if config_dir.exists(): | |
| for item in config_dir.rglob("*"): | |
| # Skip private key material | |
| if item.suffix in (".key", ".pem") or item.name.startswith("signing"): | |
| continue | |
| if item.is_file(): | |
| zf.write(item, item.relative_to(config_dir.parent)) | |
| # Add a manifest of what was exported | |
| manifest = { | |
| "export_version": 1, | |
| "exported_from": str(config_dir), | |
| "contains": "node config, identity (public parts only)", | |
| } | |
| zf.writestr("EXPORT_MANIFEST.json", json.dumps(manifest, indent=2)) | |
| click.echo(f"Exported to {out_path}") | |
| except Exception as exc: | |
| click.echo(f"Export failed: {exc}", err=True) | |
| sys.exit(1) | |
| # --------------------------------------------------------------------------- | |
| # log (Β§3.6) | |
| # --------------------------------------------------------------------------- | |
| def log(follow: bool, level: str, component: str | None, host: str, port: int) -> None: | |
| """Stream or display recent structured log entries.""" | |
| url = f"http://{host}:{port}/trace/recent?n=100" | |
| try: | |
| data = _http_get(url) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| entries = data if isinstance(data, list) else data.get("traces", []) | |
| for entry in entries: | |
| if component and entry.get("component", "") != component: | |
| continue | |
| entry_level = entry.get("level", "INFO").upper() | |
| if ["DEBUG", "INFO", "WARNING", "ERROR"].index(entry_level) < [ | |
| "DEBUG", | |
| "INFO", | |
| "WARNING", | |
| "ERROR", | |
| ].index(level): | |
| continue | |
| ts = entry.get("ts", "?") | |
| msg = entry.get("message") or entry.get("capability") or json.dumps(entry) | |
| click.echo(f"[{ts}] {entry_level:7s} {msg}") | |
| if follow: | |
| click.echo("(follow mode: reconnect not implemented β use --no-follow for snapshot)") | |
| # --------------------------------------------------------------------------- | |
| # erase (Β§3.10) | |
| # --------------------------------------------------------------------------- | |
| def erase(keep_keys: bool, yes: bool) -> None: | |
| """Erase all local HearthNet data. | |
| Exit codes: 0 erased, 2 aborted. | |
| """ | |
| config_dir = Path.home() / ".hearthnet" | |
| if not yes: | |
| click.confirm( | |
| f"This will delete {config_dir} {'(keeping keys)' if keep_keys else ''}. Continue?", | |
| abort=True, | |
| ) | |
| import shutil | |
| if not config_dir.exists(): | |
| click.echo("Nothing to erase.") | |
| return | |
| if keep_keys: | |
| key_file = config_dir / "identity.key" | |
| key_backup = None | |
| if key_file.exists(): | |
| import tempfile | |
| key_backup = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".key").name) | |
| import shutil as _sh | |
| _sh.copy2(key_file, key_backup) | |
| shutil.rmtree(config_dir) | |
| if key_backup and key_backup.exists(): | |
| config_dir.mkdir(parents=True, exist_ok=True) | |
| _sh.move(str(key_backup), key_file) | |
| click.echo("Data erased (keys preserved).") | |
| else: | |
| shutil.rmtree(config_dir) | |
| click.echo("All HearthNet data erased.") | |
| # --------------------------------------------------------------------------- | |
| # rag subgroup (Β§3.11) | |
| # --------------------------------------------------------------------------- | |
| def rag() -> None: | |
| """RAG corpus management.""" | |
| def rag_list(host: str, port: int) -> None: | |
| """List available RAG corpora.""" | |
| try: | |
| result = _bus_call(host, port, "rag.list_corpora", (1, 0), {}) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| corpora = result.get("output", result).get("corpora", []) | |
| if not corpora: | |
| click.echo("No corpora.") | |
| return | |
| for c in corpora: | |
| name = c.get("name", c) if isinstance(c, dict) else c | |
| count = c.get("doc_count", "?") if isinstance(c, dict) else "?" | |
| click.echo(f" {name:<30} docs={count}") | |
| def rag_ingest(path: str, corpus: str, host: str, port: int) -> None: | |
| """Ingest a file or directory into a RAG corpus.""" | |
| p = Path(path) | |
| files: list[Path] = list(p.rglob("*")) if p.is_dir() else [p] | |
| ingested = 0 | |
| for f in files: | |
| if not f.is_file(): | |
| continue | |
| data_b64 = __import__("base64").b64encode(f.read_bytes()).decode() | |
| try: | |
| result = _bus_call( | |
| host, | |
| port, | |
| "rag.ingest", | |
| (1, 0), | |
| {"input": {"corpus": corpus, "filename": f.name, "data_b64": data_b64}}, | |
| ) | |
| err = result.get("error") | |
| if err: | |
| click.echo(f" SKIP {f.name}: {err}") | |
| else: | |
| ingested += 1 | |
| click.echo(f" OK {f.name}") | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| click.echo(f"Ingested {ingested} file(s) into corpus '{corpus}'.") | |
| def rag_reindex(corpus: str, embedding_model: str | None, host: str, port: int) -> None: | |
| """Rebuild the vector index for a corpus.""" | |
| body: dict = {"input": {"corpus": corpus}} | |
| if embedding_model: | |
| body["input"]["embedding_model"] = embedding_model | |
| try: | |
| result = _bus_call(host, port, "rag.reindex", (1, 0), body) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| err = result.get("error") | |
| if err: | |
| click.echo(f"Reindex failed: {err}", err=True) | |
| sys.exit(1) | |
| out = result.get("output", result) | |
| click.echo(f"Reindexed corpus '{corpus}': {out.get('doc_count', '?')} docs.") | |
| # --------------------------------------------------------------------------- | |
| # invite subgroup (Β§3.12) | |
| # --------------------------------------------------------------------------- | |
| def invite() -> None: | |
| """Community invite management.""" | |
| def invite_create(node_id: str, level: str, ttl: int, host: str, port: int) -> None: | |
| """Create an invite link for a new member.""" | |
| try: | |
| result = _bus_call( | |
| host, | |
| port, | |
| "community.invite", | |
| (1, 0), | |
| {"input": {"invitee_node_id": node_id, "initial_level": level, "ttl_seconds": ttl}}, | |
| ) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| err = result.get("error") | |
| if err: | |
| click.echo(f"Invite failed: {err}", err=True) | |
| sys.exit(1) | |
| out = result.get("output", result) | |
| click.echo(out.get("invite_url") or json.dumps(out, indent=2)) | |
| def invite_redeem(text_or_path: str, host: str, port: int) -> None: | |
| """Redeem a hearthnet:// invite link (file path or URL).""" | |
| p = Path(text_or_path) | |
| invite_text = p.read_text().strip() if p.exists() else text_or_path.strip() | |
| try: | |
| result = _bus_call( | |
| host, port, "community.redeem", (1, 0), {"input": {"invite_text": invite_text}} | |
| ) | |
| except ConnectionError: | |
| click.echo(f"Node not reachable at {host}:{port}") | |
| sys.exit(3) | |
| err = result.get("error") | |
| if err: | |
| click.echo(f"Redeem failed: {err}", err=True) | |
| sys.exit(1) | |
| out = result.get("output", result) | |
| click.echo(f"Joined community: {out.get('community_name', out)}") | |
| # --------------------------------------------------------------------------- | |
| # version (Β§3.13) | |
| # --------------------------------------------------------------------------- | |
| def version_cmd() -> None: | |
| """Print HearthNet version and exit.""" | |
| try: | |
| from importlib.metadata import version as _v | |
| ver = _v("hearthnet") | |
| except Exception: | |
| try: | |
| from hearthnet import __version__ as ver # type: ignore[attr-defined] | |
| except Exception: | |
| ver = "dev" | |
| click.echo(f"hearthnet {ver}") | |
| # --------------------------------------------------------------------------- | |
| # config subgroup β Configuration management | |
| # --------------------------------------------------------------------------- | |
| def config() -> None: | |
| """Configuration management.""" | |
| def config_show() -> None: | |
| """Display current HearthNet configuration.""" | |
| try: | |
| from build.shared.first_run import get_config_file, load_config | |
| config = load_config() | |
| config_file = get_config_file() | |
| click.echo("π HearthNet Configuration") | |
| click.echo(f"Location: {config_file}") | |
| click.echo("") | |
| for key, value in config.items(): | |
| value_str = ("β Yes" if value else "β No") if isinstance(value, bool) else str(value) | |
| click.echo(f" {key:<20} : {value_str}") | |
| except Exception as exc: | |
| click.echo(f"β Failed to load config: {exc}", err=True) | |
| sys.exit(1) | |
| def config_set(key: str, value: str) -> None: | |
| """Update a configuration value.""" | |
| try: | |
| from build.shared.first_run import load_config, save_config | |
| config = load_config() | |
| # Type conversion | |
| if value.lower() in ("true", "yes", "1"): | |
| config[key] = True | |
| elif value.lower() in ("false", "no", "0"): | |
| config[key] = False | |
| elif value.isdigit(): | |
| config[key] = int(value) | |
| else: | |
| config[key] = value | |
| if save_config(config): | |
| click.echo(f"β Config updated: {key} = {config[key]}") | |
| else: | |
| sys.exit(1) | |
| except Exception as exc: | |
| click.echo(f"β Failed to update config: {exc}", err=True) | |
| sys.exit(1) | |
| # --------------------------------------------------------------------------- | |
| # model subgroup β LLM Model management | |
| # --------------------------------------------------------------------------- | |
| def model() -> None: | |
| """LLM model management.""" | |
| def model_download(model_id: str, cache: str | None) -> None: | |
| """Download and cache an LLM model from HuggingFace Hub.""" | |
| try: | |
| from build.shared.download_model import download_model, get_model_path, is_model_cached | |
| if is_model_cached(model_id): | |
| click.echo(f"β Model already cached: {get_model_path(model_id)}") | |
| return | |
| click.echo(f"π₯ Downloading model: {model_id}") | |
| click.echo(" (This may take several minutes depending on model size)") | |
| success = download_model(model_id, destination=Path(cache) if cache else None) | |
| if success: | |
| model_path = get_model_path(model_id) | |
| click.echo(f"β Model downloaded and cached at: {model_path}") | |
| else: | |
| click.echo("β Failed to download model", err=True) | |
| sys.exit(1) | |
| except Exception as exc: | |
| click.echo(f"β Error: {exc}", err=True) | |
| sys.exit(1) | |
| def model_list() -> None: | |
| """List cached models.""" | |
| try: | |
| from build.shared.download_model import get_model_cache_dir | |
| cache_dir = get_model_cache_dir() | |
| if not cache_dir.exists() or not list(cache_dir.iterdir()): | |
| click.echo("π¦ No cached models found.") | |
| click.echo(f" Cache location: {cache_dir}") | |
| return | |
| click.echo("π¦ Cached Models:") | |
| click.echo("") | |
| for model_dir in sorted(cache_dir.iterdir()): | |
| if not model_dir.is_dir(): | |
| continue | |
| size_mb = sum(f.stat().st_size for f in model_dir.rglob("*") if f.is_file()) / ( | |
| 1024 * 1024 | |
| ) | |
| file_count = len(list(model_dir.rglob("*"))) | |
| click.echo(f" π {model_dir.name}") | |
| click.echo(f" Size: {size_mb:.1f} MB Files: {file_count}") | |
| except Exception as exc: | |
| click.echo(f"β Error: {exc}", err=True) | |
| sys.exit(1) | |
| def model_info(model_id: str) -> None: | |
| """Get information about a model.""" | |
| try: | |
| from build.shared.download_model import get_model_info | |
| info = get_model_info(model_id) | |
| click.echo(f"π Model Information: {model_id}") | |
| click.echo("") | |
| for key, value in info.items(): | |
| if key == "size_mb": | |
| click.echo(f" Size: {value:.1f} MB") | |
| elif key == "cached": | |
| cached_str = "β Yes" if value else "β No" | |
| click.echo(f" Cached: {cached_str}") | |
| elif key == "path" and value: | |
| click.echo(f" Path: {value}") | |
| elif key not in ("model_id",): | |
| click.echo(f" {key}: {value}") | |
| except Exception as exc: | |
| click.echo(f"β Error: {exc}", err=True) | |
| sys.exit(1) | |
| # --------------------------------------------------------------------------- | |
| # doctor enhancement β Added model and backend checks | |
| # --------------------------------------------------------------------------- | |
| def health(detailed: bool) -> None: | |
| """Quick health check of HearthNet installation.""" | |
| checks_passed = 0 | |
| checks_failed = 0 | |
| # 1. Python version | |
| import sys | |
| py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" | |
| click.echo(f"β Python: {py_version}") | |
| checks_passed += 1 | |
| # 2. Key dependencies | |
| deps = ["click", "gradio", "transformers", "torch", "fastapi"] | |
| for dep in deps: | |
| try: | |
| __import__(dep) | |
| click.echo(f"β {dep}: installed") | |
| checks_passed += 1 | |
| except ImportError: | |
| click.echo(f"β {dep}: NOT installed") | |
| checks_failed += 1 | |
| # 3. Model cache | |
| try: | |
| from build.shared.download_model import get_model_cache_dir, is_model_cached | |
| from build.shared.first_run import load_config | |
| config = load_config() | |
| model_id = config.get("model_id", "HuggingFaceTB/SmolLM2-135M-Instruct") | |
| if is_model_cached(model_id): | |
| click.echo(f"β Model: {model_id} (cached)") | |
| checks_passed += 1 | |
| else: | |
| click.echo(f"β οΈ Model: {model_id} (not cached, will download on first run)") | |
| if detailed: | |
| cache_dir = get_model_cache_dir() | |
| click.echo(f" Cache location: {cache_dir}") | |
| except Exception: | |
| click.echo("β οΈ Model: could not verify") | |
| # 4. GPU support | |
| try: | |
| import torch | |
| has_gpu = torch.cuda.is_available() | |
| if has_gpu: | |
| gpu_name = torch.cuda.get_device_name(0) | |
| click.echo(f"β GPU: {gpu_name}") | |
| checks_passed += 1 | |
| else: | |
| click.echo("i GPU: not available (CPU mode)") | |
| except Exception: | |
| click.echo("i GPU: could not detect") | |
| # Summary | |
| click.echo("") | |
| total = checks_passed + checks_failed | |
| if checks_failed == 0: | |
| click.echo(f"β All checks passed ({checks_passed}/{total})") | |
| sys.exit(0) | |
| else: | |
| click.echo(f"β {checks_failed} check(s) failed ({checks_passed}/{total} passed)") | |
| sys.exit(1) | |
| # --------------------------------------------------------------------------- | |
| # _bus_call helper (used by several commands above) | |
| # --------------------------------------------------------------------------- | |
| def _bus_call(host: str, port: int, capability: str, version: tuple, body: dict) -> dict: | |
| """POST to /bus/v1/call and return parsed JSON. Raises ConnectionError on failure.""" | |
| payload = { | |
| "capability": capability, | |
| "version": f"{version[0]}.{version[1]}", | |
| **body, | |
| } | |
| return _http_post(f"http://{host}:{port}/bus/v1/call", json.dumps(payload)) | |