diff --git a/hyperspell_cli/commands/auth.py b/hyperspell_cli/commands/auth.py index c67b572..1d8c1dc 100644 --- a/hyperspell_cli/commands/auth.py +++ b/hyperspell_cli/commands/auth.py @@ -8,9 +8,11 @@ from hyperspell_cli.config import ( clear_config, + get_sdk_client, load_config, resolve_base_url, save_config, + serialize, ) from hyperspell_cli.lib.output import output_error, output_result, should_output_json from hyperspell_cli.lib.prompts import confirm_action, require_password, require_text @@ -34,12 +36,8 @@ def _get_opts(ctx: typer.Context) -> Dict[str, Any]: @app.command() def login( ctx: typer.Context, - base_url: Optional[str] = typer.Option( - None, "--base-url", help="Hyperspell API base URL." - ), - user_id: Optional[str] = typer.Option( - None, "--user-id", help="User ID for X-As-User header." - ), + base_url: Optional[str] = typer.Option(None, "--base-url", help="Hyperspell API base URL."), + user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for X-As-User header."), ) -> None: """Log in to Hyperspell with an API key. @@ -77,7 +75,7 @@ def login( if uid and uid.strip(): kwargs["default_headers"] = {"X-As-User": uid.strip()} client = Hyperspell(**kwargs) - me = client.auth.me() + client.auth.me() except typer.Exit: raise except Exception as exc: @@ -138,12 +136,43 @@ def status(ctx: typer.Context) -> None: @app.command() -def logout(ctx: typer.Context) -> None: +def whoami(ctx: typer.Context) -> None: + """Show the currently authenticated user (calls /auth/me).""" + opts = _get_opts(ctx) + json_flag = opts.get("json", False) + quiet = opts.get("quiet", False) + + try: + with with_spinner( + "Fetching user info...", "User info retrieved", "Failed to fetch user info", quiet=quiet + ): + client = get_sdk_client() + data = serialize(client.auth.me()) + except typer.Exit: + raise + except Exception as exc: + output_error(f"Failed to fetch user info: {exc}", code="whoami_failed", json_flag=json_flag) + + if should_output_json(json_flag): + output_result(data, json_flag=json_flag) + return + + stdout.print() + for key, value in data.items(): + stdout.print(f" {key}: {value}") + stdout.print() + + +@app.command() +def logout( + ctx: typer.Context, + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), +) -> None: """Clear saved credentials from ~/.hyperspell/config.json.""" opts = _get_opts(ctx) json_flag = opts.get("json", False) - confirm_action("Are you sure you want to log out?", json_flag=json_flag) + confirm_action("Are you sure you want to log out?", yes_flag=yes, json_flag=json_flag) clear_config() stderr.print(" Logged out. Config cleared.") diff --git a/hyperspell_cli/commands/memories.py b/hyperspell_cli/commands/memories.py index c25f4cb..241ecb4 100644 --- a/hyperspell_cli/commands/memories.py +++ b/hyperspell_cli/commands/memories.py @@ -8,6 +8,7 @@ from hyperspell_cli.config import get_sdk_client, serialize from hyperspell_cli.lib.output import output_error, output_result, should_output_json +from hyperspell_cli.lib.prompts import confirm_action from hyperspell_cli.lib.spinner import with_spinner app = typer.Typer( @@ -151,12 +152,15 @@ def delete( ctx: typer.Context, source: str = typer.Argument(..., help="Source name."), resource_id: str = typer.Argument(..., help="Resource ID."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), ) -> None: """Delete a memory.""" opts = _get_opts(ctx) json_flag = opts.get("json", False) quiet = opts.get("quiet", False) + confirm_action(f"Delete memory {source}/{resource_id}?", yes_flag=yes, json_flag=json_flag) + client = get_sdk_client() try: diff --git a/hyperspell_cli/commands/search.py b/hyperspell_cli/commands/search.py index 98bd8d9..bbacb95 100644 --- a/hyperspell_cli/commands/search.py +++ b/hyperspell_cli/commands/search.py @@ -1,12 +1,10 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict import typer from rich.console import Console -from rich.markdown import Markdown -from rich.panel import Panel -from rich.text import Text +from rich.table import Table from hyperspell_cli.config import get_sdk_client, serialize from hyperspell_cli.lib.output import output_error, output_result, should_output_json @@ -22,29 +20,35 @@ def _get_opts(ctx: typer.Context) -> Dict[str, Any]: def _render_results(results: Any) -> None: highlights = results.highlights if hasattr(results, "highlights") else [] + total = getattr(results, "total", len(highlights)) + query_id = getattr(results, "query_id", None) + if not highlights: stderr.print("[yellow]No results found.[/yellow]") return - for i, h in enumerate(highlights, 1): - source = getattr(h, "source", "unknown") - score = getattr(h, "score", None) - title_text = getattr(h, "title", "") or "" - content = getattr(h, "text", "") or getattr(h, "content", "") or "" - url = getattr(h, "url", None) + meta_parts = [f"[bold]{total}[/bold] result(s)"] + if query_id: + meta_parts.append(f"[dim]query_id={query_id}[/dim]") + stdout.print(" ".join(meta_parts)) - title_parts = [f"[bold]#{i}[/bold] {source}"] - if score is not None: - title_parts.append(f"[dim]score={score:.3f}[/dim]") - title = Text.from_markup(" ".join(title_parts)) + table = Table(show_header=True, header_style="bold cyan", expand=True) + table.add_column("Score", style="green", no_wrap=True, min_width=6) + table.add_column("Title", no_wrap=False, min_width=20) + table.add_column("Source", style="dim", no_wrap=True) + table.add_column("Summary", no_wrap=False) - snippet = content[:500] + ("..." if len(content) > 500 else "") if content else "" - body = Markdown(snippet) if snippet else Text.from_markup("[dim]no content[/dim]") + for h in highlights: + score = getattr(h, "score", None) + title = getattr(h, "title", "") or "" + source = getattr(h, "source", "") or "" + content = getattr(h, "text", "") or getattr(h, "content", "") or "" + summary = content[:100] + ("…" if len(content) > 100 else "") - if title_text and not snippet: - body = Text(title_text) + score_str = f"{score:.3f}" if score is not None else "-" + table.add_row(score_str, title, source, summary) - stdout.print(Panel(body, title=title, subtitle=url, border_style="blue")) + stdout.print(table) def search( @@ -68,7 +72,7 @@ def search( try: with with_spinner("Searching...", "Search complete", "Search failed", quiet=quiet): - results = client.memories.search(query=query, limit=limit) + results = client.memories.search(query=query, max_results=limit) except typer.Exit: raise except Exception as exc: diff --git a/hyperspell_cli/config.py b/hyperspell_cli/config.py index 65eff53..e9cbe04 100644 --- a/hyperspell_cli/config.py +++ b/hyperspell_cli/config.py @@ -87,18 +87,6 @@ def get_sdk_client(): return Hyperspell(**kwargs) -def get_http_client(): - """Return an httpx client with API key auth.""" - import httpx - - headers: Dict[str, str] = {"X-API-Key": resolve_api_key()} - user_id = resolve_user_id() - if user_id: - headers["X-As-User"] = user_id - - return httpx.Client(base_url=resolve_base_url(), headers=headers, timeout=30) - - def serialize(obj: Any) -> Any: """Convert an SDK model (or list of models) to a JSON-serializable dict.""" if isinstance(obj, list): diff --git a/hyperspell_cli/lib/output.py b/hyperspell_cli/lib/output.py index 7952eb5..983c201 100644 --- a/hyperspell_cli/lib/output.py +++ b/hyperspell_cli/lib/output.py @@ -27,9 +27,7 @@ def output_result(data: Any, *, json_flag: bool = False) -> None: def output_error(message: str, *, code: str = "unknown", json_flag: bool = False) -> NoReturn: """Print an error and exit 1.""" if should_output_json(json_flag): - sys.stderr.write( - json.dumps({"error": {"message": message, "code": code}}, indent=2) + "\n" - ) + sys.stderr.write(json.dumps({"error": {"message": message, "code": code}}, indent=2) + "\n") else: stderr.print(f"[red]Error:[/red] {message}") raise typer.Exit(code=1) diff --git a/pyproject.toml b/pyproject.toml index 8959817..f25b363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ typer = ">=0.15" rich = "^13" questionary = "^2.1" hyperspell = ">=0.34" -httpx = ">=0.23" [tool.poetry.group.dev.dependencies] pytest = "^8.0" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f47f809 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,149 @@ +"""Basic CLI tests - help output, command registration, config loading.""" + +from __future__ import annotations + +import json +import sys +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +# Mock the hyperspell SDK before importing our code +sys.modules.setdefault("hyperspell", type(sys)("hyperspell")) + +from hyperspell_cli.config import ( # noqa: E402 + DEFAULT_BASE_URL, + clear_config, + load_config, + save_config, +) +from hyperspell_cli.main import app # noqa: E402 + +runner = CliRunner() + + +class TestHelpOutput: + def test_main_help(self): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Hyperspell CLI" in result.output + + def test_auth_help(self): + result = runner.invoke(app, ["auth", "--help"]) + assert result.exit_code == 0 + assert "login" in result.output + assert "logout" in result.output + assert "status" in result.output + assert "whoami" in result.output + + def test_memories_help(self): + result = runner.invoke(app, ["memories", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "add" in result.output + assert "delete" in result.output + + def test_search_help(self): + result = runner.invoke(app, ["search", "--help"]) + assert result.exit_code == 0 + assert "query" in result.output.lower() or "QUERY" in result.output + + def test_connections_help(self): + result = runner.invoke(app, ["connections", "--help"]) + assert result.exit_code == 0 + + def test_version_flag(self): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "hyperspell-cli" in result.output + + +class TestCommandRegistration: + def test_top_level_commands(self): + result = runner.invoke(app, ["--help"]) + for cmd in ["auth", "memories", "connections", "search"]: + assert cmd in result.output, f"Missing top-level command: {cmd}" + + def test_auth_subcommands(self): + result = runner.invoke(app, ["auth", "--help"]) + for cmd in ["login", "logout", "status", "whoami"]: + assert cmd in result.output, f"Missing auth subcommand: {cmd}" + + def test_memories_subcommands(self): + result = runner.invoke(app, ["memories", "--help"]) + for cmd in ["list", "add", "get", "delete", "status"]: + assert cmd in result.output, f"Missing memories subcommand: {cmd}" + + +class TestConfig: + def test_load_config_missing_file(self, tmp_path): + with patch("hyperspell_cli.config.CONFIG_PATH", tmp_path / "nonexistent.json"): + assert load_config() == {} + + def test_save_and_load_config(self, tmp_path): + config_path = tmp_path / "config.json" + with ( + patch("hyperspell_cli.config.CONFIG_PATH", config_path), + patch("hyperspell_cli.config.CONFIG_DIR", tmp_path), + ): + save_config({"api_key": "test-key", "base_url": "https://example.com"}) + cfg = load_config() + assert cfg["api_key"] == "test-key" + assert cfg["base_url"] == "https://example.com" + + def test_clear_config(self, tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text("{}") + with patch("hyperspell_cli.config.CONFIG_PATH", config_path): + clear_config() + assert not config_path.exists() + + def test_load_config_invalid_json(self, tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text("not json") + with patch("hyperspell_cli.config.CONFIG_PATH", config_path): + assert load_config() == {} + + def test_default_base_url(self): + assert DEFAULT_BASE_URL == "https://api.hyperspell.com" + + +class TestLogoutYesFlag: + def test_logout_requires_confirmation_noninteractive(self): + result = runner.invoke(app, ["auth", "logout"]) + assert result.exit_code != 0 + + def test_logout_yes_flag_skips_confirmation(self, tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({"api_key": "test"})) + with ( + patch("hyperspell_cli.config.CONFIG_PATH", config_path), + patch("hyperspell_cli.commands.auth.clear_config") as mock_clear, + ): + runner.invoke(app, ["auth", "logout", "--yes"]) + mock_clear.assert_called_once() + + +class TestDeleteYesFlag: + def test_delete_requires_confirmation_noninteractive(self): + result = runner.invoke(app, ["memories", "delete", "test-source", "test-id"]) + assert result.exit_code != 0 + + def test_delete_yes_flag(self): + with patch("hyperspell_cli.commands.memories.get_sdk_client") as mock_client: + mock_client.return_value.memories.delete.return_value = None + runner.invoke(app, ["memories", "delete", "src", "rid", "--yes"]) + mock_client.assert_called() + + +class TestWhoami: + def test_whoami_json(self): + mock_user = MagicMock() + mock_user.model_dump.return_value = {"email": "test@example.com", "name": "Test User"} + + with patch("hyperspell_cli.commands.auth.get_sdk_client") as mock_client: + mock_client.return_value.auth.me.return_value = mock_user + result = runner.invoke(app, ["--json", "auth", "whoami"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["email"] == "test@example.com"