From 9e1908246104e1f951dca66f93a7d0089843812a Mon Sep 17 00:00:00 2001 From: Cubert Date: Tue, 17 Mar 2026 08:11:31 -0700 Subject: [PATCH 1/3] feat: wire --yes flag into logout/delete, add whoami command, add tests - Wire --yes/-y flag into 'auth logout' and 'memories delete' commands so confirm_action actually receives the yes_flag parameter - Add 'auth whoami' command that calls /auth/me and prints the result - Add 19 pytest tests covering help output, command registration, config loading, --yes flag behavior, and whoami - Fix unused variable (me=) in login command - Run ruff check + ruff format --- hyperspell_cli/commands/auth.py | 48 +++++++-- hyperspell_cli/commands/memories.py | 4 + hyperspell_cli/commands/search.py | 2 +- hyperspell_cli/lib/output.py | 4 +- tests/test_cli.py | 154 ++++++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 tests/test_cli.py diff --git a/hyperspell_cli/commands/auth.py b/hyperspell_cli/commands/auth.py index c67b572..dceaa89 100644 --- a/hyperspell_cli/commands/auth.py +++ b/hyperspell_cli/commands/auth.py @@ -8,6 +8,7 @@ from hyperspell_cli.config import ( clear_config, + get_http_client, load_config, resolve_base_url, save_config, @@ -34,12 +35,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 +74,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 +135,45 @@ 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 + ): + http = get_http_client() + resp = http.get("/auth/me") + resp.raise_for_status() + data = resp.json() + 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..975d342 100644 --- a/hyperspell_cli/commands/search.py +++ b/hyperspell_cli/commands/search.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict import typer from rich.console import Console 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/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d3c1d17 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,154 @@ +"""Basic CLI tests - help output, command registration, config loading.""" + +from __future__ import annotations + +import json +import sys +from unittest.mock import 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): + import httpx + + mock_response = httpx.Response( + 200, + json={"email": "test@example.com", "name": "Test User"}, + request=httpx.Request("GET", "https://api.hyperspell.com/auth/me"), + ) + + with patch("hyperspell_cli.commands.auth.get_http_client") as mock_http: + mock_http.return_value.get.return_value = mock_response + result = runner.invoke(app, ["--json", "auth", "whoami"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["email"] == "test@example.com" From daa158767210df810b8cc47d2fd354876e17b6c1 Mon Sep 17 00:00:00 2001 From: Cubert Date: Tue, 17 Mar 2026 09:50:21 -0700 Subject: [PATCH 2/3] refactor: replace raw httpx in whoami with SDK client.auth.me() - Refactored whoami command to use get_sdk_client() + client.auth.me() instead of get_http_client() with raw httpx calls - Removed get_http_client() from config.py (no longer used) - Removed httpx from pyproject.toml dependencies - Updated tests to mock SDK client instead of httpx --- hyperspell_cli/commands/auth.py | 9 ++++----- hyperspell_cli/config.py | 12 ------------ pyproject.toml | 1 - tests/test_cli.py | 15 +++++---------- 4 files changed, 9 insertions(+), 28 deletions(-) diff --git a/hyperspell_cli/commands/auth.py b/hyperspell_cli/commands/auth.py index dceaa89..1d8c1dc 100644 --- a/hyperspell_cli/commands/auth.py +++ b/hyperspell_cli/commands/auth.py @@ -8,10 +8,11 @@ from hyperspell_cli.config import ( clear_config, - get_http_client, + 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 @@ -145,10 +146,8 @@ def whoami(ctx: typer.Context) -> None: with with_spinner( "Fetching user info...", "User info retrieved", "Failed to fetch user info", quiet=quiet ): - http = get_http_client() - resp = http.get("/auth/me") - resp.raise_for_status() - data = resp.json() + client = get_sdk_client() + data = serialize(client.auth.me()) 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/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 index d3c1d17..f47f809 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ import json import sys -from unittest.mock import patch +from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -138,16 +138,11 @@ def test_delete_yes_flag(self): class TestWhoami: def test_whoami_json(self): - import httpx + mock_user = MagicMock() + mock_user.model_dump.return_value = {"email": "test@example.com", "name": "Test User"} - mock_response = httpx.Response( - 200, - json={"email": "test@example.com", "name": "Test User"}, - request=httpx.Request("GET", "https://api.hyperspell.com/auth/me"), - ) - - with patch("hyperspell_cli.commands.auth.get_http_client") as mock_http: - mock_http.return_value.get.return_value = mock_response + 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) From 097512b165b627c280372d05878a462a78b9173a Mon Sep 17 00:00:00 2001 From: Cubert Date: Tue, 17 Mar 2026 11:43:14 -0700 Subject: [PATCH 3/3] fix: search output rendering, max_results parameter --- hyperspell_cli/commands/search.py | 42 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/hyperspell_cli/commands/search.py b/hyperspell_cli/commands/search.py index 975d342..bbacb95 100644 --- a/hyperspell_cli/commands/search.py +++ b/hyperspell_cli/commands/search.py @@ -4,9 +4,7 @@ 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: