Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions hyperspell_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
4 changes: 4 additions & 0 deletions hyperspell_cli/commands/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 24 additions & 20 deletions hyperspell_cli/commands/search.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: results is uninitialized if the client.memories.search(...) call raises an exception that is caught by the except Exception block — output_error does not raise or exit, so execution continues to _render_results(results) with an unbound results, causing a NameError.

🤖 AI Agent Prompt for Cursor/Windsurf

📋 Copy this prompt to your AI coding assistant (Cursor, Windsurf, etc.) to get help fixing this issue

In hyperspell_cli/commands/search.py around line 75-85, the `except Exception as exc` block calls `output_error(...)` but does not raise or return, so execution falls through to `_render_results(results)` where `results` is unbound. Fix by adding a `return` after `output_error(...)` in the except block, or re-raise the exception.

Expand All @@ -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:
Expand Down
12 changes: 0 additions & 12 deletions hyperspell_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 1 addition & 3 deletions hyperspell_cli/lib/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
149 changes: 149 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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"