Skip to content

Commit a368d06

Browse files
phernandezclaude
andcommitted
fix: improve cloud CLI status and error messages
- Simplify `bm cloud status` output: remove verbose health check details (status/version/timestamp), show simple "Cloud connected" / "Cloud not connected" message instead - Improve `bm reindex --project` error for cloud projects: distinguish between "project not found" and "project is cloud-only" with a helpful message explaining reindexing is a local operation - Improve `bm project list` cloud error message: show the actual error and soften the credentials suggestion - Add tests for cloud status command (5 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 3a2b80b commit a368d06

4 files changed

Lines changed: 181 additions & 39 deletions

File tree

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -85,65 +85,47 @@ def logout():
8585

8686
@cloud_app.command("status")
8787
def status() -> None:
88-
"""Check cloud authentication state and cloud instance health."""
88+
"""Check cloud authentication and connection status."""
8989
config_manager = ConfigManager()
9090
config = config_manager.load_config()
9191
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
9292
tokens = auth.load_tokens()
9393

94-
console.print("[bold blue]Cloud Authentication Status[/bold blue]")
94+
console.print("[bold blue]Cloud Status[/bold blue]")
9595
console.print(f" Host: {config.cloud_host}")
9696
console.print(
9797
f" API Key: {'[green]configured[/green]' if config.cloud_api_key else '[yellow]not set[/yellow]'}"
9898
)
9999

100100
oauth_status = "[yellow]not logged in[/yellow]"
101101
if tokens:
102-
oauth_status = (
103-
"[green]token valid[/green]"
104-
if auth.is_token_valid(tokens)
105-
else "[yellow]token expired[/yellow]"
106-
)
102+
if auth.is_token_valid(tokens):
103+
oauth_status = "[green]token valid[/green]"
104+
else:
105+
oauth_status = "[yellow]token expired[/yellow]"
107106
console.print(f" OAuth: {oauth_status}")
108107

109-
# Get cloud configuration
110-
_, _, host_url = get_cloud_config()
111-
host_url = host_url.rstrip("/")
112-
113108
has_credentials = bool(config.cloud_api_key) or tokens is not None
114109
if not has_credentials:
115110
console.print(
116111
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud api-key save <key>[/dim]"
117112
)
118113
return
119114

120-
try:
121-
console.print("\n[blue]Checking cloud instance health...[/blue]")
122-
123-
# Make API request to check health
124-
response = run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health"))
125-
126-
health_data = response.json()
127-
128-
console.print("[green]Cloud instance is healthy[/green]")
129-
130-
# Display status details
131-
if "status" in health_data:
132-
console.print(f" Status: {health_data['status']}")
133-
if "version" in health_data:
134-
console.print(f" Version: {health_data['version']}")
135-
if "timestamp" in health_data:
136-
console.print(f" Timestamp: {health_data['timestamp']}")
137-
138-
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
115+
# Quick connection check — just verify we can reach the cloud
116+
_, _, host_url = get_cloud_config()
117+
host_url = host_url.rstrip("/")
139118

140-
except CloudAPIError as e:
141-
console.print(f"[yellow]Cloud health check failed: {e}[/yellow]")
119+
try:
120+
run_with_cleanup(make_api_request(method="GET", url=f"{host_url}/proxy/health"))
121+
console.print("\n[green]Cloud connected[/green]")
122+
except CloudAPIError:
123+
console.print("\n[yellow]Cloud not connected[/yellow]")
142124
console.print(
143-
"[dim]Try re-authenticating with 'bm cloud login' or setting API key with 'bm cloud api-key save'.[/dim]"
125+
"[dim]Try re-authenticating with 'bm cloud login' or 'bm cloud api-key save'.[/dim]"
144126
)
145-
except Exception as e:
146-
console.print(f"[yellow]Unexpected health check error: {e}[/yellow]")
127+
except Exception:
128+
console.print("\n[yellow]Cloud not connected[/yellow]")
147129

148130

149131
@cloud_app.command("setup")

src/basic_memory/cli/commands/db.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from basic_memory import db
1212
from basic_memory.cli.app import app
1313
from basic_memory.cli.commands.command_utils import run_with_cleanup
14-
from basic_memory.config import ConfigManager
14+
from basic_memory.config import ConfigManager, ProjectMode
1515
from basic_memory.repository import ProjectRepository
1616
from basic_memory.services.initialization import reconcile_projects_with_config
1717
from basic_memory.sync.sync_service import get_sync_service
@@ -169,7 +169,16 @@ async def _reindex(app_config, search: bool, embeddings: bool, project: str | No
169169
if project:
170170
projects = [p for p in projects if p.name == project]
171171
if not projects:
172-
console.print(f"[red]Project '{project}' not found.[/red]")
172+
# Check if it's a cloud-only project — those can't be reindexed locally
173+
project_mode = app_config.get_project_mode(project)
174+
if project_mode == ProjectMode.CLOUD:
175+
console.print(
176+
f"[yellow]Project '{project}' is a cloud project.[/yellow]\n"
177+
"Reindexing is a local operation — cloud projects are "
178+
"indexed on the server."
179+
)
180+
else:
181+
console.print(f"[red]Project '{project}' not found.[/red]")
173182
raise typer.Exit(1)
174183

175184
for proj in projects:

src/basic_memory/cli/commands/project.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,11 @@ async def _list_projects(ws: str | None = None):
228228
console.print(table)
229229
if cloud_error is not None:
230230
console.print(
231-
"[yellow]Cloud project discovery failed. "
232-
"Showing local projects only. Run 'bm cloud login' or 'bm cloud api-key save <key>'.[/yellow]"
231+
f"[yellow]Cloud project discovery failed: {cloud_error}[/yellow]"
232+
)
233+
console.print(
234+
"[dim]Showing local projects only. "
235+
"Run 'bm cloud login' or 'bm cloud api-key save <key>' if this is a credentials issue.[/dim]"
233236
)
234237
except Exception as e:
235238
console.print(f"[red]Error listing projects: {str(e)}[/red]")

tests/cli/test_cloud_status.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tests for cloud status command."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
7+
import httpx
8+
import pytest
9+
from typer.testing import CliRunner
10+
11+
from basic_memory.cli.app import app
12+
from basic_memory.cli.commands.cloud.api_client import CloudAPIError
13+
14+
15+
# --- status command integration tests ---
16+
17+
18+
class _FakeTokens:
19+
"""Provides canned token data for CLIAuth stubs."""
20+
21+
@classmethod
22+
def valid(cls) -> dict:
23+
return {
24+
"access_token": "fake-access-token",
25+
"refresh_token": "rt_test",
26+
"expires_at": int(time.time()) + 3600,
27+
}
28+
29+
@classmethod
30+
def expired(cls) -> dict:
31+
return {
32+
"access_token": "fake-access-token",
33+
"refresh_token": "rt_test",
34+
"expires_at": int(time.time()) - 3600,
35+
}
36+
37+
38+
def _patch_status_deps(monkeypatch, *, tokens=None, api_side_effect=None):
39+
"""Patch ConfigManager and CLIAuth for the status command."""
40+
41+
class FakeConfig:
42+
cloud_client_id = "cid"
43+
cloud_domain = "https://auth.example.com"
44+
cloud_host = "https://cloud.example.com"
45+
cloud_api_key = "bmc_test123"
46+
47+
class FakeConfigManager:
48+
config = FakeConfig()
49+
50+
def load_config(self):
51+
return self.config
52+
53+
class FakeAuth:
54+
def __init__(self, **_kwargs):
55+
pass
56+
57+
def load_tokens(self):
58+
return tokens
59+
60+
def is_token_valid(self, t):
61+
return t.get("expires_at", 0) > time.time()
62+
63+
monkeypatch.setattr(
64+
"basic_memory.cli.commands.cloud.core_commands.ConfigManager", FakeConfigManager
65+
)
66+
monkeypatch.setattr(
67+
"basic_memory.cli.commands.cloud.core_commands.CLIAuth", FakeAuth
68+
)
69+
monkeypatch.setattr(
70+
"basic_memory.cli.commands.cloud.core_commands.get_cloud_config",
71+
lambda: ("cid", "domain", "https://cloud.example.com"),
72+
)
73+
74+
if api_side_effect is None:
75+
# Default: cloud is reachable
76+
async def _ok(*_a, **_kw):
77+
return httpx.Response(200, json={"status": "ok"})
78+
79+
api_side_effect = _ok
80+
81+
monkeypatch.setattr(
82+
"basic_memory.cli.commands.cloud.core_commands.make_api_request", api_side_effect
83+
)
84+
85+
86+
class TestStatusCommand:
87+
def test_status_connected(self, monkeypatch):
88+
_patch_status_deps(monkeypatch, tokens=_FakeTokens.valid())
89+
runner = CliRunner()
90+
result = runner.invoke(app, ["cloud", "status"])
91+
92+
assert result.exit_code == 0
93+
assert "Cloud Status" in result.stdout
94+
assert "cloud.example.com" in result.stdout
95+
assert "token valid" in result.stdout
96+
assert "Cloud connected" in result.stdout
97+
98+
def test_status_expired_token(self, monkeypatch):
99+
_patch_status_deps(monkeypatch, tokens=_FakeTokens.expired())
100+
runner = CliRunner()
101+
result = runner.invoke(app, ["cloud", "status"])
102+
103+
assert result.exit_code == 0
104+
assert "token expired" in result.stdout
105+
106+
def test_status_no_credentials(self, monkeypatch):
107+
_patch_status_deps(monkeypatch, tokens=None)
108+
109+
# Also clear the API key so there are no credentials at all
110+
class FakeConfig:
111+
cloud_client_id = "cid"
112+
cloud_domain = "https://auth.example.com"
113+
cloud_host = "https://cloud.example.com"
114+
cloud_api_key = ""
115+
116+
class FakeConfigManager:
117+
config = FakeConfig()
118+
119+
def load_config(self):
120+
return self.config
121+
122+
monkeypatch.setattr(
123+
"basic_memory.cli.commands.cloud.core_commands.ConfigManager", FakeConfigManager
124+
)
125+
126+
runner = CliRunner()
127+
result = runner.invoke(app, ["cloud", "status"])
128+
129+
assert result.exit_code == 0
130+
assert "No cloud credentials found" in result.stdout
131+
132+
@pytest.mark.parametrize(
133+
"exc",
134+
[
135+
CloudAPIError("connection refused"),
136+
Exception("network timeout"),
137+
],
138+
)
139+
def test_status_cloud_not_connected(self, monkeypatch, exc):
140+
async def _fail(*_a, **_kw):
141+
raise exc
142+
143+
_patch_status_deps(monkeypatch, tokens=_FakeTokens.valid(), api_side_effect=_fail)
144+
runner = CliRunner()
145+
result = runner.invoke(app, ["cloud", "status"])
146+
147+
assert result.exit_code == 0
148+
assert "Cloud not connected" in result.stdout

0 commit comments

Comments
 (0)