diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 965cc25b..bab844c0 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -17,6 +17,7 @@ from openjarvis.cli.doctor_cmd import doctor from openjarvis.cli.init_cmd import init from openjarvis.cli.memory_cmd import memory from openjarvis.cli.model import model +from openjarvis.cli.quickstart_cmd import quickstart from openjarvis.cli.scheduler_cmd import scheduler from openjarvis.cli.serve import serve from openjarvis.cli.skill_cmd import skill @@ -52,6 +53,7 @@ cli.add_command(status, "status") cli.add_command(vault, "vault") cli.add_command(add, "add") cli.add_command(operators, "operators") +cli.add_command(quickstart, "quickstart") def main() -> None: diff --git a/src/openjarvis/cli/quickstart_cmd.py b/src/openjarvis/cli/quickstart_cmd.py new file mode 100644 index 00000000..936f4bd2 --- /dev/null +++ b/src/openjarvis/cli/quickstart_cmd.py @@ -0,0 +1,123 @@ +"""``jarvis quickstart`` — guided 5-step setup for new users.""" + +from __future__ import annotations + +import click +from rich.console import Console + +from openjarvis.core.config import ( + DEFAULT_CONFIG_DIR, + DEFAULT_CONFIG_PATH, + detect_hardware, + generate_default_toml, + recommend_engine, +) + + +def _check_engine_health(engine_key: str) -> bool: + """Return True if the recommended engine is reachable.""" + try: + from openjarvis.core.config import load_config + from openjarvis.core.registry import EngineRegistry + from openjarvis.engine import _discovery + + import openjarvis.engine # noqa: F401 — trigger registration + + config = load_config() + if engine_key not in EngineRegistry.keys(): + return False + engine = _discovery._make_engine(engine_key, config) + return engine.health() + except Exception: + return False + + +def _check_model_available(engine_key: str) -> bool: + """Return True if at least one model is available on the engine.""" + try: + from openjarvis.core.config import load_config + from openjarvis.core.registry import EngineRegistry + from openjarvis.engine import _discovery + + config = load_config() + if engine_key not in EngineRegistry.keys(): + return False + engine = _discovery._make_engine(engine_key, config) + return bool(engine.list_models()) + except Exception: + return False + + +def _test_query(engine_key: str) -> str: + """Run a quick test query and return the response text.""" + try: + from openjarvis import Jarvis + + j = Jarvis(engine_key=engine_key) + response = j.ask("Say hello in one sentence.") + j.close() + return response + except Exception as exc: + return f"(query failed: {exc})" + + +@click.command() +@click.option("--force", is_flag=True, help="Redo all steps even if already done.") +def quickstart(force: bool) -> None: + """Guided 5-step setup for new users.""" + console = Console() + + # Step 1: Detect hardware + console.print("[bold cyan][1/5][/bold cyan] Detecting hardware...") + hw = detect_hardware() + console.print(f" Platform : {hw.platform}") + console.print(f" CPU : {hw.cpu_brand} ({hw.cpu_count} cores)") + console.print(f" RAM : {hw.ram_gb} GB") + if hw.gpu: + console.print( + f" GPU : {hw.gpu.name} ({hw.gpu.vram_gb} GB VRAM, x{hw.gpu.count})" + ) + else: + console.print(" GPU : none detected") + + engine_key = recommend_engine(hw) + + # Step 2: Write config + console.print() + console.print("[bold cyan][2/5][/bold cyan] Writing config...") + if DEFAULT_CONFIG_PATH.exists() and not force: + console.print(f" [dim]Config already exists at {DEFAULT_CONFIG_PATH} (skip)[/dim]") + else: + toml_content = generate_default_toml(hw) + DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + DEFAULT_CONFIG_PATH.write_text(toml_content) + console.print(f" [green]Config written to {DEFAULT_CONFIG_PATH}[/green]") + + # Step 3: Check engine + console.print() + console.print(f"[bold cyan][3/5][/bold cyan] Checking engine ({engine_key})...") + if not _check_engine_health(engine_key): + console.print(f" [red bold]Engine '{engine_key}' is not reachable.[/red bold]") + console.print() + console.print(f" Start the {engine_key} server and try again.") + console.print(f" Run [bold]jarvis doctor[/bold] for detailed diagnostics.") + raise SystemExit(1) + console.print(f" [green]Engine '{engine_key}' is healthy.[/green]") + + # Step 4: Verify model + console.print() + console.print("[bold cyan][4/5][/bold cyan] Checking for available models...") + if not _check_model_available(engine_key): + console.print(" [yellow]No models found.[/yellow]") + console.print(" Pull a model first (e.g. [bold]ollama pull qwen3:8b[/bold]).") + raise SystemExit(1) + console.print(" [green]Models available.[/green]") + + # Step 5: Test query + console.print() + console.print("[bold cyan][5/5][/bold cyan] Running test query...") + response = _test_query(engine_key) + console.print(f" [green]Response:[/green] {response[:200]}") + + console.print() + console.print("[bold green]Setup complete![/bold green] Try: [bold]jarvis ask \"Hello\"[/bold]") diff --git a/tests/cli/test_quickstart.py b/tests/cli/test_quickstart.py new file mode 100644 index 00000000..3b1e5541 --- /dev/null +++ b/tests/cli/test_quickstart.py @@ -0,0 +1,119 @@ +"""Tests for ``jarvis quickstart`` command.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from openjarvis.cli import cli + + +class TestQuickstartCommand: + def test_registered(self): + """quickstart should be a registered CLI command.""" + runner = CliRunner() + result = runner.invoke(cli, ["quickstart", "--help"]) + assert result.exit_code == 0 + assert "quickstart" in result.output.lower() or "--help" in result.output + + def test_happy_path(self, tmp_path): + """Full quickstart succeeds when hardware detected and engine healthy.""" + config_path = tmp_path / "config.toml" + hw = MagicMock() + hw.platform = "linux" + hw.cpu_brand = "Test CPU" + hw.cpu_count = 8 + hw.ram_gb = 32 + hw.gpu = MagicMock(name="Test GPU", vram_gb=24, count=1, vendor="nvidia") + + with ( + patch("openjarvis.cli.quickstart_cmd.detect_hardware", return_value=hw), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path), + patch("openjarvis.cli.quickstart_cmd.generate_default_toml", return_value="[engine]\n"), + patch("openjarvis.cli.quickstart_cmd.recommend_engine", return_value="ollama"), + patch("openjarvis.cli.quickstart_cmd._check_engine_health", return_value=True), + patch("openjarvis.cli.quickstart_cmd._check_model_available", return_value=True), + patch("openjarvis.cli.quickstart_cmd._test_query", return_value="Hello!"), + ): + runner = CliRunner() + result = runner.invoke(cli, ["quickstart"]) + assert result.exit_code == 0 + assert "1/5" in result.output + assert "5/5" in result.output + + def test_skips_config_if_exists(self, tmp_path): + """Config step is skipped when config already exists.""" + config_path = tmp_path / "config.toml" + config_path.write_text("[engine]\n") + hw = MagicMock() + hw.platform = "linux" + hw.cpu_brand = "Test CPU" + hw.cpu_count = 8 + hw.ram_gb = 32 + hw.gpu = None + + with ( + patch("openjarvis.cli.quickstart_cmd.detect_hardware", return_value=hw), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path), + patch("openjarvis.cli.quickstart_cmd.generate_default_toml", return_value="[engine]\n"), + patch("openjarvis.cli.quickstart_cmd.recommend_engine", return_value="ollama"), + patch("openjarvis.cli.quickstart_cmd._check_engine_health", return_value=True), + patch("openjarvis.cli.quickstart_cmd._check_model_available", return_value=True), + patch("openjarvis.cli.quickstart_cmd._test_query", return_value="Hello!"), + ): + runner = CliRunner() + result = runner.invoke(cli, ["quickstart"]) + assert result.exit_code == 0 + assert "already exists" in result.output.lower() or "skip" in result.output.lower() + + def test_force_regenerates_config(self, tmp_path): + """--force should regenerate config even if it exists.""" + config_path = tmp_path / "config.toml" + config_path.write_text("[old]\n") + hw = MagicMock() + hw.platform = "linux" + hw.cpu_brand = "Test CPU" + hw.cpu_count = 8 + hw.ram_gb = 32 + hw.gpu = None + + with ( + patch("openjarvis.cli.quickstart_cmd.detect_hardware", return_value=hw), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path), + patch("openjarvis.cli.quickstart_cmd.generate_default_toml", return_value="[engine]\nnew = true\n"), + patch("openjarvis.cli.quickstart_cmd.recommend_engine", return_value="ollama"), + patch("openjarvis.cli.quickstart_cmd._check_engine_health", return_value=True), + patch("openjarvis.cli.quickstart_cmd._check_model_available", return_value=True), + patch("openjarvis.cli.quickstart_cmd._test_query", return_value="Hello!"), + ): + runner = CliRunner() + result = runner.invoke(cli, ["quickstart", "--force"]) + assert result.exit_code == 0 + assert "new = true" in config_path.read_text() + + def test_engine_not_found(self, tmp_path): + """Helpful message when engine is unreachable.""" + config_path = tmp_path / "config.toml" + hw = MagicMock() + hw.platform = "linux" + hw.cpu_brand = "Test CPU" + hw.cpu_count = 8 + hw.ram_gb = 32 + hw.gpu = None + + with ( + patch("openjarvis.cli.quickstart_cmd.detect_hardware", return_value=hw), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path), + patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path), + patch("openjarvis.cli.quickstart_cmd.generate_default_toml", return_value="[engine]\n"), + patch("openjarvis.cli.quickstart_cmd.recommend_engine", return_value="ollama"), + patch("openjarvis.cli.quickstart_cmd._check_engine_health", return_value=False), + ): + runner = CliRunner() + result = runner.invoke(cli, ["quickstart"]) + assert result.exit_code == 1 + assert "engine" in result.output.lower() or "not reachable" in result.output.lower()