From 04c28ef7ed7e89a32b82cc01cb7275fa57284a7e Mon Sep 17 00:00:00 2001 From: geodab Date: Mon, 23 Mar 2026 04:38:23 -0400 Subject: [PATCH] feat: add CLI commands: config, registry, tool (#98) * feat: add CLI commands: config, registry, tool * fix: address review feedback on CLI config/registry/tool commands - Replace non-existent `create_config_template` with `generate_default_toml` to fix ImportError when config file is missing - Deduplicate registry maps into shared `_load_registry_map()` helper - Remove dead `_get_registry_class()` function and its tests - Route JSON output to stdout for pipeability (`jarvis config show json | jq`) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- src/openjarvis/cli/__init__.py | 6 + src/openjarvis/cli/config_cmd.py | 277 +++++++++++++++++++++++++++++ src/openjarvis/cli/registry_cmd.py | 146 +++++++++++++++ src/openjarvis/cli/tool_cmd.py | 107 +++++++++++ tests/cli/test_config_cmd.py | 210 ++++++++++++++++++++++ tests/cli/test_registry_cmd.py | 154 ++++++++++++++++ tests/cli/test_tool_cmd.py | 238 +++++++++++++++++++++++++ 7 files changed, 1138 insertions(+) create mode 100644 src/openjarvis/cli/config_cmd.py create mode 100644 src/openjarvis/cli/registry_cmd.py create mode 100644 src/openjarvis/cli/tool_cmd.py create mode 100644 tests/cli/test_config_cmd.py create mode 100644 tests/cli/test_registry_cmd.py create mode 100644 tests/cli/test_tool_cmd.py diff --git a/src/openjarvis/cli/__init__.py b/src/openjarvis/cli/__init__.py index 678ff1fa..10a70a94 100644 --- a/src/openjarvis/cli/__init__.py +++ b/src/openjarvis/cli/__init__.py @@ -12,6 +12,7 @@ from openjarvis.cli.bench_cmd import bench from openjarvis.cli.channel_cmd import channel from openjarvis.cli.chat_cmd import chat from openjarvis.cli.compose_cmd import compose +from openjarvis.cli.config_cmd import config from openjarvis.cli.daemon_cmd import restart, start, status, stop from openjarvis.cli.doctor_cmd import doctor from openjarvis.cli.eval_cmd import eval_group @@ -24,10 +25,12 @@ from openjarvis.cli.model import model from openjarvis.cli.operators_cmd import operators from openjarvis.cli.optimize_cmd import optimize_group from openjarvis.cli.quickstart_cmd import quickstart +from openjarvis.cli.registry_cmd import registry from openjarvis.cli.scheduler_cmd import scheduler from openjarvis.cli.serve import serve from openjarvis.cli.skill_cmd import skill from openjarvis.cli.telemetry_cmd import telemetry +from openjarvis.cli.tool_cmd import tool from openjarvis.cli.vault_cmd import vault from openjarvis.cli.workflow_cmd import workflow @@ -81,6 +84,9 @@ cli.add_command(optimize_group, "optimize") cli.add_command(feedback_group, "feedback") cli.add_command(compose, "compose") cli.add_command(gateway, "gateway") +cli.add_command(tool, "tool") +cli.add_command(registry, "registry") +cli.add_command(config, "config") def main() -> None: diff --git a/src/openjarvis/cli/config_cmd.py b/src/openjarvis/cli/config_cmd.py new file mode 100644 index 00000000..324a348a --- /dev/null +++ b/src/openjarvis/cli/config_cmd.py @@ -0,0 +1,277 @@ +"""``jarvis config`` — configuration inspection commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel +from rich.syntax import Syntax +from rich.table import Table + + +@click.group() +def config() -> None: + """Inspect configuration — show loaded settings, hardware, and config files.""" + + +def _get_config_path(path: str | None) -> Path: + """Determine the config path from argument or environment.""" + from openjarvis.core.config import DEFAULT_CONFIG_PATH + + if path: + return Path(path) + return Path(os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_PATH)) + + +def _show_hardware_info(console: Console, show_recommendations: bool = True) -> None: + """Display detected hardware information.""" + from openjarvis.core.config import detect_hardware, recommend_engine + + hardware = detect_hardware() + + console.print("\n[bold]Detected Hardware[/bold]") + + hardware_table = Table(show_header=True, header_style="cyan") + hardware_table.add_column("Component", style="green") + hardware_table.add_column("Value", style="white") + + # Platform + hardware_table.add_row("Platform", str(hardware.platform)) + + # CPU + if hardware.cpu_brand: + hardware_table.add_row("CPU", hardware.cpu_brand) + hardware_table.add_row("CPU Count", str(hardware.cpu_count)) + hardware_table.add_row("RAM", f"{hardware.ram_gb:.1f} GB") + + # GPU + if hardware.gpu: + hardware_table.add_row("GPU Vendor", hardware.gpu.vendor) + hardware_table.add_row("GPU Model", hardware.gpu.name) + hardware_table.add_row("GPU VRAM", f"{hardware.gpu.vram_gb:.1f} GB") + hardware_table.add_row("GPU Count", str(hardware.gpu.count)) + + console.print(hardware_table) + + # Show recommended engine + if show_recommendations: + recommended = recommend_engine(hardware) + console.print(f"\n[bold]Recommended Engine:[/bold] [cyan]{recommended}[/cyan]") + + +def _show_config_template(console: Console, config_path: Path) -> None: + """Show default config template when config file doesn't exist.""" + from openjarvis.core.config import ( + DEFAULT_CONFIG_DIR, + detect_hardware, + generate_default_toml, + ) + + console.print(f"[yellow]Config file not found: {config_path}[/yellow]") + config_location = str(DEFAULT_CONFIG_DIR / "config.toml") + msg = f"Create one at {config_location} or use --path to specify a location." + console.print(f"[dim]{msg}[/dim]") + + console.print("\n[bold]Default Configuration Template:[/bold]") + hw = detect_hardware() + template = generate_default_toml(hw) + syntax = Syntax(template, "toml", theme="monokai", line_numbers=True) + console.print(Panel(syntax, border_style="dim")) + + +def _show_loaded_config(console: Console, config_path: Path, as_json: bool) -> None: + """Show the loaded effective configuration from config.toml.""" + from openjarvis.core.config import load_config + + console.print(f"[dim]Loading config from: {config_path}[/dim]") + + if config_path.exists(): + config = load_config(config_path) + + if as_json: + # Convert the dataclass to a dict and output as JSON + from dataclasses import fields, is_dataclass + + def convert(obj): + if obj is None: + return None + if isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, (list, tuple)): + return [convert(item) for item in obj] + if isinstance(obj, dict): + return {k: convert(v) for k, v in obj.items()} + if is_dataclass(obj): + result = {} + for field in fields(obj): + field_value = getattr(obj, field.name) + if field_value is not None: + result[field.name] = convert(field_value) + return result + # Fallback for other types + return str(obj) + + config_dict = convert(config) + # Write JSON to stdout so it is pipeable + stdout_console = Console() + stdout_console.print_json(json.dumps(config_dict, indent=2, default=str)) + else: + # Show as formatted table + console.print("[bold]Engine Configuration[/bold]") + console.print(f" Default Engine: [cyan]{config.engine.default}[/cyan]") + + console.print("\n[bold]Intelligence Configuration[/bold]") + console.print( + f" Default Model: [cyan]{config.intelligence.default_model}[/cyan]" + ) + fallback = config.intelligence.fallback_model or "N/A" + console.print(f" Fallback Model: [cyan]{fallback}[/cyan]") + console.print( + f" Temperature: [cyan]{config.intelligence.temperature}[/cyan]" + ) + console.print( + f" Max Tokens: [cyan]{config.intelligence.max_tokens}[/cyan]" + ) + + console.print("\n[bold]Agent Configuration[/bold]") + console.print(f" Default Agent: [cyan]{config.agent.default_agent}[/cyan]") + console.print(f" Max Turns: [cyan]{config.agent.max_turns}[/cyan]") + console.print(f" Tools: [cyan]{config.agent.tools or 'none'}[/cyan]") + ctx_mem = config.agent.context_from_memory + console.print(f" Context from Memory: [cyan]{ctx_mem}[/cyan]") + + # Show hardware info + _show_hardware_info(console) + + else: + _show_config_template(console, config_path) + + +def _show_toml_config(console: Console, config_path: Path) -> None: + """Show the raw TOML configuration file content with syntax highlighting.""" + console.print(f"[dim]Loading config from: {config_path}[/dim]") + + if config_path.exists(): + config_content = config_path.read_text() + syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True) + console.print(Panel(syntax, title="Config File", border_style="cyan")) + else: + _show_config_template(console, config_path) + + +def _show_json_config(console: Console, config_path: Path) -> None: + """Show the parsed TOML configuration as JSON.""" + console.print(f"[dim]Loading config from: {config_path}[/dim]") + + if config_path.exists(): + config_content = config_path.read_text() + + try: + import tomllib # Python 3.11+ + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + config_dict = tomllib.loads(config_content) + # Write JSON to stdout so it is pipeable + stdout_console = Console() + stdout_console.print_json(json.dumps(config_dict, indent=2)) + else: + _show_config_template(console, config_path) + + +def _show_hardware(console: Console) -> None: + """Show detected hardware information with recommended engine and model.""" + from openjarvis.core.config import ( + detect_hardware, + recommend_engine, + recommend_model, + ) + + hardware = detect_hardware() + _show_hardware_info(console, show_recommendations=False) + + # Show recommended engine + recommended_engine = recommend_engine(hardware) + console.print( + f"\n[bold]Recommended Engine:[/bold] [cyan]{recommended_engine}[/cyan]" + ) + + # Show recommended model + console.print("\n[bold]Model Recommendations[/bold]") + recommended_model = recommend_model(hardware, recommended_engine) + console.print(f" Recommended Model: [cyan]{recommended_model}[/cyan]") + + +# Nested group for show sub-commands +@click.group(invoke_without_command=True) +@click.option("--path", "-p", default=None, help="Explicit config file path") +@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON") +@click.pass_context +def show_group(ctx: click.Context, path: str | None, as_json: bool) -> None: + """Show configuration details.""" + # Default to 'loaded' if no subcommand is invoked + if ctx.invoked_subcommand is None: + console = Console(stderr=True) + try: + config_path = _get_config_path(path) + _show_loaded_config(console, config_path, as_json) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +@show_group.command() +@click.option("--path", "-p", default=None, help="Explicit config file path") +@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON") +def loaded(path: str | None, as_json: bool) -> None: + """Show the loaded effective configuration from config.toml.""" + console = Console(stderr=True) + try: + config_path = _get_config_path(path) + _show_loaded_config(console, config_path, as_json) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +@show_group.command() +@click.option("--path", "-p", default=None, help="Explicit config file path") +def toml(path: str | None) -> None: + """Show the raw TOML configuration file content with syntax highlighting.""" + console = Console(stderr=True) + try: + config_path = _get_config_path(path) + _show_toml_config(console, config_path) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +@show_group.command("json") +@click.option("--path", "-p", default=None, help="Explicit config file path") +def as_json(path: str | None) -> None: + """Show the parsed TOML configuration as JSON.""" + console = Console(stderr=True) + try: + config_path = _get_config_path(path) + _show_json_config(console, config_path) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +@show_group.command() +def hardware() -> None: + """Show detected hardware information with recommended engine and model.""" + console = Console(stderr=True) + try: + _show_hardware(console) + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +# Register the show group under config +config.add_command(show_group, "show") + + +__all__ = ["config"] diff --git a/src/openjarvis/cli/registry_cmd.py b/src/openjarvis/cli/registry_cmd.py new file mode 100644 index 00000000..9558ea77 --- /dev/null +++ b/src/openjarvis/cli/registry_cmd.py @@ -0,0 +1,146 @@ +"""``jarvis registry`` — registry inspection commands.""" + +from __future__ import annotations + +import click +from rich.console import Console +from rich.table import Table + + +def _load_registry_map() -> tuple[dict[str, object], dict[str, object]]: + """Import all registries and return (by_name, aliases) lookup dicts.""" + from openjarvis.core.registry import ( + AgentRegistry, + BenchmarkRegistry, + ChannelRegistry, + CompressionRegistry, + EngineRegistry, + LearningRegistry, + MemoryRegistry, + ModelRegistry, + RouterPolicyRegistry, + SkillRegistry, + SpeechRegistry, + ToolRegistry, + ) + + by_name = { + "ToolRegistry": ToolRegistry, + "AgentRegistry": AgentRegistry, + "EngineRegistry": EngineRegistry, + "MemoryRegistry": MemoryRegistry, + "ModelRegistry": ModelRegistry, + "ChannelRegistry": ChannelRegistry, + "LearningRegistry": LearningRegistry, + "SkillRegistry": SkillRegistry, + "BenchmarkRegistry": BenchmarkRegistry, + "RouterPolicyRegistry": RouterPolicyRegistry, + "SpeechRegistry": SpeechRegistry, + "CompressionRegistry": CompressionRegistry, + } + + aliases: dict[str, object] = {} + _alias_map = { + "ToolRegistry": ("tool", "tools"), + "AgentRegistry": ("agent", "agents"), + "EngineRegistry": ("engine", "engines"), + "MemoryRegistry": ("memory", "memories"), + "ModelRegistry": ("model", "models"), + "ChannelRegistry": ("channel", "channels"), + "LearningRegistry": ("learning", "learnings"), + "SkillRegistry": ("skill", "skills"), + "BenchmarkRegistry": ("benchmark", "benchmarks"), + "RouterPolicyRegistry": ("router", "routers"), + "SpeechRegistry": ("speech", "speeches"), + "CompressionRegistry": ("compression", "compressions"), + } + for class_name, cls in by_name.items(): + aliases[class_name] = cls + for alias in _alias_map[class_name]: + aliases[alias] = cls + + return by_name, aliases + + +@click.group() +def registry() -> None: + """Inspect registered components — list registries, show entries.""" + + +@registry.command("list") +def list_registries() -> None: + """List all available registries.""" + console = Console(stderr=True) + + table = Table(title="Available Registries") + table.add_column("Registry", style="cyan") + table.add_column("Module", style="green") + table.add_column("Entry Count", style="yellow") + + try: + by_name, _ = _load_registry_map() + except Exception as exc: + console.print(f"[red]Error loading registries: {exc}[/red]") + return + + module_path = "openjarvis.core.registry" + for reg_name, registry_cls in by_name.items(): + try: + count = len(registry_cls.keys()) + table.add_row(reg_name, module_path, str(count)) + except Exception as exc: + table.add_row(reg_name, module_path, f"[red]Error: {exc}[/red]") + + console.print(table) + + +@registry.command() +@click.argument("registry_name") +@click.option( + "--verbose", "-v", is_flag=True, default=False, help="Show full entry details" +) +def show(registry_name: str, verbose: bool) -> None: + """Show entries in a specific registry.""" + console = Console(stderr=True) + + try: + _, aliases = _load_registry_map() + + registry_cls = aliases.get(registry_name) + if registry_cls is None: + console.print(f"[red]Unknown registry: {registry_name}[/red]") + console.print( + "[dim]Run 'jarvis registry list' to see available registries.[/dim]" + ) + return + + keys = registry_cls.keys() + if not keys: + console.print(f"[dim]{registry_name} is empty.[/dim]") + return + + console.print(f"[bold]{registry_name}[/bold] — {len(keys)} entry/entries") + + if verbose: + for key in keys: + entry = registry_cls.get(key) + console.print(f"\n [cyan]{key}[/cyan]") + console.print(f" Type: {type(entry).__name__}") + console.print(f" Value: {entry}") + else: + table = Table() + table.add_column("Key", style="cyan") + table.add_column("Type", style="green") + table.add_column("Value", style="white", max_width=80) + for key in keys: + entry = registry_cls.get(key) + entry_type = type(entry).__name__ + entry_value = str(entry) + table.add_row(key, entry_type, entry_value) + console.print(table) + + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +__all__ = ["registry"] diff --git a/src/openjarvis/cli/tool_cmd.py b/src/openjarvis/cli/tool_cmd.py new file mode 100644 index 00000000..80960b38 --- /dev/null +++ b/src/openjarvis/cli/tool_cmd.py @@ -0,0 +1,107 @@ +"""``jarvis tool`` — tool management commands.""" + +from __future__ import annotations + +import click +from rich.console import Console +from rich.table import Table + + +@click.group() +def tool() -> None: + """Manage tools — list, inspect.""" + + +@tool.command("list") +def list_tools() -> None: + """List all registered tools with their descriptions.""" + console = Console(stderr=True) + try: + # Trigger tool registration by importing the tools module + import openjarvis.tools # noqa: F401 + from openjarvis.core.registry import ToolRegistry + + keys = sorted(ToolRegistry.keys()) + if not keys: + console.print("[dim]No tools registered.[/dim]") + return + + table = Table(title="Registered Tools") + table.add_column("Name", style="cyan") + table.add_column("Description", style="green", max_width=60) + table.add_column("Category", style="yellow") + + for key in keys: + tool_cls = ToolRegistry.get(key) + description = "" + category = "" + + # Try to get spec from the class or an instance + try: + # Some tools may require initialization, so try with default init + tool_instance = tool_cls() if callable(tool_cls) else tool_cls + if hasattr(tool_instance, "spec"): + spec = tool_instance.spec + description = getattr(spec, "description", "")[:60] + category = getattr(spec, "category", "") + except Exception: + # If instantiation fails, just show the key + description = "N/A (instantiation error)" + + table.add_row(key, description, category) + + console.print(table) + console.print(f"\n[dim]Total: {len(keys)} tool(s)[/dim]") + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +@tool.command() +@click.argument("tool_name") +def inspect(tool_name: str) -> None: + """Show detailed information about a specific tool.""" + console = Console(stderr=True) + try: + # Trigger tool registration by importing the tools module + import openjarvis.tools # noqa: F401 + from openjarvis.core.registry import ToolRegistry + + if not ToolRegistry.contains(tool_name): + console.print(f"[red]Tool not found: {tool_name}[/red]") + console.print("[dim]Run 'jarvis tool list' to see available tools.[/dim]") + return + + tool_cls = ToolRegistry.get(tool_name) + console.print(f"[bold]{tool_name}[/bold]") + + try: + tool_instance = tool_cls() if callable(tool_cls) else tool_cls + if hasattr(tool_instance, "spec"): + spec = tool_instance.spec + console.print(f" [cyan]Name:[/cyan] {getattr(spec, 'name', 'N/A')}") + console.print( + f" [cyan]Description:[/cyan] {getattr(spec, 'description', 'N/A')}" + ) + category = getattr(spec, "category", "N/A") or "none" + console.print(f" [cyan]Category:[/cyan] {category}") + + params = getattr(spec, "parameters", {}) + if params: + console.print(" [cyan]Parameters:[/cyan]") + if isinstance(params, dict) and "properties" in params: + for param_name, param_info in params["properties"].items(): + param_type = param_info.get("type", "any") + param_desc = param_info.get("description", "") + console.print( + f" • {param_name}: {param_type} — {param_desc}" + ) + else: + console.print(f" {params}") + except Exception as e: + console.print(f"[yellow]Note: Could not instantiate tool: {e}[/yellow]") + + except Exception as exc: + console.print(f"[red]Error: {exc}[/red]") + + +__all__ = ["tool"] diff --git a/tests/cli/test_config_cmd.py b/tests/cli/test_config_cmd.py new file mode 100644 index 00000000..d2e9d249 --- /dev/null +++ b/tests/cli/test_config_cmd.py @@ -0,0 +1,210 @@ +"""Tests for the ``jarvis config`` CLI commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from openjarvis.cli import cli + + +class TestConfigCmd: + """Test cases for the jarvis config CLI group.""" + + def test_config_group_help(self) -> None: + """Test that the config group help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "--help"]) + assert result.exit_code == 0 + assert "config" in result.output.lower() + + def test_config_show_help(self) -> None: + """Test that the config show help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "show", "--help"]) + assert result.exit_code == 0 + assert "show" in result.output.lower() + + def test_config_show_loaded_help(self) -> None: + """Test that the config show loaded help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "show", "loaded", "--help"]) + assert result.exit_code == 0 + assert "loaded" in result.output.lower() + + def test_config_show_toml_help(self) -> None: + """Test that the config show toml help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "show", "toml", "--help"]) + assert result.exit_code == 0 + assert "toml" in result.output.lower() + + def test_config_show_json_help(self) -> None: + """Test that the config show json help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "show", "json", "--help"]) + assert result.exit_code == 0 + assert "json" in result.output.lower() + + def test_config_show_hardware_help(self) -> None: + """Test that the config show hardware help displays correctly.""" + result = CliRunner().invoke(cli, ["config", "show", "hardware", "--help"]) + assert result.exit_code == 0 + assert "hardware" in result.output.lower() + + def test_config_show_loaded_displays_config(self, tmp_path: Path) -> None: + """Test that config show loaded displays the configuration.""" + # Create a temporary config file + config_file = tmp_path / "test_config.toml" + config_file.write_text( + """ +[engine] +default = "ollama" + +[intelligence] +default_model = "test-model" +temperature = 0.7 +max_tokens = 1024 + +[agent] +default_agent = "simple" +max_turns = 5 +""" + ) + + result = CliRunner().invoke( + cli, ["config", "show", "loaded", "--path", str(config_file)] + ) + + assert result.exit_code == 0 + assert "ollama" in result.output + assert "test-model" in result.output + + def test_config_show_loaded_json_output(self, tmp_path: Path) -> None: + """Test that config show loaded --json outputs valid JSON.""" + # Create a temporary config file + config_file = tmp_path / "test_config.toml" + config_file.write_text( + """ +[engine] +default = "ollama" + +[intelligence] +default_model = "test-model" +temperature = 0.7 +""" + ) + + result = CliRunner().invoke( + cli, ["config", "show", "loaded", "--path", str(config_file), "--json"] + ) + + assert result.exit_code == 0 + # Try to parse the output as JSON (may have prefix lines) + # Find the JSON part by looking for the opening brace + json_start = result.output.find("{") + assert json_start >= 0, f"Could not find JSON in output: {result.output}" + try: + json_data = json.loads(result.output[json_start:]) + assert "engine" in json_data + assert json_data["engine"]["default"] == "ollama" + except json.JSONDecodeError: + pytest.fail(f"Output is not valid JSON: {result.output}") + + def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None: + """Test that config show toml displays the raw TOML content.""" + # Create a temporary config file + config_file = tmp_path / "test_config.toml" + config_file.write_text('[engine]\ndefault = "ollama"\n') + + result = CliRunner().invoke( + cli, ["config", "show", "toml", "--path", str(config_file)] + ) + + assert result.exit_code == 0 + assert "[engine]" in result.output + assert "ollama" in result.output + + def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None: + """Test that config show json displays parsed TOML as JSON.""" + # Create a temporary config file + config_file = tmp_path / "test_config.toml" + config_file.write_text('[engine]\ndefault = "ollama"\n') + + result = CliRunner().invoke( + cli, ["config", "show", "json", "--path", str(config_file)] + ) + + assert result.exit_code == 0 + # The output should be valid JSON (may have prefix lines) + json_start = result.output.find("{") + assert json_start >= 0, f"Could not find JSON in output: {result.output}" + try: + json_data = json.loads(result.output[json_start:]) + assert json_data["engine"]["default"] == "ollama" + except json.JSONDecodeError: + pytest.fail(f"Output is not valid JSON: {result.output}") + + def test_config_show_hardware_displays_info(self) -> None: + """Test that config show hardware displays hardware information.""" + result = CliRunner().invoke(cli, ["config", "show", "hardware"]) + assert result.exit_code == 0 + # Should display hardware-related content + output = result.output.lower() + assert "hardware" in output or "cpu" in output or "ram" in output + + def test_config_show_default_to_loaded(self, tmp_path: Path) -> None: + """Test that config show (no subcommand) defaults to loaded.""" + # Create a temporary config file + config_file = tmp_path / "test_config.toml" + config_file.write_text('[engine]\ndefault = "ollama"\n') + + result = CliRunner().invoke(cli, ["config", "show", "--path", str(config_file)]) + + assert result.exit_code == 0 + # Default behavior should show loaded config + assert "ollama" in result.output + + def test_config_show_no_config_file(self, tmp_path: Path) -> None: + """Test that config show handles missing config file gracefully.""" + # Create a path for a non-existent config file + config_file = tmp_path / "nonexistent_config.toml" + + result = CliRunner().invoke( + cli, ["config", "show", "toml", "--path", str(config_file)] + ) + + # Should exit 0 and show a message about missing config + assert result.exit_code == 0 + assert "not found" in result.output.lower() or "config" in result.output.lower() + + def test_config_show_path_option(self, tmp_path: Path) -> None: + """Test that the --path option works for all subcommands.""" + # Create a custom config file + custom_config = tmp_path / "custom.toml" + custom_config.write_text('[engine]\ndefault = "custom-engine"\n') + + # Test various subcommands with custom path + result = CliRunner().invoke( + cli, ["config", "show", "toml", "--path", str(custom_config)] + ) + assert result.exit_code == 0 + assert "custom-engine" in result.output + + result = CliRunner().invoke( + cli, ["config", "show", "json", "--path", str(custom_config)] + ) + assert result.exit_code == 0 + assert "custom-engine" in result.output + + def test_config_show_invalid_subcommand(self) -> None: + """Test that an invalid subcommand shows an error.""" + result = CliRunner().invoke(cli, ["config", "show", "invalid_subcommand_xyz"]) + # Should show error about unknown command + assert result.exit_code != 0 + assert "no such" in result.output.lower() or "unknown" in result.output.lower() + + def test_config_subcommands_not_available_standalone(self) -> None: + """Test that config subcommands are not available directly under config.""" + # These should not be available as direct children of config + result = CliRunner().invoke(cli, ["config", "loaded"]) + assert result.exit_code != 0 + assert "no such" in result.output.lower() or "unknown" in result.output.lower() diff --git a/tests/cli/test_registry_cmd.py b/tests/cli/test_registry_cmd.py new file mode 100644 index 00000000..17e2a848 --- /dev/null +++ b/tests/cli/test_registry_cmd.py @@ -0,0 +1,154 @@ +"""Tests for the ``jarvis registry`` CLI commands.""" + +from __future__ import annotations + +from click.testing import CliRunner + +from openjarvis.cli import cli +from openjarvis.core.registry import ( + ToolRegistry, +) + + +class TestRegistryCmd: + """Test cases for the jarvis registry CLI group.""" + + def test_registry_group_help(self) -> None: + """Test that the registry group help displays correctly.""" + result = CliRunner().invoke(cli, ["registry", "--help"]) + assert result.exit_code == 0 + assert "registry" in result.output.lower() + + def test_registry_list_help(self) -> None: + """Test that the registry list help displays correctly.""" + result = CliRunner().invoke(cli, ["registry", "list", "--help"]) + assert result.exit_code == 0 + + def test_registry_show_help(self) -> None: + """Test that the registry show help displays correctly.""" + result = CliRunner().invoke(cli, ["registry", "show", "--help"]) + assert result.exit_code == 0 + + def test_registry_list_shows_all_registries(self) -> None: + """Test that registry list displays all available registries.""" + result = CliRunner().invoke(cli, ["registry", "list"]) + assert result.exit_code == 0 + # Should show registry-related content + output = result.output.lower() + assert "registry" in output + + def test_registry_show_unknown_registry(self) -> None: + """Test that showing an unknown registry shows an error.""" + result = CliRunner().invoke(cli, ["registry", "show", "unknown_registry_xyz"]) + assert result.exit_code == 0 # CLI still exits 0, just shows error message + assert ( + "unknown" in result.output.lower() or "not found" in result.output.lower() + ) + + def test_registry_show_tool_registry(self) -> None: + """Test that showing the tool registry displays entries.""" + # Trigger tool registration + import openjarvis.tools # noqa: F401 + + result = CliRunner().invoke(cli, ["registry", "show", "tool"]) + assert result.exit_code == 0 + # Should show tool-related content + assert "tool" in result.output.lower() or "key" in result.output.lower() + + def test_registry_show_tool_registry_verbose(self) -> None: + """Test that showing the tool registry with verbose flag shows details.""" + # Trigger tool registration + import openjarvis.tools # noqa: F401 + + result = CliRunner().invoke(cli, ["registry", "show", "tool", "-v"]) + assert result.exit_code == 0 + + def test_registry_show_empty_registry(self) -> None: + """Test registry show behavior with an empty registry.""" + result = CliRunner().invoke(cli, ["registry", "show", "agent"]) + assert result.exit_code == 0 + # Should handle empty registries gracefully + assert "agent" in result.output.lower() or "empty" in result.output.lower() + + def test_registry_show_accepts_aliases(self) -> None: + """Test that registry show accepts various aliases.""" + # Trigger tool registration + import openjarvis.tools # noqa: F401 + + # Test with 'tools' alias + result = CliRunner().invoke(cli, ["registry", "show", "tools"]) + assert result.exit_code == 0 + + def test_registry_keys_match_actual_registries(self) -> None: + """Test that the list command shows all expected registry names.""" + result = CliRunner().invoke(cli, ["registry", "list"]) + assert result.exit_code == 0 + output = result.output + + # Check that key registries are mentioned + assert "ToolRegistry" in output or "tool" in output.lower() + assert "EngineRegistry" in output or "engine" in output.lower() + assert "MemoryRegistry" in output or "memory" in output.lower() + assert "ChannelRegistry" in output or "channel" in output.lower() + + def test_registry_show_nonexistent_key(self) -> None: + """Test that showing a nonexistent key in a registry is handled.""" + result = CliRunner().invoke(cli, ["registry", "show", "nonexistent"]) + assert result.exit_code == 0 + assert ( + "unknown" in result.output.lower() or "not found" in result.output.lower() + ) + + def test_registry_list_handles_import_error(self) -> None: + """Test that registry list handles import errors gracefully.""" + # This tests the exception handling path in list_registries + result = CliRunner().invoke(cli, ["registry", "list"]) + assert result.exit_code == 0 + # Should still complete even if some registries have issues + + def test_registry_show_handles_exception(self) -> None: + """Test that registry show handles exceptions gracefully.""" + # Patch the registry to raise an exception during keys() call + from unittest.mock import patch + + with patch( + "openjarvis.core.registry.ToolRegistry.keys", + side_effect=Exception("Test error"), + ): + result = CliRunner().invoke(cli, ["registry", "show", "tool"]) + assert result.exit_code == 0 + assert "error" in result.output.lower() + + def test_registry_list_handles_registry_error(self) -> None: + """Test that registry list handles errors for specific registries.""" + from unittest.mock import patch + + # Make one of the registry classes raise an error during keys() + with patch.object(ToolRegistry, "keys", side_effect=Exception("Import error")): + result = CliRunner().invoke(cli, ["registry", "list"]) + assert result.exit_code == 0 + # Should still complete and show other registries + + def test_registry_show_handles_error_during_import(self) -> None: + """Test that registry show handles exceptions during import.""" + from unittest.mock import patch + + # Patch the entire import to raise an exception + with patch.dict("sys.modules", {"openjarvis.core.registry": None}): + # This tests the outer exception handler + result = CliRunner().invoke(cli, ["registry", "show", "tool"]) + assert result.exit_code == 0 + + def test_registry_show_handles_error_during_iteration(self) -> None: + """Test that registry show handles exceptions during entry iteration.""" + from unittest.mock import patch + + # Patch keys to return a fake entry so iteration happens, + # then patch get to raise + with patch.object(ToolRegistry, "keys", return_value=["fake_tool"]): + with patch.object( + ToolRegistry, "get", side_effect=Exception("Iteration error") + ): + result = CliRunner().invoke(cli, ["registry", "show", "tool"]) + assert result.exit_code == 0 + assert "error" in result.output.lower() diff --git a/tests/cli/test_tool_cmd.py b/tests/cli/test_tool_cmd.py new file mode 100644 index 00000000..fbb8351c --- /dev/null +++ b/tests/cli/test_tool_cmd.py @@ -0,0 +1,238 @@ +"""Tests for the ``jarvis tool`` CLI commands.""" + +from __future__ import annotations + +from click.testing import CliRunner + +from openjarvis.cli import cli +from openjarvis.core.registry import ToolRegistry + + +class TestToolCmd: + """Test cases for the jarvis tool CLI group.""" + + def test_tool_group_help(self) -> None: + """Test that the tool group help displays correctly.""" + result = CliRunner().invoke(cli, ["tool", "--help"]) + assert result.exit_code == 0 + assert "tool" in result.output.lower() or "list" in result.output + + def test_tool_list_help(self) -> None: + """Test that the tool list help displays correctly.""" + result = CliRunner().invoke(cli, ["tool", "list", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output.lower() or "registered" in result.output.lower() + + def test_tool_inspect_help(self) -> None: + """Test that the tool inspect help displays correctly.""" + result = CliRunner().invoke(cli, ["tool", "inspect", "--help"]) + assert result.exit_code == 0 + assert "tool" in result.output.lower() or "inspect" in result.output.lower() + + def test_tool_list_shows_registered_tools(self) -> None: + """Test that tool list displays registered tools.""" + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should show at least some tools if they're registered + output = result.output.lower() + # The output should contain a table header or tool-related content + assert "tool" in output or "name" in output or "description" in output + + def test_tool_inspect_unknown_tool(self) -> None: + """Test that inspecting an unknown tool shows an error.""" + result = CliRunner().invoke(cli, ["tool", "inspect", "nonexistent_tool_xyz"]) + assert result.exit_code == 0 # CLI still exits 0, just shows error message + assert "not found" in result.output.lower() or "error" in result.output.lower() + + def test_tool_inspect_known_tool(self) -> None: + """Test that inspecting a known tool shows details.""" + # First, trigger tool registration + import openjarvis.tools # noqa: F401 + + # Get a known tool name + registered_tools = ToolRegistry.keys() + if registered_tools: + tool_name = registered_tools[0] + result = CliRunner().invoke(cli, ["tool", "inspect", tool_name]) + assert result.exit_code == 0 + assert tool_name in result.output + + def test_tool_list_empty_registry(self) -> None: + """Test tool list behavior with empty registry.""" + # This test verifies the command runs even if no tools are registered + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + + def test_tool_inspect_requires_tool_name(self) -> None: + """Test that inspect command requires a tool name argument.""" + # Running inspect without a tool name should show help or error + result = CliRunner().invoke(cli, ["tool", "inspect"]) + # Either shows help (exit 0) or error (exit non-zero) + assert result.exit_code in (0, 2) # 0 for help, 2 for missing argument + + def test_tool_list_with_registered_tools(self) -> None: + """Test that tool list shows details for registered tools.""" + # Trigger tool registration + import openjarvis.tools # noqa: F401 + + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should either show tools or indicate no tools are registered + output = result.output + assert ( + "Registered Tools" in output + or "No tools registered" in output + or "Total:" in output + ) + + def test_tool_list_handles_instantiation_error(self) -> None: + """Test that tool list handles tool instantiation errors gracefully.""" + from unittest.mock import patch + + # Mock a tool class that raises an exception during instantiation + with patch.object(ToolRegistry, "keys", return_value=["mock_tool"]): + with patch.object(ToolRegistry, "get", return_value=object): + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should still complete even if tools have instantiation issues + assert "mock_tool" in result.output or "Total:" in result.output + + def test_tool_inspect_with_spec_details(self) -> None: + """Test that inspect shows full spec details for tools with specs.""" + import openjarvis.tools # noqa: F401 + + registered_tools = ToolRegistry.keys() + if registered_tools: + tool_name = registered_tools[0] + result = CliRunner().invoke(cli, ["tool", "inspect", tool_name]) + assert result.exit_code == 0 + # Should show tool details including name, description, category + output = result.output + assert tool_name in output + + def test_tool_inspect_handles_instantiation_error(self) -> None: + """Test that inspect handles tool instantiation errors gracefully.""" + from unittest.mock import patch + + # Mock a tool that exists but fails to instantiate + with patch.object(ToolRegistry, "contains", return_value=True): + with patch.object(ToolRegistry, "get", return_value=object): + result = CliRunner().invoke(cli, ["tool", "inspect", "mock_tool"]) + assert result.exit_code == 0 + # Should show error note but still complete + assert "mock_tool" in result.output + + def test_tool_list_catches_registry_exception(self) -> None: + """Test that tool list handles exceptions during registry access.""" + from unittest.mock import patch + + # Mock ToolRegistry.keys to raise an exception + with patch.object( + ToolRegistry, "keys", side_effect=Exception("Registry error") + ): + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should catch exception and display error message + assert "error" in result.output.lower() + + def test_tool_inspect_catches_registry_exception(self) -> None: + """Test that tool inspect handles exceptions during registry access.""" + from unittest.mock import patch + + # Mock ToolRegistry.contains to raise an exception + with patch.object( + ToolRegistry, "contains", side_effect=Exception("Registry error") + ): + result = CliRunner().invoke(cli, ["tool", "inspect", "mock_tool"]) + assert result.exit_code == 0 + # Should catch exception and display error message + assert "error" in result.output.lower() + + def test_tool_list_shows_tool_spec(self) -> None: + """Test that tool list displays spec details when available.""" + from unittest.mock import MagicMock, patch + + # Create a mock tool class with a spec + mock_spec = MagicMock() + mock_spec.description = "Test tool description" + mock_spec.category = "test-category" + + mock_tool_cls = MagicMock() + mock_tool_instance = MagicMock() + mock_tool_instance.spec = mock_spec + mock_tool_cls.return_value = mock_tool_instance + + with patch.object(ToolRegistry, "keys", return_value=["mock_tool"]): + with patch.object(ToolRegistry, "get", return_value=mock_tool_cls): + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should show spec details + output = result.output + assert "mock_tool" in output + assert "Test tool description" in output + + def test_tool_inspect_shows_full_spec_details(self) -> None: + """Test that inspect displays full spec details including parameters.""" + from unittest.mock import MagicMock, patch + + # Create a mock tool with full spec including parameters + mock_spec = MagicMock() + mock_spec.name = "MockTool" + mock_spec.description = "A mock tool for testing" + mock_spec.category = "testing" + mock_spec.parameters = { + "properties": { + "input": {"type": "string", "description": "Input parameter"}, + "count": {"type": "integer", "description": "Count value"}, + } + } + + mock_tool_cls = MagicMock() + mock_tool_instance = MagicMock() + mock_tool_instance.spec = mock_spec + mock_tool_cls.return_value = mock_tool_instance + + with patch.object(ToolRegistry, "contains", return_value=True): + with patch.object(ToolRegistry, "get", return_value=mock_tool_cls): + result = CliRunner().invoke(cli, ["tool", "inspect", "mock_tool"]) + assert result.exit_code == 0 + output = result.output + assert "mock_tool" in output + assert "MockTool" in output + assert "A mock tool for testing" in output + assert "testing" in output + assert "Parameters" in output + assert "input" in output + assert "count" in output + + def test_tool_inspect_handles_tool_without_spec(self) -> None: + """Test that inspect handles tools without spec attribute.""" + from unittest.mock import MagicMock, patch + + # Mock a tool class without spec attribute + mock_tool_cls = MagicMock() + del mock_tool_cls.spec # Remove spec attribute + + with patch.object(ToolRegistry, "contains", return_value=True): + with patch.object(ToolRegistry, "get", return_value=mock_tool_cls): + result = CliRunner().invoke(cli, ["tool", "inspect", "mock_tool"]) + assert result.exit_code == 0 + # Should handle gracefully + assert "mock_tool" in result.output + + def test_tool_list_handles_tool_without_spec(self) -> None: + """Test that tool list handles tools without spec attribute.""" + from unittest.mock import patch + + # Create a simple class without spec attribute + class ToolWithoutSpec: + pass + + mock_tool_cls = ToolWithoutSpec + + with patch.object(ToolRegistry, "keys", return_value=["mock_tool"]): + with patch.object(ToolRegistry, "get", return_value=mock_tool_cls): + result = CliRunner().invoke(cli, ["tool", "list"]) + assert result.exit_code == 0 + # Should handle gracefully and show the tool name + assert "mock_tool" in result.output or "Total" in result.output